v4.0.0-beta.443 (#7144)
This commit is contained in:
commit
6b25bc7e78
90 changed files with 3515 additions and 508 deletions
|
|
@ -1,2 +0,0 @@
|
|||
reviews:
|
||||
review_status: false
|
||||
69
.github/workflows/coolify-helper-next.yml
vendored
69
.github/workflows/coolify-helper-next.yml
vendored
|
|
@ -17,8 +17,17 @@ env:
|
|||
IMAGE_NAME: "coollabsio/coolify-helper"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -43,60 +52,22 @@ jobs:
|
|||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-helper/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
aarch64:
|
||||
runs-on: [ self-hosted, arm64 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-helper/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -126,14 +97,16 @@ jobs:
|
|||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
||||
|
||||
|
|
|
|||
69
.github/workflows/coolify-helper.yml
vendored
69
.github/workflows/coolify-helper.yml
vendored
|
|
@ -17,8 +17,17 @@ env:
|
|||
IMAGE_NAME: "coollabsio/coolify-helper"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -43,59 +52,21 @@ jobs:
|
|||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-helper/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
aarch64:
|
||||
runs-on: [ self-hosted, arm64 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-helper/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -125,14 +96,16 @@ jobs:
|
|||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
|
|
|
|||
68
.github/workflows/coolify-production-build.yml
vendored
68
.github/workflows/coolify-production-build.yml
vendored
|
|
@ -24,8 +24,17 @@ env:
|
|||
IMAGE_NAME: "coollabsio/coolify"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -50,57 +59,20 @@ jobs:
|
|||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
|
||||
aarch64:
|
||||
runs-on: [self-hosted, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [amd64, aarch64]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -130,14 +102,16 @@ jobs:
|
|||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
|
|
|
|||
71
.github/workflows/coolify-realtime-next.yml
vendored
71
.github/workflows/coolify-realtime-next.yml
vendored
|
|
@ -21,8 +21,17 @@ env:
|
|||
IMAGE_NAME: "coollabsio/coolify-realtime"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -47,62 +56,22 @@ jobs:
|
|||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-realtime/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
aarch64:
|
||||
runs-on: [ self-hosted, arm64 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-realtime/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-${{ matrix.arch }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -132,14 +101,16 @@ jobs:
|
|||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-next \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next
|
||||
|
||||
|
|
|
|||
70
.github/workflows/coolify-realtime.yml
vendored
70
.github/workflows/coolify-realtime.yml
vendored
|
|
@ -21,8 +21,17 @@ env:
|
|||
IMAGE_NAME: "coollabsio/coolify-realtime"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -47,61 +56,22 @@ jobs:
|
|||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-realtime/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
aarch64:
|
||||
runs-on: [ self-hosted, arm64 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
echo "VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)"|xargs >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/coolify-realtime/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-${{ matrix.arch }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -131,14 +101,16 @@ jobs:
|
|||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
|
|
|
|||
65
.github/workflows/coolify-testing-host.yml
vendored
65
.github/workflows/coolify-testing-host.yml
vendored
|
|
@ -17,8 +17,17 @@ env:
|
|||
IMAGE_NAME: "coollabsio/coolify-testing-host"
|
||||
|
||||
jobs:
|
||||
amd64:
|
||||
runs-on: ubuntu-latest
|
||||
build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
platform: linux/aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -38,56 +47,22 @@ jobs:
|
|||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/testing-host/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
aarch64:
|
||||
runs-on: [ self-hosted, arm64 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to ${{ env.GITHUB_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to ${{ env.DOCKER_REGISTRY }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/testing-host/Dockerfile
|
||||
platforms: linux/aarch64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-${{ matrix.arch }}
|
||||
labels: |
|
||||
coolify.managed=true
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ amd64, aarch64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
|
|
@ -112,13 +87,15 @@ jobs:
|
|||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
- uses: sarisia/actions-status-discord@v1
|
||||
|
|
|
|||
1
.github/workflows/generate-changelog.yml
vendored
1
.github/workflows/generate-changelog.yml
vendored
|
|
@ -16,7 +16,6 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate changelog
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class GetContainersStatus
|
||||
|
|
@ -28,6 +29,8 @@ class GetContainersStatus
|
|||
|
||||
protected ?Collection $applicationContainerStatuses;
|
||||
|
||||
protected ?Collection $applicationContainerRestartCounts;
|
||||
|
||||
public function handle(Server $server, ?Collection $containers = null, ?Collection $containerReplicates = null)
|
||||
{
|
||||
$this->containers = $containers;
|
||||
|
|
@ -136,6 +139,18 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
if ($containerName) {
|
||||
$this->applicationContainerStatuses->get($applicationId)->put($containerName, $containerStatus);
|
||||
}
|
||||
|
||||
// Track restart counts for applications
|
||||
$restartCount = data_get($container, 'RestartCount', 0);
|
||||
if (! isset($this->applicationContainerRestartCounts)) {
|
||||
$this->applicationContainerRestartCounts = collect();
|
||||
}
|
||||
if (! $this->applicationContainerRestartCounts->has($applicationId)) {
|
||||
$this->applicationContainerRestartCounts->put($applicationId, collect());
|
||||
}
|
||||
if ($containerName) {
|
||||
$this->applicationContainerRestartCounts->get($applicationId)->put($containerName, $restartCount);
|
||||
}
|
||||
} else {
|
||||
// Notify user that this container should not be there.
|
||||
}
|
||||
|
|
@ -291,7 +306,24 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
continue;
|
||||
}
|
||||
|
||||
$application->update(['status' => 'exited']);
|
||||
// If container was recently restarting (crash loop), keep it as degraded for a grace period
|
||||
// This prevents false "exited" status during the brief moment between container removal and recreation
|
||||
$recentlyRestarted = $application->restart_count > 0 &&
|
||||
$application->last_restart_at &&
|
||||
$application->last_restart_at->greaterThan(now()->subSeconds(30));
|
||||
|
||||
if ($recentlyRestarted) {
|
||||
// Keep it as degraded if it was recently in a crash loop
|
||||
$application->update(['status' => 'degraded (unhealthy)']);
|
||||
} else {
|
||||
// Reset restart count when application exits completely
|
||||
$application->update([
|
||||
'status' => 'exited',
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
$notRunningApplicationPreviews = $previews->pluck('id')->diff($foundApplicationPreviews);
|
||||
foreach ($notRunningApplicationPreviews as $previewId) {
|
||||
|
|
@ -340,22 +372,56 @@ public function handle(Server $server, ?Collection $containers = null, ?Collecti
|
|||
continue;
|
||||
}
|
||||
|
||||
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses);
|
||||
if ($aggregatedStatus) {
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $aggregatedStatus) {
|
||||
$application->update(['status' => $aggregatedStatus]);
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
// Track restart counts first
|
||||
$maxRestartCount = 0;
|
||||
if (isset($this->applicationContainerRestartCounts) && $this->applicationContainerRestartCounts->has($applicationId)) {
|
||||
$containerRestartCounts = $this->applicationContainerRestartCounts->get($applicationId);
|
||||
$maxRestartCount = $containerRestartCounts->max() ?? 0;
|
||||
}
|
||||
|
||||
// Wrap all database updates in a transaction to ensure consistency
|
||||
DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
|
||||
$previousRestartCount = $application->restart_count ?? 0;
|
||||
|
||||
if ($maxRestartCount > $previousRestartCount) {
|
||||
// Restart count increased - this is a crash restart
|
||||
$application->update([
|
||||
'restart_count' => $maxRestartCount,
|
||||
'last_restart_at' => now(),
|
||||
'last_restart_type' => 'crash',
|
||||
]);
|
||||
|
||||
// Send notification
|
||||
$containerName = $application->name;
|
||||
$projectUuid = data_get($application, 'environment.project.uuid');
|
||||
$environmentName = data_get($application, 'environment.name');
|
||||
$applicationUuid = data_get($application, 'uuid');
|
||||
|
||||
if ($projectUuid && $applicationUuid && $environmentName) {
|
||||
$url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
|
||||
} else {
|
||||
$url = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate status after tracking restart counts
|
||||
$aggregatedStatus = $this->aggregateApplicationStatus($application, $containerStatuses, $maxRestartCount);
|
||||
if ($aggregatedStatus) {
|
||||
$statusFromDb = $application->status;
|
||||
if ($statusFromDb !== $aggregatedStatus) {
|
||||
$application->update(['status' => $aggregatedStatus]);
|
||||
} else {
|
||||
$application->update(['last_online_at' => now()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ServiceChecked::dispatch($this->server->team->id);
|
||||
}
|
||||
|
||||
private function aggregateApplicationStatus($application, Collection $containerStatuses): ?string
|
||||
private function aggregateApplicationStatus($application, Collection $containerStatuses, int $maxRestartCount = 0): ?string
|
||||
{
|
||||
// Parse docker compose to check for excluded containers
|
||||
$dockerComposeRaw = data_get($application, 'docker_compose_raw');
|
||||
|
|
@ -413,6 +479,11 @@ private function aggregateApplicationStatus($application, Collection $containerS
|
|||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
// If container is exited but has restart count > 0, it's in a crash loop
|
||||
if ($hasExited && $maxRestartCount > 0) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
||||
if ($hasRunning && $hasExited) {
|
||||
return 'degraded (unhealthy)';
|
||||
}
|
||||
|
|
@ -421,7 +492,7 @@ private function aggregateApplicationStatus($application, Collection $containerS
|
|||
return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)';
|
||||
}
|
||||
|
||||
// All containers are exited
|
||||
// All containers are exited with no restart count - truly stopped
|
||||
return 'exited (unhealthy)';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,18 +20,23 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
|
|||
}
|
||||
$service->saveComposeConfigs();
|
||||
$service->isConfigurationChanged(save: true);
|
||||
$commands[] = 'cd '.$service->workdir();
|
||||
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
|
||||
$workdir = $service->workdir();
|
||||
// $commands[] = "cd {$workdir}";
|
||||
$commands[] = "echo 'Saved configuration files to {$workdir}.'";
|
||||
// Ensure .env exists in the correct directory before docker compose tries to load it
|
||||
// This is defensive programming - saveComposeConfigs() already creates it,
|
||||
// but we guarantee it here in case of any edge cases or manual deployments
|
||||
$commands[] = "touch {$workdir}/.env";
|
||||
if ($pullLatestImages) {
|
||||
$commands[] = "echo 'Pulling images.'";
|
||||
$commands[] = 'docker compose pull';
|
||||
$commands[] = "docker compose --project-directory {$workdir} pull";
|
||||
}
|
||||
if ($service->networks()->count() > 0) {
|
||||
$commands[] = "echo 'Creating Docker network.'";
|
||||
$commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid";
|
||||
}
|
||||
$commands[] = 'echo Starting service.';
|
||||
$commands[] = 'docker compose up -d --remove-orphans --force-recreate --build';
|
||||
$commands[] = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --remove-orphans --force-recreate --build";
|
||||
$commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true";
|
||||
if (data_get($service, 'connect_to_docker_network')) {
|
||||
$compose = data_get($service, 'docker_compose', []);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
class CleanupRedis extends Command
|
||||
{
|
||||
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks}';
|
||||
protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks} {--restart : Aggressive cleanup mode for system restart (marks all processing jobs as failed)}';
|
||||
|
||||
protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)';
|
||||
|
||||
|
|
@ -63,6 +63,14 @@ public function handle()
|
|||
$deletedCount += $locksCleaned;
|
||||
}
|
||||
|
||||
// Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
|
||||
$isRestart = $this->option('restart');
|
||||
if ($isRestart || $this->option('clear-locks')) {
|
||||
$this->info($isRestart ? 'Cleaning up stuck jobs (RESTART MODE - aggressive)...' : 'Checking for stuck jobs (runtime mode - conservative)...');
|
||||
$jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart);
|
||||
$deletedCount += $jobsCleaned;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
|
||||
} else {
|
||||
|
|
@ -332,4 +340,130 @@ private function cleanupCacheLocks(bool $dryRun): int
|
|||
|
||||
return $cleanedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stuck jobs based on mode (restart vs runtime).
|
||||
*
|
||||
* @param mixed $redis Redis connection
|
||||
* @param string $prefix Horizon prefix
|
||||
* @param bool $dryRun Dry run mode
|
||||
* @param bool $isRestart Restart mode (aggressive) vs runtime mode (conservative)
|
||||
* @return int Number of jobs cleaned
|
||||
*/
|
||||
private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $isRestart): int
|
||||
{
|
||||
$cleanedCount = 0;
|
||||
$now = time();
|
||||
|
||||
// Get all keys with the horizon prefix
|
||||
$cursor = 0;
|
||||
$keys = [];
|
||||
do {
|
||||
$result = $redis->scan($cursor, ['match' => '*', 'count' => 100]);
|
||||
|
||||
// Guard against scan() returning false
|
||||
if ($result === false) {
|
||||
$this->error('Redis scan failed, stopping key retrieval');
|
||||
break;
|
||||
}
|
||||
|
||||
$cursor = $result[0];
|
||||
$keys = array_merge($keys, $result[1]);
|
||||
} while ($cursor !== 0);
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
||||
$type = $redis->command('type', [$keyWithoutPrefix]);
|
||||
|
||||
// Only process hash-type keys (individual jobs)
|
||||
if ($type !== 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = $redis->command('hgetall', [$keyWithoutPrefix]);
|
||||
$status = data_get($data, 'status');
|
||||
$payload = data_get($data, 'payload');
|
||||
|
||||
// Only process jobs in "processing" or "reserved" state
|
||||
if (! in_array($status, ['processing', 'reserved'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse job payload to get job class and started time
|
||||
$payloadData = json_decode($payload, true);
|
||||
|
||||
// Check for JSON decode errors
|
||||
if ($payloadData === null || json_last_error() !== JSON_ERROR_NONE) {
|
||||
$errorMsg = json_last_error_msg();
|
||||
$truncatedPayload = is_string($payload) ? substr($payload, 0, 200) : 'non-string payload';
|
||||
$this->error("Failed to decode job payload for {$keyWithoutPrefix}: {$errorMsg}. Payload: {$truncatedPayload}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$jobClass = data_get($payloadData, 'displayName', 'Unknown');
|
||||
|
||||
// Prefer reserved_at (when job started processing), fallback to created_at
|
||||
$reservedAt = (int) data_get($data, 'reserved_at', 0);
|
||||
$createdAt = (int) data_get($data, 'created_at', 0);
|
||||
$startTime = $reservedAt ?: $createdAt;
|
||||
|
||||
// If we can't determine when the job started, skip it
|
||||
if (! $startTime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate how long the job has been processing
|
||||
$processingTime = $now - $startTime;
|
||||
|
||||
$shouldFail = false;
|
||||
$reason = '';
|
||||
|
||||
if ($isRestart) {
|
||||
// RESTART MODE: Mark ALL processing/reserved jobs as failed
|
||||
// Safe because all workers are dead on restart
|
||||
$shouldFail = true;
|
||||
$reason = 'System restart - all workers terminated';
|
||||
} else {
|
||||
// RUNTIME MODE: Only mark truly stuck jobs as failed
|
||||
// Be conservative to avoid killing legitimate long-running jobs
|
||||
|
||||
// Skip ApplicationDeploymentJob entirely (has dynamic_timeout, can run 2+ hours)
|
||||
if (str_contains($jobClass, 'ApplicationDeploymentJob')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip DatabaseBackupJob (large backups can take hours)
|
||||
if (str_contains($jobClass, 'DatabaseBackupJob')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other jobs, only fail if processing > 12 hours
|
||||
if ($processingTime > 43200) { // 12 hours
|
||||
$shouldFail = true;
|
||||
$reason = 'Processing for more than 12 hours';
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldFail) {
|
||||
if ($dryRun) {
|
||||
$this->warn(" Would mark as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1)." min) - {$reason}");
|
||||
} else {
|
||||
// Mark job as failed
|
||||
$redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
|
||||
$redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
|
||||
$redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]);
|
||||
|
||||
$this->info(" ✓ Marked as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1).' min) - '.$reason);
|
||||
}
|
||||
$cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($cleanedCount === 0) {
|
||||
$this->info($isRestart ? ' No jobs to clean up' : ' No stuck jobs found (all jobs running normally)');
|
||||
}
|
||||
|
||||
return $cleanedCount;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,9 +222,14 @@ private function cleanup_stucked_resources()
|
|||
try {
|
||||
$scheduled_backups = ScheduledDatabaseBackup::all();
|
||||
foreach ($scheduled_backups as $scheduled_backup) {
|
||||
if (! $scheduled_backup->server()) {
|
||||
echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
|
||||
$scheduled_backup->delete();
|
||||
try {
|
||||
$server = $scheduled_backup->server();
|
||||
if (! $server) {
|
||||
echo "Deleting stuck scheduledbackup: {$scheduled_backup->name}\n";
|
||||
$scheduled_backup->delete();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error checking server for scheduledbackup {$scheduled_backup->id}: {$e->getMessage()}\n";
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -416,7 +421,7 @@ private function cleanup_stucked_resources()
|
|||
foreach ($serviceApplications as $service) {
|
||||
if (! data_get($service, 'service')) {
|
||||
echo 'ServiceApplication without service: '.$service->name.'\n';
|
||||
DeleteResourceJob::dispatch($service);
|
||||
$service->forceDelete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
@ -429,7 +434,7 @@ private function cleanup_stucked_resources()
|
|||
foreach ($serviceDatabases as $service) {
|
||||
if (! data_get($service, 'service')) {
|
||||
echo 'ServiceDatabase without service: '.$service->name.'\n';
|
||||
DeleteResourceJob::dispatch($service);
|
||||
$service->forceDelete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
|
|
@ -45,6 +48,44 @@ public function init()
|
|||
} else {
|
||||
echo "Instance already initialized.\n";
|
||||
}
|
||||
|
||||
// Clean up stuck jobs and stale locks on development startup
|
||||
try {
|
||||
echo "Cleaning up Redis (stuck jobs and stale locks)...\n";
|
||||
Artisan::call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
|
||||
echo "Redis cleanup completed.\n";
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:redis: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
if ($updatedTaskCount > 0) {
|
||||
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
if ($updatedBackupCount > 0) {
|
||||
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
CheckHelperImageJob::dispatch();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ public function handle()
|
|||
]);
|
||||
}
|
||||
$output = 'Because of an error, the backup of the database '.$db->name.' failed.';
|
||||
$this->mail = (new BackupFailed($backup, $db, $output))->toMail();
|
||||
$this->mail = (new BackupFailed($backup, $db, $output, $backup->database_name ?? 'unknown'))->toMail();
|
||||
$this->sendEmail();
|
||||
break;
|
||||
case 'backup-success':
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@
|
|||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
|
@ -73,7 +76,7 @@ public function handle()
|
|||
$this->cleanupUnusedNetworkFromCoolifyProxy();
|
||||
|
||||
try {
|
||||
$this->call('cleanup:redis', ['--clear-locks' => true]);
|
||||
$this->call('cleanup:redis', ['--restart' => true, '--clear-locks' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:redis command: {$e->getMessage()}\n";
|
||||
}
|
||||
|
|
@ -86,6 +89,7 @@ public function handle()
|
|||
$this->call('cleanup:stucked-resources');
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error in cleanup:stucked-resources command: {$e->getMessage()}\n";
|
||||
echo "Continuing with initialization - cleanup errors will not prevent Coolify from starting\n";
|
||||
}
|
||||
try {
|
||||
$updatedCount = ApplicationDeploymentQueue::whereIn('status', [
|
||||
|
|
@ -102,6 +106,34 @@ public function handle()
|
|||
echo "Could not cleanup inprogress deployments: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$updatedTaskCount = ScheduledTaskExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
if ($updatedTaskCount > 0) {
|
||||
echo "Marked {$updatedTaskCount} stuck scheduled task executions as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck scheduled task executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$updatedBackupCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
if ($updatedBackupCount > 0) {
|
||||
echo "Marked {$updatedBackupCount} stuck database backup executions as failed\n";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
echo "Could not cleanup stuck database backup executions: {$e->getMessage()}\n";
|
||||
}
|
||||
|
||||
try {
|
||||
$localhost = $this->servers->where('id', 0)->first();
|
||||
if ($localhost) {
|
||||
|
|
|
|||
32
app/Exceptions/DeploymentException.php
Normal file
32
app/Exceptions/DeploymentException.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Exception for expected deployment failures caused by user/application errors.
|
||||
* These are not Coolify bugs and should not be logged to laravel.log.
|
||||
* Examples: Nixpacks detection failures, missing Dockerfiles, invalid configs, etc.
|
||||
*/
|
||||
class DeploymentException extends Exception
|
||||
{
|
||||
/**
|
||||
* Create a new deployment exception instance.
|
||||
*
|
||||
* @param string $message
|
||||
* @param int $code
|
||||
*/
|
||||
public function __construct($message = '', $code = 0, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from another exception, preserving its message and stack trace.
|
||||
*/
|
||||
public static function fromException(\Throwable $exception): static
|
||||
{
|
||||
return new static($exception->getMessage(), $exception->getCode(), $exception);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ class Handler extends ExceptionHandler
|
|||
protected $dontReport = [
|
||||
ProcessException::class,
|
||||
NonReportableException::class,
|
||||
DeploymentException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -246,6 +246,40 @@ public function manual(Request $request)
|
|||
if ($action === 'closed') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
// Cancel any active deployments for this PR immediately
|
||||
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->whereIn('status', [
|
||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($activeDeployment) {
|
||||
try {
|
||||
// Mark deployment as cancelled
|
||||
$activeDeployment->update([
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Add cancellation log entry
|
||||
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||
|
||||
// Check if helper container exists and kill it
|
||||
$deployment_uuid = $activeDeployment->deployment_uuid;
|
||||
$server = $application->destination->server;
|
||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
|
||||
$activeDeployment->addLogEntry('Deployment container stopped.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Silently handle errors during deployment cancellation
|
||||
}
|
||||
}
|
||||
|
||||
DeleteResourceJob::dispatch($found);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
|
|
@ -481,6 +515,42 @@ public function normal(Request $request)
|
|||
if ($action === 'closed' || $action === 'close') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
// Cancel any active deployments for this PR immediately
|
||||
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->whereIn('status', [
|
||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->first();
|
||||
|
||||
if ($activeDeployment) {
|
||||
try {
|
||||
// Mark deployment as cancelled
|
||||
$activeDeployment->update([
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Add cancellation log entry
|
||||
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||
|
||||
// Check if helper container exists and kill it
|
||||
$deployment_uuid = $activeDeployment->deployment_uuid;
|
||||
$server = $application->destination->server;
|
||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
|
||||
$activeDeployment->addLogEntry('Deployment container stopped.');
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// Silently handle errors during deployment cancellation
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any deployed containers
|
||||
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
|
||||
if ($containers->isNotEmpty()) {
|
||||
$containers->each(function ($container) use ($application) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Enums\ProcessStatus;
|
||||
use App\Events\ApplicationConfigurationChanged;
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
|
|
@ -31,7 +32,6 @@
|
|||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Sleep;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
use Spatie\Url\Url;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Throwable;
|
||||
|
|
@ -341,20 +341,42 @@ public function handle(): void
|
|||
$this->fail($e);
|
||||
throw $e;
|
||||
} finally {
|
||||
$this->application_deployment_queue->update([
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
]);
|
||||
|
||||
if ($this->use_build_server) {
|
||||
$this->server = $this->build_server;
|
||||
} else {
|
||||
$this->write_deployment_configurations();
|
||||
// Wrap cleanup operations in try-catch to prevent exceptions from interfering
|
||||
// with Laravel's job failure handling and status updates
|
||||
try {
|
||||
$this->application_deployment_queue->update([
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
// Log but don't fail - finished_at is not critical
|
||||
\Log::warning('Failed to update finished_at for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
|
||||
$this->graceful_shutdown_container($this->deployment_uuid);
|
||||
try {
|
||||
if ($this->use_build_server) {
|
||||
$this->server = $this->build_server;
|
||||
} else {
|
||||
$this->write_deployment_configurations();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Log but don't fail - configuration writing errors shouldn't prevent status updates
|
||||
$this->application_deployment_queue->addLogEntry('Warning: Failed to write deployment configurations: '.$e->getMessage(), 'stderr');
|
||||
}
|
||||
|
||||
ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
|
||||
try {
|
||||
$this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}");
|
||||
$this->graceful_shutdown_container($this->deployment_uuid);
|
||||
} catch (Exception $e) {
|
||||
// Log but don't fail - container cleanup errors are expected when container is already gone
|
||||
\Log::warning('Failed to shutdown container '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
ServiceStatusChanged::dispatch(data_get($this->application, 'environment.project.team.id'));
|
||||
} catch (Exception $e) {
|
||||
// Log but don't fail - event dispatch errors shouldn't prevent status updates
|
||||
\Log::warning('Failed to dispatch ServiceStatusChanged for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -954,7 +976,7 @@ private function push_to_docker_registry()
|
|||
} catch (Exception $e) {
|
||||
$this->application_deployment_queue->addLogEntry('Failed to push image to docker registry. Please check debug logs for more information.');
|
||||
if ($forceFail) {
|
||||
throw new RuntimeException($e->getMessage(), 69420);
|
||||
throw new DeploymentException($e->getMessage(), 69420);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1146,6 +1168,18 @@ private function generate_runtime_environment_variables()
|
|||
foreach ($runtime_environment_variables as $env) {
|
||||
$envs->push($env->key.'='.$env->real_value);
|
||||
}
|
||||
|
||||
// Check for PORT environment variable mismatch with ports_exposes
|
||||
if ($this->build_pack !== 'dockercompose') {
|
||||
$detectedPort = $this->application->detectPortFromEnvironment(false);
|
||||
if ($detectedPort && ! empty($ports) && ! in_array($detectedPort, $ports)) {
|
||||
$this->application_deployment_queue->addLogEntry(
|
||||
"Warning: PORT environment variable ({$detectedPort}) does not match configured ports_exposes: ".implode(',', $ports).'. It could case "bad gateway" or "no server" errors. Check the "General" page to fix it.',
|
||||
'stderr'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add PORT if not exists, use the first port as default
|
||||
if ($this->build_pack !== 'dockercompose') {
|
||||
if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
|
||||
|
|
@ -1789,7 +1823,7 @@ private function prepare_builder_image(bool $firstTry = true)
|
|||
$env_flags = $this->generate_docker_env_flags_for_secrets();
|
||||
if ($this->use_build_server) {
|
||||
if ($this->dockerConfigFileExists === 'NOK') {
|
||||
throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
|
||||
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
|
||||
}
|
||||
$runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
|
||||
} else {
|
||||
|
|
@ -2055,7 +2089,7 @@ private function generate_nixpacks_confs()
|
|||
if ($this->saved_outputs->get('nixpacks_type')) {
|
||||
$this->nixpacks_type = $this->saved_outputs->get('nixpacks_type');
|
||||
if (str($this->nixpacks_type)->isEmpty()) {
|
||||
throw new RuntimeException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers');
|
||||
throw new DeploymentException('Nixpacks failed to detect the application type. Please check the documentation of Nixpacks: https://nixpacks.com/docs/providers');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3029,6 +3063,11 @@ private function stop_running_container(bool $force = false)
|
|||
|
||||
private function start_by_compose_file()
|
||||
{
|
||||
// Ensure .env file exists before docker compose tries to load it (defensive programming)
|
||||
$this->execute_remote_command(
|
||||
["touch {$this->configuration_dir}/.env", 'hidden' => true],
|
||||
);
|
||||
|
||||
if ($this->application->build_pack === 'dockerimage') {
|
||||
$this->application_deployment_queue->addLogEntry('Pulling latest images from the registry.');
|
||||
$this->execute_remote_command(
|
||||
|
|
@ -3637,7 +3676,7 @@ private function run_pre_deployment_command()
|
|||
return;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
|
||||
throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
|
||||
}
|
||||
|
||||
private function run_post_deployment_command()
|
||||
|
|
@ -3673,7 +3712,7 @@ private function run_post_deployment_command()
|
|||
return;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('Post-deployment command: Could not find a valid container. Is the container name correct?');
|
||||
throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -3684,7 +3723,7 @@ private function checkForCancellation(): void
|
|||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
|
||||
throw new \RuntimeException('Deployment cancelled by user', 69420);
|
||||
throw new DeploymentException('Deployment cancelled by user', 69420);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3717,7 +3756,7 @@ private function isInTerminalState(): bool
|
|||
|
||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
$this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.');
|
||||
throw new \RuntimeException('Deployment cancelled by user', 69420);
|
||||
throw new DeploymentException('Deployment cancelled by user', 69420);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
@ -3798,10 +3837,8 @@ private function failDeployment(): void
|
|||
public function failed(Throwable $exception): void
|
||||
{
|
||||
$this->failDeployment();
|
||||
$this->application_deployment_queue->addLogEntry('Oops something is not okay, are you okay? 😢', 'stderr');
|
||||
if (str($exception->getMessage())->isNotEmpty()) {
|
||||
$this->application_deployment_queue->addLogEntry($exception->getMessage(), 'stderr');
|
||||
}
|
||||
$errorMessage = $exception->getMessage() ?: 'Unknown error occurred';
|
||||
$this->application_deployment_queue->addLogEntry("Deployment failed: {$errorMessage}", 'stderr');
|
||||
|
||||
if ($this->application->build_pack !== 'dockercompose') {
|
||||
$code = $exception->getCode();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
|
|
@ -20,10 +22,51 @@ public function __construct(public Server $server) {}
|
|||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
// Get all active deployments on this server
|
||||
$activeDeployments = ApplicationDeploymentQueue::where('server_id', $this->server->id)
|
||||
->whereIn('status', [
|
||||
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
ApplicationDeploymentStatus::QUEUED->value,
|
||||
])
|
||||
->pluck('deployment_uuid')
|
||||
->toArray();
|
||||
|
||||
\Log::info('CleanupHelperContainersJob - Active deployments', [
|
||||
'server' => $this->server->name,
|
||||
'active_deployment_uuids' => $activeDeployments,
|
||||
]);
|
||||
|
||||
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
|
||||
$containerIds = collect(json_decode($containers))->pluck('ID');
|
||||
if ($containerIds->count() > 0) {
|
||||
foreach ($containerIds as $containerId) {
|
||||
$helperContainers = collect(json_decode($containers));
|
||||
|
||||
if ($helperContainers->count() > 0) {
|
||||
foreach ($helperContainers as $container) {
|
||||
$containerId = data_get($container, 'ID');
|
||||
$containerName = data_get($container, 'Names');
|
||||
|
||||
// Check if this container belongs to an active deployment
|
||||
$isActiveDeployment = false;
|
||||
foreach ($activeDeployments as $deploymentUuid) {
|
||||
if (str_contains($containerName, $deploymentUuid)) {
|
||||
$isActiveDeployment = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isActiveDeployment) {
|
||||
\Log::info('CleanupHelperContainersJob - Skipping active deployment container', [
|
||||
'container' => $containerName,
|
||||
'id' => $containerId,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
\Log::info('CleanupHelperContainersJob - Removing orphaned helper container', [
|
||||
'container' => $containerName,
|
||||
'id' => $containerId,
|
||||
]);
|
||||
|
||||
instant_remote_process_with_timeout(['docker container rm -f '.$containerId], $this->server, false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,35 @@
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\CoolifyTask\RunRemoteProcess;
|
||||
use App\Enums\ProcessStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public $tries = 3;
|
||||
|
||||
/**
|
||||
* The maximum number of unhandled exceptions to allow before failing.
|
||||
*/
|
||||
public $maxExceptions = 1;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
*/
|
||||
public $timeout = 600;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
|
|
@ -42,4 +59,36 @@ public function handle(): void
|
|||
|
||||
$remote_process();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public function backoff(): array
|
||||
{
|
||||
return [30, 90, 180]; // 30s, 90s, 180s between retries
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
Log::channel('scheduled-errors')->error('CoolifyTask permanently failed', [
|
||||
'job' => 'CoolifyTask',
|
||||
'activity_id' => $this->activity->id,
|
||||
'server_uuid' => $this->activity->getExtraProperty('server_uuid'),
|
||||
'command_preview' => substr($this->activity->getExtraProperty('command') ?? '', 0, 200),
|
||||
'error' => $exception?->getMessage(),
|
||||
'total_attempts' => $this->attempts(),
|
||||
'trace' => $exception?->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// Update activity status to reflect permanent failure
|
||||
$this->activity->properties = $this->activity->properties->merge([
|
||||
'status' => ProcessStatus::ERROR->value,
|
||||
'error' => $exception?->getMessage() ?? 'Job permanently failed',
|
||||
'failed_at' => now()->toIso8601String(),
|
||||
]);
|
||||
$this->activity->save();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
|
@ -31,6 +32,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $maxExceptions = 1;
|
||||
|
||||
public ?Team $team = null;
|
||||
|
||||
public Server $server;
|
||||
|
|
@ -74,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||
public function __construct(public ScheduledDatabaseBackup $backup)
|
||||
{
|
||||
$this->onQueue('high');
|
||||
$this->timeout = $backup->timeout;
|
||||
$this->timeout = $backup->timeout ?? 3600;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
|
|
@ -661,15 +664,34 @@ private function getFullImageName(): string
|
|||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [
|
||||
'job' => 'DatabaseBackupJob',
|
||||
'backup_id' => $this->backup->uuid,
|
||||
'database' => $this->database?->name ?? 'unknown',
|
||||
'database_type' => get_class($this->database ?? new \stdClass),
|
||||
'server' => $this->server?->name ?? 'unknown',
|
||||
'total_attempts' => $this->attempts(),
|
||||
'error' => $exception?->getMessage(),
|
||||
'trace' => $exception?->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
|
||||
|
||||
if ($log) {
|
||||
$log->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Job failed: '.($exception?->getMessage() ?? 'Unknown error'),
|
||||
'message' => 'Job permanently failed after '.$this->attempts().' attempts: '.($exception?->getMessage() ?? 'Unknown error'),
|
||||
'size' => 0,
|
||||
'filename' => null,
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Notify team about permanent failure
|
||||
if ($this->team) {
|
||||
$databaseName = $log?->database_name ?? 'unknown';
|
||||
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
|
||||
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,16 +124,54 @@ private function deleteApplicationPreview()
|
|||
$this->resource->delete();
|
||||
}
|
||||
|
||||
// Cancel any active deployments for this PR (same logic as API cancel_deployment)
|
||||
$activeDeployments = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->whereIn('status', [
|
||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
])
|
||||
->get();
|
||||
|
||||
foreach ($activeDeployments as $activeDeployment) {
|
||||
try {
|
||||
// Mark deployment as cancelled
|
||||
$activeDeployment->update([
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
// Add cancellation log entry
|
||||
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||
|
||||
// Check if helper container exists and kill it
|
||||
$deployment_uuid = $activeDeployment->deployment_uuid;
|
||||
$escapedDeploymentUuid = escapeshellarg($deployment_uuid);
|
||||
$checkCommand = "docker ps -a --filter name={$escapedDeploymentUuid} --format '{{.Names}}'";
|
||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||
|
||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||
instant_remote_process(["docker rm -f {$escapedDeploymentUuid}"], $server);
|
||||
$activeDeployment->addLogEntry('Deployment container stopped.');
|
||||
} else {
|
||||
$activeDeployment->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.');
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// Silently handle errors during deployment cancellation
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if ($server->isSwarm()) {
|
||||
instant_remote_process(["docker stack rm {$application->uuid}-{$pull_request_id}"], $server);
|
||||
$escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
|
||||
instant_remote_process(["docker stack rm {$escapedStackName}"], $server);
|
||||
} else {
|
||||
$containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray();
|
||||
$this->stopPreviewContainers($containers, $server);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Log the error but don't fail the job
|
||||
ray('Error stopping preview containers: '.$e->getMessage());
|
||||
\Log::warning('Error stopping preview containers for application '.$application->uuid.', PR #'.$pull_request_id.': '.$e->getMessage());
|
||||
}
|
||||
|
||||
// Finally, force delete to trigger resource cleanup
|
||||
|
|
@ -156,7 +194,6 @@ private function stopPreviewContainers(array $containers, $server, int $timeout
|
|||
"docker stop --time=$timeout $containerList",
|
||||
"docker rm -f $containerList",
|
||||
];
|
||||
|
||||
instant_remote_process(
|
||||
command: $commands,
|
||||
server: $server,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ public function middleware(): array
|
|||
{
|
||||
return [
|
||||
(new WithoutOverlapping('scheduled-job-manager'))
|
||||
->expireAfter(60) // Lock expires after 1 minute to prevent stale locks
|
||||
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
|
||||
->dontRelease(), // Don't re-queue on lock conflict
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,14 +18,30 @@
|
|||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ScheduledTaskJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
public $tries = 3;
|
||||
|
||||
/**
|
||||
* The maximum number of unhandled exceptions to allow before failing.
|
||||
*/
|
||||
public $maxExceptions = 1;
|
||||
|
||||
/**
|
||||
* The number of seconds the job can run before timing out.
|
||||
*/
|
||||
public $timeout = 300;
|
||||
|
||||
public Team $team;
|
||||
|
||||
public Server $server;
|
||||
public ?Server $server = null;
|
||||
|
||||
public ScheduledTask $task;
|
||||
|
||||
|
|
@ -33,6 +49,11 @@ class ScheduledTaskJob implements ShouldQueue
|
|||
|
||||
public ?ScheduledTaskExecution $task_log = null;
|
||||
|
||||
/**
|
||||
* Store execution ID to survive job serialization for timeout handling.
|
||||
*/
|
||||
protected ?int $executionId = null;
|
||||
|
||||
public string $task_status = 'failed';
|
||||
|
||||
public ?string $task_output = null;
|
||||
|
|
@ -55,6 +76,9 @@ public function __construct($task)
|
|||
}
|
||||
$this->team = Team::findOrFail($task->team_id);
|
||||
$this->server_timezone = $this->getServerTimezone();
|
||||
|
||||
// Set timeout from task configuration
|
||||
$this->timeout = $this->task->timeout ?? 300;
|
||||
}
|
||||
|
||||
private function getServerTimezone(): string
|
||||
|
|
@ -70,11 +94,18 @@ private function getServerTimezone(): string
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
$startTime = Carbon::now();
|
||||
|
||||
try {
|
||||
$this->task_log = ScheduledTaskExecution::create([
|
||||
'scheduled_task_id' => $this->task->id,
|
||||
'started_at' => $startTime,
|
||||
'retry_count' => $this->attempts() - 1,
|
||||
]);
|
||||
|
||||
// Store execution ID for timeout handling
|
||||
$this->executionId = $this->task_log->id;
|
||||
|
||||
$this->server = $this->resource->destination->server;
|
||||
|
||||
if ($this->resource->type() === 'application') {
|
||||
|
|
@ -129,15 +160,101 @@ public function handle(): void
|
|||
'message' => $this->task_output ?? $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
$this->team?->notify(new TaskFailed($this->task, $e->getMessage()));
|
||||
|
||||
// Log the error to the scheduled-errors channel
|
||||
Log::channel('scheduled-errors')->error('ScheduledTask execution failed', [
|
||||
'job' => 'ScheduledTaskJob',
|
||||
'task_id' => $this->task->uuid,
|
||||
'task_name' => $this->task->name,
|
||||
'server' => $this->server?->name ?? 'unknown',
|
||||
'attempt' => $this->attempts(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// Only notify and throw on final failure
|
||||
|
||||
// Re-throw to trigger Laravel's retry mechanism with backoff
|
||||
throw $e;
|
||||
} finally {
|
||||
ScheduledTaskDone::dispatch($this->team->id);
|
||||
if ($this->task_log) {
|
||||
$finishedAt = Carbon::now();
|
||||
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
|
||||
|
||||
$this->task_log->update([
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
'finished_at' => $finishedAt->toImmutable(),
|
||||
'duration' => $duration,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the number of seconds to wait before retrying the job.
|
||||
*/
|
||||
public function backoff(): array
|
||||
{
|
||||
return [30, 60, 120]; // 30s, 60s, 120s between retries
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a job failure.
|
||||
*/
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
|
||||
'job' => 'ScheduledTaskJob',
|
||||
'task_id' => $this->task->uuid,
|
||||
'task_name' => $this->task->name,
|
||||
'server' => $this->server?->name ?? 'unknown',
|
||||
'total_attempts' => $this->attempts(),
|
||||
'error' => $exception?->getMessage(),
|
||||
'trace' => $exception?->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// Reload execution log from database
|
||||
// When a job times out, failed() is called in a fresh process with the original
|
||||
// queue payload, so $executionId will be null. We need to query for the latest execution.
|
||||
$execution = null;
|
||||
|
||||
// Try to find execution using stored ID first (works for non-timeout failures)
|
||||
if ($this->executionId) {
|
||||
$execution = ScheduledTaskExecution::find($this->executionId);
|
||||
}
|
||||
|
||||
// If no stored ID or not found, query for the most recent execution log for this task
|
||||
if (! $execution) {
|
||||
$execution = ScheduledTaskExecution::query()
|
||||
->where('scheduled_task_id', $this->task->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
}
|
||||
|
||||
// Last resort: check task_log property
|
||||
if (! $execution && $this->task_log) {
|
||||
$execution = $this->task_log;
|
||||
}
|
||||
|
||||
if ($execution) {
|
||||
$errorMessage = 'Job permanently failed after '.$this->attempts().' attempts';
|
||||
if ($exception) {
|
||||
$errorMessage .= ': '.$exception->getMessage();
|
||||
}
|
||||
|
||||
$execution->update([
|
||||
'status' => 'failed',
|
||||
'message' => $errorMessage,
|
||||
'error_details' => $exception?->getTraceAsString(),
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
]);
|
||||
} else {
|
||||
Log::channel('scheduled-errors')->warning('Could not find execution log to update', [
|
||||
'execution_id' => $this->executionId,
|
||||
'task_id' => $this->task->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
// Notify team about permanent failure
|
||||
$this->team?->notify(new TaskFailed($this->task, $exception?->getMessage() ?? 'Unknown error'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1000,4 +1000,23 @@ private function updateServiceEnvironmentVariables()
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getDetectedPortInfoProperty(): ?array
|
||||
{
|
||||
$detectedPort = $this->application->detectPortFromEnvironment();
|
||||
|
||||
if (! $detectedPort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$portsExposesArray = $this->application->ports_exposes_array;
|
||||
$isMatch = in_array($detectedPort, $portsExposesArray);
|
||||
$isEmpty = empty($portsExposesArray);
|
||||
|
||||
return [
|
||||
'port' => $detectedPort,
|
||||
'matches' => $isMatch,
|
||||
'isEmpty' => $isEmpty,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,11 +101,18 @@ public function deploy(bool $force_rebuild = false)
|
|||
force_rebuild: $force_rebuild,
|
||||
);
|
||||
if ($result['status'] === 'skipped') {
|
||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||
$this->dispatch('error', 'Deployment skipped', $result['message']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset restart count on successful deployment
|
||||
$this->application->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
|
||||
return $this->redirectRoute('project.application.deployment.show', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'application_uuid' => $this->parameters['application_uuid'],
|
||||
|
|
@ -137,6 +144,7 @@ public function restart()
|
|||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->setDeploymentUuid();
|
||||
$result = queue_application_deployment(
|
||||
application: $this->application,
|
||||
|
|
@ -149,6 +157,13 @@ public function restart()
|
|||
return;
|
||||
}
|
||||
|
||||
// Reset restart count on manual restart
|
||||
$this->application->update([
|
||||
'restart_count' => 0,
|
||||
'last_restart_at' => now(),
|
||||
'last_restart_type' => 'manual',
|
||||
]);
|
||||
|
||||
return $this->redirectRoute('project.application.deployment.show', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'application_uuid' => $this->parameters['application_uuid'],
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ class BackupEdit extends Component
|
|||
#[Validate(['required', 'boolean'])]
|
||||
public bool $dumpAll = false;
|
||||
|
||||
#[Validate(['required', 'int', 'min:1', 'max:36000'])]
|
||||
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
|
||||
public int $timeout = 3600;
|
||||
|
||||
public function mount()
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ public function mount()
|
|||
{
|
||||
$this->application = ServiceApplication::ownedByCurrentTeam()->findOrFail($this->applicationId);
|
||||
$this->authorize('view', $this->application);
|
||||
$this->requiredPort = $this->application->service->getRequiredPort();
|
||||
$this->requiredPort = $this->application->getRequiredPort();
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
|
|
@ -113,8 +113,7 @@ public function submit()
|
|||
|
||||
// Check for required port
|
||||
if (! $this->forceRemovePort) {
|
||||
$service = $this->application->service;
|
||||
$requiredPort = $service->getRequiredPort();
|
||||
$requiredPort = $this->application->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
// Check if all FQDNs have a port
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ public function mount()
|
|||
try {
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->authorize('view', $this->application);
|
||||
$this->requiredPort = $this->application->service->getRequiredPort();
|
||||
$this->requiredPort = $this->application->getRequiredPort();
|
||||
$this->syncData();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -268,8 +268,7 @@ public function submit()
|
|||
|
||||
// Check for required port
|
||||
if (! $this->forceRemovePort) {
|
||||
$service = $this->application->service;
|
||||
$requiredPort = $service->getRequiredPort();
|
||||
$requiredPort = $this->application->getRequiredPort();
|
||||
|
||||
if ($requiredPort !== null) {
|
||||
// Check if all FQDNs have a port
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Models\Service;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class StackForm extends Component
|
||||
|
|
@ -22,7 +23,7 @@ class StackForm extends Component
|
|||
|
||||
public string $dockerComposeRaw;
|
||||
|
||||
public string $dockerCompose;
|
||||
public ?string $dockerCompose = null;
|
||||
|
||||
public ?bool $connectToDockerNetwork = null;
|
||||
|
||||
|
|
@ -30,7 +31,7 @@ protected function rules(): array
|
|||
{
|
||||
$baseRules = [
|
||||
'dockerComposeRaw' => 'required',
|
||||
'dockerCompose' => 'required',
|
||||
'dockerCompose' => 'nullable',
|
||||
'name' => ValidationPatterns::nameRules(),
|
||||
'description' => ValidationPatterns::descriptionRules(),
|
||||
'connectToDockerNetwork' => 'nullable',
|
||||
|
|
@ -140,18 +141,27 @@ public function submit($notify = true)
|
|||
$this->validate();
|
||||
$this->syncData(true);
|
||||
|
||||
// Validate for command injection BEFORE saving to database
|
||||
// Validate for command injection BEFORE any database operations
|
||||
validateDockerComposeForInjection($this->service->docker_compose_raw);
|
||||
|
||||
$this->service->save();
|
||||
$this->service->saveExtraFields($this->fields);
|
||||
$this->service->parse();
|
||||
// Use transaction to ensure atomicity - if parse fails, save is rolled back
|
||||
DB::transaction(function () {
|
||||
$this->service->save();
|
||||
$this->service->saveExtraFields($this->fields);
|
||||
$this->service->parse();
|
||||
});
|
||||
// Refresh and write files after a successful commit
|
||||
$this->service->refresh();
|
||||
$this->service->saveComposeConfigs();
|
||||
|
||||
$this->dispatch('refreshEnvs');
|
||||
$this->dispatch('refreshServices');
|
||||
$notify && $this->dispatch('success', 'Service saved.');
|
||||
} catch (\Throwable $e) {
|
||||
// On error, refresh from database to restore clean state
|
||||
$this->service->refresh();
|
||||
$this->syncData(false);
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
if (is_null($this->service->config_hash)) {
|
||||
|
|
|
|||
|
|
@ -34,11 +34,14 @@ class Add extends Component
|
|||
|
||||
public ?string $container = '';
|
||||
|
||||
public int $timeout = 300;
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string',
|
||||
'command' => 'required|string',
|
||||
'frequency' => 'required|string',
|
||||
'container' => 'nullable|string',
|
||||
'timeout' => 'required|integer|min:60|max:3600',
|
||||
];
|
||||
|
||||
protected $validationAttributes = [
|
||||
|
|
@ -46,6 +49,7 @@ class Add extends Component
|
|||
'command' => 'command',
|
||||
'frequency' => 'frequency',
|
||||
'container' => 'container',
|
||||
'timeout' => 'timeout',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
|
|
@ -103,6 +107,7 @@ public function saveScheduledTask()
|
|||
$task->command = $this->command;
|
||||
$task->frequency = $this->frequency;
|
||||
$task->container = $this->container;
|
||||
$task->timeout = $this->timeout;
|
||||
$task->team_id = currentTeam()->id;
|
||||
|
||||
switch ($this->type) {
|
||||
|
|
@ -130,5 +135,6 @@ public function clear()
|
|||
$this->command = '';
|
||||
$this->frequency = '';
|
||||
$this->container = '';
|
||||
$this->timeout = 300;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ class Show extends Component
|
|||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $container = null;
|
||||
|
||||
#[Validate(['integer', 'required', 'min:60', 'max:3600'])]
|
||||
public $timeout = 300;
|
||||
|
||||
#[Locked]
|
||||
public ?string $application_uuid;
|
||||
|
||||
|
|
@ -99,6 +102,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->task->command = str($this->command)->trim()->value();
|
||||
$this->task->frequency = str($this->frequency)->trim()->value();
|
||||
$this->task->container = str($this->container)->trim()->value();
|
||||
$this->task->timeout = (int) $this->timeout;
|
||||
$this->task->save();
|
||||
} else {
|
||||
$this->isEnabled = $this->task->enabled;
|
||||
|
|
@ -106,6 +110,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->command = $this->task->command;
|
||||
$this->frequency = $this->task->frequency;
|
||||
$this->container = $this->task->container;
|
||||
$this->timeout = $this->task->timeout ?? 300;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,8 @@ class Application extends BaseModel
|
|||
|
||||
protected $casts = [
|
||||
'http_basic_auth_password' => 'encrypted',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
|
|
@ -772,6 +774,24 @@ public function main_port()
|
|||
return $this->settings->is_static ? [80] : $this->ports_exposes_array;
|
||||
}
|
||||
|
||||
public function detectPortFromEnvironment(?bool $isPreview = false): ?int
|
||||
{
|
||||
$envVars = $isPreview
|
||||
? $this->environment_variables_preview
|
||||
: $this->environment_variables;
|
||||
|
||||
$portVar = $envVars->firstWhere('key', 'PORT');
|
||||
|
||||
if ($portVar && $portVar->real_value) {
|
||||
$portValue = trim($portVar->real_value);
|
||||
if (is_numeric($portValue)) {
|
||||
return (int) $portValue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ class ScheduledTask extends BaseModel
|
|||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'timeout' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function service()
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@ class ScheduledTaskExecution extends BaseModel
|
|||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'retry_count' => 'integer',
|
||||
'duration' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduledTask(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ScheduledTask::class);
|
||||
|
|
|
|||
|
|
@ -1287,6 +1287,11 @@ public function workdir()
|
|||
|
||||
public function saveComposeConfigs()
|
||||
{
|
||||
// Guard against null or empty docker_compose
|
||||
if (! $this->docker_compose) {
|
||||
return;
|
||||
}
|
||||
|
||||
$workdir = $this->workdir();
|
||||
|
||||
instant_remote_process([
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@ public function fileStorages()
|
|||
return $this->morphMany(LocalFileVolume::class, 'resource');
|
||||
}
|
||||
|
||||
public function environment_variables()
|
||||
{
|
||||
return $this->morphMany(EnvironmentVariable::class, 'resourceable');
|
||||
}
|
||||
|
||||
public function fqdns(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
|
@ -174,4 +179,77 @@ public function isBackupSolutionAvailable()
|
|||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the required port for this service application.
|
||||
* Extracts port from SERVICE_URL_* or SERVICE_FQDN_* environment variables
|
||||
* stored at the Service level, filtering by normalized container name.
|
||||
* Falls back to service-level port if no port-specific variable is found.
|
||||
*/
|
||||
public function getRequiredPort(): ?int
|
||||
{
|
||||
try {
|
||||
// Normalize container name same way as variable creation
|
||||
// (uppercase, replace - and . with _)
|
||||
$normalizedName = str($this->name)
|
||||
->upper()
|
||||
->replace('-', '_')
|
||||
->replace('.', '_')
|
||||
->value();
|
||||
// Get all environment variables from the service
|
||||
$serviceEnvVars = $this->service->environment_variables()->get();
|
||||
|
||||
// Look for SERVICE_FQDN_* or SERVICE_URL_* variables that match this container
|
||||
foreach ($serviceEnvVars as $envVar) {
|
||||
$key = str($envVar->key);
|
||||
|
||||
// Check if this is a SERVICE_FQDN_* or SERVICE_URL_* variable
|
||||
if (! $key->startsWith('SERVICE_FQDN_') && ! $key->startsWith('SERVICE_URL_')) {
|
||||
continue;
|
||||
}
|
||||
// Extract the part after SERVICE_FQDN_ or SERVICE_URL_
|
||||
if ($key->startsWith('SERVICE_FQDN_')) {
|
||||
$suffix = $key->after('SERVICE_FQDN_');
|
||||
} else {
|
||||
$suffix = $key->after('SERVICE_URL_');
|
||||
}
|
||||
|
||||
// Check if this variable starts with our normalized container name
|
||||
// Format: {NORMALIZED_NAME}_{PORT} or just {NORMALIZED_NAME}
|
||||
if (! $suffix->startsWith($normalizedName)) {
|
||||
\Log::debug('[ServiceApplication::getRequiredPort] Suffix does not match container', [
|
||||
'expected_start' => $normalizedName,
|
||||
'actual_suffix' => $suffix->value(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if there's a port suffix after the container name
|
||||
// The suffix should be exactly NORMALIZED_NAME or NORMALIZED_NAME_PORT
|
||||
$afterName = $suffix->after($normalizedName)->value();
|
||||
|
||||
// If there's content after the name, it should start with underscore
|
||||
if ($afterName !== '' && str($afterName)->startsWith('_')) {
|
||||
// Extract port: _3210 -> 3210
|
||||
$port = str($afterName)->after('_')->value();
|
||||
// Validate that the extracted port is numeric
|
||||
if (is_numeric($port)) {
|
||||
\Log::debug('[ServiceApplication::getRequiredPort] MATCH FOUND - Returning port', [
|
||||
'port' => (int) $port,
|
||||
]);
|
||||
|
||||
return (int) $port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to service-level port if no port-specific variable is found
|
||||
$fallbackPort = $this->service->getRequiredPort();
|
||||
|
||||
return $fallbackPort;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,10 @@ public function databaseType()
|
|||
$image = str($this->image)->before(':');
|
||||
if ($image->contains('supabase/postgres')) {
|
||||
$finalImage = 'supabase/postgres';
|
||||
} elseif ($image->contains('timescale')) {
|
||||
$finalImage = 'postgresql';
|
||||
} elseif ($image->contains('pgvector')) {
|
||||
$finalImage = 'postgresql';
|
||||
} elseif ($image->contains('postgres') || $image->contains('postgis')) {
|
||||
$finalImage = 'postgresql';
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,38 @@ public function send(SendsEmail $notifiable, Notification $notification): void
|
|||
|
||||
$mailer->send($email);
|
||||
}
|
||||
} catch (\Resend\Exceptions\ErrorException $e) {
|
||||
// Map HTTP status codes to user-friendly messages
|
||||
$userMessage = match ($e->getErrorCode()) {
|
||||
403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.',
|
||||
401 => 'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.',
|
||||
429 => 'Resend rate limit exceeded. Please try again in a few minutes.',
|
||||
400 => 'Email validation failed: '.$e->getErrorMessage(),
|
||||
default => 'Failed to send email via Resend: '.$e->getErrorMessage(),
|
||||
};
|
||||
|
||||
// Log detailed error for admin debugging (redact sensitive data)
|
||||
$emailSettings = $notifiable->emailNotificationSettings ?? instanceSettings();
|
||||
data_set($emailSettings, 'smtp_password', '********');
|
||||
data_set($emailSettings, 'resend_api_key', '********');
|
||||
|
||||
send_internal_notification(sprintf(
|
||||
"Resend Error\nStatus Code: %s\nMessage: %s\nNotification: %s\nEmail Settings:\n%s",
|
||||
$e->getErrorCode(),
|
||||
$e->getErrorMessage(),
|
||||
get_class($notification),
|
||||
json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
|
||||
));
|
||||
|
||||
// Don't report expected errors (invalid keys, validation) to Sentry
|
||||
if (in_array($e->getErrorCode(), [403, 401, 400])) {
|
||||
throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e));
|
||||
}
|
||||
|
||||
throw new \Exception($userMessage, $e->getCode(), $e);
|
||||
} catch (\Resend\Exceptions\TransporterException $e) {
|
||||
send_internal_notification("Resend Transport Error: {$e->getMessage()}");
|
||||
throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.');
|
||||
} catch (\Throwable $e) {
|
||||
// Check if this is a Resend domain verification error on cloud instances
|
||||
if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) {
|
||||
|
|
|
|||
|
|
@ -219,9 +219,22 @@ private function executeCommandWithProcess($command, $hidden, $customType, $appe
|
|||
$process_result = $process->wait();
|
||||
if ($process_result->exitCode() !== 0) {
|
||||
if (! $ignore_errors) {
|
||||
// Check if deployment was cancelled while command was running
|
||||
if (isset($this->application_deployment_queue)) {
|
||||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
throw new \RuntimeException('Deployment cancelled by user', 69420);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't immediately set to FAILED - let the retry logic handle it
|
||||
// This prevents premature status changes during retryable SSH errors
|
||||
throw new \RuntimeException($process_result->errorOutput());
|
||||
$error = $process_result->errorOutput();
|
||||
if (empty($error)) {
|
||||
$error = $process_result->output() ?: 'Command failed with no error output';
|
||||
}
|
||||
$redactedCommand = $this->redact_sensitive_info($command);
|
||||
throw new \RuntimeException("Command execution failed (exit code {$process_result->exitCode()}): {$redactedCommand}\nError: {$error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@
|
|||
'neo4j',
|
||||
'influxdb',
|
||||
'clickhouse/clickhouse-server',
|
||||
'timescaledb/timescaledb',
|
||||
'pgvector/pgvector',
|
||||
];
|
||||
const SPECIFIC_SERVICES = [
|
||||
'quay.io/minio/minio',
|
||||
|
|
|
|||
|
|
@ -17,24 +17,44 @@ function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pul
|
|||
if (! $server->isSwarm()) {
|
||||
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.applicationId={$id}' --format '{{json .}}' "], $server);
|
||||
$containers = format_docker_command_output_to_json($containers);
|
||||
|
||||
$containers = $containers->map(function ($container) use ($pullRequestId, $includePullrequests) {
|
||||
$labels = data_get($container, 'Labels');
|
||||
if (! str($labels)->contains('coolify.pullRequestId=')) {
|
||||
data_set($container, 'Labels', $labels.",coolify.pullRequestId={$pullRequestId}");
|
||||
$containerName = data_get($container, 'Names');
|
||||
$hasPrLabel = str($labels)->contains('coolify.pullRequestId=');
|
||||
$prLabelValue = null;
|
||||
|
||||
if ($hasPrLabel) {
|
||||
preg_match('/coolify\.pullRequestId=(\d+)/', $labels, $matches);
|
||||
$prLabelValue = $matches[1] ?? null;
|
||||
}
|
||||
|
||||
// Treat pullRequestId=0 or missing label as base deployment (convention: 0 = no PR)
|
||||
$isBaseDeploy = ! $hasPrLabel || (int) $prLabelValue === 0;
|
||||
|
||||
// If we're looking for a specific PR and this is a base deployment, exclude it
|
||||
if ($pullRequestId !== null && $pullRequestId !== 0 && $isBaseDeploy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If this is a base deployment, include it when not filtering for PRs
|
||||
if ($isBaseDeploy) {
|
||||
return $container;
|
||||
}
|
||||
|
||||
if ($includePullrequests) {
|
||||
return $container;
|
||||
}
|
||||
if (str($labels)->contains("coolify.pullRequestId=$pullRequestId")) {
|
||||
if ($pullRequestId !== null && $pullRequestId !== 0 && str($labels)->contains("coolify.pullRequestId={$pullRequestId}")) {
|
||||
return $container;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
return $containers->filter();
|
||||
$filtered = $containers->filter();
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
return $containers;
|
||||
|
|
|
|||
|
|
@ -59,11 +59,13 @@ function validateDockerComposeForInjection(string $composeYaml): void
|
|||
if (isset($volume['source'])) {
|
||||
$source = $volume['source'];
|
||||
if (is_string($source)) {
|
||||
// Allow simple env vars and env vars with defaults (validated in parseDockerVolumeString)
|
||||
// Allow env vars and env vars with defaults (validated in parseDockerVolumeString)
|
||||
// Also allow env vars followed by safe path concatenation (e.g., ${VAR}/path)
|
||||
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $source);
|
||||
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $source);
|
||||
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $source);
|
||||
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault) {
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
|
||||
try {
|
||||
validateShellSafePath($source, 'volume source');
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -310,15 +312,17 @@ function parseDockerVolumeString(string $volumeString): array
|
|||
// Validate source path for command injection attempts
|
||||
// We validate the final source value after environment variable processing
|
||||
if ($source !== null) {
|
||||
// Allow simple environment variables like ${VAR_NAME} or ${VAR}
|
||||
// but validate everything else for shell metacharacters
|
||||
// Allow environment variables like ${VAR_NAME} or ${VAR}
|
||||
// Also allow env vars followed by safe path concatenation (e.g., ${VAR}/path)
|
||||
$sourceStr = is_string($source) ? $source : $source;
|
||||
|
||||
// Skip validation for simple environment variable references
|
||||
// Pattern: ${WORD_CHARS} with no special characters inside
|
||||
// Pattern 1: ${WORD_CHARS} with no special characters inside
|
||||
// Pattern 2: ${WORD_CHARS}/path/to/file (env var with path concatenation)
|
||||
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr);
|
||||
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceStr);
|
||||
|
||||
if (! $isSimpleEnvVar) {
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
|
||||
try {
|
||||
validateShellSafePath($sourceStr, 'volume source');
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -453,13 +457,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
// for example SERVICE_FQDN_APP_3000 (without a value)
|
||||
if ($key->startsWith('SERVICE_FQDN_')) {
|
||||
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
|
||||
if (substr_count(str($key)->value(), '_') === 3) {
|
||||
$fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
|
||||
$port = $key->afterLast('_')->value();
|
||||
} else {
|
||||
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
|
||||
$port = null;
|
||||
}
|
||||
$parsed = parseServiceEnvironmentVariable($key->value());
|
||||
$fqdnFor = $parsed['service_name'];
|
||||
$port = $parsed['port'];
|
||||
$fqdn = $resource->fqdn;
|
||||
if (blank($resource->fqdn)) {
|
||||
$fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
|
||||
|
|
@ -482,7 +482,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$resource->save();
|
||||
}
|
||||
|
||||
if (substr_count(str($key)->value(), '_') === 2) {
|
||||
if (! $parsed['has_port']) {
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $key->value(),
|
||||
'resourceable_type' => get_class($resource),
|
||||
|
|
@ -492,7 +492,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
if (substr_count(str($key)->value(), '_') === 3) {
|
||||
if ($parsed['has_port']) {
|
||||
|
||||
$newKey = str($key)->beforeLast('_');
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
|
|
@ -563,12 +563,21 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
}
|
||||
}
|
||||
} elseif ($command->value() === 'URL') {
|
||||
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
|
||||
// SERVICE_URL_APP or SERVICE_URL_APP_3000
|
||||
// Detect if there's a port suffix
|
||||
$parsed = parseServiceEnvironmentVariable($key->value());
|
||||
$urlFor = $parsed['service_name'];
|
||||
$port = $parsed['port'];
|
||||
$originalUrlFor = str($urlFor)->replace('_', '-');
|
||||
if (str($urlFor)->contains('-')) {
|
||||
$urlFor = str($urlFor)->replace('-', '_')->replace('.', '_');
|
||||
}
|
||||
$url = generateUrl(server: $server, random: "$originalUrlFor-$uuid");
|
||||
// Append port if specified
|
||||
$urlWithPort = $url;
|
||||
if ($port && is_numeric($port)) {
|
||||
$urlWithPort = "$url:$port";
|
||||
}
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key->value(),
|
||||
'resourceable_type' => get_class($resource),
|
||||
|
|
@ -595,12 +604,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
|
||||
if ($domainExists !== $envExists->value) {
|
||||
$envExists->update([
|
||||
'value' => $url,
|
||||
'value' => $urlWithPort,
|
||||
]);
|
||||
}
|
||||
if (is_null($domainExists)) {
|
||||
$domains->put((string) $urlFor, [
|
||||
'domain' => $url,
|
||||
'domain' => $urlWithPort,
|
||||
]);
|
||||
$resource->docker_compose_domains = $domains->toJson();
|
||||
$resource->save();
|
||||
|
|
@ -711,9 +720,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
// Validate source and target for command injection (array/long syntax)
|
||||
if ($source !== null && ! empty($source->value())) {
|
||||
$sourceValue = $source->value();
|
||||
// Allow simple environment variable references
|
||||
// Allow environment variable references and env vars with path concatenation
|
||||
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
|
||||
if (! $isSimpleEnvVar) {
|
||||
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue);
|
||||
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue);
|
||||
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
|
||||
try {
|
||||
validateShellSafePath($sourceValue, 'volume source');
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -1293,6 +1305,15 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
if ($depends_on->count() > 0) {
|
||||
$payload['depends_on'] = $depends_on;
|
||||
}
|
||||
// Auto-inject .env file so Coolify environment variables are available inside containers
|
||||
// This makes Applications behave consistently with manual .env file usage
|
||||
$existingEnvFiles = data_get($service, 'env_file');
|
||||
$envFiles = collect(is_null($existingEnvFiles) ? [] : (is_array($existingEnvFiles) ? $existingEnvFiles : [$existingEnvFiles]))
|
||||
->push('.env')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$payload['env_file'] = $envFiles;
|
||||
if ($isPullRequest) {
|
||||
$serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
|
||||
}
|
||||
|
|
@ -1412,22 +1433,40 @@ function serviceParser(Service $resource): Collection
|
|||
}
|
||||
|
||||
$image = data_get_str($service, 'image');
|
||||
$isDatabase = isDatabaseImage($image, $service);
|
||||
if ($isDatabase) {
|
||||
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
|
||||
if ($applicationFound) {
|
||||
$savedService = $applicationFound;
|
||||
|
||||
// Check for manually migrated services first (respects user's conversion choice)
|
||||
$migratedApp = ServiceApplication::where('name', $serviceName)
|
||||
->where('service_id', $resource->id)
|
||||
->where('is_migrated', true)
|
||||
->first();
|
||||
$migratedDb = ServiceDatabase::where('name', $serviceName)
|
||||
->where('service_id', $resource->id)
|
||||
->where('is_migrated', true)
|
||||
->first();
|
||||
|
||||
if ($migratedApp || $migratedDb) {
|
||||
// Use the migrated service type, ignoring image detection
|
||||
$isDatabase = (bool) $migratedDb;
|
||||
$savedService = $migratedApp ?: $migratedDb;
|
||||
} else {
|
||||
// Use image detection for non-migrated services
|
||||
$isDatabase = isDatabaseImage($image, $service);
|
||||
if ($isDatabase) {
|
||||
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
|
||||
if ($applicationFound) {
|
||||
$savedService = $applicationFound;
|
||||
} else {
|
||||
$savedService = ServiceDatabase::firstOrCreate([
|
||||
'name' => $serviceName,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$savedService = ServiceDatabase::firstOrCreate([
|
||||
$savedService = ServiceApplication::firstOrCreate([
|
||||
'name' => $serviceName,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$savedService = ServiceApplication::firstOrCreate([
|
||||
'name' => $serviceName,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
// Update image if it changed
|
||||
if ($savedService->image !== $image) {
|
||||
|
|
@ -1442,7 +1481,24 @@ function serviceParser(Service $resource): Collection
|
|||
$environment = collect(data_get($service, 'environment', []));
|
||||
$buildArgs = collect(data_get($service, 'build.args', []));
|
||||
$environment = $environment->merge($buildArgs);
|
||||
$isDatabase = isDatabaseImage($image, $service);
|
||||
|
||||
// Check for manually migrated services first (respects user's conversion choice)
|
||||
$migratedApp = ServiceApplication::where('name', $serviceName)
|
||||
->where('service_id', $resource->id)
|
||||
->where('is_migrated', true)
|
||||
->first();
|
||||
$migratedDb = ServiceDatabase::where('name', $serviceName)
|
||||
->where('service_id', $resource->id)
|
||||
->where('is_migrated', true)
|
||||
->first();
|
||||
|
||||
if ($migratedApp || $migratedDb) {
|
||||
// Use the migrated service type, ignoring image detection
|
||||
$isDatabase = (bool) $migratedDb;
|
||||
} else {
|
||||
// Use image detection for non-migrated services
|
||||
$isDatabase = isDatabaseImage($image, $service);
|
||||
}
|
||||
|
||||
$containerName = "$serviceName-{$resource->uuid}";
|
||||
|
||||
|
|
@ -1462,7 +1518,11 @@ function serviceParser(Service $resource): Collection
|
|||
if ($serviceName === 'plausible') {
|
||||
$predefinedPort = '8000';
|
||||
}
|
||||
if ($isDatabase) {
|
||||
|
||||
if ($migratedApp || $migratedDb) {
|
||||
// Use the already determined migrated service
|
||||
$savedService = $migratedApp ?: $migratedDb;
|
||||
} elseif ($isDatabase) {
|
||||
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
|
||||
if ($applicationFound) {
|
||||
$savedService = $applicationFound;
|
||||
|
|
@ -1525,27 +1585,16 @@ function serviceParser(Service $resource): Collection
|
|||
// Get magic environments where we need to preset the FQDN / URL
|
||||
if ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_')) {
|
||||
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
|
||||
if (substr_count(str($key)->value(), '_') === 3) {
|
||||
if ($key->startsWith('SERVICE_FQDN_')) {
|
||||
$urlFor = null;
|
||||
$fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
|
||||
}
|
||||
if ($key->startsWith('SERVICE_URL_')) {
|
||||
$fqdnFor = null;
|
||||
$urlFor = $key->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
|
||||
}
|
||||
$port = $key->afterLast('_')->value();
|
||||
} else {
|
||||
if ($key->startsWith('SERVICE_FQDN_')) {
|
||||
$urlFor = null;
|
||||
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
|
||||
}
|
||||
if ($key->startsWith('SERVICE_URL_')) {
|
||||
$fqdnFor = null;
|
||||
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
|
||||
}
|
||||
$port = null;
|
||||
$parsed = parseServiceEnvironmentVariable($key->value());
|
||||
if ($key->startsWith('SERVICE_FQDN_')) {
|
||||
$urlFor = null;
|
||||
$fqdnFor = $parsed['service_name'];
|
||||
}
|
||||
if ($key->startsWith('SERVICE_URL_')) {
|
||||
$fqdnFor = null;
|
||||
$urlFor = $parsed['service_name'];
|
||||
}
|
||||
$port = $parsed['port'];
|
||||
if (blank($savedService->fqdn)) {
|
||||
if ($fqdnFor) {
|
||||
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||
|
|
@ -1590,7 +1639,7 @@ function serviceParser(Service $resource): Collection
|
|||
}
|
||||
$savedService->save();
|
||||
}
|
||||
if (substr_count(str($key)->value(), '_') === 2) {
|
||||
if (! $parsed['has_port']) {
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $key->value(),
|
||||
'resourceable_type' => get_class($resource),
|
||||
|
|
@ -1608,7 +1657,7 @@ function serviceParser(Service $resource): Collection
|
|||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
if (substr_count(str($key)->value(), '_') === 3) {
|
||||
if ($parsed['has_port']) {
|
||||
// For port-specific variables (e.g., SERVICE_FQDN_UMAMI_3000),
|
||||
// keep the port suffix in the key and use the URL with port
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
|
|
@ -1642,8 +1691,17 @@ function serviceParser(Service $resource): Collection
|
|||
$url = generateUrl(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid");
|
||||
|
||||
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
|
||||
// Also check if a port-suffixed version exists (e.g., SERVICE_FQDN_UMAMI_3000)
|
||||
$portSuffixedExists = $resource->environment_variables()
|
||||
->where('key', 'LIKE', $key->value().'_%')
|
||||
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
|
||||
->exists();
|
||||
$serviceExists = ServiceApplication::where('name', str($fqdnFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
|
||||
if (! $envExists && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) {
|
||||
// Check if FQDN already has a port set (contains ':' after the domain)
|
||||
$fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
|
||||
// Only set FQDN if it's for the current service being processed (prevent race conditions)
|
||||
$isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
|
||||
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService && (data_get($serviceExists, 'name') === str($fqdnFor)->replace('_', '-')->value())) {
|
||||
// Save URL otherwise it won't work.
|
||||
$serviceExists->fqdn = $url;
|
||||
$serviceExists->save();
|
||||
|
|
@ -1662,8 +1720,17 @@ function serviceParser(Service $resource): Collection
|
|||
$url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid");
|
||||
|
||||
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
|
||||
// Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791)
|
||||
$portSuffixedExists = $resource->environment_variables()
|
||||
->where('key', 'LIKE', $key->value().'_%')
|
||||
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
|
||||
->exists();
|
||||
$serviceExists = ServiceApplication::where('name', str($urlFor)->replace('_', '-')->value())->where('service_id', $resource->id)->first();
|
||||
if (! $envExists && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) {
|
||||
// Check if FQDN already has a port set (contains ':' after the domain)
|
||||
$fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
|
||||
// Only set FQDN if it's for the current service being processed (prevent race conditions)
|
||||
$isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
|
||||
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService && (data_get($serviceExists, 'name') === str($urlFor)->replace('_', '-')->value())) {
|
||||
$serviceExists->fqdn = $url;
|
||||
$serviceExists->save();
|
||||
}
|
||||
|
|
@ -1728,7 +1795,25 @@ function serviceParser(Service $resource): Collection
|
|||
$environment = convertToKeyValueCollection($environment);
|
||||
$coolifyEnvironments = collect([]);
|
||||
|
||||
$isDatabase = isDatabaseImage($image, $service);
|
||||
// Check for manually migrated services first (respects user's conversion choice)
|
||||
$migratedApp = ServiceApplication::where('name', $serviceName)
|
||||
->where('service_id', $resource->id)
|
||||
->where('is_migrated', true)
|
||||
->first();
|
||||
$migratedDb = ServiceDatabase::where('name', $serviceName)
|
||||
->where('service_id', $resource->id)
|
||||
->where('is_migrated', true)
|
||||
->first();
|
||||
|
||||
if ($migratedApp || $migratedDb) {
|
||||
// Use the migrated service type, ignoring image detection
|
||||
$isDatabase = (bool) $migratedDb;
|
||||
$savedService = $migratedApp ?: $migratedDb;
|
||||
} else {
|
||||
// Use image detection for non-migrated services
|
||||
$isDatabase = isDatabaseImage($image, $service);
|
||||
}
|
||||
|
||||
$volumesParsed = collect([]);
|
||||
|
||||
$containerName = "$serviceName-{$resource->uuid}";
|
||||
|
|
@ -1750,7 +1835,10 @@ function serviceParser(Service $resource): Collection
|
|||
$predefinedPort = '8000';
|
||||
}
|
||||
|
||||
if ($isDatabase) {
|
||||
if ($migratedApp || $migratedDb) {
|
||||
// Use the already determined migrated service
|
||||
$savedService = $migratedApp ?: $migratedDb;
|
||||
} elseif ($isDatabase) {
|
||||
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
|
||||
if ($applicationFound) {
|
||||
$savedService = $applicationFound;
|
||||
|
|
@ -1812,9 +1900,12 @@ function serviceParser(Service $resource): Collection
|
|||
// Validate source and target for command injection (array/long syntax)
|
||||
if ($source !== null && ! empty($source->value())) {
|
||||
$sourceValue = $source->value();
|
||||
// Allow simple environment variable references
|
||||
// Allow environment variable references and env vars with path concatenation
|
||||
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
|
||||
if (! $isSimpleEnvVar) {
|
||||
$isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue);
|
||||
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue);
|
||||
|
||||
if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
|
||||
try {
|
||||
validateShellSafePath($sourceValue, 'volume source');
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -2269,6 +2360,15 @@ function serviceParser(Service $resource): Collection
|
|||
if ($depends_on->count() > 0) {
|
||||
$payload['depends_on'] = $depends_on;
|
||||
}
|
||||
// Auto-inject .env file so Coolify environment variables are available inside containers
|
||||
// This makes Services behave consistently with Applications
|
||||
$existingEnvFiles = data_get($service, 'env_file');
|
||||
$envFiles = collect(is_null($existingEnvFiles) ? [] : (is_array($existingEnvFiles) ? $existingEnvFiles : [$existingEnvFiles]))
|
||||
->push('.env')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$payload['env_file'] = $envFiles;
|
||||
|
||||
$parsedServices->put($serviceName, $payload);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
|
|||
'services' => [
|
||||
'traefik' => [
|
||||
'container_name' => 'coolify-proxy',
|
||||
'image' => 'traefik:v3.1',
|
||||
'image' => 'traefik:v3.5',
|
||||
'restart' => RESTART_MODE,
|
||||
'extra_hosts' => [
|
||||
'host.docker.internal:host-gateway',
|
||||
|
|
|
|||
|
|
@ -184,3 +184,53 @@ function serviceKeys()
|
|||
{
|
||||
return get_service_templates()->keys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a SERVICE_URL_* or SERVICE_FQDN_* variable to extract the service name and port.
|
||||
*
|
||||
* This function detects if a service environment variable has a port suffix by checking
|
||||
* if the last segment after the underscore is numeric.
|
||||
*
|
||||
* Examples:
|
||||
* - SERVICE_URL_APP_3000 → ['service_name' => 'app', 'port' => '3000', 'has_port' => true]
|
||||
* - SERVICE_URL_MY_API_8080 → ['service_name' => 'my_api', 'port' => '8080', 'has_port' => true]
|
||||
* - SERVICE_URL_MY_APP → ['service_name' => 'my_app', 'port' => null, 'has_port' => false]
|
||||
* - SERVICE_FQDN_REDIS_CACHE_6379 → ['service_name' => 'redis_cache', 'port' => '6379', 'has_port' => true]
|
||||
*
|
||||
* @param string $key The environment variable key (e.g., SERVICE_URL_APP_3000)
|
||||
* @return array{service_name: string, port: string|null, has_port: bool} Parsed service information
|
||||
*/
|
||||
function parseServiceEnvironmentVariable(string $key): array
|
||||
{
|
||||
$strKey = str($key);
|
||||
$lastSegment = $strKey->afterLast('_')->value();
|
||||
$hasPort = is_numeric($lastSegment) && ctype_digit($lastSegment);
|
||||
|
||||
if ($hasPort) {
|
||||
// Port-specific variable (e.g., SERVICE_URL_APP_3000)
|
||||
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->lower()->value();
|
||||
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
|
||||
$serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value();
|
||||
} else {
|
||||
$serviceName = '';
|
||||
}
|
||||
$port = $lastSegment;
|
||||
} else {
|
||||
// Base variable without port (e.g., SERVICE_URL_APP)
|
||||
if ($strKey->startsWith('SERVICE_URL_')) {
|
||||
$serviceName = $strKey->after('SERVICE_URL_')->lower()->value();
|
||||
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
|
||||
$serviceName = $strKey->after('SERVICE_FQDN_')->lower()->value();
|
||||
} else {
|
||||
$serviceName = '';
|
||||
}
|
||||
$port = null;
|
||||
}
|
||||
|
||||
return [
|
||||
'service_name' => $serviceName,
|
||||
'port' => $port,
|
||||
'has_port' => $hasPort,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1353,52 +1353,71 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
|
||||
// Decide if the service is a database
|
||||
$image = data_get_str($service, 'image');
|
||||
$isDatabase = isDatabaseImage($image, $service);
|
||||
data_set($service, 'is_database', $isDatabase);
|
||||
|
||||
// Create new serviceApplication or serviceDatabase
|
||||
if ($isDatabase) {
|
||||
if ($isNew) {
|
||||
$savedService = ServiceDatabase::create([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
} else {
|
||||
$savedService = ServiceDatabase::where([
|
||||
'name' => $serviceName,
|
||||
'service_id' => $resource->id,
|
||||
])->first();
|
||||
if (is_null($savedService)) {
|
||||
// Check for manually migrated services first (respects user's conversion choice)
|
||||
$migratedApp = ServiceApplication::where('name', $serviceName)
|
||||
->where('service_id', $resource->id)
|
||||
->where('is_migrated', true)
|
||||
->first();
|
||||
$migratedDb = ServiceDatabase::where('name', $serviceName)
|
||||
->where('service_id', $resource->id)
|
||||
->where('is_migrated', true)
|
||||
->first();
|
||||
|
||||
if ($migratedApp || $migratedDb) {
|
||||
// Use the migrated service type, ignoring image detection
|
||||
$isDatabase = (bool) $migratedDb;
|
||||
$savedService = $migratedApp ?: $migratedDb;
|
||||
} else {
|
||||
// Use image detection for non-migrated services
|
||||
$isDatabase = isDatabaseImage($image, $service);
|
||||
|
||||
// Create new serviceApplication or serviceDatabase
|
||||
if ($isDatabase) {
|
||||
if ($isNew) {
|
||||
$savedService = ServiceDatabase::create([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
} else {
|
||||
$savedService = ServiceDatabase::where([
|
||||
'name' => $serviceName,
|
||||
'service_id' => $resource->id,
|
||||
])->first();
|
||||
if (is_null($savedService)) {
|
||||
$savedService = ServiceDatabase::create([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($isNew) {
|
||||
$savedService = ServiceApplication::create([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
} else {
|
||||
$savedService = ServiceApplication::where([
|
||||
'name' => $serviceName,
|
||||
'service_id' => $resource->id,
|
||||
])->first();
|
||||
if (is_null($savedService)) {
|
||||
if ($isNew) {
|
||||
$savedService = ServiceApplication::create([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
} else {
|
||||
$savedService = ServiceApplication::where([
|
||||
'name' => $serviceName,
|
||||
'service_id' => $resource->id,
|
||||
])->first();
|
||||
if (is_null($savedService)) {
|
||||
$savedService = ServiceApplication::create([
|
||||
'name' => $serviceName,
|
||||
'image' => $image,
|
||||
'service_id' => $resource->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data_set($service, 'is_database', $isDatabase);
|
||||
|
||||
// Check if image changed
|
||||
if ($savedService->image !== $image) {
|
||||
$savedService->image = $image;
|
||||
|
|
|
|||
|
|
@ -58,16 +58,35 @@ function parseCommandsByLineForSudo(Collection $commands, Server $server): array
|
|||
|
||||
$commands = $commands->map(function ($line) {
|
||||
$line = str($line);
|
||||
|
||||
// Detect complex piped commands that should be wrapped in bash -c
|
||||
$isComplexPipeCommand = (
|
||||
$line->contains(' | sh') ||
|
||||
$line->contains(' | bash') ||
|
||||
($line->contains(' | ') && ($line->contains('||') || $line->contains('&&')))
|
||||
);
|
||||
|
||||
// If it's a complex pipe command and starts with sudo, wrap it in bash -c
|
||||
if ($isComplexPipeCommand && $line->startsWith('sudo ')) {
|
||||
$commandWithoutSudo = $line->after('sudo ')->value();
|
||||
// Escape single quotes for bash -c by replacing ' with '\''
|
||||
$escapedCommand = str_replace("'", "'\\''", $commandWithoutSudo);
|
||||
|
||||
return "sudo bash -c '$escapedCommand'";
|
||||
}
|
||||
|
||||
// For non-complex commands, apply the original logic
|
||||
if (str($line)->contains('$(')) {
|
||||
$line = $line->replace('$(', '$(sudo ');
|
||||
}
|
||||
if (str($line)->contains('||')) {
|
||||
if (! $isComplexPipeCommand && str($line)->contains('||')) {
|
||||
$line = $line->replace('||', '|| sudo');
|
||||
}
|
||||
if (str($line)->contains('&&')) {
|
||||
if (! $isComplexPipeCommand && str($line)->contains('&&')) {
|
||||
$line = $line->replace('&&', '&& sudo');
|
||||
}
|
||||
if (str($line)->contains(' | ')) {
|
||||
// Don't insert sudo into pipes for complex commands
|
||||
if (! $isComplexPipeCommand && str($line)->contains(' | ')) {
|
||||
$line = $line->replace(' | ', ' | sudo ');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
"poliander/cron": "^3.2.1",
|
||||
"purplepixie/phpdns": "^2.2",
|
||||
"pusher/pusher-php-server": "^7.2.7",
|
||||
"resend/resend-laravel": "^0.19.0",
|
||||
"resend/resend-laravel": "^0.20.0",
|
||||
"sentry/sentry-laravel": "^4.15.1",
|
||||
"socialiteproviders/authentik": "^5.2",
|
||||
"socialiteproviders/clerk": "^5.0",
|
||||
|
|
|
|||
14
composer.lock
generated
14
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "a993799242581bd06b5939005ee458d9",
|
||||
"content-hash": "423b7d10901b9f31c926d536ff163a22",
|
||||
"packages": [
|
||||
{
|
||||
"name": "amphp/amp",
|
||||
|
|
@ -7048,16 +7048,16 @@
|
|||
},
|
||||
{
|
||||
"name": "resend/resend-laravel",
|
||||
"version": "v0.19.0",
|
||||
"version": "v0.20.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/resend/resend-laravel.git",
|
||||
"reference": "ce11e363c42c1d6b93983dfebbaba3f906863c3a"
|
||||
"reference": "f32c2f484df2bc65fba8ea9ab9b210cd42d9f3ed"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/resend/resend-laravel/zipball/ce11e363c42c1d6b93983dfebbaba3f906863c3a",
|
||||
"reference": "ce11e363c42c1d6b93983dfebbaba3f906863c3a",
|
||||
"url": "https://api.github.com/repos/resend/resend-laravel/zipball/f32c2f484df2bc65fba8ea9ab9b210cd42d9f3ed",
|
||||
"reference": "f32c2f484df2bc65fba8ea9ab9b210cd42d9f3ed",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
|
|
@ -7111,9 +7111,9 @@
|
|||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/resend/resend-laravel/issues",
|
||||
"source": "https://github.com/resend/resend-laravel/tree/v0.19.0"
|
||||
"source": "https://github.com/resend/resend-laravel/tree/v0.20.0"
|
||||
},
|
||||
"time": "2025-05-06T21:36:51+00:00"
|
||||
"time": "2025-08-04T19:26:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "resend/resend-php",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.0.0-beta.442',
|
||||
'version' => '4.0.0-beta.443',
|
||||
'helper_version' => '1.0.12',
|
||||
'realtime_version' => '1.0.10',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
|
|
|||
|
|
@ -129,8 +129,8 @@
|
|||
'scheduled-errors' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/scheduled-errors.log'),
|
||||
'level' => 'debug',
|
||||
'days' => 7,
|
||||
'level' => 'warning',
|
||||
'days' => 14,
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('scheduled_tasks', function (Blueprint $table) {
|
||||
$table->integer('timeout')->default(300)->after('frequency');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('scheduled_tasks', function (Blueprint $table) {
|
||||
$table->dropColumn('timeout');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('scheduled_task_executions', function (Blueprint $table) {
|
||||
$table->timestamp('started_at')->nullable()->after('scheduled_task_id');
|
||||
$table->integer('retry_count')->default(0)->after('status');
|
||||
$table->decimal('duration', 10, 2)->nullable()->after('retry_count')->comment('Duration in seconds');
|
||||
$table->text('error_details')->nullable()->after('message');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('scheduled_task_executions', function (Blueprint $table) {
|
||||
$table->dropColumn(['started_at', 'retry_count', 'duration', 'error_details']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->integer('restart_count')->default(0)->after('status');
|
||||
$table->timestamp('last_restart_at')->nullable()->after('restart_count');
|
||||
$table->string('last_restart_type', 10)->nullable()->after('last_restart_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->dropColumn(['restart_count', 'last_restart_at', 'last_restart_type']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -13,6 +13,9 @@ charset utf-8;
|
|||
# Set max upload to 2048M
|
||||
client_max_body_size 2048M;
|
||||
|
||||
# Set client body buffer to handle Sentinel payloads in memory
|
||||
client_body_buffer_size 256k;
|
||||
|
||||
# Healthchecks: Set /healthcheck to be the healthcheck URL
|
||||
location /healthcheck {
|
||||
access_log off;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ charset utf-8;
|
|||
# Set max upload to 2048M
|
||||
client_max_body_size 2048M;
|
||||
|
||||
# Set client body buffer to handle Sentinel payloads in memory
|
||||
client_body_buffer_size 256k;
|
||||
|
||||
# Healthchecks: Set /healthcheck to be the healthcheck URL
|
||||
location /healthcheck {
|
||||
access_log off;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"scripts": {
|
||||
"onWorktreeCreate": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json",
|
||||
"run": "docker rm -f coolify coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite && spin up"
|
||||
"setup": "cp $JEAN_ROOT_PATH/.env . && mkdir -p .claude && cp $JEAN_ROOT_PATH/.claude/settings.local.json .claude/settings.local.json"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.442"
|
||||
"version": "4.0.0-beta.443"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.443"
|
||||
"version": "4.0.0-beta.444"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.11"
|
||||
|
|
|
|||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -2664,11 +2664,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz",
|
||||
"integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==",
|
||||
"version": "7.5.2",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
|
||||
"integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
|
|
|
|||
1
public/svgs/postgresus.svg
Normal file
1
public/svgs/postgresus.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?> <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30" fill="none"><path d="M18.1899 24.7431C17.4603 24.7737 16.6261 24.3423 16.1453 23.6749C15.9745 23.438 15.6161 23.4548 15.4621 23.7026C15.1857 24.1478 14.9389 24.5259 14.8066 24.751C14.4739 25.3197 14.5242 25.4223 14.7918 25.8636C15.2923 26.689 16.8374 27.9675 19.0113 27.999C22.3807 28.0474 25.2269 26.2506 26.303 21.9058C29.0811 22.0322 29.5767 20.9018 29.5866 19.8415C29.5965 18.795 29.1542 18.2796 27.8866 17.9232C27.4739 17.8067 26.9902 17.7061 26.4689 17.4948C26.3198 16.2281 25.9496 15.0257 25.376 13.8933C28.1433 2.78289 16.4839 -0.631985 12.0048 4.22426C11.3818 3.42756 9.81016 2.00395 7.14065 2C4.12857 1.99606 1 4.47798 1 8.23346C1 9.79626 1.93492 12.1331 2.56083 14.1332C3.86103 18.2875 4.6992 19.4683 6.52362 19.801C7.98376 20.0675 9.1645 19.3972 10.0471 18.2796C11.3233 18.4028 10.4726 19.5371 16.4099 19.2234C17.6765 19.1168 18.7694 19.564 19.5937 20.498C20.8071 21.8732 20.4566 24.6474 18.1899 24.7421V24.7431ZM17.8483 13.0423C17.2174 12.9801 16.707 12.4697 16.6448 11.8389C16.5599 10.9859 17.2708 10.2761 18.1237 10.36C18.7546 10.4222 19.265 10.9326 19.3272 11.5634C19.4111 12.4154 18.7013 13.1262 17.8483 13.0423ZM20.578 18.178C19.9403 17.5392 19.8524 16.7149 20.3519 16.1788C20.9186 15.5706 21.7242 15.85 22.1428 16.3061C23.4331 17.712 24.9209 18.6193 27.854 19.337C28.4651 19.487 28.4157 20.3716 27.7908 20.4476C26.4798 20.6076 24.3355 20.3065 22.8882 19.6934C22.0115 19.3222 21.1763 18.7762 20.578 18.177V18.178Z" fill="#1677FF"></path><path d="M17.0439 19.2156C17.0439 19.2156 17.037 19.2156 17.0321 19.2156C18.0648 19.2738 18.9029 19.7161 19.594 20.498C20.8073 21.8732 20.4568 24.6474 18.1901 24.7421C17.4606 24.7727 16.6263 24.3413 16.1456 23.6739C17.7202 26.6505 21.8281 26.0818 22.2694 23.3432C22.6288 21.114 20.0304 18.5699 17.0439 19.2136V19.2156ZM10 18C7.24751 15.8875 7.91886 10.4824 10.7779 6.4742C10.3317 5.85322 9.00779 4.787 7.32553 4.74751C4.61357 4.68433 2.68055 6.99842 3.66286 10.206C3.9768 6.91846 7.20805 6.33105 8.7363 8.17324C6.76477 12.1479 7.27817 16.1766 10 18C15 19.2194 12.2436 19.21 10 18Z" fill="#1E56E2"></path><path d="M10 18H12L13 19H12L11 18.5L10 18Z" fill="#1677FF"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
|
@ -12,6 +12,13 @@
|
|||
@else
|
||||
<x-status.stopped :status="$resource->status" />
|
||||
@endif
|
||||
@if (isset($resource->restart_count) && $resource->restart_count > 0 && !str($resource->status)->startsWith('exited'))
|
||||
<div class="flex items-center pl-2">
|
||||
<span class="text-xs dark:text-warning" title="Container has restarted {{ $resource->restart_count }} time{{ $resource->restart_count > 1 ? 's' : '' }}. Last restart: {{ $resource->last_restart_at?->diffForHumans() }}">
|
||||
({{ $resource->restart_count }}x restarts)
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@if (!str($resource->status)->contains('exited') && $showRefreshButton)
|
||||
<button wire:loading.remove.delay.shortest wire:target="manualCheckStatus" title="Refresh Status" wire:click='manualCheckStatus'
|
||||
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
|
||||
|
|
|
|||
|
|
@ -369,6 +369,39 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
|
|||
@endif
|
||||
@if ($application->build_pack !== 'dockercompose')
|
||||
<h3 class="pt-8">Network</h3>
|
||||
@if ($this->detectedPortInfo)
|
||||
@if ($this->detectedPortInfo['isEmpty'])
|
||||
<div class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800">
|
||||
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<span class="font-semibold">PORT environment variable detected ({{ $this->detectedPortInfo['port'] }})</span>
|
||||
<p class="mt-1">Your Ports Exposes field is empty. Consider setting it to <strong>{{ $this->detectedPortInfo['port'] }}</strong> to ensure the proxy routes traffic correctly.</p>
|
||||
</div>
|
||||
</div>
|
||||
@elseif (!$this->detectedPortInfo['matches'])
|
||||
<div class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800">
|
||||
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<span class="font-semibold">PORT mismatch detected</span>
|
||||
<p class="mt-1">Your PORT environment variable is set to <strong>{{ $this->detectedPortInfo['port'] }}</strong>, but it's not in your Ports Exposes configuration. Ensure they match for proper proxy routing.</p>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
|
||||
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<span class="font-semibold">PORT environment variable configured</span>
|
||||
<p class="mt-1">Your PORT environment variable ({{ $this->detectedPortInfo['port'] }}) matches your Ports Exposes configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
<div class="flex flex-col gap-2 xl:flex-row">
|
||||
@if ($application->settings->is_static || $application->build_pack === 'static')
|
||||
<x-forms.input id="portsExposes" label="Ports Exposes" readonly
|
||||
|
|
|
|||
|
|
@ -12,7 +12,14 @@
|
|||
</a>
|
||||
<a class="{{ request()->routeIs('project.application.logs') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.application.logs', $parameters) }}">
|
||||
Logs
|
||||
<div class="flex items-center gap-1">
|
||||
Logs
|
||||
@if ($application->restart_count > 0 && !str($application->status)->startsWith('exited'))
|
||||
<svg class="w-4 h-4 dark:text-warning" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" title="Container has restarted {{ $application->restart_count }} time{{ $application->restart_count > 1 ? 's' : '' }}">
|
||||
<path d="M12 2L1 21h22L12 2zm0 4l7.53 13H4.47L12 6zm-1 5v4h2v-4h-2zm0 5v2h2v-2h-2z"/>
|
||||
</svg>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@if (!$application->destination->server->isSwarm())
|
||||
@can('canAccessTerminal')
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
<x-forms.input placeholder="0 0 * * * or daily"
|
||||
helper="You can use every_minute, hourly, daily, weekly, monthly, yearly or a cron expression." id="frequency"
|
||||
label="Frequency" />
|
||||
<x-forms.input type="number" placeholder="300" id="timeout"
|
||||
helper="Maximum execution time in seconds (60-3600). Default is 300 seconds (5 minutes)."
|
||||
label="Timeout (seconds)" />
|
||||
@if ($type === 'application')
|
||||
@if ($containerNames->count() > 1)
|
||||
<x-forms.select id="container" label="Container name">
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@
|
|||
<x-forms.input placeholder="Name" id="name" label="Name" required />
|
||||
<x-forms.input placeholder="php artisan schedule:run" id="command" label="Command" required />
|
||||
<x-forms.input placeholder="0 0 * * * or daily" id="frequency" label="Frequency" required />
|
||||
<x-forms.input type="number" placeholder="300" id="timeout"
|
||||
helper="Maximum execution time in seconds (60-3600)." label="Timeout (seconds)" required />
|
||||
@if ($type === 'application')
|
||||
<x-forms.input placeholder="php"
|
||||
helper="You can leave this empty if your resource only has one container." id="container"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ services:
|
|||
- CONVEX_RELEASE_VERSION_DEV=${CONVEX_RELEASE_VERSION_DEV:-}
|
||||
- ACTIONS_USER_TIMEOUT_SECS=${ACTIONS_USER_TIMEOUT_SECS:-}
|
||||
# URL of the Convex API as accessed by the client/frontend.
|
||||
- CONVEX_CLOUD_ORIGIN=${SERVICE_URL_CONVEX}
|
||||
- CONVEX_CLOUD_ORIGIN=${SERVICE_URL_DASHBOARD}
|
||||
# URL of Convex HTTP actions as accessed by the client/frontend.
|
||||
- CONVEX_SITE_ORIGIN=${SERVICE_URL_BACKEND}
|
||||
- DATABASE_URL=${DATABASE_URL:-}
|
||||
|
|
@ -49,7 +49,7 @@ services:
|
|||
dashboard:
|
||||
image: ghcr.io/get-convex/convex-dashboard:33cef775a8a6228cbacee4a09ac2c4073d62ed13
|
||||
environment:
|
||||
- SERVICE_URL_CONVEX_6791
|
||||
- SERVICE_URL_DASHBOARD_6791
|
||||
# URL of the Convex API as accessed by the dashboard (browser).
|
||||
- NEXT_PUBLIC_DEPLOYMENT_URL=${SERVICE_URL_BACKEND}
|
||||
depends_on:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# documentation: https://github.com/mregni/EmbyStat
|
||||
# slogan: EmbyStat is a web analytics tool, designed to provide insight into website traffic and user behavior.
|
||||
# category: media
|
||||
# tags: media, server, movies, tv, music
|
||||
# category: analytics
|
||||
# tags: analytics, insights, statistics, web, traffic
|
||||
# port: 6555
|
||||
|
||||
services:
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ services:
|
|||
openpanel-worker:
|
||||
image: lindesvard/openpanel-worker:latest
|
||||
environment:
|
||||
- DISABLE_BULLBOARD=${DISABLE_BULLBOARD:-1}
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_SELF_HOSTED=true
|
||||
- SERVICE_URL_OPBULLBOARD
|
||||
|
|
|
|||
20
templates/compose/postgresus.yaml
Normal file
20
templates/compose/postgresus.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# documentation: https://postgresus.com/#guide
|
||||
# slogan: Postgresus is a free, open source and self-hosted tool to backup PostgreSQL.
|
||||
# category: devtools
|
||||
# tags: postgres,backup
|
||||
# logo: svgs/postgresus.svg
|
||||
# port: 4005
|
||||
|
||||
services:
|
||||
postgresus:
|
||||
image: rostislavdugin/postgresus:7fb59bb5d02fbaf856b0bcfc7a0786575818b96f # Released on 30 Sep, 2025
|
||||
environment:
|
||||
- SERVICE_URL_POSTGRESUS_4005
|
||||
volumes:
|
||||
- postgresus-data:/postgresus-data
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "wget", "-qO-", "http://localhost:4005/api/v1/system/health"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
# documentation: https://rybbit.io/docs
|
||||
# slogan: Open-source, privacy-first web analytics.
|
||||
# tags: analytics,web,privacy,self-hosted,clickhouse,postgres
|
||||
# category: analytics
|
||||
# tags: analytics, web, privacy, self-hosted, clickhouse, postgres
|
||||
# logo: svgs/rybbit.svg
|
||||
# port: 3002
|
||||
|
||||
|
|
@ -130,4 +131,4 @@ services:
|
|||
<log_processors_profiles>0</log_processors_profiles>
|
||||
</default>
|
||||
</profiles>
|
||||
</clickhouse>
|
||||
</clickhouse>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
130
tests/Feature/CleanupRedisTest.php
Normal file
130
tests/Feature/CleanupRedisTest.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\CleanupRedis;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['horizon.prefix' => 'horizon:']);
|
||||
});
|
||||
|
||||
it('handles Redis scan returning false gracefully', function () {
|
||||
// Mock Redis connection
|
||||
$redisMock = Mockery::mock();
|
||||
|
||||
// Mock scan() returning false (error case)
|
||||
$redisMock->shouldReceive('scan')
|
||||
->once()
|
||||
->with(0, ['match' => '*', 'count' => 100])
|
||||
->andReturn(false);
|
||||
|
||||
// Mock keys() for initial scan and overlapping queues cleanup
|
||||
$redisMock->shouldReceive('keys')
|
||||
->with('*')
|
||||
->andReturn([]);
|
||||
|
||||
Redis::shouldReceive('connection')
|
||||
->with('horizon')
|
||||
->andReturn($redisMock);
|
||||
|
||||
// Run the command in dry-run mode with restart flag to trigger cleanupStuckJobs
|
||||
// Use skip-overlapping to avoid additional keys() calls
|
||||
$this->artisan(CleanupRedis::class, ['--dry-run' => true, '--restart' => true, '--skip-overlapping' => true])
|
||||
->expectsOutput('DRY RUN MODE - No data will be deleted')
|
||||
->expectsOutputToContain('Redis scan failed, stopping key retrieval')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('successfully scans Redis keys when scan returns valid results', function () {
|
||||
// Mock Redis connection
|
||||
$redisMock = Mockery::mock();
|
||||
|
||||
// Mock successful scan() that returns keys
|
||||
// First iteration returns cursor 1 and some keys
|
||||
$redisMock->shouldReceive('scan')
|
||||
->once()
|
||||
->with(0, ['match' => '*', 'count' => 100])
|
||||
->andReturn([1, ['horizon:job:1', 'horizon:job:2']]);
|
||||
|
||||
// Second iteration returns cursor 0 (end of scan) and more keys
|
||||
$redisMock->shouldReceive('scan')
|
||||
->once()
|
||||
->with(1, ['match' => '*', 'count' => 100])
|
||||
->andReturn([0, ['horizon:job:3']]);
|
||||
|
||||
// Mock keys() for initial scan
|
||||
$redisMock->shouldReceive('keys')
|
||||
->with('*')
|
||||
->andReturn([]);
|
||||
|
||||
// Mock command() for type checking on each key
|
||||
$redisMock->shouldReceive('command')
|
||||
->with('type', Mockery::any())
|
||||
->andReturn(5); // Hash type
|
||||
|
||||
// Mock command() for hgetall to get job data
|
||||
$redisMock->shouldReceive('command')
|
||||
->with('hgetall', Mockery::any())
|
||||
->andReturn([
|
||||
'status' => 'processing',
|
||||
'reserved_at' => time() - 60, // Started 1 minute ago
|
||||
'payload' => json_encode(['displayName' => 'TestJob']),
|
||||
]);
|
||||
|
||||
Redis::shouldReceive('connection')
|
||||
->with('horizon')
|
||||
->andReturn($redisMock);
|
||||
|
||||
// Run the command with restart flag to trigger cleanupStuckJobs
|
||||
$this->artisan(CleanupRedis::class, ['--dry-run' => true, '--restart' => true, '--skip-overlapping' => true])
|
||||
->expectsOutput('DRY RUN MODE - No data will be deleted')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('handles empty scan results gracefully', function () {
|
||||
// Mock Redis connection
|
||||
$redisMock = Mockery::mock();
|
||||
|
||||
// Mock scan() returning empty results
|
||||
$redisMock->shouldReceive('scan')
|
||||
->once()
|
||||
->with(0, ['match' => '*', 'count' => 100])
|
||||
->andReturn([0, []]); // Cursor 0 and no keys
|
||||
|
||||
// Mock keys() for initial scan
|
||||
$redisMock->shouldReceive('keys')
|
||||
->with('*')
|
||||
->andReturn([]);
|
||||
|
||||
Redis::shouldReceive('connection')
|
||||
->with('horizon')
|
||||
->andReturn($redisMock);
|
||||
|
||||
// Run the command with restart flag
|
||||
$this->artisan(CleanupRedis::class, ['--dry-run' => true, '--restart' => true, '--skip-overlapping' => true])
|
||||
->expectsOutput('DRY RUN MODE - No data will be deleted')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('uses lowercase option keys for scan', function () {
|
||||
// Mock Redis connection
|
||||
$redisMock = Mockery::mock();
|
||||
|
||||
// Verify that scan is called with lowercase keys: 'match' and 'count'
|
||||
$redisMock->shouldReceive('scan')
|
||||
->once()
|
||||
->with(0, ['match' => '*', 'count' => 100])
|
||||
->andReturn([0, []]);
|
||||
|
||||
// Mock keys() for initial scan
|
||||
$redisMock->shouldReceive('keys')
|
||||
->with('*')
|
||||
->andReturn([]);
|
||||
|
||||
Redis::shouldReceive('connection')
|
||||
->with('horizon')
|
||||
->andReturn($redisMock);
|
||||
|
||||
// Run the command with restart flag
|
||||
$this->artisan(CleanupRedis::class, ['--dry-run' => true, '--restart' => true, '--skip-overlapping' => true])
|
||||
->assertSuccessful();
|
||||
});
|
||||
70
tests/Feature/CoolifyTaskRetryTest.php
Normal file
70
tests/Feature/CoolifyTaskRetryTest.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\CoolifyTask;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('can dispatch CoolifyTask successfully', function () {
|
||||
// Skip if no servers available
|
||||
$server = Server::where('ip', '!=', '1.2.3.4')->first();
|
||||
|
||||
if (! $server) {
|
||||
$this->markTestSkipped('No servers available for testing');
|
||||
}
|
||||
|
||||
Queue::fake();
|
||||
|
||||
// Create an activity for the task
|
||||
$activity = activity()
|
||||
->withProperties([
|
||||
'server_uuid' => $server->uuid,
|
||||
'command' => 'echo "test"',
|
||||
'type' => 'inline',
|
||||
])
|
||||
->event('inline')
|
||||
->log('[]');
|
||||
|
||||
// Dispatch the job
|
||||
CoolifyTask::dispatch(
|
||||
activity: $activity,
|
||||
ignore_errors: false,
|
||||
call_event_on_finish: null,
|
||||
call_event_data: null
|
||||
);
|
||||
|
||||
// Assert job was dispatched
|
||||
Queue::assertPushed(CoolifyTask::class);
|
||||
});
|
||||
|
||||
it('has correct retry configuration on CoolifyTask', function () {
|
||||
$server = Server::where('ip', '!=', '1.2.3.4')->first();
|
||||
|
||||
if (! $server) {
|
||||
$this->markTestSkipped('No servers available for testing');
|
||||
}
|
||||
|
||||
$activity = activity()
|
||||
->withProperties([
|
||||
'server_uuid' => $server->uuid,
|
||||
'command' => 'echo "test"',
|
||||
'type' => 'inline',
|
||||
])
|
||||
->event('inline')
|
||||
->log('[]');
|
||||
|
||||
$job = new CoolifyTask(
|
||||
activity: $activity,
|
||||
ignore_errors: false,
|
||||
call_event_on_finish: null,
|
||||
call_event_data: null
|
||||
);
|
||||
|
||||
// Assert retry configuration
|
||||
expect($job->tries)->toBe(3);
|
||||
expect($job->maxExceptions)->toBe(1);
|
||||
expect($job->timeout)->toBe(600);
|
||||
expect($job->backoff())->toBe([30, 90, 180]);
|
||||
});
|
||||
216
tests/Feature/StartupExecutionCleanupTest.php
Normal file
216
tests/Feature/StartupExecutionCleanupTest.php
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<?php
|
||||
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Freeze time for consistent testing
|
||||
Carbon::setTestNow('2025-01-15 12:00:00');
|
||||
|
||||
// Fake notifications to ensure none are sent
|
||||
Notification::fake();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('app:init marks stuck scheduled task executions as failed', function () {
|
||||
// Create a team for the scheduled task
|
||||
$team = Team::factory()->create();
|
||||
|
||||
// Create a scheduled task
|
||||
$scheduledTask = ScheduledTask::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
// Create multiple task executions with 'running' status
|
||||
$runningExecution1 = ScheduledTaskExecution::create([
|
||||
'scheduled_task_id' => $scheduledTask->id,
|
||||
'status' => 'running',
|
||||
'started_at' => Carbon::now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$runningExecution2 = ScheduledTaskExecution::create([
|
||||
'scheduled_task_id' => $scheduledTask->id,
|
||||
'status' => 'running',
|
||||
'started_at' => Carbon::now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
// Create a completed execution (should not be affected)
|
||||
$completedExecution = ScheduledTaskExecution::create([
|
||||
'scheduled_task_id' => $scheduledTask->id,
|
||||
'status' => 'success',
|
||||
'started_at' => Carbon::now()->subMinutes(15),
|
||||
'finished_at' => Carbon::now()->subMinutes(14),
|
||||
]);
|
||||
|
||||
// Run the app:init command
|
||||
Artisan::call('app:init');
|
||||
|
||||
// Refresh models from database
|
||||
$runningExecution1->refresh();
|
||||
$runningExecution2->refresh();
|
||||
$completedExecution->refresh();
|
||||
|
||||
// Assert running executions are now failed
|
||||
expect($runningExecution1->status)->toBe('failed')
|
||||
->and($runningExecution1->message)->toBe('Marked as failed during Coolify startup - job was interrupted')
|
||||
->and($runningExecution1->finished_at)->not->toBeNull()
|
||||
->and($runningExecution1->finished_at->toDateTimeString())->toBe('2025-01-15 12:00:00');
|
||||
|
||||
expect($runningExecution2->status)->toBe('failed')
|
||||
->and($runningExecution2->message)->toBe('Marked as failed during Coolify startup - job was interrupted')
|
||||
->and($runningExecution2->finished_at)->not->toBeNull();
|
||||
|
||||
// Assert completed execution is unchanged
|
||||
expect($completedExecution->status)->toBe('success')
|
||||
->and($completedExecution->message)->toBeNull();
|
||||
|
||||
// Assert NO notifications were sent
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('app:init marks stuck database backup executions as failed', function () {
|
||||
// Create a team for the scheduled backup
|
||||
$team = Team::factory()->create();
|
||||
|
||||
// Create a database
|
||||
$database = StandalonePostgresql::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
// Create a scheduled backup
|
||||
$scheduledBackup = ScheduledDatabaseBackup::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'database_id' => $database->id,
|
||||
'database_type' => StandalonePostgresql::class,
|
||||
]);
|
||||
|
||||
// Create multiple backup executions with 'running' status
|
||||
$runningBackup1 = ScheduledDatabaseBackupExecution::create([
|
||||
'scheduled_database_backup_id' => $scheduledBackup->id,
|
||||
'status' => 'running',
|
||||
'database_name' => 'test_db',
|
||||
]);
|
||||
|
||||
$runningBackup2 = ScheduledDatabaseBackupExecution::create([
|
||||
'scheduled_database_backup_id' => $scheduledBackup->id,
|
||||
'status' => 'running',
|
||||
'database_name' => 'test_db_2',
|
||||
]);
|
||||
|
||||
// Create a successful backup (should not be affected)
|
||||
$successfulBackup = ScheduledDatabaseBackupExecution::create([
|
||||
'scheduled_database_backup_id' => $scheduledBackup->id,
|
||||
'status' => 'success',
|
||||
'database_name' => 'test_db_3',
|
||||
'finished_at' => Carbon::now()->subMinutes(20),
|
||||
]);
|
||||
|
||||
// Run the app:init command
|
||||
Artisan::call('app:init');
|
||||
|
||||
// Refresh models from database
|
||||
$runningBackup1->refresh();
|
||||
$runningBackup2->refresh();
|
||||
$successfulBackup->refresh();
|
||||
|
||||
// Assert running backups are now failed
|
||||
expect($runningBackup1->status)->toBe('failed')
|
||||
->and($runningBackup1->message)->toBe('Marked as failed during Coolify startup - job was interrupted')
|
||||
->and($runningBackup1->finished_at)->not->toBeNull()
|
||||
->and($runningBackup1->finished_at->toDateTimeString())->toBe('2025-01-15 12:00:00');
|
||||
|
||||
expect($runningBackup2->status)->toBe('failed')
|
||||
->and($runningBackup2->message)->toBe('Marked as failed during Coolify startup - job was interrupted')
|
||||
->and($runningBackup2->finished_at)->not->toBeNull();
|
||||
|
||||
// Assert successful backup is unchanged
|
||||
expect($successfulBackup->status)->toBe('success')
|
||||
->and($successfulBackup->message)->toBeNull();
|
||||
|
||||
// Assert NO notifications were sent
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('app:init handles cleanup when no stuck executions exist', function () {
|
||||
// Create a team
|
||||
$team = Team::factory()->create();
|
||||
|
||||
// Create a scheduled task
|
||||
$scheduledTask = ScheduledTask::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
// Create only completed executions
|
||||
ScheduledTaskExecution::create([
|
||||
'scheduled_task_id' => $scheduledTask->id,
|
||||
'status' => 'success',
|
||||
'started_at' => Carbon::now()->subMinutes(10),
|
||||
'finished_at' => Carbon::now()->subMinutes(9),
|
||||
]);
|
||||
|
||||
ScheduledTaskExecution::create([
|
||||
'scheduled_task_id' => $scheduledTask->id,
|
||||
'status' => 'failed',
|
||||
'started_at' => Carbon::now()->subMinutes(20),
|
||||
'finished_at' => Carbon::now()->subMinutes(19),
|
||||
]);
|
||||
|
||||
// Run the app:init command (should not fail)
|
||||
$exitCode = Artisan::call('app:init');
|
||||
|
||||
// Assert command succeeded
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
// Assert all executions remain unchanged
|
||||
expect(ScheduledTaskExecution::where('status', 'running')->count())->toBe(0)
|
||||
->and(ScheduledTaskExecution::where('status', 'success')->count())->toBe(1)
|
||||
->and(ScheduledTaskExecution::where('status', 'failed')->count())->toBe(1);
|
||||
|
||||
// Assert NO notifications were sent
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('cleanup does not send notifications even when team has notification settings', function () {
|
||||
// Create a team with notification settings enabled
|
||||
$team = Team::factory()->create([
|
||||
'smtp_enabled' => true,
|
||||
'smtp_from_address' => 'test@example.com',
|
||||
]);
|
||||
|
||||
// Create a scheduled task
|
||||
$scheduledTask = ScheduledTask::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
// Create a running execution
|
||||
$runningExecution = ScheduledTaskExecution::create([
|
||||
'scheduled_task_id' => $scheduledTask->id,
|
||||
'status' => 'running',
|
||||
'started_at' => Carbon::now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
// Run the app:init command
|
||||
Artisan::call('app:init');
|
||||
|
||||
// Refresh model
|
||||
$runningExecution->refresh();
|
||||
|
||||
// Assert execution is failed
|
||||
expect($runningExecution->status)->toBe('failed');
|
||||
|
||||
// Assert NO notifications were sent despite team having notification settings
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
156
tests/Unit/ApplicationPortDetectionTest.php
Normal file
156
tests/Unit/ApplicationPortDetectionTest.php
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests for PORT environment variable detection feature.
|
||||
*
|
||||
* Tests verify that the Application model can correctly detect PORT environment
|
||||
* variables and provide information to the UI about matches and mismatches with
|
||||
* the configured ports_exposes field.
|
||||
*/
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Mockery;
|
||||
|
||||
beforeEach(function () {
|
||||
// Clean up Mockery after each test
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('detects PORT environment variable when present', function () {
|
||||
// Create a mock Application instance
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
|
||||
// Mock environment variables collection with PORT set to 3000
|
||||
$portEnvVar = Mockery::mock(EnvironmentVariable::class);
|
||||
$portEnvVar->shouldReceive('getAttribute')->with('real_value')->andReturn('3000');
|
||||
|
||||
$envVars = new Collection([$portEnvVar]);
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('environment_variables')
|
||||
->andReturn($envVars);
|
||||
|
||||
// Mock the firstWhere method to return our PORT env var
|
||||
$envVars = Mockery::mock(Collection::class);
|
||||
$envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn($portEnvVar);
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('environment_variables')
|
||||
->andReturn($envVars);
|
||||
|
||||
// Call the method we're testing
|
||||
$detectedPort = $application->detectPortFromEnvironment();
|
||||
|
||||
expect($detectedPort)->toBe(3000);
|
||||
});
|
||||
|
||||
it('returns null when PORT environment variable is not set', function () {
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
|
||||
// Mock environment variables collection without PORT
|
||||
$envVars = Mockery::mock(Collection::class);
|
||||
$envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn(null);
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('environment_variables')
|
||||
->andReturn($envVars);
|
||||
|
||||
$detectedPort = $application->detectPortFromEnvironment();
|
||||
|
||||
expect($detectedPort)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when PORT value is not numeric', function () {
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
|
||||
// Mock environment variables with non-numeric PORT value
|
||||
$portEnvVar = Mockery::mock(EnvironmentVariable::class);
|
||||
$portEnvVar->shouldReceive('getAttribute')->with('real_value')->andReturn('invalid-port');
|
||||
|
||||
$envVars = Mockery::mock(Collection::class);
|
||||
$envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn($portEnvVar);
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('environment_variables')
|
||||
->andReturn($envVars);
|
||||
|
||||
$detectedPort = $application->detectPortFromEnvironment();
|
||||
|
||||
expect($detectedPort)->toBeNull();
|
||||
});
|
||||
|
||||
it('handles PORT value with whitespace', function () {
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
|
||||
// Mock environment variables with PORT value that has whitespace
|
||||
$portEnvVar = Mockery::mock(EnvironmentVariable::class);
|
||||
$portEnvVar->shouldReceive('getAttribute')->with('real_value')->andReturn(' 8080 ');
|
||||
|
||||
$envVars = Mockery::mock(Collection::class);
|
||||
$envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn($portEnvVar);
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('environment_variables')
|
||||
->andReturn($envVars);
|
||||
|
||||
$detectedPort = $application->detectPortFromEnvironment();
|
||||
|
||||
expect($detectedPort)->toBe(8080);
|
||||
});
|
||||
|
||||
it('detects PORT from preview environment variables when isPreview is true', function () {
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
|
||||
// Mock preview environment variables with PORT
|
||||
$portEnvVar = Mockery::mock(EnvironmentVariable::class);
|
||||
$portEnvVar->shouldReceive('getAttribute')->with('real_value')->andReturn('4000');
|
||||
|
||||
$envVars = Mockery::mock(Collection::class);
|
||||
$envVars->shouldReceive('firstWhere')->with('key', 'PORT')->andReturn($portEnvVar);
|
||||
$application->shouldReceive('getAttribute')
|
||||
->with('environment_variables_preview')
|
||||
->andReturn($envVars);
|
||||
|
||||
$detectedPort = $application->detectPortFromEnvironment(true);
|
||||
|
||||
expect($detectedPort)->toBe(4000);
|
||||
});
|
||||
|
||||
it('verifies ports_exposes array conversion logic', function () {
|
||||
// Test the logic that converts comma-separated ports to array
|
||||
$portsExposesString = '3000,3001,8080';
|
||||
$expectedArray = [3000, 3001, 8080];
|
||||
|
||||
// This simulates what portsExposesArray accessor does
|
||||
$result = is_null($portsExposesString)
|
||||
? []
|
||||
: explode(',', $portsExposesString);
|
||||
|
||||
// Convert to integers for comparison
|
||||
$result = array_map('intval', $result);
|
||||
|
||||
expect($result)->toBe($expectedArray);
|
||||
});
|
||||
|
||||
it('verifies PORT matches detection logic', function () {
|
||||
$detectedPort = 3000;
|
||||
$portsExposesArray = [3000, 3001];
|
||||
|
||||
$isMatch = in_array($detectedPort, $portsExposesArray);
|
||||
|
||||
expect($isMatch)->toBeTrue();
|
||||
});
|
||||
|
||||
it('verifies PORT mismatch detection logic', function () {
|
||||
$detectedPort = 8080;
|
||||
$portsExposesArray = [3000, 3001];
|
||||
|
||||
$isMatch = in_array($detectedPort, $portsExposesArray);
|
||||
|
||||
expect($isMatch)->toBeFalse();
|
||||
});
|
||||
|
||||
it('verifies empty ports_exposes detection logic', function () {
|
||||
$portsExposesArray = [];
|
||||
|
||||
$isEmpty = empty($portsExposesArray);
|
||||
|
||||
expect($isEmpty)->toBeTrue();
|
||||
});
|
||||
71
tests/Unit/DeploymentExceptionTest.php
Normal file
71
tests/Unit/DeploymentExceptionTest.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Exceptions\Handler;
|
||||
|
||||
test('DeploymentException is in the dontReport array', function () {
|
||||
$handler = new Handler(app());
|
||||
|
||||
// Use reflection to access the protected $dontReport property
|
||||
$reflection = new ReflectionClass($handler);
|
||||
$property = $reflection->getProperty('dontReport');
|
||||
$property->setAccessible(true);
|
||||
$dontReport = $property->getValue($handler);
|
||||
|
||||
expect($dontReport)->toContain(DeploymentException::class);
|
||||
});
|
||||
|
||||
test('DeploymentException can be created with a message', function () {
|
||||
$exception = new DeploymentException('Test deployment error');
|
||||
|
||||
expect($exception->getMessage())->toBe('Test deployment error');
|
||||
expect($exception)->toBeInstanceOf(Exception::class);
|
||||
});
|
||||
|
||||
test('DeploymentException can be created with a message and code', function () {
|
||||
$exception = new DeploymentException('Test error', 69420);
|
||||
|
||||
expect($exception->getMessage())->toBe('Test error');
|
||||
expect($exception->getCode())->toBe(69420);
|
||||
});
|
||||
|
||||
test('DeploymentException can be created from another exception', function () {
|
||||
$originalException = new RuntimeException('Original error', 500);
|
||||
$deploymentException = DeploymentException::fromException($originalException);
|
||||
|
||||
expect($deploymentException->getMessage())->toBe('Original error');
|
||||
expect($deploymentException->getCode())->toBe(500);
|
||||
expect($deploymentException->getPrevious())->toBe($originalException);
|
||||
});
|
||||
|
||||
test('DeploymentException is not reported when thrown', function () {
|
||||
$handler = new Handler(app());
|
||||
|
||||
// DeploymentException should not be reported (logged)
|
||||
$exception = new DeploymentException('Test deployment failure');
|
||||
|
||||
// Check that the exception should not be reported
|
||||
$reflection = new ReflectionClass($handler);
|
||||
$method = $reflection->getMethod('shouldReport');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$shouldReport = $method->invoke($handler, $exception);
|
||||
|
||||
expect($shouldReport)->toBeFalse();
|
||||
});
|
||||
|
||||
test('RuntimeException is still reported when thrown', function () {
|
||||
$handler = new Handler(app());
|
||||
|
||||
// RuntimeException should still be reported (this is for Coolify bugs)
|
||||
$exception = new RuntimeException('Unexpected error in Coolify code');
|
||||
|
||||
// Check that the exception should be reported
|
||||
$reflection = new ReflectionClass($handler);
|
||||
$method = $reflection->getMethod('shouldReport');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$shouldReport = $method->invoke($handler, $exception);
|
||||
|
||||
expect($shouldReport)->toBeTrue();
|
||||
});
|
||||
186
tests/Unit/Notifications/Channels/EmailChannelTest.php
Normal file
186
tests/Unit/Notifications/Channels/EmailChannelTest.php
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\NonReportableException;
|
||||
use App\Models\EmailNotificationSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Channels\EmailChannel;
|
||||
use App\Notifications\Channels\SendsEmail;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Resend\Exceptions\ErrorException;
|
||||
use Resend\Exceptions\TransporterException;
|
||||
|
||||
beforeEach(function () {
|
||||
// Mock the Team with members
|
||||
$this->team = Mockery::mock(Team::class);
|
||||
$this->team->id = 1;
|
||||
|
||||
$user1 = new User(['email' => 'test@example.com']);
|
||||
$user2 = new User(['email' => 'admin@example.com']);
|
||||
$members = collect([$user1, $user2]);
|
||||
$this->team->shouldReceive('getAttribute')->with('members')->andReturn($members);
|
||||
Team::shouldReceive('find')->with(1)->andReturn($this->team);
|
||||
|
||||
// Mock the notifiable (Team)
|
||||
$this->notifiable = Mockery::mock(SendsEmail::class);
|
||||
$this->notifiable->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
|
||||
// Mock email settings with Resend enabled
|
||||
$this->settings = Mockery::mock(EmailNotificationSettings::class);
|
||||
$this->settings->resend_enabled = true;
|
||||
$this->settings->smtp_enabled = false;
|
||||
$this->settings->use_instance_email_settings = false;
|
||||
$this->settings->smtp_from_name = 'Test Sender';
|
||||
$this->settings->smtp_from_address = 'sender@example.com';
|
||||
$this->settings->resend_api_key = 'test_api_key';
|
||||
$this->settings->smtp_password = 'password';
|
||||
|
||||
$this->notifiable->shouldReceive('getAttribute')->with('emailNotificationSettings')->andReturn($this->settings);
|
||||
$this->notifiable->emailNotificationSettings = $this->settings;
|
||||
$this->notifiable->shouldReceive('getRecipients')->andReturn(['test@example.com']);
|
||||
|
||||
// Mock the notification
|
||||
$this->notification = Mockery::mock(Notification::class);
|
||||
$this->notification->shouldReceive('getAttribute')->with('isTransactionalEmail')->andReturn(false);
|
||||
$this->notification->shouldReceive('getAttribute')->with('emails')->andReturn(null);
|
||||
|
||||
$mailMessage = Mockery::mock(MailMessage::class);
|
||||
$mailMessage->subject = 'Test Email';
|
||||
$mailMessage->shouldReceive('render')->andReturn('<html>Test</html>');
|
||||
|
||||
$this->notification->shouldReceive('toMail')->andReturn($mailMessage);
|
||||
|
||||
// Mock global functions
|
||||
$this->app->instance('send_internal_notification', function () {});
|
||||
});
|
||||
|
||||
it('throws user-friendly error for invalid Resend API key (403)', function () {
|
||||
// Create mock ErrorException for invalid API key
|
||||
$resendError = Mockery::mock(ErrorException::class);
|
||||
$resendError->shouldReceive('getErrorCode')->andReturn(403);
|
||||
$resendError->shouldReceive('getErrorMessage')->andReturn('API key is invalid.');
|
||||
$resendError->shouldReceive('getCode')->andReturn(403);
|
||||
|
||||
// Mock Resend client to throw the error
|
||||
$resendClient = Mockery::mock();
|
||||
$emailsService = Mockery::mock();
|
||||
$emailsService->shouldReceive('send')->andThrow($resendError);
|
||||
$resendClient->emails = $emailsService;
|
||||
|
||||
Resend::shouldReceive('client')->andReturn($resendClient);
|
||||
|
||||
$channel = new EmailChannel;
|
||||
|
||||
expect(fn () => $channel->send($this->notifiable, $this->notification))
|
||||
->toThrow(
|
||||
NonReportableException::class,
|
||||
'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws user-friendly error for restricted Resend API key (401)', function () {
|
||||
// Create mock ErrorException for restricted key
|
||||
$resendError = Mockery::mock(ErrorException::class);
|
||||
$resendError->shouldReceive('getErrorCode')->andReturn(401);
|
||||
$resendError->shouldReceive('getErrorMessage')->andReturn('This API key is restricted to only send emails.');
|
||||
$resendError->shouldReceive('getCode')->andReturn(401);
|
||||
|
||||
// Mock Resend client to throw the error
|
||||
$resendClient = Mockery::mock();
|
||||
$emailsService = Mockery::mock();
|
||||
$emailsService->shouldReceive('send')->andThrow($resendError);
|
||||
$resendClient->emails = $emailsService;
|
||||
|
||||
Resend::shouldReceive('client')->andReturn($resendClient);
|
||||
|
||||
$channel = new EmailChannel;
|
||||
|
||||
expect(fn () => $channel->send($this->notifiable, $this->notification))
|
||||
->toThrow(
|
||||
NonReportableException::class,
|
||||
'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws user-friendly error for rate limiting (429)', function () {
|
||||
// Create mock ErrorException for rate limit
|
||||
$resendError = Mockery::mock(ErrorException::class);
|
||||
$resendError->shouldReceive('getErrorCode')->andReturn(429);
|
||||
$resendError->shouldReceive('getErrorMessage')->andReturn('Too many requests.');
|
||||
$resendError->shouldReceive('getCode')->andReturn(429);
|
||||
|
||||
// Mock Resend client to throw the error
|
||||
$resendClient = Mockery::mock();
|
||||
$emailsService = Mockery::mock();
|
||||
$emailsService->shouldReceive('send')->andThrow($resendError);
|
||||
$resendClient->emails = $emailsService;
|
||||
|
||||
Resend::shouldReceive('client')->andReturn($resendClient);
|
||||
|
||||
$channel = new EmailChannel;
|
||||
|
||||
expect(fn () => $channel->send($this->notifiable, $this->notification))
|
||||
->toThrow(Exception::class, 'Resend rate limit exceeded. Please try again in a few minutes.');
|
||||
});
|
||||
|
||||
it('throws user-friendly error for validation errors (400)', function () {
|
||||
// Create mock ErrorException for validation error
|
||||
$resendError = Mockery::mock(ErrorException::class);
|
||||
$resendError->shouldReceive('getErrorCode')->andReturn(400);
|
||||
$resendError->shouldReceive('getErrorMessage')->andReturn('Invalid email format.');
|
||||
$resendError->shouldReceive('getCode')->andReturn(400);
|
||||
|
||||
// Mock Resend client to throw the error
|
||||
$resendClient = Mockery::mock();
|
||||
$emailsService = Mockery::mock();
|
||||
$emailsService->shouldReceive('send')->andThrow($resendError);
|
||||
$resendClient->emails = $emailsService;
|
||||
|
||||
Resend::shouldReceive('client')->andReturn($resendClient);
|
||||
|
||||
$channel = new EmailChannel;
|
||||
|
||||
expect(fn () => $channel->send($this->notifiable, $this->notification))
|
||||
->toThrow(NonReportableException::class, 'Email validation failed: Invalid email format.');
|
||||
});
|
||||
|
||||
it('throws user-friendly error for network/transport errors', function () {
|
||||
// Create mock TransporterException
|
||||
$transportError = Mockery::mock(TransporterException::class);
|
||||
$transportError->shouldReceive('getMessage')->andReturn('Network error');
|
||||
|
||||
// Mock Resend client to throw the error
|
||||
$resendClient = Mockery::mock();
|
||||
$emailsService = Mockery::mock();
|
||||
$emailsService->shouldReceive('send')->andThrow($transportError);
|
||||
$resendClient->emails = $emailsService;
|
||||
|
||||
Resend::shouldReceive('client')->andReturn($resendClient);
|
||||
|
||||
$channel = new EmailChannel;
|
||||
|
||||
expect(fn () => $channel->send($this->notifiable, $this->notification))
|
||||
->toThrow(Exception::class, 'Unable to connect to Resend API. Please check your internet connection and try again.');
|
||||
});
|
||||
|
||||
it('throws generic error with message for unknown error codes', function () {
|
||||
// Create mock ErrorException with unknown code
|
||||
$resendError = Mockery::mock(ErrorException::class);
|
||||
$resendError->shouldReceive('getErrorCode')->andReturn(500);
|
||||
$resendError->shouldReceive('getErrorMessage')->andReturn('Internal server error.');
|
||||
$resendError->shouldReceive('getCode')->andReturn(500);
|
||||
|
||||
// Mock Resend client to throw the error
|
||||
$resendClient = Mockery::mock();
|
||||
$emailsService = Mockery::mock();
|
||||
$emailsService->shouldReceive('send')->andThrow($resendError);
|
||||
$resendClient->emails = $emailsService;
|
||||
|
||||
Resend::shouldReceive('client')->andReturn($resendClient);
|
||||
|
||||
$channel = new EmailChannel;
|
||||
|
||||
expect(fn () => $channel->send($this->notifiable, $this->notification))
|
||||
->toThrow(Exception::class, 'Failed to send email via Resend: Internal server error.');
|
||||
});
|
||||
310
tests/Unit/ParseCommandsByLineForSudoTest.php
Normal file
310
tests/Unit/ParseCommandsByLineForSudoTest.php
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create a mock server with non-root user
|
||||
$this->server = Mockery::mock(Server::class)->makePartial();
|
||||
$this->server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
|
||||
$this->server->shouldReceive('setAttribute')->andReturnSelf();
|
||||
$this->server->user = 'ubuntu';
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
test('wraps complex Docker install command with pipes in bash -c', function () {
|
||||
$commands = collect([
|
||||
'curl https://releases.rancher.com/install-docker/27.3.sh | sh || curl https://get.docker.com | sh',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe("sudo bash -c 'curl https://releases.rancher.com/install-docker/27.3.sh | sh || curl https://get.docker.com | sh'");
|
||||
});
|
||||
|
||||
test('wraps complex Docker install command with multiple fallbacks', function () {
|
||||
$commands = collect([
|
||||
'curl --max-time 300 https://releases.rancher.com/install-docker/27.3.sh | sh || curl https://get.docker.com | sh -s -- --version 27.3',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe("sudo bash -c 'curl --max-time 300 https://releases.rancher.com/install-docker/27.3.sh | sh || curl https://get.docker.com | sh -s -- --version 27.3'");
|
||||
});
|
||||
|
||||
test('wraps command with pipe to bash in bash -c', function () {
|
||||
$commands = collect([
|
||||
'curl https://example.com/script.sh | bash',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe("sudo bash -c 'curl https://example.com/script.sh | bash'");
|
||||
});
|
||||
|
||||
test('wraps complex command with pipes and && operators', function () {
|
||||
$commands = collect([
|
||||
'curl https://example.com | sh && echo "done"',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe("sudo bash -c 'curl https://example.com | sh && echo \"done\"'");
|
||||
});
|
||||
|
||||
test('escapes single quotes in complex piped commands', function () {
|
||||
$commands = collect([
|
||||
"curl https://example.com | sh -c 'echo \"test\"'",
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe("sudo bash -c 'curl https://example.com | sh -c '\\''echo \"test\"'\\'''");
|
||||
});
|
||||
|
||||
test('handles simple command without pipes or operators', function () {
|
||||
$commands = collect([
|
||||
'apt-get update',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe('sudo apt-get update');
|
||||
});
|
||||
|
||||
test('handles command with double ampersand operator but no pipes', function () {
|
||||
$commands = collect([
|
||||
'mkdir -p /foo && chown ubuntu /foo',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe('sudo mkdir -p /foo && sudo chown ubuntu /foo');
|
||||
});
|
||||
|
||||
test('handles command with double pipe operator but no pipes', function () {
|
||||
$commands = collect([
|
||||
'command -v docker || echo "not found"',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
// 'command' is exempted from sudo, but echo gets sudo after ||
|
||||
expect($result[0])->toBe('command -v docker || sudo echo "not found"');
|
||||
});
|
||||
|
||||
test('handles command with simple pipe but no operators', function () {
|
||||
$commands = collect([
|
||||
'cat file | grep pattern',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe('sudo cat file | sudo grep pattern');
|
||||
});
|
||||
|
||||
test('handles command with subshell $(...)', function () {
|
||||
$commands = collect([
|
||||
'echo $(whoami)',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
// 'echo' is exempted from sudo at the start
|
||||
expect($result[0])->toBe('echo $(sudo whoami)');
|
||||
});
|
||||
|
||||
test('skips sudo for cd commands', function () {
|
||||
$commands = collect([
|
||||
'cd /var/www',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe('cd /var/www');
|
||||
});
|
||||
|
||||
test('skips sudo for echo commands', function () {
|
||||
$commands = collect([
|
||||
'echo "test"',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe('echo "test"');
|
||||
});
|
||||
|
||||
test('skips sudo for command commands', function () {
|
||||
$commands = collect([
|
||||
'command -v docker',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe('command -v docker');
|
||||
});
|
||||
|
||||
test('skips sudo for true commands', function () {
|
||||
$commands = collect([
|
||||
'true',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe('true');
|
||||
});
|
||||
|
||||
test('handles if statements by adding sudo to condition', function () {
|
||||
$commands = collect([
|
||||
'if command -v docker',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe('if sudo command -v docker');
|
||||
});
|
||||
|
||||
test('skips sudo for fi statements', function () {
|
||||
$commands = collect([
|
||||
'fi',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe('fi');
|
||||
});
|
||||
|
||||
test('adds ownership changes for Coolify data paths', function () {
|
||||
$commands = collect([
|
||||
'mkdir -p /data/coolify/logs',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
// Note: The && operator adds another sudo, creating double sudo for chown/chmod
|
||||
// This is existing behavior that may need refactoring but isn't part of this bug fix
|
||||
expect($result[0])->toBe('sudo mkdir -p /data/coolify/logs && sudo sudo chown -R ubuntu:ubuntu /data/coolify/logs && sudo sudo chmod -R o-rwx /data/coolify/logs');
|
||||
});
|
||||
|
||||
test('adds ownership changes for Coolify tmp paths', function () {
|
||||
$commands = collect([
|
||||
'mkdir -p /tmp/coolify/cache',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
// Note: The && operator adds another sudo, creating double sudo for chown/chmod
|
||||
// This is existing behavior that may need refactoring but isn't part of this bug fix
|
||||
expect($result[0])->toBe('sudo mkdir -p /tmp/coolify/cache && sudo sudo chown -R ubuntu:ubuntu /tmp/coolify/cache && sudo sudo chmod -R o-rwx /tmp/coolify/cache');
|
||||
});
|
||||
|
||||
test('does not add ownership changes for system paths', function () {
|
||||
$commands = collect([
|
||||
'mkdir -p /var/log',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe('sudo mkdir -p /var/log');
|
||||
});
|
||||
|
||||
test('handles multiple commands in sequence', function () {
|
||||
$commands = collect([
|
||||
'apt-get update',
|
||||
'apt-get install -y docker',
|
||||
'curl https://get.docker.com | sh',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result)->toHaveCount(3);
|
||||
expect($result[0])->toBe('sudo apt-get update');
|
||||
expect($result[1])->toBe('sudo apt-get install -y docker');
|
||||
expect($result[2])->toBe("sudo bash -c 'curl https://get.docker.com | sh'");
|
||||
});
|
||||
|
||||
test('handles empty command list', function () {
|
||||
$commands = collect([]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result)->toBeArray();
|
||||
expect($result)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('handles real-world Docker installation command from InstallDocker action', function () {
|
||||
$version = '27.3';
|
||||
$commands = collect([
|
||||
"curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$version}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$version}",
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toStartWith("sudo bash -c '");
|
||||
expect($result[0])->toEndWith("'");
|
||||
expect($result[0])->toContain('curl --max-time 300');
|
||||
expect($result[0])->toContain('| sh');
|
||||
expect($result[0])->toContain('||');
|
||||
expect($result[0])->not->toContain('| sudo sh');
|
||||
});
|
||||
|
||||
test('preserves command structure in wrapped bash -c', function () {
|
||||
$commands = collect([
|
||||
'curl https://example.com | sh || curl https://backup.com | sh',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
// The command should be wrapped without breaking the pipe and fallback structure
|
||||
expect($result[0])->toBe("sudo bash -c 'curl https://example.com | sh || curl https://backup.com | sh'");
|
||||
|
||||
// Verify it doesn't contain broken patterns like "| sudo sh"
|
||||
expect($result[0])->not->toContain('| sudo sh');
|
||||
expect($result[0])->not->toContain('|| sudo curl');
|
||||
});
|
||||
|
||||
test('handles command with mixed operators and subshells', function () {
|
||||
$commands = collect([
|
||||
'docker ps || echo $(date)',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
// This should use the original logic since it's not a complex pipe command
|
||||
expect($result[0])->toBe('sudo docker ps || sudo echo $(sudo date)');
|
||||
});
|
||||
|
||||
test('handles whitespace-only commands gracefully', function () {
|
||||
$commands = collect([
|
||||
' ',
|
||||
'',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result)->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('detects pipe to sh with additional arguments', function () {
|
||||
$commands = collect([
|
||||
'curl https://example.com | sh -s -- --arg1 --arg2',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toBe("sudo bash -c 'curl https://example.com | sh -s -- --arg1 --arg2'");
|
||||
});
|
||||
|
||||
test('handles command chains with both && and || operators with pipes', function () {
|
||||
$commands = collect([
|
||||
'curl https://first.com | sh && echo "success" || curl https://backup.com | sh',
|
||||
]);
|
||||
|
||||
$result = parseCommandsByLineForSudo($commands, $this->server);
|
||||
|
||||
expect($result[0])->toStartWith("sudo bash -c '");
|
||||
expect($result[0])->toEndWith("'");
|
||||
expect($result[0])->not->toContain('| sudo');
|
||||
});
|
||||
82
tests/Unit/RestartCountTrackingTest.php
Normal file
82
tests/Unit/RestartCountTrackingTest.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
|
||||
beforeEach(function () {
|
||||
// Mock server
|
||||
$this->server = Mockery::mock(Server::class);
|
||||
$this->server->shouldReceive('isFunctional')->andReturn(true);
|
||||
$this->server->shouldReceive('isSwarm')->andReturn(false);
|
||||
$this->server->shouldReceive('applications')->andReturn(collect());
|
||||
|
||||
// Mock application
|
||||
$this->application = Mockery::mock(Application::class);
|
||||
$this->application->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
$this->application->shouldReceive('getAttribute')->with('name')->andReturn('test-app');
|
||||
$this->application->shouldReceive('getAttribute')->with('restart_count')->andReturn(0);
|
||||
$this->application->shouldReceive('getAttribute')->with('uuid')->andReturn('test-uuid');
|
||||
$this->application->shouldReceive('getAttribute')->with('environment')->andReturn(null);
|
||||
});
|
||||
|
||||
it('extracts restart count from container data', function () {
|
||||
$containerData = [
|
||||
'RestartCount' => 5,
|
||||
'State' => [
|
||||
'Status' => 'running',
|
||||
'Health' => ['Status' => 'healthy'],
|
||||
],
|
||||
'Config' => [
|
||||
'Labels' => [
|
||||
'coolify.applicationId' => '1',
|
||||
'com.docker.compose.service' => 'web',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$restartCount = data_get($containerData, 'RestartCount', 0);
|
||||
|
||||
expect($restartCount)->toBe(5);
|
||||
});
|
||||
|
||||
it('defaults to zero when restart count is missing', function () {
|
||||
$containerData = [
|
||||
'State' => [
|
||||
'Status' => 'running',
|
||||
],
|
||||
'Config' => [
|
||||
'Labels' => [],
|
||||
],
|
||||
];
|
||||
|
||||
$restartCount = data_get($containerData, 'RestartCount', 0);
|
||||
|
||||
expect($restartCount)->toBe(0);
|
||||
});
|
||||
|
||||
it('detects restart count increase', function () {
|
||||
$previousRestartCount = 2;
|
||||
$currentRestartCount = 5;
|
||||
|
||||
expect($currentRestartCount)->toBeGreaterThan($previousRestartCount);
|
||||
});
|
||||
|
||||
it('identifies maximum restart count from multiple containers', function () {
|
||||
$containerRestartCounts = collect([
|
||||
'web' => 3,
|
||||
'worker' => 5,
|
||||
'scheduler' => 1,
|
||||
]);
|
||||
|
||||
$maxRestartCount = $containerRestartCounts->max();
|
||||
|
||||
expect($maxRestartCount)->toBe(5);
|
||||
});
|
||||
|
||||
it('handles empty restart counts collection', function () {
|
||||
$containerRestartCounts = collect([]);
|
||||
|
||||
$maxRestartCount = $containerRestartCounts->max() ?? 0;
|
||||
|
||||
expect($maxRestartCount)->toBe(0);
|
||||
});
|
||||
77
tests/Unit/ScheduledJobsRetryConfigTest.php
Normal file
77
tests/Unit/ScheduledJobsRetryConfigTest.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\CoolifyTask;
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
|
||||
it('CoolifyTask has correct retry properties defined', function () {
|
||||
$reflection = new ReflectionClass(CoolifyTask::class);
|
||||
|
||||
// Check public properties exist
|
||||
expect($reflection->hasProperty('tries'))->toBeTrue()
|
||||
->and($reflection->hasProperty('maxExceptions'))->toBeTrue()
|
||||
->and($reflection->hasProperty('timeout'))->toBeTrue()
|
||||
->and($reflection->hasMethod('backoff'))->toBeTrue();
|
||||
|
||||
// Get default values from class definition
|
||||
$defaultProperties = $reflection->getDefaultProperties();
|
||||
|
||||
expect($defaultProperties['tries'])->toBe(3)
|
||||
->and($defaultProperties['maxExceptions'])->toBe(1)
|
||||
->and($defaultProperties['timeout'])->toBe(600);
|
||||
});
|
||||
|
||||
it('ScheduledTaskJob has correct retry properties defined', function () {
|
||||
$reflection = new ReflectionClass(ScheduledTaskJob::class);
|
||||
|
||||
// Check public properties exist
|
||||
expect($reflection->hasProperty('tries'))->toBeTrue()
|
||||
->and($reflection->hasProperty('maxExceptions'))->toBeTrue()
|
||||
->and($reflection->hasProperty('timeout'))->toBeTrue()
|
||||
->and($reflection->hasMethod('backoff'))->toBeTrue()
|
||||
->and($reflection->hasMethod('failed'))->toBeTrue();
|
||||
|
||||
// Get default values from class definition
|
||||
$defaultProperties = $reflection->getDefaultProperties();
|
||||
|
||||
expect($defaultProperties['tries'])->toBe(3)
|
||||
->and($defaultProperties['maxExceptions'])->toBe(1)
|
||||
->and($defaultProperties['timeout'])->toBe(300);
|
||||
});
|
||||
|
||||
it('DatabaseBackupJob has correct retry properties defined', function () {
|
||||
$reflection = new ReflectionClass(DatabaseBackupJob::class);
|
||||
|
||||
// Check public properties exist
|
||||
expect($reflection->hasProperty('tries'))->toBeTrue()
|
||||
->and($reflection->hasProperty('maxExceptions'))->toBeTrue()
|
||||
->and($reflection->hasProperty('timeout'))->toBeTrue()
|
||||
->and($reflection->hasMethod('backoff'))->toBeTrue()
|
||||
->and($reflection->hasMethod('failed'))->toBeTrue();
|
||||
|
||||
// Get default values from class definition
|
||||
$defaultProperties = $reflection->getDefaultProperties();
|
||||
|
||||
expect($defaultProperties['tries'])->toBe(2)
|
||||
->and($defaultProperties['maxExceptions'])->toBe(1)
|
||||
->and($defaultProperties['timeout'])->toBe(3600);
|
||||
});
|
||||
|
||||
it('DatabaseBackupJob enforces minimum timeout of 60 seconds', function () {
|
||||
// Read the constructor to verify minimum timeout enforcement
|
||||
$reflection = new ReflectionClass(DatabaseBackupJob::class);
|
||||
$constructor = $reflection->getMethod('__construct');
|
||||
|
||||
// Get the constructor source
|
||||
$filename = $reflection->getFileName();
|
||||
$startLine = $constructor->getStartLine();
|
||||
$endLine = $constructor->getEndLine();
|
||||
|
||||
$source = file($filename);
|
||||
$constructorSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
|
||||
|
||||
// Verify the implementation enforces minimum of 60 seconds
|
||||
expect($constructorSource)
|
||||
->toContain('max(')
|
||||
->toContain('60');
|
||||
});
|
||||
96
tests/Unit/ScheduledTaskJobTimeoutTest.php
Normal file
96
tests/Unit/ScheduledTaskJobTimeoutTest.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
beforeEach(function () {
|
||||
// Mock Log facade to prevent actual logging during tests
|
||||
Log::spy();
|
||||
});
|
||||
|
||||
it('has executionId property for timeout handling', function () {
|
||||
$reflection = new ReflectionClass(ScheduledTaskJob::class);
|
||||
|
||||
// Verify executionId property exists
|
||||
expect($reflection->hasProperty('executionId'))->toBeTrue();
|
||||
|
||||
// Verify it's protected (will be serialized with the job)
|
||||
$property = $reflection->getProperty('executionId');
|
||||
expect($property->isProtected())->toBeTrue();
|
||||
});
|
||||
|
||||
it('has failed method that handles job failures', function () {
|
||||
$reflection = new ReflectionClass(ScheduledTaskJob::class);
|
||||
|
||||
// Verify failed() method exists
|
||||
expect($reflection->hasMethod('failed'))->toBeTrue();
|
||||
|
||||
// Verify it accepts a Throwable parameter
|
||||
$method = $reflection->getMethod('failed');
|
||||
$parameters = $method->getParameters();
|
||||
|
||||
expect($parameters)->toHaveCount(1);
|
||||
expect($parameters[0]->getName())->toBe('exception');
|
||||
expect($parameters[0]->allowsNull())->toBeTrue();
|
||||
});
|
||||
|
||||
it('failed method implementation reloads execution from database', function () {
|
||||
// Read the failed() method source code to verify it reloads from database
|
||||
$reflection = new ReflectionClass(ScheduledTaskJob::class);
|
||||
$method = $reflection->getMethod('failed');
|
||||
|
||||
// Get the file and method source
|
||||
$filename = $reflection->getFileName();
|
||||
$startLine = $method->getStartLine();
|
||||
$endLine = $method->getEndLine();
|
||||
|
||||
$source = file($filename);
|
||||
$methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
|
||||
|
||||
// Verify the implementation includes reloading from database
|
||||
expect($methodSource)
|
||||
->toContain('$this->executionId')
|
||||
->toContain('ScheduledTaskExecution::find')
|
||||
->toContain('ScheduledTaskExecution::query')
|
||||
->toContain('scheduled_task_id')
|
||||
->toContain('orderBy')
|
||||
->toContain('status')
|
||||
->toContain('failed')
|
||||
->toContain('notify');
|
||||
});
|
||||
|
||||
it('failed method updates execution with error_details field', function () {
|
||||
// Read the failed() method source code to verify error_details is populated
|
||||
$reflection = new ReflectionClass(ScheduledTaskJob::class);
|
||||
$method = $reflection->getMethod('failed');
|
||||
|
||||
// Get the file and method source
|
||||
$filename = $reflection->getFileName();
|
||||
$startLine = $method->getStartLine();
|
||||
$endLine = $method->getEndLine();
|
||||
|
||||
$source = file($filename);
|
||||
$methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
|
||||
|
||||
// Verify the implementation populates error_details field
|
||||
expect($methodSource)->toContain('error_details');
|
||||
});
|
||||
|
||||
it('failed method logs when execution cannot be found', function () {
|
||||
// Read the failed() method source code to verify defensive logging
|
||||
$reflection = new ReflectionClass(ScheduledTaskJob::class);
|
||||
$method = $reflection->getMethod('failed');
|
||||
|
||||
// Get the file and method source
|
||||
$filename = $reflection->getFileName();
|
||||
$startLine = $method->getStartLine();
|
||||
$endLine = $method->getEndLine();
|
||||
|
||||
$source = file($filename);
|
||||
$methodSource = implode('', array_slice($source, $startLine - 1, $endLine - $startLine + 1));
|
||||
|
||||
// Verify the implementation logs a warning if execution is not found
|
||||
expect($methodSource)
|
||||
->toContain('Could not find execution log')
|
||||
->toContain('warning');
|
||||
});
|
||||
158
tests/Unit/ServiceParserPortDetectionLogicTest.php
Normal file
158
tests/Unit/ServiceParserPortDetectionLogicTest.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify the parser logic for detecting port-specific SERVICE variables.
|
||||
* These tests simulate the logic used in bootstrap/helpers/parsers.php without database operations.
|
||||
*
|
||||
* The parser should detect when a SERVICE_URL_* or SERVICE_FQDN_* variable has a numeric
|
||||
* port suffix and extract both the service name and port correctly.
|
||||
*/
|
||||
it('detects port suffix using numeric check (correct logic)', function () {
|
||||
// This tests the CORRECT logic: check if last segment is numeric
|
||||
$testCases = [
|
||||
// [variable_name, expected_service_name, expected_port, is_port_specific]
|
||||
|
||||
// 2-underscore pattern: SERVICE_URL_{SERVICE}_{PORT}
|
||||
['SERVICE_URL_MYAPP_3000', 'myapp', '3000', true],
|
||||
['SERVICE_URL_REDIS_6379', 'redis', '6379', true],
|
||||
['SERVICE_FQDN_NGINX_80', 'nginx', '80', true],
|
||||
|
||||
// 3-underscore pattern: SERVICE_URL_{SERVICE}_{NAME}_{PORT}
|
||||
['SERVICE_URL_MY_API_8080', 'my_api', '8080', true],
|
||||
['SERVICE_URL_WEB_APP_3000', 'web_app', '3000', true],
|
||||
['SERVICE_FQDN_DB_SERVER_5432', 'db_server', '5432', true],
|
||||
|
||||
// 4-underscore pattern: SERVICE_URL_{SERVICE}_{NAME}_{OTHER}_{PORT}
|
||||
['SERVICE_URL_REDIS_CACHE_SERVER_6379', 'redis_cache_server', '6379', true],
|
||||
['SERVICE_URL_MY_LONG_APP_8080', 'my_long_app', '8080', true],
|
||||
['SERVICE_FQDN_POSTGRES_PRIMARY_DB_5432', 'postgres_primary_db', '5432', true],
|
||||
|
||||
// Non-numeric suffix: should NOT be port-specific
|
||||
['SERVICE_URL_MY_APP', 'my_app', null, false],
|
||||
['SERVICE_URL_REDIS_PRIMARY', 'redis_primary', null, false],
|
||||
['SERVICE_FQDN_WEB_SERVER', 'web_server', null, false],
|
||||
['SERVICE_URL_APP_CACHE_REDIS', 'app_cache_redis', null, false],
|
||||
|
||||
// Single word without port
|
||||
['SERVICE_URL_APP', 'app', null, false],
|
||||
['SERVICE_FQDN_DB', 'db', null, false],
|
||||
|
||||
// Edge cases with numbers in service name
|
||||
['SERVICE_URL_REDIS2_MASTER', 'redis2_master', null, false],
|
||||
['SERVICE_URL_WEB3_APP', 'web3_app', null, false],
|
||||
];
|
||||
|
||||
foreach ($testCases as [$varName, $expectedService, $expectedPort, $isPortSpecific]) {
|
||||
// Use the actual helper function from bootstrap/helpers/services.php
|
||||
$parsed = parseServiceEnvironmentVariable($varName);
|
||||
|
||||
// Assertions
|
||||
expect($parsed['service_name'])->toBe($expectedService, "Service name mismatch for $varName");
|
||||
expect($parsed['port'])->toBe($expectedPort, "Port mismatch for $varName");
|
||||
expect($parsed['has_port'])->toBe($isPortSpecific, "Port detection mismatch for $varName");
|
||||
}
|
||||
});
|
||||
|
||||
it('shows current underscore-counting logic fails for some patterns', function () {
|
||||
// This demonstrates the CURRENT BROKEN logic: substr_count === 3
|
||||
|
||||
$testCases = [
|
||||
// [variable_name, underscore_count, should_detect_port]
|
||||
|
||||
// Works correctly with current logic (3 underscores total)
|
||||
['SERVICE_URL_APP_3000', 3, true], // 3 underscores ✓
|
||||
['SERVICE_URL_API_8080', 3, true], // 3 underscores ✓
|
||||
|
||||
// FAILS: 4 underscores (two-word service + port) - current logic says no port
|
||||
['SERVICE_URL_MY_API_8080', 4, true], // 4 underscores ✗
|
||||
['SERVICE_URL_WEB_APP_3000', 4, true], // 4 underscores ✗
|
||||
|
||||
// FAILS: 5+ underscores (three-word service + port) - current logic says no port
|
||||
['SERVICE_URL_REDIS_CACHE_SERVER_6379', 5, true], // 5 underscores ✗
|
||||
['SERVICE_URL_MY_LONG_APP_8080', 5, true], // 5 underscores ✗
|
||||
|
||||
// Works correctly (no port, not 3 underscores)
|
||||
['SERVICE_URL_MY_APP', 3, false], // 3 underscores but non-numeric ✓
|
||||
['SERVICE_URL_APP', 2, false], // 2 underscores ✓
|
||||
];
|
||||
|
||||
foreach ($testCases as [$varName, $expectedUnderscoreCount, $shouldDetectPort]) {
|
||||
$key = str($varName);
|
||||
|
||||
// Current logic: count underscores
|
||||
$underscoreCount = substr_count($key->value(), '_');
|
||||
expect($underscoreCount)->toBe($expectedUnderscoreCount, "Underscore count for $varName");
|
||||
|
||||
$currentLogicDetectsPort = ($underscoreCount === 3);
|
||||
|
||||
// Correct logic: check if numeric
|
||||
$lastSegment = $key->afterLast('_')->value();
|
||||
$correctLogicDetectsPort = is_numeric($lastSegment);
|
||||
|
||||
expect($correctLogicDetectsPort)->toBe($shouldDetectPort, "Correct logic should detect port for $varName");
|
||||
|
||||
// Show the discrepancy where current logic fails
|
||||
if ($currentLogicDetectsPort !== $correctLogicDetectsPort) {
|
||||
// This is a known bug - current logic is wrong
|
||||
expect($currentLogicDetectsPort)->not->toBe($correctLogicDetectsPort, "Bug confirmed: current logic wrong for $varName");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('generates correct URL with port suffix', function () {
|
||||
// Test that URLs are correctly formatted with port appended
|
||||
|
||||
$testCases = [
|
||||
['http://umami-abc123.domain.com', '3000', 'http://umami-abc123.domain.com:3000'],
|
||||
['http://api-xyz789.domain.com', '8080', 'http://api-xyz789.domain.com:8080'],
|
||||
['https://db-server.example.com', '5432', 'https://db-server.example.com:5432'],
|
||||
['http://app.local', '80', 'http://app.local:80'],
|
||||
];
|
||||
|
||||
foreach ($testCases as [$baseUrl, $port, $expectedUrlWithPort]) {
|
||||
$urlWithPort = "$baseUrl:$port";
|
||||
expect($urlWithPort)->toBe($expectedUrlWithPort);
|
||||
}
|
||||
});
|
||||
|
||||
it('generates correct FQDN with port suffix', function () {
|
||||
// Test that FQDNs are correctly formatted with port appended
|
||||
|
||||
$testCases = [
|
||||
['umami-abc123.domain.com', '3000', 'umami-abc123.domain.com:3000'],
|
||||
['postgres-xyz789.domain.com', '5432', 'postgres-xyz789.domain.com:5432'],
|
||||
['redis-cache.example.com', '6379', 'redis-cache.example.com:6379'],
|
||||
];
|
||||
|
||||
foreach ($testCases as [$baseFqdn, $port, $expectedFqdnWithPort]) {
|
||||
$fqdnWithPort = "$baseFqdn:$port";
|
||||
expect($fqdnWithPort)->toBe($expectedFqdnWithPort);
|
||||
}
|
||||
});
|
||||
|
||||
it('correctly identifies service name with various patterns', function () {
|
||||
// Test service name extraction with different patterns
|
||||
|
||||
$testCases = [
|
||||
// After parsing, service names should preserve underscores
|
||||
['SERVICE_URL_MY_API_8080', 'my_api'],
|
||||
['SERVICE_URL_REDIS_CACHE_6379', 'redis_cache'],
|
||||
['SERVICE_URL_NEW_API_3000', 'new_api'],
|
||||
['SERVICE_FQDN_DB_SERVER_5432', 'db_server'],
|
||||
|
||||
// Single-word services
|
||||
['SERVICE_URL_UMAMI_3000', 'umami'],
|
||||
['SERVICE_URL_MYAPP_8080', 'myapp'],
|
||||
|
||||
// Without port
|
||||
['SERVICE_URL_MY_APP', 'my_app'],
|
||||
['SERVICE_URL_REDIS_PRIMARY', 'redis_primary'],
|
||||
];
|
||||
|
||||
foreach ($testCases as [$varName, $expectedServiceName]) {
|
||||
// Use the actual helper function from bootstrap/helpers/services.php
|
||||
$parsed = parseServiceEnvironmentVariable($varName);
|
||||
|
||||
expect($parsed['service_name'])->toBe($expectedServiceName, "Service name extraction for $varName");
|
||||
}
|
||||
});
|
||||
|
|
@ -172,3 +172,50 @@
|
|||
expect($extractedPort)->toBe((string) $port, "Port extraction failed for $description");
|
||||
}
|
||||
});
|
||||
|
||||
it('detects port-specific variables with numeric suffix', function () {
|
||||
// Test that variables ending with a numeric port are detected correctly
|
||||
// This tests the logic: if last segment after _ is numeric, it's a port
|
||||
|
||||
$tests = [
|
||||
// 2-underscore pattern: single-word service name + port
|
||||
'SERVICE_URL_MYAPP_3000' => ['service' => 'myapp', 'port' => '3000', 'hasPort' => true],
|
||||
'SERVICE_URL_REDIS_6379' => ['service' => 'redis', 'port' => '6379', 'hasPort' => true],
|
||||
'SERVICE_FQDN_NGINX_80' => ['service' => 'nginx', 'port' => '80', 'hasPort' => true],
|
||||
|
||||
// 3-underscore pattern: two-word service name + port
|
||||
'SERVICE_URL_MY_API_8080' => ['service' => 'my_api', 'port' => '8080', 'hasPort' => true],
|
||||
'SERVICE_URL_WEB_APP_3000' => ['service' => 'web_app', 'port' => '3000', 'hasPort' => true],
|
||||
'SERVICE_FQDN_DB_SERVER_5432' => ['service' => 'db_server', 'port' => '5432', 'hasPort' => true],
|
||||
|
||||
// 4-underscore pattern: three-word service name + port
|
||||
'SERVICE_URL_REDIS_CACHE_SERVER_6379' => ['service' => 'redis_cache_server', 'port' => '6379', 'hasPort' => true],
|
||||
'SERVICE_URL_MY_LONG_APP_8080' => ['service' => 'my_long_app', 'port' => '8080', 'hasPort' => true],
|
||||
'SERVICE_FQDN_POSTGRES_PRIMARY_DB_5432' => ['service' => 'postgres_primary_db', 'port' => '5432', 'hasPort' => true],
|
||||
|
||||
// Non-numeric suffix: should NOT be treated as port-specific
|
||||
'SERVICE_URL_MY_APP' => ['service' => 'my_app', 'port' => null, 'hasPort' => false],
|
||||
'SERVICE_URL_REDIS_PRIMARY' => ['service' => 'redis_primary', 'port' => null, 'hasPort' => false],
|
||||
'SERVICE_FQDN_WEB_SERVER' => ['service' => 'web_server', 'port' => null, 'hasPort' => false],
|
||||
'SERVICE_URL_APP_CACHE_REDIS' => ['service' => 'app_cache_redis', 'port' => null, 'hasPort' => false],
|
||||
|
||||
// Edge numeric cases
|
||||
'SERVICE_URL_APP_0' => ['service' => 'app', 'port' => '0', 'hasPort' => true], // Port 0
|
||||
'SERVICE_URL_APP_99999' => ['service' => 'app', 'port' => '99999', 'hasPort' => true], // Port out of range
|
||||
'SERVICE_URL_APP_3.14' => ['service' => 'app_3.14', 'port' => null, 'hasPort' => false], // Float (should not be port)
|
||||
'SERVICE_URL_APP_1e5' => ['service' => 'app_1e5', 'port' => null, 'hasPort' => false], // Scientific notation
|
||||
|
||||
// Edge cases
|
||||
'SERVICE_URL_APP' => ['service' => 'app', 'port' => null, 'hasPort' => false],
|
||||
'SERVICE_FQDN_DB' => ['service' => 'db', 'port' => null, 'hasPort' => false],
|
||||
];
|
||||
|
||||
foreach ($tests as $varName => $expected) {
|
||||
// Use the actual helper function from bootstrap/helpers/services.php
|
||||
$parsed = parseServiceEnvironmentVariable($varName);
|
||||
|
||||
expect($parsed['service_name'])->toBe($expected['service'], "Service name mismatch for $varName");
|
||||
expect($parsed['port'])->toBe($expected['port'], "Port mismatch for $varName");
|
||||
expect($parsed['has_port'])->toBe($expected['hasPort'], "Port detection mismatch for $varName");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
116
tests/Unit/StartupExecutionCleanupTest.php
Normal file
116
tests/Unit/StartupExecutionCleanupTest.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
use App\Models\ScheduledDatabaseBackupExecution;
|
||||
use App\Models\ScheduledTaskExecution;
|
||||
use Carbon\Carbon;
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow('2025-01-15 12:00:00');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
\Mockery::close();
|
||||
});
|
||||
|
||||
it('marks stuck scheduled task executions as failed without triggering notifications', function () {
|
||||
// Mock the ScheduledTaskExecution model
|
||||
$mockBuilder = \Mockery::mock('alias:'.ScheduledTaskExecution::class);
|
||||
|
||||
// Expect where clause to be called with 'running' status
|
||||
$mockBuilder->shouldReceive('where')
|
||||
->once()
|
||||
->with('status', 'running')
|
||||
->andReturnSelf();
|
||||
|
||||
// Expect update to be called with correct parameters
|
||||
$mockBuilder->shouldReceive('update')
|
||||
->once()
|
||||
->with([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
])
|
||||
->andReturn(2); // Simulate 2 records updated
|
||||
|
||||
// Execute the cleanup logic directly
|
||||
$updatedCount = ScheduledTaskExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
// Assert the count is correct
|
||||
expect($updatedCount)->toBe(2);
|
||||
});
|
||||
|
||||
it('marks stuck database backup executions as failed without triggering notifications', function () {
|
||||
// Mock the ScheduledDatabaseBackupExecution model
|
||||
$mockBuilder = \Mockery::mock('alias:'.ScheduledDatabaseBackupExecution::class);
|
||||
|
||||
// Expect where clause to be called with 'running' status
|
||||
$mockBuilder->shouldReceive('where')
|
||||
->once()
|
||||
->with('status', 'running')
|
||||
->andReturnSelf();
|
||||
|
||||
// Expect update to be called with correct parameters
|
||||
$mockBuilder->shouldReceive('update')
|
||||
->once()
|
||||
->with([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
])
|
||||
->andReturn(3); // Simulate 3 records updated
|
||||
|
||||
// Execute the cleanup logic directly
|
||||
$updatedCount = ScheduledDatabaseBackupExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
// Assert the count is correct
|
||||
expect($updatedCount)->toBe(3);
|
||||
});
|
||||
|
||||
it('handles cleanup when no stuck executions exist', function () {
|
||||
// Mock the ScheduledTaskExecution model
|
||||
$mockBuilder = \Mockery::mock('alias:'.ScheduledTaskExecution::class);
|
||||
|
||||
$mockBuilder->shouldReceive('where')
|
||||
->once()
|
||||
->with('status', 'running')
|
||||
->andReturnSelf();
|
||||
|
||||
$mockBuilder->shouldReceive('update')
|
||||
->once()
|
||||
->andReturn(0); // No records updated
|
||||
|
||||
$updatedCount = ScheduledTaskExecution::where('status', 'running')->update([
|
||||
'status' => 'failed',
|
||||
'message' => 'Marked as failed during Coolify startup - job was interrupted',
|
||||
'finished_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
expect($updatedCount)->toBe(0);
|
||||
});
|
||||
|
||||
it('uses correct failure message for interrupted jobs', function () {
|
||||
$expectedMessage = 'Marked as failed during Coolify startup - job was interrupted';
|
||||
|
||||
// Verify the message clearly indicates the job was interrupted during startup
|
||||
expect($expectedMessage)
|
||||
->toContain('Coolify startup')
|
||||
->toContain('interrupted')
|
||||
->toContain('failed');
|
||||
});
|
||||
|
||||
it('sets finished_at timestamp when marking executions as failed', function () {
|
||||
$now = Carbon::now();
|
||||
|
||||
// Verify Carbon::now() is used for finished_at
|
||||
expect($now)->toBeInstanceOf(Carbon::class)
|
||||
->and($now->toDateTimeString())->toBe('2025-01-15 12:00:00');
|
||||
});
|
||||
80
tests/Unit/TimescaleDbDetectionTest.php
Normal file
80
tests/Unit/TimescaleDbDetectionTest.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
use App\Models\ServiceDatabase;
|
||||
|
||||
use function PHPUnit\Framework\assertTrue;
|
||||
|
||||
test('timescaledb is detected as database with postgres environment variables', function () {
|
||||
$image = 'timescale/timescaledb';
|
||||
$serviceConfig = [
|
||||
'image' => 'timescale/timescaledb',
|
||||
'environment' => [
|
||||
'POSTGRES_DB=$POSTGRES_DB',
|
||||
'POSTGRES_USER=$SERVICE_USER_POSTGRES',
|
||||
'POSTGRES_PASSWORD=$SERVICE_PASSWORD_POSTGRES',
|
||||
],
|
||||
'volumes' => [
|
||||
'timescaledb-data:/var/lib/postgresql/data',
|
||||
],
|
||||
];
|
||||
|
||||
$isDatabase = isDatabaseImage($image, $serviceConfig);
|
||||
|
||||
assertTrue($isDatabase, 'TimescaleDB with POSTGRES_PASSWORD should be detected as database');
|
||||
});
|
||||
|
||||
test('timescaledb is detected as database without service config', function () {
|
||||
$image = 'timescale/timescaledb';
|
||||
|
||||
$isDatabase = isDatabaseImage($image);
|
||||
|
||||
assertTrue($isDatabase, 'TimescaleDB image should be in DATABASE_DOCKER_IMAGES constant');
|
||||
});
|
||||
|
||||
test('timescaledb-ha is detected as database', function () {
|
||||
$image = 'timescale/timescaledb-ha';
|
||||
|
||||
$isDatabase = isDatabaseImage($image);
|
||||
|
||||
assertTrue($isDatabase, 'TimescaleDB HA image should be in DATABASE_DOCKER_IMAGES constant');
|
||||
});
|
||||
|
||||
test('timescaledb databaseType returns postgresql', function () {
|
||||
$database = new ServiceDatabase;
|
||||
$database->setRawAttributes(['image' => 'timescale/timescaledb:latest', 'custom_type' => null]);
|
||||
$database->syncOriginal();
|
||||
|
||||
$type = $database->databaseType();
|
||||
|
||||
expect($type)->toBe('standalone-postgresql');
|
||||
});
|
||||
|
||||
test('timescaledb-ha databaseType returns postgresql', function () {
|
||||
$database = new ServiceDatabase;
|
||||
$database->setRawAttributes(['image' => 'timescale/timescaledb-ha:pg17', 'custom_type' => null]);
|
||||
$database->syncOriginal();
|
||||
|
||||
$type = $database->databaseType();
|
||||
|
||||
expect($type)->toBe('standalone-postgresql');
|
||||
});
|
||||
|
||||
test('timescaledb backup solution is available', function () {
|
||||
$database = new ServiceDatabase;
|
||||
$database->setRawAttributes(['image' => 'timescale/timescaledb:latest', 'custom_type' => null]);
|
||||
$database->syncOriginal();
|
||||
|
||||
$isAvailable = $database->isBackupSolutionAvailable();
|
||||
|
||||
assertTrue($isAvailable, 'TimescaleDB should have backup solution available');
|
||||
});
|
||||
|
||||
test('timescaledb-ha backup solution is available', function () {
|
||||
$database = new ServiceDatabase;
|
||||
$database->setRawAttributes(['image' => 'timescale/timescaledb-ha:pg17', 'custom_type' => null]);
|
||||
$database->syncOriginal();
|
||||
|
||||
$isAvailable = $database->isBackupSolutionAvailable();
|
||||
|
||||
assertTrue($isAvailable, 'TimescaleDB HA should have backup solution available');
|
||||
});
|
||||
|
|
@ -194,6 +194,36 @@
|
|||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('array-format with environment variable and path concatenation', function () {
|
||||
// This is the reported issue #7127 - ${VAR}/path should be allowed
|
||||
$dockerComposeYaml = <<<'YAML'
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
volumes:
|
||||
- type: bind
|
||||
source: '${VOLUMES_PATH}/mysql'
|
||||
target: /var/lib/mysql
|
||||
- type: bind
|
||||
source: '${DATA_PATH}/config'
|
||||
target: /etc/config
|
||||
- type: bind
|
||||
source: '${VOLUME_PATH}/app_data'
|
||||
target: /app/data
|
||||
YAML;
|
||||
|
||||
$parsed = Yaml::parse($dockerComposeYaml);
|
||||
|
||||
// Verify all three volumes have the correct source format
|
||||
expect($parsed['services']['web']['volumes'][0]['source'])->toBe('${VOLUMES_PATH}/mysql');
|
||||
expect($parsed['services']['web']['volumes'][1]['source'])->toBe('${DATA_PATH}/config');
|
||||
expect($parsed['services']['web']['volumes'][2]['source'])->toBe('${VOLUME_PATH}/app_data');
|
||||
|
||||
// The validation should allow this - the reported bug was that it was blocked
|
||||
expect(fn () => validateDockerComposeForInjection($dockerComposeYaml))
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('array-format with malicious environment variable default', function () {
|
||||
$dockerComposeYaml = <<<'YAML'
|
||||
services:
|
||||
|
|
|
|||
|
|
@ -94,6 +94,27 @@
|
|||
}
|
||||
});
|
||||
|
||||
test('parseDockerVolumeString accepts environment variables with path concatenation', function () {
|
||||
$volumes = [
|
||||
'${VOLUMES_PATH}/mysql:/var/lib/mysql',
|
||||
'${DATA_PATH}/config:/etc/config',
|
||||
'${VOLUME_PATH}/app_data:/app',
|
||||
'${MY_VAR_123}/deep/nested/path:/data',
|
||||
'${VAR}/path:/app',
|
||||
'${VAR}_suffix:/app',
|
||||
'${VAR}-suffix:/app',
|
||||
'${VAR}.ext:/app',
|
||||
'${VOLUMES_PATH}/mysql:/var/lib/mysql:ro',
|
||||
'${DATA_PATH}/config:/etc/config:rw',
|
||||
];
|
||||
|
||||
foreach ($volumes as $volume) {
|
||||
$result = parseDockerVolumeString($volume);
|
||||
expect($result)->toBeArray();
|
||||
expect($result['source'])->not->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('parseDockerVolumeString rejects environment variables with command injection in default', function () {
|
||||
$maliciousVolumes = [
|
||||
'${VAR:-`whoami`}:/app',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.442"
|
||||
"version": "4.0.0-beta.443"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.443"
|
||||
"version": "4.0.0-beta.444"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.11"
|
||||
|
|
|
|||
Loading…
Reference in a new issue