Merge remote-tracking branch 'origin/next' into pr-10960-compose-settings-memory-crash
This commit is contained in:
commit
ccd7c59abf
575 changed files with 7671 additions and 9652 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml
vendored
2
.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -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`.
|
||||
|
|
|
|||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
182
.github/workflows/chore-manage-pr-branch.yaml
vendored
Normal file
182
.github/workflows/chore-manage-pr-branch.yaml
vendored
Normal 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}.`);
|
||||
47
.github/workflows/coolify-helper.yml
vendored
47
.github/workflows/coolify-helper.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
47
.github/workflows/coolify-realtime.yml
vendored
47
.github/workflows/coolify-realtime.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
8
.github/workflows/coolify-release.yml
vendored
8
.github/workflows/coolify-release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
13
.github/workflows/coolify-sha-build.yml
vendored
13
.github/workflows/coolify-sha-build.yml
vendored
|
|
@ -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 \
|
||||
|
|
|
|||
2
.github/workflows/coolify-staging-build.yml
vendored
2
.github/workflows/coolify-staging-build.yml
vendored
|
|
@ -3,7 +3,7 @@ name: Staging Build
|
|||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- v4.x
|
||||
- main
|
||||
- v3.x
|
||||
- '**v5.x**'
|
||||
paths-ignore:
|
||||
|
|
|
|||
2
.github/workflows/generate-changelog.yml
vendored
2
.github/workflows/generate-changelog.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
7
.github/workflows/pr-quality.yaml
vendored
7
.github/workflows/pr-quality.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
44
AGENTS.md
44
AGENTS.md
|
|
@ -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` | Coolify’s 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 ===
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
33
DESIGN.md
33
DESIGN.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
22
RELEASE.md
22
RELEASE.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.'";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -316,6 +316,6 @@ public function feedback(Request $request)
|
|||
)]
|
||||
public function healthcheck(Request $request)
|
||||
{
|
||||
return 'OK';
|
||||
return response('OK');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
app/Http/Controllers/ProjectIconController.php
Normal file
20
app/Http/Controllers/ProjectIconController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
], [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -222,9 +222,6 @@ protected function buildDomainRows(): array
|
|||
$configured[] = $row;
|
||||
}
|
||||
|
||||
foreach ($this->buildSuggestedWwwRows($configured, $app, $stored) as $suggested) {
|
||||
$rows[] = $suggested;
|
||||
}
|
||||
}
|
||||
|
||||
return collect($rows)
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
], [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
88
app/Services/CoolifyUpgradeStatus.php
Normal file
88
app/Services/CoolifyUpgradeStatus.php
Normal 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, '>=');
|
||||
}
|
||||
}
|
||||
69
app/Services/ProjectIconStorageService.php
Normal file
69
app/Services/ProjectIconStorageService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
20
app/Support/BackupCompression.php
Normal file
20
app/Support/BackupCompression.php
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)."'";
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue