mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 09:34:36 +08:00
Merge remote-tracking branch 'origin/main' into feat/services
This commit is contained in:
249 files changed
+8928
-1027
No files matched your search
@@ -0,0 +1,152 @@
|
||||
name: Build Coolify Next
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [next]
|
||||
paths-ignore:
|
||||
- .github/workflows/coolify-helper.yml
|
||||
- .github/workflows/coolify-helper-next.yml
|
||||
- .github/workflows/coolify-realtime.yml
|
||||
- .github/workflows/coolify-realtime-next.yml
|
||||
- .github/workflows/pr-quality.yaml
|
||||
- docker/coolify-helper/Dockerfile
|
||||
- docker/coolify-realtime/Dockerfile
|
||||
- docker/testing-host/Dockerfile
|
||||
- templates/**
|
||||
- CHANGELOG.md
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: coolify-next-build
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
DOCKER_REGISTRY: docker.io
|
||||
IMAGE_NAME: coollabsio/coolify
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
rc_version: ${{ steps.version.outputs.rc_version }}
|
||||
short_sha: ${{ steps.version.outputs.short_sha }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve next version
|
||||
id: version
|
||||
run: |
|
||||
RC_VERSION=$(jq -r '.coolify.nightly.version' versions.json)
|
||||
if [[ ! "${RC_VERSION}" =~ ^[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
echo "Invalid next RC version: ${RC_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
VERSION="${RC_VERSION}.${SHORT_SHA}"
|
||||
echo "rc_version=${RC_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
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:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push next image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
build-args: |
|
||||
COOLIFY_VERSION=${{ needs.prepare.outputs.version }}
|
||||
tags: |
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }}
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }}
|
||||
|
||||
publish:
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Publish next manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
env:
|
||||
REGISTRY: ${{ env.GITHUB_REGISTRY }}
|
||||
SHA: ${{ needs.prepare.outputs.short_sha }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
||||
SOURCE="next-build-${SHA}"
|
||||
docker buildx imagetools create \
|
||||
"${IMAGE}:${SOURCE}-amd64" \
|
||||
"${IMAGE}:${SOURCE}-aarch64" \
|
||||
--tag "${IMAGE}:sha-${SHA}" \
|
||||
--tag "${IMAGE}:${VERSION}" \
|
||||
--tag "${IMAGE}:next"
|
||||
|
||||
- name: Publish next manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
env:
|
||||
REGISTRY: ${{ env.DOCKER_REGISTRY }}
|
||||
SHA: ${{ needs.prepare.outputs.short_sha }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
||||
SOURCE="next-build-${SHA}"
|
||||
docker buildx imagetools create \
|
||||
"${IMAGE}:${SOURCE}-amd64" \
|
||||
"${IMAGE}:${SOURCE}-aarch64" \
|
||||
--tag "${IMAGE}:sha-${SHA}" \
|
||||
--tag "${IMAGE}:${VERSION}" \
|
||||
--tag "${IMAGE}:next"
|
||||
@@ -0,0 +1,304 @@
|
||||
name: Release Coolify RC
|
||||
run-name: ${{ inputs.tag }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing draft prerelease tag (for example, v4.4-rc.1)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: coolify-rc-release
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
DOCKER_REGISTRY: docker.io
|
||||
IMAGE_NAME: coollabsio/coolify
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
release_id: ${{ steps.draft.outputs.release_id }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Reject releases outside next
|
||||
if: ${{ github.ref != 'refs/heads/next' }}
|
||||
run: |
|
||||
echo "RC releases must run from the next branch, not ${{ github.ref }}."
|
||||
exit 1
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate version
|
||||
id: version
|
||||
env:
|
||||
TAG_NAME: ${{ inputs.tag }}
|
||||
run: |
|
||||
if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
echo "Unsupported RC tag: ${TAG_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${TAG_NAME#v}"
|
||||
CONFIG_VERSION=$(jq -r '.coolify.nightly.version' versions.json)
|
||||
if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then
|
||||
echo "RC tag ${VERSION} does not match nightly version ${CONFIG_VERSION}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate and pin draft prerelease
|
||||
id: draft
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
TAG_NAME: ${{ inputs.tag }}
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME);
|
||||
|
||||
if (!release) {
|
||||
core.setFailed(`Create a draft prerelease for ${process.env.TAG_NAME} before running this workflow.`);
|
||||
return;
|
||||
}
|
||||
if (!release.draft) {
|
||||
core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`);
|
||||
return;
|
||||
}
|
||||
if (!release.prerelease) {
|
||||
core.setFailed(`RC release ${process.env.TAG_NAME} must be marked as a prerelease.`);
|
||||
return;
|
||||
}
|
||||
if (!release.body?.trim()) {
|
||||
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} must contain reviewed release notes.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.git.getRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `tags/${process.env.TAG_NAME}`,
|
||||
});
|
||||
core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: release.id,
|
||||
tag_name: process.env.TAG_NAME,
|
||||
target_commitish: context.sha,
|
||||
prerelease: true,
|
||||
});
|
||||
core.setOutput('release_id', release.id);
|
||||
|
||||
build:
|
||||
needs: validate
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
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:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push RC image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
build-args: |
|
||||
COOLIFY_VERSION=${{ needs.validate.outputs.version }}
|
||||
tags: |
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
|
||||
|
||||
revalidate:
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Revalidate draft prerelease
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
|
||||
TAG_NAME: ${{ inputs.tag }}
|
||||
with:
|
||||
script: |
|
||||
const releaseId = Number(process.env.RELEASE_ID);
|
||||
const { data: release } = await github.rest.repos.getRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: releaseId,
|
||||
});
|
||||
|
||||
if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) {
|
||||
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`);
|
||||
return;
|
||||
}
|
||||
if (!release.body?.trim()) {
|
||||
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`);
|
||||
return;
|
||||
}
|
||||
if (release.target_commitish !== context.sha) {
|
||||
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.git.getRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `tags/${process.env.TAG_NAME}`,
|
||||
});
|
||||
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
|
||||
publish:
|
||||
needs: [validate, build, revalidate]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Publish RC and next on ${{ env.GITHUB_REGISTRY }}
|
||||
env:
|
||||
REGISTRY: ${{ env.GITHUB_REGISTRY }}
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
||||
SOURCE="rc-release-${VERSION}-${GITHUB_SHA}"
|
||||
docker buildx imagetools create \
|
||||
"${IMAGE}:${SOURCE}-amd64" \
|
||||
"${IMAGE}:${SOURCE}-aarch64" \
|
||||
--tag "${IMAGE}:${VERSION}" \
|
||||
--tag "${IMAGE}:next"
|
||||
|
||||
- name: Publish RC and next on ${{ env.DOCKER_REGISTRY }}
|
||||
env:
|
||||
REGISTRY: ${{ env.DOCKER_REGISTRY }}
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
||||
SOURCE="rc-release-${VERSION}-${GITHUB_SHA}"
|
||||
docker buildx imagetools create \
|
||||
"${IMAGE}:${SOURCE}-amd64" \
|
||||
"${IMAGE}:${SOURCE}-aarch64" \
|
||||
--tag "${IMAGE}:${VERSION}" \
|
||||
--tag "${IMAGE}:next"
|
||||
|
||||
- name: Publish reviewed draft prerelease
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
|
||||
TAG_NAME: ${{ inputs.tag }}
|
||||
with:
|
||||
script: |
|
||||
const releaseId = Number(process.env.RELEASE_ID);
|
||||
const { data: release } = await github.rest.repos.getRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: releaseId,
|
||||
});
|
||||
|
||||
if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) {
|
||||
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`);
|
||||
return;
|
||||
}
|
||||
if (!release.body?.trim()) {
|
||||
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`);
|
||||
return;
|
||||
}
|
||||
if (release.target_commitish !== context.sha) {
|
||||
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.git.getRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `tags/${process.env.TAG_NAME}`,
|
||||
});
|
||||
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: Number(process.env.RELEASE_ID),
|
||||
tag_name: process.env.TAG_NAME,
|
||||
target_commitish: context.sha,
|
||||
prerelease: true,
|
||||
draft: false,
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
name: Staging Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- main
|
||||
- v3.x
|
||||
- '**v5.x**'
|
||||
paths-ignore:
|
||||
- .github/workflows/coolify-helper.yml
|
||||
- .github/workflows/coolify-helper-next.yml
|
||||
- .github/workflows/coolify-realtime.yml
|
||||
- .github/workflows/coolify-realtime-next.yml
|
||||
- .github/workflows/pr-quality.yaml
|
||||
- docker/coolify-helper/Dockerfile
|
||||
- docker/coolify-realtime/Dockerfile
|
||||
- docker/testing-host/Dockerfile
|
||||
- templates/**
|
||||
- CHANGELOG.md
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
GITHUB_REGISTRY: ghcr.io
|
||||
DOCKER_REGISTRY: docker.io
|
||||
IMAGE_NAME: "coollabsio/coolify"
|
||||
|
||||
jobs:
|
||||
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:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Sanitize branch name for Docker tag
|
||||
id: sanitize
|
||||
run: |
|
||||
# Replace slashes and other invalid characters with dashes
|
||||
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
|
||||
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
|
||||
cache-from: |
|
||||
type=gha,scope=build-${{ matrix.arch }}
|
||||
type=registry,ref=${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
|
||||
|
||||
merge-manifest:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-push
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Sanitize branch name for Docker tag
|
||||
id: sanitize
|
||||
run: |
|
||||
# Replace slashes and other invalid characters with dashes
|
||||
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
|
||||
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
|
||||
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
|
||||
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
|
||||
|
||||
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
|
||||
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
|
||||
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
|
||||
|
||||
- uses: sarisia/actions-status-discord@v1
|
||||
if: always()
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Sync main to next
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: sync-main-to-next
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Merge main into next
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout next
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: next
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Merge main into next
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git fetch origin main next
|
||||
|
||||
if git merge --no-edit origin/main; then
|
||||
git push origin HEAD:next
|
||||
exit 0
|
||||
fi
|
||||
|
||||
conflicts=$(git diff --name-only --diff-filter=U)
|
||||
git merge --abort
|
||||
|
||||
if [ -z "$conflicts" ]; then
|
||||
echo 'The merge failed without conflicts, so no pull request was created.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sync_branch='automation/sync-main-to-next'
|
||||
existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url')
|
||||
if [ -n "$existing_pr" ]; then
|
||||
echo "A main to next pull request already exists: $existing_pr"
|
||||
else
|
||||
git push --force origin origin/main:"refs/heads/$sync_branch"
|
||||
gh pr create \
|
||||
--base next \
|
||||
--head "$sync_branch" \
|
||||
--title 'chore: merge main into next' \
|
||||
--body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.'
|
||||
fi
|
||||
|
||||
echo 'main could not be merged into next without conflicts.'
|
||||
exit 1
|
||||
@@ -39,6 +39,7 @@ docker/coolify-realtime/node_modules
|
||||
CHANGELOG.md
|
||||
/.workspaces
|
||||
/.superpowers/
|
||||
/docs/superpowers/plans/
|
||||
tests/Browser/Screenshots
|
||||
tests/v4/Browser/Screenshots
|
||||
ref
|
||||
|
||||
@@ -99,9 +99,30 @@ function loginAsRoot(): mixed
|
||||
```
|
||||
|
||||
- See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions.
|
||||
- Chrome driver runs on `localhost:4444`, app on `localhost:8000` (configured in `tests/DuskTestCase.php`).
|
||||
- Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API.
|
||||
|
||||
### How Browser Tests Actually Run (no Docker, no display needed)
|
||||
|
||||
`visit()` does NOT hit the dev app on `localhost:8000` and does NOT use the Dusk ChromeDriver on `:4444` (that config in `tests/DuskTestCase.php` is legacy). Instead the Pest Browser Plugin:
|
||||
|
||||
1. Starts a local Playwright server (`node node_modules/.bin/playwright run-server`) and launches a **headless Chromium** from `~/.cache/ms-playwright` (install once with `npm install && npx playwright install chromium`).
|
||||
2. Boots an **in-process amphp HTTP server** on a random port that serves the Laravel app from the test process itself.
|
||||
|
||||
Because the "server" and the test share one PHP process, they share the phpunit env (sqlite `:memory:`, array cache) — so `config()->set(...)`, model writes, and `Cache` calls in the test are visible to browser-issued requests, and `RefreshDatabase` never touches the dev Postgres.
|
||||
|
||||
`->screenshot(filename: '...')` writes real PNGs to `tests/Browser/Screenshots/` — read them to visually verify UI state (toasts, modals, stray elements).
|
||||
|
||||
### Browser Test Gotchas
|
||||
|
||||
- **`Class "Redis" not found` thrown by the HTTP server**: host PHP has no phpredis, and the maintenance-mode store is hard-wired to redis (`config/app.php` → `'maintenance' => ['store' => 'redis']`). Add `config()->set('app.maintenance.store', 'array');` in `beforeEach`.
|
||||
- **Every path redirects to onboarding** for a fresh user (`DecideWhatToDoWithUser` + `showBoarding()`). Finish boarding before navigating: `Team::query()->update(['show_boarding' => false]); Cache::flush();` — the `Cache::flush()` is required because `User::currentTeam()` caches the Team for an hour and the in-process server shares that cache.
|
||||
- **`->navigate('/path')` races form-submit redirects.** After `->click('Login')`, assert something on the destination page (e.g. `->assertSee('Welcome to Coolify')`) before calling `navigate()`.
|
||||
- **Failure messages print the *initial* `visit()` URL**, not the current URL. Read the auto-saved screenshot in `tests/Browser/Screenshots/` to see where the browser actually ended up.
|
||||
- **Runs hang forever**: stale Playwright servers from a previously killed run. Fix: `pkill -f "playwright run-server"` and rerun. Healthy runs take seconds.
|
||||
- **Guest pages miss `DOMPurify`** (`public/js/purify.min.js` loads only `@auth` in `layouts/base.blade.php`), so toast descriptions fail on unauthenticated pages — log in first for toast-related assertions.
|
||||
- Layouts that call `@livewireScripts` manually must also call `@livewireStyles`, otherwise Livewire's asset auto-injection is disabled and `[wire\:loading]`/`[x-cloak]` elements render visible.
|
||||
- Run browser test files in their own `php artisan test` invocation — combining them with non-browser test paths in one command can hang the runner.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Structure (app/)
|
||||
|
||||
@@ -228,6 +228,35 @@ A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effor
|
||||
## Local Development
|
||||
To build and run Coolify locally, see: [Development](./DEVELOPMENT.md)
|
||||
|
||||
### Testing the Coolify Helper Locally
|
||||
|
||||
Use `scripts/dev-helper` to build a local helper image and test it with the running development instance. The script requires the standard local Coolify container and the seeded Dockerfile, Docker Compose, and Nixpacks applications.
|
||||
|
||||
Run the complete workflow:
|
||||
|
||||
```bash
|
||||
./scripts/dev-helper test my-helper-test
|
||||
```
|
||||
|
||||
This builds and selects the helper image, verifies its bundled tools and Docker socket access, runs a Docker Compose smoke test, and deploys all three seeded applications.
|
||||
|
||||
You can also run each step separately:
|
||||
|
||||
```bash
|
||||
./scripts/dev-helper build my-helper-test
|
||||
./scripts/dev-helper use my-helper-test
|
||||
./scripts/dev-helper verify my-helper-test
|
||||
./scripts/dev-helper deploy my-helper-test
|
||||
```
|
||||
|
||||
Clear the helper override when finished:
|
||||
|
||||
```bash
|
||||
./scripts/dev-helper reset
|
||||
```
|
||||
|
||||
The default image repository is `docker.io/coollabsio/coolify-helper`. Set `HELPER_IMAGE_REPOSITORY` to test another repository, or `COOLIFY_CONTAINER` if the local Coolify container has a different name.
|
||||
|
||||
### macOS Development with Lima
|
||||
Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS.
|
||||
|
||||
|
||||
+6
-5
@@ -9,7 +9,7 @@
|
||||
| `feature/*` | New features based on and merged into `next` |
|
||||
| `hotfix/X.Y.Z` | Production fixes based on `main` |
|
||||
|
||||
Release workflows never edit or commit versions. Set the intended version in `config/constants.php` before running a release workflow.
|
||||
Release workflows never edit or commit versions. Stable versions come from `config/constants.php`; RC versions come from `coolify.nightly.version` in `versions.json` and `other/nightly/versions.json`.
|
||||
|
||||
## Where changes go
|
||||
|
||||
@@ -25,11 +25,12 @@ feature/* → next → RC
|
||||
```
|
||||
|
||||
1. Merge feature branches into `next`.
|
||||
2. Set the intended RC version on `next`, such as `4.4-rc.1`.
|
||||
3. Regular builds publish `sha-<commit>`, `4.4-rc.1.<short-sha>`, and the moving `next` tag.
|
||||
2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.4-rc.1`.
|
||||
3. Regular `next` builds publish `sha-<short-sha>`, `4.4-rc.1.<short-sha>`, and the moving `next` tag. They never publish the exact `4.4-rc.1` tag.
|
||||
4. Create a reviewed draft GitHub Release named `v4.4-rc.1` and mark it as a prerelease.
|
||||
5. Run the RC workflow from `next`. It publishes `4.4-rc.1`, updates `next`, and publishes the draft.
|
||||
6. Advance `next` to the next intended RC version.
|
||||
5. Run **Release Coolify RC** manually from `next` and enter `v4.4-rc.1`.
|
||||
6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.4-rc.1`, updates `next`, and publishes the draft prerelease.
|
||||
7. Advance `coolify.nightly.version` to the next intended RC version.
|
||||
|
||||
## Stable release flow
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class StopApplicationOneServer
|
||||
instant_remote_process(
|
||||
[
|
||||
dockerStopCommand($timeout, $containerName, $server),
|
||||
"docker rm -f $containerName",
|
||||
dockerRemoveCommand($containerName),
|
||||
],
|
||||
$server
|
||||
);
|
||||
|
||||
@@ -24,10 +24,10 @@ class StopDatabaseProxy
|
||||
{
|
||||
$server = data_get($database, 'destination.server');
|
||||
$uuid = $database->uuid;
|
||||
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
if ($database->getMorphClass() === ServiceDatabase::class) {
|
||||
$server = data_get($database, 'service.server');
|
||||
}
|
||||
instant_remote_process(["docker rm -f {$uuid}-proxy"], $server);
|
||||
instant_remote_process([dockerRemoveCommand("{$uuid}-proxy")], $server);
|
||||
|
||||
$database->save();
|
||||
|
||||
|
||||
@@ -11,6 +11,6 @@ class RemoveStandaloneDockerNetwork
|
||||
$safeNetwork = escapeshellarg($destination->network);
|
||||
|
||||
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false);
|
||||
instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server);
|
||||
instant_remote_process([dockerNetworkRemoveCommand($destination->network)], $destination->server);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Development;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use RuntimeException;
|
||||
|
||||
class ConfigureDevelopmentQemuHost
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->ensureDevelopmentEnvironment();
|
||||
$this->installDependencies();
|
||||
$this->runOrFail('systemctl enable --now libvirtd');
|
||||
$this->configureLibvirtNetwork();
|
||||
$this->configureIpForwarding();
|
||||
$this->configureStorage();
|
||||
$this->configureDockerForwarding();
|
||||
}
|
||||
|
||||
private function installDependencies(): void
|
||||
{
|
||||
$binaries = ['curl', 'docker', 'iptables', 'qemu-img', 'virsh', 'virt-install'];
|
||||
$check = collect($binaries)->map(fn (string $binary) => 'command -v '.escapeshellarg($binary))->implode(' && ');
|
||||
|
||||
if (Process::run($check)->successful()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! File::exists('/usr/bin/apt-get')) {
|
||||
throw new RuntimeException('Missing QEMU dependencies. Automatic installation currently supports apt-based development hosts.');
|
||||
}
|
||||
|
||||
$this->runOrFail('apt-get update');
|
||||
$this->runOrFail('DEBIAN_FRONTEND=noninteractive apt-get install -y curl iptables libvirt-clients libvirt-daemon-system qemu-utils qemu-system-x86 virtinst');
|
||||
}
|
||||
|
||||
private function configureLibvirtNetwork(): void
|
||||
{
|
||||
$network = config('development-qemu.libvirt_network');
|
||||
$networkInfo = Process::run('virsh net-info '.escapeshellarg($network));
|
||||
|
||||
if ($networkInfo->failed()) {
|
||||
$networkXml = config('development-qemu.storage_path').'/libvirt-network.xml';
|
||||
File::ensureDirectoryExists(dirname($networkXml), 0777, true);
|
||||
File::put($networkXml, $this->libvirtNetworkXml($network));
|
||||
$this->runOrFail('virsh net-define '.escapeshellarg($networkXml));
|
||||
$networkInfo = Process::result(output: 'Active: no');
|
||||
}
|
||||
|
||||
if (! preg_match('/^Active:\s+yes$/m', $networkInfo->output())) {
|
||||
$this->runOrFail('virsh net-start '.escapeshellarg($network));
|
||||
}
|
||||
|
||||
$this->runOrFail('virsh net-autostart '.escapeshellarg($network));
|
||||
}
|
||||
|
||||
private function configureIpForwarding(): void
|
||||
{
|
||||
$this->runOrFail("printf 'net.ipv4.ip_forward=1\\n' > /etc/sysctl.d/99-coolify-development-qemu.conf");
|
||||
$this->runOrFail('sysctl -w net.ipv4.ip_forward=1');
|
||||
}
|
||||
|
||||
private function configureStorage(): void
|
||||
{
|
||||
$directory = config('development-qemu.storage_path');
|
||||
File::ensureDirectoryExists($directory, 0777, true);
|
||||
File::chmod($directory, 0777);
|
||||
}
|
||||
|
||||
private function configureDockerForwarding(): void
|
||||
{
|
||||
$dockerNetwork = escapeshellarg(config('development-qemu.docker_network'));
|
||||
$subnetResult = Process::run("docker network inspect {$dockerNetwork} --format ".escapeshellarg('{{(index .IPAM.Config 0).Subnet}}'));
|
||||
$subnet = trim($subnetResult->output());
|
||||
|
||||
if ($subnetResult->failed() || $subnet === '') {
|
||||
throw new RuntimeException('Unable to determine the Coolify Docker network subnet.');
|
||||
}
|
||||
|
||||
$rule = sprintf('-s %s -d %s -o virbr0 -j ACCEPT', escapeshellarg($subnet), escapeshellarg(config('development-qemu.subnet')));
|
||||
|
||||
Process::run("iptables -D LIBVIRT_FWI {$rule}");
|
||||
$this->runOrFail("iptables -I LIBVIRT_FWI 1 {$rule}");
|
||||
}
|
||||
|
||||
private function libvirtNetworkXml(string $network): string
|
||||
{
|
||||
return <<<XML
|
||||
<network>
|
||||
<name>{$network}</name>
|
||||
<forward mode="nat"/>
|
||||
<bridge name="virbr0" stp="on" delay="0"/>
|
||||
<ip address="192.168.122.1" netmask="255.255.255.0">
|
||||
<dhcp>
|
||||
<range start="192.168.122.2" end="192.168.122.254"/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
</network>
|
||||
XML;
|
||||
}
|
||||
|
||||
private function runOrFail(string $command): void
|
||||
{
|
||||
$result = Process::forever()->run($command);
|
||||
|
||||
if ($result->failed()) {
|
||||
throw new RuntimeException(trim($result->errorOutput()) ?: "Command failed: {$command}");
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureDevelopmentEnvironment(): void
|
||||
{
|
||||
if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) {
|
||||
throw new RuntimeException('QEMU host configuration may only run in development environments.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Development;
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class ManageDevelopmentQemuVm
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/** @param string|array<int, string> $profileNames */
|
||||
public function handle(string|array $profileNames): void
|
||||
{
|
||||
$profileNames = is_array($profileNames) ? array_values(array_unique($profileNames)) : [$profileNames];
|
||||
|
||||
foreach ($profileNames as $index => $profileName) {
|
||||
StartDevelopmentQemuVm::run($profileName, $index === 0);
|
||||
|
||||
try {
|
||||
SeedDevelopmentQemuServer::run($profileName, $index === 0);
|
||||
} catch (QueryException $exception) {
|
||||
$keepOthers = $index === 0 ? '' : ' --keep-others';
|
||||
$result = Process::run('docker exec coolify php artisan dev:qemu:seed '.escapeshellarg($profileName).$keepOthers);
|
||||
|
||||
if ($result->failed()) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Development;
|
||||
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use InvalidArgumentException;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use RuntimeException;
|
||||
|
||||
class SeedDevelopmentQemuServer
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(string $profileName, bool $removeOtherServers = true): Server
|
||||
{
|
||||
$this->ensureDevelopmentEnvironment();
|
||||
$profile = config("development-qemu.profiles.{$profileName}");
|
||||
|
||||
if (! is_array($profile)) {
|
||||
throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}");
|
||||
}
|
||||
|
||||
$privateKey = PrivateKey::query()->find(1);
|
||||
|
||||
if (! $privateKey) {
|
||||
throw new RuntimeException('Development private key 1 is missing. Run the development database seeders first.');
|
||||
}
|
||||
|
||||
if ($removeOtherServers) {
|
||||
Server::query()
|
||||
->where('uuid', 'like', 'development-qemu-%')
|
||||
->where('uuid', '!=', $profile['uuid'])
|
||||
->delete();
|
||||
}
|
||||
|
||||
$server = Server::withTrashed()->where('uuid', $profile['uuid'])->first() ?? new Server;
|
||||
$server->forceFill(['uuid' => $profile['uuid']]);
|
||||
$server->fill([
|
||||
'name' => $profile['name'],
|
||||
'description' => 'Development-only QEMU virtual machine managed by dev:qemu.',
|
||||
'ip' => $profile['ip'],
|
||||
'port' => 22,
|
||||
'user' => $profile['user'],
|
||||
'team_id' => 0,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$server->deleted_at = null;
|
||||
$server->save();
|
||||
|
||||
return $server->fresh();
|
||||
}
|
||||
|
||||
private function ensureDevelopmentEnvironment(): void
|
||||
{
|
||||
if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) {
|
||||
throw new RuntimeException('QEMU VM servers may only be seeded in development environments.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Development;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use InvalidArgumentException;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use RuntimeException;
|
||||
|
||||
class StartDevelopmentQemuVm
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(string $profileName, bool $resetManagedVms = true): void
|
||||
{
|
||||
$this->ensureDevelopmentEnvironment();
|
||||
$profiles = config('development-qemu.profiles');
|
||||
$profile = $profiles[$profileName] ?? null;
|
||||
|
||||
if (! is_array($profile)) {
|
||||
throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}");
|
||||
}
|
||||
|
||||
ConfigureDevelopmentQemuHost::run();
|
||||
$this->configureDhcpReservation($profile);
|
||||
|
||||
if ($resetManagedVms) {
|
||||
foreach ($profiles as $managedProfile) {
|
||||
Process::run('virsh destroy '.escapeshellarg($managedProfile['domain']));
|
||||
Process::run('virsh undefine '.escapeshellarg($managedProfile['domain']));
|
||||
$this->deleteVmData($managedProfile['domain']);
|
||||
}
|
||||
}
|
||||
|
||||
$this->createVm($profile);
|
||||
|
||||
ConfigureDevelopmentQemuHost::run();
|
||||
$this->waitForSsh($profile['ip']);
|
||||
}
|
||||
|
||||
/** @param array{domain: string, ip: string, user: string, mac: string, image: string, image_url: string, os_variant: string, provisioner: string} $profile */
|
||||
private function createVm(array $profile): void
|
||||
{
|
||||
$directory = config('development-qemu.storage_path');
|
||||
File::ensureDirectoryExists($directory);
|
||||
File::chmod($directory, 0777);
|
||||
$this->moveLegacyFiles($directory);
|
||||
$baseImage = "{$directory}/{$profile['image']}";
|
||||
$disk = "{$directory}/{$profile['domain']}.qcow2";
|
||||
$userData = "{$directory}/{$profile['domain']}-user-data.yaml";
|
||||
$networkConfig = "{$directory}/{$profile['domain']}-network.yaml";
|
||||
|
||||
if (! File::exists($baseImage)) {
|
||||
$this->runOrFail(sprintf(
|
||||
'curl --fail --location --output %s %s',
|
||||
escapeshellarg($baseImage),
|
||||
escapeshellarg($profile['image_url']),
|
||||
));
|
||||
}
|
||||
|
||||
if (! File::exists($disk)) {
|
||||
$this->runOrFail(sprintf(
|
||||
'qemu-img create -f qcow2 -F qcow2 -b %s %s %s',
|
||||
escapeshellarg($baseImage),
|
||||
escapeshellarg($disk),
|
||||
escapeshellarg(config('development-qemu.disk_size')),
|
||||
));
|
||||
}
|
||||
|
||||
if (File::exists($baseImage)) {
|
||||
File::chmod($baseImage, 0644);
|
||||
}
|
||||
|
||||
if (File::exists($disk)) {
|
||||
File::chmod($disk, 0666);
|
||||
}
|
||||
|
||||
File::put($userData, $this->userData($profile));
|
||||
File::put($networkConfig, $this->networkConfig($profile));
|
||||
|
||||
$this->runOrFail(sprintf(
|
||||
'virt-install --connect qemu:///system --name %s --memory %d --vcpus %d --import --os-variant %s --disk path=%s,format=qcow2,bus=virtio --network network=%s,model=virtio,mac=%s --cloud-init user-data=%s,network-config=%s,disable=on --noautoconsole',
|
||||
escapeshellarg($profile['domain']),
|
||||
config('development-qemu.memory'),
|
||||
config('development-qemu.vcpus'),
|
||||
escapeshellarg($profile['os_variant']),
|
||||
escapeshellarg($disk),
|
||||
escapeshellarg(config('development-qemu.libvirt_network')),
|
||||
escapeshellarg($profile['mac']),
|
||||
escapeshellarg($userData),
|
||||
escapeshellarg($networkConfig),
|
||||
));
|
||||
}
|
||||
|
||||
private function moveLegacyFiles(string $directory): void
|
||||
{
|
||||
$legacyDirectory = storage_path('app/development-qemu');
|
||||
|
||||
if ($legacyDirectory === $directory || ! File::isDirectory($legacyDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (File::files($legacyDirectory) as $file) {
|
||||
$destination = "{$directory}/{$file->getFilename()}";
|
||||
|
||||
if (! File::exists($destination)) {
|
||||
File::move($file->getPathname(), $destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteVmData(string $domain): void
|
||||
{
|
||||
$directory = config('development-qemu.storage_path');
|
||||
File::delete([
|
||||
"{$directory}/{$domain}.qcow2",
|
||||
"{$directory}/{$domain}-user-data.yaml",
|
||||
"{$directory}/{$domain}-network.yaml",
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array{user: string, provisioner: string} $profile */
|
||||
private function userData(array $profile): string
|
||||
{
|
||||
$publicKey = config('development-qemu.public_key');
|
||||
$adminGroup = $profile['provisioner'] === 'apt' ? 'sudo' : 'wheel';
|
||||
$sudo = $profile['user'] === 'root' ? '' : " groups: [{$adminGroup}]\n sudo: ALL=(ALL) NOPASSWD:ALL\n";
|
||||
|
||||
[$packages, $startDocker] = match ($profile['provisioner']) {
|
||||
'apk' => [" - docker\n - sudo", 'rc-update add docker default && service docker start'],
|
||||
'rpm' => [" - curl\n - sudo", 'curl -fsSL https://get.docker.com | sh && systemctl enable --now docker'],
|
||||
default => [" - docker.io\n - sudo", 'systemctl enable --now docker'],
|
||||
};
|
||||
$addUserToDockerGroup = $profile['user'] === 'root' ? '' : "\n - usermod -aG docker {$profile['user']}";
|
||||
|
||||
return <<<YAML
|
||||
#cloud-config
|
||||
disable_root: false
|
||||
users:
|
||||
- name: {$profile['user']}
|
||||
{$sudo} shell: /bin/bash
|
||||
lock_passwd: true
|
||||
ssh_authorized_keys:
|
||||
- {$publicKey}
|
||||
package_update: true
|
||||
packages:
|
||||
{$packages}
|
||||
runcmd:
|
||||
- {$startDocker}{$addUserToDockerGroup}
|
||||
YAML;
|
||||
}
|
||||
|
||||
/** @param array{mac: string} $profile */
|
||||
private function networkConfig(array $profile): string
|
||||
{
|
||||
return <<<YAML
|
||||
version: 2
|
||||
ethernets:
|
||||
default:
|
||||
match:
|
||||
macaddress: "{$profile['mac']}"
|
||||
dhcp4: true
|
||||
YAML;
|
||||
}
|
||||
|
||||
/** @param array{domain: string, ip: string, mac: string} $profile */
|
||||
private function configureDhcpReservation(array $profile): void
|
||||
{
|
||||
$network = escapeshellarg(config('development-qemu.libvirt_network'));
|
||||
$networkXml = Process::run("virsh net-dumpxml {$network}");
|
||||
|
||||
if ($networkXml->failed()) {
|
||||
throw new RuntimeException(trim($networkXml->errorOutput()) ?: 'Unable to inspect the libvirt network.');
|
||||
}
|
||||
|
||||
if (str_contains($networkXml->output(), $profile['mac']) && str_contains($networkXml->output(), $profile['ip'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$host = sprintf("<host mac='%s' name='%s' ip='%s'/>", $profile['mac'], $profile['domain'], $profile['ip']);
|
||||
$this->runOrFail("virsh net-update {$network} add-last ip-dhcp-host ".escapeshellarg($host).' --live --config');
|
||||
}
|
||||
|
||||
private function waitForSsh(string $ip): void
|
||||
{
|
||||
$container = escapeshellarg(config('development-qemu.coolify_container'));
|
||||
$probe = <<<'PHP'
|
||||
$deadline = time() + 120;
|
||||
do {
|
||||
$socket = @fsockopen($argv[1], 22, $errorCode, $errorMessage, 1);
|
||||
if (is_resource($socket)) {
|
||||
fclose($socket);
|
||||
exit(0);
|
||||
}
|
||||
sleep(1);
|
||||
} while (time() < $deadline);
|
||||
exit(1);
|
||||
PHP;
|
||||
$this->runOrFail("docker exec {$container} php -r ".escapeshellarg($probe).' '.escapeshellarg($ip));
|
||||
}
|
||||
|
||||
private function runOrFail(string $command): void
|
||||
{
|
||||
$result = Process::forever()->run($command);
|
||||
|
||||
if ($result->failed()) {
|
||||
throw new RuntimeException(trim($result->errorOutput()) ?: "Command failed: {$command}");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function ensureDevelopmentEnvironment(): void
|
||||
{
|
||||
if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) {
|
||||
throw new RuntimeException('QEMU VMs may only be managed in development environments.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ class GetProxyConfiguration
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public const MAX_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
public function handle(Server $server, bool $forceRegenerate = false): string
|
||||
{
|
||||
$proxyType = $server->proxyType();
|
||||
@@ -98,11 +100,17 @@ class GetProxyConfiguration
|
||||
private function backfillFromDisk(Server $server): ?string
|
||||
{
|
||||
$proxy_path = $server->proxyPath();
|
||||
$configurationPath = escapeshellarg("$proxy_path/docker-compose.yml");
|
||||
$readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1;
|
||||
$result = instant_remote_process([
|
||||
"mkdir -p $proxy_path",
|
||||
"cat $proxy_path/docker-compose.yml 2>/dev/null",
|
||||
"if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi",
|
||||
], $server, false);
|
||||
|
||||
if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__' || strlen($result ?? '') > self::MAX_CONFIGURATION_SIZE_BYTES) {
|
||||
throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.');
|
||||
}
|
||||
|
||||
if (! empty(trim($result ?? ''))) {
|
||||
$server->proxy->last_saved_proxy_configuration = $result;
|
||||
$server->save();
|
||||
|
||||
@@ -2,77 +2,52 @@
|
||||
|
||||
namespace App\Actions\Service;
|
||||
|
||||
use App\Actions\Server\CleanupDocker;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class DeleteService
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations, bool $dockerCleanup)
|
||||
public function cleanupRemote(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations): void
|
||||
{
|
||||
try {
|
||||
$server = data_get($service, 'server');
|
||||
if ($deleteVolumes && $server->isFunctional()) {
|
||||
$storagesToDelete = collect([]);
|
||||
|
||||
$service->environment_variables()->delete();
|
||||
$commands = [];
|
||||
foreach ($service->applications()->get() as $application) {
|
||||
$storages = $application->persistentStorages()->get();
|
||||
foreach ($storages as $storage) {
|
||||
$storagesToDelete->push($storage);
|
||||
}
|
||||
}
|
||||
foreach ($service->databases()->get() as $database) {
|
||||
$storages = $database->persistentStorages()->get();
|
||||
foreach ($storages as $storage) {
|
||||
$storagesToDelete->push($storage);
|
||||
}
|
||||
}
|
||||
foreach ($storagesToDelete as $storage) {
|
||||
$server = data_get($service, 'server');
|
||||
if ($deleteVolumes && $server->isFunctional()) {
|
||||
$commands = [];
|
||||
foreach ($service->applications()->get() as $application) {
|
||||
foreach ($application->persistentStorages()->get() as $storage) {
|
||||
$commands[] = 'docker volume rm -f '.escapeshellarg($storage->name);
|
||||
}
|
||||
|
||||
// Execute volume deletion first, this must be done first otherwise volumes will not be deleted.
|
||||
if (! empty($commands)) {
|
||||
foreach ($commands as $command) {
|
||||
$result = instant_remote_process([$command], $server, false);
|
||||
if ($result !== null && $result !== 0) {
|
||||
Log::error('Error deleting volumes: '.$result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteConnectedNetworks) {
|
||||
$service->deleteConnectedNetworks();
|
||||
}
|
||||
|
||||
instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false);
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException($e->getMessage());
|
||||
} finally {
|
||||
if ($deleteConfigurations) {
|
||||
$service->deleteConfigurations();
|
||||
}
|
||||
foreach ($service->applications()->get() as $application) {
|
||||
$application->forceDelete();
|
||||
}
|
||||
foreach ($service->databases()->get() as $database) {
|
||||
$database->forceDelete();
|
||||
foreach ($database->persistentStorages()->get() as $storage) {
|
||||
$commands[] = 'docker volume rm -f '.escapeshellarg($storage->name);
|
||||
}
|
||||
}
|
||||
foreach ($service->scheduled_tasks as $task) {
|
||||
$task->delete();
|
||||
}
|
||||
$service->tags()->detach();
|
||||
$service->forceDelete();
|
||||
|
||||
if ($dockerCleanup) {
|
||||
CleanupDocker::dispatch($server, false, false);
|
||||
foreach ($commands as $command) {
|
||||
instant_remote_process([$command], $server, false);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteConnectedNetworks) {
|
||||
$service->deleteConnectedNetworks();
|
||||
}
|
||||
if ($deleteConfigurations) {
|
||||
$service->deleteConfigurations();
|
||||
}
|
||||
instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false);
|
||||
}
|
||||
|
||||
public function deleteLocal(Service $service): void
|
||||
{
|
||||
foreach ($service->applications()->get() as $application) {
|
||||
$application->forceDelete();
|
||||
}
|
||||
foreach ($service->databases()->get() as $database) {
|
||||
$database->forceDelete();
|
||||
}
|
||||
foreach ($service->scheduled_tasks as $task) {
|
||||
$task->delete();
|
||||
}
|
||||
$service->environment_variables()->delete();
|
||||
$service->tags()->detach();
|
||||
$service->forceDelete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Shared;
|
||||
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use PurplePixie\PhpDns\DNSQuery;
|
||||
use PurplePixie\PhpDns\DNSTypes;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class CheckDomainDns
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
* @return array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}>
|
||||
*/
|
||||
public function handle(
|
||||
array $entries,
|
||||
?Server $server,
|
||||
?string $expectedIp,
|
||||
bool $skipForMultipleServers = false,
|
||||
int $timeoutSeconds = 5,
|
||||
): array {
|
||||
if (! data_get(instanceSettings(), 'is_dns_validation_enabled')) {
|
||||
return $this->sameResultForAll($entries, 'skipped', 'DNS validation is disabled in instance settings.', $expectedIp);
|
||||
}
|
||||
|
||||
if (! $server) {
|
||||
return $this->sameResultForAll($entries, 'skipped', 'No server available for DNS validation.', null);
|
||||
}
|
||||
|
||||
if ($skipForMultipleServers) {
|
||||
return $this->sameResultForAll($entries, 'skipped', 'DNS check skipped for multi-server applications.', $expectedIp);
|
||||
}
|
||||
|
||||
$deadline = hrtime(true) + ($timeoutSeconds * 1_000_000_000);
|
||||
$dnsServers = str(data_get(instanceSettings(), 'custom_dns_servers'))
|
||||
->explode(',')
|
||||
->map(fn ($dnsServer) => trim((string) $dnsServer))
|
||||
->filter()
|
||||
->values();
|
||||
$results = [];
|
||||
|
||||
foreach ($entries as $key => $url) {
|
||||
$results[$key] = $this->check($url, $server, $expectedIp, $dnsServers->all(), $deadline);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $dnsServers
|
||||
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
|
||||
*/
|
||||
private function check(string $url, Server $server, ?string $expectedIp, array $dnsServers, int $deadline): array
|
||||
{
|
||||
try {
|
||||
$host = Url::fromString($url)->getHost();
|
||||
} catch (\Throwable) {
|
||||
return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp);
|
||||
}
|
||||
if (str($host)->contains('sslip.io')) {
|
||||
return $this->result('ok', 'DNS looks correct.', $expectedIp);
|
||||
}
|
||||
|
||||
$type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
|
||||
|
||||
foreach ($dnsServers as $dnsServer) {
|
||||
$remainingNanoseconds = $deadline - hrtime(true);
|
||||
if ($remainingNanoseconds < 1_000_000_000) {
|
||||
return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp);
|
||||
}
|
||||
|
||||
try {
|
||||
$query = app()->make(DNSQuery::class, [
|
||||
'server' => $dnsServer,
|
||||
'port' => 53,
|
||||
'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)),
|
||||
]);
|
||||
$records = $query->query($host, $type);
|
||||
|
||||
if ($records === false || $query->hasError()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($records as $record) {
|
||||
if ($record->getType() !== $type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isCloudflareIp($record->getData()) || ($expectedIp && $record->getData() === $expectedIp)) {
|
||||
return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp);
|
||||
}
|
||||
|
||||
private function successMessage(Server $server, ?string $expectedIp): string
|
||||
{
|
||||
if (
|
||||
filled($expectedIp)
|
||||
&& filled($server->ip)
|
||||
&& $server->ip !== $expectedIp
|
||||
&& filter_var($server->ip, FILTER_VALIDATE_IP) === false
|
||||
) {
|
||||
return "DNS points to {$expectedIp} ({$server->ip}) (or Cloudflare).";
|
||||
}
|
||||
|
||||
return $expectedIp ? "DNS points to {$expectedIp} (or Cloudflare)." : 'DNS looks correct.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
|
||||
*/
|
||||
private function result(string $status, string $message, ?string $expectedIp): array
|
||||
{
|
||||
return [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'expected_ip' => $expectedIp,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
* @return array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}>
|
||||
*/
|
||||
private function sameResultForAll(array $entries, string $status, string $message, ?string $expectedIp): array
|
||||
{
|
||||
$result = $this->result($status, $message, $expectedIp);
|
||||
|
||||
return array_fill_keys(array_keys($entries), $result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Team;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class DeleteTeam
|
||||
{
|
||||
public function handle(Team $team, User $user): ?Team
|
||||
{
|
||||
$newTeam = DB::transaction(function () use ($team, $user): ?Team {
|
||||
$team = Team::query()->lockForUpdate()->findOrFail($team->id);
|
||||
|
||||
$role = DB::table('team_user')
|
||||
->where('team_id', $team->id)
|
||||
->where('user_id', $user->id)
|
||||
->lockForUpdate()
|
||||
->value('role');
|
||||
|
||||
if ($role !== 'owner') {
|
||||
throw new AuthorizationException('Only team owners can delete a team.');
|
||||
}
|
||||
|
||||
$hasRunningApplications = Application::query()
|
||||
->whereHas('environment.project', fn ($query) => $query->where('team_id', $team->id))
|
||||
->lockForUpdate()
|
||||
->get(['id', 'status'])
|
||||
->contains(fn (Application $application): bool => $application->isRunning());
|
||||
|
||||
if ($hasRunningApplications) {
|
||||
throw new RuntimeException('Stop all running applications before deleting this team.');
|
||||
}
|
||||
|
||||
if ($team->servers()->lockForUpdate()->get(['servers.id'])->isNotEmpty()) {
|
||||
throw new RuntimeException('Delete all team servers before deleting this team.');
|
||||
}
|
||||
|
||||
if (! $team->isEmpty()) {
|
||||
throw new RuntimeException('Delete all team resources before deleting this team.');
|
||||
}
|
||||
|
||||
$team->members()
|
||||
->where('users.id', '!=', $user->id)
|
||||
->get()
|
||||
->each(function (User $member) use ($team): void {
|
||||
$member->teams()->detach($team);
|
||||
DB::table('sessions')->where('user_id', $member->id)->delete();
|
||||
});
|
||||
|
||||
$team->delete();
|
||||
|
||||
return $user->teams()->first();
|
||||
});
|
||||
|
||||
Cache::forget("user:{$user->id}:team:{$team->id}");
|
||||
|
||||
return $newTeam;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\Development\ManageDevelopmentQemuVm;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
use function Laravel\Prompts\multiselect;
|
||||
|
||||
class ManageDevelopmentQemuVmCommand extends Command
|
||||
{
|
||||
protected $signature = 'dev:qemu {profiles?* : Profile keys from config/development-qemu.php}';
|
||||
|
||||
protected $description = 'Recreate selected development QEMU VMs and seed their Coolify servers';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! isDev()) {
|
||||
$this->error('This command may only run in development mode.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$profiles = config('development-qemu.profiles');
|
||||
$profileNames = $this->argument('profiles') ?: multiselect(
|
||||
label: 'Which QEMU servers should be started and seeded?',
|
||||
options: collect($profiles)->mapWithKeys(fn (array $profile, string $key) => [$key => $profile['label']])->all(),
|
||||
required: true,
|
||||
);
|
||||
|
||||
ManageDevelopmentQemuVm::run($profileNames);
|
||||
|
||||
foreach ($profileNames as $profileName) {
|
||||
$profile = $profiles[$profileName];
|
||||
$this->info("Started and seeded {$profile['label']} at {$profile['ip']}.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\Development\SeedDevelopmentQemuServer;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SeedDevelopmentQemuServerCommand extends Command
|
||||
{
|
||||
protected $signature = 'dev:qemu:seed {profile : Profile key from config/development-qemu.php} {--keep-others}';
|
||||
|
||||
protected $description = 'Seed one development QEMU server in the Coolify database';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! isDev()) {
|
||||
$this->error('This command may only run in development mode.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$server = SeedDevelopmentQemuServer::run($this->argument('profile'), ! $this->option('keep-others'));
|
||||
$this->info("Seeded {$server->name} at {$server->ip}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -87,15 +87,48 @@ class SshMultiplexingHelper
|
||||
return false;
|
||||
}
|
||||
|
||||
self::storeConnectionMetadata($server);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function removeMuxFile(Server $server): void
|
||||
{
|
||||
Process::run(self::muxControlCommand($server, 'exit'));
|
||||
self::clearConnectionMetadata($server);
|
||||
$checkProcess = Process::run(self::muxControlCommand($server, 'check'));
|
||||
$pid = preg_match('/pid=(\d+)/', $checkProcess->output().$checkProcess->errorOutput(), $matches)
|
||||
? $matches[1]
|
||||
: null;
|
||||
|
||||
if ($pid !== null) {
|
||||
self::markMuxProcessAsRetiring($pid, self::muxSocket($server));
|
||||
}
|
||||
|
||||
$stopProcess = Process::run(self::muxControlCommand($server, 'stop'));
|
||||
|
||||
if ($pid !== null && ! $stopProcess->successful()) {
|
||||
self::unmarkMuxProcessAsRetiring($pid, self::muxSocket($server));
|
||||
}
|
||||
}
|
||||
|
||||
public static function markMuxProcessAsRetiring(string $pid, string $muxSocket, ?string $processStartTime = null): void
|
||||
{
|
||||
$processStartTime ??= self::processStartTime($pid);
|
||||
Cache::forever(self::muxProcessRetirementKey($pid, $muxSocket, $processStartTime), true);
|
||||
}
|
||||
|
||||
public static function isMuxProcessRetiring(string $pid, string $muxSocket, ?string $processStartTime = null): bool
|
||||
{
|
||||
$processStartTime ??= self::processStartTime($pid);
|
||||
$key = self::muxProcessRetirementKey($pid, $muxSocket, $processStartTime);
|
||||
if (! Cache::has($key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function unmarkMuxProcessAsRetiring(string $pid, string $muxSocket, ?string $processStartTime = null): void
|
||||
{
|
||||
$processStartTime ??= self::processStartTime($pid);
|
||||
Cache::forget(self::muxProcessRetirementKey($pid, $muxSocket, $processStartTime));
|
||||
}
|
||||
|
||||
public static function generateScpCommand(Server $server, string $source, string $dest): string
|
||||
@@ -242,25 +275,6 @@ class SshMultiplexingHelper
|
||||
return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
|
||||
}
|
||||
|
||||
public static function isConnectionExpired(Server $server): bool
|
||||
{
|
||||
$connectionAge = self::getConnectionAge($server);
|
||||
$maxAge = config('constants.ssh.mux_max_age');
|
||||
|
||||
return $connectionAge !== null && $connectionAge > $maxAge;
|
||||
}
|
||||
|
||||
public static function getConnectionAge(Server $server): ?int
|
||||
{
|
||||
$connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}");
|
||||
|
||||
if ($connectionTime === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return time() - $connectionTime;
|
||||
}
|
||||
|
||||
public static function refreshMultiplexedConnection(Server $server): bool
|
||||
{
|
||||
self::removeMuxFile($server);
|
||||
@@ -273,6 +287,28 @@ class SshMultiplexingHelper
|
||||
return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid;
|
||||
}
|
||||
|
||||
private static function muxProcessRetirementKey(string $pid, string $muxSocket, ?string $processStartTime): string
|
||||
{
|
||||
return 'ssh_mux_retiring_'.hash('sha256', self::processScope().'|'.$pid.'|'.$processStartTime.'|'.$muxSocket);
|
||||
}
|
||||
|
||||
private static function processScope(): string
|
||||
{
|
||||
return (gethostname() ?: 'unknown').'|'.(@readlink('/proc/self/ns/pid') ?: 'unknown');
|
||||
}
|
||||
|
||||
private static function processStartTime(string $pid): ?string
|
||||
{
|
||||
$stat = @file_get_contents("/proc/{$pid}/stat");
|
||||
if ($stat === false || ! preg_match('/^\d+ \(.*\) (.*)$/', trim($stat), $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$fields = preg_split('/\s+/', $matches[1]);
|
||||
|
||||
return $fields[19] ?? null;
|
||||
}
|
||||
|
||||
private static function masterConnectionExists(Server $server): bool
|
||||
{
|
||||
return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0;
|
||||
@@ -284,14 +320,6 @@ class SshMultiplexingHelper
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self::getConnectionAge($server) === null) {
|
||||
self::storeConnectionMetadata($server);
|
||||
}
|
||||
|
||||
if (self::isConnectionExpired($server)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) {
|
||||
return false;
|
||||
}
|
||||
@@ -382,14 +410,4 @@ class SshMultiplexingHelper
|
||||
|
||||
return $options.'-p '.escapeshellarg((string) $server->port).' ';
|
||||
}
|
||||
|
||||
private static function storeConnectionMetadata(Server $server): void
|
||||
{
|
||||
Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300);
|
||||
}
|
||||
|
||||
private static function clearConnectionMetadata(Server $server): void
|
||||
{
|
||||
Cache::forget("ssh_mux_connection_time_{$server->uuid}");
|
||||
}
|
||||
}
|
||||
@@ -1604,7 +1604,12 @@ class ApplicationsController extends Controller
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first();
|
||||
$githubApp = GithubApp::where('uuid', $githubAppUuid)
|
||||
->where(function ($query) use ($teamId) {
|
||||
$query->where('team_id', $teamId)
|
||||
->orWhere('is_system_wide', true);
|
||||
})
|
||||
->first();
|
||||
if (! $githubApp) {
|
||||
return response()->json(['message' => 'Github App not found.'], 404);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class GithubController extends Controller
|
||||
{
|
||||
private function removeSensitiveData($githubApp)
|
||||
private function removeSensitiveData(GithubApp $githubApp, int $teamId)
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true && $githubApp->team_id === $teamId) {
|
||||
$githubApp->makeVisible([
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
@@ -97,8 +97,8 @@ class GithubController extends Controller
|
||||
->orWhere('is_system_wide', true);
|
||||
})->get();
|
||||
|
||||
$githubApps = $githubApps->map(function ($app) {
|
||||
return $this->removeSensitiveData($app);
|
||||
$githubApps = $githubApps->map(function ($app) use ($teamId) {
|
||||
return $this->removeSensitiveData($app, $teamId);
|
||||
});
|
||||
|
||||
return response()->json($githubApps);
|
||||
@@ -642,7 +642,7 @@ class GithubController extends Controller
|
||||
$rules['webhook_secret'] = 'string';
|
||||
}
|
||||
if (isset($payload['private_key_uuid'])) {
|
||||
$rules['private_key_uuid'] = 'string|uuid';
|
||||
$rules['private_key_uuid'] = 'string';
|
||||
}
|
||||
if (! isCloud() && isset($payload['is_system_wide'])) {
|
||||
$rules['is_system_wide'] = 'boolean';
|
||||
|
||||
@@ -13,9 +13,9 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class GitlabController extends Controller
|
||||
{
|
||||
private function removeSensitiveData(GitlabApp $gitlabApp)
|
||||
private function removeSensitiveData(GitlabApp $gitlabApp, int $teamId)
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true && $gitlabApp->team_id === $teamId) {
|
||||
$gitlabApp->makeVisible([
|
||||
'client_secret',
|
||||
'webhook_token',
|
||||
@@ -108,8 +108,8 @@ class GitlabController extends Controller
|
||||
->orWhere('is_system_wide', true);
|
||||
})->get();
|
||||
|
||||
$gitlabApps = $gitlabApps->map(function ($app) {
|
||||
return $this->removeSensitiveData($app);
|
||||
$gitlabApps = $gitlabApps->map(function ($app) use ($teamId) {
|
||||
return $this->removeSensitiveData($app, $teamId);
|
||||
});
|
||||
|
||||
return response()->json($gitlabApps);
|
||||
@@ -280,7 +280,7 @@ class GitlabController extends Controller
|
||||
'gitlab_app_name' => $gitlabApp->name,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($gitlabApp->fresh()), 201);
|
||||
return response()->json($this->removeSensitiveData($gitlabApp->fresh(), $teamId), 201);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
@@ -441,7 +441,7 @@ class GitlabController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitLab app updated successfully',
|
||||
'data' => $this->removeSensitiveData($gitlabApp->fresh()),
|
||||
'data' => $this->removeSensitiveData($gitlabApp->fresh(), $teamId),
|
||||
]);
|
||||
} catch (ModelNotFoundException $e) {
|
||||
return response()->json([
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Rules\ValidHostname;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class InstanceEmailSettingsController extends Controller
|
||||
{
|
||||
private const FIELDS = [
|
||||
'smtp_enabled', 'smtp_from_address', 'smtp_from_name', 'smtp_host',
|
||||
'smtp_port', 'smtp_encryption', 'smtp_username', 'smtp_password',
|
||||
'smtp_timeout', 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key',
|
||||
];
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get instance email settings',
|
||||
description: 'Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.',
|
||||
path: '/settings/email', operationId: 'get-instance-email-settings',
|
||||
security: [['bearerAuth' => []]], tags: ['Settings'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Instance email settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
]
|
||||
)]
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
$settings = InstanceSettings::get();
|
||||
$this->authorizeRootTeam('view', $settings);
|
||||
|
||||
return response()->json($this->serialize($settings));
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update instance email settings',
|
||||
description: 'Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.',
|
||||
path: '/settings/email', operationId: 'update-instance-email-settings',
|
||||
security: [['bearerAuth' => []]], tags: ['Settings'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Updated instance email settings.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Forbidden.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$settings = InstanceSettings::get();
|
||||
$this->authorizeRootTeam('update', $settings);
|
||||
|
||||
$validator = customApiValidator($request->json()->all(), [
|
||||
'smtp_enabled' => 'sometimes|boolean',
|
||||
'smtp_from_address' => 'sometimes|nullable|email',
|
||||
'smtp_from_name' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_host' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_port' => 'sometimes|nullable|integer|min:1|max:65535',
|
||||
'smtp_encryption' => 'sometimes|nullable|string|in:starttls,tls,none',
|
||||
'smtp_username' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_password' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_timeout' => 'sometimes|nullable|integer|min:0',
|
||||
'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname],
|
||||
'resend_enabled' => 'sometimes|boolean',
|
||||
'resend_api_key' => 'sometimes|nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$settings->fill($validator->validated());
|
||||
$settings->save();
|
||||
|
||||
auditLog('api.settings.email.updated', ['changed_fields' => array_keys($validator->validated())]);
|
||||
|
||||
return response()->json($this->serialize($settings->refresh()));
|
||||
}
|
||||
|
||||
private function authorizeRootTeam(string $ability, InstanceSettings $settings): void
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
abort_unless(! is_null($teamId) && (int) $teamId === 0, 403, 'Instance email settings require a root-team API token.');
|
||||
$this->authorize($ability, $settings);
|
||||
}
|
||||
|
||||
private function serialize(InstanceSettings $settings): array
|
||||
{
|
||||
exposeSensitiveFields($settings);
|
||||
|
||||
return Arr::only($settings->toArray(), self::FIELDS);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Models\Team;
|
||||
use App\Models\TelegramNotificationSettings;
|
||||
use App\Models\WebhookNotificationSettings;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Rules\ValidHostname;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -37,6 +38,7 @@ class NotificationsController extends Controller
|
||||
'smtp_username' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_password' => 'sometimes|nullable|string|max:255',
|
||||
'smtp_timeout' => 'sometimes|nullable|integer|min:0',
|
||||
'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname],
|
||||
'resend_enabled' => 'sometimes|boolean',
|
||||
'resend_api_key' => 'sometimes|nullable|string|max:255',
|
||||
'use_instance_email_settings' => 'sometimes|boolean',
|
||||
@@ -283,7 +285,7 @@ class NotificationsController extends Controller
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get email notification settings',
|
||||
description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
|
||||
description: 'Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
|
||||
path: '/notifications/email',
|
||||
operationId: 'get-current-team-email-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -301,7 +303,7 @@ class NotificationsController extends Controller
|
||||
|
||||
#[OA\Patch(
|
||||
summary: 'Update email notification settings',
|
||||
description: 'Update the current team email notification settings.',
|
||||
description: 'Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.',
|
||||
path: '/notifications/email',
|
||||
operationId: 'update-current-team-email-notifications',
|
||||
security: [['bearerAuth' => []]],
|
||||
|
||||
@@ -158,6 +158,8 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $project);
|
||||
|
||||
$environment = $project->environments()->whereName($request->environment_name_or_uuid)->first();
|
||||
if (! $environment) {
|
||||
$environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first();
|
||||
|
||||
@@ -550,11 +550,7 @@ class ServersController extends Controller
|
||||
}
|
||||
$foundServer = ModelsServer::whereIp($request->ip)->first();
|
||||
if ($foundServer) {
|
||||
if ($foundServer->team_id === $teamId) {
|
||||
return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400);
|
||||
return response()->json(['message' => 'A server with this IP/Domain is already in use.'], 400);
|
||||
}
|
||||
|
||||
$proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value;
|
||||
|
||||
@@ -56,7 +56,7 @@ class TeamController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$teams = auth()->user()->teams->sortBy('id');
|
||||
$teams = auth()->user()->teams->where('id', $teamId)->values();
|
||||
$teams = $teams->map(function ($team) {
|
||||
return $this->removeSensitiveData($team);
|
||||
});
|
||||
@@ -100,13 +100,14 @@ class TeamController extends Controller
|
||||
)]
|
||||
public function team_by_id(Request $request)
|
||||
{
|
||||
$id = $request->id;
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$teams = auth()->user()->teams;
|
||||
$team = $teams->where('id', $id)->first();
|
||||
if ((int) $request->id !== (int) $teamId) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
$team = auth()->user()->teams->where('id', $teamId)->first();
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
@@ -159,13 +160,14 @@ class TeamController extends Controller
|
||||
)]
|
||||
public function members_by_id(Request $request)
|
||||
{
|
||||
$id = $request->id;
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$teams = auth()->user()->teams;
|
||||
$team = $teams->where('id', $id)->first();
|
||||
if ((int) $request->id !== (int) $teamId) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
$team = auth()->user()->teams->where('id', $teamId)->first();
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ use RuntimeException;
|
||||
new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000),
|
||||
new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
|
||||
new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
|
||||
new OA\Property(property: 'timeout', type: 'integer', default: 3600, minimum: 60, maximum: 36000),
|
||||
new OA\Property(property: 'timeout', type: 'integer', default: ScheduledVolumeBackup::DEFAULT_TIMEOUT, minimum: 60, maximum: 36000),
|
||||
],
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -261,7 +261,7 @@ class VolumeBackupsController extends Controller
|
||||
string $resourceType,
|
||||
Model $resource,
|
||||
): JsonResponse {
|
||||
$backup = $storage->scheduledBackups()->updateOrCreate([], [
|
||||
$attributes = [
|
||||
'team_id' => $teamId,
|
||||
'frequency' => $request->string('frequency')->toString(),
|
||||
'enabled' => $request->boolean('enabled', true),
|
||||
@@ -275,8 +275,12 @@ class VolumeBackupsController extends Controller
|
||||
'retention_amount_s3' => $request->integer('retention_amount_s3', 7),
|
||||
'retention_days_s3' => $request->integer('retention_days_s3'),
|
||||
'retention_max_storage_s3' => $request->float('retention_max_storage_s3'),
|
||||
'timeout' => $request->integer('timeout', 3600),
|
||||
]);
|
||||
];
|
||||
if ($request->has('timeout')) {
|
||||
$attributes['timeout'] = $request->integer('timeout');
|
||||
}
|
||||
|
||||
$backup = $storage->scheduledBackups()->updateOrCreate([], $attributes);
|
||||
$created = $backup->wasRecentlyCreated;
|
||||
|
||||
auditLog('api.volume_backup.schedule_set', [
|
||||
|
||||
@@ -8,12 +8,15 @@ use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -95,61 +98,105 @@ class Controller extends BaseController
|
||||
return response()->json(['message' => 'Transactional emails are not active'], 400);
|
||||
}
|
||||
|
||||
public function link()
|
||||
public function link(): View|RedirectResponse
|
||||
{
|
||||
$token = request()->get('token');
|
||||
if (is_string($token) && $token !== '') {
|
||||
try {
|
||||
$decrypted = Crypt::decryptString($token);
|
||||
} catch (DecryptException) {
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
if (! str_contains($decrypted, '@@@')) {
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
}
|
||||
|
||||
$payload = explode('@@@', $decrypted, 3);
|
||||
if (count($payload) === 3) {
|
||||
[$email, $invitationUuid, $password] = $payload;
|
||||
} else {
|
||||
[$email, $password] = $payload;
|
||||
$invitationUuid = null;
|
||||
}
|
||||
|
||||
$email = Str::lower($email);
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$invitation = TeamInvitation::query()
|
||||
->where('email', $email)
|
||||
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
|
||||
->first();
|
||||
if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
|
||||
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||
}
|
||||
|
||||
if (Hash::check($password, $user->password)) {
|
||||
$team = $invitation->team;
|
||||
if (! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||
$user->teams()->attach($team->id, ['role' => $invitation->role]);
|
||||
}
|
||||
$invitation->delete();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
])->save();
|
||||
|
||||
Auth::login($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
$credentials = is_string($token) ? $this->magicLinkCredentials($token) : null;
|
||||
if (! $credentials) {
|
||||
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||
}
|
||||
|
||||
return redirect()->route('login')->with('error', 'Invalid credentials.');
|
||||
[$user, $invitation] = $credentials;
|
||||
|
||||
return view('invitation.accept', [
|
||||
'invitation' => $invitation,
|
||||
'team' => $invitation->team,
|
||||
'alreadyMember' => $user->teams()->where('team_id', $invitation->team_id)->exists(),
|
||||
'formAction' => route('auth.link.accept'),
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
|
||||
public function acceptLink(Request $request): RedirectResponse
|
||||
{
|
||||
$token = $request->input('token');
|
||||
if (! is_string($token)) {
|
||||
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||
}
|
||||
|
||||
$acceptedInvitation = DB::transaction(function () use ($token) {
|
||||
$credentials = $this->magicLinkCredentials($token, lockForUpdate: true);
|
||||
if (! $credentials) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$user, $invitation] = $credentials;
|
||||
$team = $invitation->team;
|
||||
if (! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||
$user->teams()->attach($team->id, ['role' => $invitation->role]);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
])->save();
|
||||
$invitation->delete();
|
||||
|
||||
return [$user, $team];
|
||||
});
|
||||
|
||||
if (! $acceptedInvitation) {
|
||||
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||
}
|
||||
|
||||
[$user, $team] = $acceptedInvitation;
|
||||
|
||||
Auth::login($user);
|
||||
session(['currentTeam' => $team]);
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: User, 1: TeamInvitation}|null
|
||||
*/
|
||||
private function magicLinkCredentials(string $token, bool $lockForUpdate = false): ?array
|
||||
{
|
||||
if ($token === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decrypted = Crypt::decryptString($token);
|
||||
} catch (DecryptException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = explode('@@@', $decrypted, 3);
|
||||
if (count($payload) === 3) {
|
||||
[$email, $invitationUuid, $password] = $payload;
|
||||
} elseif (count($payload) === 2) {
|
||||
[$email, $password] = $payload;
|
||||
$invitationUuid = null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
$email = Str::lower($email);
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
$invitationQuery = TeamInvitation::query()
|
||||
->where('email', $email)
|
||||
->when($lockForUpdate, fn ($query) => $query->lockForUpdate());
|
||||
$invitation = $invitationUuid
|
||||
? $invitationQuery->where('uuid', $invitationUuid)->first()
|
||||
: $invitationQuery->get()->first(
|
||||
fn (TeamInvitation $invitation) => $this->invitationLinkMatchesToken($invitation, $token)
|
||||
);
|
||||
|
||||
if (! $user || ! $invitation || $invitation->hasExpired() || ! $this->invitationLinkMatchesToken($invitation, $token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Hash::check($password, $user->password) ? [$user, $invitation] : null;
|
||||
}
|
||||
|
||||
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
|
||||
@@ -185,6 +232,7 @@ class Controller extends BaseController
|
||||
'invitation' => $invitation,
|
||||
'team' => $invitation->team,
|
||||
'alreadyMember' => $alreadyMember,
|
||||
'formAction' => route('team.invitation.accept', $invitation->uuid),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class Gitlab extends Controller
|
||||
{
|
||||
|
||||
@@ -41,6 +41,10 @@ class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Si
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $team->members()->whereKey($token->tokenable_id)->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$warningSentAt = now();
|
||||
|
||||
$team->notify(new ApiTokenExpiringNotification($token));
|
||||
|
||||
@@ -52,6 +52,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
|
||||
|
||||
private const CONTAINER_REMOVE_TIMEOUT_MARKER = '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__';
|
||||
|
||||
private const DOCKER_CLIENT_ENV_KEYS = [
|
||||
'BUILDKIT_HOST',
|
||||
'BUILDX_BUILDER',
|
||||
@@ -612,6 +614,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return $this->dockerImagePreviewTag;
|
||||
}
|
||||
|
||||
if ($this->rollback && str($this->commit)->isNotEmpty()) {
|
||||
return $this->commit;
|
||||
}
|
||||
|
||||
if (str($this->application->docker_registry_image_tag)->isNotEmpty()) {
|
||||
return $this->application->docker_registry_image_tag;
|
||||
}
|
||||
@@ -3977,15 +3983,45 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
);
|
||||
} else {
|
||||
$this->execute_remote_command(
|
||||
[dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true],
|
||||
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
|
||||
[dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true]
|
||||
);
|
||||
$this->removeContainerWithTimeout($containerName);
|
||||
}
|
||||
} catch (Exception $error) {
|
||||
$this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr');
|
||||
}
|
||||
}
|
||||
|
||||
private function removeContainerWithTimeout(string $containerName): void
|
||||
{
|
||||
$outputKey = 'container_remove_'.md5($containerName);
|
||||
|
||||
$this->execute_remote_command([
|
||||
dockerRemoveCommandWithTimeout($containerName),
|
||||
'hidden' => true,
|
||||
'ignore_errors' => true,
|
||||
'save' => $outputKey,
|
||||
'append' => false,
|
||||
]);
|
||||
|
||||
if (! isset($this->saved_outputs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$output = (string) $this->saved_outputs->get($outputKey, '');
|
||||
if (! str_contains($output, self::CONTAINER_REMOVE_TIMEOUT_MARKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->application_deployment_queue->addLogEntry(
|
||||
"Warning: Removing container {$containerName} timed out after 60 seconds. The deployment will continue and cleanup will be retried in 5 minutes.",
|
||||
'stderr'
|
||||
);
|
||||
|
||||
RemoveContainerJob::dispatch($this->server->id, $containerName)
|
||||
->delay(now()->addMinutes(5));
|
||||
}
|
||||
|
||||
private function stop_running_container(bool $force = false)
|
||||
{
|
||||
try {
|
||||
@@ -5016,9 +5052,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
// do not remove already running container for PR deployments
|
||||
} else {
|
||||
$this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr');
|
||||
$this->execute_remote_command(
|
||||
["docker rm -f $this->container_name >/dev/null 2>&1", 'hidden' => true, 'ignore_errors' => true]
|
||||
);
|
||||
$this->removeContainerWithTimeout($this->container_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceApplication;
|
||||
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\DB;
|
||||
|
||||
class CheckDomainDnsJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
public function __construct(
|
||||
public Application|ServiceApplication $resource,
|
||||
public string $statusKey,
|
||||
public string $url,
|
||||
public ?Server $server,
|
||||
public ?string $expectedIp,
|
||||
public string $checkId,
|
||||
public bool $skipForMultipleServers = false,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->persistResults(CheckDomainDns::run(
|
||||
[$this->statusKey => $this->url],
|
||||
$this->server,
|
||||
$this->expectedIp,
|
||||
$this->skipForMultipleServers,
|
||||
));
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
$this->persistResults([
|
||||
$this->statusKey => $this->status('failed', 'Could not validate DNS for this domain.'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
|
||||
*/
|
||||
private function status(string $status, string $message): array
|
||||
{
|
||||
return [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'expected_ip' => $this->expectedIp,
|
||||
'checked_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}> $results
|
||||
*/
|
||||
private function persistResults(array $results): void
|
||||
{
|
||||
DB::transaction(function () use ($results): void {
|
||||
$resource = $this->resource::query()->lockForUpdate()->find($this->resource->getKey());
|
||||
if (! $resource) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statuses = $resource->domain_dns_statuses ?? [];
|
||||
|
||||
foreach ($results as $key => $result) {
|
||||
if (($statuses[$key]['status'] ?? null) !== 'checking' || ($statuses[$key]['check_id'] ?? null) !== $this->checkId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statuses[$key] = $result;
|
||||
}
|
||||
|
||||
$resource->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$resource->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$this->clearOutdatedInfo();
|
||||
|
||||
// Detect current version (makes SSH call)
|
||||
$currentVersion = getTraefikVersionFromDockerCompose($this->server);
|
||||
|
||||
// Update detected version in database
|
||||
$this->server->update(['detected_traefik_version' => $currentVersion]);
|
||||
|
||||
if (! $currentVersion) {
|
||||
@@ -113,6 +114,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||
}
|
||||
|
||||
private function clearOutdatedInfo(): void
|
||||
{
|
||||
$this->server->update(['traefik_outdated_info' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about newer branches if available.
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Models\Server;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -51,7 +51,9 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) {
|
||||
if ($process['etimes'] >= $minAge
|
||||
&& ! file_exists($pathMatch[1])
|
||||
&& ! SshMultiplexingHelper::isMuxProcessRetiring($process['pid'], $pathMatch[1])) {
|
||||
$this->reapOrphan('ssh', $process);
|
||||
}
|
||||
}
|
||||
@@ -169,14 +171,6 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
|
||||
|
||||
if ($checkProcess->exitCode() !== 0) {
|
||||
$this->removeMultiplexFile($muxFile, 'connection_check_failed');
|
||||
} else {
|
||||
$muxContent = Storage::disk('ssh-mux')->get($muxFile);
|
||||
$establishedAt = Carbon::parse(substr($muxContent, 37));
|
||||
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
|
||||
|
||||
if (Carbon::now()->isAfter($expirationTime)) {
|
||||
$this->removeMultiplexFile($muxFile, 'expired');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,8 +210,20 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
|
||||
}
|
||||
|
||||
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
|
||||
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null";
|
||||
Process::run($closeCommand);
|
||||
$checkProcess = Process::run("ssh -O check -o ControlPath={$muxSocket} localhost");
|
||||
$pid = preg_match('/pid=(\d+)/', $checkProcess->output().$checkProcess->errorOutput(), $matches)
|
||||
? $matches[1]
|
||||
: null;
|
||||
|
||||
if ($pid !== null) {
|
||||
SshMultiplexingHelper::markMuxProcessAsRetiring($pid, $muxSocket);
|
||||
}
|
||||
|
||||
$closeCommand = "ssh -O stop -o ControlPath={$muxSocket} localhost 2>/dev/null";
|
||||
$stopProcess = Process::run($closeCommand);
|
||||
if ($pid !== null && ! $stopProcess->successful()) {
|
||||
SshMultiplexingHelper::unmarkMuxProcessAsRetiring($pid, $muxSocket);
|
||||
}
|
||||
Storage::disk('ssh-mux')->delete($muxFile);
|
||||
|
||||
Log::info('Removed stale mux file', [
|
||||
|
||||
@@ -798,7 +798,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->add_to_error_output($e->getMessage());
|
||||
throw $e;
|
||||
} finally {
|
||||
$command = "docker rm -f backup-of-{$this->backup_log_uuid}";
|
||||
$command = dockerRemoveCommand("backup-of-{$this->backup_log_uuid}");
|
||||
instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Jobs;
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Server\CleanupDocker;
|
||||
use App\Actions\Service\DeleteService;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Actions\Shared\DeleteScheduledVolumeBackup;
|
||||
@@ -28,6 +27,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
@@ -43,20 +44,17 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle()
|
||||
public function handle(): void
|
||||
{
|
||||
if (! $this->resource instanceof ApplicationPreview) {
|
||||
$this->deleteScheduledVolumeBackups();
|
||||
if ($this->resource instanceof ApplicationPreview) {
|
||||
DB::transaction(function (): void {
|
||||
$this->deleteApplicationPreview();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle ApplicationPreview instances separately
|
||||
if ($this->resource instanceof ApplicationPreview) {
|
||||
$this->deleteApplicationPreview();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($this->resource->type()) {
|
||||
case 'application':
|
||||
StopApplication::run($this->resource, previewDeployments: true, dockerCleanup: $this->dockerCleanup);
|
||||
@@ -73,21 +71,71 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
||||
break;
|
||||
case 'service':
|
||||
StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup);
|
||||
DeleteService::run($this->resource, $this->deleteVolumes, $this->deleteConnectedNetworks, $this->deleteConfigurations, $this->dockerCleanup);
|
||||
|
||||
return;
|
||||
app(DeleteService::class)->cleanupRemote(
|
||||
$this->resource,
|
||||
$this->deleteVolumes,
|
||||
$this->deleteConnectedNetworks,
|
||||
$this->deleteConfigurations,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if ($this->deleteConfigurations) {
|
||||
$this->resource->deleteConfigurations();
|
||||
if (! $this->resource instanceof Service) {
|
||||
if ($this->deleteConfigurations) {
|
||||
$this->resource->deleteConfigurations();
|
||||
}
|
||||
if ($this->deleteVolumes) {
|
||||
$this->resource->deleteVolumes();
|
||||
}
|
||||
if ($this->deleteConnectedNetworks && $this->resource->type() === 'application') {
|
||||
$this->resource->deleteConnectedNetworks();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Remote cleanup failed while deleting resource; continuing with local deletion.', [
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => $this->resource->type(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function (): void {
|
||||
try {
|
||||
$this->deleteScheduledVolumeBackups();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Remote backup cleanup failed while deleting resource; continuing with local deletion.', [
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => $this->resource->type(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->resource instanceof Service) {
|
||||
app(DeleteService::class)->deleteLocal($this->resource);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->deleteVolumes) {
|
||||
$this->resource->deleteVolumes();
|
||||
$this->resource->persistentStorages()->delete();
|
||||
}
|
||||
$this->resource->fileStorages()->delete(); // these are file mounts which should probably have their own flag
|
||||
$this->resource->fileStorages()->delete();
|
||||
|
||||
$isDatabase = $this->resource instanceof StandalonePostgresql
|
||||
if ($this->isDatabase()) {
|
||||
$this->resource->sslCertificates()->delete();
|
||||
$this->resource->scheduledBackups()->delete();
|
||||
$this->resource->tags()->detach();
|
||||
}
|
||||
$this->resource->environment_variables()->delete();
|
||||
$this->resource->forceDelete();
|
||||
});
|
||||
|
||||
Artisan::queue('cleanup:stucked-resources');
|
||||
}
|
||||
|
||||
private function isDatabase(): bool
|
||||
{
|
||||
return $this->resource instanceof StandalonePostgresql
|
||||
|| $this->resource instanceof StandaloneRedis
|
||||
|| $this->resource instanceof StandaloneMongodb
|
||||
|| $this->resource instanceof StandaloneMysql
|
||||
@@ -95,29 +143,6 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|| $this->resource instanceof StandaloneKeydb
|
||||
|| $this->resource instanceof StandaloneDragonfly
|
||||
|| $this->resource instanceof StandaloneClickhouse;
|
||||
|
||||
if ($isDatabase) {
|
||||
$this->resource->sslCertificates()->delete();
|
||||
$this->resource->scheduledBackups()->delete();
|
||||
$this->resource->tags()->detach();
|
||||
}
|
||||
$this->resource->environment_variables()->delete();
|
||||
|
||||
if ($this->deleteConnectedNetworks && $this->resource->type() === 'application') {
|
||||
$this->resource->deleteConnectedNetworks();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
throw $e;
|
||||
} finally {
|
||||
$this->resource->forceDelete();
|
||||
if ($this->dockerCleanup) {
|
||||
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
|
||||
if ($server) {
|
||||
CleanupDocker::dispatch($server, false, false);
|
||||
}
|
||||
}
|
||||
Artisan::queue('cleanup:stucked-resources');
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteScheduledVolumeBackups(): void
|
||||
|
||||
@@ -155,4 +155,38 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
$execution = DockerCleanupExecution::query()
|
||||
->where('server_id', $this->server->id)
|
||||
->where('status', 'running')
|
||||
->whereNull('finished_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if (! $execution) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = $exception?->getMessage() ?? 'Docker cleanup job failed without an exception.';
|
||||
|
||||
$updated = DockerCleanupExecution::query()
|
||||
->whereKey($execution->id)
|
||||
->where('status', 'running')
|
||||
->whereNull('finished_at')
|
||||
->update([
|
||||
'status' => 'failed',
|
||||
'message' => $message,
|
||||
'finished_at' => Carbon::now()->toImmutable(),
|
||||
]);
|
||||
|
||||
if ($updated === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$execution->refresh();
|
||||
event(new DockerCleanupDone($execution));
|
||||
$this->server->team?->notify(new DockerCleanupFailed($this->server, 'Docker cleanup job failed with the following error: '.$message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Server;
|
||||
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;
|
||||
|
||||
class RemoveContainerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 90;
|
||||
|
||||
public function __construct(public int $serverId, public string $containerName) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$server = Server::findOrFail($this->serverId);
|
||||
|
||||
instant_remote_process(
|
||||
[dockerRemoveCommandWithTimeout($this->containerName)],
|
||||
$server,
|
||||
timeout: 75,
|
||||
disableMultiplexing: true,
|
||||
);
|
||||
}
|
||||
|
||||
public function backoff(): array
|
||||
{
|
||||
return [300, 900];
|
||||
}
|
||||
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
Log::warning('Deferred container removal failed', [
|
||||
'server_id' => $this->serverId,
|
||||
'container' => $this->containerName,
|
||||
'error' => $exception?->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*/
|
||||
@@ -148,10 +150,12 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
foreach ($this->containers as $containerName) {
|
||||
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) {
|
||||
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'";
|
||||
$exec = "docker exec {$containerName} {$cmd}";
|
||||
$dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker';
|
||||
$execCommand = "{$dockerCommand} exec {$containerName} {$cmd}";
|
||||
$exec = $this->boundedTaskCommand($execCommand);
|
||||
// Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently
|
||||
// See: https://github.com/coollabsio/coolify/issues/6736
|
||||
$this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true);
|
||||
$this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true);
|
||||
$this->task_log->update([
|
||||
'status' => 'success',
|
||||
'message' => $this->task_output,
|
||||
@@ -204,6 +208,14 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function boundedTaskCommand(string $command): string
|
||||
{
|
||||
$maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES;
|
||||
$readLimit = $maxOutputBytes + 1;
|
||||
|
||||
return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$readLimit} > \"\$output_file\"; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$(wc -c < \"\$output_file\")\" -gt {$maxOutputBytes} ]; then truncate -s {$maxOutputBytes} \"\$output_file\"; printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the number of seconds to wait before retrying the job.
|
||||
*/
|
||||
|
||||
@@ -28,14 +28,14 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public int $maxExceptions = 1;
|
||||
|
||||
public int $timeout = 3600;
|
||||
public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT;
|
||||
|
||||
private ?ScheduledVolumeBackupExecution $execution = null;
|
||||
|
||||
public function __construct(public ScheduledVolumeBackup $backup)
|
||||
{
|
||||
$this->onQueue(crons_queue());
|
||||
$this->timeout = $backup->timeout ?? 3600;
|
||||
$this->timeout = $backup->timeout ?? ScheduledVolumeBackup::DEFAULT_TIMEOUT;
|
||||
}
|
||||
|
||||
public function middleware(): array
|
||||
|
||||
@@ -61,7 +61,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
|
||||
|
||||
if ($status === 'created') {
|
||||
instant_remote_process([
|
||||
'docker rm -f coolify-proxy',
|
||||
dockerRemoveCommand('coolify-proxy'),
|
||||
], $server);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ class ActivityMonitor extends Component
|
||||
|
||||
public static $eventDispatched = false;
|
||||
|
||||
protected $listeners = ['activityMonitor' => 'newMonitorActivity'];
|
||||
protected $listeners = [
|
||||
'activityMonitor' => 'newMonitorActivity',
|
||||
'processDialogClosed' => 'clearActivity',
|
||||
];
|
||||
|
||||
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null)
|
||||
{
|
||||
@@ -50,6 +53,16 @@ class ActivityMonitor extends Component
|
||||
$this->isPollingActive = true;
|
||||
}
|
||||
|
||||
public function clearActivity(): void
|
||||
{
|
||||
$this->activityId = null;
|
||||
$this->activity = null;
|
||||
$this->isPollingActive = false;
|
||||
$this->eventToDispatch = 'activityFinished';
|
||||
$this->eventData = null;
|
||||
self::$eventDispatched = false;
|
||||
}
|
||||
|
||||
public function hydrateActivity()
|
||||
{
|
||||
if ($this->activityId === null) {
|
||||
|
||||
@@ -81,7 +81,7 @@ class Show extends Component
|
||||
}
|
||||
$safeNetwork = escapeshellarg($this->destination->network);
|
||||
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $this->destination->server, throwError: false);
|
||||
instant_remote_process(["docker network rm -f {$safeNetwork}"], $this->destination->server);
|
||||
instant_remote_process([dockerNetworkRemoveCommand($this->destination->network)], $this->destination->server);
|
||||
}
|
||||
$this->destination->delete();
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Dev;
|
||||
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Livewire\Component;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LivewireRequestFailurePreview extends Component
|
||||
{
|
||||
/**
|
||||
* @var list<int>
|
||||
*/
|
||||
public array $statuses = [502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530];
|
||||
|
||||
public function fail(int $status): never
|
||||
{
|
||||
abort_unless(in_array($status, $this->statuses, true), Response::HTTP_NOT_FOUND);
|
||||
|
||||
throw new HttpResponseException(response(
|
||||
'<!doctype html><html><body><h1>Gateway time-out</h1><p>cloudflare proxy error '.$status.'</p></body></html>',
|
||||
$status,
|
||||
['Content-Type' => 'text/html']
|
||||
));
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
return view('livewire.dev.livewire-request-failure-preview')->layout('layouts.simple');
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Actions\Team\DeleteTeam;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class NavbarDeleteTeam extends Component
|
||||
@@ -28,22 +26,7 @@ class NavbarDeleteTeam extends Component
|
||||
|
||||
$currentTeam = currentTeam();
|
||||
$this->authorize('delete', $currentTeam);
|
||||
|
||||
$currentTeam->members->each(function ($user) use ($currentTeam) {
|
||||
if ($user->id === Auth::id()) {
|
||||
return;
|
||||
}
|
||||
$user->teams()->detach($currentTeam);
|
||||
$session = DB::table('sessions')->where('user_id', $user->id)->first();
|
||||
if ($session) {
|
||||
DB::table('sessions')->where('id', $session->id)->delete();
|
||||
}
|
||||
});
|
||||
|
||||
Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id);
|
||||
$currentTeam->delete();
|
||||
|
||||
$newTeam = Auth::user()->teams()->first();
|
||||
$newTeam = app(DeleteTeam::class)->handle($currentTeam, auth()->user());
|
||||
refreshSession($newTeam);
|
||||
|
||||
return redirect()->route('team.index');
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
|
||||
use App\Models\EmailNotificationSettings;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\Test;
|
||||
use App\Rules\ValidHostname;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Locked;
|
||||
@@ -57,6 +58,9 @@ class Email extends Component
|
||||
#[Validate(['nullable', 'numeric'])]
|
||||
public ?string $smtpTimeout = null;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $smtpEhloDomain = null;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $resendEnabled = false;
|
||||
|
||||
@@ -129,6 +133,7 @@ class Email extends Component
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled;
|
||||
$this->settings->smtp_from_address = $this->smtpFromAddress;
|
||||
@@ -140,6 +145,7 @@ class Email extends Component
|
||||
$this->settings->smtp_username = $this->smtpUsername;
|
||||
$this->settings->smtp_password = $this->smtpPassword;
|
||||
$this->settings->smtp_timeout = $this->smtpTimeout;
|
||||
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
|
||||
|
||||
$this->settings->resend_enabled = $this->resendEnabled;
|
||||
$this->settings->resend_api_key = $this->resendApiKey;
|
||||
@@ -175,6 +181,7 @@ class Email extends Component
|
||||
? $this->settings->smtp_password
|
||||
: null;
|
||||
$this->smtpTimeout = $this->settings->smtp_timeout;
|
||||
$this->smtpEhloDomain = $this->settings->smtp_ehlo_domain;
|
||||
|
||||
$this->resendEnabled = $this->settings->resend_enabled;
|
||||
$this->resendApiKey = auth()->user()->can('update', $this->settings)
|
||||
@@ -261,6 +268,7 @@ class Email extends Component
|
||||
'smtpUsername' => 'nullable|string',
|
||||
'smtpPassword' => 'nullable|string',
|
||||
'smtpTimeout' => 'nullable|numeric',
|
||||
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
|
||||
], [
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
@@ -284,6 +292,7 @@ class Email extends Component
|
||||
$this->settings->smtp_username = $this->smtpUsername;
|
||||
$this->settings->smtp_password = $this->smtpPassword;
|
||||
$this->settings->smtp_timeout = $this->smtpTimeout;
|
||||
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
|
||||
|
||||
$this->settings->save();
|
||||
$this->dispatch('success', 'SMTP settings updated.');
|
||||
@@ -375,6 +384,7 @@ class Email extends Component
|
||||
$this->smtpUsername = $settings->smtp_username;
|
||||
$this->smtpPassword = $settings->smtp_password;
|
||||
$this->smtpTimeout = $settings->smtp_timeout;
|
||||
$this->smtpEhloDomain = $settings->smtp_ehlo_domain;
|
||||
|
||||
if ($settings->resend_enabled) {
|
||||
$this->resendEnabled = true;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Application;
|
||||
@@ -10,6 +12,7 @@ use App\Support\DomainUrlParts;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class Domains extends Component
|
||||
@@ -141,6 +144,39 @@ class Domains extends Component
|
||||
$this->loadDomainState();
|
||||
}
|
||||
|
||||
public function pollDnsChecks(): void
|
||||
{
|
||||
$this->authorize('view', $this->application);
|
||||
|
||||
$checkingRows = collect($this->domainRows)
|
||||
->where('dns_status', 'checking')
|
||||
->values();
|
||||
|
||||
$this->refreshDomains();
|
||||
|
||||
foreach ($checkingRows as $checkingRow) {
|
||||
$row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url']
|
||||
&& ($row['service'] ?? null) === ($checkingRow['service'] ?? null));
|
||||
|
||||
if (! is_array($row) || $row['dns_status'] === 'checking') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
|
||||
}
|
||||
}
|
||||
|
||||
protected function dispatchDnsCheckNotification(string $url, string $status): void
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST) ?: $url;
|
||||
|
||||
match ($status) {
|
||||
'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."),
|
||||
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."),
|
||||
default => $this->dispatch('info', "DNS check skipped for {$host}."),
|
||||
};
|
||||
}
|
||||
|
||||
public function toggleNoindexDomain(string $domain, string|bool $indexing): void
|
||||
{
|
||||
$this->authorize('update', $this->application);
|
||||
@@ -464,6 +500,7 @@ class Domains extends Component
|
||||
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
|
||||
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
|
||||
'checked_at' => data_get($entry, 'checked_at'),
|
||||
'check_id' => data_get($entry, 'check_id'),
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
@@ -478,6 +515,7 @@ class Domains extends Component
|
||||
'dns_message' => 'Not checked yet.',
|
||||
'expected_ip' => $this->serverIp,
|
||||
'checked_at' => null,
|
||||
'check_id' => null,
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
@@ -533,6 +571,8 @@ class Domains extends Component
|
||||
|| ! $server
|
||||
|| $this->application->additional_servers->count() > 0;
|
||||
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if ($skipDns) {
|
||||
$reason = ! $this->dnsValidationEnabled
|
||||
@@ -548,7 +588,11 @@ class Domains extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $row['url'], $server);
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistDomainDnsStatuses();
|
||||
@@ -575,45 +619,50 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server);
|
||||
$this->applyDnsStatus($index, $server);
|
||||
$this->persistDomainDnsStatuses();
|
||||
}
|
||||
|
||||
protected function applyDnsStatus(int $index, string $url, Server $server): void
|
||||
protected function applyDnsStatus(int $index, Server $server): void
|
||||
{
|
||||
$target = $this->dnsTargetLabel();
|
||||
$this->applyDnsStatuses([$index], $server);
|
||||
}
|
||||
|
||||
try {
|
||||
$isValid = validateDNSEntry($url, $server);
|
||||
if ($isValid) {
|
||||
$this->domainRows[$index]['dns_status'] = 'ok';
|
||||
$this->domainRows[$index]['dns_message'] = $target
|
||||
? "DNS points to {$target} (or Cloudflare)."
|
||||
: 'DNS looks correct.';
|
||||
} else {
|
||||
$this->domainRows[$index]['dns_status'] = 'failed';
|
||||
$this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp);
|
||||
/**
|
||||
* @param array<int, int> $indexes
|
||||
*/
|
||||
protected function applyDnsStatuses(array $indexes, Server $server): void
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($indexes as $index) {
|
||||
$entries[(string) $index] = $this->domainRows[$index]['url'];
|
||||
}
|
||||
|
||||
$results = CheckDomainDns::run($entries, $server, $this->serverIp);
|
||||
|
||||
foreach ($results as $index => $result) {
|
||||
$index = (int) $index;
|
||||
$this->domainRows[$index]['dns_status'] = $result['status'];
|
||||
$this->domainRows[$index]['dns_message'] = $result['message'];
|
||||
|
||||
// Keep suggested-row copy short after DNS checks (no role badge).
|
||||
if ($this->domainRows[$index]['is_suggested'] ?? false) {
|
||||
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
|
||||
$serviceName = $this->domainRows[$index]['service'] ?? null;
|
||||
$meta = $this->suggestedDomainMeta(
|
||||
$isWww,
|
||||
$this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null)
|
||||
);
|
||||
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
|
||||
$this->domainRows[$index]['suggestion_label'] = null;
|
||||
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$this->domainRows[$index]['dns_status'] = 'failed';
|
||||
$this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.';
|
||||
}
|
||||
|
||||
// Keep suggested-row copy short after DNS checks (no role badge).
|
||||
if ($this->domainRows[$index]['is_suggested'] ?? false) {
|
||||
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
|
||||
$serviceName = $this->domainRows[$index]['service'] ?? null;
|
||||
$meta = $this->suggestedDomainMeta(
|
||||
$isWww,
|
||||
$this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null)
|
||||
);
|
||||
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
|
||||
$this->domainRows[$index]['suggestion_label'] = null;
|
||||
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
|
||||
$this->domainRows[$index]['expected_ip'] = $result['expected_ip'];
|
||||
$this->domainRows[$index]['checked_at'] = $result['checked_at'];
|
||||
$this->domainRows[$index]['check_id'] = null;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['expected_ip'] = $this->serverIp;
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -647,11 +696,34 @@ class Domains extends Component
|
||||
'message' => (string) ($row['dns_message'] ?? ''),
|
||||
'expected_ip' => $row['expected_ip'] ?? $this->serverIp,
|
||||
'checked_at' => $row['checked_at'] ?? now()->toIso8601String(),
|
||||
'check_id' => $row['check_id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
DB::transaction(function () use (&$statuses): void {
|
||||
$application = Application::query()->lockForUpdate()->findOrFail($this->application->id);
|
||||
$storedStatuses = $application->domain_dns_statuses ?? [];
|
||||
|
||||
foreach ($statuses as $key => $status) {
|
||||
$localCheckId = $status['check_id'] ?? null;
|
||||
$storedCheckId = $storedStatuses[$key]['check_id'] ?? null;
|
||||
|
||||
if ($storedCheckId !== null && $localCheckId !== $storedCheckId) {
|
||||
$statuses[$key] = $storedStatuses[$key];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') {
|
||||
$statuses[$key] = $storedStatuses[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$application->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$application->save();
|
||||
});
|
||||
|
||||
$this->application->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$this->application->save();
|
||||
}
|
||||
|
||||
protected function pruneDomainDnsStatusesToCurrentDomains(): void
|
||||
@@ -804,16 +876,6 @@ class Domains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->forceSaveDns && $this->shouldValidateDnsForAdd()) {
|
||||
$dnsFailure = $this->findDnsFailureMessage($newUrls);
|
||||
if ($dnsFailure !== null) {
|
||||
$this->addDomainDnsFailed = true;
|
||||
$this->addDomainDnsMessage = $dnsFailure;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
|
||||
$this->pendingAction = 'add';
|
||||
if (! $this->saveDomainList($merged, $this->newDomainService)) {
|
||||
@@ -825,14 +887,110 @@ class Domains extends Component
|
||||
$serviceForCheck = $this->newDomainService;
|
||||
$this->resetAddDomainForm();
|
||||
$this->dispatch('close-modal');
|
||||
$this->dispatch('success', 'Domain added.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck);
|
||||
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
|
||||
$dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck))
|
||||
->map(fn (string $url, string $statusKey) => [
|
||||
'status_key' => $statusKey,
|
||||
'url' => $url,
|
||||
'check_id' => new_public_id(),
|
||||
]);
|
||||
|
||||
foreach ($dnsChecks as $dnsCheck) {
|
||||
$this->markUrlsAsChecking([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']);
|
||||
}
|
||||
$this->persistDomainDnsStatuses();
|
||||
|
||||
$failedDnsChecks = 0;
|
||||
foreach ($dnsChecks as $dnsCheck) {
|
||||
try {
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$this->application,
|
||||
$dnsCheck['status_key'],
|
||||
$dnsCheck['url'],
|
||||
$this->application->destination?->server,
|
||||
$this->serverIp,
|
||||
$dnsCheck['check_id'],
|
||||
$this->application->additional_servers->count() > 0,
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$failedDnsChecks++;
|
||||
$this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($failedDnsChecks > 0) {
|
||||
$this->persistDomainDnsStatuses();
|
||||
$this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.');
|
||||
}
|
||||
|
||||
$this->dispatch('success', $failedDnsChecks === $dnsChecks->count()
|
||||
? 'Domain added.'
|
||||
: 'Domain added. DNS check started.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function markUrlsAsChecking(array $urls, ?string $service = null, ?string $checkId = null): void
|
||||
{
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if (! in_array($row['url'], $urls, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($service !== null && ($row['service'] ?? null) !== $service) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'checking';
|
||||
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
|
||||
$this->domainRows[$index]['check_id'] = $checkId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function markUrlsDnsCheckUnavailable(array $urls, ?string $service = null, ?string $checkId = null): void
|
||||
{
|
||||
$this->markUrlsAsChecking($urls, $service, $checkId);
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if (! in_array($row['url'], $urls, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($service !== null && ($row['service'] ?? null) !== $service) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function dnsEntriesForUrls(array $urls, ?string $service = null): array
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($urls as $url) {
|
||||
$entries[$this->domainDnsStatusKey($url, $service)] = $url;
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a first-time DNS check for newly added/updated domain URLs and persist results.
|
||||
*
|
||||
@@ -875,7 +1033,11 @@ class Domains extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $url, $server);
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistDomainDnsStatuses();
|
||||
@@ -909,15 +1071,11 @@ class Domains extends Component
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = $this->dnsTargetLabel() ?? $server->ip;
|
||||
$results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp);
|
||||
|
||||
foreach ($urls as $url) {
|
||||
try {
|
||||
if (! validateDNSEntry($url, $server)) {
|
||||
return dnsMismatchGuidanceMessage($target, $this->serverIp);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return 'Could not validate DNS for this domain.';
|
||||
foreach ($results as $result) {
|
||||
if ($result['status'] === 'failed') {
|
||||
return $result['message'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -796,6 +796,8 @@ EOD;
|
||||
*
|
||||
* Hardened against bypasses:
|
||||
* - decompresses gzip backups before scanning,
|
||||
* - converts custom-format (PGDMP) archives to SQL with pg_restore
|
||||
* before scanning, and rejects archives that cannot be inspected,
|
||||
* - strips `--` line comments and flattens newlines so multi-line and
|
||||
* comment-separated payloads (e.g. `FROM/**/PROGRAM`) are caught,
|
||||
* - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects.
|
||||
@@ -817,8 +819,30 @@ EOD;
|
||||
$escapedSqlPattern = escapeshellarg($sqlPattern);
|
||||
$escapedPsqlPattern = escapeshellarg($psqlPattern);
|
||||
$contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }";
|
||||
$scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}";
|
||||
$customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null');
|
||||
$sqlScan = $scan($contents);
|
||||
$blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1';
|
||||
$blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1';
|
||||
|
||||
return "header=\$({$contents} | head -c 5); if [ \"\$header\" = 'PGDMP' ]; then exit 0; fi; if {$contents} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$contents} | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedSqlPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
|
||||
return <<<SH
|
||||
header=\$({$contents} | head -c 5)
|
||||
if [ "\$header" = 'PGDMP' ]; then
|
||||
inspect=\$(mktemp)
|
||||
trap 'rm -f "\$inspect"' EXIT
|
||||
if ! {$contents} > "\$inspect"; then
|
||||
{$blockedInspect}
|
||||
fi
|
||||
if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then
|
||||
{$blockedInspect}
|
||||
fi
|
||||
if {$customScan}; then
|
||||
{$blockedProgram}
|
||||
fi
|
||||
elif {$sqlScan}; then
|
||||
{$blockedProgram}
|
||||
fi
|
||||
SH;
|
||||
}
|
||||
|
||||
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
|
||||
@@ -883,7 +907,7 @@ EOD;
|
||||
case 'postgresql':
|
||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
|
||||
$restoreCommand .= " && if [ \"\$({ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; } | head -c 5)\" = 'PGDMP' ]; then pg_restore -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}} {$escapedTmpPath}; else (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}; fi";
|
||||
} else {
|
||||
$restoreCommand .= " {$escapedTmpPath}";
|
||||
}
|
||||
|
||||
@@ -26,10 +26,17 @@ class Configuration extends Component
|
||||
|
||||
public array $parameters;
|
||||
|
||||
protected $listeners = [
|
||||
'refreshServices' => 'refreshServices',
|
||||
'refresh' => 'refreshServices',
|
||||
];
|
||||
public function getListeners(): array
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
'refreshServices' => 'refreshServices',
|
||||
'refresh' => 'refreshServices',
|
||||
'configurationChanged' => 'refreshServices',
|
||||
"echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'refreshServices',
|
||||
];
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Server;
|
||||
@@ -131,6 +133,39 @@ class Domains extends Component
|
||||
$this->loadDomainState();
|
||||
}
|
||||
|
||||
public function pollDnsChecks(): void
|
||||
{
|
||||
$this->authorize('view', $this->service);
|
||||
|
||||
$checkingRows = collect($this->domainRows)
|
||||
->where('dns_status', 'checking')
|
||||
->values();
|
||||
|
||||
$this->refreshDomains();
|
||||
|
||||
foreach ($checkingRows as $checkingRow) {
|
||||
$row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url']
|
||||
&& (int) $row['service_application_id'] === (int) $checkingRow['service_application_id']);
|
||||
|
||||
if (! is_array($row) || $row['dns_status'] === 'checking') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
|
||||
}
|
||||
}
|
||||
|
||||
protected function dispatchDnsCheckNotification(string $url, string $status): void
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST) ?: $url;
|
||||
|
||||
match ($status) {
|
||||
'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."),
|
||||
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."),
|
||||
default => $this->dispatch('info', "DNS check skipped for {$host}."),
|
||||
};
|
||||
}
|
||||
|
||||
public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void
|
||||
{
|
||||
$application = $this->service->applications()->findOrFail($serviceApplicationId);
|
||||
@@ -282,6 +317,7 @@ class Domains extends Component
|
||||
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
|
||||
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
|
||||
'checked_at' => data_get($entry, 'checked_at'),
|
||||
'check_id' => data_get($entry, 'check_id'),
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
@@ -298,6 +334,7 @@ class Domains extends Component
|
||||
'dns_message' => 'Not checked yet.',
|
||||
'expected_ip' => $this->serverIp,
|
||||
'checked_at' => null,
|
||||
'check_id' => null,
|
||||
'is_suggested' => false,
|
||||
'suggested_for' => null,
|
||||
'suggestion_label' => null,
|
||||
@@ -404,6 +441,8 @@ class Domains extends Component
|
||||
$server = $this->service->server;
|
||||
$skipDns = ! $this->dnsValidationEnabled || ! $server;
|
||||
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if ($skipDns) {
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
@@ -415,7 +454,11 @@ class Domains extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $row['url'], $server);
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
@@ -443,33 +486,37 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server);
|
||||
$this->applyDnsStatus($index, $server);
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
}
|
||||
|
||||
protected function applyDnsStatus(int $index, string $url, Server $server): void
|
||||
protected function applyDnsStatus(int $index, Server $server): void
|
||||
{
|
||||
$target = $this->dnsTargetLabel();
|
||||
$this->applyDnsStatuses([$index], $server);
|
||||
}
|
||||
|
||||
try {
|
||||
$isValid = validateDNSEntry($url, $server);
|
||||
if ($isValid) {
|
||||
$this->domainRows[$index]['dns_status'] = 'ok';
|
||||
$this->domainRows[$index]['dns_message'] = $target
|
||||
? "DNS points to {$target} (or Cloudflare)."
|
||||
: 'DNS looks correct.';
|
||||
} else {
|
||||
$this->domainRows[$index]['dns_status'] = 'failed';
|
||||
$this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$this->domainRows[$index]['dns_status'] = 'failed';
|
||||
$this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.';
|
||||
/**
|
||||
* @param array<int, int> $indexes
|
||||
*/
|
||||
protected function applyDnsStatuses(array $indexes, Server $server): void
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach ($indexes as $index) {
|
||||
$entries[(string) $index] = $this->domainRows[$index]['url'];
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['expected_ip'] = $this->serverIp;
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
$this->decorateSuggestedDomainAfterDnsCheck($index);
|
||||
$results = CheckDomainDns::run($entries, $server, $this->serverIp);
|
||||
|
||||
foreach ($results as $index => $result) {
|
||||
$index = (int) $index;
|
||||
$this->domainRows[$index]['dns_status'] = $result['status'];
|
||||
$this->domainRows[$index]['dns_message'] = $result['message'];
|
||||
$this->domainRows[$index]['expected_ip'] = $result['expected_ip'];
|
||||
$this->domainRows[$index]['checked_at'] = $result['checked_at'];
|
||||
$this->domainRows[$index]['check_id'] = null;
|
||||
$this->decorateSuggestedDomainAfterDnsCheck($index);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -516,6 +563,7 @@ class Domains extends Component
|
||||
'message' => (string) ($row['dns_message'] ?? ''),
|
||||
'expected_ip' => $row['expected_ip'] ?? $this->serverIp,
|
||||
'checked_at' => $row['checked_at'] ?? now()->toIso8601String(),
|
||||
'check_id' => $row['check_id'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -528,8 +576,30 @@ class Domains extends Component
|
||||
->all();
|
||||
$statuses = array_intersect_key($statuses, array_flip($currentUrls));
|
||||
|
||||
DB::transaction(function () use ($app, &$statuses): void {
|
||||
$application = ServiceApplication::query()->lockForUpdate()->findOrFail($app->id);
|
||||
$storedStatuses = $application->domain_dns_statuses ?? [];
|
||||
|
||||
foreach ($statuses as $key => $status) {
|
||||
$localCheckId = $status['check_id'] ?? null;
|
||||
$storedCheckId = $storedStatuses[$key]['check_id'] ?? null;
|
||||
|
||||
if ($storedCheckId !== null && $localCheckId !== $storedCheckId) {
|
||||
$statuses[$key] = $storedStatuses[$key];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') {
|
||||
$statuses[$key] = $storedStatuses[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$application->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$application->save();
|
||||
});
|
||||
|
||||
$app->domain_dns_statuses = $statuses === [] ? null : $statuses;
|
||||
$app->save();
|
||||
}
|
||||
|
||||
$this->service->load('applications');
|
||||
@@ -928,16 +998,6 @@ class Domains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->forceSaveDns && $this->shouldValidateDns()) {
|
||||
$dnsFailure = $this->findDnsFailureMessage($newUrls);
|
||||
if ($dnsFailure !== null) {
|
||||
$this->addDomainDnsFailed = true;
|
||||
$this->addDomainDnsMessage = $dnsFailure;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
|
||||
$this->pendingAction = 'add';
|
||||
|
||||
@@ -955,14 +1015,93 @@ class Domains extends Component
|
||||
$this->forceRemovePort = false;
|
||||
$this->pendingAction = null;
|
||||
$this->dispatch('close-modal');
|
||||
$this->dispatch('success', 'Domain added.');
|
||||
$this->refreshDomains();
|
||||
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id);
|
||||
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
|
||||
$serviceApplicationId = (int) $app->id;
|
||||
$dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [
|
||||
'url' => $url,
|
||||
'check_id' => new_public_id(),
|
||||
]);
|
||||
|
||||
foreach ($dnsChecks as $dnsCheck) {
|
||||
$this->markUrlsAsChecking([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']);
|
||||
}
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
|
||||
$failedDnsChecks = 0;
|
||||
foreach ($dnsChecks as $dnsCheck) {
|
||||
try {
|
||||
CheckDomainDnsJob::dispatch(
|
||||
$app,
|
||||
$dnsCheck['url'],
|
||||
$dnsCheck['url'],
|
||||
$this->service->server,
|
||||
$this->serverIp,
|
||||
$dnsCheck['check_id'],
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$failedDnsChecks++;
|
||||
$this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($failedDnsChecks > 0) {
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
$this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.');
|
||||
}
|
||||
|
||||
$this->dispatch('success', $failedDnsChecks === $dnsChecks->count()
|
||||
? 'Domain added.'
|
||||
: 'Domain added. DNS check started.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function markUrlsAsChecking(array $urls, int $serviceApplicationId, ?string $checkId = null): void
|
||||
{
|
||||
$indexesToCheck = [];
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if (! in_array($row['url'], $urls, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'checking';
|
||||
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
|
||||
$this->domainRows[$index]['check_id'] = $checkId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
*/
|
||||
protected function markUrlsDnsCheckUnavailable(array $urls, int $serviceApplicationId, ?string $checkId = null): void
|
||||
{
|
||||
$this->markUrlsAsChecking($urls, $serviceApplicationId, $checkId);
|
||||
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
if (! in_array($row['url'], $urls, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->domainRows[$index]['dns_status'] = 'skipped';
|
||||
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
}
|
||||
}
|
||||
|
||||
public function startEdit(int $index): void
|
||||
{
|
||||
if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) {
|
||||
@@ -1309,7 +1448,11 @@ class Domains extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->applyDnsStatus($index, $url, $server);
|
||||
$indexesToCheck[] = $index;
|
||||
}
|
||||
|
||||
if ($server && $indexesToCheck !== []) {
|
||||
$this->applyDnsStatuses($indexesToCheck, $server);
|
||||
}
|
||||
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
@@ -1330,15 +1473,11 @@ class Domains extends Component
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = $this->dnsTargetLabel() ?? $server->ip;
|
||||
$results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp);
|
||||
|
||||
foreach ($urls as $url) {
|
||||
try {
|
||||
if (! validateDNSEntry($url, $server)) {
|
||||
return dnsMismatchGuidanceMessage($target, $this->serverIp);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return 'Could not validate DNS for this domain.';
|
||||
foreach ($results as $result) {
|
||||
if ($result['status'] === 'failed') {
|
||||
return $result['message'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,8 +99,8 @@ class Create extends Component
|
||||
$label = str($resource->name)->headline();
|
||||
$targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [
|
||||
'key' => 'volume:'.$volume->id,
|
||||
'type' => 'Volume · '.$label,
|
||||
'name' => $volume->name,
|
||||
'type' => $label,
|
||||
'name' => str($volume->name)->after($this->service->uuid.'_')->value(),
|
||||
]));
|
||||
$targets->push(...$resource->fileStorages()
|
||||
->where('is_directory', true)
|
||||
@@ -109,8 +109,8 @@ class Create extends Component
|
||||
->get()
|
||||
->map(fn (LocalFileVolume $directory): array => [
|
||||
'key' => 'directory:'.$directory->id,
|
||||
'type' => 'Directory · '.$label,
|
||||
'name' => $directory->fs_path,
|
||||
'type' => $label,
|
||||
'name' => $directory->fs_path.' (directory)',
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Events\ApplicationConfigurationChanged;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
||||
@@ -298,6 +299,10 @@ class Show extends Component
|
||||
$this->dispatch('success', 'Environment variable updated.');
|
||||
$this->dispatch('envsUpdated');
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
if ($this->is_required && $this->resource instanceof Service) {
|
||||
event(new ApplicationConfigurationChanged($this->resource->team()->id));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ class GetLogs extends Component
|
||||
{
|
||||
public const MAX_LOG_LINES = 50000;
|
||||
|
||||
public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
public string $outputs = '';
|
||||
@@ -154,14 +156,12 @@ class GetLogs extends Component
|
||||
$command = parseCommandsByLineForSudo(collect($command), $this->server);
|
||||
$command = $command[0];
|
||||
}
|
||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||
} else {
|
||||
$command = "docker logs -n {$this->numberOfLines} -t {$this->container}";
|
||||
if ($this->server->isNonRoot()) {
|
||||
$command = parseCommandsByLineForSudo(collect($command), $this->server);
|
||||
$command = $command[0];
|
||||
}
|
||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||
}
|
||||
} else {
|
||||
if ($this->server->isSwarm()) {
|
||||
@@ -170,22 +170,39 @@ class GetLogs extends Component
|
||||
$command = parseCommandsByLineForSudo(collect($command), $this->server);
|
||||
$command = $command[0];
|
||||
}
|
||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||
} else {
|
||||
$command = "docker logs -n {$this->numberOfLines} {$this->container}";
|
||||
if ($this->server->isNonRoot()) {
|
||||
$command = parseCommandsByLineForSudo(collect($command), $this->server);
|
||||
$command = $command[0];
|
||||
}
|
||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||
}
|
||||
}
|
||||
$command = $this->boundedLogCommand($command, self::MAX_DISPLAY_SIZE_BYTES);
|
||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||
|
||||
// Collect new logs into temporary variable first to prevent flickering
|
||||
// (avoids clearing output before new data is ready)
|
||||
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
|
||||
$logChunks = [];
|
||||
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) {
|
||||
$accumulatedBytes = 0;
|
||||
$truncated = false;
|
||||
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) {
|
||||
if ($truncated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes;
|
||||
$outputBytes = strlen($output);
|
||||
if ($outputBytes > $remainingBytes) {
|
||||
$logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes)));
|
||||
$truncated = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$logChunks[] = removeAnsiColors($output);
|
||||
$accumulatedBytes += $outputBytes;
|
||||
});
|
||||
$newOutputs = implode('', $logChunks);
|
||||
|
||||
@@ -198,6 +215,10 @@ class GetLogs extends Component
|
||||
})->join("\n");
|
||||
}
|
||||
|
||||
if ($truncated) {
|
||||
$newOutputs .= "\n\n[... Output truncated at 5MB limit ...]";
|
||||
}
|
||||
|
||||
// Only update outputs after new data is ready (atomic update prevents flicker)
|
||||
$this->outputs = $newOutputs;
|
||||
}
|
||||
@@ -239,6 +260,7 @@ class GetLogs extends Component
|
||||
$command = $command[0];
|
||||
}
|
||||
|
||||
$command = $this->boundedLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES);
|
||||
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||
|
||||
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation
|
||||
@@ -252,20 +274,19 @@ class GetLogs extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$output = removeAnsiColors($output);
|
||||
$outputBytes = strlen($output);
|
||||
|
||||
if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) {
|
||||
$remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes;
|
||||
if ($remaining > 0) {
|
||||
$logChunks[] = substr($output, 0, $remaining);
|
||||
$logChunks[] = removeAnsiColors(substr($output, 0, $remaining));
|
||||
}
|
||||
$truncated = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$logChunks[] = $output;
|
||||
$logChunks[] = removeAnsiColors($output);
|
||||
$accumulatedBytes += $outputBytes;
|
||||
});
|
||||
|
||||
@@ -287,6 +308,11 @@ class GetLogs extends Component
|
||||
return sanitizeLogsForExport($allLogs);
|
||||
}
|
||||
|
||||
private function boundedLogCommand(string $command, int $maxBytes): string
|
||||
{
|
||||
return "({$command}) 2>&1 | head -c ".($maxBytes + 1);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.get-logs');
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared\ScheduledTask;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
@@ -59,13 +62,13 @@ class Add extends Component
|
||||
// Get the resource based on type and id
|
||||
switch ($this->type) {
|
||||
case 'application':
|
||||
$this->resource = \App\Models\Application::findOrFail($this->id);
|
||||
$this->resource = Application::ownedByCurrentTeam()->findOrFail($this->id);
|
||||
break;
|
||||
case 'service':
|
||||
$this->resource = \App\Models\Service::findOrFail($this->id);
|
||||
$this->resource = Service::ownedByCurrentTeam()->findOrFail($this->id);
|
||||
break;
|
||||
case 'standalone-postgresql':
|
||||
$this->resource = \App\Models\StandalonePostgresql::findOrFail($this->id);
|
||||
$this->resource = StandalonePostgresql::ownedByCurrentTeam()->findOrFail($this->id);
|
||||
break;
|
||||
default:
|
||||
throw new \Exception('Invalid resource type');
|
||||
|
||||
@@ -10,6 +10,7 @@ use Livewire\Component;
|
||||
|
||||
class Executions extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public ScheduledTask $task;
|
||||
|
||||
#[Locked]
|
||||
@@ -28,6 +29,7 @@ class Executions extends Component
|
||||
|
||||
public $logsPerPage = 100;
|
||||
|
||||
#[Locked]
|
||||
public $selectedExecution = null;
|
||||
|
||||
public $isPollingActive = false;
|
||||
@@ -45,7 +47,7 @@ class Executions extends Component
|
||||
{
|
||||
try {
|
||||
$this->taskId = $taskId;
|
||||
$this->task = ScheduledTask::findOrFail($taskId);
|
||||
$this->task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId);
|
||||
$this->executions = $this->task->executions()->take(20)->get();
|
||||
$this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone');
|
||||
if (! $this->serverTimezone) {
|
||||
|
||||
@@ -15,8 +15,10 @@ class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
#[Locked]
|
||||
public Application|Service $resource;
|
||||
|
||||
#[Locked]
|
||||
public ScheduledTask $task;
|
||||
|
||||
#[Locked]
|
||||
@@ -115,6 +117,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->authorize('update', $this->task);
|
||||
$this->isEnabled = ! $this->isEnabled;
|
||||
$this->task->enabled = $this->isEnabled;
|
||||
$this->task->save();
|
||||
@@ -128,6 +131,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->authorize('update', $this->task);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Scheduled task updated.');
|
||||
$this->refreshTasks();
|
||||
@@ -140,6 +144,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->authorize('update', $this->task);
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Scheduled task updated.');
|
||||
} catch (\Exception $e) {
|
||||
@@ -160,6 +165,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->authorize('delete', $this->task);
|
||||
$this->task->delete();
|
||||
|
||||
if ($this->type === 'application') {
|
||||
@@ -176,6 +182,7 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->authorize('update', $this->task);
|
||||
ScheduledTaskJob::dispatch($this->task);
|
||||
$this->dispatch('success', 'Scheduled task executed.');
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -56,7 +56,7 @@ class VolumeBackups extends Component
|
||||
|
||||
public string $timezone = '';
|
||||
|
||||
public int $timeout = 3600;
|
||||
public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT;
|
||||
|
||||
public int $perPage = 10;
|
||||
|
||||
|
||||
@@ -59,7 +59,10 @@ class ApiTokens extends Component
|
||||
|
||||
private function getTokens()
|
||||
{
|
||||
$this->tokens = auth()->user()->tokens->sortByDesc('created_at');
|
||||
$this->tokens = auth()->user()->tokens()
|
||||
->where('team_id', currentTeam()->id)
|
||||
->latest()
|
||||
->get();
|
||||
}
|
||||
|
||||
public function updatedPermissions($permissionToUpdate)
|
||||
@@ -148,7 +151,10 @@ class ApiTokens extends Component
|
||||
public function revoke(int $id)
|
||||
{
|
||||
try {
|
||||
$token = auth()->user()->tokens()->where('id', $id)->firstOrFail();
|
||||
$token = auth()->user()->tokens()
|
||||
->where('team_id', currentTeam()->id)
|
||||
->where('id', $id)
|
||||
->firstOrFail();
|
||||
$this->authorize('delete', $token);
|
||||
$token->delete();
|
||||
$this->getTokens();
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Models\DockerCleanupExecution;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
@@ -46,7 +45,7 @@ class DockerCleanupExecutions extends Component
|
||||
->get();
|
||||
|
||||
if ($this->selectedKey) {
|
||||
$this->selectedExecution = DockerCleanupExecution::find($this->selectedKey);
|
||||
$this->selectedExecution = $this->server->dockerCleanupExecutions()->find($this->selectedKey);
|
||||
if ($this->selectedExecution && $this->selectedExecution->status !== 'running') {
|
||||
$this->isPollingActive = false;
|
||||
}
|
||||
@@ -64,7 +63,7 @@ class DockerCleanupExecutions extends Component
|
||||
return;
|
||||
}
|
||||
$this->selectedKey = $key;
|
||||
$this->selectedExecution = DockerCleanupExecution::find($key);
|
||||
$this->selectedExecution = $this->server->dockerCleanupExecutions()->find($key);
|
||||
$this->currentPage = 1;
|
||||
|
||||
if ($this->selectedExecution && $this->selectedExecution->status === 'running') {
|
||||
|
||||
@@ -163,6 +163,7 @@ class Navbar extends Component
|
||||
$previousStatus = $this->proxyStatus;
|
||||
$this->server->refresh();
|
||||
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
|
||||
$this->dispatchProxyConfigurationState();
|
||||
|
||||
// If event contains activityId, open activity monitor
|
||||
if ($event && isset($event['activityId'])) {
|
||||
@@ -227,6 +228,16 @@ class Navbar extends Component
|
||||
{
|
||||
$this->server->refresh();
|
||||
$this->server->load('settings');
|
||||
$this->dispatchProxyConfigurationState();
|
||||
}
|
||||
|
||||
private function dispatchProxyConfigurationState(): void
|
||||
{
|
||||
$this->dispatch(
|
||||
'proxy-configuration-state-changed',
|
||||
pending: $this->server->hasPendingProxyConfiguration(),
|
||||
traefikOutdated: $this->server->hasCurrentTraefikOutdatedInfo(),
|
||||
);
|
||||
}
|
||||
|
||||
public function refreshSentinelStatus($event = null): void
|
||||
@@ -248,10 +259,12 @@ class Navbar extends Component
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if server has outdated info stored
|
||||
$outdatedInfo = $this->server->traefik_outdated_info;
|
||||
return $this->server->hasCurrentTraefikOutdatedInfo();
|
||||
}
|
||||
|
||||
return ! empty($outdatedInfo) && isset($outdatedInfo['type']);
|
||||
public function getHasPendingProxyConfigurationProperty(): bool
|
||||
{
|
||||
return $this->server->hasPendingProxyConfiguration();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -161,6 +161,7 @@ class Proxy extends Component
|
||||
$this->server->proxy->redirect_url = $this->redirectUrl;
|
||||
$this->server->save();
|
||||
$this->server->setupDefaultRedirect();
|
||||
$this->dispatch('refreshServerShow');
|
||||
$this->dispatch('success', 'Proxy configuration saved.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
@@ -175,6 +176,7 @@ class Proxy extends Component
|
||||
$this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true);
|
||||
SaveProxyConfiguration::run($this->server, $this->proxySettings);
|
||||
$this->server->save();
|
||||
$this->dispatch('refreshServerShow');
|
||||
$this->dispatch('success', 'Proxy configuration reset to default.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
@@ -276,7 +278,9 @@ class Proxy extends Component
|
||||
|
||||
// Check if we have outdated info stored for this server (faster than computing)
|
||||
$outdatedInfo = $this->server->traefik_outdated_info;
|
||||
if ($outdatedInfo && isset($outdatedInfo['type']) && $outdatedInfo['type'] === 'minor_upgrade') {
|
||||
$storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v');
|
||||
$detectedCurrentVersion = ltrim($currentVersion, 'v');
|
||||
if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') {
|
||||
// Use the upgrade_target field if available (e.g., "v3.6")
|
||||
if (isset($outdatedInfo['upgrade_target'])) {
|
||||
return str_starts_with($outdatedInfo['upgrade_target'], 'v')
|
||||
|
||||
@@ -11,6 +11,12 @@ class DynamicConfigurations extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public const MAX_CONFIGURATION_FILE_SIZE_BYTES = 1024 * 1024;
|
||||
|
||||
public const MAX_TOTAL_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
public const MAX_CONFIGURATION_FILES = 100;
|
||||
|
||||
public ?Server $server = null;
|
||||
|
||||
public $parameters = [];
|
||||
@@ -44,15 +50,36 @@ class DynamicConfigurations extends Component
|
||||
return handleError($e, $this);
|
||||
}
|
||||
$proxy_path = $this->server->proxyPath();
|
||||
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server);
|
||||
$fileLimit = self::MAX_CONFIGURATION_FILES + 1;
|
||||
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic | head -n {$fileLimit}"], $this->server);
|
||||
$files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file));
|
||||
$files = $files->map(fn ($file) => trim($file));
|
||||
$files = $files->sort();
|
||||
$contents = collect([]);
|
||||
foreach ($files as $file) {
|
||||
$skippedFiles = collect([]);
|
||||
$totalBytes = 0;
|
||||
if ($files->count() > self::MAX_CONFIGURATION_FILES) {
|
||||
$skippedFiles->push('additional files');
|
||||
}
|
||||
foreach ($files->take(self::MAX_CONFIGURATION_FILES) as $file) {
|
||||
$without_extension = str_replace('.', '|', $file);
|
||||
$content = instant_remote_process(["cat {$proxy_path}/dynamic/{$file}"], $this->server);
|
||||
$contents[$without_extension] = $content ?? '';
|
||||
$filePath = escapeshellarg("{$proxy_path}/dynamic/{$file}");
|
||||
$readLimit = self::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1;
|
||||
$content = instant_remote_process(["head -c {$readLimit} {$filePath}"], $this->server);
|
||||
$content = $content ?? '';
|
||||
$contentBytes = strlen($content);
|
||||
|
||||
if ($contentBytes > self::MAX_CONFIGURATION_FILE_SIZE_BYTES || $totalBytes + $contentBytes > self::MAX_TOTAL_CONFIGURATION_SIZE_BYTES) {
|
||||
$skippedFiles->push($file);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents[$without_extension] = $content;
|
||||
$totalBytes += $contentBytes;
|
||||
}
|
||||
if ($skippedFiles->isNotEmpty()) {
|
||||
$this->dispatch('warning', 'Some dynamic configurations were not loaded because they exceed the safe display limits: '.$skippedFiles->implode(', '));
|
||||
}
|
||||
$this->contents = $contents;
|
||||
$this->dispatch('$refresh');
|
||||
|
||||
@@ -146,7 +146,7 @@ class Sentinel extends Component
|
||||
{
|
||||
try {
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Sentinel settings updated.');
|
||||
$this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ class Index extends Component
|
||||
#[Validate('nullable|string|max:255|url')]
|
||||
public ?string $fqdn = null;
|
||||
|
||||
#[Validate('boolean')]
|
||||
public bool $is_dashboard_force_https_enabled = true;
|
||||
|
||||
#[Validate('required|integer|min:1025|max:65535')]
|
||||
public int $public_port_min;
|
||||
|
||||
@@ -68,6 +71,7 @@ class Index extends Component
|
||||
$this->server = Server::findOrFail(0);
|
||||
}
|
||||
$this->fqdn = $this->settings->fqdn;
|
||||
$this->is_dashboard_force_https_enabled = $this->settings->is_dashboard_force_https_enabled;
|
||||
$this->public_port_min = $this->settings->public_port_min;
|
||||
$this->public_port_max = $this->settings->public_port_max;
|
||||
$this->instance_name = $this->settings->instance_name;
|
||||
@@ -91,6 +95,7 @@ class Index extends Component
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->validate();
|
||||
$this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn;
|
||||
$this->settings->is_dashboard_force_https_enabled = $this->is_dashboard_force_https_enabled;
|
||||
$this->settings->public_port_min = $this->public_port_min;
|
||||
$this->settings->public_port_max = $this->public_port_max;
|
||||
$this->settings->instance_name = $this->instance_name;
|
||||
@@ -207,7 +212,7 @@ class Index extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}");
|
||||
$imageRef = escapeshellarg(coolifyHelperImage().":{$version}");
|
||||
$buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile .";
|
||||
|
||||
$activity = remote_process(
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\TransactionalEmails\Test;
|
||||
use App\Rules\ValidHostname;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Locked;
|
||||
@@ -50,6 +51,9 @@ class SettingsEmail extends Component
|
||||
#[Validate(['nullable', 'numeric'])]
|
||||
public ?string $smtpTimeout = null;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $smtpEhloDomain = null;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $resendEnabled = false;
|
||||
|
||||
@@ -74,6 +78,7 @@ class SettingsEmail extends Component
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->validate();
|
||||
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled;
|
||||
$this->settings->smtp_host = $this->smtpHost;
|
||||
$this->settings->smtp_port = $this->smtpPort;
|
||||
@@ -81,6 +86,7 @@ class SettingsEmail extends Component
|
||||
$this->settings->smtp_username = $this->smtpUsername;
|
||||
$this->settings->smtp_password = $this->smtpPassword;
|
||||
$this->settings->smtp_timeout = $this->smtpTimeout;
|
||||
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
|
||||
$this->settings->smtp_from_address = $this->smtpFromAddress;
|
||||
$this->settings->smtp_from_name = $this->smtpFromName;
|
||||
|
||||
@@ -95,6 +101,7 @@ class SettingsEmail extends Component
|
||||
$this->smtpUsername = $this->settings->smtp_username;
|
||||
$this->smtpPassword = $this->settings->smtp_password;
|
||||
$this->smtpTimeout = $this->settings->smtp_timeout;
|
||||
$this->smtpEhloDomain = $this->settings->smtp_ehlo_domain;
|
||||
$this->smtpFromAddress = $this->settings->smtp_from_address;
|
||||
$this->smtpFromName = $this->settings->smtp_from_name;
|
||||
|
||||
@@ -167,6 +174,7 @@ class SettingsEmail extends Component
|
||||
'smtpUsername' => 'nullable|string',
|
||||
'smtpPassword' => 'nullable|string',
|
||||
'smtpTimeout' => 'nullable|numeric',
|
||||
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
|
||||
], [
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
@@ -184,6 +192,7 @@ class SettingsEmail extends Component
|
||||
$this->settings->smtp_username = $this->smtpUsername;
|
||||
$this->settings->smtp_password = $this->smtpPassword;
|
||||
$this->settings->smtp_timeout = $this->smtpTimeout;
|
||||
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
|
||||
$this->settings->smtp_from_address = $this->smtpFromAddress;
|
||||
$this->settings->smtp_from_name = $this->smtpFromName;
|
||||
|
||||
@@ -234,11 +243,20 @@ class SettingsEmail extends Component
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->validate([
|
||||
'testEmailAddress' => 'required|email',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
'testEmailAddress.required' => 'Test email address is required.',
|
||||
'testEmailAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
]);
|
||||
|
||||
$this->settings->smtp_from_address = $this->smtpFromAddress;
|
||||
$this->settings->smtp_from_name = $this->smtpFromName;
|
||||
$this->settings->save();
|
||||
|
||||
$executed = RateLimiter::attempt(
|
||||
'test-email:'.$this->team->id,
|
||||
$perMinute = 0,
|
||||
|
||||
@@ -19,7 +19,7 @@ class SwitchTeam extends Component
|
||||
$this->switch_to($this->selectedTeamId);
|
||||
}
|
||||
|
||||
public function switch_to($team_id)
|
||||
public function switch_to($team_id, ?string $currentUrl = null)
|
||||
{
|
||||
if (! auth()->user()->teams->contains($team_id)) {
|
||||
return;
|
||||
@@ -30,6 +30,12 @@ class SwitchTeam extends Component
|
||||
}
|
||||
refreshSession($team_to_switch_to);
|
||||
|
||||
return redirect('dashboard');
|
||||
$parsedUrl = parse_url($currentUrl ?? '/dashboard');
|
||||
$redirectUrl = data_get($parsedUrl, 'path', '/dashboard');
|
||||
if ($query = data_get($parsedUrl, 'query')) {
|
||||
$redirectUrl .= '?'.$query;
|
||||
}
|
||||
|
||||
return redirect($redirectUrl);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class Create extends Component
|
||||
'personal_team' => false,
|
||||
'is_mcp_server_enabled' => true,
|
||||
]);
|
||||
auth()->user()->teams()->attach($team, ['role' => 'admin']);
|
||||
auth()->user()->teams()->attach($team, ['role' => 'owner']);
|
||||
refreshSession($team);
|
||||
|
||||
return redirectRoute($this, 'team.index');
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
namespace App\Livewire\Team;
|
||||
|
||||
use App\Actions\Team\DeleteTeam;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class DangerZone extends Component
|
||||
@@ -25,22 +23,7 @@ class DangerZone extends Component
|
||||
try {
|
||||
$currentTeam = currentTeam();
|
||||
$this->authorize('delete', $currentTeam);
|
||||
$currentTeam->members->each(function ($user) use ($currentTeam): void {
|
||||
if ($user->id === Auth::id()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user->teams()->detach($currentTeam);
|
||||
$session = DB::table('sessions')->where('user_id', $user->id)->first();
|
||||
if ($session) {
|
||||
DB::table('sessions')->where('id', $session->id)->delete();
|
||||
}
|
||||
});
|
||||
|
||||
Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id);
|
||||
$currentTeam->delete();
|
||||
|
||||
$newTeam = Auth::user()->teams()->first();
|
||||
$newTeam = app(DeleteTeam::class)->handle($currentTeam, auth()->user());
|
||||
refreshSession($newTeam);
|
||||
|
||||
return redirect()->route('team.index');
|
||||
@@ -49,6 +32,12 @@ class DangerZone extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshResources(): void
|
||||
{
|
||||
$this->team = Team::query()->findOrFail($this->team->id);
|
||||
refreshSession($this->team);
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
return view('livewire.team.danger-zone');
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Team;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class Invitations extends Component
|
||||
@@ -21,12 +22,14 @@ class Invitations extends Component
|
||||
$this->authorize('manageInvitations', currentTeam());
|
||||
|
||||
$invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id);
|
||||
$user = User::whereEmail($invitation->email)->first();
|
||||
if (filled($user)) {
|
||||
$user->deleteIfNotVerifiedAndForcePasswordReset();
|
||||
}
|
||||
DB::transaction(function () use ($invitation): void {
|
||||
$user = User::whereEmail($invitation->email)->first();
|
||||
if (filled($user)) {
|
||||
$user->deleteIfNotVerifiedAndForcePasswordReset();
|
||||
}
|
||||
|
||||
$invitation->delete();
|
||||
$invitation->delete();
|
||||
});
|
||||
$this->refreshInvitations();
|
||||
$this->dispatch('success', 'Invitation revoked.');
|
||||
} catch (\Exception) {
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Enums\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class Member extends Component
|
||||
@@ -25,8 +26,10 @@ class Member extends Component
|
||||
throw new \Exception('You are not authorized to perform this action.');
|
||||
}
|
||||
$teamId = currentTeam()->id;
|
||||
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]);
|
||||
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
|
||||
DB::transaction(function () use ($teamId): void {
|
||||
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]);
|
||||
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
|
||||
});
|
||||
$this->dispatch('reloadWindow');
|
||||
} catch (\Exception $e) {
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
@@ -43,8 +46,10 @@ class Member extends Component
|
||||
throw new \Exception('You are not authorized to perform this action.');
|
||||
}
|
||||
$teamId = currentTeam()->id;
|
||||
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]);
|
||||
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
|
||||
DB::transaction(function () use ($teamId): void {
|
||||
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]);
|
||||
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
|
||||
});
|
||||
$this->dispatch('reloadWindow');
|
||||
} catch (\Exception $e) {
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
@@ -61,8 +66,10 @@ class Member extends Component
|
||||
throw new \Exception('You are not authorized to perform this action.');
|
||||
}
|
||||
$teamId = currentTeam()->id;
|
||||
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]);
|
||||
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
|
||||
DB::transaction(function () use ($teamId): void {
|
||||
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]);
|
||||
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
|
||||
});
|
||||
$this->dispatch('reloadWindow');
|
||||
} catch (\Exception $e) {
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
@@ -79,8 +86,10 @@ class Member extends Component
|
||||
throw new \Exception('You are not authorized to perform this action.');
|
||||
}
|
||||
$teamId = currentTeam()->id;
|
||||
$this->member->teams()->detach(currentTeam());
|
||||
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
|
||||
DB::transaction(function () use ($teamId): void {
|
||||
$this->member->teams()->detach($teamId);
|
||||
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
|
||||
});
|
||||
// Clear cache for the removed user - both old and new key formats
|
||||
Cache::forget("team:{$this->member->id}");
|
||||
Cache::forget("user:{$this->member->id}:team:{$teamId}");
|
||||
|
||||
@@ -47,7 +47,7 @@ class Index extends Component
|
||||
return [
|
||||
'name' => data_get($container, 'Names'),
|
||||
'connection_name' => data_get($container, 'Names'),
|
||||
'uuid' => data_get($container, 'Names'),
|
||||
'uuid' => $server->uuid.':'.data_get($container, 'Names'),
|
||||
'status' => data_get_str($container, 'State')->lower(),
|
||||
'server' => $server,
|
||||
'server_uuid' => $server->uuid,
|
||||
|
||||
@@ -20,6 +20,8 @@ class Upgrade extends Component
|
||||
|
||||
public bool $devMode = false;
|
||||
|
||||
public bool $fullButton = false;
|
||||
|
||||
protected $listeners = ['updateAvailable' => 'checkUpdate'];
|
||||
|
||||
public function mount()
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Traits\HasConfiguration;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasNoindexDomains;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Database\Factories\ApplicationFactory;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@@ -120,7 +121,12 @@ use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class Application extends BaseModel
|
||||
{
|
||||
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
|
||||
use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
|
||||
|
||||
/** @use HasFactory<ApplicationFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
private static $parserVersion = '5';
|
||||
|
||||
@@ -2109,6 +2115,9 @@ class Application extends BaseModel
|
||||
$workdir = rtrim($this->base_directory, '/');
|
||||
$composeFile = $this->docker_compose_location;
|
||||
$fileList = collect([".$workdir$composeFile"]);
|
||||
$composeFilePath = escapeshellarg(".$workdir$composeFile");
|
||||
$composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1;
|
||||
$readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi";
|
||||
$gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid);
|
||||
if (! $gitRemoteStatus['is_accessible']) {
|
||||
throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.');
|
||||
@@ -2139,7 +2148,7 @@ class Application extends BaseModel
|
||||
'git sparse-checkout init',
|
||||
"git sparse-checkout set {$fileList->implode(' ')}",
|
||||
'git read-tree -mu HEAD',
|
||||
"cat .$workdir$composeFile",
|
||||
$readComposeFile,
|
||||
]);
|
||||
} else {
|
||||
$commands = collect([
|
||||
@@ -2151,11 +2160,14 @@ class Application extends BaseModel
|
||||
'git sparse-checkout init --cone',
|
||||
"git sparse-checkout set {$fileList->implode(' ')}",
|
||||
'git read-tree -mu HEAD',
|
||||
"cat .$workdir$composeFile",
|
||||
$readComposeFile,
|
||||
]);
|
||||
}
|
||||
try {
|
||||
$composeFileContent = instant_remote_process($commands, $this->destination->server);
|
||||
if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) {
|
||||
throw new RuntimeException('Docker Compose file exceeds the 5 MiB size limit.');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Restore original values on failure only
|
||||
$this->docker_compose_location = $initialDockerComposeLocation;
|
||||
@@ -2171,6 +2183,9 @@ class Application extends BaseModel
|
||||
}
|
||||
throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.');
|
||||
}
|
||||
if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) {
|
||||
throw $e;
|
||||
}
|
||||
throw new RuntimeException('Failed to read the Docker Compose file from the repository.');
|
||||
} finally {
|
||||
// Cleanup only - restoration happens in catch block
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use RuntimeException;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class ApplicationPreview extends BaseModel
|
||||
@@ -28,9 +29,9 @@ class ApplicationPreview extends BaseModel
|
||||
'pull_request_id' => 'integer',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::forceDeleting(function ($preview) {
|
||||
static::forceDeleting(function (ApplicationPreview $preview): void {
|
||||
$server = $preview->application->destination->server;
|
||||
$application = $preview->application;
|
||||
|
||||
@@ -57,10 +58,19 @@ class ApplicationPreview extends BaseModel
|
||||
});
|
||||
} else {
|
||||
// Regular application volume cleanup
|
||||
$persistentStorages = $preview->persistentStorages()->get() ?? collect();
|
||||
if ($persistentStorages->count() > 0) {
|
||||
foreach ($persistentStorages as $storage) {
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false);
|
||||
$persistentStorages = $application->persistentStorages()
|
||||
->get()
|
||||
->filter(fn (LocalPersistentVolume $storage): bool => blank($storage->host_path)
|
||||
&& $storage->is_preview_suffix_enabled);
|
||||
|
||||
foreach ($persistentStorages as $storage) {
|
||||
$volumeName = addPreviewDeploymentSuffix($storage->name, $preview->pull_request_id);
|
||||
try {
|
||||
instant_remote_process(['docker volume rm -f '.escapeshellarg($volumeName)], $server);
|
||||
} catch (RuntimeException $exception) {
|
||||
if (! preg_match('/\bvolume\b.*\bnot found\b/i', $exception->getMessage())) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class EmailNotificationSettings extends Model
|
||||
'smtp_username',
|
||||
'smtp_password',
|
||||
'smtp_timeout',
|
||||
'smtp_ehlo_domain',
|
||||
|
||||
'resend_enabled',
|
||||
'resend_api_key',
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GithubApp extends BaseModel
|
||||
{
|
||||
public function delete(): ?bool
|
||||
{
|
||||
return DB::transaction(fn () => parent::delete());
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'private_key_id',
|
||||
|
||||
@@ -100,6 +100,7 @@ class GitlabApp extends BaseModel
|
||||
if ($gitlabApp->applications()->count() > 0) {
|
||||
throw new \RuntimeException('This source is being used by an application. Please delete all applications first.');
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ use Spatie\Url\Url;
|
||||
|
||||
class InstanceSettings extends Model
|
||||
{
|
||||
protected $attributes = [
|
||||
'is_dashboard_force_https_enabled' => true,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'public_ipv4',
|
||||
'public_ipv6',
|
||||
@@ -29,6 +33,7 @@ class InstanceSettings extends Model
|
||||
'smtp_username',
|
||||
'smtp_password',
|
||||
'smtp_timeout',
|
||||
'smtp_ehlo_domain',
|
||||
'resend_enabled',
|
||||
'resend_api_key',
|
||||
'is_dns_validation_enabled',
|
||||
@@ -51,6 +56,7 @@ class InstanceSettings extends Model
|
||||
'webhook_allow_localhost',
|
||||
'avatar_storage_type',
|
||||
'avatar_s3_storage_id',
|
||||
'is_dashboard_force_https_enabled',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -89,6 +95,7 @@ class InstanceSettings extends Model
|
||||
'is_mcp_server_enabled' => 'boolean',
|
||||
'webhook_allowed_internal_hosts' => 'array',
|
||||
'webhook_allow_localhost' => 'boolean',
|
||||
'is_dashboard_force_https_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
@@ -138,9 +138,9 @@ class LocalFileVolume extends BaseModel
|
||||
|
||||
return;
|
||||
}
|
||||
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
|
||||
$content = $this->readRemoteFileContent($escapedPath, $server);
|
||||
// Check if content contains binary data by looking for null bytes or non-printable characters
|
||||
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
|
||||
if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) {
|
||||
$content = self::BINARY_PLACEHOLDER;
|
||||
}
|
||||
$this->content = $content;
|
||||
@@ -161,6 +161,27 @@ class LocalFileVolume extends BaseModel
|
||||
return $size > self::MAX_CONTENT_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap the remote read itself so a file that grows after the size check
|
||||
* cannot be fully slurped into PHP memory.
|
||||
*/
|
||||
protected function readRemoteFileContent(string $escapedPath, $server): string
|
||||
{
|
||||
$readLimit = self::MAX_CONTENT_SIZE + 1;
|
||||
$content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false);
|
||||
|
||||
return self::contentFromBoundedRead($content);
|
||||
}
|
||||
|
||||
public static function contentFromBoundedRead(?string $content): string
|
||||
{
|
||||
if (strlen((string) $content) > self::MAX_CONTENT_SIZE) {
|
||||
return self::TOO_LARGE_PLACEHOLDER;
|
||||
}
|
||||
|
||||
return (string) $content;
|
||||
}
|
||||
|
||||
public function deleteStorageOnServer()
|
||||
{
|
||||
if ($this->is_host_file) {
|
||||
@@ -253,7 +274,7 @@ class LocalFileVolume extends BaseModel
|
||||
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
|
||||
$this->content = self::TOO_LARGE_PLACEHOLDER;
|
||||
} else {
|
||||
$this->content = instant_remote_process(["cat {$escapedPath}"], $server, false);
|
||||
$this->content = $this->readRemoteFileContent($escapedPath, $server);
|
||||
}
|
||||
$this->is_directory = false;
|
||||
$this->save();
|
||||
|
||||
@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class ScheduledVolumeBackup extends BaseModel
|
||||
{
|
||||
public const int DEFAULT_TIMEOUT = 36000;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'backupable_type',
|
||||
|
||||
+48
-4
@@ -731,9 +731,7 @@ class Server extends BaseModel
|
||||
];
|
||||
|
||||
if ($schema === 'https') {
|
||||
$traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = [
|
||||
0 => 'redirect-to-https',
|
||||
];
|
||||
$traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = $this->dashboardHttpMiddlewares($settings);
|
||||
|
||||
$traefik_dynamic_conf['http']['routers']['coolify-https'] = [
|
||||
'middlewares' => [
|
||||
@@ -792,8 +790,9 @@ class Server extends BaseModel
|
||||
$url = Url::fromString($settings->fqdn);
|
||||
$host = $url->getHost();
|
||||
$schema = $url->getScheme();
|
||||
$siteAddress = $this->dashboardCaddySiteAddress($settings, $schema, $host);
|
||||
$caddy_file = "
|
||||
$schema://$host {
|
||||
$siteAddress {
|
||||
encode zstd gzip
|
||||
handle /app/* {
|
||||
reverse_proxy coolify-realtime:6001
|
||||
@@ -819,6 +818,24 @@ $schema://$host {
|
||||
], $this);
|
||||
}
|
||||
|
||||
public function dashboardHttpMiddlewares(InstanceSettings $settings): array
|
||||
{
|
||||
if ($settings->is_dashboard_force_https_enabled) {
|
||||
return ['redirect-to-https'];
|
||||
}
|
||||
|
||||
return ['gzip'];
|
||||
}
|
||||
|
||||
public function dashboardCaddySiteAddress(InstanceSettings $settings, string $schema, string $host): string
|
||||
{
|
||||
if ($schema === 'https' && ! $settings->is_dashboard_force_https_enabled) {
|
||||
return "http://{$host}, https://{$host}";
|
||||
}
|
||||
|
||||
return "{$schema}://{$host}";
|
||||
}
|
||||
|
||||
public function proxyPath()
|
||||
{
|
||||
$base_path = config('constants.coolify.base_config_path');
|
||||
@@ -841,6 +858,33 @@ $schema://$host {
|
||||
return data_get($this->proxy, 'type');
|
||||
}
|
||||
|
||||
public function hasPendingProxyConfiguration(): bool
|
||||
{
|
||||
if ($this->proxy->get('status') !== 'running') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$savedSettings = $this->proxy->get('last_saved_settings');
|
||||
$appliedSettings = $this->proxy->get('last_applied_settings');
|
||||
|
||||
return filled($savedSettings) && filled($appliedSettings) && $savedSettings !== $appliedSettings;
|
||||
}
|
||||
|
||||
public function hasCurrentTraefikOutdatedInfo(): bool
|
||||
{
|
||||
if ($this->proxyType() !== ProxyTypes::TRAEFIK->value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$detectedVersion = ltrim((string) $this->detected_traefik_version, 'v');
|
||||
$storedVersion = ltrim((string) data_get($this->traefik_outdated_info, 'current'), 'v');
|
||||
$type = data_get($this->traefik_outdated_info, 'type');
|
||||
|
||||
return filled($detectedVersion)
|
||||
&& $storedVersion === $detectedVersion
|
||||
&& in_array($type, ['patch_update', 'minor_upgrade'], true);
|
||||
}
|
||||
|
||||
public function scopeWithProxy(): Builder
|
||||
{
|
||||
return $this->proxy->modelScope();
|
||||
|
||||
@@ -33,11 +33,9 @@ class TeamInvitation extends Model
|
||||
return TeamInvitation::whereTeamId(currentTeam()->id);
|
||||
}
|
||||
|
||||
public function isValid()
|
||||
public function isValid(): bool
|
||||
{
|
||||
$createdAt = $this->created_at;
|
||||
$diff = $createdAt->diffInDays(now());
|
||||
if ($diff <= config('constants.invitation.link.expiration_days')) {
|
||||
if (! $this->hasExpired()) {
|
||||
return true;
|
||||
} else {
|
||||
$this->delete();
|
||||
@@ -49,4 +47,9 @@ class TeamInvitation extends Model
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function hasExpired(): bool
|
||||
{
|
||||
return $this->created_at->diffInDays(now()) > config('constants.invitation.link.expiration_days');
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,14 @@ namespace App\Notifications\Channels;
|
||||
|
||||
use App\Exceptions\NonReportableException;
|
||||
use App\Models\Team;
|
||||
use App\Support\SmtpTransportFactory;
|
||||
use Exception;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Resend;
|
||||
use Resend\Exceptions\ErrorException;
|
||||
use Resend\Exceptions\TransporterException;
|
||||
use Symfony\Component\Mailer\Mailer;
|
||||
use Symfony\Component\Mime\Email;
|
||||
|
||||
class EmailChannel
|
||||
{
|
||||
@@ -70,43 +75,27 @@ class EmailChannel
|
||||
|
||||
if ($isResendEnabled) {
|
||||
$resend = Resend::client($settings->resend_api_key);
|
||||
$from = "{$settings->smtp_from_name} <{$settings->smtp_from_address}>";
|
||||
$resend->emails->send([
|
||||
'from' => $from,
|
||||
'from' => mail_from_formatted($settings),
|
||||
'to' => $recipients,
|
||||
'subject' => $mailMessage->subject,
|
||||
'html' => (string) $mailMessage->render(),
|
||||
]);
|
||||
} elseif ($isSmtpEnabled) {
|
||||
$encryption = match (strtolower($settings->smtp_encryption)) {
|
||||
'starttls' => null,
|
||||
'tls' => 'tls',
|
||||
'none' => null,
|
||||
default => null,
|
||||
};
|
||||
|
||||
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
|
||||
$settings->smtp_host,
|
||||
$settings->smtp_port,
|
||||
$encryption
|
||||
$transport = SmtpTransportFactory::fromSettings(
|
||||
$settings,
|
||||
config('mail.mailers.smtp.local_domain')
|
||||
);
|
||||
$transport->setUsername($settings->smtp_username ?? '');
|
||||
$transport->setPassword($settings->smtp_password ?? '');
|
||||
$mailer = new Mailer($transport);
|
||||
|
||||
$mailer = new \Symfony\Component\Mailer\Mailer($transport);
|
||||
|
||||
$fromEmail = $settings->smtp_from_address ?? 'noreply@localhost';
|
||||
$fromName = $settings->smtp_from_name ?? 'System';
|
||||
$from = new \Symfony\Component\Mime\Address($fromEmail, $fromName);
|
||||
$email = (new \Symfony\Component\Mime\Email)
|
||||
->from($from)
|
||||
$email = mail_from_email(new Email, $settings)
|
||||
->to(...$recipients)
|
||||
->subject($mailMessage->subject)
|
||||
->html((string) $mailMessage->render());
|
||||
|
||||
$mailer->send($email);
|
||||
}
|
||||
} catch (\Resend\Exceptions\ErrorException $e) {
|
||||
} catch (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.',
|
||||
@@ -131,13 +120,13 @@ class EmailChannel
|
||||
|
||||
// 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 NonReportableException::fromException(new Exception($userMessage, $e->getCode(), $e));
|
||||
}
|
||||
|
||||
throw new \Exception($userMessage, $e->getCode(), $e);
|
||||
} catch (\Resend\Exceptions\TransporterException $e) {
|
||||
throw new Exception($userMessage, $e->getCode(), $e);
|
||||
} catch (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.');
|
||||
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')) {
|
||||
|
||||
@@ -30,7 +30,7 @@ class TransactionalEmailChannel
|
||||
Mail::send(
|
||||
[],
|
||||
[],
|
||||
fn (Message $message) => $message
|
||||
fn (Message $message) => mail_from_message($message, $settings)
|
||||
->to($email)
|
||||
->subject($mailMessage->subject)
|
||||
->html((string) $mailMessage->render())
|
||||
|
||||
@@ -54,7 +54,10 @@ class ResetPassword extends Notification
|
||||
|
||||
protected function buildMailMessage($url)
|
||||
{
|
||||
$from = mail_from_identity($this->settings);
|
||||
$mail = new MailMessage;
|
||||
$mail->from($from['address'], $from['name']);
|
||||
$mail->withSymfonyMessage(fn ($message) => prevent_mail_from_header_folding($message, $this->settings));
|
||||
$mail->subject('Coolify: Reset Password');
|
||||
$mail->view('emails.reset-password', ['url' => $url, 'count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')]);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class ApiTokenPolicy
|
||||
*/
|
||||
public function view(User $user, PersonalAccessToken $token): bool
|
||||
{
|
||||
return $user->id === $token->tokenable_id && $token->tokenable_type === User::class;
|
||||
return $this->belongsToUserAndCurrentTeam($user, $token);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +36,7 @@ class ApiTokenPolicy
|
||||
*/
|
||||
public function update(User $user, PersonalAccessToken $token): bool
|
||||
{
|
||||
return $user->id === $token->tokenable_id && $token->tokenable_type === User::class;
|
||||
return $this->belongsToUserAndCurrentTeam($user, $token);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,7 +44,7 @@ class ApiTokenPolicy
|
||||
*/
|
||||
public function delete(User $user, PersonalAccessToken $token): bool
|
||||
{
|
||||
return $user->id === $token->tokenable_id && $token->tokenable_type === User::class;
|
||||
return $this->belongsToUserAndCurrentTeam($user, $token);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,4 +86,19 @@ class ApiTokenPolicy
|
||||
{
|
||||
return $user->isAdmin() || $user->isOwner();
|
||||
}
|
||||
|
||||
private function belongsToUserAndCurrentTeam(User $user, PersonalAccessToken $token): bool
|
||||
{
|
||||
if ($user->id !== $token->tokenable_id || $token->tokenable_type !== User::class) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentTeamId = $user->currentTeam()?->id;
|
||||
|
||||
if ($currentTeamId === null || $token->team_id === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (string) $currentTeamId === (string) $token->team_id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class ScheduledTaskPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, ScheduledTask $scheduledTask): bool
|
||||
{
|
||||
return $user->teams->contains('id', $scheduledTask->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, ScheduledTask $scheduledTask): Response
|
||||
{
|
||||
if (! $user->isAdminOfTeam($scheduledTask->team_id)) {
|
||||
return Response::deny('You need at least admin or owner permissions to update this scheduled task.');
|
||||
}
|
||||
|
||||
return Response::allow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, ScheduledTask $scheduledTask): bool
|
||||
{
|
||||
return $user->isAdminOfTeam($scheduledTask->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, ScheduledTask $scheduledTask): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, ScheduledTask $scheduledTask): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class TeamPolicy
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->isAdminOfTeam($team->id);
|
||||
return $user->roleInTeam($team->id) === 'owner';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\PushoverNotificationSettings;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
@@ -56,6 +57,7 @@ use App\Policies\PrivateKeyPolicy;
|
||||
use App\Policies\ProjectPolicy;
|
||||
use App\Policies\ResourceCreatePolicy;
|
||||
use App\Policies\S3StoragePolicy;
|
||||
use App\Policies\ScheduledTaskPolicy;
|
||||
use App\Policies\ServerPolicy;
|
||||
use App\Policies\ServiceApplicationPolicy;
|
||||
use App\Policies\ServiceDatabasePolicy;
|
||||
@@ -118,6 +120,9 @@ class AuthServiceProvider extends ServiceProvider
|
||||
// S3 storage policy
|
||||
S3Storage::class => S3StoragePolicy::class,
|
||||
|
||||
// Scheduled task policy
|
||||
ScheduledTask::class => ScheduledTaskPolicy::class,
|
||||
|
||||
// Team policy
|
||||
Team::class => TeamPolicy::class,
|
||||
|
||||
|
||||
@@ -152,6 +152,13 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
return Limit::perMinute(5)->by($email.'|'.$realIp);
|
||||
});
|
||||
|
||||
RateLimiter::for('magic-link', function (Request $request) {
|
||||
$realIp = $request->server('REMOTE_ADDR') ?? $request->ip();
|
||||
$token = (string) $request->input('token');
|
||||
|
||||
return Limit::perMinute(5)->by(hash('sha256', $token.'|'.$realIp));
|
||||
});
|
||||
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
@@ -75,12 +75,16 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||
|
||||
protected function gate(): void
|
||||
{
|
||||
Gate::define('viewHorizon', function ($user) {
|
||||
$root_user = User::find(0);
|
||||
Gate::define('viewHorizon', function (User $user) {
|
||||
if ($user->id === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($user->email, [
|
||||
$root_user->email,
|
||||
]);
|
||||
return str(config()->string('horizon.allowed_emails'))
|
||||
->lower()
|
||||
->explode(',')
|
||||
->map(fn (string $email) => trim($email))
|
||||
->contains($user->email);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\SmtpTransportFactory;
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class ConfigurationRepository
|
||||
{
|
||||
@@ -15,40 +17,51 @@ class ConfigurationRepository
|
||||
|
||||
public function updateMailConfig($settings): void
|
||||
{
|
||||
$from = mail_from_identity($settings);
|
||||
|
||||
if ($settings->resend_enabled) {
|
||||
$this->config->set('mail.default', 'resend');
|
||||
$this->config->set('mail.from.address', $settings->smtp_from_address ?? 'test@example.com');
|
||||
$this->config->set('mail.from.name', $settings->smtp_from_name ?? 'Test');
|
||||
$this->applyMailFrom($from);
|
||||
$this->config->set('resend.api_key', $settings->resend_api_key);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($settings->smtp_enabled) {
|
||||
$encryption = match (strtolower($settings->smtp_encryption)) {
|
||||
'starttls' => null,
|
||||
'tls' => 'tls',
|
||||
'none' => null,
|
||||
default => null,
|
||||
};
|
||||
$mailerOptions = SmtpTransportFactory::mailerOptions($settings);
|
||||
$localDomain = $settings->smtp_ehlo_domain
|
||||
?? $this->config->get('mail.mailers.smtp.local_domain');
|
||||
|
||||
$this->config->set('mail.default', 'smtp');
|
||||
$this->config->set('mail.from.address', $settings->smtp_from_address ?? 'test@example.com');
|
||||
$this->config->set('mail.from.name', $settings->smtp_from_name ?? 'Test');
|
||||
$this->applyMailFrom($from);
|
||||
$this->config->set('mail.mailers.smtp', [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => $mailerOptions['scheme'],
|
||||
'host' => $settings->smtp_host,
|
||||
'port' => $settings->smtp_port,
|
||||
'encryption' => $encryption,
|
||||
'encryption' => $mailerOptions['encryption'],
|
||||
'username' => $settings->smtp_username,
|
||||
'password' => $settings->smtp_password,
|
||||
'timeout' => $settings->smtp_timeout,
|
||||
'local_domain' => null,
|
||||
'auto_tls' => $settings->smtp_encryption === 'none' ? '0' : '',
|
||||
'local_domain' => $localDomain,
|
||||
'auto_tls' => $mailerOptions['auto_tls'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{address: string, name: string} $from
|
||||
*/
|
||||
private function applyMailFrom(array $from): void
|
||||
{
|
||||
$this->config->set('mail.from.address', $from['address']);
|
||||
$this->config->set('mail.from.name', $from['name']);
|
||||
|
||||
if (app()->bound('mail.manager')) {
|
||||
Mail::purge();
|
||||
}
|
||||
}
|
||||
|
||||
public function disableSshMux(): void
|
||||
{
|
||||
$this->config->set('constants.ssh.mux_enabled', false);
|
||||
|
||||
@@ -17,28 +17,8 @@ class ConfigurationDiffer
|
||||
*/
|
||||
private const IGNORED_KEYS = ['build.docker_compose'];
|
||||
|
||||
/**
|
||||
* Defaults for fields introduced after configuration snapshots were first
|
||||
* stored. Older snapshots omitted these keys, which should not make an
|
||||
* unchanged default look like a pending configuration change.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private const INTRODUCED_DEFAULTS = [
|
||||
'build.is_static' => false,
|
||||
'build.is_spa' => false,
|
||||
'build.is_git_submodules_enabled' => true,
|
||||
'build.is_git_lfs_enabled' => true,
|
||||
'build.is_git_shallow_clone_enabled' => true,
|
||||
'build.is_env_sorting_enabled' => [false, true],
|
||||
'runtime.is_consistent_container_name_enabled' => false,
|
||||
'runtime.is_container_label_escape_enabled' => true,
|
||||
'runtime.is_container_label_readonly_enabled' => true,
|
||||
'runtime.is_log_drain_enabled' => false,
|
||||
'runtime.is_swarm_only_worker_nodes' => true,
|
||||
'runtime.is_preserve_repository_enabled' => false,
|
||||
'domains.noindex_domains' => [],
|
||||
];
|
||||
/** @var array<int, string> */
|
||||
private const DYNAMIC_SECTIONS = ['environment', 'storage'];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $previousSnapshot
|
||||
@@ -59,11 +39,7 @@ class ConfigurationDiffer
|
||||
$previous = $previousItems[$key] ?? null;
|
||||
$current = $currentItems[$key] ?? null;
|
||||
|
||||
if (
|
||||
$previous === null
|
||||
&& array_key_exists($key, self::INTRODUCED_DEFAULTS)
|
||||
&& $this->matchesIntroducedDefault($key, data_get($current, 'compare_value'))
|
||||
) {
|
||||
if ($previous === null && ! in_array(data_get($current, 'section'), self::DYNAMIC_SECTIONS, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -127,17 +103,6 @@ class ConfigurationDiffer
|
||||
return ConfigurationDiff::fromChanges($changes);
|
||||
}
|
||||
|
||||
private function matchesIntroducedDefault(string $key, mixed $value): bool
|
||||
{
|
||||
$default = self::INTRODUCED_DEFAULTS[$key];
|
||||
|
||||
if (is_array($default) && $default !== [] && array_is_list($default)) {
|
||||
return in_array($value, $default, true);
|
||||
}
|
||||
|
||||
return $value === $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce two multi-line values to only the lines that differ, so the modal
|
||||
* shows just the changed container labels instead of the whole block.
|
||||
|
||||
@@ -90,8 +90,11 @@ class DatabaseBackupFileValidator
|
||||
|
||||
public static function containsPostgresqlProgramExecution(string $sql): bool
|
||||
{
|
||||
$requireStatementBoundary = true;
|
||||
|
||||
if (str_starts_with($sql, 'PGDMP')) {
|
||||
return false;
|
||||
$sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql;
|
||||
$requireStatementBoundary = false;
|
||||
}
|
||||
|
||||
$withoutComments = self::stripSqlComments($sql);
|
||||
@@ -100,7 +103,9 @@ class DatabaseBackupFileValidator
|
||||
return true;
|
||||
}
|
||||
|
||||
return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
|
||||
$copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b';
|
||||
|
||||
return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
|
||||
}
|
||||
|
||||
private static function extensionFor(string $name): ?string
|
||||
|
||||
@@ -4,11 +4,11 @@ namespace App\Support;
|
||||
|
||||
class DomainUrlParts
|
||||
{
|
||||
public static function compose(string $scheme, string $host, string $port = '', string $path = ''): string
|
||||
public static function compose(string $scheme, string $host, ?string $port = '', string $path = ''): string
|
||||
{
|
||||
$scheme = strtolower(trim($scheme)) === 'http' ? 'http' : 'https';
|
||||
$host = trim($host);
|
||||
$port = trim($port);
|
||||
$port = trim((string) $port);
|
||||
$path = trim($path);
|
||||
|
||||
if ($path !== '' && ! str_starts_with($path, '/') && ! str_starts_with($path, '?') && ! str_starts_with($path, '#')) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
|
||||
use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream;
|
||||
|
||||
class SmtpTransportFactory
|
||||
{
|
||||
public static function fromSettings(object $settings, ?string $localDomain = null): EsmtpTransport
|
||||
{
|
||||
$mode = self::encryptionMode($settings);
|
||||
|
||||
$transport = new EsmtpTransport(
|
||||
$settings->smtp_host,
|
||||
(int) $settings->smtp_port,
|
||||
match ($mode) {
|
||||
'none' => false,
|
||||
'tls' => true,
|
||||
default => null,
|
||||
}
|
||||
);
|
||||
|
||||
if ($mode === 'none') {
|
||||
$transport->setAutoTls(false);
|
||||
}
|
||||
|
||||
$transport->setUsername($settings->smtp_username ?? '');
|
||||
$transport->setPassword($settings->smtp_password ?? '');
|
||||
|
||||
$localDomain = $settings->smtp_ehlo_domain ?? $localDomain;
|
||||
if ($localDomain !== null && $localDomain !== '') {
|
||||
$transport->setLocalDomain($localDomain);
|
||||
}
|
||||
|
||||
$stream = $transport->getStream();
|
||||
if (isset($settings->smtp_timeout) && $stream instanceof SocketStream) {
|
||||
$stream->setTimeout((float) $settings->smtp_timeout);
|
||||
}
|
||||
|
||||
return $transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{scheme: ?string, encryption: ?string, auto_tls: string}
|
||||
*/
|
||||
public static function mailerOptions(object $settings): array
|
||||
{
|
||||
$mode = self::encryptionMode($settings);
|
||||
|
||||
return [
|
||||
'scheme' => match ($mode) {
|
||||
'none', 'starttls' => 'smtp',
|
||||
'tls' => 'smtps',
|
||||
default => null,
|
||||
},
|
||||
'encryption' => $mode === 'tls' ? 'tls' : null,
|
||||
'auto_tls' => $mode === 'none' ? '0' : '',
|
||||
];
|
||||
}
|
||||
|
||||
private static function encryptionMode(object $settings): ?string
|
||||
{
|
||||
return $settings->smtp_encryption === null
|
||||
? null
|
||||
: strtolower((string) $settings->smtp_encryption);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user