Merge remote-tracking branch 'origin/next' into pr-10960-compose-settings-memory-crash

This commit is contained in:
Andras Bacsai 2026-08-18 13:58:16 +02:00
commit ccd7c59abf
575 changed files with 7671 additions and 9652 deletions

View file

@ -3,7 +3,6 @@ APP_ENV=local
APP_NAME=Coolify
APP_ID=development
APP_KEY=
COOLIFY_FLUX_LARAVEL_API_TOKEN=development-flux-token
APP_URL=http://localhost
APP_PORT=8000
APP_DEBUG=true

View file

@ -2,7 +2,6 @@ APP_ENV=production
APP_NAME="Coolify Staging"
APP_ID=development
APP_KEY=
COOLIFY_FLUX_LARAVEL_API_TOKEN=test-flux-token
APP_URL=http://localhost
APP_PORT=8000
SSH_MUX_ENABLED=true

View file

@ -1,6 +1,5 @@
APP_ENV=testing
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
COOLIFY_FLUX_LARAVEL_API_TOKEN=test-flux-token
APP_DEBUG=true
DB_CONNECTION=testing

View file

@ -8,6 +8,8 @@ body:
value: |
> [!IMPORTANT]
> **Please ensure you are using the latest version of Coolify before submitting an issue, as the bug may have already been fixed in a recent update.** (Of course, if you're experiencing an issue on the latest version that wasn't present in a previous version, please let us know.)
>
> If you plan to submit a fix, branch from `main` and target `main` with your pull request.
- type: textarea
attributes:

View file

@ -7,12 +7,12 @@ contact_links:
- name: 💡 Feature Request
url: https://github.com/coollabsio/coolify/discussions/categories/feature-requests
about: Suggest a new feature for Coolify.
about: Suggest a new feature for Coolify. Feature code should branch from `next` and target `next`.
- name: ⚙️ Service Request
url: https://github.com/coollabsio/coolify/discussions/categories/service-requests
about: Request a new service integration for Coolify.
about: Request a new service integration for Coolify. Service code should branch from `next` and target `next`.
- name: 🔧 Improvements
url: https://github.com/coollabsio/coolify/discussions/categories/improvements
about: Suggest improvements to existing features for Coolify.
about: Suggest improvements to existing features. Small fixes should target `main`; larger changes should target `next`.

View file

@ -46,6 +46,6 @@ ## Contributor Agreement
> [!IMPORTANT]
>
> - [ ] I have read and understood the [contributor guidelines](https://github.com/coollabsio/coolify/blob/v4.x/CONTRIBUTING.md). If I have failed to follow any guideline, I understand that this PR may be closed without review.
> - [ ] I have read and understood the [contributor guidelines](https://github.com/coollabsio/coolify/blob/HEAD/CONTRIBUTING.md). If I have failed to follow any guideline, I understand that this PR may be closed without review.
> - [ ] I have searched [existing issues](https://github.com/coollabsio/coolify/issues) and [pull requests](https://github.com/coollabsio/coolify/pulls) (including closed ones) to ensure this isn't a duplicate.
> - [ ] I have tested all the changes thoroughly with a local development instance of Coolify and I am confident that they will work as expected when a maintainer tests them.

View file

@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Lock threads after 30 days of inactivity
uses: dessant/lock-threads@v5
uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '30'

View file

@ -0,0 +1,182 @@
name: Manage PR Branch
# Runs *after* the "PR Quality" workflow finishes. This is required because
# PR Quality may close a PR that fails its checks, so we must wait for it to
# complete before deciding whether to retarget the PR's base branch.
on:
workflow_run:
workflows: ["PR Quality"]
types:
- completed
permissions:
contents: read
pull-requests: write
concurrency:
group: manage-pr-branch-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
jobs:
manage-branch:
runs-on: ubuntu-latest
steps:
- name: Retarget PR base branch based on category
uses: actions/github-script@v7
with:
script: |
const run = context.payload.workflow_run;
// Branch routing based on the "Category" section of the PR body.
// Bug fixes and one-click service changes ship in patch releases -> main.
// Everything else (features, improvements) -> next.
const MAIN_BRANCH = 'main';
const NEXT_BRANCH = 'next';
// Maintainers/collaborators are trusted to pick their own base branch.
const EXEMPT_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
// Resolve the open PR from the triggering run.
//
// PR Quality runs on `pull_request_target`, so `run.head_sha` is the
// *base* branch tip, not the PR head — a commit-based lookup finds
// nothing. Instead match on the source branch (`head_branch`) and its
// owner (`head_repository.owner.login`), which uniquely identify the PR
// via the `owner:branch` head filter. This also works for forked PRs,
// where `workflow_run.pull_requests` is empty.
const headOwner = run.head_repository?.owner?.login;
const headBranch = run.head_branch;
let prRef;
if (headOwner && headBranch) {
const { data: openPrs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${headOwner}:${headBranch}`,
per_page: 100,
});
prRef = openPrs[0];
}
// Fallback: same-repo PRs may also be resolvable by commit association.
if (!prRef) {
const { data: associated } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: run.head_sha,
});
prRef = associated.find(pr => pr.state === 'open');
}
if (!prRef) {
core.info('No open PR associated with this run (possibly closed by PR Quality). Skipping.');
return;
}
// Fetch the full PR to get an up-to-date body, base ref, and state.
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prRef.number,
});
if (pr.state !== 'open') {
core.info(`PR #${pr.number} is not open. Skipping.`);
return;
}
// Skip PRs opened by owners/members/collaborators — they choose their own base.
if (EXEMPT_ASSOCIATIONS.has(pr.author_association)) {
core.info(`PR #${pr.number} author association is ${pr.author_association}. Skipping.`);
return;
}
// Skip if a maintainer has already changed the base branch manually.
const timeline = await github.paginate(github.rest.issues.listEventsForTimeline, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
per_page: 100,
});
const baseChanges = timeline.filter(e => e.event === 'base_ref_changed');
for (const change of baseChanges) {
const actor = change.actor?.login;
if (!actor) {
continue;
}
try {
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: actor,
});
// admin/maintain/write => trusted maintainer.
if (['admin', 'maintain', 'write'].includes(perm.permission)) {
core.info(`Base branch was changed manually by ${actor} (${perm.permission}). Skipping.`);
return;
}
} catch (error) {
core.info(`Could not resolve permission for ${actor}: ${error.message}`);
}
}
// Parse the checked category checkboxes from the PR body.
const body = pr.body ?? '';
const checked = [];
const checkboxRegex = /^\s*-\s*\[([ xX])\]\s*(.+?)\s*$/gm;
let match;
while ((match = checkboxRegex.exec(body)) !== null) {
if (match[1].toLowerCase() === 'x') {
checked.push(match[2].toLowerCase());
}
}
const includesAny = (labels) => labels.some(label => checked.some(c => c.includes(label)));
const mainCategories = ['bug fix', 'adding new one click service', 'fixing or updating existing one click service'];
const nextCategories = ['improvement', 'new feature'];
const wantsMain = includesAny(mainCategories);
const wantsNext = includesAny(nextCategories);
if (!wantsMain && !wantsNext) {
core.info('No category selected in the PR body. Skipping.');
return;
}
// If categories from both groups are checked, prefer next: features and
// improvements can only be released from the development branch.
const targetBranch = wantsNext ? NEXT_BRANCH : MAIN_BRANCH;
if (pr.base.ref === targetBranch) {
core.info(`PR #${pr.number} already targets ${targetBranch}. Nothing to do.`);
return;
}
const previousBranch = pr.base.ref;
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
base: targetBranch,
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: [
`Based on the selected category, this PR's base branch was automatically changed from \`${previousBranch}\` to \`${targetBranch}\`.`,
'',
targetBranch === MAIN_BRANCH
? 'Bug fixes and one-click service changes target `main`.'
: 'New features and improvements target `next`.',
'',
'If you believe this is incorrect, please let a maintainer know.',
].join('\n'),
});
core.info(`Retargeted PR #${pr.number}: ${previousBranch} -> ${targetBranch}.`);

View file

@ -2,7 +2,7 @@ name: Coolify Helper Image
on:
push:
branches: [ "v4.x", "main" ]
branches: [ "main" ]
paths:
- .github/workflows/coolify-helper.yml
- docker/coolify-helper/Dockerfile
@ -16,8 +16,53 @@ env:
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-helper"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
check-version:
runs-on: ubuntu-24.04
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: Ensure version is not published
run: |
BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getHelperVersion.php)
VERSION="${BASE_VERSION}"
for registry in "${DOCKER_REGISTRY}" "${GITHUB_REGISTRY}"; do
IMAGE="${registry}/${IMAGE_NAME}:${VERSION}"
if output=$(docker buildx imagetools inspect "$IMAGE" 2>&1); then
echo "::error::Version $VERSION already exists in $registry"
exit 1
fi
if ! grep -Eqi 'manifest unknown|not found|no such manifest' <<< "$output"; then
echo "::error::Could not verify $IMAGE: $output"
exit 1
fi
done
echo "Version $VERSION is available in both registries"
build-push:
needs: check-version
strategy:
matrix:
include:

View file

@ -2,7 +2,7 @@ name: Coolify Realtime
on:
push:
branches: [ "v4.x", "main" ]
branches: [ "main" ]
paths:
- .github/workflows/coolify-realtime.yml
- docker/coolify-realtime/**
@ -16,8 +16,53 @@ env:
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify-realtime"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
check-version:
runs-on: ubuntu-24.04
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: Ensure version is not published
run: |
BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getRealtimeVersion.php)
VERSION="${BASE_VERSION}"
for registry in "${DOCKER_REGISTRY}" "${GITHUB_REGISTRY}"; do
IMAGE="${registry}/${IMAGE_NAME}:${VERSION}"
if output=$(docker buildx imagetools inspect "$IMAGE" 2>&1); then
echo "::error::Version $VERSION already exists in $registry"
exit 1
fi
if ! grep -Eqi 'manifest unknown|not found|no such manifest' <<< "$output"; then
echo "::error::Could not verify $IMAGE: $output"
exit 1
fi
done
echo "Version $VERSION is available in both registries"
build-push:
needs: check-version
strategy:
matrix:
include:

View file

@ -1,4 +1,5 @@
name: Release Coolify Stable
run-name: ${{ inputs.tag }}
on:
workflow_dispatch:
@ -22,17 +23,16 @@ env:
jobs:
validate:
runs-on: ubuntu-24.04
environment: production-release
permissions:
contents: write
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Reject releases outside v4.x
if: ${{ github.ref_name != 'v4.x' }}
- name: Reject releases outside the production branch
if: ${{ github.ref_name != 'main' }}
run: |
echo "Fix releases must run from v4.x, not ${{ github.ref_name }}."
echo "Stable releases must run from main, not ${{ github.ref_name }}."
exit 1
- uses: actions/checkout@v5

View file

@ -2,7 +2,7 @@ name: Build Coolify (SHA)
on:
push:
branches: ["v4.x"]
branches: ["main"]
permissions:
contents: read
@ -15,6 +15,8 @@ env:
jobs:
build-push:
outputs:
short_sha: ${{ steps.version.outputs.short_sha }}
strategy:
matrix:
include:
@ -35,6 +37,7 @@ jobs:
run: |
BASE_VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php)
echo "version=${BASE_VERSION}-dev.${GITHUB_SHA::9}" >> "$GITHUB_OUTPUT"
echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
@ -60,8 +63,8 @@ jobs:
build-args: |
COOLIFY_VERSION=${{ steps.version.outputs.version }}
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ steps.version.outputs.short_sha }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ steps.version.outputs.short_sha }}-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-24.04
@ -86,7 +89,7 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
SHA: ${{ github.sha }}
SHA: ${{ needs.build-push.outputs.short_sha }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker buildx imagetools create \
@ -97,7 +100,7 @@ jobs:
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
SHA: ${{ github.sha }}
SHA: ${{ needs.build-push.outputs.short_sha }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
docker buildx imagetools create \

View file

@ -3,7 +3,7 @@ name: Staging Build
on:
push:
branches-ignore:
- v4.x
- main
- v3.x
- '**v5.x**'
paths-ignore:

View file

@ -2,7 +2,7 @@ name: Generate Changelog
on:
push:
branches: [ v4.x ]
branches: [ main ]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml

View file

@ -19,13 +19,10 @@ jobs:
max-failures: 4
# PR Branch Checks
allowed-target-branches: "next"
allowed-target-branches: ""
blocked-target-branches: ""
allowed-source-branches: ""
blocked-source-branches: |
main
master
v4.x
blocked-source-branches: ""
# PR Quality Checks
max-negative-reactions: 0

View file

@ -16,8 +16,8 @@ ## Development Environment
```bash
# Start dev environment (uses docker-compose.dev.yml)
spin up # or: docker compose -f docker-compose.dev.yml up -d
spin down # stop services
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
docker compose -f docker-compose.yml -f docker-compose.dev.yml down # stop services
# Two local Coolify instances (isolated stacks; server transfer / multi-control-plane)
./scripts/dev-instances up # a:8000 + b:8001 (uses npm run build for CSS/JS)
@ -30,6 +30,25 @@ # Note: dual Vite HMR is unsupported (shared public/hot); multi-instance always
The app runs at `localhost:8000` by default. Instance **b** is on `8001` (db `5433`, redis `6380`, …); see `./scripts/dev-instances`.
## Testing the Self-Hosted Upgrade Process
Use the following workflow to test a self-hosted upgrade:
1. Install the source version with the upgrade script:
```bash
bash upgrade.sh sha-6492d081362c009519481ac70e50873e39ba1861
```
2. Set the current Coolify version and rebuild the cached configuration:
```bash
docker exec -e COOLIFY_VERSION=4.3.0 coolify php artisan config:cache
```
3. In the Coolify UI, click **Check for Updates**.
4. Confirm that an upgrade is available, then click **Upgrade** and verify that the upgrade completes successfully.
## Common Commands
```bash
@ -122,6 +141,23 @@ ### Key Domain Concepts
- **Project/Environment** — Organizational hierarchy: Team → Project → Environment → Resources.
- **Proxy** — Traefik reverse proxy managed per server.
### Instance sentinels (`id = 0`)
Coolify seeds **instance-owned** rows at primary key `0`. That value is a sentinel meaning “this is the Coolify instance itself”, not a normal autoincrement id. Do not migrate, resequence, or “fix” these to a positive id.
| Record | Model / lookup | Meaning |
|---|---|---|
| Root team | `Team::find(0)`, `team_id === 0` | Instance / root team. Cloud billing and many skip-checks exempt `team_id === 0`. |
| Localhost server | `Server::find(0)` / `findOrFail(0)` | The machine running Coolify. Upgrades, instance backups, and docker inspect target this server. |
| Instance settings | `InstanceSettings` with `id = 0` | Singleton settings row. Tests must seed `InstanceSettings::create(['id' => 0])` (or `forceCreate`). |
| Instance Postgres | `StandalonePostgresql` `id = 0`, name `coolify-db` | Coolifys own database. UI treats `database_id === 0` as the instance DB (e.g. hide delete on backup screens). |
| Local docker dest | `StandaloneDocker` `id = 0` | Destination on the localhost server (`destination_id = 0`). |
| Root user / default GitHub App | seeders | First-install defaults. |
**Do not assign `id = 0` to new or non-instance rows.** In particular, `ScheduledDatabaseBackup` and `ScheduledTask` are ordinary schedules. Legacy installs may still have a `coolify-db` backup at `id = 0`; resolve that backup via the `coolify-db` relation / uuid, not `ScheduledDatabaseBackup::find(0)`.
`0` is a PHP/Eloquent landmine (`empty(0)` is true; keyset pagination `where('id', '>', $cursor)` starting at `0` skips the row). Queries that page by id must include `id = 0` on the first page (no lower bound, or cursor `< 0`). Prefer `chunkById()` over a hand-rolled `id > 0` cursor.
### Frontend
- Livewire 3 components with Alpine.js for client-side interactivity
- Blade templates in `resources/views/livewire/`
@ -146,9 +182,9 @@ ## Key Conventions
## Git Workflow
- Main branch: `v4.x`
- Production branch: `main`
- Development branch: `next`
- PRs should target `v4.x`
- Fix PRs should target the current production branch; feature PRs should target `next`
<laravel-boost-guidelines>
=== foundation rules ===

View file

@ -32,9 +32,7 @@ ## State of the Project
- A more complex user experience
- Other smaller issues that need refinement
These limitations will be addressed in Coolify v5, which is in the planning stage. Because of this, major features, architectural changes, or significant UI changes will not be accepted for v4 at this stage.
We welcome contributions that help stabilize v4 for a bug free experience.
These limitations will be addressed over time. Fixes and small improvements are accepted on the production line. New features and larger changes require prior discussion and must go through the development line.
## What Makes a Strong Contribution
@ -188,8 +186,19 @@ ## Submitting a Pull Request
- GitHub will auto-populate the PR template
- The contributor agreement in PR description must remain intact
- Pull requests without the contributor agreement will be closed
- All pull requests must target the `next` branch
- PRs targeting other branches will be closed without review
Choose the branch based on the type of change:
| Change | Start from | Pull request target |
| --- | --- | --- |
| Fixes and small improvements | `main` | `main` |
| Security fixes | `main` | `main` |
| New features and larger changes | `next` | `next` |
- For a fix, branch from `main` and target `main`.
- For a feature, branch from `next` and target `next`.
- If a fix is discovered while developing a feature, submit it separately to `main`. Maintainers will merge `main` into `next` so the fix is included there too.
- Pull requests targeting the wrong branch may be closed or asked to retarget.
## FAQ

View file

@ -154,6 +154,21 @@ ### Layer-2 navigation
resource navigation may repeat this context because the desktop global topbar
is hidden there.
Desktop resource lifecycle actions dock in `#resource-action-hud-slot` and
use `<x-resource-heading-overflow>`. Show primary actions (Deploy, Redeploy,
Restart, Stop) as sibling header buttons. Collapse that group into an Actions
dropdown only when the remaining top-bar width cannot fit them (breadcrumb
keeps a 200px floor). Infrequent operations live in a separate Advanced
dropdown with the grid icon: force restart / force deploy / force cleanup
on services, and Traefik dashboard / refresh proxy status on servers. Place
Advanced immediately after Links, or first in the action cluster when there
is no Links control. Application Deploy is a dropdown with Deploy and
Deploy (without cache). A running service Restart control is a dropdown with
Restart current version and Pull latest and restart. Mobile
headings keep a full-width Actions dropdown because the desktop HUD is hidden
below `xl`. Do not hide primary actions behind a menu on a wide desktop. Links
stay a separate dropdown because the URL list is unbounded.
Only add layer-2 tabs when they represent real sibling routes inside one
context. Never repeat main-sidebar destinations such as Dashboard, Projects,
Terminal, Servers, Sources, Destinations, or Storage as a second tab row. A
@ -162,6 +177,14 @@ ### Layer-2 navigation
uses the same compact `pl-2` alignment as application navigation rather than
the content container's wide horizontal padding.
A layer-2 tab must be active on the page that renders it. A bar whose only tab
points at a different route reads as broken navigation, so project and
environment pages (`project.show`, `project.edit`, `project.environment.edit`,
`project.clone-me`) carry a plain page header with a 24px title and a 13px
muted summary instead of a bar. The environment identity and the way back to
its resources already live in `x-top-breadcrumb`; do not restate them in a
sub-header.
The dashboard is a compact overview, not a metrics wall. Use two full-width
sections that follow the projects-page grid pattern: projects first, then
servers. Keep one `New` action in the page header and let its modal choose the
@ -619,6 +642,16 @@ ### Terminals
shell, theme picker, compact header controls, and outline `browser-terminal`
Reicon. Hide a container switcher when only one container exists.
The themed console shell belongs to an open session. Before a target is
selected, the global Terminal page stays a normal top-level destination: a
full-width layer card titled `Start a terminal session`, its filter input in
the card header actions, and grouped `Servers` / `Containers` rows reusing the
command-palette row classes. Do not render an empty full-height console canvas
just to host the target picker, and do not offer the console theme selector
before a session owns that canvas. Rows show the target name, a muted server
column that only appears when the team has more than one server, and the shared
chevron. Group headers stick to the top of the scrolling list and carry a count.
### Logs
Runtime and deployment logs should feel like a clean terminal surface:

View file

@ -34,8 +34,6 @@ ## 1. Setup Development Environment
- Download and install [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/?ref=coolify)
- Ensure WSL2 backend is enabled in Docker Desktop settings
2. Install Spin:
- Follow the instructions to install Spin on Windows from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-windows#download-and-install-spin-into-wsl2?ref=coolify)
</details>
@ -48,8 +46,6 @@ ## 1. Setup Development Environment
- Docker Desktop:
- Download and install [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/?ref=coolify)
2. Install Spin:
- Follow the instructions to install Spin on MacOS from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-macos/#download-and-install-spin?ref=coolify)
</details>
@ -62,22 +58,20 @@ ## 1. Setup Development Environment
- Docker Desktop:
- If you want a GUI, you can use [Docker Desktop for Linux](https://docs.docker.com/desktop/install/linux-install/?ref=coolify)
2. Install Spin:
- Follow the instructions to install Spin on Linux from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-linux#configure-docker-permissions?ref=coolify)
</details>
## 2. Verify Installation (Optional)
After installing Docker (or Orbstack) and Spin, verify the installation:
After installing Docker (or Orbstack), verify the installation:
1. Open a terminal or command prompt
2. Run the following commands:
```bash
docker --version
spin --version
docker compose version
```
You should see version information for both Docker and Spin.
You should see version information for Docker and Docker Compose.
## 3. Fork and Setup Local Repository
@ -105,7 +99,7 @@ ## 4. Set up Environment Variables
1. In the Code Editor, locate the `.env.development.example` file in the root directory of your local Coolify repository.
2. Duplicate the `.env.development.example` file and rename the copy to `.env`.
3. Open the new `.env` file and review its contents. Adjust any environment variables as needed for your development setup.
4. If you encounter errors during database migrations, update the database connection settings in your `.env` file. Use the IP address or hostname of your PostgreSQL database container. You can find this information by running `docker ps` after executing `spin up`.
4. If you encounter errors during database migrations, update the database connection settings in your `.env` file. Use the IP address or hostname of your PostgreSQL database container. You can find this information by running `docker ps` after executing `docker compose -f docker-compose.yml -f docker-compose.dev.yml up`.
5. Save the changes to your `.env` file.
@ -113,7 +107,7 @@ ## 5. Start Coolify
1. Open a terminal in the local Coolify directory.
2. Run the following command in the terminal (leave that terminal open):
```bash
spin up
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
```
> [!NOTE]
@ -121,11 +115,11 @@ ## 5. Start Coolify
3. If you encounter permission errors, especially on macOS, use:
```bash
sudo spin up
sudo docker compose -f docker-compose.yml -f docker-compose.dev.yml up
```
> [!NOTE]
> If you change environment variables afterwards or anything seems broken, press Ctrl + C to stop the process and run `spin up` again.
> If you change environment variables afterwards or anything seems broken, press Ctrl + C to stop the process and run `docker compose -f docker-compose.yml -f docker-compose.dev.yml up` again.
## 6. Start Development
@ -196,7 +190,7 @@ ## Resetting Development Environment
5. Start Coolify again:
```bash
spin up
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
```
6. Run database migrations and seeders:

View file

@ -11,6 +11,13 @@ ## Branches
Release workflows never edit or commit versions. Set the intended version in `config/constants.php` before running a release workflow.
## Where changes go
- Fixes, security updates, and small improvements target `main`.
- New features and larger changes target `next`.
- Merge `main` into `next` regularly so every production fix is included in the next release.
- Do not merge `next` into `main` until an RC is approved for a stable release.
## Feature and RC flow
```text
@ -45,13 +52,14 @@ ## Hotfix flow
main → hotfix/X.Y.Z → main → next
```
1. Create `hotfix/X.Y.Z` from `main` and set the intended patch version.
2. Implement and test the fix. SHA images report `X.Y.Z-dev.<short-sha>`.
3. Merge the hotfix into `main`.
4. Create a reviewed draft GitHub Release named `vX.Y.Z`.
5. Run the stable release workflow from `main`.
6. Merge `main` into `next`, resolve the version in favor of the next intended RC, and delete the hotfix branch.
7. Update the CDN only after the release is approved.
1. Create `hotfix/X.Y.Z` from `main` when a patch needs an integration branch. A single fix may use a normal branch from `main` instead.
2. Set the intended patch version.
3. Implement and test the fix. SHA images report `X.Y.Z-dev.<short-sha>`.
4. Merge the fix into `main`.
5. Create a reviewed draft GitHub Release named `vX.Y.Z`.
6. Run the stable release workflow from `main`.
7. Merge `main` into `next`, resolve the version in favor of the next intended RC, and delete the hotfix branch if one was used.
8. Update the CDN only after the release is approved.
## Image tags

View file

@ -28,7 +28,7 @@ public function handle(Application $application, bool $previewDeployments = fals
if ($server->isSwarm()) {
instant_remote_process(["docker stack rm {$application->uuid}"], $server);
return;
continue;
}
$containers = $previewDeployments
@ -40,7 +40,7 @@ public function handle(Application $application, bool $previewDeployments = fals
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
"docker stop --time=$timeout $containerName",
dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName",
], server: $server, throwError: false);
}
@ -57,17 +57,15 @@ public function handle(Application $application, bool $previewDeployments = fals
}
}
$status = ['status' => 'exited'];
if ($resetRestartCount) {
$application->update([
$status = array_merge($status, [
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
]);
} else {
$application->update([
'status' => 'exited',
]);
}
$application->update($status);
ServiceStatusChanged::dispatch($application->environment->project->team->id);
}

View file

@ -28,7 +28,7 @@ public function handle(Application $application, Server $server)
if ($containerName) {
instant_remote_process(
[
"docker stop --time=$timeout $containerName",
dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName",
],
$server

View file

@ -104,7 +104,7 @@ public function handle(StandaloneClickhouse $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -191,7 +191,7 @@ public function handle(StandaloneDragonfly $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -209,7 +209,7 @@ public function handle(StandaloneKeydb $database)
if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
$this->commands[] = "chown 999:999 $this->configuration_dir/keydb.conf";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -208,13 +208,13 @@ public function handle(StandaloneMariadb $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
if ($this->database->enable_ssl) {
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt";
}
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, 'chown mysql:mysql /etc/mysql/certs/server.crt /etc/mysql/certs/server.key');
}
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
}

View file

@ -257,12 +257,12 @@ public function handle(StandaloneMongodb $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, 'chown mongodb:mongodb /etc/mongo/certs/server.pem');
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem";
}
$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');

View file

@ -209,14 +209,12 @@ public function handle(StandaloneMysql $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
$mysqlUser = escapeshellarg($this->database->mysql_user);
$this->commands[] = executeInDocker($this->database->uuid, "chown {$mysqlUser}:{$mysqlUser} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt";
}
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -219,13 +219,12 @@ public function handle(StandalonePostgresql $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
$postgresUser = escapeshellarg($this->database->postgres_user);
$this->commands[] = executeInDocker($this->database->uuid, "chown {$postgresUser}:{$postgresUser} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt";
}
$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');

View file

@ -204,7 +204,7 @@ public function handle(StandaloneRedis $database)
if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
$this->commands[] = "chown 999:999 $this->configuration_dir/redis.conf";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -30,6 +30,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
// Reset restart tracking when database is manually stopped
$database->update([
'status' => 'exited',
'restart_count' => 0,
'last_restart_at' => null,
'last_restart_type' => null,
@ -56,7 +57,7 @@ private function stopContainer($database, string $containerName, int $timeout =
{
$server = $database->destination->server;
instant_remote_process(command: [
"docker stop -t $timeout $containerName",
dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName",
], server: $server, throwError: false);
}

View file

@ -24,7 +24,7 @@ public function handle(Server $server, bool $forceStop = true, int $timeout = 30
}
instant_remote_process(command: [
"docker stop -t=$timeout $containerName 2>/dev/null || true",
dockerStopCommand($timeout, $containerName, $server).' 2>/dev/null || true',
"docker rm -f $containerName 2>/dev/null || true",
'# Wait for container to be fully removed',
'for i in {1..10}; do',

View file

@ -49,6 +49,9 @@ public function handle(Service $service, bool $deleteConnectedNetworks = false,
$this->stopContainersInParallel($containersToStop, $server);
}
$applications->each->update(['status' => 'exited']);
$dbs->each->update(['status' => 'exited']);
if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks();
}
@ -67,7 +70,7 @@ private function stopContainersInParallel(array $containersToStop, Server $serve
$timeout = count($containersToStop) > 5 ? 10 : 30;
$commands = [];
$containerList = implode(' ', $containersToStop);
$commands[] = "docker stop -t $timeout $containerList";
$commands[] = dockerStopCommand($timeout, $containerList, $server);
$commands[] = "docker rm -f $containerList";
instant_remote_process(
command: $commands,

View file

@ -2,6 +2,7 @@
namespace App\Actions\Service;
use App\Events\ServiceStatusChanged;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Lorisleiva\Actions\Concerns\AsAction;
@ -21,5 +22,8 @@ public function handle(ServiceApplication|ServiceDatabase $serviceApplication):
instant_remote_process([
"docker stop {$containerName}",
], $server);
$serviceApplication->update(['status' => 'exited']);
ServiceStatusChanged::dispatch($service->environment->project->team->id);
}
}

View file

@ -70,7 +70,7 @@ public function getResourcesPreview(): array
return [
'applications' => $applications->unique('id'),
'databases' => $databases->unique('id'),
'databases' => $databases->unique(fn ($database) => $database::class.':'.$database->id),
'services' => $services->unique('id'),
];
}

View file

@ -15,10 +15,7 @@
use App\Jobs\ScheduledJobManager;
use App\Jobs\ServerManagerJob;
use App\Jobs\UpdateCoolifyJob;
use App\Jobs\V5ReconcileServersJob;
use App\Jobs\V5RotateAgentTokensJob;
use App\Models\InstanceSettings;
use App\Support\V5\V5Feature;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@ -52,11 +49,6 @@ protected function schedule(Schedule $schedule): void
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
if (V5Feature::enabled()) {
$this->scheduleInstance->job(new V5ReconcileServersJob)->everyFiveMinutes()->withoutOverlapping()->onOneServer();
$this->scheduleInstance->job(new V5RotateAgentTokensJob)->everyFifteenMinutes()->withoutOverlapping()->onOneServer();
}
if (isDev()) {
// Instance Jobs
$this->scheduleInstance->command('horizon:snapshot')->everyMinute();

View file

@ -2884,6 +2884,10 @@ public function update_by_uuid(Request $request)
], 422);
}
$requestHasHttpBasicAuth = $request->has('is_http_basic_auth_enabled')
|| $request->has('http_basic_auth_username')
|| $request->has('http_basic_auth_password');
if ($request->has('is_http_basic_auth_enabled') && $request->is_http_basic_auth_enabled === true) {
if (blank($application->http_basic_auth_username) || blank($application->http_basic_auth_password)) {
$validationErrors = [];
@ -2901,10 +2905,6 @@ public function update_by_uuid(Request $request)
}
}
}
if ($request->has('is_http_basic_auth_enabled') && $application->is_container_label_readonly_enabled === false) {
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
$application->save();
}
// For dockercompose applications, domains (fqdn) field should not be used
// Only docker_compose_domains should be used to set domains for individual services
@ -3119,7 +3119,7 @@ public function update_by_uuid(Request $request)
// Must run after fqdn is filled: flags are kept only for domains the app still has.
$application->setNoindexDomains($request->input('noindex_domains') ?? []);
}
if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains) && $server->isProxyShouldRun()) {
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();

View file

@ -316,6 +316,6 @@ public function feedback(Request $request)
)]
public function healthcheck(Request $request)
{
return 'OK';
return response('OK');
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Models\Project;
use App\Services\ProjectIconStorageService;
use Illuminate\Http\Response;
class ProjectIconController extends Controller
{
public function __invoke(string $project_uuid, ProjectIconStorageService $iconStorage): Response
{
$project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
$contents = $iconStorage->projectContents($project);
abort_if($contents === null, 404);
return response($contents)->header('Content-Type', 'image/jpeg');
}
}

View file

@ -20,8 +20,6 @@
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\V5\EnsureCurrentTeam as V5EnsureCurrentTeam;
use App\Http\Middleware\V5\HandleInertiaRequests as V5HandleInertiaRequests;
use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
@ -82,23 +80,6 @@ class Kernel extends HttpKernel
],
'v5.web' => [
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
V5HandleInertiaRequests::class,
],
'v5.authenticated' => [
'auth',
'verified',
'throttle:v5',
V5EnsureCurrentTeam::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
ThrottleRequests::class.':api',

View file

@ -431,6 +431,7 @@ private function detectBuildKitCapabilities(): void
["docker version --format '{{.Server.Version}}'"],
$serverToCheck
);
$serverToCheck->rememberDockerVersion($dockerVersion);
$versionParts = explode('.', $dockerVersion);
$majorVersion = (int) $versionParts[0];
@ -3972,11 +3973,11 @@ private function graceful_shutdown_container(string $containerName, bool $skipRe
if ($skipRemove) {
$this->execute_remote_command(
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
[dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true]
);
} else {
$this->execute_remote_command(
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
[dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
);
}

View file

@ -21,6 +21,10 @@ public function __construct(public Server $server) {}
public function handle(): void
{
if (! $this->sentinelIsEnabled()) {
return;
}
$latestVersion = get_latest_sentinel_version();
// Check if sentinel is running
@ -28,7 +32,7 @@ public function handle(): void
$sentinelFoundJson = json_decode($sentinelFound, true);
$sentinelStatus = data_get($sentinelFoundJson, '0.State.Status', 'exited');
if ($sentinelStatus !== 'running') {
StartSentinel::run(server: $this->server, restart: true, latestVersion: $latestVersion);
$this->startSentinel($latestVersion);
return;
}
@ -38,15 +42,31 @@ public function handle(): void
$runningVersion = '0.0.0';
}
if ($latestVersion === '0.0.0' && $runningVersion === '0.0.0') {
StartSentinel::run(server: $this->server, restart: true, latestVersion: 'latest');
$this->startSentinel('latest');
return;
} else {
if (version_compare($runningVersion, $latestVersion, '<')) {
StartSentinel::run(server: $this->server, restart: true, latestVersion: $latestVersion);
$this->startSentinel($latestVersion);
return;
}
}
}
private function sentinelIsEnabled(): bool
{
$this->server->unsetRelation('settings');
return $this->server->isSentinelEnabled();
}
private function startSentinel(string $latestVersion): void
{
if (! $this->sentinelIsEnabled()) {
return;
}
StartSentinel::run(server: $this->server, restart: true, latestVersion: $latestVersion);
}
}

View file

@ -18,6 +18,7 @@
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use App\Rules\SafeWebhookUrl;
use App\Support\BackupCompression;
use App\Support\ClickhouseBackupCommand;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
@ -609,7 +610,8 @@ private function backup_standalone_postgresql(string $database): void
}
$escapedUsername = escapeshellarg($this->database->postgres_user);
if ($this->backup->dump_all) {
$backupCommand .= " $this->container_name pg_dumpall --username $escapedUsername | gzip > $this->backup_location";
$backupCommand .= " $this->container_name pg_dumpall --username $escapedUsername";
$backupCommand = $this->buildCompressedDumpCommand($backupCommand).' > '.escapeshellarg($this->backup_location);
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
@ -635,7 +637,8 @@ private function backup_standalone_mysql(string $database): void
$commands[] = 'mkdir -p '.$this->backup_dir;
$escapedPassword = escapeshellarg($this->database->mysql_root_password);
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mysqldump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
$dumpCommand = "docker exec $this->container_name mysqldump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false";
$commands[] = $this->buildCompressedDumpCommand($dumpCommand).' > '.escapeshellarg($this->backup_location);
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
@ -659,7 +662,8 @@ private function backup_standalone_mariadb(string $database): void
$commands[] = 'mkdir -p '.$this->backup_dir;
$escapedPassword = escapeshellarg($this->database->mariadb_root_password);
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
$dumpCommand = "docker exec $this->container_name mariadb-dump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false";
$commands[] = $this->buildCompressedDumpCommand($dumpCommand).' > '.escapeshellarg($this->backup_location);
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
@ -785,7 +789,7 @@ private function upload_to_s3(): void
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}";
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->s3_uploaded = true;
} catch (Throwable $e) {
@ -806,6 +810,15 @@ private function getFullImageName(): string
return "{$helperImage}:{$latestVersion}";
}
private function buildCompressedDumpCommand(string $dumpCommand): string
{
$cpuPercentage = BackupCompression::cpuPercentage($this->server->settings->backup_compression_cpu_percentage);
$compressorCommand = BackupCompression::compressorCommand($cpuPercentage);
$script = "compressor=\$({$compressorCommand}); exec \$compressor";
return $dumpCommand.' | docker run --rm -i '.escapeshellarg($this->getFullImageName()).' sh -c '.escapeshellarg($script);
}
private function markStaleExecutionsAsFailed(): void
{
try {

View file

@ -216,7 +216,7 @@ private function stopPreviewContainers(array $containers, $server, int $timeout
$containerList = implode(' ', array_map('escapeshellarg', $containerNames));
$commands = [
"docker stop -t $timeout $containerList",
dockerStopCommand($timeout, $containerList, $server),
"docker rm -f $containerList",
];
instant_remote_process(

View file

@ -188,7 +188,7 @@ public function handle()
Cache::forget($storageCacheKey);
}
if ($this->containers->isEmpty()) {
if ($this->containers->isEmpty() && ! $this->isCompleteSnapshot()) {
return;
}
@ -625,12 +625,6 @@ private function updateNotFoundApplicationStatus()
return;
}
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
// Batch update: mark all not-found applications as exited (excluding already exited ones)
Application::whereIn('id', $notFoundApplicationIds)
->where('status', 'not like', 'exited%')
@ -644,12 +638,6 @@ private function updateNotFoundApplicationPreviewStatus()
return;
}
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
// Collect IDs of previews that need to be marked as exited
$previewIdsToUpdate = collect();
foreach ($notFoundApplicationPreviewsIds as $previewKey) {
@ -738,12 +726,6 @@ private function updateNotFoundDatabaseStatus()
return;
}
// Only protection: Verify we received any container data at all
// If containers collection is completely empty, Sentinel might have failed
if ($this->containers->isEmpty()) {
return;
}
$notFoundDatabaseUuids->each(function ($databaseUuid) {
$database = $this->databasesByUuid->get($databaseUuid);
if ($database) {

View file

@ -98,7 +98,7 @@ private function buildRestartCommands(): array
// === STOP PHASE ===
$commands = $commands->merge([
"echo 'Stopping proxy...'",
"docker stop -t=$stopTimeout $containerName 2>/dev/null || true",
dockerStopCommand($stopTimeout, $containerName, $this->server).' 2>/dev/null || true',
"docker rm -f $containerName 2>/dev/null || true",
'# Wait for container to be fully removed',
'for i in {1..15}; do',

View file

@ -149,8 +149,8 @@ public function handle(): void
private function processScheduledBackupsAndTasks(): void
{
$lastBackupId = 0;
$lastTaskId = 0;
$lastBackupId = null;
$lastTaskId = null;
do {
$backups = $this->scheduledBackupQuery($lastBackupId)->get();
@ -190,16 +190,16 @@ private function processInterleavedDueSchedules(array $dueBackups, array $dueTas
}
}
private function scheduledBackupQuery(int $lastBackupId): Builder
private function scheduledBackupQuery(?int $lastBackupId): Builder
{
return ScheduledDatabaseBackup::with(['database', 'team.subscription'])
->where('enabled', true)
->where('id', '>', $lastBackupId)
->when($lastBackupId !== null, fn (Builder $query) => $query->where('id', '>', $lastBackupId))
->orderBy('id')
->limit(self::CHUNK_SIZE);
}
private function scheduledTaskQuery(int $lastTaskId): Builder
private function scheduledTaskQuery(?int $lastTaskId): Builder
{
return ScheduledTask::with([
'service.destination.server.settings',
@ -208,7 +208,7 @@ private function scheduledTaskQuery(int $lastTaskId): Builder
'application.destination.server.team.subscription',
])
->where('enabled', true)
->where('id', '>', $lastTaskId)
->when($lastTaskId !== null, fn (Builder $query) => $query->where('id', '>', $lastTaskId))
->orderBy('id')
->limit(self::CHUNK_SIZE);
}

View file

@ -194,6 +194,20 @@ private function checkDockerAvailability(): bool
$output = trim($output);
if (! empty($output)) {
$dockerInfo = json_decode($output, true);
$dockerVersion = dockerEngineVersionFromJson($output);
if ($dockerVersion !== null) {
$this->server->rememberDockerVersion($dockerVersion);
}
$composeOutput = instant_remote_process_with_timeout(
['docker compose version --short'],
$this->server,
false
);
$composeVersion = parseDockerEngineVersion($composeOutput);
if ($composeVersion !== null) {
$this->server->rememberComposeVersion($composeVersion);
}
return isset($dockerInfo['Server']['Version']);
}

View file

@ -8,6 +8,7 @@
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
use App\Rules\SafeWebhookUrl;
use App\Support\BackupCompression;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@ -77,14 +78,19 @@ public function handle(): void
$source = $this->backup->sourcePath();
$containerName = 'volume-backup-'.$this->execution->uuid;
$image = coolifyHelperImage().':'.getHelperVersion();
$compressionCpuPercentage = BackupCompression::cpuPercentage($server->settings->backup_compression_cpu_percentage);
$this->logCompressorInDevelopment($image, $server, $compressionCpuPercentage);
$verifySourceCommand = $target instanceof LocalPersistentVolume && blank($target->host_path)
? 'docker volume inspect '.escapeshellarg($source).' >/dev/null'
: 'test -d '.escapeshellarg($source);
$compressorCommand = BackupCompression::compressorCommand($compressionCpuPercentage);
$archiveScript = "compressor=\$({$compressorCommand}); tar -I \"\$compressor\" -cf - -C /volume .";
$archiveCommand = 'docker run --rm --name '.escapeshellarg($containerName)
.' -v '.escapeshellarg($source.':/volume:ro')
.' '.escapeshellarg($image)
.' tar -czf - -C /volume . > '.escapeshellarg($backupLocation);
.' sh -c '.escapeshellarg($archiveScript)
.' > '.escapeshellarg($backupLocation);
if ($this->backup->stop_during_backup) {
$containers = $this->containersUsingVolume($source, $server);
@ -332,6 +338,29 @@ private function uploadToS3(string $backupLocation, string $backupDirectory, Ser
}
}
private function logCompressorInDevelopment(string $image, Server $server, int $compressionCpuPercentage): void
{
if (! isDev()) {
return;
}
$script = BackupCompression::compressorCommand($compressionCpuPercentage);
$compressor = instant_remote_process(
['docker run --rm '.escapeshellarg($image).' sh -c '.escapeshellarg($script)],
$server,
timeout: 60,
disableMultiplexing: true,
);
Log::info('Volume backup compressor selected', [
'backup_id' => $this->backup->id,
'execution_id' => $this->execution?->id,
'compressor' => $compressor,
'helper_image' => $image,
'cpu_percentage' => $compressionCpuPercentage,
]);
}
private function removeExpiredBackups(Server $server): void
{
if ($this->hasRetentionLimits(

View file

@ -302,7 +302,7 @@ public function submitResend()
$this->resetErrorBag();
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => 'required|string',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [

View file

@ -22,6 +22,14 @@ class Index extends Component
public int $defaultTake = 10;
public function updatedDefaultTake(): void
{
$this->defaultTake = max(1, min(100, $this->defaultTake));
$this->skip = 0;
$this->loadDeployments();
}
public bool $showNext = false;
public bool $showPrev = false;
@ -65,10 +73,11 @@ public function mount()
if (! $project) {
return redirect()->route('dashboard');
}
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first()->load(['applications']);
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first();
if (! $environment) {
return redirect()->route('dashboard');
abort(404);
}
$environment->load(['applications']);
$application = $environment->applications->where('uuid', request()->route('application_uuid'))->first();
if (! $application) {
return redirect()->route('dashboard');

View file

@ -39,10 +39,11 @@ public function mount()
if (! $project) {
return redirect()->route('dashboard');
}
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first()->load(['applications']);
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first();
if (! $environment) {
return redirect()->route('dashboard');
abort(404);
}
$environment->load(['applications']);
$application = $environment->applications->where('uuid', request()->route('application_uuid'))->first();
if (! $application) {
return redirect()->route('dashboard');

View file

@ -293,9 +293,6 @@ protected function buildDomainRows(): array
$configured[] = $row;
}
foreach ($this->buildSuggestedWwwRows($configured, $stored, $serviceName) as $suggested) {
$rows[] = $suggested;
}
}
return $this->sortDomainRowsByDnsStatus($rows);
@ -305,7 +302,7 @@ protected function buildDomainRows(): array
$rows[] = $this->domainRowFromStored($url, null, $stored);
}
return $this->sortDomainRowsByDnsStatus(array_merge($rows, $this->buildSuggestedWwwRows($rows, $stored)));
return $this->sortDomainRowsByDnsStatus($rows);
}
/**

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application;
use App\Actions\Docker\GetContainersStatus;
use App\Events\ServiceStatusChanged;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
@ -354,7 +355,7 @@ private function stopContainers(array $containers, $server)
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
"docker stop --time=$timeout $containerName",
dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName",
], server: $server, throwError: false);
}
@ -373,6 +374,11 @@ public function stop(int $pull_request_id)
$this->stopContainers($containers, $server);
}
ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id)
->update(['status' => 'exited']);
ServiceStatusChanged::dispatch($this->application->environment->project->team->id);
GetContainersStatus::run($server);
$this->application->refresh();
$this->dispatch('containerStatusUpdated');

View file

@ -26,10 +26,11 @@ public function mount()
if (! $project) {
return redirect()->route('dashboard');
}
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first()->load(['applications']);
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first();
if (! $environment) {
return redirect()->route('dashboard');
abort(404);
}
$environment->load(['applications']);
$database = $environment->databases()->where('uuid', request()->route('database_uuid'))->first();
if (! $database) {
return redirect()->route('dashboard');

View file

@ -14,10 +14,11 @@ public function mount()
if (! $project) {
return redirect()->route('dashboard');
}
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first()->load(['applications']);
$environment = $project->load(['environments'])->environments->where('uuid', request()->route('environment_uuid'))->first();
if (! $environment) {
return redirect()->route('dashboard');
abort(404);
}
$environment->load(['applications']);
$database = $environment->databases()->where('uuid', request()->route('database_uuid'))->first();
if (! $database) {
return redirect()->route('dashboard');

View file

@ -66,7 +66,7 @@ public function submit(): void
$this->authorize('update', $this->database);
$this->syncData(true);
$updateSuccessful = true;
$this->dispatch('success', 'Health check updated. Restart the database to apply the changes.');
$this->dispatch('success', 'Healthcheck updated. Restart the database to apply the changes.');
} catch (\Throwable $e) {
handleError($e, $this);
}
@ -87,7 +87,7 @@ public function toggleHealthcheck(): void
$this->healthCheckEnabled = ! $this->healthCheckEnabled;
$this->syncData(true);
$updateSuccessful = true;
$this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'. Restart the database to apply the changes.');
$this->dispatch('success', 'Healthcheck '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'. Restart the database to apply the changes.');
} catch (\Throwable $e) {
handleError($e, $this);
}

View file

@ -812,14 +812,13 @@ public function buildPostgresRestoreScanScript(string $tmpPath): ?string
// /* ... */ block comment (used to split keywords like FROM/**/PROGRAM).
$sep = '([[:space:]]|/\\*[^*]*\\*/)';
$pattern = implode('|', [
"copy{$sep}+[^;]*(from|to){$sep}+program",
'(^|[[:space:]])\\\\!',
"(^|[[:space:]])\\\\(o|g){$sep}*\\|",
]);
$escapedPattern = escapeshellarg($pattern);
$sqlPattern = "(^|;){$sep}*copy{$sep}+[^;]*(from|to){$sep}+program";
$psqlPattern = "^{$sep}*\\\\(!|copy{$sep}+[^[:space:]]+.*{$sep}+program|(o|g){$sep}*\\|)";
$escapedSqlPattern = escapeshellarg($sqlPattern);
$escapedPsqlPattern = escapeshellarg($psqlPattern);
$contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }";
return "if (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
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";
}
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void

