Merge remote-tracking branch 'origin/next' into reverb-realtime-migration

This commit is contained in:
Andras Bacsai
2026-08-25 20:02:36 +02:00
356 changed files with 14043 additions and 2201 deletions
-7
View File
@@ -1,7 +0,0 @@
# Lessons
## Alpine x-transition + tw-animate-css exit animations flash at the end
- Symptom: a modal/overlay fades out, then flashes fully visible for 1-2 frames before it disappears.
- Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity.
- Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`.
- Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one.
+1
View File
@@ -1,6 +1,7 @@
APP_ENV=testing
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
APP_DEBUG=true
APP_MAINTENANCE_DRIVER=file
DB_CONNECTION=testing
+152
View File
@@ -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"
+304
View File
@@ -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,
});
-131
View File
@@ -1,131 +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/pr-quality.yaml
- docker/coolify-helper/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 }}
+5 -3
View File
@@ -45,15 +45,17 @@ jobs:
exit 1
fi
existing_pr=$(gh pr list --base next --head main --state open --json url --jq '.[0].url')
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 main \
--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.'
--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.'
+22 -1
View File
@@ -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/)
+29
View File
@@ -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
View File
@@ -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
);
+20 -5
View File
@@ -3,12 +3,14 @@
namespace App\Actions\Database;
use App\Models\StandaloneClickhouse;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartClickhouse
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneClickhouse $database;
@@ -16,7 +18,11 @@ class StartClickhouse
public string $configuration_dir;
public function handle(StandaloneClickhouse $database)
private string $resolvedClickhouseUser;
private string $resolvedClickhousePassword;
public function handle(StandaloneClickhouse $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -51,7 +57,7 @@ class StartClickhouse
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
'CMD', 'clickhouse-client', '--user', $this->resolvedClickhouseUser, '--password', $this->resolvedClickhousePassword, '--query', 'SELECT 1',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -109,7 +115,7 @@ class StartClickhouse
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -147,8 +153,17 @@ class StartClickhouse
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedClickhouseUser = (string) $this->database->clickhouse_admin_user;
$this->resolvedClickhousePassword = (string) $this->database->clickhouse_admin_password;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'CLICKHOUSE_USER') {
$this->resolvedClickhouseUser = $rawValue;
} elseif ($env->key === 'CLICKHOUSE_PASSWORD') {
$this->resolvedClickhousePassword = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) {
+30 -26
View File
@@ -2,6 +2,9 @@
namespace App\Actions\Database;
use App\Enums\ActivityTypes;
use App\Enums\ProcessStatus;
use App\Jobs\DatabaseStartJob;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@@ -12,6 +15,7 @@ use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Spatie\Activitylog\Models\Activity;
class StartDatabase
{
@@ -22,38 +26,38 @@ class StartDatabase
$job->onQueue(deployment_queue());
}
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database): Activity|string
{
$server = $database->destination->server;
if (! $server->isFunctional()) {
return 'Server is not functional';
}
switch ($database->getMorphClass()) {
case StandalonePostgresql::class:
$activity = StartPostgresql::run($database);
break;
case StandaloneRedis::class:
$activity = StartRedis::run($database);
break;
case StandaloneMongodb::class:
$activity = StartMongodb::run($database);
break;
case StandaloneMysql::class:
$activity = StartMysql::run($database);
break;
case StandaloneMariadb::class:
$activity = StartMariadb::run($database);
break;
case StandaloneKeydb::class:
$activity = StartKeydb::run($database);
break;
case StandaloneDragonfly::class:
$activity = StartDragonfly::run($database);
break;
case StandaloneClickhouse::class:
$activity = StartClickhouse::run($database);
break;
$activity = activity()
->withProperties([
'server_uuid' => $server->uuid,
'type' => ActivityTypes::INLINE->value,
'type_uuid' => $database->uuid,
'status' => ProcessStatus::QUEUED->value,
'team_id' => $server->team_id,
'operation' => 'database-start',
])
->performedOn($database)
->event(ActivityTypes::INLINE->value)
->log('[]');
if ($activity === null) {
return 'Database start could not be queued because activity logging is disabled.';
}
DatabaseStartJob::dispatch(
$database->getMorphClass(),
(int) $database->getKey(),
(int) $database->team()->id,
(int) $activity->getKey(),
auth()->id(),
);
if ($database->is_public && $database->public_port) {
StartDatabaseProxy::dispatch($database);
}
+17 -6
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneDragonfly;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartDragonfly
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneDragonfly $database;
@@ -20,7 +22,9 @@ class StartDragonfly
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneDragonfly $database)
private string $resolvedRedisPassword;
public function handle(StandaloneDragonfly $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -107,7 +111,7 @@ class StartDragonfly
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
'CMD', 'redis-cli', '-a', $this->resolvedRedisPassword, 'ping',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -196,12 +200,13 @@ class StartDragonfly
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function buildStartCommand(): string
{
$command = "dragonfly --requirepass {$this->database->dragonfly_password}";
$escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword);
$command = "dragonfly --requirepass {$escapedRedisPassword}";
if ($this->database->enable_ssl) {
$sslArgs = [
@@ -251,8 +256,14 @@ class StartDragonfly
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedRedisPassword = (string) $this->database->dragonfly_password;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'REDIS_PASSWORD') {
$this->resolvedRedisPassword = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
+18 -7
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartKeydb
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneKeydb $database;
@@ -20,7 +22,9 @@ class StartKeydb
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneKeydb $database)
private string $resolvedRedisPassword;
public function handle(StandaloneKeydb $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -109,7 +113,7 @@ class StartKeydb
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
'CMD', 'keydb-cli', '--pass', $this->resolvedRedisPassword, 'ping',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -214,7 +218,7 @@ class StartKeydb
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -252,8 +256,14 @@ class StartKeydb
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedRedisPassword = (string) $this->database->keydb_password;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'REDIS_PASSWORD') {
$this->resolvedRedisPassword = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
@@ -280,6 +290,7 @@ class StartKeydb
{
$hasKeydbConf = ! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf);
$keydbConfPath = '/etc/keydb/keydb.conf';
$escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword);
if ($hasKeydbConf) {
$confContent = $this->database->keydb_conf;
@@ -288,10 +299,10 @@ class StartKeydb
if ($hasRequirePass) {
$command = "keydb-server $keydbConfPath";
} else {
$command = "keydb-server $keydbConfPath --requirepass {$this->database->keydb_password}";
$command = "keydb-server $keydbConfPath --requirepass {$escapedRedisPassword}";
}
} else {
$command = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes";
$command = "keydb-server --requirepass {$escapedRedisPassword} --appendonly yes";
}
if ($this->database->enable_ssl) {
+6 -4
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMariadb;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartMariadb
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneMariadb $database;
@@ -20,7 +22,7 @@ class StartMariadb
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMariadb $database)
public function handle(StandaloneMariadb $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -216,7 +218,7 @@ class StartMariadb
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -255,7 +257,7 @@ class StartMariadb
{
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) {
+27 -7
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMongodb;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartMongodb
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneMongodb $database;
@@ -20,7 +22,13 @@ class StartMongodb
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMongodb $database)
private string $resolvedMongoUsername;
private string $resolvedMongoPassword;
private string $resolvedMongoDatabase;
public function handle(StandaloneMongodb $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -265,7 +273,7 @@ class StartMongodb
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -303,8 +311,20 @@ class StartMongodb
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedMongoUsername = (string) $this->database->mongo_initdb_root_username;
$this->resolvedMongoPassword = (string) $this->database->mongo_initdb_root_password;
$this->resolvedMongoDatabase = (string) $this->database->mongo_initdb_database;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'MONGO_INITDB_ROOT_USERNAME') {
$this->resolvedMongoUsername = $rawValue;
} elseif ($env->key === 'MONGO_INITDB_ROOT_PASSWORD') {
$this->resolvedMongoPassword = $rawValue;
} elseif ($env->key === 'MONGO_INITDB_DATABASE') {
$this->resolvedMongoDatabase = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) {
@@ -337,9 +357,9 @@ class StartMongodb
private function add_default_database()
{
$dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES);
$userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES);
$pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES);
$dbJson = json_encode($this->resolvedMongoDatabase, JSON_UNESCAPED_SLASHES);
$userJson = json_encode($this->resolvedMongoUsername, JSON_UNESCAPED_SLASHES);
$pwdJson = json_encode($this->resolvedMongoPassword, JSON_UNESCAPED_SLASHES);
$content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});";
$content_base64 = base64_encode($content);
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
+15 -5
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneMysql;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartMysql
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneMysql $database;
@@ -20,7 +22,9 @@ class StartMysql
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneMysql $database)
private string $resolvedMysqlRootPassword;
public function handle(StandaloneMysql $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -104,7 +108,7 @@ class StartMysql
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->resolvedMysqlRootPassword}",
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -218,7 +222,7 @@ class StartMysql
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -256,8 +260,14 @@ class StartMysql
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedMysqlRootPassword = (string) $this->database->mysql_root_password;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'MYSQL_ROOT_PASSWORD') {
$this->resolvedMysqlRootPassword = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) {
+20 -5
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandalonePostgresql;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartPostgresql
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandalonePostgresql $database;
@@ -22,7 +24,11 @@ class StartPostgresql
private ?SslCertificate $ssl_certificate = null;
public function handle(StandalonePostgresql $database)
private string $resolvedPostgresUser;
private string $resolvedPostgresDatabase;
public function handle(StandalonePostgresql $database, ?Activity $activity = null)
{
$this->database = $database;
$container_name = $this->database->uuid;
@@ -111,7 +117,7 @@ class StartPostgresql
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => $this->database->healthCheckConfiguration([
'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
'CMD', 'psql', '-U', $this->resolvedPostgresUser, '-d', $this->resolvedPostgresDatabase, '-c', 'SELECT 1',
]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
@@ -227,7 +233,7 @@ class StartPostgresql
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -265,8 +271,17 @@ class StartPostgresql
private function generate_environment_variables()
{
$environment_variables = collect();
$this->resolvedPostgresUser = (string) $this->database->postgres_user;
$this->resolvedPostgresDatabase = (string) $this->database->postgres_db;
foreach ($this->database->runtime_environment_variables as $env) {
$environment_variables->push("$env->key=$env->real_value");
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
$environment_variables->push($env->key.'='.$resolvedValue);
if ($env->key === 'POSTGRES_USER') {
$this->resolvedPostgresUser = $rawValue;
} elseif ($env->key === 'POSTGRES_DB') {
$this->resolvedPostgresDatabase = $rawValue;
}
}
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) {
+35 -11
View File
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
use App\Helpers\SslHelper;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis;
use App\Traits\ExecutesDatabaseStartCommands;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Yaml\Yaml;
class StartRedis
{
use AsAction;
use AsAction, ExecutesDatabaseStartCommands;
public StandaloneRedis $database;
@@ -20,7 +22,11 @@ class StartRedis
private ?SslCertificate $ssl_certificate = null;
public function handle(StandaloneRedis $database)
private ?string $resolvedRedisPassword = null;
private ?string $resolvedRedisUsername = null;
public function handle(StandaloneRedis $database, ?Activity $activity = null)
{
$this->database = $database;
@@ -209,7 +215,7 @@ class StartRedis
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
}
private function generate_local_persistent_volumes()
@@ -249,23 +255,40 @@ class StartRedis
$environment_variables = collect();
foreach ($this->database->runtime_environment_variables as $env) {
$usesSecretManager = $this->database->environmentVariableUsesSecretManager($env);
if ($env->is_shared) {
$environment_variables->push("$env->key=$env->real_value");
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
if ($env->key === 'REDIS_PASSWORD') {
$this->database->update(['redis_password' => $env->real_value]);
$this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
if (! $usesSecretManager) {
$this->database->update(['redis_password' => $this->resolvedRedisPassword]);
}
}
if ($env->key === 'REDIS_USERNAME') {
$this->database->update(['redis_username' => $env->real_value]);
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
if (! $usesSecretManager) {
$this->database->update(['redis_username' => $this->resolvedRedisUsername]);
}
}
} else {
if ($env->key === 'REDIS_PASSWORD') {
if ($env->key === 'REDIS_PASSWORD' && ! $usesSecretManager) {
$env->update(['value' => $this->database->redis_password]);
} elseif ($env->key === 'REDIS_USERNAME') {
} elseif ($env->key === 'REDIS_USERNAME' && ! $usesSecretManager) {
$env->update(['value' => $this->database->redis_username]);
}
$environment_variables->push("$env->key=$env->real_value");
if ($env->key === 'REDIS_PASSWORD') {
$this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
} elseif ($env->key === 'REDIS_USERNAME') {
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
}
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
}
}
@@ -276,6 +299,7 @@ class StartRedis
private function buildStartCommand(): string
{
$redisPassword = $this->resolvedRedisPassword ?? $this->database->redis_password;
$hasRedisConf = ! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf);
$redisConfPath = '/usr/local/etc/redis/redis.conf';
@@ -286,10 +310,10 @@ class StartRedis
if ($hasRequirePass) {
$command = "redis-server $redisConfPath";
} else {
$command = "redis-server $redisConfPath --requirepass {$this->database->redis_password}";
$command = "redis-server $redisConfPath --requirepass {$redisPassword}";
}
} else {
$command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes";
$command = "redis-server --requirepass {$redisPassword} --appendonly yes";
}
if ($this->database->enable_ssl) {
+2 -2
View File
@@ -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.');
}
}
}
+2 -1
View File
@@ -3,6 +3,7 @@
namespace App\Actions\Server;
use App\Models\Server;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
class CheckUpdates
@@ -275,7 +276,7 @@ class CheckUpdates
// Include unparsed lines in the result for debugging if any exist
if (! empty($unparsedLines)) {
$result['unparsed_lines'] = $unparsedLines;
\Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [
Log::debug('Pacman output contained unparsed lines', [
'unparsed_lines' => $unparsedLines,
]);
}
+35 -60
View File
@@ -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();
}
}
+142
View File
@@ -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);
}
}
+65
View File
@@ -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;
}
}
+7
View File
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Models\AuditEvent;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@@ -49,6 +50,12 @@ class CleanupDatabase extends Command
$activity_log->delete();
}
$count = DB::table('audit_events')->where('created_at', '<', now()->subDays(90))->count();
echo "Delete $count entries from audit_events.\n";
if ($this->option('yes')) {
AuditEvent::pruneExpired();
}
// Cleanup application_deployment_queues table
$application_deployment_queues = DB::table('application_deployment_queues')->where('created_at', '<', now()->subDays($keep_days))->orderBy('created_at', 'desc')->skip(10);
$count = $application_deployment_queues->count();
@@ -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;
}
}
+59 -41
View File
@@ -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
@@ -248,25 +281,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);
@@ -279,6 +293,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;
@@ -290,14 +326,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;
}
@@ -388,14 +416,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}");
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\IntegrationToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class ApplicationSecretManagerController extends Controller
{
#[OA\Patch(
summary: 'Configure Application Secret Manager',
description: 'Configure the secret manager source used by an application.',
path: '/applications/{uuid}/secret-manager',
operationId: 'configure-application-secret-manager',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['integration_token_uuid'],
properties: [
new OA\Property(property: 'integration_token_uuid', type: 'string'),
new OA\Property(property: 'settings', type: 'object'),
],
),
),
responses: [
new OA\Response(response: 200, description: 'Secret manager configured.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function update(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)
->where('uuid', $request->route('uuid'))
->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
$this->authorize('update', $application);
$body = $request->json()->all();
$token = IntegrationToken::query()
->where('team_id', $teamId)
->where('uuid', $body['integration_token_uuid'] ?? '')
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
->first();
if (! $token || ! in_array('secrets', $token->capabilities ?? [], true)) {
return response()->json(['message' => 'Secret manager integration token not found.'], 404);
}
$rules = [
'integration_token_uuid' => ['required', 'string'],
'settings' => ['sometimes', 'array'],
];
$rules += match ($token->provider) {
'doppler' => $token->dopplerTokenType() === 'service_account' ? [
'settings.project' => ['required', 'string'],
'settings.config' => ['required', 'string'],
] : [],
'infisical' => [
'settings.project_id' => ['required', 'string'],
'settings.environment' => ['required', 'string'],
'settings.secret_path' => ['nullable', 'string'],
],
'vault' => [
'settings.mount' => ['required', 'string'],
'settings.path' => ['required', 'string'],
],
default => [],
};
$validator = customApiValidator($body, $rules);
$extraFields = array_diff(array_keys($body), ['integration_token_uuid', 'settings']);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$settings = array_filter($validator->validated()['settings'] ?? [], fn ($value) => filled($value));
$application->secretManagerLink()->updateOrCreate([], [
'integration_token_id' => $token->id,
'settings' => $settings ?: null,
]);
auditLog('api.application.secret_manager.updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'integration_token_uuid' => $token->uuid,
]);
return response()->json([
'integration_token_uuid' => $token->uuid,
'provider' => $token->provider,
'settings' => $settings ?: null,
]);
}
}
@@ -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);
}
@@ -3122,7 +3127,7 @@ class ApplicationsController extends Controller
if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth) && $server->isProxyShouldRun()) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
}
$application->save();
$application->withoutAuditLogging(fn () => $application->save());
auditLog('api.application.updated', [
'team_id' => $teamId,
@@ -5630,14 +5635,6 @@ class ApplicationsController extends Controller
return response()->json(['message' => $result['message']], 200);
}
auditLog('api.application.rollback', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid,
'commit' => $commit,
]);
return response()->json([
'message' => 'Rollback deployment queued.',
'deployment_uuid' => $deployment_uuid,
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AuditEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class AuditEventsController extends Controller
{
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if (! $request->user()->isAdminOfTeam($teamId)) {
return response()->json(['message' => 'Only team admins and owners can view audit logs.'], 403);
}
$validator = Validator::make($request->all(), [
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
'page' => ['sometimes', 'integer', 'min:1'],
'search' => ['sometimes', 'nullable', 'string', 'max:255'],
'action' => ['sometimes', 'nullable', 'string', 'max:255'],
'source' => ['sometimes', 'nullable', 'string', Rule::in(['all', 'ui', 'api', 'mcp', 'webhook', 'system', 'scheduler'])],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$validated = $validator->validated();
$perPage = (int) ($validated['per_page'] ?? 25);
$search = trim((string) ($validated['search'] ?? ''));
$canReadSensitive = $request->attributes->get('can_read_sensitive', false) === true;
$events = AuditEvent::query()
->select([
'id',
'team_id',
'event',
'source',
'action',
'actor_type',
'actor_id',
'actor_name',
'resource_type',
'resource_uuid',
'resource_name',
'description',
'created_at',
])
->when($canReadSensitive, fn ($query) => $query->addSelect([
'actor_email',
'actor_token_id',
'actor_token_name',
'metadata',
'ip_address',
'user_agent',
]))
->visibleToTeam($teamId)
->filtered(
search: $search,
action: (string) ($validated['action'] ?? 'all'),
source: (string) ($validated['source'] ?? 'all'),
searchSensitiveFields: $canReadSensitive,
)
->latestFirst()
->paginate($perPage);
return response()->json(serializeApiResponse($events));
}
}
@@ -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);
}
}
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\IntegrationToken;
use App\Services\IntegrationTokenValidator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class IntegrationTokensController extends Controller
{
#[OA\Post(
summary: 'Create Secret Manager Token',
description: 'Create and validate a Doppler, Infisical, or Vault integration token.',
path: '/security/integration-tokens',
operationId: 'create-secret-manager-integration-token',
security: [['bearerAuth' => []]],
tags: ['Secret Managers'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['provider', 'name', 'token'],
properties: [
new OA\Property(property: 'provider', type: 'string', enum: ['doppler', 'infisical', 'vault']),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'token', type: 'string'),
new OA\Property(property: 'metadata', type: 'object'),
],
),
),
responses: [
new OA\Response(response: 201, description: 'Integration token created.'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function store(Request $request, IntegrationTokenValidator $tokenValidator): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', IntegrationToken::class);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$body = $request->json()->all();
$rules = [
'provider' => ['required', 'string', 'in:'.implode(',', IntegrationToken::SECRET_MANAGER_PROVIDERS)],
'name' => ['required', 'string', 'max:255'],
'token' => ['required', 'string'],
'metadata' => ['sometimes', 'array'],
];
if (($body['provider'] ?? null) === 'doppler') {
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
} elseif (($body['provider'] ?? null) === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.client_id'] = ['required', 'string'];
} elseif (($body['provider'] ?? null) === 'vault') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.namespace'] = ['nullable', 'string'];
}
$validator = customApiValidator($body, $rules);
$extraFields = array_diff(array_keys($body), ['provider', 'name', 'token', 'metadata']);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$validated = $validator->validated();
$metadata = array_filter($validated['metadata'] ?? [], fn ($value) => filled($value));
if (! $tokenValidator->validate($validated['provider'], $validated['token'], ['secrets'], $metadata)) {
return response()->json(['message' => $tokenValidator->errorMessage($validated['provider'])], 400);
}
$integrationToken = IntegrationToken::query()->create([
'team_id' => $teamId,
'provider' => $validated['provider'],
'name' => $validated['name'],
'token' => $validated['token'],
'capabilities' => ['secrets'],
'metadata' => $metadata ?: null,
]);
auditLog('api.integration_token.created', [
'team_id' => $teamId,
'integration_token_uuid' => $integrationToken->uuid,
'provider' => $integrationToken->provider,
]);
return response()->json(['uuid' => $integrationToken->uuid], 201);
}
}
@@ -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' => []]],
+2 -21
View File
@@ -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();
@@ -269,12 +271,6 @@ class ProjectController extends Controller
'team_id' => $teamId,
]);
auditLog('api.project.created', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
]);
return response()->json([
'uuid' => $project->uuid,
])->setStatusCode(201);
@@ -394,13 +390,6 @@ class ProjectController extends Controller
$project->update($request->only($allowedFields));
auditLog('api.project.updated', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'uuid' => $project->uuid,
'name' => $project->name,
@@ -480,16 +469,8 @@ class ProjectController extends Controller
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
$projectUuid = $project->uuid;
$projectName = $project->name;
$project->delete();
auditLog('api.project.deleted', [
'team_id' => $teamId,
'project_uuid' => $projectUuid,
'project_name' => $projectName,
]);
return response()->json(['message' => 'Project deleted.']);
}
@@ -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;
+9 -7
View File
@@ -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);
}
+99 -51
View File
@@ -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),
]);
}
+2
View File
@@ -29,6 +29,7 @@ use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Http\Middleware\SetCacheHeaders;
@@ -59,6 +60,7 @@ class Kernel extends HttpKernel
ValidatePostSize::class,
TrimStrings::class,
ConvertEmptyStringsToNull::class,
InvokeDeferredCallbacks::class,
];
@@ -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));
+163 -18
View File
@@ -19,6 +19,7 @@ use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Notifications\Application\DeploymentFailed;
use App\Notifications\Application\DeploymentSuccess;
use App\Support\RemoteSecretReferences;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\ExecuteRemoteCommand;
@@ -143,6 +144,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_args;
/** @var array<string, string>|null */
private ?array $remote_secrets_cache = null;
private $env_nixpacks_args;
private $env_railpack_args;
@@ -614,6 +618,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;
}
@@ -1275,6 +1283,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return true;
}
if ($this->has_remote_buildtime_secret_references()) {
$this->application_deployment_queue->addLogEntry('Remote build-time secrets are configured. Running the build to check for updated values.');
return false;
}
$configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
if (! $configurationDiff->requiresBuild()) {
$this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
@@ -1302,6 +1315,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return false;
}
private function has_remote_buildtime_secret_references(): bool
{
$environmentVariables = $this->pull_request_id === 0
? $this->application->environment_variables()
: $this->application->environment_variables_preview();
return $environmentVariables
->where('is_buildtime', true)
->get(['value'])
->contains(fn (EnvironmentVariable $environmentVariable) => RemoteSecretReferences::containsReference($environmentVariable->value));
}
private function check_image_locally_or_remotely()
{
$this->execute_remote_command([
@@ -1323,6 +1348,101 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
/**
* Fetch the secrets from the application's secret manager source. Values
* live only in memory during the deployment and in the generated .env on
* the server they are never persisted in the Coolify database. Fetched
* lazily (only when a variable references a secret), once per deployment.
* A fetch failure fails the deployment.
*
* @return array<string, string>
*/
private function remote_secrets(): array
{
if ($this->remote_secrets_cache !== null) {
return $this->remote_secrets_cache;
}
$link = $this->application->secretManagerLink()->with('integrationToken')->first();
if (! $link) {
throw new DeploymentException('Environment variables reference remote secrets ({{vault.KEY}}), but no secret manager source is configured for this application.');
}
$provider = $link->integrationToken->providerName();
$tokenName = $link->integrationToken->name;
try {
$secrets = $link->fetchSecrets();
} catch (Throwable $e) {
$this->application_deployment_queue->addLogEntry("Failed to fetch secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}): {$e->getMessage()}", 'stderr');
throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets.");
}
$this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}).");
return $this->remote_secrets_cache = $secrets;
}
/**
* Replace {{vault.KEY}} references with values from the configured secret
* manager source. Missing keys fail the deployment with a
* list changing the source never re-checks references, so this is the
* moment problems surface.
*/
private function substitute_remote_secrets(string $value, string $envKey): string
{
$secrets = $this->remote_secrets();
$missing = RemoteSecretReferences::missingKeys($value, $secrets);
if ($missing !== []) {
$message = 'Missing secret keys: '.implode(', ', $missing)." (referenced by {$envKey}).";
$this->application_deployment_queue->addLogEntry($message, 'stderr');
throw new DeploymentException($message.' Check the secret manager source of this application.');
}
return RemoteSecretReferences::substitute($value, $secrets);
}
/**
* Resolve shared variables, then secret references, in a raw variable value.
*/
private function resolve_environment_variable_raw(EnvironmentVariable $env): string
{
$value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer);
return $this->substitute_remote_secrets($value ?? '', $env->key);
}
/**
* Resolve a runtime variable to its dotenv representation. Values with
* secret references are substituted and written as literals.
*/
private function resolve_environment_variable(EnvironmentVariable $env): ?string
{
if (! RemoteSecretReferences::containsReference($env->value)) {
return $env->getResolvedValueWithServer($this->mainServer);
}
return $this->format_remote_secret_value($this->resolve_environment_variable_raw($env));
}
/**
* Format a remote secret value for the runtime .env file (dotenv syntax read
* by docker compose). Values are treated as literals no interpolation.
*/
private function format_remote_secret_value(string $value): string
{
if (! str_contains($value, "'")) {
return "'".$value."'";
}
// Fall back to double quotes; $$ escapes compose interpolation.
return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '$$'], $value).'"';
}
private function generate_runtime_environment_variables()
{
$envs = collect([]);
@@ -1391,7 +1511,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
});
foreach ($runtime_environment_variables as $env) {
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
}
// Check for PORT environment variable mismatch with ports_exposes
@@ -1458,7 +1578,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
});
foreach ($runtime_environment_variables_preview as $env) {
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
}
// Fall back to production env vars for keys not overridden by preview vars,
@@ -1472,7 +1592,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $env->is_runtime && ! in_array($env->key, $previewKeys);
});
foreach ($fallback_production_vars as $env) {
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
}
}
@@ -1580,6 +1700,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
'skip_command_log' => true,
]
);
@@ -1598,6 +1719,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
'skip_command_log' => true,
]
);
$this->server = $this->build_server;
@@ -1605,6 +1727,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
'skip_command_log' => true,
]
);
}
@@ -1728,6 +1851,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
if (RemoteSecretReferences::containsReference($env->value)) {
$envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1783,6 +1912,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
if (RemoteSecretReferences::containsReference($env->value)) {
$envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1853,6 +1988,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'),
'skip_command_log' => true,
]
);
@@ -2651,6 +2787,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
{
if (RemoteSecretReferences::containsReference($environmentVariable->value)) {
$resolved = $this->resolve_environment_variable_raw($environmentVariable);
return $resolved === '' ? null : $resolved;
}
$resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
if (is_null($resolvedValue) || $resolvedValue === '') {
return null;
@@ -3194,7 +3336,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
$resolvedValue = RemoteSecretReferences::containsReference($env->value)
? $this->resolve_environment_variable_raw($env)
: $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
$this->env_args->put($env->key, $resolvedValue);
}
@@ -3210,7 +3354,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
$resolvedValue = RemoteSecretReferences::containsReference($env->value)
? $this->resolve_environment_variable_raw($env)
: $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
$this->env_args->put($env->key, $resolvedValue);
}
@@ -4268,7 +4414,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
$secrets_string = $variables
->map(function ($env) {
return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}";
return "{$env->key}={$this->resolve_environment_variable($env)}";
})
->sort()
->implode('|');
@@ -4334,7 +4480,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
$argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
$argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
}
}
// Add Coolify variables as ARGs
@@ -4356,7 +4502,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
$argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
$argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
}
}
// Add Coolify variables as ARGs
@@ -4370,6 +4516,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
}
if ($argsToInsert->isNotEmpty()) {
$environmentVariables = $envs->mapWithKeys(function ($environmentVariable) {
return [$environmentVariable->key => escapeBashEnvValue($this->resolve_environment_variable_raw($environmentVariable))];
});
$secretsHash = $this->generate_secrets_hash($environmentVariables);
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secretsHash}");
}
// Development logging to show what ARGs are being injected
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
@@ -4391,11 +4545,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
}
}
$envs_mapped = $envs->mapWithKeys(function ($env) {
return [$env->key => $env->getResolvedValueWithServer($this->mainServer)];
});
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
@@ -4404,11 +4553,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
[
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true,
'ignore_errors' => true,
'skip_command_log' => true,
]);
}
+90
View File
@@ -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();
});
}
}
+18 -12
View File
@@ -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', [
+1 -1
View 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);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Jobs;
use App\Actions\Database\StartClickhouse;
use App\Actions\Database\StartDragonfly;
use App\Actions\Database\StartKeydb;
use App\Actions\Database\StartMariadb;
use App\Actions\Database\StartMongodb;
use App\Actions\Database\StartMysql;
use App\Actions\Database\StartPostgresql;
use App\Actions\Database\StartRedis;
use App\Enums\ProcessStatus;
use App\Events\DatabaseStatusChanged;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
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 Spatie\Activitylog\Models\Activity;
use Throwable;
class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 600;
public function __construct(
public string $databaseClass,
public int $databaseId,
public int $teamId,
public int $activityId,
public ?int $userId,
) {
$this->onQueue(deployment_queue());
}
public function handle(): void
{
$database = $this->databaseClass::query()->findOrFail($this->databaseId);
abort_unless((int) $database->team()->id === $this->teamId, 403);
$activity = Activity::query()->findOrFail($this->activityId);
match ($database->getMorphClass()) {
StandalonePostgresql::class => StartPostgresql::run($database, $activity),
StandaloneRedis::class => StartRedis::run($database, $activity),
StandaloneMongodb::class => StartMongodb::run($database, $activity),
StandaloneMysql::class => StartMysql::run($database, $activity),
StandaloneMariadb::class => StartMariadb::run($database, $activity),
StandaloneKeydb::class => StartKeydb::run($database, $activity),
StandaloneDragonfly::class => StartDragonfly::run($database, $activity),
StandaloneClickhouse::class => StartClickhouse::run($database, $activity),
};
event(new DatabaseStatusChanged($this->userId));
}
public function failed(?Throwable $exception): void
{
try {
$activity = Activity::query()->find($this->activityId);
if (! $activity) {
return;
}
$activity->properties = $activity->properties->merge([
'status' => ProcessStatus::ERROR->value,
'error' => 'Database start failed.',
'failed_at' => now()->toIso8601String(),
]);
$activity->save();
} finally {
event(new DatabaseStatusChanged($this->userId));
}
}
}
+67 -42
View File
@@ -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
+34
View File
@@ -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));
}
}
@@ -61,7 +61,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
if ($status === 'created') {
instant_remote_process([
'docker rm -f coolify-proxy',
dockerRemoveCommand('coolify-proxy'),
], $server);
}
}
+1 -1
View File
@@ -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 -19
View File
@@ -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');
+10
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Notifications;
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;
@@ -56,6 +57,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;
@@ -128,6 +132,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;
@@ -139,6 +144,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;
@@ -174,6 +180,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)
@@ -311,6 +318,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.');
@@ -356,6 +364,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.',
@@ -430,6 +439,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;
@@ -104,7 +104,6 @@ class DeploymentNavbar extends Component
$this->application_deployment_queue->update([
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
try {
if ($this->application->settings->is_build_server_enabled) {
$server = Server::ownedByCurrentTeam()->find($build_server_id);
+212 -54
View File
@@ -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'];
}
}
@@ -156,6 +156,11 @@ class Heading extends Component
$this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.');
StopApplication::dispatch($this->application, false, $this->docker_cleanup);
auditLog('ui.application.stopped', [
'team_id' => $this->application->team()?->id,
'application_uuid' => $this->application->uuid,
'application_name' => $this->application->name,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -377,6 +377,12 @@ class Previews extends Component
ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id)
->update(['status' => 'exited']);
auditLog('ui.application.preview_stopped', [
'team_id' => $this->application->team()?->id,
'application_uuid' => $this->application->uuid,
'application_name' => $this->application->name,
'pull_request_id' => $pull_request_id,
]);
ServiceStatusChanged::dispatch($this->application->environment->project->team->id);
GetContainersStatus::run($server);
+8
View File
@@ -102,6 +102,14 @@ class CloneMe extends Component
if (! $selectedDestination) {
throw new \Exception('Destination not found.');
}
auditLog('ui.project.clone_started', [
'team_id' => $this->project->team_id,
'project_uuid' => $this->project->uuid,
'project_name' => $this->project->name,
'clone_type' => $type,
'new_name' => $this->newName,
'destination_uuid' => $selectedDestination->uuid,
]);
if ($type === 'project') {
$foundProject = Project::where('name', $this->newName)->first();
if ($foundProject) {
+17 -4
View File
@@ -207,10 +207,18 @@ class BackupEdit extends Component
}
}
$database = $this->backup->database;
$backupUuid = $this->backup->uuid;
$this->backup->delete();
auditLog('ui.database.backup_schedule_deleted', [
'team_id' => $database->team()?->id,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'backup_uuid' => $backupUuid,
]);
if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $this->backup->database;
if ($database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $database;
return redirect()->route('project.service.database.backups', [
'project_uuid' => $this->parameters['project_uuid'],
@@ -238,9 +246,14 @@ class BackupEdit extends Component
$this->authorize('manageBackups', $this->backup->database);
DatabaseBackupJob::dispatch($this->backup);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
$database = $this->backup->database;
auditLog('ui.database.backup_started', [
'team_id' => $database->team()?->id,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'backup_uuid' => $this->backup->uuid,
]);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
if ($database instanceof ServiceDatabase) {
return redirect()->route('project.service.database.backup.executions', [
@@ -18,6 +18,13 @@ class BackupNow extends Component
$this->authorize('manageBackups', $this->backup->database);
DatabaseBackupJob::dispatch($this->backup);
$database = $this->backup->database;
auditLog('ui.database.backup_started', [
'team_id' => $database->team()?->id,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'backup_uuid' => $this->backup->uuid,
]);
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
} catch (\Throwable $e) {
return handleError($e, $this);
+12
View File
@@ -83,6 +83,7 @@ class Heading extends Component
$this->dispatch('info', 'Gracefully stopping database.');
StopDatabase::dispatch($this->database, false, $this->docker_cleanup);
$this->auditDatabaseAction('ui.database.stopped');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
}
@@ -94,6 +95,7 @@ class Heading extends Component
$this->authorize('manage', $this->database);
$activity = RestartDatabase::run($this->database);
$this->auditDatabaseAction('ui.database.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
@@ -107,6 +109,7 @@ class Heading extends Component
$this->authorize('manage', $this->database);
$activity = StartDatabase::run($this->database);
$this->auditDatabaseAction('ui.database.started');
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
} catch (\Throwable $e) {
@@ -122,4 +125,13 @@ class Heading extends Component
],
]);
}
private function auditDatabaseAction(string $event): void
{
auditLog($event, [
'team_id' => $this->database->team()?->id,
'database_uuid' => $this->database->uuid,
'database_name' => $this->database->name,
]);
}
}
+39 -2
View File
@@ -510,6 +510,12 @@ EOD;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
auditLog('ui.database.import_started', [
'team_id' => $this->resource->team()?->id,
'database_uuid' => $this->resource->uuid,
'database_name' => $this->resource->name,
'source' => 'file',
]);
}
} catch (\Throwable $e) {
handleError($e, $this);
@@ -768,6 +774,13 @@ EOD;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
auditLog('ui.database.restore_started', [
'team_id' => $this->resource->team()?->id,
'database_uuid' => $this->resource->uuid,
'database_name' => $this->resource->name,
'source' => 's3',
'storage_id' => $this->s3StorageId,
]);
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) {
$this->importRunning = false;
@@ -796,6 +809,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 +832,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 +920,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}";
}
+11 -4
View File
@@ -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()
{
+182 -43
View File
@@ -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'];
}
}
+13
View File
@@ -113,6 +113,7 @@ class Heading extends Component
try {
$this->authorizeService('deploy');
$activity = StartService::run($this->service, pullLatestImages: true);
$this->auditServiceAction('ui.service.started');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -146,6 +147,7 @@ class Heading extends Component
try {
$this->authorizeService('stop');
StopService::dispatch($this->service, false, $this->docker_cleanup);
$this->auditServiceAction('ui.service.stopped');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -162,6 +164,7 @@ class Heading extends Component
return;
}
$activity = StartService::run($this->service, stopBeforeStart: true);
$this->auditServiceAction('ui.service.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -180,6 +183,7 @@ class Heading extends Component
return;
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->auditServiceAction('ui.service.restarted');
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
@@ -196,6 +200,15 @@ class Heading extends Component
$this->authorize($ability, $this->service);
}
private function auditServiceAction(string $event): void
{
auditLog($event, [
'team_id' => $this->service->team()?->id,
'service_uuid' => $this->service->uuid,
'service_name' => $this->service->name,
]);
}
public function render()
{
return view('livewire.project.service.heading', [
+10 -4
View File
@@ -77,6 +77,7 @@ class Storage extends Component
$this->activeTab = $this->resolveDefaultTab();
$this->fileStorage = collect();
$this->loadFileStorageForActiveTab();
$this->name = $this->generateDefaultVolumeName();
}
public function refreshStoragesFromEvent()
@@ -201,9 +202,7 @@ class Storage extends Component
$this->validate([
'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
'host_path' => $this->isSwarm
? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN]
: ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
], array_merge(ValidationPatterns::volumeNameMessages(), [
'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
]));
@@ -340,7 +339,7 @@ class Storage extends Component
public function clearForm()
{
$this->name = '';
$this->name = $this->generateDefaultVolumeName();
$this->mount_path = '';
$this->host_path = null;
$this->file_storage_path = '';
@@ -373,6 +372,13 @@ class Storage extends Component
throw new \Exception('No valid resource type for file mount storage type!');
}
private function generateDefaultVolumeName(): string
{
$name = str($this->resource->name)->slug()->value();
return ($name ?: 'volume').'-data';
}
public function fileStoragePreviewPath(): string
{
$path = str($this->file_storage_path)->trim();
@@ -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)',
]));
}
@@ -64,6 +64,13 @@ class Destination extends Component
$this->authorize('deploy', $this->resource);
$server = Server::ownedByCurrentTeam()->findOrFail($serverId);
StopApplicationOneServer::run($this->resource, $server);
auditLog('ui.application.destination_stopped', [
'team_id' => $this->resource->team()?->id,
'application_uuid' => $this->resource->uuid,
'application_name' => $this->resource->name,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
]);
$this->refreshServers();
} catch (\Exception $e) {
return handleError($e, $this);
@@ -9,14 +9,27 @@ use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\HasSecretManagerAutocomplete;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Add extends Component
{
use AuthorizesRequests, EnvironmentVariableAnalyzer;
use AuthorizesRequests, EnvironmentVariableAnalyzer, HasSecretManagerAutocomplete;
protected function secretManagerResource(): ?Model
{
if ($this->shared || ! $this->resource) {
return null;
}
return $this->resource;
}
public $resource;
public $parameters;
@@ -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;
@@ -12,7 +13,9 @@ use App\Models\SharedEnvironmentVariable;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
use App\Traits\HasSecretManagerAutocomplete;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -21,7 +24,12 @@ class Show extends Component
{
public bool $showEnvironmentType = true;
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete;
protected function secretManagerResource(): ?Model
{
return $this->isSharedVariable ? null : $this->env->resourceable;
}
public $parameters;
@@ -161,6 +169,22 @@ class Show extends Component
$this->valuesLoaded = true;
}
public function copyValue(): ?string
{
if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
return null;
}
if (! $this->env instanceof ModelsEnvironmentVariable) {
return $this->env->value;
}
return $this->env->get_real_environment_variables_with_server(
$this->env->resolveReferencedValue(),
$this->env->resourceable,
);
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
@@ -204,7 +228,7 @@ class Show extends Component
$this->is_required = (bool) ($this->env->is_required ?? false);
// Use the stored column, not the value-based accessor (that decrypts).
$this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false);
$this->isValueHidden = auth()->user()?->isMember() ?? false;
$this->isValueHidden = auth()->user()?->isMember() ?? true;
if ($this->valuesLoaded) {
$this->hydrateValueFields();
@@ -231,12 +255,12 @@ class Show extends Component
$this->is_really_required = $this->is_required && blank($this->value);
}
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
$this->value = null;
$this->real_value = null;
}
$this->isValueHidden = auth()->user()?->isMember() ?? false;
$this->isValueHidden = auth()->user()?->isMember() ?? true;
}
public function checkEnvs()
@@ -298,6 +322,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);
}
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\EnvironmentVariable;
use Livewire\Component;
class ShowHardcoded extends Component
@@ -20,6 +21,10 @@ class ShowHardcoded extends Component
public bool $isPreview = false;
public ?string $resourceableType = null;
public ?int $resourceableId = null;
public function mount()
{
$this->key = $this->env['key'];
@@ -28,6 +33,20 @@ class ShowHardcoded extends Component
$this->serviceName = $this->env['service_name'] ?? null;
}
public function copyValue(): ?string
{
if (auth()->user()?->isMember() ?? true) {
return null;
}
return EnvironmentVariable::make([
'value' => $this->value,
'is_preview' => $this->isPreview,
'resourceable_type' => $this->resourceableType,
'resourceable_id' => $this->resourceableId,
])->resolveReferencedValue();
}
public function render()
{
return view('livewire.project.shared.environment-variable.show-hardcoded');
@@ -86,6 +86,14 @@ class ResourceOperations extends Component
if (! $server->canHostResources()) {
return $this->addError('destination_id', 'The selected server cannot host resources.');
}
auditLog('ui.resource.clone_started', [
'team_id' => $this->resource->team()?->id,
'resource_uuid' => $this->resource->uuid,
'resource_name' => $this->resource->name,
'resource_type' => class_basename($this->resource),
'destination_uuid' => $new_destination->uuid,
'environment_id' => $new_environment->id,
]);
if ($this->resource->getMorphClass() === Application::class) {
$new_resource = clone_application($this->resource, $new_destination, [
@@ -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');
@@ -184,6 +184,13 @@ class Show extends Component
$this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
ScheduledTaskJob::dispatch($this->task);
auditLog('ui.scheduled_task.executed', [
'team_id' => $this->resource->team()?->id,
'resource_uuid' => $this->resource->uuid,
'resource_name' => $this->resource->name,
'scheduled_task_uuid' => $this->task->uuid,
'scheduled_task_name' => $this->task->name,
]);
$this->dispatch('success', 'Scheduled task executed.');
} catch (\Exception $e) {
return handleError($e);
@@ -0,0 +1,290 @@
<?php
namespace App\Livewire\Project\Shared;
use App\Models\IntegrationToken;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
/**
* Manages a resource's single secret manager source and lets the user
* browse remote key names, add {{vault.KEY}} reference variables, and import
* all missing keys. Secret values never enter the component state or the DB.
*/
class SecretManagerLinks extends Component
{
use AuthorizesRequests;
public $resource;
public $link;
public $availableTokens;
public string $integration_token_uuid = '';
public array $settings = [];
/** @var list<string> Remote key names only — values are never stored. */
public array $keys = [];
public bool $keysLoaded = false;
public string $search = '';
public function mount(): void
{
$this->loadData();
}
private function loadData(): void
{
$this->link = $this->resource->secretManagerLink()->with('integrationToken')->first();
$this->availableTokens = IntegrationToken::ownedByCurrentTeam()
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
->get()
->filter(fn (IntegrationToken $token) => in_array('secrets', $token->capabilities ?? [], true))
->values();
if ($this->link) {
$this->integration_token_uuid = $this->link->integrationToken->uuid;
$this->settings = $this->link->settings ?? [];
}
}
public function getSelectedTokenProperty(): ?IntegrationToken
{
if (blank($this->integration_token_uuid)) {
return null;
}
return $this->availableTokens->firstWhere('uuid', $this->integration_token_uuid);
}
protected function rules(): array
{
$rules = [
'integration_token_uuid' => ['required', 'string'],
];
$rules += match ($this->selectedToken?->provider) {
'doppler' => $this->selectedToken->dopplerTokenType() === 'service_account'
? [
'settings.project' => ['required', 'string'],
'settings.config' => ['required', 'string'],
]
: [],
'infisical' => [
'settings.project_id' => ['required', 'string'],
'settings.environment' => ['required', 'string'],
'settings.secret_path' => ['nullable', 'string'],
],
'vault' => [
'settings.mount' => ['required', 'string'],
'settings.path' => ['required', 'string'],
],
default => [],
};
return $rules;
}
/**
* Auto-save when a token is selected in the dropdown. Existing {{vault.*}}
* references are intentionally NOT re-checked missing keys surface at
* the next deployment.
*/
public function updatedIntegrationTokenUuid(): void
{
try {
$this->authorize('update', $this->resource);
$token = $this->selectedToken;
if (! $token) {
return;
}
if ($this->link?->integrationToken?->provider !== $token->provider
|| $this->link?->integrationToken?->dopplerTokenType() !== $token->dopplerTokenType()) {
$this->settings = [];
}
$settings = array_filter($this->settings, fn ($value) => filled($value));
$this->resource->secretManagerLink()->updateOrCreate([], [
'integration_token_id' => $token->id,
'settings' => $settings ?: null,
]);
$this->auditSecretManagerAction('source_updated', [
'integration_token_uuid' => $token->uuid,
'provider' => $token->provider,
]);
$this->resetKeys();
$this->loadData();
$this->dispatch('success', 'Secret manager source saved. References resolve at the next deployment.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
/**
* Auto-save of the provider-specific settings fields (called on blur).
*/
public function saveSettings(): void
{
$this->authorize('update', $this->resource);
if (! $this->link) {
return;
}
$validated = $this->validate();
try {
$settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value));
$this->link->update(['settings' => $settings ?: null]);
$this->auditSecretManagerAction('settings_updated');
$this->resetKeys();
$this->loadData();
$this->dispatch('success', 'Secret manager settings saved.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function removeSource(): void
{
try {
$this->authorize('update', $this->resource);
$token = $this->link?->integrationToken;
$this->resource->secretManagerLink()->delete();
$this->auditSecretManagerAction('source_removed', [
'integration_token_uuid' => $token?->uuid,
'provider' => $token?->provider,
]);
$this->link = null;
$this->integration_token_uuid = '';
$this->settings = [];
$this->resetKeys();
$this->loadData();
$this->dispatch('success', 'Secret manager source removed. Existing {{vault.*}} references will fail the next deployment until they are removed too.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function loadKeys(): void
{
try {
$this->authorize('update', $this->resource);
if (! $this->link) {
return;
}
// Values are fetched into memory, reduced to key names, and discarded.
$keys = array_keys($this->link->fetchSecrets());
sort($keys);
$this->keys = $keys;
$this->keysLoaded = true;
$this->auditSecretManagerAction('keys_viewed', ['key_count' => count($keys)]);
} catch (\Throwable $e) {
$this->dispatch('error', 'Could not fetch keys: '.$e->getMessage());
}
}
public function addReference(string $key): void
{
try {
$this->authorize('update', $this->resource);
if (! in_array($key, $this->keys, true)) {
return;
}
if ($this->resource->environment_variables()->where('key', $key)->exists()) {
$this->dispatch('error', "A variable with the key {$key} already exists.");
return;
}
$this->resource->environment_variables()->create([
'key' => $key,
'value' => '{{vault.'.$key.'}}',
]);
$this->auditSecretManagerAction('reference_created', ['secret_key' => $key]);
$this->dispatch('refreshEnvs');
$this->dispatch('success', "Added {$key} as {{vault.{$key}}}.");
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function importAll(): void
{
try {
$this->authorize('update', $this->resource);
if (! $this->link) {
return;
}
$imported = $this->link->importMissingReferences();
$this->auditSecretManagerAction('references_imported', [
'key_count' => count($imported),
'secret_keys' => $imported,
]);
$this->dispatch('refreshEnvs');
$this->dispatch('success', $imported === []
? 'All remote keys already exist as variables.'
: 'Imported '.count($imported).' keys as {{vault.KEY}} references.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
private function resetKeys(): void
{
$this->keys = [];
$this->keysLoaded = false;
$this->search = '';
}
/** @param array<string, mixed> $context */
private function auditSecretManagerAction(string $action, array $context = []): void
{
$resourceType = str(class_basename($this->resource))->snake()->value();
auditLog("ui.{$resourceType}.secret_manager.{$action}", array_merge([
'team_id' => $this->resource->team()?->id,
"{$resourceType}_uuid" => $this->resource->uuid,
"{$resourceType}_name" => $this->resource->name,
], $context));
}
public function getFilteredKeysProperty(): array
{
if (blank($this->search)) {
return $this->keys;
}
return array_values(array_filter(
$this->keys,
fn (string $key) => stripos($key, $this->search) !== false,
));
}
public function render(): View
{
return view('livewire.project.shared.secret-manager-links', [
'selectedToken' => $this->selectedToken,
'filteredKeys' => $this->filteredKeys,
]);
}
}
@@ -107,6 +107,25 @@ class All extends Component
$this->submit($storageId);
}
public function clearHostPath(int $storageId): void
{
$this->authorize('update', $this->resource);
$storage = $this->findStorageOrFail($storageId);
if ($storage->shouldBeReadOnlyInUI()) {
$this->dispatch('error', 'This volume is read-only.');
return;
}
$storage->host_path = null;
$storage->save();
$this->forms[$storageId]['hostPath'] = null;
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.');
}
/**
* Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms.
*/
@@ -204,6 +204,12 @@ class VolumeBackups extends Component
}
VolumeBackupJob::dispatch($this->backup);
auditLog('ui.volume_backup.started', [
'team_id' => $this->resource->team()?->id,
'resource_uuid' => $this->resource->uuid,
'resource_name' => $this->resource->name,
'backup_uuid' => $this->backup->uuid,
]);
$this->dispatch('success', 'Storage backup queued.');
return redirect()->route($this->routeName('executions'), $this->routeParameters());
+19 -2
View File
@@ -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)
@@ -137,6 +140,12 @@ class ApiTokens extends Component
]);
$expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
$token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt);
auditLog('ui.api_token.created', [
'team_id' => currentTeam()->id,
'api_token_name' => $this->description,
'abilities' => array_values($this->permissions),
'expires_at' => $expiresAt?->toIso8601String(),
]);
$this->getTokens();
// Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens.
session()->flash('token', $token->plainTextToken);
@@ -148,9 +157,17 @@ 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);
$tokenName = $token->name;
$token->delete();
auditLog('ui.api_token.revoked', [
'team_id' => currentTeam()->id,
'api_token_name' => $tokenName,
]);
$this->getTokens();
} catch (\Exception $e) {
return handleError($e, $this);
@@ -3,7 +3,7 @@
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use App\Services\CloudflareTokenValidator;
use App\Services\IntegrationTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -19,6 +19,8 @@ class IntegrationTokenEditor extends Component
public array $capabilities = [];
public array $metadata = [];
public function mount(string $integration_token_uuid): void
{
$this->integrationToken = IntegrationToken::ownedByCurrentTeam()
@@ -29,16 +31,31 @@ class IntegrationTokenEditor extends Component
$this->name = $this->integrationToken->name;
$this->capabilities = $this->integrationToken->capabilities;
$this->metadata = $this->integrationToken->metadata ?? [];
}
protected function rules(): array
{
return [
$allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets';
$rules = [
'name' => ['required', 'string', 'max:255'],
'newToken' => ['nullable', 'string'],
'capabilities' => ['required', 'array', 'min:1'],
'capabilities.*' => ['required', 'in:dns'],
'capabilities.*' => ['required', 'in:'.$allowedCapability],
];
if ($this->integrationToken->provider === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url'];
$rules['metadata.client_id'] = ['required', 'string'];
}
if ($this->integrationToken->provider === 'vault') {
$rules['metadata.base_url'] = ['required', 'url'];
$rules['metadata.namespace'] = ['nullable', 'string'];
}
return $rules;
}
protected function messages(): array
@@ -49,18 +66,21 @@ class IntegrationTokenEditor extends Component
];
}
public function save(CloudflareTokenValidator $validator): void
public function save(IntegrationTokenValidator $validator): void
{
$this->authorize('update', $this->integrationToken);
$validated = $this->validate();
$provider = $this->integrationToken->provider;
$token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token;
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
$capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all()
!== collect($this->integrationToken->capabilities)->sort()->values()->all();
$metadataChanged = $metadata != ($this->integrationToken->metadata ?? []);
try {
if ((filled($validated['newToken']) || $capabilitiesChanged)
&& ! $validator->validate($token, $validated['capabilities'])) {
$this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.');
if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged)
&& ! $validator->validate($provider, $token, $validated['capabilities'], $metadata)) {
$this->dispatch('error', $validator->errorMessage($provider));
return;
}
@@ -68,6 +88,7 @@ class IntegrationTokenEditor extends Component
$updates = [
'name' => $validated['name'],
'capabilities' => $validated['capabilities'],
'metadata' => $metadata ?: null,
];
if (filled($validated['newToken'])) {
@@ -100,8 +121,25 @@ class IntegrationTokenEditor extends Component
public function delete(string $password = ''): void
{
$this->authorize('delete', $this->integrationToken);
if ($this->integrationToken->secretManagerLinks()->exists()) {
$this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
return;
}
$uuid = $this->integrationToken->uuid;
$name = $this->integrationToken->name;
$provider = $this->integrationToken->provider;
$this->integrationToken->delete();
auditLog('ui.integration_token.deleted', [
'team_id' => currentTeam()->id,
'integration_token_uuid' => $uuid,
'integration_token_name' => $name,
'provider' => $provider,
]);
$this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid);
$this->dispatch('close-modal');
$this->dispatch('success', 'Integration token deleted successfully.');
+55 -9
View File
@@ -3,7 +3,7 @@
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use App\Services\CloudflareTokenValidator;
use App\Services\IntegrationTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -21,20 +21,53 @@ class IntegrationTokenForm extends Component
public array $capabilities = ['dns'];
public array $metadata = [];
public function mount(): void
{
$this->authorize('create', IntegrationToken::class);
}
public function updatedProvider(): void
{
if ($this->provider === 'cloudflare') {
$this->capabilities = ['dns'];
$this->metadata = [];
} else {
$this->capabilities = ['secrets'];
$this->metadata = $this->provider === 'infisical'
? ['base_url' => 'https://app.infisical.com']
: [];
}
}
protected function rules(): array
{
return [
'provider' => ['required', 'in:cloudflare'],
$allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets';
$rules = [
'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))],
'name' => ['required', 'string', 'max:255'],
'token' => ['required', 'string'],
'capabilities' => ['required', 'array', 'min:1'],
'capabilities.*' => ['required', 'in:dns'],
'capabilities.*' => ['required', 'in:'.$allowedCapability],
];
if ($this->provider === 'infisical') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.client_id'] = ['required', 'string'];
}
if ($this->provider === 'doppler') {
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
}
if ($this->provider === 'vault') {
$rules['metadata.base_url'] = ['required', 'url:http,https'];
$rules['metadata.namespace'] = ['nullable', 'string'];
}
return $rules;
}
protected function messages(): array
@@ -42,25 +75,38 @@ class IntegrationTokenForm extends Component
return [
'capabilities.required' => 'Select at least one capability.',
'capabilities.min' => 'Select at least one capability.',
'token.regex' => 'Use a Doppler service token (dp.st.*) or service account token (dp.sa.*).',
];
}
public function addToken(CloudflareTokenValidator $validator): void
public function addToken(IntegrationTokenValidator $validator): void
{
$validated = $this->validate();
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
try {
if (! $validator->validate($validated['token'], $validated['capabilities'])) {
$this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.');
if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) {
$this->dispatch('error', $validator->errorMessage($validated['provider']));
return;
}
IntegrationToken::query()->create([
...$validated,
$integrationToken = IntegrationToken::query()->create([
'provider' => $validated['provider'],
'name' => $validated['name'],
'token' => $validated['token'],
'capabilities' => $validated['capabilities'],
'metadata' => $metadata ?: null,
'team_id' => currentTeam()->id,
]);
auditLog('ui.integration_token.created', [
'team_id' => currentTeam()->id,
'integration_token_uuid' => $integrationToken->uuid,
'integration_token_name' => $integrationToken->name,
'provider' => $integrationToken->provider,
]);
$this->reset(['name', 'token']);
$this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class);
@@ -29,7 +29,23 @@ class IntegrationTokens extends Component
{
$token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId);
$this->authorize('delete', $token);
if ($token->secretManagerLinks()->exists()) {
$this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
return;
}
$tokenUuid = $token->uuid;
$tokenName = $token->name;
$provider = $token->provider;
$token->delete();
auditLog('ui.integration_token.deleted', [
'team_id' => currentTeam()->id,
'integration_token_uuid' => $tokenUuid,
'integration_token_name' => $tokenName,
'provider' => $provider,
]);
$this->loadTokens();
$this->dispatch('success', 'Integration token deleted successfully.');
}
+7
View File
@@ -134,6 +134,13 @@ class DockerCleanup extends Component
try {
$this->authorize('update', $this->server);
DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks);
auditLog('ui.server.docker_cleanup_started', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
'delete_unused_volumes' => $this->deleteUnusedVolumes,
'delete_unused_networks' => $this->deleteUnusedNetworks,
]);
$this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -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') {
+16
View File
@@ -101,6 +101,11 @@ class Navbar extends Component
// Always use background job for all servers
RestartProxyJob::dispatch($this->server);
auditLog('ui.proxy.restarted', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
]);
} catch (\Throwable $e) {
$this->restartInitiated = false;
@@ -125,6 +130,11 @@ class Navbar extends Component
try {
$this->authorize('manageProxy', $this->server);
$activity = StartProxy::run($this->server, force: true);
auditLog('ui.proxy.started', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
]);
$this->dispatch('activityMonitor', $activity->id);
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -136,6 +146,12 @@ class Navbar extends Component
try {
$this->authorize('manageProxy', $this->server);
StopProxy::dispatch($this->server, $forceStop);
auditLog('ui.proxy.stopped', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
'force' => $forceStop,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
+9
View File
@@ -123,6 +123,15 @@ class TransferImport extends Component
$this->lastWarnings = array_values((array) data_get($result, 'warnings', []));
$this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid');
if (! $dryRun) {
auditLog('ui.server.imported', [
'team_id' => $teamId,
'server_uuid' => $this->importedServerUuid,
'claimed' => (bool) data_get($result, 'claimed'),
'adopt_mode' => $this->adoptMode,
]);
}
if ($dryRun) {
$this->dispatch('success', 'Dry run completed — nothing was written.');
} elseif (data_get($result, 'claimed')) {
+1 -1
View File
@@ -212,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(
+18
View File
@@ -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;
@@ -214,6 +221,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;
@@ -264,6 +272,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.',
@@ -296,11 +305,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,
+8 -2
View File
@@ -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);
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Livewire\Team;
use App\Models\AuditEvent;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Str;
use Livewire\Component;
use Livewire\WithPagination;
class AuditLog extends Component
{
use WithPagination;
public string $search = '';
public string $action = 'all';
public string $source = 'all';
public int $perPage = 25;
public function boot(): void
{
abort_unless(auth()->user()->isAdminOfTeam(currentTeam()->id), 403);
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedAction(): void
{
$this->resetPage();
}
public function updatedSource(): void
{
$this->resetPage();
}
public function updatedPerPage(): void
{
$this->perPage = max(10, min(100, $this->perPage));
$this->resetPage();
}
public function render(): View
{
$search = trim($this->search);
$teamId = currentTeam()->id;
$canViewInstanceEvents = $teamId === 0 && isInstanceAdmin();
$visibleEvents = AuditEvent::query()->visibleToTeam($teamId, $canViewInstanceEvents);
$actionOptions = [
['value' => 'all', 'label' => 'All actions'],
...$visibleEvents->clone()
->select('action')
->distinct()
->orderBy('action')
->pluck('action')
->map(fn (string $action): array => ['value' => $action, 'label' => Str::headline($action)])
->all(),
];
$events = AuditEvent::query()
->visibleToTeam($teamId, $canViewInstanceEvents)
->filtered($search, $this->action, $this->source)
->latestFirst()
->paginate($this->perPage);
return view('livewire.team.audit-log', ['actionOptions' => $actionOptions, 'events' => $events]);
}
}
+1 -1
View File
@@ -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');
+8 -19
View File
@@ -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');
+15 -5
View File
@@ -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,21 @@ 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();
}
$invitationEmail = $invitation->email;
$invitationUuid = $invitation->uuid;
DB::transaction(function () use ($invitation): void {
$user = User::whereEmail($invitation->email)->first();
if (filled($user)) {
$user->deleteIfNotVerifiedAndForcePasswordReset();
}
$invitation->delete();
$invitation->delete();
});
auditLog('ui.team_invitation.revoked', [
'team_id' => currentTeam()->id,
'invitation_uuid' => $invitationUuid,
'invitation_email' => $invitationEmail,
]);
$this->refreshInvitations();
$this->dispatch('success', 'Invitation revoked.');
} catch (\Exception) {
+7
View File
@@ -103,6 +103,13 @@ class InviteLink extends Component
'link' => $link,
'via' => $sendEmail ? 'email' : 'link',
]);
auditLog('ui.team_invitation.created', [
'team_id' => currentTeam()->id,
'invitation_uuid' => $invitation->uuid,
'invitation_email' => $invitation->email,
'role' => $invitation->role,
'via' => $invitation->via,
]);
if ($sendEmail) {
$mail = new MailMessage;
$mail->view('emails.invitation-link', [
+37 -8
View File
@@ -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,11 @@ 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->auditRoleUpdate($teamId, Role::ADMIN);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -43,8 +47,11 @@ 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->auditRoleUpdate($teamId, Role::OWNER);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -61,8 +68,11 @@ 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->auditRoleUpdate($teamId, Role::MEMBER);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -79,8 +89,16 @@ 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);
});
auditLog('ui.team_member.removed', [
'team_id' => $teamId,
'member_id' => $this->member->id,
'member_name' => $this->member->name,
'member_email' => $this->member->email,
]);
// 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}");
@@ -94,4 +112,15 @@ class Member extends Component
{
return $this->member->teams()->where('teams.id', currentTeam()->id)->first()?->pivot?->role;
}
private function auditRoleUpdate(int $teamId, Role $role): void
{
auditLog('ui.team_member.role_updated', [
'team_id' => $teamId,
'member_id' => $this->member->id,
'member_name' => $this->member->name,
'member_email' => $this->member->email,
'role' => $role->value,
]);
}
}

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