diff --git a/.coderabbit.yaml b/.coderabbit.yaml deleted file mode 100644 index 24c099119..000000000 --- a/.coderabbit.yaml +++ /dev/null @@ -1,2 +0,0 @@ -reviews: - review_status: false diff --git a/.github/workflows/coolify-helper-next.yml b/.github/workflows/coolify-helper-next.yml index ba8a69d28..fec54d54a 100644 --- a/.github/workflows/coolify-helper-next.yml +++ b/.github/workflows/coolify-helper-next.yml @@ -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 diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml index 738a3480c..0c9996ec8 100644 --- a/.github/workflows/coolify-helper.yml +++ b/.github/workflows/coolify-helper.yml @@ -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 diff --git a/.github/workflows/coolify-production-build.yml b/.github/workflows/coolify-production-build.yml index b6cfd34ae..21871b103 100644 --- a/.github/workflows/coolify-production-build.yml +++ b/.github/workflows/coolify-production-build.yml @@ -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 diff --git a/.github/workflows/coolify-realtime-next.yml b/.github/workflows/coolify-realtime-next.yml index 7a6071bde..7ab4dcc42 100644 --- a/.github/workflows/coolify-realtime-next.yml +++ b/.github/workflows/coolify-realtime-next.yml @@ -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 diff --git a/.github/workflows/coolify-realtime.yml b/.github/workflows/coolify-realtime.yml index 1074af3ee..5efe445c5 100644 --- a/.github/workflows/coolify-realtime.yml +++ b/.github/workflows/coolify-realtime.yml @@ -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 diff --git a/.github/workflows/coolify-testing-host.yml b/.github/workflows/coolify-testing-host.yml index c4aecd85e..24133887a 100644 --- a/.github/workflows/coolify-testing-host.yml +++ b/.github/workflows/coolify-testing-host.yml @@ -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 diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index f62b41736..935a88721 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -16,7 +16,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - persist-credentials: false fetch-depth: 0 - name: Generate changelog diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index f5d5f82b6..c7f4055f0 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -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)'; } } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index dfef6a566..6b5e1d4ac 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -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', []); diff --git a/app/Console/Commands/CleanupRedis.php b/app/Console/Commands/CleanupRedis.php index f6a2de75b..abf8010c0 100644 --- a/app/Console/Commands/CleanupRedis.php +++ b/app/Console/Commands/CleanupRedis.php @@ -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; + } } diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php index 0b13462ef..165a3ae21 100644 --- a/app/Console/Commands/CleanupStuckedResources.php +++ b/app/Console/Commands/CleanupStuckedResources.php @@ -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; } diff --git a/app/Console/Commands/Dev.php b/app/Console/Commands/Dev.php index 8f26d78ff..acc6dc2f9 100644 --- a/app/Console/Commands/Dev.php +++ b/app/Console/Commands/Dev.php @@ -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(); } } diff --git a/app/Console/Commands/Emails.php b/app/Console/Commands/Emails.php index a022d54dc..43ba06804 100644 --- a/app/Console/Commands/Emails.php +++ b/app/Console/Commands/Emails.php @@ -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': diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index 4bc818f0a..66cb77838 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -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) { diff --git a/app/Exceptions/DeploymentException.php b/app/Exceptions/DeploymentException.php new file mode 100644 index 000000000..01e0a8235 --- /dev/null +++ b/app/Exceptions/DeploymentException.php @@ -0,0 +1,32 @@ +getMessage(), $exception->getCode(), $exception); + } +} diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 3d731223d..71de48bcd 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -30,6 +30,7 @@ class Handler extends ExceptionHandler protected $dontReport = [ ProcessException::class, NonReportableException::class, + DeploymentException::class, ]; /** diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 5ba9c08e7..a1fcaa7f5 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -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) { diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index ea8cdff95..349fccb50 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -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(); diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index c82a27ce9..f6f5e8b5b 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -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); } } diff --git a/app/Jobs/CoolifyTask.php b/app/Jobs/CoolifyTask.php index 49a5ba8dd..d6dc6fa05 100755 --- a/app/Jobs/CoolifyTask.php +++ b/app/Jobs/CoolifyTask.php @@ -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(); + } } diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 45586f0d0..8766a1afc 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -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)); + } } } diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index b9fbebcc9..c4358570e 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -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, diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 9937444b8..75ff883c2 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -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 ]; } diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 609595356..e55db5440 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -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')); + } } diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index a83e6f70a..ce7d6b1b7 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -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, + ]; + } } diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 5231438e5..fc63c7f4b 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -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'], diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 7deaa82a9..da543a049 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -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() diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index a9a7de878..7158b6e40 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -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 diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 1d8d8b247..259b9dbec 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -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 diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index 85cd21a7f..72ae6915a 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -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)) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index e4b666532..d7210c15d 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -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; } } diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index c8d07ae36..088de0a76 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -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; } } diff --git a/app/Models/Application.php b/app/Models/Application.php index 615e35f68..5e2aaa347 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -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') diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index 06903ffb6..bada0b7a5 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -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); diff --git a/app/Models/ScheduledTaskExecution.php b/app/Models/ScheduledTaskExecution.php index de13fefb0..02fd6917a 100644 --- a/app/Models/ScheduledTaskExecution.php +++ b/app/Models/ScheduledTaskExecution.php @@ -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); diff --git a/app/Models/Service.php b/app/Models/Service.php index 12d3d6a11..ef755d105 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -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([ diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index 49bd56206..fd5c4afdb 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -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; + } + } } diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index d595721d8..3a249059c 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -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 { diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index 245bd85f0..234bc37ad 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -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')) { diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 4aa5aae8b..58ae5f249 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -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}"); } } } diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 382e2d015..f588b6c00 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -47,6 +47,8 @@ 'neo4j', 'influxdb', 'clickhouse/clickhouse-server', + 'timescaledb/timescaledb', + 'pgvector/pgvector', ]; const SPECIFIC_SERVICES = [ 'quay.io/minio/minio', diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 5bccb50f1..c62c2ad8e 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -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; diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 1deec45d7..7012e2087 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -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); } diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index 924bad307..b8332cba2 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -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', diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index a124272a2..a6d427a6b 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -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, + ]; +} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index effde712a..d9e76f399 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -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; diff --git a/bootstrap/helpers/sudo.php b/bootstrap/helpers/sudo.php index ba252c64f..f7336beeb 100644 --- a/bootstrap/helpers/sudo.php +++ b/bootstrap/helpers/sudo.php @@ -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 '); } diff --git a/composer.json b/composer.json index ea466049d..1db389a57 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 6320db071..5ffeb7d39 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/constants.php b/config/constants.php index 02a1eaae6..770e00ffe 100644 --- a/config/constants.php +++ b/config/constants.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), diff --git a/config/logging.php b/config/logging.php index 488327414..1a75978f3 100644 --- a/config/logging.php +++ b/config/logging.php @@ -129,8 +129,8 @@ 'scheduled-errors' => [ 'driver' => 'daily', 'path' => storage_path('logs/scheduled-errors.log'), - 'level' => 'debug', - 'days' => 7, + 'level' => 'warning', + 'days' => 14, ], ], diff --git a/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php b/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php new file mode 100644 index 000000000..067861e16 --- /dev/null +++ b/database/migrations/2025_11_09_000001_add_timeout_to_scheduled_tasks_table.php @@ -0,0 +1,28 @@ +integer('timeout')->default(300)->after('frequency'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('scheduled_tasks', function (Blueprint $table) { + $table->dropColumn('timeout'); + }); + } +}; diff --git a/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php b/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php new file mode 100644 index 000000000..14fdd5998 --- /dev/null +++ b/database/migrations/2025_11_09_000002_improve_scheduled_task_executions_tracking.php @@ -0,0 +1,31 @@ +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']); + }); + } +}; diff --git a/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php b/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php new file mode 100644 index 000000000..329ac7af9 --- /dev/null +++ b/database/migrations/2025_11_10_112500_add_restart_tracking_to_applications_table.php @@ -0,0 +1,30 @@ +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']); + }); + } +}; diff --git a/docker/development/etc/nginx/site-opts.d/http.conf b/docker/development/etc/nginx/site-opts.d/http.conf index a5bbd78a3..d7855ae80 100644 --- a/docker/development/etc/nginx/site-opts.d/http.conf +++ b/docker/development/etc/nginx/site-opts.d/http.conf @@ -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; diff --git a/docker/production/etc/nginx/site-opts.d/http.conf b/docker/production/etc/nginx/site-opts.d/http.conf index a5bbd78a3..d7855ae80 100644 --- a/docker/production/etc/nginx/site-opts.d/http.conf +++ b/docker/production/etc/nginx/site-opts.d/http.conf @@ -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; diff --git a/jean.json b/jean.json index c625e08c0..4e5c788ed 100644 --- a/jean.json +++ b/jean.json @@ -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" } } diff --git a/other/nightly/versions.json b/other/nightly/versions.json index a83b4c8ce..0d9519bf8 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.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" diff --git a/package-lock.json b/package-lock.json index 9e8fe7328..b076800e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/public/svgs/postgresus.svg b/public/svgs/postgresus.svg new file mode 100644 index 000000000..a45e81167 --- /dev/null +++ b/public/svgs/postgresus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/views/components/status/index.blade.php b/resources/views/components/status/index.blade.php index d592cff79..57e5409c6 100644 --- a/resources/views/components/status/index.blade.php +++ b/resources/views/components/status/index.blade.php @@ -12,6 +12,13 @@ @else @endif +@if (isset($resource->restart_count) && $resource->restart_count > 0 && !str($resource->status)->startsWith('exited')) +
+ + ({{ $resource->restart_count }}x restarts) + +
+@endif @if (!str($resource->status)->contains('exited') && $showRefreshButton)