View file

@ -3,13 +3,16 @@
namespace App\Livewire\Project;
use App\Models\Project;
use App\Services\ProjectIconStorageService;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Livewire\WithFileUploads;
class Edit extends Component
{
use AuthorizesRequests;
use WithFileUploads;
public Project $project;
@ -17,6 +20,40 @@ class Edit extends Component
public ?string $description = null;
public $icon;
public function uploadIcon(ProjectIconStorageService $iconStorage): bool
{
try {
$this->authorize('update', $this->project);
$this->validate([
'icon' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120', 'dimensions:max_width=6000,max_height=6000'],
]);
$iconStorage->storeProject($this->project, $this->icon);
$this->reset('icon');
$this->project->refresh();
$this->dispatch('success', 'Project icon updated.');
return true;
} catch (\Throwable $e) {
handleError($e, $this);
return false;
}
}
public function removeIcon(ProjectIconStorageService $iconStorage): void
{
try {
$this->authorize('update', $this->project);
$iconStorage->deleteProject($this->project);
$this->project->refresh();
$this->dispatch('success', 'Project icon removed.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
protected function rules(): array
{
return [

View file

@ -53,6 +53,10 @@ public function render(): View
'uuid' => $project->uuid,
'name' => $project->name,
'description' => $project->description,
'iconUrl' => $project->icon_path ? route('project.icon', [
'project_uuid' => $project->uuid,
'v' => $project->updated_at->timestamp,
]) : null,
'href' => $project->navigateTo(),
'environmentCount' => $project->environments->count(),
'resourceCount' => $resourceCount,

View file

@ -4,8 +4,6 @@
use App\Models\Environment;
use App\Models\Project;
use App\Models\V5\Application as V5Application;
use App\Support\V5\V5Feature;
use Illuminate\Support\Collection;
use Livewire\Component;
@ -72,10 +70,6 @@ public function mount(): void
'clickhouses:id,uuid,name,environment_id',
];
if (V5Feature::enabled()) {
$environmentRelations[] = 'v5Applications:id,uuid,name,environment_id,status';
}
$this->allEnvironments = $project->environments()
->select('id', 'uuid', 'name', 'project_id')
->with($environmentRelations)
@ -111,24 +105,6 @@ public function mount(): void
return $application;
});
if (V5Feature::enabled()) {
$this->applications = $this->applications->merge(V5Application::query()
->where('team_id', currentTeam()->id)
->where('project_id', $this->project->id)
->where('environment_id', $this->environment->id)
->with('server:id,name')
->get()
->map(function (V5Application $application) use ($projectUuid, $environmentUuid) {
$application->hrefLink = route('v5.dashboard', [
'project' => $projectUuid,
'environment' => $environmentUuid,
'application' => $application->uuid,
]);
return $application;
}));
}
$this->applications = $this->applications->sortBy('name');
// Load all database resources in a single query per type
@ -207,21 +183,18 @@ private function toSearchableArray(Collection $items, string $type, string $type
'uuid' => $item->uuid,
'name' => $item->name,
'type' => $type,
'typeLabel' => $item instanceof V5Application ? 'Application (V5)' : $typeLabel,
'typeLabel' => $typeLabel,
'fqdn' => $item->fqdn ?? null,
'description' => $item instanceof V5Application ? 'Managed by Coolify V5' : ($item->description ?? null),
'description' => $item->description ?? null,
'status' => $item->status ?? '',
'version' => $item instanceof V5Application ? 'v5' : 'v4',
'server_status' => $item->server_status ?? null,
'hrefLink' => $item->hrefLink ?? '',
'destination' => [
'server' => [
'name' => $item instanceof V5Application
? ($item->server?->name ?? 'Unknown')
: ($item->destination?->server?->name ?? 'Unknown'),
'name' => $item->destination?->server?->name ?? 'Unknown',
],
],
'tags' => ($item instanceof V5Application ? collect() : $item->tags)->map(fn ($tag) => [
'tags' => $item->tags->map(fn ($tag) => [
'id' => $tag->id,
'name' => $tag->name,
])->values()->toArray(),

View file

@ -222,9 +222,6 @@ protected function buildDomainRows(): array
$configured[] = $row;
}
foreach ($this->buildSuggestedWwwRows($configured, $app, $stored) as $suggested) {
$rows[] = $suggested;
}
}
return collect($rows)

View file

@ -101,6 +101,7 @@ public function mount()
$rules = data_get($field, 'rules', 'nullable');
$isPassword = data_get($field, 'isPassword', false);
$customHelper = data_get($field, 'customHelper', false);
$sortOrder = data_get($field, 'sortOrder');
$this->fields->put($key, [
'serviceName' => $serviceName,
'key' => $key,
@ -109,6 +110,7 @@ public function mount()
'isPassword' => $isPassword,
'rules' => $rules,
'customHelper' => $customHelper,
'sortOrder' => $sortOrder,
]);
$this->validationAttributes["fields.$key.value"] = $fieldKey;
@ -116,7 +118,7 @@ public function mount()
}
$this->fields = $this->fields->groupBy('serviceName')->map(function ($group) {
return $group->sortBy(function ($field) {
return data_get($field, 'isPassword') ? 1 : 0;
return data_get($field, 'sortOrder') ?? (data_get($field, 'isPassword') ? 1 : 0);
})->mapWithKeys(function ($field) {
return [$field['key'] => $field];
});

View file

@ -44,6 +44,14 @@ class All extends Component
public int $perPage = 10;
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
public bool $is_env_sorting_enabled = false;
public bool $use_build_secrets = false;
@ -714,6 +722,12 @@ protected function getHardcodedVariables(bool $isPreview)
// Extract all hard-coded variables
$hardcodedVars = extractHardcodedEnvironmentVariables($dockerComposeRaw);
// Compose self-references are inputs supplied through Coolify's .env file,
// not hard-coded values. Keep them editable in the environment variables UI.
$hardcodedVars = $hardcodedVars->reject(
fn (array $variable): bool => $this->isSelfReferencingComposeVariable($variable)
);
// Filter out magic variables (SERVICE_FQDN_*, SERVICE_URL_*, SERVICE_NAME_*)
$hardcodedVars = $hardcodedVars->filter(function ($var) {
$key = $var['key'];
@ -755,7 +769,14 @@ private function hardcodedEnvironmentVariableKeys(): array
return [];
}
return extractHardcodedEnvironmentVariables($dockerComposeRaw)
$assignments = extractHardcodedEnvironmentVariables($dockerComposeRaw);
$editableKeys = $assignments
->filter(fn (array $variable): bool => $this->isSelfReferencingComposeVariable($variable))
->pluck('key')
->unique();
return $assignments
->reject(fn (array $variable): bool => $editableKeys->contains($variable['key']))
->pluck('key')
->reject(fn (string $key): bool => str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_']))
->unique()
@ -763,6 +784,28 @@ private function hardcodedEnvironmentVariableKeys(): array
->all();
}
private function isSelfReferencingComposeVariable(array $variable): bool
{
$value = $variable['value'] ?? null;
if (! is_string($value)) {
return false;
}
if ($value === '$'.$variable['key']) {
return true;
}
$reference = extractBalancedBraceContent($value);
if ($reference === null || $reference['start'] !== 1 || $reference['end'] !== strlen($value) - 1) {
return false;
}
$splitReference = splitOnOperatorOutsideNested($reference['content']);
$referencedKey = $splitReference['variable'] ?? $reference['content'];
return $referencedKey === $variable['key'];
}
public function getDevView()
{
$this->variables = $this->formatEnvironmentVariables($this->getEnvironmentVariables(false, false));

View file

@ -151,7 +151,7 @@ public function instantSave()
$this->resource->health_check_start_period = $this->healthCheckStartPeriod;
$this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
$this->dispatch('success', 'Healthcheck updated.');
$this->dispatch('configurationChanged');
}
@ -178,7 +178,7 @@ public function submit()
$this->resource->health_check_start_period = $this->healthCheckStartPeriod;
$this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save();
$this->dispatch('success', 'Health check updated.');
$this->dispatch('success', 'Healthcheck updated.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
@ -211,9 +211,9 @@ public function toggleHealthcheck()
$this->resource->save();
if ($this->healthCheckEnabled && ! $wasEnabled && $this->resource->isRunning()) {
$this->dispatch('info', 'Health check has been enabled. A restart is required to apply the new settings.');
$this->dispatch('info', 'Healthcheck has been enabled. A restart is required to apply the new settings.');
} else {
$this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.');
$this->dispatch('success', 'Healthcheck '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.');
}
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {

View file

@ -21,7 +21,7 @@ class All extends Component
/**
* Editable form state keyed by storage id.
*
* @var array<int|string, array{name: string, mountPath: string, hostPath: ?string, isPreviewSuffixEnabled: bool, isReadOnly: bool}>
* @var array<int|string, array{name: string, mountPath: string, hostPath: ?string, isPreviewSuffixEnabled: bool, isReadOnly: bool, canDeleteStale: bool}>
*/
public array $forms = [];
@ -42,13 +42,16 @@ class All extends Component
public bool $canUpdate = false;
public bool $deleteDockerVolume = false;
protected $listeners = ['refreshStorages' => 'refreshList', 'refreshVolumeBackups' => 'refreshList'];
public function mount(): void
{
$this->canUpdate = (bool) auth()->user()?->can('update', $this->resource);
$this->supportsPreviewSuffix = $this->resource instanceof Application
&& $this->resource->git_based();
&& $this->resource->git_based()
&& filled($this->resource->git_repository);
$this->showActionsColumn = $this->canUpdate;
$this->showBackupAction = $this->resource instanceof Application
|| $this->resource instanceof ServiceApplication
@ -129,12 +132,35 @@ public function delete(int $storageId, $password = '', $selectedActions = [])
$storage = $this->findStorageOrFail($storageId);
if ($this->isComposeOrService && $storage->isDeclaredInCompose()) {
$this->dispatch('error', 'This volume is managed by the current Docker Compose file.');
return false;
}
if ($storage->scheduledBackups()->exists()) {
$this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.');
return false;
}
$this->deleteDockerVolume = in_array('deleteDockerVolume', $selectedActions, true);
if ($this->deleteDockerVolume) {
$server = $this->resource instanceof Application
? $this->resource->destination->server
: $this->resource->service->server;
try {
instant_remote_process([
'docker volume rm -f '.escapeshellarg($storage->name),
], $server);
} catch (\Throwable $exception) {
$this->dispatch('error', 'Failed to delete the Docker volume: '.$exception->getMessage());
return false;
}
}
$storage->delete();
$this->refreshList();
$this->dispatch('refreshStorages');
@ -169,6 +195,9 @@ private function rebuildForms(): void
'hostPath' => $storage->host_path,
'isPreviewSuffixEnabled' => (bool) ($storage->is_preview_suffix_enabled ?? true),
'isReadOnly' => $storage->shouldBeReadOnlyInUI() || ! $this->canUpdate,
'canDeleteStale' => $this->canUpdate
&& ($storage->isServiceResource() || $storage->isDockerComposeResource())
&& ! $storage->isDeclaredInCompose(),
];
}
$this->forms = $forms;

View file

@ -105,6 +105,7 @@ public function mount(): void
// PR deployment volume suffixes only apply to git-based applications.
$this->supportsPreviewSuffix = $this->resource instanceof Application
&& $this->resource->git_based()
&& filled($this->resource->git_repository)
&& ! $this->isService;
// Parent All batches badge/url; isolated embeds still hydrate themselves.
if (! $this->backupMetaHydrated) {

View file

@ -58,6 +58,15 @@ class VolumeBackups extends Component
public int $timeout = 3600;
public int $perPage = 10;
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->resetPage();
}
public bool $delete_backup_s3 = false;
public Collection $availableS3Storages;
@ -316,7 +325,7 @@ public function deleteBackup(int $executionId, string $password, array $selected
public function render()
{
$executions = $this->backup?->executions()->paginate(10);
$executions = $this->backup?->executions()->paginate($this->perPage);
return view('livewire.project.shared.storages.volume-backups', [
'executions' => $executions ?? collect(),

View file

@ -151,6 +151,9 @@ public function changePrivateKey()
refresh_server_connection($this->private_key);
$this->dispatch('success', 'Private key updated.');
$this->dispatch('securityResourceChanged');
if ($this->modalMode) {
$this->dispatch('close-modal');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -27,6 +27,9 @@ class Advanced extends Component
#[Validate(['required', 'integer', 'min:1'])]
public int|string $deploymentQueueLimit = 25;
#[Validate(['required', 'integer', 'in:25,50,75,100'])]
public int|string $backupCompressionCpuPercentage = 25;
public function mount(string $server_uuid)
{
try {
@ -47,6 +50,7 @@ public function syncData(bool $toModel = false)
$this->server->settings->concurrent_builds = $this->concurrentBuilds;
$this->server->settings->dynamic_timeout = $this->dynamicTimeout;
$this->server->settings->deployment_queue_limit = $this->deploymentQueueLimit;
$this->server->settings->backup_compression_cpu_percentage = $this->backupCompressionCpuPercentage;
$this->server->settings->server_disk_usage_notification_threshold = $this->serverDiskUsageNotificationThreshold;
$this->server->settings->server_disk_usage_check_frequency = $this->serverDiskUsageCheckFrequency;
$this->server->settings->save();
@ -54,6 +58,7 @@ public function syncData(bool $toModel = false)
$this->concurrentBuilds = $this->server->settings->concurrent_builds;
$this->dynamicTimeout = $this->server->settings->dynamic_timeout;
$this->deploymentQueueLimit = $this->server->settings->deployment_queue_limit;
$this->backupCompressionCpuPercentage = $this->server->settings->backup_compression_cpu_percentage;
$this->serverDiskUsageNotificationThreshold = $this->server->settings->server_disk_usage_notification_threshold;
$this->serverDiskUsageCheckFrequency = $this->server->settings->server_disk_usage_check_frequency;
}

View file

@ -371,6 +371,8 @@ public function checkLocalhostConnection()
$this->server->settings->is_usable = $this->isUsable = true;
$this->server->settings->save();
ServerReachabilityChanged::dispatch($this->server);
$this->server->gatherServerMetadata();
$this->server->refresh();
} else {
$this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error);
@ -671,12 +673,18 @@ public function refreshServerMetadata(): void
{
try {
$this->authorize('update', $this->server);
if (! $this->server->isFunctional()) {
$this->dispatch('error', 'Validate the server connection before fetching details.');
return;
}
$result = $this->server->gatherServerMetadata();
if ($result) {
$this->server->refresh();
$this->server->refresh()->load('settings');
$this->dispatch('success', 'Server details refreshed.');
} else {
$this->dispatch('error', 'Could not fetch server details. Is the server reachable?');
$this->dispatch('error', 'Could not collect server details. Check the application logs for the remote command output.');
}
} catch (\Throwable $e) {
handleError($e, $this);

View file

@ -203,7 +203,7 @@ public function submitResend()
$this->authorize('update', $this->settings);
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => 'required|string',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [

View file

@ -26,7 +26,7 @@ class Create extends Component
public string $bucket;
public string $endpoint;
public string $endpoint = '';
public S3Storage $storage;
@ -71,34 +71,12 @@ protected function messages(): array
'endpoint' => 'Endpoint',
];
public function updatedEndpoint($value)
{
try {
if (empty($value)) {
return;
}
if (str($value)->contains('digitaloceanspaces.com')) {
$uri = Uri::of($value);
$host = $uri->host();
if (preg_match('/^(.+)\.([^.]+\.digitaloceanspaces\.com)$/', $host, $matches)) {
$host = $matches[2];
$value = "https://{$host}";
}
}
} finally {
if (! str($value)->startsWith('https://') && ! str($value)->startsWith('http://')) {
$value = 'https://'.$value;
}
$this->endpoint = $value;
}
}
public function submit()
{
try {
$this->authorize('create', S3Storage::class);
$this->endpoint = $this->normalizeEndpoint($this->endpoint);
$this->validate();
$this->storage = new S3Storage;
$this->storage->name = $this->name;
@ -118,8 +96,42 @@ public function submit()
return redirectRoute($this, 'storage.show', [$this->storage->uuid]);
} catch (\Throwable $e) {
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
$this->dispatch('error', 'Failed to create storage.', $this->connectionErrorDescription($e));
// return handleError($e, $this);
}
}
private function connectionErrorDescription(\Throwable $exception): string
{
$settingsUrl = route('settings.advanced').'#endpoint-section';
$description = e($exception->getMessage());
if (! str_contains($exception->getMessage(), $settingsUrl)) {
return $description;
}
$link = '<a class="font-medium underline" href="'.e($settingsUrl).'">Set them here.</a>';
return str_replace(e($settingsUrl), $link, $description);
}
private function normalizeEndpoint(string $endpoint): string
{
$endpoint = trim($endpoint);
$hasScheme = preg_match('/^(?:https?:|[a-z][a-z0-9+.-]*:\/\/)/i', $endpoint) === 1;
if (! $hasScheme) {
$endpoint = 'https://'.$endpoint;
}
if (str($endpoint)->contains('digitaloceanspaces.com')) {
$host = Uri::of($endpoint)->host();
if (preg_match('/^(.+)\.([^.]+\.digitaloceanspaces\.com)$/', $host, $matches)) {
return "https://{$matches[2]}";
}
}
return $endpoint;
}
}

View file

@ -122,20 +122,39 @@ public function mount()
public function testConnection()
{
$testedStorage = null;
try {
$this->authorize('validateConnection', $this->storage);
$testedStorage = new S3Storage;
$testedStorage->uuid = $this->storage->uuid;
$testedStorage->team_id = $this->storage->team_id;
$testedStorage->unusable_email_sent = $this->storage->unusable_email_sent;
$testedStorage->name = $this->name;
$testedStorage->description = $this->description;
$testedStorage->endpoint = $this->endpoint;
$testedStorage->bucket = $this->bucket;
$testedStorage->region = $this->region;
$testedStorage->key = $this->key;
$testedStorage->secret = $this->secret;
$this->storage->testConnection(shouldSave: true);
$testedStorage->testConnection();
// Update component property to reflect the new validation status
$this->isUsable = $this->storage->is_usable;
$this->isUsable = $testedStorage->is_usable;
$this->storage->is_usable = $testedStorage->is_usable;
$this->storage->unusable_email_sent = $testedStorage->unusable_email_sent;
$this->storage->save();
$this->dispatch('storage-status-changed', isUsable: $this->isUsable);
return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.');
} catch (\Throwable $e) {
// Refresh model and sync to get the latest state
$this->storage->refresh();
$this->isUsable = $this->storage->is_usable;
if ($testedStorage) {
$this->isUsable = $testedStorage->is_usable;
$this->storage->is_usable = $testedStorage->is_usable;
$this->storage->unusable_email_sent = $testedStorage->unusable_email_sent;
$this->storage->save();
}
$this->dispatch('storage-status-changed', isUsable: $this->isUsable);
$this->dispatch('error', 'Failed to test connection.', $e->getMessage());

View file

@ -16,6 +16,8 @@ class AdminView extends Component
public string $sort = 'name_asc';
public int $perPage = 10;
public function mount()
{
if (! isInstanceAdmin()) {
@ -42,6 +44,13 @@ public function updatedSort(): void
$this->resetPage();
}
public function updatedPerPage(): void
{
$this->perPage = max(1, min(100, $this->perPage));
$this->resetPage();
}
public function submitSearch(): void
{
if (! isInstanceAdmin()) {
@ -103,7 +112,7 @@ public function render()
->when($this->sort === 'email_desc', fn ($query) => $query->orderByDesc('email'))
->when($this->sort === 'name_asc', fn ($query) => $query->orderBy('name'))
->orderBy('id')
->paginate(10);
->paginate($this->perPage);
return view('livewire.team.admin-view', [
'users' => $users,

View file

@ -5,6 +5,7 @@
use App\Actions\Server\UpdateCoolify;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Services\CoolifyUpgradeStatus;
use Livewire\Component;
class Upgrade extends Component
@ -69,7 +70,13 @@ public function upgrade()
return;
}
$this->updateInProgress = true;
UpdateCoolify::run(manual_update: true);
dispatch(function () {
try {
UpdateCoolify::run(manual_update: true);
} catch (\Throwable $e) {
report($e);
}
})->afterResponse();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -100,45 +107,10 @@ public function getUpgradeStatus(): array
return ['status' => 'none'];
}
if (empty($content)) {
return ['status' => 'none'];
}
$parts = explode('|', $content);
if (count($parts) < 3) {
return ['status' => 'none'];
}
[$step, $message, $timestamp] = $parts;
// Check if status is stale (older than 10 minutes)
try {
$statusTime = new \DateTime($timestamp);
$now = new \DateTime;
$diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60;
if ($diffMinutes > 10) {
return ['status' => 'none'];
}
} catch (\Throwable $e) {
return ['status' => 'none'];
}
if ($step === 'error') {
return [
'status' => 'error',
'step' => 0,
'message' => $message,
];
}
$stepInt = (int) $step;
$status = $stepInt >= 6 ? 'complete' : 'in_progress';
return [
'status' => $status,
'step' => $stepInt,
'message' => $message,
];
return CoolifyUpgradeStatus::fromFile(
content: $content,
runningVersion: $this->currentVersion !== '' ? $this->currentVersion : (string) config('constants.coolify.version'),
targetVersion: $this->latestVersion !== '' ? $this->latestVersion : get_latest_version_of_coolify(),
);
}
}

View file

@ -2,9 +2,6 @@
namespace App\Models;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection as V5ResourceConnection;
use App\Support\V5\V5Feature;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -57,11 +54,7 @@ public static function ownedByCurrentTeamAPI(int $teamId)
public function isEmpty()
{
return (! V5Feature::enabled() || (
! V5Application::query()->where('environment_id', $this->id)->exists() &&
! V5ResourceConnection::query()->where('environment_id', $this->id)->exists()
)) &&
$this->applications()->count() == 0 &&
return $this->applications()->count() == 0 &&
$this->redis()->count() == 0 &&
$this->postgresqls()->count() == 0 &&
$this->mysqls()->count() == 0 &&
@ -83,11 +76,6 @@ public function applications()
return $this->hasMany(Application::class);
}
public function v5Applications()
{
return $this->hasMany(V5Application::class);
}
public function postgresqls()
{
return $this->hasMany(StandalonePostgresql::class);

View file

@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends BaseModel
@ -136,6 +137,49 @@ public function shouldBeReadOnlyInUI(): bool
return $this->isReadOnlyVolume();
}
public function isDeclaredInCompose(): bool
{
try {
$resource = $this->resource;
if (! $resource) {
return true;
}
$composeContent = $resource instanceof Application
? $resource->docker_compose_raw
: data_get($resource, 'service.docker_compose_raw');
if (blank($composeContent)) {
return true;
}
$compose = Yaml::parse($composeContent);
$services = data_get($compose, 'services', []);
if ($this->isServiceResource()) {
$services = array_intersect_key($services, [$resource->name => true]);
}
foreach ($services as $service) {
foreach (data_get($service, 'volumes', []) as $volume) {
$parsedVolume = is_array($volume) ? $volume : parseDockerVolumeString($volume);
$source = data_get($parsedVolume, 'source');
$target = data_get($parsedVolume, 'target');
$resourceUuid = $resource instanceof Application ? $resource->uuid : data_get($resource, 'service.uuid');
$generatedName = $source ? $resourceUuid.'_'.Str::slug($source, '-') : null;
if ($generatedName === $this->name && $target && str($target)->start('/')->value() === $this->mount_path) {
return true;
}
}
}
return false;
} catch (\Throwable) {
return true;
}
}
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{

View file

@ -2,9 +2,6 @@
namespace App\Models;
use App\Models\V5\Application as V5Application;
use App\Models\V5\ResourceConnection as V5ResourceConnection;
use App\Support\V5\V5Feature;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -147,11 +144,7 @@ public function mariadbs()
public function isEmpty()
{
return (! V5Feature::enabled() || (
! V5Application::query()->where('project_id', $this->id)->exists() &&
! V5ResourceConnection::query()->where('project_id', $this->id)->exists()
)) &&
$this->applications()->count() == 0 &&
return $this->applications()->count() == 0 &&
$this->redis()->count() == 0 &&
$this->postgresqls()->count() == 0 &&
$this->mysqls()->count() == 0 &&
@ -166,13 +159,13 @@ public function isEmpty()
public function databases(array $with = []): Collection
{
return $this->postgresqls()->with($with)->get()
->merge($this->redis()->with($with)->get())
->merge($this->mongodbs()->with($with)->get())
->merge($this->mysqls()->with($with)->get())
->merge($this->mariadbs()->with($with)->get())
->merge($this->keydbs()->with($with)->get())
->merge($this->dragonflies()->with($with)->get())
->merge($this->clickhouses()->with($with)->get());
->concat($this->redis()->with($with)->get())
->concat($this->mongodbs()->with($with)->get())
->concat($this->mysqls()->with($with)->get())
->concat($this->mariadbs()->with($with)->get())
->concat($this->keydbs()->with($with)->get())
->concat($this->dragonflies()->with($with)->get())
->concat($this->clickhouses()->with($with)->get());
}
public function navigateTo()

View file

@ -24,6 +24,7 @@ protected function casts(): array
{
return [
'size' => 'integer',
'finished_at' => 'datetime',
's3_uploaded' => 'boolean',
'local_storage_deleted' => 'boolean',
's3_storage_deleted' => 'boolean',

View file

@ -961,7 +961,7 @@ public function definedResources()
public function stopUnmanaged($id)
{
return instant_remote_process(['docker stop -t 0 '.escapeshellarg($id)], $this);
return instant_remote_process([dockerStopCommand(0, escapeshellarg($id), $this)], $this);
}
public function restartUnmanaged($id)
@ -1351,7 +1351,7 @@ public function gatherServerMetadata(): ?array
try {
$output = instant_remote_process([
'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s',
'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s && echo "---DOCKER---" && (docker version --format \'{{.Server.Version}}\' 2>/dev/null || true) && echo "---COMPOSE---" && (docker compose version --short 2>/dev/null || true)',
], $this, false);
if (! $output) {
@ -1381,6 +1381,23 @@ public function gatherServerMetadata(): ?array
$this->update(['server_metadata' => $metadata]);
try {
$detectedDockerVersion = parseDockerEngineVersion($sections['DOCKER'] ?? null);
if ($detectedDockerVersion !== null) {
$this->rememberDockerVersion($detectedDockerVersion);
}
$detectedComposeVersion = parseDockerEngineVersion($sections['COMPOSE'] ?? null);
if ($detectedComposeVersion !== null) {
$this->rememberComposeVersion($detectedComposeVersion);
}
} catch (\Throwable $e) {
Log::debug('Failed to store server runtime versions', [
'server_id' => $this->id,
'error' => $e->getMessage(),
]);
}
return $metadata;
} catch (\Throwable $e) {
Log::debug('Failed to gather server metadata', [
@ -1604,11 +1621,40 @@ public function validateDockerSwarm()
return true;
}
public function dockerVersion(): ?string
{
return $this->settings?->docker_version;
}
public function rememberDockerVersion(?string $version): void
{
$this->settings->update([
'docker_version' => parseDockerEngineVersion($version),
'docker_version_checked_at' => now(),
]);
}
public function composeVersion(): ?string
{
return $this->settings?->compose_version;
}
public function rememberComposeVersion(?string $version): void
{
$this->settings->update([
'compose_version' => parseDockerEngineVersion($version),
'compose_version_checked_at' => now(),
]);
}
public function validateDockerEngineVersion()
{
$dockerVersionRaw = instant_remote_process(['docker version --format json'], $this, false);
$dockerVersionJson = json_decode($dockerVersionRaw, true);
$dockerVersion = data_get($dockerVersionJson, 'Server.Version', '0.0.0');
$this->rememberDockerVersion(is_string($dockerVersion) ? $dockerVersion : null);
$composeVersionRaw = instant_remote_process(['docker compose version --short'], $this, false);
$this->rememberComposeVersion(is_string($composeVersionRaw) ? $composeVersionRaw : null);
$dockerVersion = checkMinimumDockerEngineVersion($dockerVersion);
if (is_null($dockerVersion)) {
$this->settings->is_usable = false;

View file

@ -15,6 +15,7 @@
'id' => ['type' => 'integer'],
'concurrent_builds' => ['type' => 'integer'],
'deployment_queue_limit' => ['type' => 'integer'],
'backup_compression_cpu_percentage' => ['type' => 'integer'],
'dynamic_timeout' => ['type' => 'integer'],
'force_disabled' => ['type' => 'boolean'],
'force_server_cleanup' => ['type' => 'boolean'],
@ -51,6 +52,10 @@
'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'],
'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'],
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'],
'docker_version' => ['type' => 'string', 'nullable' => true, 'description' => 'Detected Docker Engine version on the server.'],
'docker_version_checked_at' => ['type' => 'string', 'nullable' => true, 'description' => 'When Docker Engine version was last detected.'],
'compose_version' => ['type' => 'string', 'nullable' => true, 'description' => 'Detected Docker Compose plugin version on the server.'],
'compose_version_checked_at' => ['type' => 'string', 'nullable' => true, 'description' => 'When Docker Compose version was last detected.'],
]
)]
class ServerSetting extends Model
@ -98,8 +103,13 @@ class ServerSetting extends Model
'server_disk_usage_check_frequency',
'is_terminal_enabled',
'deployment_queue_limit',
'backup_compression_cpu_percentage',
'disable_application_image_retention',
'connection_timeout',
'docker_version',
'docker_version_checked_at',
'compose_version',
'compose_version_checked_at',
];
protected $casts = [
@ -113,6 +123,9 @@ class ServerSetting extends Model
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
'connection_timeout' => 'integer',
'docker_version_checked_at' => 'datetime',
'compose_version_checked_at' => 'datetime',
'backup_compression_cpu_percentage' => 'integer',
];
/**

View file

@ -1180,6 +1180,27 @@ public function extraFields()
}
$fields->put('Openclaw', $data->toArray());
break;
case $image->contains('coollabsio/jean-server'):
$data = collect([]);
$settings = [
'Token' => ['key' => 'SERVICE_PASSWORD_64_JEAN', 'rules' => 'required', 'isPassword' => true, 'sortOrder' => 1, 'customHelper' => 'Token required to access Jean Server. Variable name: SERVICE_PASSWORD_64_JEAN'],
'Allowed Origins' => ['key' => 'JEAN_ALLOWED_ORIGINS', 'rules' => 'nullable|string', 'sortOrder' => 2, 'customHelper' => 'Comma-separated additional browser origins. Same-origin access is always allowed. Variable name: JEAN_ALLOWED_ORIGINS'],
];
foreach ($settings as $label => $setting) {
$variable = $this->environment_variables()->where('key', $setting['key'])->first();
if (! $variable) {
continue;
}
$data->put($label, [
...$setting,
'value' => data_get($variable, 'value'),
]);
}
$fields->put('', $data->toArray());
break;
default:
$data = collect([]);
$admin_user = $this->environment_variables()->where('key', 'SERVICE_USER_ADMIN')->first();

View file

@ -4,12 +4,10 @@
use App\Actions\User\RevokeUserTeamTokens;
use App\Events\ServerReachabilityChanged;
use App\Jobs\V5TeardownTeamJob;
use App\Notifications\Channels\SendsDiscord;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\Channels\SendsPushover;
use App\Notifications\Channels\SendsSlack;
use App\Support\V5\V5Feature;
use App\Traits\HasNotificationSettings;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -81,20 +79,6 @@ protected static function booted()
});
static::deleting(function (Team $team) {
// Best-effort on-host teardown of this team's v5 resources BEFORE the
// DB cascade removes the servers/applications/private keys. Captured
// synchronously into a queued job so an unreachable host cannot block
// or fail the team deletion (see V5TeardownTeamJob). Guarded so a v5
// teardown problem never breaks v4 team deletion. This is disabled
// with the rest of v5 outside development environments.
if (V5Feature::enabled()) {
try {
V5TeardownTeamJob::dispatchForTeam($team);
} catch (\Throwable $exception) {
report($exception);
}
}
RevokeUserTeamTokens::forTeam($team->id);
foreach ($team->privateKeys as $key) {
@ -107,7 +91,7 @@ protected static function booted()
// Delete non-instance-wide sources owned by this team
$teamSources = GithubApp::where('team_id', $team->id)->get()
->merge(GitlabApp::where('team_id', $team->id)->get());
->concat(GitlabApp::where('team_id', $team->id)->get());
foreach ($teamSources as $source) {
$source->delete();
}

View file

@ -3,10 +3,7 @@
namespace App\Providers;
use App\Models\PersonalAccessToken;
use App\Models\V5\Application;
use App\Support\V5\V5Feature;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
@ -26,11 +23,6 @@ public function boot(): void
{
$this->configureCommands();
if (V5Feature::enabled()) {
$this->loadMigrationsFrom(database_path('migrations-v5'));
$this->configureMorphMap();
}
$this->configureModels();
$this->configurePasswords();
$this->configureSanctumModel();
@ -45,18 +37,6 @@ private function configureCommands(): void
}
}
/**
* Map v5 models to stable morph aliases so polymorphic rows survive class
* renames. Deliberately NOT enforced: v4 polymorphic relations store FQCNs
* and must keep resolving them.
*/
private function configureMorphMap(): void
{
Relation::morphMap([
'v5.application' => Application::class,
]);
}
private function configureModels(): void
{
// Disabled because it's causing issues with the application

View file

@ -38,10 +38,6 @@
use App\Models\Tag;
use App\Models\Team;
use App\Models\TelegramNotificationSettings;
use App\Models\V5\Application as V5Application;
use App\Models\V5\Cluster as V5Cluster;
use App\Models\V5\ResourceConnection as V5ResourceConnection;
use App\Models\V5\Server as V5Server;
use App\Models\WebhookNotificationSettings;
use App\Policies\ApiTokenPolicy;
use App\Policies\ApplicationPolicy;
@ -69,10 +65,6 @@
use App\Policies\SwarmDockerPolicy;
use App\Policies\TagPolicy;
use App\Policies\TeamPolicy;
use App\Policies\V5\ApplicationPolicy as V5ApplicationPolicy;
use App\Policies\V5\ClusterPolicy as V5ClusterPolicy;
use App\Policies\V5\ResourceConnectionPolicy as V5ResourceConnectionPolicy;
use App\Policies\V5\ServerPolicy as V5ServerPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Laravel\Sanctum\PersonalAccessToken;
@ -138,12 +130,6 @@ class AuthServiceProvider extends ServiceProvider
CloudInitScript::class => CloudInitScriptPolicy::class,
Tag::class => TagPolicy::class,
// V5 policies - scoped to the current team resolved from the request
V5Application::class => V5ApplicationPolicy::class,
V5Cluster::class => V5ClusterPolicy::class,
V5ResourceConnection::class => V5ResourceConnectionPolicy::class,
V5Server::class => V5ServerPolicy::class,
];
/**

View file

@ -75,12 +75,16 @@ public function boot(): void
protected function gate(): void
{
Gate::define('viewHorizon', function ($user) {
$root_user = User::find(0);
Gate::define('viewHorizon', function (User $user) {
if ($user->id === 0) {
return true;
}
return in_array($user->email, [
$root_user->email,
]);
return str(config()->string('horizon.allowed_emails'))
->lower()
->explode(',')
->map(fn (string $email) => trim($email))
->contains($user->email);
});
}
}

View file

@ -2,7 +2,6 @@
namespace App\Providers;
use App\Support\V5\V5Feature;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
@ -35,13 +34,6 @@ public function boot(): void
Route::prefix('webhooks')
->group(base_path('routes/webhooks.php'));
if (V5Feature::enabled()) {
Route::middleware('v5.web')
->prefix('v5')
->as('v5.')
->group(base_path('routes/v5.php'));
}
Route::middleware('web')
->group(base_path('routes/web.php'));
});
@ -63,12 +55,6 @@ protected function configureRateLimiting(): void
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
});
if (V5Feature::enabled()) {
RateLimiter::for('v5', function (Request $request) {
return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip());
});
}
RateLimiter::for('feedback', function (Request $request) {
return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip());
});

View file

@ -2,178 +2,10 @@
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
class SafeExternalUrl implements ValidationRule
{
/**
* @param (Closure(string): array<int, string>)|null $resolver
*/
public function __construct(private ?Closure $resolver = null) {}
/**
* Run the validation rule.
*
* Validates that a URL points to an external, publicly-routable host.
* Blocks private IP ranges, reserved ranges, localhost, and link-local
* addresses to prevent Server-Side Request Forgery (SSRF).
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! filter_var($value, FILTER_VALIDATE_URL)) {
$fail('The :attribute must be a valid URL.');
return;
}
$scheme = strtolower(parse_url($value, PHP_URL_SCHEME) ?? '');
if (! in_array($scheme, ['https', 'http'])) {
$fail('The :attribute must use the http or https scheme.');
return;
}
$host = parse_url($value, PHP_URL_HOST);
if (! $host) {
$fail('The :attribute must contain a valid host.');
return;
}
$host = strtolower($host);
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
$hostForDns = rtrim($hostForIpCheck, '.');
$internalHosts = ['localhost', '0.0.0.0', '::1'];
if (in_array($hostForDns, $internalHosts, true) || str_ends_with($hostForDns, '.local') || str_ends_with($hostForDns, '.internal')) {
$this->logBlockedHost($attribute, $value, $host);
$fail('The :attribute must not point to internal hosts.');
return;
}
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
if (! $this->isPublicIp($hostForIpCheck)) {
$this->logBlockedIp($attribute, $value, $host, $hostForIpCheck);
$fail('The :attribute must not point to a private or reserved IP address.');
return;
}
return;
}
$resolvedIps = $this->resolveHost($hostForDns);
if ($resolvedIps === []) {
$fail('The :attribute host could not be resolved.');
return;
}
foreach ($resolvedIps as $resolvedIp) {
if (! $this->isPublicIp($resolvedIp)) {
$this->logBlockedIp($attribute, $value, $host, $resolvedIp);
$fail('The :attribute must not point to a private or reserved IP address.');
return;
}
}
}
private function normalizeHostForIpCheck(string $host): string
{
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
? substr($host, 1, -1)
: $host;
}
/**
* @return array<int, string>
*/
private function resolveHost(string $host): array
{
if ($this->resolver instanceof Closure) {
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
}
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false) {
$records = [];
}
$ips = [];
foreach ($records as $record) {
foreach (['ip', 'ipv6'] as $key) {
if (isset($record[$key]) && filter_var($record[$key], FILTER_VALIDATE_IP)) {
$ips[] = $record[$key];
}
}
}
$ipv4Addresses = @gethostbynamel($host);
if (is_array($ipv4Addresses)) {
foreach ($ipv4Addresses as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP)) {
$ips[] = $ip;
}
}
}
return array_values(array_unique($ips));
}
private function isPublicIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return filter_var($embeddedIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
private function extractIpv4FromMappedIpv6(string $ip): ?string
{
$packed = @inet_pton($ip);
if ($packed === false || strlen($packed) !== 16) {
return null;
}
$prefix = substr($packed, 0, 12);
if ($prefix !== str_repeat("\0", 10)."\xff\xff") {
return null;
}
$parts = unpack('C4', substr($packed, 12, 4));
if ($parts === false) {
return null;
}
return implode('.', $parts);
}
private function logBlockedHost(string $attribute, string $url, string $host): void
{
Log::warning('External URL points to internal host', [
'attribute' => $attribute,
'url' => $url,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
private function logBlockedIp(string $attribute, string $url, string $host, string $resolvedIp): void
{
Log::warning('External URL resolves to private or reserved IP', [
'attribute' => $attribute,
'url' => $url,
'host' => $host,
'resolved_ip' => $resolvedIp,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
}
/**
* Backwards-compatible name for outbound URL validation.
*
* External service URLs use the same private-target allowlist as webhooks
* and S3 endpoints.
*/
class SafeExternalUrl extends SafeWebhookUrl {}

View file

@ -62,7 +62,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
if ($this->isBlockedHostname($hostForDns) && ! $this->isAllowedHostname($hostForDns)) {
$this->logBlockedHost($attribute, $host);
$fail('The :attribute must not point to localhost or internal hosts.');
$fail($this->privateTargetMessage($attribute));
return;
}
@ -70,7 +70,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
if (! $this->isAllowedIp($hostForIpCheck, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
$fail($this->isLinkLocalIp($hostForIpCheck)
? 'The :attribute must not point to link-local addresses.'
: $this->privateTargetMessage($attribute));
return;
}
@ -88,13 +90,22 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
foreach ($resolvedIps as $resolvedIp) {
if (! $this->isAllowedIp($resolvedIp, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $resolvedIp);
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
$fail($this->isLinkLocalIp($resolvedIp)
? 'The :attribute must not resolve to a link-local address.'
: $this->privateTargetMessage($attribute));
return;
}
}
}
private function privateTargetMessage(string $attribute): string
{
$settingsUrl = route('settings.advanced').'#endpoint-section';
return "The {$attribute} points to a local or private address that is not allowed. Configure allowed internal targets: {$settingsUrl}";
}
/**
* Build HTTP client options that pin the validated host to the resolved IPs.
*
@ -334,6 +345,10 @@ private function isAllowedIp(string $ip, string $host): bool
$ip = $embeddedIpv4;
}
if ($this->isLinkLocalIp($ip)) {
return false;
}
if ($this->isPublicIp($ip)) {
return true;
}
@ -350,6 +365,15 @@ private function isAllowedIp(string $ip, string $host): bool
return $this->isAllowlistedIp($ip);
}
private function isLinkLocalIp(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->ipv4InCidr($ip, '169.254.0.0/16');
}
return $this->ipInCidr($ip, 'fe80::/10');
}
private function isPublicIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);

View file

@ -68,7 +68,7 @@ public function delete(User $user): void
]);
}
private function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter
protected function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter
{
if ($storageType !== 's3') {
return Storage::disk('local');
@ -82,7 +82,7 @@ private function disk(string $storageType, ?int $s3StorageId): FilesystemAdapter
return $storage->filesystem();
}
private function compress(UploadedFile $upload): string
protected function compress(UploadedFile $upload): string
{
$imageInfo = getimagesize($upload->getRealPath());
if ($imageInfo && $imageInfo['mime'] === 'image/jpeg' && $imageInfo[0] <= 256 && $imageInfo[1] <= 256) {

View file

@ -0,0 +1,88 @@
<?php
namespace App\Services;
use DateTimeInterface;
class CoolifyUpgradeStatus
{
public const STALE_AFTER_MINUTES = 10;
/**
* @return array{status: string, step?: int, message?: string, running_version: string, target_version: string}
*/
public static function fromFile(
string $content,
string $runningVersion,
string $targetVersion,
?DateTimeInterface $now = null,
int $staleAfterMinutes = self::STALE_AFTER_MINUTES,
): array {
$base = [
'running_version' => $runningVersion,
'target_version' => $targetVersion,
];
$content = trim($content);
if ($content === '') {
return ['status' => 'none', ...$base];
}
$parts = explode('|', $content);
if (count($parts) < 3) {
return ['status' => 'none', ...$base];
}
[$step, $message, $timestamp] = $parts;
try {
$statusTime = new \DateTime($timestamp);
$now = $now ?? new \DateTime;
$diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60;
if ($diffMinutes > $staleAfterMinutes) {
return ['status' => 'none', ...$base];
}
} catch (\Throwable) {
return ['status' => 'none', ...$base];
}
if ($step === 'error') {
return [
'status' => 'error',
'step' => 0,
'message' => $message,
...$base,
];
}
$stepInt = (int) $step;
if ($stepInt >= 6 && ! self::hasReachedTargetVersion($runningVersion, $targetVersion)) {
return [
'status' => 'in_progress',
'step' => $stepInt,
'message' => "Waiting for Coolify {$targetVersion} to come online...",
...$base,
];
}
$status = $stepInt >= 6 ? 'complete' : 'in_progress';
return [
'status' => $status,
'step' => $stepInt,
'message' => $message,
...$base,
];
}
public static function hasReachedTargetVersion(string $runningVersion, string $targetVersion): bool
{
if ($runningVersion === '' || $targetVersion === '') {
return false;
}
return version_compare($runningVersion, $targetVersion, '>=');
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Services;
use App\Models\Project;
use Illuminate\Http\UploadedFile;
use RuntimeException;
class ProjectIconStorageService extends AvatarStorageService
{
public function storeProject(Project $project, UploadedFile $upload): void
{
$settings = instanceSettings();
$storageType = $settings->avatar_storage_type === 's3' && $settings->avatar_s3_storage_id ? 's3' : 'local';
$s3StorageId = $storageType === 's3' ? $settings->avatar_s3_storage_id : null;
$disk = $this->disk($storageType, $s3StorageId);
$path = "project-icons/{$project->uuid}/icon.jpg";
if (! $disk->put($path, $this->compress($upload))) {
throw new RuntimeException('Unable to store the project icon.');
}
$oldStorageType = $project->icon_storage_type;
$oldS3StorageId = $project->icon_s3_storage_id;
$oldPath = $project->icon_path;
$project->forceFill([
'icon_path' => $path,
'icon_storage_type' => $storageType,
'icon_s3_storage_id' => $s3StorageId,
])->save();
if ($oldPath && ($oldStorageType !== $storageType || $oldS3StorageId !== $s3StorageId)) {
$this->disk($oldStorageType ?? 'local', $oldS3StorageId)->delete($oldPath);
}
}
public function projectContents(Project $project): ?string
{
if (! $project->icon_path) {
return null;
}
try {
$disk = $this->disk($project->icon_storage_type ?? 'local', $project->icon_s3_storage_id);
} catch (RuntimeException) {
return null;
}
return $disk->exists($project->icon_path) ? $disk->get($project->icon_path) : null;
}
public function deleteProject(Project $project): void
{
if ($project->icon_path) {
try {
$this->disk($project->icon_storage_type ?? 'local', $project->icon_s3_storage_id)
->delete($project->icon_path);
} catch (RuntimeException) {
}
}
$project->forceFill([
'icon_path' => null,
'icon_storage_type' => null,
'icon_s3_storage_id' => null,
])->save();
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Support;
final class BackupCompression
{
public static function cpuPercentage(int|string|null $configuredPercentage): int
{
$percentage = (int) $configuredPercentage;
return in_array($percentage, [25, 50, 75, 100], true) ? $percentage : 25;
}
public static function compressorCommand(int $cpuPercentage): string
{
$cpuPercentage = self::cpuPercentage($cpuPercentage);
return "if command -v pigz >/dev/null 2>&1; then printf 'pigz -3 -p %s' \"\$(( (\$(nproc) * {$cpuPercentage} + 99) / 100 ))\"; else printf 'gzip -3'; fi";
}
}

View file

@ -90,13 +90,17 @@ public static function fileContainsPostgresqlProgramExecution(string $path): boo
public static function containsPostgresqlProgramExecution(string $sql): bool
{
if (str_starts_with($sql, 'PGDMP')) {
return false;
}
$withoutComments = self::stripSqlComments($sql);
if (preg_match('/^\s*\\\\(?:!|copy\b.*\bprogram\b)/mi', $withoutComments) === 1) {
if (preg_match('/^\s*\\\\(?:!|copy\b[^\r\n]*\bprogram\b|(?:o|g)\s*\|)/mi', $withoutComments) === 1) {
return true;
}
return preg_match('/\bcopy\b[\s\S]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
}
private static function extensionFor(string $name): ?string

View file

@ -15,9 +15,16 @@ public function getCpuMetrics(int $mins = 5): ?array
public function getMemoryMetrics(int $mins = 5): ?array
{
$field = $this->isServerMetrics() ? 'usedPercent' : 'used';
if ($this->isServerMetrics()) {
return $this->getMetrics('memory', $mins, 'usedPercent');
}
return $this->getMetrics('memory', $mins, $field);
$metrics = $this->getMetrics('memory', $mins, 'used');
if ($metrics === null) {
return null;
}
return convertContainerMemoryBytesToMegabytes($metrics);
}
private function getMetrics(string $type, int $mins, string $valueField): ?array

View file

@ -7,6 +7,7 @@
use App\Models\ServiceApplication;
use App\Support\ValidationPatterns;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
@ -198,6 +199,70 @@ function checkMinimumDockerEngineVersion($dockerVersion)
return $dockerVersion;
}
function parseDockerEngineVersion(?string $rawVersion): ?string
{
if ($rawVersion === null || trim($rawVersion) === '') {
return null;
}
if (preg_match('/\d+\.\d+(?:\.\d+)?/', $rawVersion, $matches) !== 1) {
return null;
}
$parts = explode('.', $matches[0]);
return sprintf('%d.%d.%d', (int) $parts[0], (int) ($parts[1] ?? 0), (int) ($parts[2] ?? 0));
}
function dockerEngineVersionFromJson(?string $raw): ?string
{
if ($raw === null || trim($raw) === '') {
return null;
}
$decoded = json_decode($raw, true);
if (! is_array($decoded)) {
return null;
}
$version = $decoded['Server']['Version'] ?? null;
return is_string($version) ? parseDockerEngineVersion($version) : null;
}
function dockerStopTimeoutOption(?string $dockerVersion): string
{
$normalized = parseDockerEngineVersion($dockerVersion);
if ($normalized !== null && version_compare($normalized, '28.0.0', '>=')) {
return '--timeout';
}
return '--time';
}
function dockerStopCommand(int $timeout, string $containers, Server|string|null $dockerVersion = null): string
{
$version = $dockerVersion instanceof Server
? $dockerVersion->dockerVersion()
: $dockerVersion;
$option = dockerStopTimeoutOption($version);
$flag = $option === '--timeout'
? "--timeout={$timeout}"
: "--time={$timeout}";
$command = "docker stop {$flag} {$containers}";
if (app()->bound('config') && isDev()) {
Log::info('docker stop command', [
'command' => $command,
'docker_version' => $version,
]);
}
return $command;
}
function escapeShellValue(string $value): string
{
return "'".str_replace("'", "'\\''", $value)."'";

View file

@ -358,6 +358,19 @@ function parseDockerVolumeString(string $volumeString): array
];
}
function addTraefikDockerNetworkLabel(Collection $labels, string $network): Collection
{
$hasUserDefinedNetwork = $labels->contains(
fn ($label): bool => is_string($label) && str($label)->before('=')->is('traefik.docker.network')
);
if (! $hasUserDefinedNetwork) {
$labels->push("traefik.docker.network={$network}");
}
return $labels;
}
function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null): Collection
{
$uuid = data_get($resource, 'uuid');
@ -1346,6 +1359,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
? $composeRedirect
: 'both';
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
}
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
@ -2620,6 +2636,9 @@ function serviceParser(Service $resource): Collection
$redirectDirection = in_array(data_get($originalResource, 'redirect'), ['www', 'non-www', 'both'], true)
? data_get($originalResource, 'redirect')
: 'both';
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
}
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:

View file

@ -177,7 +177,7 @@ function instant_remote_process(Collection|array $command, Server $server, bool
return SshRetryHandler::retry(
function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) {
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing);
$sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string, $disableMultiplexing, (int) $effectiveTimeout);
$process = Process::timeout($effectiveTimeout)->run($sshCommand);
$output = trim($process->output());

View file

@ -4724,6 +4724,23 @@ function downsampleLTTB(array $data, int $threshold): array
return $sampled;
}
/**
* Convert Sentinel container memory samples from bytes to megabytes.
*
* Sentinel stores container `used` memory in bytes. Application and database
* metric charts label the series as megabytes, so the values must be converted
* before they are sent to the frontend.
*
* @param array<int, array{0: int|float, 1: int|float}> $metrics
* @return array<int, array{0: int, 1: float}>
*/
function convertContainerMemoryBytesToMegabytes(array $metrics): array
{
return array_map(static function (array $point): array {
return [(int) $point[0], round(((float) $point[1]) / 1024 / 1024, 2)];
}, $metrics);
}
/**
* Resolve shared environment variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}.
*

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