Merge branch 'next' into fix-port-modal-strip-prefixes

This commit is contained in:
Andras Bacsai 2025-12-01 13:43:41 +01:00 committed by GitHub
commit 7a28886c73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
167 changed files with 7113 additions and 1132 deletions

View file

@ -270,6 +270,84 @@ ### Build Optimization
- **Build artifact** reuse
- **Parallel build** processing
### Docker Build Cache Preservation
Coolify provides settings to preserve Docker build cache across deployments, addressing cache invalidation issues.
#### The Problem
By default, Coolify injects `ARG` statements into user Dockerfiles for build-time variables. This breaks Docker's cache mechanism because:
1. **ARG declarations invalidate cache** - Any change in ARG values after the `ARG` instruction invalidates all subsequent layers
2. **SOURCE_COMMIT changes every commit** - Causes full rebuilds even when code changes are minimal
#### Application Settings
Two toggles in **Advanced Settings** control this behavior:
| Setting | Default | Description |
|---------|---------|-------------|
| `inject_build_args_to_dockerfile` | `true` | Controls whether Coolify adds `ARG` statements to Dockerfile |
| `include_source_commit_in_build` | `false` | Controls whether `SOURCE_COMMIT` is included in build context |
**Database columns:** `application_settings.inject_build_args_to_dockerfile`, `application_settings.include_source_commit_in_build`
#### Buildpack Coverage
| Build Pack | ARG Injection | Method |
|------------|---------------|--------|
| **Dockerfile** | ✅ Yes | `add_build_env_variables_to_dockerfile()` |
| **Docker Compose** (with `build:`) | ✅ Yes | `modify_dockerfiles_for_compose()` |
| **PR Deployments** (Dockerfile only) | ✅ Yes | `add_build_env_variables_to_dockerfile()` |
| **Nixpacks** | ❌ No | Generates its own Dockerfile internally |
| **Static** | ❌ No | Uses internal Dockerfile |
| **Docker Image** | ❌ No | No build phase |
#### How It Works
**When `inject_build_args_to_dockerfile` is enabled (default):**
```dockerfile
# Coolify modifies your Dockerfile to add:
FROM node:20
ARG MY_VAR=value
ARG COOLIFY_URL=...
ARG SOURCE_COMMIT=abc123 # (if include_source_commit_in_build is true)
# ... rest of your Dockerfile
```
**When `inject_build_args_to_dockerfile` is disabled:**
- Coolify does NOT modify the Dockerfile
- `--build-arg` flags are still passed (harmless without matching `ARG` in Dockerfile)
- User must manually add `ARG` statements for any build-time variables they need
**When `include_source_commit_in_build` is disabled (default):**
- `SOURCE_COMMIT` is NOT included in build-time variables
- `SOURCE_COMMIT` is still available at **runtime** (in container environment)
- Docker cache preserved across different commits
#### Recommended Configuration
| Use Case | inject_build_args | include_source_commit | Cache Behavior |
|----------|-------------------|----------------------|----------------|
| Maximum cache preservation | `false` | `false` | Best cache retention |
| Need build-time vars, no commit | `true` | `false` | Cache breaks on var changes |
| Need commit at build-time | `true` | `true` | Cache breaks every commit |
| Manual ARG management | `false` | `true` | Cache preserved (no ARG in Dockerfile) |
#### Implementation Details
**Files:**
- `app/Jobs/ApplicationDeploymentJob.php`:
- `set_coolify_variables()` - Conditionally adds SOURCE_COMMIT to Docker build context based on `include_source_commit_in_build` setting
- `generate_coolify_env_variables(bool $forBuildTime)` - Distinguishes build-time vs. runtime variables; excludes cache-busting variables like SOURCE_COMMIT from build context unless explicitly enabled
- `generate_env_variables()` - Populates `$this->env_args` with build-time ARG values, respecting `include_source_commit_in_build` toggle
- `add_build_env_variables_to_dockerfile()` - Injects ARG statements into Dockerfiles after FROM instructions; skips injection if `inject_build_args_to_dockerfile` is disabled
- `modify_dockerfiles_for_compose()` - Applies ARG injection to Docker Compose service Dockerfiles; respects `inject_build_args_to_dockerfile` toggle
- `app/Models/ApplicationSetting.php` - Defines `inject_build_args_to_dockerfile` and `include_source_commit_in_build` boolean properties
- `app/Livewire/Project/Application/Advanced.php` - Livewire component providing UI bindings for cache preservation toggles
- `resources/views/livewire/project/application/advanced.blade.php` - Checkbox UI elements for user-facing toggles
**Note:** Docker Compose services without a `build:` section (image-only) are automatically skipped.
### Runtime Optimization
- **Container resource** limits
- **Auto-scaling** based on metrics
@ -428,7 +506,7 @@ #### `content`
- `templates/compose/chaskiq.yaml` - Entrypoint script
**Implementation:**
- Parsed: `bootstrap/helpers/parsers.php` (line 717)
- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `content` field extraction)
- Storage: `app/Models/LocalFileVolume.php`
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
@ -481,7 +559,7 @@ #### `is_directory` / `isDirectory`
- Pre-creating mount points before container starts
**Implementation:**
- Parsed: `bootstrap/helpers/parsers.php` (line 718)
- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `is_directory`/`isDirectory` field extraction)
- Storage: `app/Models/LocalFileVolume.php` (`is_directory` column)
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`

View file

@ -44,8 +44,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
@ -86,8 +86,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version

View file

@ -44,8 +44,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
@ -85,8 +85,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version

View file

@ -51,8 +51,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
@ -91,8 +91,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version

View file

@ -48,8 +48,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
@ -90,8 +90,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version

View file

@ -48,8 +48,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version
@ -90,8 +90,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get Version
id: version

View file

@ -64,8 +64,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
@ -110,8 +110,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |

View file

@ -44,8 +44,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
@ -81,8 +81,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |

View file

@ -39,7 +39,7 @@ public function handle(Application $application, bool $previewDeployments = fals
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
"docker stop --time=30 $containerName",
"docker stop -t 30 $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}

View file

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

View file

@ -105,7 +105,7 @@ public function handle(StandaloneClickhouse $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
$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";
$this->commands[] = "echo 'Database started.'";

View file

@ -192,7 +192,7 @@ public function handle(StandaloneDragonfly $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
$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";
$this->commands[] = "echo 'Database started.'";

View file

@ -208,7 +208,7 @@ public function handle(StandaloneKeydb $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
$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";
$this->commands[] = "echo 'Database started.'";

View file

@ -209,7 +209,7 @@ public function handle(StandaloneMariadb $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
$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";
$this->commands[] = "echo 'Database started.'";

View file

@ -260,7 +260,7 @@ public function handle(StandaloneMongodb $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
$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";
if ($this->database->enable_ssl) {

View file

@ -210,7 +210,7 @@ public function handle(StandaloneMysql $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
$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";

View file

@ -223,7 +223,7 @@ public function handle(StandalonePostgresql $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
$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";
if ($this->database->enable_ssl) {

View file

@ -205,7 +205,7 @@ public function handle(StandaloneRedis $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
$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";
$this->commands[] = "echo 'Database started.'";

View file

@ -49,7 +49,7 @@ private function stopContainer($database, string $containerName, int $timeout =
{
$server = $database->destination->server;
instant_remote_process(command: [
"docker stop --time=$timeout $containerName",
"docker stop -t $timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}

View file

@ -75,6 +75,10 @@ public function handle(Server $server, bool $async = true, bool $force = false,
' done',
" echo 'Successfully stopped and removed existing coolify-proxy.'",
'fi',
]);
// Ensure required networks exist BEFORE docker compose up (networks are declared as external)
$commands = $commands->merge(ensureProxyNetworksExist($server));
$commands = $commands->merge([
"echo 'Starting coolify-proxy.'",
'docker compose up -d --wait --remove-orphans',
"echo 'Successfully started coolify-proxy.'",

View file

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

View file

@ -3,6 +3,8 @@
namespace App\Actions\Server;
use App\Models\Server;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep;
use Lorisleiva\Actions\Concerns\AsAction;
@ -29,7 +31,59 @@ public function handle($manual_update = false)
return;
}
CleanupDocker::dispatch($this->server, false, false);
$this->latestVersion = get_latest_version_of_coolify();
// Fetch fresh version from CDN instead of using cache
try {
$response = Http::retry(3, 1000)->timeout(10)
->get(config('constants.coolify.versions_url'));
if ($response->successful()) {
$versions = $response->json();
$this->latestVersion = data_get($versions, 'coolify.v4.version');
} else {
// Fallback to cache if CDN unavailable
$cacheVersion = get_latest_version_of_coolify();
// Validate cache version against current running version
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
'cached_version' => $cacheVersion,
'current_version' => config('constants.coolify.version'),
]);
throw new \Exception(
'Cannot determine latest version: CDN unavailable and cache version '.
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
);
}
$this->latestVersion = $cacheVersion;
Log::warning('Failed to fetch fresh version from CDN (unsuccessful response), using validated cache', [
'version' => $cacheVersion,
]);
}
} catch (\Throwable $e) {
$cacheVersion = get_latest_version_of_coolify();
// Validate cache version against current running version
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
'error' => $e->getMessage(),
'cached_version' => $cacheVersion,
'current_version' => config('constants.coolify.version'),
]);
throw new \Exception(
'Cannot determine latest version: CDN unavailable and cache version '.
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
);
}
$this->latestVersion = $cacheVersion;
Log::warning('Failed to fetch fresh version from CDN, using validated cache', [
'error' => $e->getMessage(),
'version' => $cacheVersion,
]);
}
$this->currentVersion = config('constants.coolify.version');
if (! $manual_update) {
if (! $settings->is_auto_update_enabled) {
@ -42,6 +96,20 @@ public function handle($manual_update = false)
return;
}
}
// ALWAYS check for downgrades (even for manual updates)
if (version_compare($this->latestVersion, $this->currentVersion, '<')) {
Log::error('Downgrade prevented', [
'target_version' => $this->latestVersion,
'current_version' => $this->currentVersion,
'manual_update' => $manual_update,
]);
throw new \Exception(
"Cannot downgrade from {$this->currentVersion} to {$this->latestVersion}. ".
'If you need to downgrade, please do so manually via Docker commands.'
);
}
$this->update();
$settings->new_version_available = false;
$settings->save();
@ -56,8 +124,9 @@ private function update()
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
instant_remote_process(["docker pull -q $image"], $this->server, false);
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
remote_process([
'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh',
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion",
], $this->server);
}

View file

@ -54,7 +54,7 @@ private function stopContainersInParallel(array $containersToStop, Server $serve
$timeout = count($containersToStop) > 5 ? 10 : 30;
$commands = [];
$containerList = implode(' ', $containersToStop);
$commands[] = "docker stop --time=$timeout $containerList";
$commands[] = "docker stop -t $timeout $containerList";
$commands[] = "docker rm -f $containerList";
instant_remote_process(
command: $commands,

View file

@ -63,8 +63,6 @@ class CleanupNames extends Command
public function handle(): int
{
$this->info('🔍 Scanning for invalid characters in name fields...');
if ($this->option('backup') && ! $this->option('dry-run')) {
$this->createBackup();
}
@ -75,7 +73,7 @@ public function handle(): int
: $this->modelsToClean;
if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
$this->error("Unknown model: {$modelFilter}");
$this->error("Unknown model: {$modelFilter}");
$this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
return self::FAILURE;
@ -88,19 +86,21 @@ public function handle(): int
$this->processModel($modelName, $modelClass);
}
$this->displaySummary();
if (! $this->option('dry-run') && $this->totalCleaned > 0) {
$this->logChanges();
}
if ($this->option('dry-run')) {
$this->info("Name cleanup: would sanitize {$this->totalCleaned} records");
} else {
$this->info("Name cleanup: sanitized {$this->totalCleaned} records");
}
return self::SUCCESS;
}
protected function processModel(string $modelName, string $modelClass): void
{
$this->info("\n📋 Processing {$modelName}...");
try {
$records = $modelClass::all(['id', 'name']);
$cleaned = 0;
@ -128,21 +128,17 @@ protected function processModel(string $modelName, string $modelClass): void
$cleaned++;
$this->totalCleaned++;
$this->warn(" 🧹 {$modelName} #{$record->id}:");
$this->line(' From: '.$this->truncate($originalName, 80));
$this->line(' To: '.$this->truncate($sanitizedName, 80));
// Only log in dry-run mode to preview changes
if ($this->option('dry-run')) {
$this->warn(" 🧹 {$modelName} #{$record->id}:");
$this->line(' From: '.$this->truncate($originalName, 80));
$this->line(' To: '.$this->truncate($sanitizedName, 80));
}
}
}
if ($cleaned > 0) {
$action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized';
$this->info("{$cleaned}/{$records->count()} records {$action}");
} else {
$this->info(' ✨ No invalid characters found');
}
} catch (\Exception $e) {
$this->error("Error processing {$modelName}: ".$e->getMessage());
$this->error("Error processing {$modelName}: ".$e->getMessage());
}
}
@ -165,28 +161,6 @@ protected function sanitizeName(string $name): string
return $sanitized;
}
protected function displaySummary(): void
{
$this->info("\n".str_repeat('=', 60));
$this->info('📊 CLEANUP SUMMARY');
$this->info(str_repeat('=', 60));
$this->line("Records processed: {$this->totalProcessed}");
$this->line("Records with invalid characters: {$this->totalCleaned}");
if ($this->option('dry-run')) {
$this->warn("\n🔍 DRY RUN - No changes were made to the database");
$this->info('Run without --dry-run to apply these changes');
} else {
if ($this->totalCleaned > 0) {
$this->info("\n✅ Database successfully sanitized!");
$this->info('Changes logged to storage/logs/name-cleanup.log');
} else {
$this->info("\n✨ No cleanup needed - all names are valid!");
}
}
}
protected function logChanges(): void
{
$logFile = storage_path('logs/name-cleanup.log');
@ -208,8 +182,6 @@ protected function logChanges(): void
protected function createBackup(): void
{
$this->info('💾 Creating database backup...');
try {
$backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
@ -229,15 +201,9 @@ protected function createBackup(): void
);
exec($command, $output, $returnCode);
if ($returnCode === 0) {
$this->info("✅ Backup created: {$backupFile}");
} else {
$this->warn('⚠️ Backup creation may have failed. Proceeding anyway...');
}
} catch (\Exception $e) {
$this->warn('⚠️ Could not create backup: '.$e->getMessage());
$this->warn('Proceeding without backup...');
// Log failure but continue - backup is optional safeguard
Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]);
}
}

View file

@ -18,10 +18,6 @@ public function handle()
$dryRun = $this->option('dry-run');
$skipOverlapping = $this->option('skip-overlapping');
if ($dryRun) {
$this->info('DRY RUN MODE - No data will be deleted');
}
$deletedCount = 0;
$totalKeys = 0;
@ -29,8 +25,6 @@ public function handle()
$keys = $redis->keys('*');
$totalKeys = count($keys);
$this->info("Scanning {$totalKeys} keys for cleanup...");
foreach ($keys as $key) {
$keyWithoutPrefix = str_replace($prefix, '', $key);
$type = $redis->command('type', [$keyWithoutPrefix]);
@ -51,14 +45,12 @@ public function handle()
// Clean up overlapping queues if not skipped
if (! $skipOverlapping) {
$this->info('Cleaning up overlapping queues...');
$overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
$deletedCount += $overlappingCleaned;
}
// Clean up stale cache locks (WithoutOverlapping middleware)
if ($this->option('clear-locks')) {
$this->info('Cleaning up stale cache locks...');
$locksCleaned = $this->cleanupCacheLocks($dryRun);
$deletedCount += $locksCleaned;
}
@ -66,15 +58,14 @@ public function handle()
// Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
$isRestart = $this->option('restart');
if ($isRestart || $this->option('clear-locks')) {
$this->info($isRestart ? 'Cleaning up stuck jobs (RESTART MODE - aggressive)...' : 'Checking for stuck jobs (runtime mode - conservative)...');
$jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart);
$deletedCount += $jobsCleaned;
}
if ($dryRun) {
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
$this->info("Redis cleanup: would delete {$deletedCount} items");
} else {
$this->info("Deleted {$deletedCount} out of {$totalKeys} keys");
$this->info("Redis cleanup: deleted {$deletedCount} items");
}
}
@ -85,11 +76,8 @@ private function shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)
// Delete completed and failed jobs
if (in_array($status, ['completed', 'failed'])) {
if ($dryRun) {
$this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})");
} else {
if (! $dryRun) {
$redis->command('del', [$keyWithoutPrefix]);
$this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})");
}
return true;
@ -115,11 +103,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR
foreach ($patterns as $pattern => $description) {
if (str_contains($keyWithoutPrefix, $pattern)) {
if ($dryRun) {
$this->line("Would delete {$description}: {$keyWithoutPrefix}");
} else {
if (! $dryRun) {
$redis->command('del', [$keyWithoutPrefix]);
$this->line("Deleted {$description}: {$keyWithoutPrefix}");
}
return true;
@ -132,11 +117,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR
$weekAgo = now()->subDays(7)->timestamp;
if ($timestamp < $weekAgo) {
if ($dryRun) {
$this->line("Would delete old timestamped data: {$keyWithoutPrefix}");
} else {
if (! $dryRun) {
$redis->command('del', [$keyWithoutPrefix]);
$this->line("Deleted old timestamped data: {$keyWithoutPrefix}");
}
return true;
@ -160,8 +142,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
}
}
$this->info('Found '.count($queueKeys).' queue-related keys');
// Group queues by name pattern to find duplicates
$queueGroups = [];
foreach ($queueKeys as $queueKey) {
@ -193,7 +173,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
{
$cleanedCount = 0;
$this->line("Processing queue group: {$baseName} (".count($keys).' keys)');
// Sort keys to keep the most recent one
usort($keys, function ($a, $b) {
@ -244,11 +223,8 @@ private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
}
if ($shouldDelete) {
if ($dryRun) {
$this->line(" Would delete empty queue: {$redundantKey}");
} else {
if (! $dryRun) {
$redis->command('del', [$redundantKey]);
$this->line(" Deleted empty queue: {$redundantKey}");
}
$cleanedCount++;
}
@ -271,15 +247,12 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun)
if (count($uniqueItems) < count($items)) {
$duplicates = count($items) - count($uniqueItems);
if ($dryRun) {
$this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}");
} else {
if (! $dryRun) {
// Rebuild the list with unique items
$redis->command('del', [$queueKey]);
foreach (array_reverse($uniqueItems) as $item) {
$redis->command('lpush', [$queueKey, $item]);
}
$this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}");
}
$cleanedCount += $duplicates;
}
@ -307,13 +280,9 @@ private function cleanupCacheLocks(bool $dryRun): int
}
}
if (empty($lockKeys)) {
$this->info(' No cache locks found.');
return 0;
}
$this->info(' Found '.count($lockKeys).' cache lock(s)');
foreach ($lockKeys as $lockKey) {
// Check TTL to identify stale locks
$ttl = $redis->ttl($lockKey);
@ -326,18 +295,11 @@ private function cleanupCacheLocks(bool $dryRun): int
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
} else {
$redis->del($lockKey);
$this->info(" ✓ Deleted STALE lock: {$lockKey}");
}
$cleanedCount++;
} elseif ($ttl > 0) {
$this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}");
}
}
if ($cleanedCount === 0) {
$this->info(' No stale locks found (all locks have expiration set)');
}
return $cleanedCount;
}
@ -453,17 +415,11 @@ private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $is
$redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
$redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
$redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]);
$this->info(" ✓ Marked as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1).' min) - '.$reason);
}
$cleanedCount++;
}
}
if ($cleanedCount === 0) {
$this->info($isRestart ? ' No jobs to clean up' : ' No stuck jobs found (all jobs running normally)');
}
return $cleanedCount;
}
}

View file

@ -56,7 +56,7 @@ private function showHelp()
php artisan app:demo-notify {channel}
</p>
<div class="my-1">
<div class="text-yellow-500"> Channels: </div>
<div class="text-warning-500"> Channels: </div>
<ul class="text-coolify">
<li>email</li>
<li>discord</li>

View file

@ -16,7 +16,7 @@ class SyncBunny extends Command
*
* @var string
*/
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}';
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}';
/**
* The console command description.
@ -50,6 +50,7 @@ private function syncReleasesToGitHubRepo(): bool
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
@ -59,6 +60,7 @@ private function syncReleasesToGitHubRepo(): bool
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
@ -70,12 +72,25 @@ private function syncReleasesToGitHubRepo(): bool
// Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
$releasesDir = dirname($releasesPath);
// Ensure directory exists
if (! is_dir($releasesDir)) {
$this->info("Creating directory: $releasesDir");
if (! mkdir($releasesDir, 0755, true)) {
$this->error("Failed to create directory: $releasesDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write releases.json to: $releasesPath");
$this->error('Possible reasons: directory does not exist, permission denied, or disk full.');
$this->error('Possible reasons: permission denied or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
@ -83,6 +98,7 @@ private function syncReleasesToGitHubRepo(): bool
// Stage and commit
$this->info('Committing changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
@ -120,6 +136,7 @@ private function syncReleasesToGitHubRepo(): bool
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
@ -133,6 +150,7 @@ private function syncReleasesToGitHubRepo(): bool
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// Clean up
@ -158,6 +176,343 @@ private function syncReleasesToGitHubRepo(): bool
}
}
/**
* Sync both releases.json and versions.json to GitHub repository in one PR
*/
private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
{
$this->info('Syncing releases.json and versions.json to GitHub repository...');
try {
// 1. Fetch releases from GitHub API
$this->info('Fetching releases from GitHub API...');
$response = Http::timeout(30)
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
'per_page' => 30,
]);
if (! $response->successful()) {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
$releases = $response->json();
// 2. Read versions.json
if (! file_exists($versionsLocation)) {
$this->error("versions.json not found at: $versionsLocation");
return false;
}
$file = file_get_contents($versionsLocation);
$versionsJson = json_decode($file, true);
$actualVersion = data_get($versionsJson, 'coolify.v4.version');
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
$branchName = 'update-releases-and-versions-'.$timestamp;
$versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
// 3. Clone the repository
$this->info('Cloning coolify-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// 4. Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 5. Write releases.json
$this->info('Writing releases.json...');
$releasesPath = "$tmpDir/json/releases.json";
$releasesDir = dirname($releasesPath);
if (! is_dir($releasesDir)) {
if (! mkdir($releasesDir, 0755, true)) {
$this->error("Failed to create directory: $releasesDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
$this->error("Failed to write releases.json to: $releasesPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 6. Write versions.json
$this->info('Writing versions.json...');
$versionsPath = "$tmpDir/$versionsTargetPath";
$versionsDir = dirname($versionsPath);
if (! is_dir($versionsDir)) {
if (! mkdir($versionsDir, 0755, true)) {
$this->error("Failed to create directory: $versionsDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
$this->error("Failed to write versions.json to: $versionsPath");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 7. Stage both files
$this->info('Staging changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 8. Check for changes
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('Both files are already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
// 9. Commit changes
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 10. Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// 11. Create pull request
$this->info('Creating pull request...');
$prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// 12. Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info("Version synced: $actualVersion");
$this->info('Total releases synced: '.count($releases));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing to GitHub: '.$e->getMessage());
return false;
}
}
/**
* Sync versions.json to GitHub repository via PR
*/
private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
{
$this->info('Syncing versions.json to GitHub repository...');
try {
if (! file_exists($versionsLocation)) {
$this->error("versions.json not found at: $versionsLocation");
return false;
}
$file = file_get_contents($versionsLocation);
$json = json_decode($file, true);
$actualVersion = data_get($json, 'coolify.v4.version');
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
$branchName = 'update-versions-'.$timestamp;
$targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
// Clone the repository
$this->info('Cloning coolify-cdn repository...');
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Write versions.json
$this->info('Writing versions.json...');
$versionsPath = "$tmpDir/$targetPath";
$versionsDir = dirname($versionsPath);
// Ensure directory exists
if (! is_dir($versionsDir)) {
$this->info("Creating directory: $versionsDir");
if (! mkdir($versionsDir, 0755, true)) {
$this->error("Failed to create directory: $versionsDir");
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
}
$jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$bytesWritten = file_put_contents($versionsPath, $jsonContent);
if ($bytesWritten === false) {
$this->error("Failed to write versions.json to: $versionsPath");
$this->error('Possible reasons: permission denied or disk full.');
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Stage and commit
$this->info('Committing changes...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
$this->info('Checking for changes...');
$statusOutput = [];
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
if (empty(array_filter($statusOutput))) {
$this->info('versions.json is already up to date. No changes to commit.');
exec('rm -rf '.escapeshellarg($tmpDir));
return true;
}
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
exec('rm -rf '.escapeshellarg($tmpDir));
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
$prBody = "Automated update of $envLabel versions.json to version $actualVersion";
$output = [];
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
exec($prCommand, $output, $returnCode);
// Clean up
exec('rm -rf '.escapeshellarg($tmpDir));
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info("Version synced: $actualVersion");
return true;
} catch (\Throwable $e) {
$this->error('Error syncing versions.json: '.$e->getMessage());
return false;
}
}
/**
* Execute the console command.
*/
@ -167,6 +522,7 @@ public function handle()
$only_template = $this->option('templates');
$only_version = $this->option('release');
$only_github_releases = $this->option('github-releases');
$only_github_versions = $this->option('github-versions');
$nightly = $this->option('nightly');
$bunny_cdn = 'https://cdn.coollabs.io';
$bunny_cdn_path = 'coolify';
@ -224,7 +580,7 @@ public function handle()
$install_script_location = "$parent_dir/other/nightly/$install_script";
$versions_location = "$parent_dir/other/nightly/$versions";
}
if (! $only_template && ! $only_version && ! $only_github_releases) {
if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
if ($nightly) {
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
} else {
@ -250,25 +606,47 @@ public function handle()
return;
} elseif ($only_version) {
if ($nightly) {
$this->info('About to sync NIGHLTY versions.json to BunnyCDN.');
$this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.');
} else {
$this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
$this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.');
}
$file = file_get_contents($versions_location);
$json = json_decode($file, true);
$actual_version = data_get($json, 'coolify.v4.version');
$confirmed = confirm("Are you sure you want to sync to {$actual_version}?");
$this->info("Version: {$actual_version}");
$this->info('This will:');
$this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)');
$this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
$this->newLine();
$confirmed = confirm('Are you sure you want to proceed?');
if (! $confirmed) {
return;
}
// Sync versions.json to BunnyCDN
// 1. Sync versions.json to BunnyCDN (deprecated but still needed)
$this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
]);
$this->info('versions.json uploaded & purged...');
$this->info('✓ versions.json uploaded & purged to BunnyCDN');
$this->newLine();
// 2. Create GitHub PR with both releases.json and versions.json
$this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
$githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
if ($githubSuccess) {
$this->info('✓ GitHub PR created successfully with both files');
} else {
$this->error('✗ Failed to create GitHub PR');
}
$this->newLine();
$this->info('=== Summary ===');
$this->info('BunnyCDN sync: ✓ Complete');
$this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed'));
return;
} elseif ($only_github_releases) {
@ -281,6 +659,22 @@ public function handle()
// Sync releases to GitHub repository
$this->syncReleasesToGitHubRepo();
return;
} elseif ($only_github_versions) {
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$file = file_get_contents($versions_location);
$json = json_decode($file, true);
$actual_version = data_get($json, 'coolify.v4.version');
$this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository.");
$confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?');
if (! $confirmed) {
return;
}
// Sync versions.json to GitHub repository
$this->syncVersionsToGitHubRepo($versions_location, $nightly);
return;
}

View file

@ -17,17 +17,23 @@ public function __construct($data)
$tmpPath = data_get($data, 'tmpPath');
$container = data_get($data, 'container');
$serverId = data_get($data, 'serverId');
if (filled($scriptPath) && filled($tmpPath) && filled($container) && filled($serverId)) {
if (str($tmpPath)->startsWith('/tmp/')
&& str($scriptPath)->startsWith('/tmp/')
&& ! str($tmpPath)->contains('..')
&& ! str($scriptPath)->contains('..')
&& strlen($tmpPath) > 5 // longer than just "/tmp/"
&& strlen($scriptPath) > 5
) {
$commands[] = "docker exec {$container} sh -c 'rm {$scriptPath}'";
$commands[] = "docker exec {$container} sh -c 'rm {$tmpPath}'";
instant_remote_process($commands, Server::find($serverId), throwError: true);
if (filled($container) && filled($serverId)) {
$commands = [];
if (isSafeTmpPath($scriptPath)) {
$commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($scriptPath)." 2>/dev/null || true'";
}
if (isSafeTmpPath($tmpPath)) {
$commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($tmpPath)." 2>/dev/null || true'";
}
if (! empty($commands)) {
$server = Server::find($serverId);
if ($server) {
instant_remote_process($commands, $server, throwError: false);
}
}
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace App\Events;
use App\Models\Server;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class S3RestoreJobFinished
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct($data)
{
$containerName = data_get($data, 'containerName');
$serverTmpPath = data_get($data, 'serverTmpPath');
$scriptPath = data_get($data, 'scriptPath');
$containerTmpPath = data_get($data, 'containerTmpPath');
$container = data_get($data, 'container');
$serverId = data_get($data, 'serverId');
// Most cleanup now happens inline during restore process
// This acts as a safety net for edge cases (errors, interruptions)
if (filled($serverId)) {
$commands = [];
// Ensure helper container is removed (may already be gone from inline cleanup)
if (filled($containerName)) {
$commands[] = 'docker rm -f '.escapeshellarg($containerName).' 2>/dev/null || true';
}
// Clean up server temp file if still exists (should already be cleaned)
if (isSafeTmpPath($serverTmpPath)) {
$commands[] = 'rm -f '.escapeshellarg($serverTmpPath).' 2>/dev/null || true';
}
// Clean up any remaining files in database container (may already be cleaned)
if (filled($container)) {
if (isSafeTmpPath($containerTmpPath)) {
$commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($containerTmpPath).' 2>/dev/null || true';
}
if (isSafeTmpPath($scriptPath)) {
$commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($scriptPath).' 2>/dev/null || true';
}
}
if (! empty($commands)) {
$server = Server::find($serverId);
if ($server) {
instant_remote_process($commands, $server, throwError: false);
}
}
}
}
}

View file

@ -1652,6 +1652,10 @@ private function create_application(Request $request, $type)
$service->save();
$service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
if ($instantDeploy) {
StartService::dispatch($service);
}

View file

@ -351,7 +351,7 @@ public function create_service(Request $request)
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
if ($oneClickServiceName === 'cloudflared') {
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = Service::create($servicePayload);
@ -376,6 +376,10 @@ public function create_service(Request $request)
});
}
$service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
if ($instantDeploy) {
StartService::dispatch($service);
}

View file

@ -1363,7 +1363,7 @@ private function save_runtime_environment_variables()
$envs_base64 = base64_encode($environment_variables->implode("\n"));
// Write .env file to workdir (for container runtime)
$this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for build phase.', hidden: true);
$this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for container.', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
@ -1401,15 +1401,44 @@ private function generate_buildtime_environment_variables()
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
}
$envs = collect([]);
$coolify_envs = $this->generate_coolify_env_variables();
// Use associative array for automatic deduplication
$envs_dict = [];
// Add COOLIFY variables
$coolify_envs->each(function ($item, $key) use ($envs) {
$envs->push($key.'='.escapeBashEnvValue($item));
});
// 1. Add nixpacks plan variables FIRST (lowest priority - can be overridden)
if ($this->build_pack === 'nixpacks' &&
isset($this->nixpacks_plan_json) &&
$this->nixpacks_plan_json->isNotEmpty()) {
// Add SERVICE_NAME variables for Docker Compose builds
$planVariables = data_get($this->nixpacks_plan_json, 'variables', []);
if (! empty($planVariables)) {
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] Adding '.count($planVariables).' nixpacks plan variables to buildtime.env');
}
foreach ($planVariables as $key => $value) {
// Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority
if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) {
continue;
}
$escapedValue = escapeBashEnvValue($value);
$envs_dict[$key] = $escapedValue;
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Nixpacks var: {$key}={$escapedValue}");
}
}
}
}
// 2. Add COOLIFY variables (can override nixpacks, but shouldn't happen in practice)
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
foreach ($coolify_envs as $key => $item) {
$envs_dict[$key] = escapeBashEnvValue($item);
}
// 3. Add SERVICE_NAME, SERVICE_FQDN, SERVICE_URL variables for Docker Compose builds
if ($this->build_pack === 'dockercompose') {
if ($this->pull_request_id === 0) {
// Generate SERVICE_NAME for dockercompose services from processed compose
@ -1420,7 +1449,7 @@ private function generate_buildtime_environment_variables()
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.escapeBashEnvValue($serviceName));
$envs_dict['SERVICE_NAME_'.str($serviceName)->upper()] = escapeBashEnvValue($serviceName);
}
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
@ -1433,8 +1462,8 @@ private function generate_buildtime_environment_variables()
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
$envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn);
}
}
} else {
@ -1442,7 +1471,7 @@ private function generate_buildtime_environment_variables()
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)));
$envs_dict['SERVICE_NAME_'.str($rawServiceName)->upper()] = escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
}
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
@ -1455,17 +1484,16 @@ private function generate_buildtime_environment_variables()
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
$envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn);
}
}
}
}
// Add build-time user variables only
// 4. Add user-defined build-time variables LAST (highest priority - can override everything)
if ($this->pull_request_id === 0) {
$sorted_environment_variables = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
@ -1483,7 +1511,12 @@ private function generate_buildtime_environment_variables()
// Strip outer quotes from real_value and apply proper bash escaping
$value = trim($env->real_value, "'");
$escapedValue = escapeBashEnvValue($value);
$envs->push($env->key.'='.$escapedValue);
if (isDev() && isset($envs_dict[$env->key])) {
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
}
$envs_dict[$env->key] = $escapedValue;
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
@ -1495,7 +1528,12 @@ private function generate_buildtime_environment_variables()
} else {
// For normal vars, use double quotes to allow $VAR expansion
$escapedValue = escapeBashDoubleQuoted($env->real_value);
$envs->push($env->key.'='.$escapedValue);
if (isDev() && isset($envs_dict[$env->key])) {
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
}
$envs_dict[$env->key] = $escapedValue;
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
@ -1507,7 +1545,6 @@ private function generate_buildtime_environment_variables()
}
} else {
$sorted_environment_variables = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
@ -1525,7 +1562,12 @@ private function generate_buildtime_environment_variables()
// Strip outer quotes from real_value and apply proper bash escaping
$value = trim($env->real_value, "'");
$escapedValue = escapeBashEnvValue($value);
$envs->push($env->key.'='.$escapedValue);
if (isDev() && isset($envs_dict[$env->key])) {
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
}
$envs_dict[$env->key] = $escapedValue;
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
@ -1537,7 +1579,12 @@ private function generate_buildtime_environment_variables()
} else {
// For normal vars, use double quotes to allow $VAR expansion
$escapedValue = escapeBashDoubleQuoted($env->real_value);
$envs->push($env->key.'='.$escapedValue);
if (isDev() && isset($envs_dict[$env->key])) {
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
}
$envs_dict[$env->key] = $escapedValue;
if (isDev()) {
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
@ -1549,6 +1596,12 @@ private function generate_buildtime_environment_variables()
}
}
// Convert dictionary back to collection in KEY=VALUE format
$envs = collect([]);
foreach ($envs_dict as $key => $value) {
$envs->push($key.'='.$value);
}
// Return the generated environment variables
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
@ -1957,7 +2010,12 @@ private function deploy_to_additional_destinations()
private function set_coolify_variables()
{
$this->coolify_variables = "SOURCE_COMMIT={$this->commit} ";
$this->coolify_variables = '';
// Only include SOURCE_COMMIT in build context if enabled in settings
if ($this->application->settings->include_source_commit_in_build) {
$this->coolify_variables .= "SOURCE_COMMIT={$this->commit} ";
}
if ($this->pull_request_id === 0) {
$fqdn = $this->application->fqdn;
} else {
@ -1979,7 +2037,6 @@ private function set_coolify_variables()
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
}
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
$this->coolify_variables .= "COOLIFY_CONTAINER_NAME={$this->container_name} ";
}
private function check_git_if_build_needed()
@ -2230,7 +2287,7 @@ private function generate_nixpacks_env_variables()
}
// Add COOLIFY_* environment variables to Nixpacks build context
$coolify_envs = $this->generate_coolify_env_variables();
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
$coolify_envs->each(function ($value, $key) {
$this->env_nixpacks_args->push("--env {$key}={$value}");
});
@ -2238,17 +2295,20 @@ private function generate_nixpacks_env_variables()
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
}
private function generate_coolify_env_variables(): Collection
private function generate_coolify_env_variables(bool $forBuildTime = false): Collection
{
$coolify_envs = collect([]);
$local_branch = $this->branch;
if ($this->pull_request_id !== 0) {
// Add SOURCE_COMMIT if not exists
if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) {
if (! is_null($this->commit)) {
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
} else {
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
// Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time
// SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build
if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) {
if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) {
if (! is_null($this->commit)) {
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
} else {
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
}
}
}
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_FQDN')->isEmpty()) {
@ -2273,20 +2333,26 @@ private function generate_coolify_env_variables(): Collection
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
}
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
// Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache
if (! $forBuildTime) {
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
}
}
}
add_coolify_default_environment_variables($this->application, $coolify_envs, $this->application->environment_variables_preview);
} else {
// Add SOURCE_COMMIT if not exists
if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
if (! is_null($this->commit)) {
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
} else {
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
// Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time
// SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build
if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) {
if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
if (! is_null($this->commit)) {
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
} else {
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
}
}
}
if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) {
@ -2311,8 +2377,11 @@ private function generate_coolify_env_variables(): Collection
if ($this->application->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
}
if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
// Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache
if (! $forBuildTime) {
if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
}
}
}
@ -2326,9 +2395,13 @@ private function generate_coolify_env_variables(): Collection
private function generate_env_variables()
{
$this->env_args = collect([]);
$this->env_args->put('SOURCE_COMMIT', $this->commit);
$coolify_envs = $this->generate_coolify_env_variables();
// Only include SOURCE_COMMIT in build args if enabled in settings
if ($this->application->settings->include_source_commit_in_build) {
$this->env_args->put('SOURCE_COMMIT', $this->commit);
}
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
$coolify_envs->each(function ($value, $key) {
$this->env_args->put($key, $value);
});
@ -2748,7 +2821,7 @@ private function build_image()
} else {
// Traditional build args approach - generate COOLIFY_ variables locally
// Generate COOLIFY_ variables locally for build args
$coolify_envs = $this->generate_coolify_env_variables();
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
$coolify_envs->each(function ($value, $key) {
$this->build_args->push("--build-arg '{$key}'");
});
@ -3070,7 +3143,7 @@ private function graceful_shutdown_container(string $containerName)
try {
$timeout = isDev() ? 1 : 30;
$this->execute_remote_command(
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
);
} catch (Exception $error) {
@ -3294,7 +3367,9 @@ private function generate_build_secrets(Collection $variables)
private function generate_secrets_hash($variables)
{
if (! $this->secrets_hash_key) {
$this->secrets_hash_key = bin2hex(random_bytes(32));
// Use APP_KEY as deterministic hash key to preserve Docker build cache
// Random keys would change every deployment, breaking cache even when secrets haven't changed
$this->secrets_hash_key = config('app.key');
}
if ($variables instanceof Collection) {
@ -3337,100 +3412,121 @@ private function add_build_env_variables_to_dockerfile()
{
if ($this->dockerBuildkitSupported) {
// We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets
return;
}
// Skip ARG injection if disabled by user - preserves Docker build cache
if ($this->application->settings->inject_build_args_to_dockerfile === false) {
$this->application_deployment_queue->addLogEntry('Skipping Dockerfile ARG injection (disabled in settings).', hidden: true);
return;
}
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true,
'save' => 'dockerfile',
'ignore_errors' => true,
]);
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
// Find all FROM instruction positions
$fromLines = $this->findFromInstructionLines($dockerfile);
// If no FROM instructions found, skip ARG insertion
if (empty($fromLines)) {
return;
}
// Collect all ARG statements to insert
$argsToInsert = collect();
if ($this->pull_request_id === 0) {
// Only add environment variables that are available during build
$envs = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
}
}
// Add Coolify variables as ARGs
if ($this->coolify_variables) {
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter()
->map(function ($var) {
return "ARG {$var}";
});
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
} else {
$this->execute_remote_command([
// Only add preview environment variables that are available during build
$envs = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
}
}
// Add Coolify variables as ARGs
if ($this->coolify_variables) {
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter()
->map(function ($var) {
return "ARG {$var}";
});
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
}
// Development logging to show what ARGs are being injected
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
$this->application_deployment_queue->addLogEntry('[DEBUG] Dockerfile ARG Injection');
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
$this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToInsert->count());
foreach ($argsToInsert as $arg) {
// Only show ARG key, not the value (for security)
$argKey = str($arg)->after('ARG ')->before('=')->toString();
$this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}");
}
}
// Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers)
if ($argsToInsert->isNotEmpty()) {
foreach (array_reverse($fromLines) as $fromLineIndex) {
// Insert all ARGs after this FROM instruction
foreach ($argsToInsert->reverse() as $arg) {
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
}
}
$envs_mapped = $envs->mapWithKeys(function ($env) {
return [$env->key => $env->real_value];
});
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
$this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true,
'save' => 'dockerfile',
'ignore_errors' => true,
]);
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
// Find all FROM instruction positions
$fromLines = $this->findFromInstructionLines($dockerfile);
// If no FROM instructions found, skip ARG insertion
if (empty($fromLines)) {
return;
}
// Collect all ARG statements to insert
$argsToInsert = collect();
if ($this->pull_request_id === 0) {
// Only add environment variables that are available during build
$envs = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
}
}
// Add Coolify variables as ARGs
if ($this->coolify_variables) {
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter()
->map(function ($var) {
return "ARG {$var}";
});
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
} else {
// Only add preview environment variables that are available during build
$envs = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
if (data_get($env, 'is_multiline') === true) {
$argsToInsert->push("ARG {$env->key}");
} else {
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
}
}
// Add Coolify variables as ARGs
if ($this->coolify_variables) {
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter()
->map(function ($var) {
return "ARG {$var}";
});
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
}
// Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers)
if ($argsToInsert->isNotEmpty()) {
foreach (array_reverse($fromLines) as $fromLineIndex) {
// Insert all ARGs after this FROM instruction
foreach ($argsToInsert->reverse() as $arg) {
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
}
}
$envs_mapped = $envs->mapWithKeys(function ($env) {
return [$env->key => $env->real_value];
});
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
}
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
$this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true,
'ignore_errors' => true,
]);
}
}
private function modify_dockerfile_for_secrets($dockerfile_path)
@ -3503,6 +3599,13 @@ private function modify_dockerfiles_for_compose($composeFile)
return;
}
// Skip ARG injection if disabled by user - preserves Docker build cache
if ($this->application->settings->inject_build_args_to_dockerfile === false) {
$this->application_deployment_queue->addLogEntry('Skipping Docker Compose Dockerfile ARG injection (disabled in settings).', hidden: true);
return;
}
// Generate env variables if not already done
// This populates $this->env_args with both user-defined and COOLIFY_* variables
if (! $this->env_args || $this->env_args->isEmpty()) {
@ -3593,6 +3696,18 @@ private function modify_dockerfiles_for_compose($composeFile)
continue;
}
// Development logging to show what ARGs are being injected for Docker Compose
if (isDev()) {
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
$this->application_deployment_queue->addLogEntry("[DEBUG] Docker Compose ARG Injection - Service: {$serviceName}");
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
$this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToAdd->count());
foreach ($argsToAdd as $arg) {
$argKey = str($arg)->after('ARG ')->toString();
$this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}");
}
}
$totalAdded = 0;
$offset = 0;

View file

@ -10,6 +10,7 @@
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
{
@ -22,20 +23,60 @@ public function handle(): void
return;
}
$settings = instanceSettings();
$response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json');
$response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url'));
if ($response->successful()) {
$versions = $response->json();
$latest_version = data_get($versions, 'coolify.v4.version');
$current_version = config('constants.coolify.version');
// Read existing cached version
$existingVersions = null;
$existingCoolifyVersion = null;
if (File::exists(base_path('versions.json'))) {
$existingVersions = json_decode(File::get(base_path('versions.json')), true);
$existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version');
}
// Determine the BEST version to use (CDN, cache, or current)
$bestVersion = $latest_version;
// Check if cache has newer version than CDN
if ($existingCoolifyVersion && version_compare($existingCoolifyVersion, $bestVersion, '>')) {
Log::warning('CDN served older Coolify version than cache', [
'cdn_version' => $latest_version,
'cached_version' => $existingCoolifyVersion,
'current_version' => $current_version,
]);
$bestVersion = $existingCoolifyVersion;
}
// CRITICAL: Never allow bestVersion to be older than currently running version
if (version_compare($bestVersion, $current_version, '<')) {
Log::warning('Version downgrade prevented in CheckForUpdatesJob', [
'cdn_version' => $latest_version,
'cached_version' => $existingCoolifyVersion,
'current_version' => $current_version,
'attempted_best' => $bestVersion,
'using' => $current_version,
]);
$bestVersion = $current_version;
}
// Use data_set() for safe mutation (fixes #3)
data_set($versions, 'coolify.v4.version', $bestVersion);
$latest_version = $bestVersion;
// ALWAYS write versions.json (for Sentinel, Helper, Traefik updates)
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
// Invalidate cache to ensure fresh data is loaded
invalidate_versions_cache();
// Only mark new version available if Coolify version actually increased
if (version_compare($latest_version, $current_version, '>')) {
// New version available
$settings->update(['new_version_available' => true]);
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
// Invalidate cache to ensure fresh data is loaded
invalidate_versions_cache();
} else {
$settings->update(['new_version_available' => false]);
}

View file

@ -21,7 +21,7 @@ public function __construct() {}
public function handle(): void
{
try {
$response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json');
$response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url'));
if ($response->successful()) {
$versions = $response->json();
$settings = instanceSettings();

View file

@ -90,5 +90,22 @@ public function failed(?\Throwable $exception): void
'failed_at' => now()->toIso8601String(),
]);
$this->activity->save();
// Dispatch cleanup event on failure (same as on success)
if ($this->call_event_on_finish) {
try {
$eventClass = "App\\Events\\$this->call_event_on_finish";
if (! is_null($this->call_event_data)) {
event(new $eventClass($this->call_event_data));
} else {
event(new $eventClass($this->activity->causer_id));
}
Log::info('Cleanup event dispatched after job failure', [
'event' => $this->call_event_on_finish,
]);
} catch (\Throwable $e) {
Log::error('Error dispatching cleanup event on failure: '.$e->getMessage());
}
}
}
}

View file

@ -489,17 +489,22 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
$collectionsToExclude = collect();
}
$commands[] = 'mkdir -p '.$this->backup_dir;
// Validate and escape database name to prevent command injection
validateShellSafePath($databaseName, 'database name');
$escapedDatabaseName = escapeshellarg($databaseName);
if ($collectionsToExclude->count() === 0) {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --archive > $this->backup_location";
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
}
} else {
if (str($this->database->image)->startsWith('mongo:4')) {
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
}
}
}
@ -525,7 +530,10 @@ private function backup_standalone_postgresql(string $database): void
if ($this->backup->dump_all) {
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
} else {
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $database > $this->backup_location";
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location";
}
$commands[] = $backupCommand;
@ -547,7 +555,10 @@ private function backup_standalone_mysql(string $database): void
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $database > $this->backup_location";
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server);
$this->backup_output = trim($this->backup_output);
@ -567,7 +578,10 @@ private function backup_standalone_mariadb(string $database): void
if ($this->backup->dump_all) {
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
} else {
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $database > $this->backup_location";
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server);
$this->backup_output = trim($this->backup_output);
@ -639,7 +653,13 @@ private function upload_to_s3(): void
} else {
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
}
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
// Escape S3 credentials to prevent command injection
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
instant_remote_process($commands, $this->server);

View file

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

View file

@ -168,6 +168,9 @@ public function handle(): void
if (! $this->server->isBuildServer()) {
$proxyShouldRun = CheckProxy::run($this->server, true);
if ($proxyShouldRun) {
// Ensure networks exist BEFORE dispatching async proxy startup
// This prevents race condition where proxy tries to start before networks are created
instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
StartProxy::dispatch($this->server);
}
}

View file

@ -10,7 +10,7 @@ class ActivityMonitor extends Component
{
public ?string $header = null;
public $activityId;
public $activityId = null;
public $eventToDispatch = 'activityFinished';
@ -49,9 +49,24 @@ public function newMonitorActivity($activityId, $eventToDispatch = 'activityFini
public function hydrateActivity()
{
if ($this->activityId === null) {
$this->activity = null;
return;
}
$this->activity = Activity::find($this->activityId);
}
public function updatedActivityId($value)
{
if ($value) {
$this->hydrateActivity();
$this->isPollingActive = true;
self::$eventDispatched = false;
}
}
public function polling()
{
$this->hydrateActivity();

View file

@ -37,6 +37,12 @@ class Advanced extends Component
#[Validate(['boolean'])]
public bool $disableBuildCache = false;
#[Validate(['boolean'])]
public bool $injectBuildArgsToDockerfile = true;
#[Validate(['boolean'])]
public bool $includeSourceCommitInBuild = false;
#[Validate(['boolean'])]
public bool $isLogDrainEnabled = false;
@ -110,6 +116,8 @@ public function syncData(bool $toModel = false)
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
$this->application->settings->connect_to_docker_network = $this->isConnectToDockerNetworkEnabled;
$this->application->settings->disable_build_cache = $this->disableBuildCache;
$this->application->settings->inject_build_args_to_dockerfile = $this->injectBuildArgsToDockerfile;
$this->application->settings->include_source_commit_in_build = $this->includeSourceCommitInBuild;
$this->application->settings->save();
} else {
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
@ -134,6 +142,8 @@ public function syncData(bool $toModel = false)
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
$this->disableBuildCache = $this->application->settings->disable_build_cache;
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
}
}

View file

@ -278,7 +278,7 @@ private function stopContainers(array $containers, $server)
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
"docker stop --time=30 $containerName",
"docker stop -t 30 $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}

View file

@ -107,6 +107,25 @@ public function syncData(bool $toModel = false)
$this->backup->save_s3 = $this->saveS3;
$this->backup->disable_local_backup = $this->disableLocalBackup;
$this->backup->s3_storage_id = $this->s3StorageId;
// Validate databases_to_backup to prevent command injection
if (filled($this->databasesToBackup)) {
$databases = str($this->databasesToBackup)->explode(',');
foreach ($databases as $index => $db) {
$dbName = trim($db);
try {
validateShellSafePath($dbName, 'database name');
} catch (\Exception $e) {
// Provide specific error message indicating which database failed validation
$position = $index + 1;
throw new \Exception(
"Database #{$position} ('{$dbName}') validation failed: ".
$e->getMessage()
);
}
}
}
$this->backup->databases_to_backup = $this->databasesToBackup;
$this->backup->dump_all = $this->dumpAll;
$this->backup->timeout = $this->timeout;

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Project\Database;
use App\Models\S3Storage;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
@ -12,6 +13,92 @@ class Import extends Component
{
use AuthorizesRequests;
/**
* Validate that a string is safe for use as an S3 bucket name.
* Allows alphanumerics, dots, dashes, and underscores.
*/
private function validateBucketName(string $bucket): bool
{
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
}
/**
* Validate that a string is safe for use as an S3 path.
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
*/
private function validateS3Path(string $path): bool
{
// Must not be empty
if (empty($path)) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
}
/**
* Validate that a string is safe for use as a file path on the server.
*/
private function validateServerPath(string $path): bool
{
// Must be an absolute path
if (! str_starts_with($path, '/')) {
return false;
}
// Must not contain dangerous shell metacharacters or command injection patterns
$dangerousPatterns = [
'..', // Directory traversal
'$(', // Command substitution
'`', // Backtick command substitution
'|', // Pipe
';', // Command separator
'&', // Background/AND
'>', // Redirect
'<', // Redirect
"\n", // Newline
"\r", // Carriage return
"\0", // Null byte
"'", // Single quote
'"', // Double quote
'\\', // Backslash
];
foreach ($dangerousPatterns as $pattern) {
if (str_contains($path, $pattern)) {
return false;
}
}
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
}
public bool $unsupported = false;
public $resource;
@ -46,6 +133,8 @@ class Import extends Component
public string $customLocation = '';
public ?int $activityId = null;
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
@ -54,22 +143,35 @@ class Import extends Component
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
// S3 Restore properties
public $availableS3Storages = [];
public ?int $s3StorageId = null;
public string $s3Path = '';
public ?int $s3FileSize = null;
public function getListeners()
{
$userId = Auth::id();
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'slideOverClosed' => 'resetActivityId',
];
}
public function resetActivityId()
{
$this->activityId = null;
}
public function mount()
{
if (isDev()) {
$this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz';
}
$this->parameters = get_route_parameters();
$this->getContainers();
$this->loadAvailableS3Storages();
}
public function updatedDumpAll($value)
@ -152,8 +254,16 @@ public function getContainers()
public function checkFile()
{
if (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
try {
$result = instant_remote_process(["ls -l {$this->customLocation}"], $this->server, throwError: false);
$escapedPath = escapeshellarg($this->customLocation);
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
if (blank($result)) {
$this->dispatch('error', 'The file does not exist or has been deleted.');
@ -179,59 +289,35 @@ public function runImport()
try {
$this->importRunning = true;
$this->importCommands = [];
if (filled($this->customLocation)) {
$backupFileName = '/tmp/restore_'.$this->resource->uuid;
$this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$backupFileName}";
$tmpPath = $backupFileName;
} else {
$backupFileName = "upload/{$this->resource->uuid}/restore";
$path = Storage::path($backupFileName);
if (! Storage::exists($backupFileName)) {
$this->dispatch('error', 'The file does not exist or has been deleted.');
$backupFileName = "upload/{$this->resource->uuid}/restore";
return;
}
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
return;
}
$tmpPath = '/tmp/restore_'.$this->resource->uuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
return;
}
// Copy the restore command to a script file
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
switch ($this->resource->getMorphClass()) {
case \App\Models\StandaloneMariadb::class:
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandaloneMysql::class:
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandalonePostgresql::class:
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
} else {
$restoreCommand .= " {$tmpPath}";
}
break;
case \App\Models\StandaloneMongodb::class:
$restoreCommand = $this->mongodbRestoreCommand;
if ($this->dumpAll === false) {
$restoreCommand .= "{$tmpPath}";
}
break;
}
$restoreCommand = $this->buildRestoreCommand($tmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
@ -248,7 +334,13 @@ public function runImport()
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
}
} catch (\Throwable $e) {
return handleError($e, $this);
@ -257,4 +349,267 @@ public function runImport()
$this->importCommands = [];
}
}
public function loadAvailableS3Storages()
{
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
->where('is_usable', true)
->get();
} catch (\Throwable $e) {
$this->availableS3Storages = collect();
}
}
public function updatedS3Path($value)
{
// Reset validation state when path changes
$this->s3FileSize = null;
// Ensure path starts with a slash
if ($value !== null && $value !== '') {
$this->s3Path = str($value)->trim()->start('/')->value();
}
}
public function updatedS3StorageId()
{
// Reset validation state when storage changes
$this->s3FileSize = null;
}
public function checkS3File()
{
if (! $this->s3StorageId) {
$this->dispatch('error', 'Please select an S3 storage.');
return;
}
if (blank($this->s3Path)) {
$this->dispatch('error', 'Please provide an S3 path.');
return;
}
// Clean the path (remove leading slash if present)
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path early to prevent command injection in subsequent operations
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
try {
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
}
// Test connection
$s3Storage->testConnection();
// Build S3 disk configuration
$disk = Storage::build([
'driver' => 's3',
'region' => $s3Storage->region,
'key' => $s3Storage->key,
'secret' => $s3Storage->secret,
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
]);
// Check if file exists
if (! $disk->exists($cleanPath)) {
$this->dispatch('error', 'File not found in S3. Please check the path.');
return;
}
// Get file size
$this->s3FileSize = $disk->size($cleanPath);
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
} catch (\Throwable $e) {
$this->s3FileSize = null;
return handleError($e, $this);
}
}
public function restoreFromS3()
{
$this->authorize('update', $this->resource);
if (! $this->s3StorageId || blank($this->s3Path)) {
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
return;
}
if (is_null($this->s3FileSize)) {
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
return;
}
try {
$this->importRunning = true;
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
$key = $s3Storage->key;
$secret = $s3Storage->secret;
$bucket = $s3Storage->bucket;
$endpoint = $s3Storage->endpoint;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
return;
}
// Clean the S3 path
$cleanPath = ltrim($this->s3Path, '/');
// Validate the S3 path to prevent command injection
if (! $this->validateS3Path($cleanPath)) {
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
return;
}
// Get helper image
$helperImage = config('constants.coolify.helper_image');
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
// Get the database destination network
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
// Generate unique names for this operation
$containerName = "s3-restore-{$this->resource->uuid}";
$helperTmpPath = '/tmp/'.basename($cleanPath);
$serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath);
$containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath);
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
// Prepare all commands in sequence
$commands = [];
// 1. Clean up any existing helper container and temp files from previous runs
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
$commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
// 2. Start helper container on the database network
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
// 3. Configure S3 access in helper container
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
// 4. Check file exists in S3 (bucket and path already validated above)
$escapedBucket = escapeshellarg($bucket);
$escapedCleanPath = escapeshellarg($cleanPath);
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
// 5. Download from S3 to helper container (progress shown by default)
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
$commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
// 8. Build and execute restore command inside database container
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
$restoreCommandBase64 = base64_encode($restoreCommand);
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
$commands[] = "chmod +x {$scriptPath}";
$commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
// 9. Execute restore and cleanup temp files immediately after completion
$commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
// Execute all commands with cleanup event (as safety net for edge cases)
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
'containerName' => $containerName,
'serverTmpPath' => $serverTmpPath,
'scriptPath' => $scriptPath,
'containerTmpPath' => $containerTmpPath,
'container' => $this->container,
'serverId' => $this->server->id,
]);
// Track the activity ID
$this->activityId = $activity->id;
// Dispatch activity to the monitor and open slide-over
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
} catch (\Throwable $e) {
$this->importRunning = false;
return handleError($e, $this);
}
}
public function buildRestoreCommand(string $tmpPath): string
{
switch ($this->resource->getMorphClass()) {
case \App\Models\StandaloneMariadb::class:
$restoreCommand = $this->mariadbRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandaloneMysql::class:
$restoreCommand = $this->mysqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
} else {
$restoreCommand .= " < {$tmpPath}";
}
break;
case \App\Models\StandalonePostgresql::class:
$restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
} else {
$restoreCommand .= " {$tmpPath}";
}
break;
case \App\Models\StandaloneMongodb::class:
$restoreCommand = $this->mongodbRestoreCommand;
if ($this->dumpAll === false) {
$restoreCommand .= "{$tmpPath}";
}
break;
default:
$restoreCommand = '';
}
return $restoreCommand;
}
}

View file

@ -328,12 +328,15 @@ public function save_init_script($script)
$configuration_dir = database_configuration_dir().'/'.$container_name;
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
$delete_command = "rm -f $old_file_path";
try {
// Validate and escape filename to prevent command injection
validateShellSafePath($oldScript['filename'], 'init script filename');
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
$escapedOldPath = escapeshellarg($old_file_path);
$delete_command = "rm -f {$escapedOldPath}";
instant_remote_process([$delete_command], $this->server);
} catch (Exception $e) {
$this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage());
$this->dispatch('error', $e->getMessage());
return;
}
@ -370,13 +373,17 @@ public function delete_init_script($script)
if ($found) {
$container_name = $this->database->uuid;
$configuration_dir = database_configuration_dir().'/'.$container_name;
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
$command = "rm -f $file_path";
try {
// Validate and escape filename to prevent command injection
validateShellSafePath($script['filename'], 'init script filename');
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
$escapedPath = escapeshellarg($file_path);
$command = "rm -f {$escapedPath}";
instant_remote_process([$command], $this->server);
} catch (Exception $e) {
$this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage());
$this->dispatch('error', $e->getMessage());
return;
}
@ -405,6 +412,16 @@ public function save_new_init_script()
'new_filename' => 'required|string',
'new_content' => 'required|string',
]);
try {
// Validate filename to prevent command injection
validateShellSafePath($this->new_filename, 'init script filename');
} catch (Exception $e) {
$this->dispatch('error', $e->getMessage());
return;
}
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
if ($found) {
$this->dispatch('error', 'Filename already exists.');

View file

@ -74,6 +74,9 @@ public function submit()
}
$service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
return redirect()->route('project.service.configuration', [
'service_uuid' => $service->uuid,
'environment_uuid' => $environment->uuid,

View file

@ -81,7 +81,7 @@ public function mount()
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') {
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
data_set($service_payload, 'connect_to_docker_network', true);
}
$service = Service::create($service_payload);
@ -104,6 +104,9 @@ public function mount()
}
$service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
return redirect()->route('project.service.configuration', [
'service_uuid' => $service->uuid,
'environment_uuid' => $environment->uuid,

View file

@ -179,6 +179,10 @@ public function submitFileStorageDirectory()
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
// Validate paths to prevent command injection
validateShellSafePath($this->file_storage_directory_source, 'storage source path');
validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
\App\Models\LocalFileVolume::create([
'fs_path' => $this->file_storage_directory_source,
'mount_path' => $this->file_storage_directory_destination,

View file

@ -2,8 +2,11 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\Environment;
use App\Models\Project;
use App\Traits\EnvironmentVariableAnalyzer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Add extends Component
@ -56,6 +59,72 @@ public function mount()
$this->problematicVariables = self::getProblematicVariablesForFrontend();
}
#[Computed]
public function availableSharedVariables(): array
{
$team = currentTeam();
$result = [
'team' => [],
'project' => [],
'environment' => [],
];
// Early return if no team
if (! $team) {
return $result;
}
// Check if user can view team variables
try {
$this->authorize('view', $team);
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view team variables
}
// Get project variables if we have a project_uuid in route
$projectUuid = data_get($this->parameters, 'project_uuid');
if ($projectUuid) {
$project = Project::where('team_id', $team->id)
->where('uuid', $projectUuid)
->first();
if ($project) {
try {
$this->authorize('view', $project);
$result['project'] = $project->environment_variables()
->pluck('key')
->toArray();
// Get environment variables if we have an environment_uuid in route
$environmentUuid = data_get($this->parameters, 'environment_uuid');
if ($environmentUuid) {
$environment = $project->environments()
->where('uuid', $environmentUuid)
->first();
if ($environment) {
try {
$this->authorize('view', $environment);
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view project variables
}
}
}
return $result;
}
public function submit()
{
$this->validate();

View file

@ -2,11 +2,14 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
class Show extends Component
@ -184,6 +187,7 @@ public function submit()
$this->serialize();
$this->syncData(true);
$this->syncData(false);
$this->dispatch('success', 'Environment variable updated.');
$this->dispatch('envsUpdated');
$this->dispatch('configurationChanged');
@ -192,6 +196,72 @@ public function submit()
}
}
#[Computed]
public function availableSharedVariables(): array
{
$team = currentTeam();
$result = [
'team' => [],
'project' => [],
'environment' => [],
];
// Early return if no team
if (! $team) {
return $result;
}
// Check if user can view team variables
try {
$this->authorize('view', $team);
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view team variables
}
// Get project variables if we have a project_uuid in route
$projectUuid = data_get($this->parameters, 'project_uuid');
if ($projectUuid) {
$project = Project::where('team_id', $team->id)
->where('uuid', $projectUuid)
->first();
if ($project) {
try {
$this->authorize('view', $project);
$result['project'] = $project->environment_variables()
->pluck('key')
->toArray();
// Get environment variables if we have an environment_uuid in route
$environmentUuid = data_get($this->parameters, 'environment_uuid');
if ($environmentUuid) {
$environment = $project->environments()
->where('uuid', $environmentUuid)
->first();
if ($environment) {
try {
$this->authorize('view', $environment);
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
// User not authorized to view project variables
}
}
}
return $result;
}
public function delete()
{
try {

View file

@ -39,7 +39,7 @@ class GetLogs extends Component
public ?bool $streamLogs = false;
public ?bool $showTimeStamps = true;
public ?bool $showTimeStamps = false;
public ?int $numberOfLines = 100;

View file

@ -10,6 +10,7 @@
use App\Models\Server;
use App\Services\ProxyDashboardCacheService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Log;
use Livewire\Component;
class Navbar extends Component
@ -72,7 +73,15 @@ public function restart()
// Check Traefik version after restart to provide immediate feedback
if ($this->server->proxyType() === ProxyTypes::TRAEFIK->value) {
CheckTraefikVersionForServerJob::dispatch($this->server, get_traefik_versions());
$traefikVersions = get_traefik_versions();
if ($traefikVersions !== null) {
CheckTraefikVersionForServerJob::dispatch($this->server, $traefikVersions);
} else {
Log::warning('Traefik version check skipped: versions.json data unavailable', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
}
}
} catch (\Throwable $e) {
return handleError($e, $this);

View file

@ -79,20 +79,19 @@ protected function getTraefikVersions(): ?array
// Load from global cached helper (Redis + filesystem)
$versionsData = get_versions_data();
$this->cachedVersionsFile = $versionsData;
if (! $versionsData) {
return null;
}
$this->cachedVersionsFile = $versionsData;
$traefikVersions = data_get($versionsData, 'traefik');
return is_array($traefikVersions) ? $traefikVersions : null;
}
public function getConfigurationFilePathProperty()
public function getConfigurationFilePathProperty(): string
{
return $this->server->proxyPath().'docker-compose.yml';
return rtrim($this->server->proxyPath(), '/').'/docker-compose.yml';
}
public function changeProxy()

View file

@ -25,13 +25,25 @@ public function delete(string $fileName)
$this->authorize('update', $this->server);
$proxy_path = $this->server->proxyPath();
$proxy_type = $this->server->proxyType();
// Decode filename: pipes are used to encode dots for Livewire property binding
// (e.g., 'my|service.yaml' -> 'my.service.yaml')
// This must happen BEFORE validation because validateShellSafePath() correctly
// rejects pipe characters as dangerous shell metacharacters
$file = str_replace('|', '.', $fileName);
// Validate filename to prevent command injection
validateShellSafePath($file, 'proxy configuration filename');
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
$this->dispatch('error', 'Cannot delete Caddyfile.');
return;
}
instant_remote_process(["rm -f {$proxy_path}/dynamic/{$file}"], $this->server);
$fullPath = "{$proxy_path}/dynamic/{$file}";
$escapedPath = escapeshellarg($fullPath);
instant_remote_process(["rm -f {$escapedPath}"], $this->server);
if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy();
}

View file

@ -41,6 +41,10 @@ public function addDynamicConfiguration()
'fileName' => 'required',
'value' => 'required',
]);
// Validate filename to prevent command injection
validateShellSafePath($this->fileName, 'proxy configuration filename');
if (data_get($this->parameters, 'server_uuid')) {
$this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first();
}
@ -65,8 +69,10 @@ public function addDynamicConfiguration()
}
$proxy_path = $this->server->proxyPath();
$file = "{$proxy_path}/dynamic/{$this->fileName}";
$escapedFile = escapeshellarg($file);
if ($this->newFile) {
$exists = instant_remote_process(["test -f $file && echo 1 || echo 0"], $this->server);
$exists = instant_remote_process(["test -f {$escapedFile} && echo 1 || echo 0"], $this->server);
if ($exists == 1) {
$this->dispatch('error', 'File already exists');
@ -80,7 +86,7 @@ public function addDynamicConfiguration()
}
$base64_value = base64_encode($this->value);
instant_remote_process([
"echo '{$base64_value}' | base64 -d | tee {$file} > /dev/null",
"echo '{$base64_value}' | base64 -d | tee {$escapedFile} > /dev/null",
], $this->server);
if ($proxy_type === 'CADDY') {
$this->server->reloadCaddy();

View file

@ -206,6 +206,9 @@ public function validateDockerVersion()
if (! $proxyShouldRun) {
return;
}
// Ensure networks exist BEFORE dispatching async proxy startup
// This prevents race condition where proxy tries to start before networks are created
instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
StartProxy::dispatch($this->server);
} else {
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');

View file

@ -44,6 +44,8 @@ class Index extends Component
public bool $forceSaveDomains = false;
public $buildActivityId = null;
public function render()
{
return view('livewire.settings.index');
@ -151,4 +153,37 @@ public function submit()
return handleError($e, $this);
}
}
public function buildHelperImage()
{
try {
if (! isDev()) {
$this->dispatch('error', 'Building helper image is only available in development mode.');
return;
}
$version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
if (empty($version)) {
$this->dispatch('error', 'Please specify a version to build.');
return;
}
$buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile .";
$activity = remote_process(
command: [$buildCommand],
server: $this->server,
type: 'build-helper-image'
);
$this->buildActivityId = $activity->id;
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('success', "Building coolify-helper:{$version}...");
} catch (\Exception $e) {
return handleError($e, $this);
}
}
}

View file

@ -5,6 +5,7 @@
use App\Models\Application;
use App\Models\Project;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Show extends Component
@ -19,7 +20,11 @@ class Show extends Component
public array $parameters;
protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey', 'environmentVariableDeleted' => '$refresh'];
public string $view = 'normal';
public ?string $variables = null;
protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
public function saveKey($data)
{
@ -39,6 +44,7 @@ public function saveKey($data)
'team_id' => currentTeam()->id,
]);
$this->environment->refresh();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -49,6 +55,120 @@ public function mount()
$this->parameters = get_route_parameters();
$this->project = Project::ownedByCurrentTeam()->where('uuid', request()->route('project_uuid'))->firstOrFail();
$this->environment = $this->project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail();
$this->getDevView();
}
public function switch()
{
$this->authorize('view', $this->environment);
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->getDevView();
}
public function getDevView()
{
$this->variables = $this->formatEnvironmentVariables($this->environment->environment_variables->sortBy('key'));
}
private function formatEnvironmentVariables($variables)
{
return $variables->map(function ($item) {
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
}
public function submit()
{
try {
$this->authorize('update', $this->environment);
$this->handleBulkSubmit();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->refreshEnvs();
}
}
private function handleBulkSubmit()
{
$variables = parseEnvFormatToArray($this->variables);
$changesMade = false;
DB::transaction(function () use ($variables, &$changesMade) {
// Delete removed variables
$deletedCount = $this->deleteRemovedVariables($variables);
if ($deletedCount > 0) {
$changesMade = true;
}
// Update or create variables
$updatedCount = $this->updateOrCreateVariables($variables);
if ($updatedCount > 0) {
$changesMade = true;
}
});
// Only dispatch success after transaction has committed
if ($changesMade) {
$this->dispatch('success', 'Environment variables updated.');
}
}
private function deleteRemovedVariables($variables)
{
$variablesToDelete = $this->environment->environment_variables()->whereNotIn('key', array_keys($variables))->get();
if ($variablesToDelete->isEmpty()) {
return 0;
}
$this->environment->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
return $variablesToDelete->count();
}
private function updateOrCreateVariables($variables)
{
$count = 0;
foreach ($variables as $key => $value) {
$found = $this->environment->environment_variables()->where('key', $key)->first();
if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) {
if ($found->value !== $value) {
$found->value = $value;
$found->save();
$count++;
}
}
} else {
$this->environment->environment_variables()->create([
'key' => $key,
'value' => $value,
'is_multiline' => false,
'is_literal' => false,
'type' => 'environment',
'team_id' => currentTeam()->id,
]);
$count++;
}
}
return $count;
}
public function refreshEnvs()
{
$this->environment->refresh();
$this->getDevView();
}
public function render()

View file

@ -4,6 +4,7 @@
use App\Models\Project;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Show extends Component
@ -12,7 +13,11 @@ class Show extends Component
public Project $project;
protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh'];
public string $view = 'normal';
public ?string $variables = null;
protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
public function saveKey($data)
{
@ -32,6 +37,7 @@ public function saveKey($data)
'team_id' => currentTeam()->id,
]);
$this->project->refresh();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -46,6 +52,114 @@ public function mount()
return redirect()->route('dashboard');
}
$this->project = $project;
$this->getDevView();
}
public function switch()
{
$this->authorize('view', $this->project);
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->getDevView();
}
public function getDevView()
{
$this->variables = $this->formatEnvironmentVariables($this->project->environment_variables->sortBy('key'));
}
private function formatEnvironmentVariables($variables)
{
return $variables->map(function ($item) {
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
}
public function submit()
{
try {
$this->authorize('update', $this->project);
$this->handleBulkSubmit();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->refreshEnvs();
}
}
private function handleBulkSubmit()
{
$variables = parseEnvFormatToArray($this->variables);
$changesMade = DB::transaction(function () use ($variables) {
// Delete removed variables
$deletedCount = $this->deleteRemovedVariables($variables);
// Update or create variables
$updatedCount = $this->updateOrCreateVariables($variables);
return $deletedCount > 0 || $updatedCount > 0;
});
if ($changesMade) {
$this->dispatch('success', 'Environment variables updated.');
}
}
private function deleteRemovedVariables($variables)
{
$variablesToDelete = $this->project->environment_variables()->whereNotIn('key', array_keys($variables))->get();
if ($variablesToDelete->isEmpty()) {
return 0;
}
$this->project->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
return $variablesToDelete->count();
}
private function updateOrCreateVariables($variables)
{
$count = 0;
foreach ($variables as $key => $value) {
$found = $this->project->environment_variables()->where('key', $key)->first();
if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) {
if ($found->value !== $value) {
$found->value = $value;
$found->save();
$count++;
}
}
} else {
$this->project->environment_variables()->create([
'key' => $key,
'value' => $value,
'is_multiline' => false,
'is_literal' => false,
'type' => 'project',
'team_id' => currentTeam()->id,
]);
$count++;
}
}
return $count;
}
public function refreshEnvs()
{
$this->project->refresh();
$this->getDevView();
}
public function render()

View file

@ -4,6 +4,7 @@
use App\Models\Team;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Index extends Component
@ -12,7 +13,11 @@ class Index extends Component
public Team $team;
protected $listeners = ['refreshEnvs' => '$refresh', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => '$refresh'];
public string $view = 'normal';
public ?string $variables = null;
protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
public function saveKey($data)
{
@ -32,6 +37,7 @@ public function saveKey($data)
'team_id' => currentTeam()->id,
]);
$this->team->refresh();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -40,6 +46,119 @@ public function saveKey($data)
public function mount()
{
$this->team = currentTeam();
$this->getDevView();
}
public function switch()
{
$this->authorize('view', $this->team);
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->getDevView();
}
public function getDevView()
{
$this->variables = $this->formatEnvironmentVariables($this->team->environment_variables->sortBy('key'));
}
private function formatEnvironmentVariables($variables)
{
return $variables->map(function ($item) {
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
}
public function submit()
{
try {
$this->authorize('update', $this->team);
$this->handleBulkSubmit();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->refreshEnvs();
}
}
private function handleBulkSubmit()
{
$variables = parseEnvFormatToArray($this->variables);
$changesMade = false;
DB::transaction(function () use ($variables, &$changesMade) {
// Delete removed variables
$deletedCount = $this->deleteRemovedVariables($variables);
if ($deletedCount > 0) {
$changesMade = true;
}
// Update or create variables
$updatedCount = $this->updateOrCreateVariables($variables);
if ($updatedCount > 0) {
$changesMade = true;
}
});
if ($changesMade) {
$this->dispatch('success', 'Environment variables updated.');
}
}
private function deleteRemovedVariables($variables)
{
$variablesToDelete = $this->team->environment_variables()->whereNotIn('key', array_keys($variables))->get();
if ($variablesToDelete->isEmpty()) {
return 0;
}
$this->team->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
return $variablesToDelete->count();
}
private function updateOrCreateVariables($variables)
{
$count = 0;
foreach ($variables as $key => $value) {
$found = $this->team->environment_variables()->where('key', $key)->first();
if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) {
if ($found->value !== $value) {
$found->value = $value;
$found->save();
$count++;
}
}
} else {
$this->team->environment_variables()->create([
'key' => $key,
'value' => $value,
'is_multiline' => false,
'is_literal' => false,
'type' => 'team',
'team_id' => currentTeam()->id,
]);
$count++;
}
}
return $count;
}
public function refreshEnvs()
{
$this->team->refresh();
$this->getDevView();
}
public function render()

View file

@ -120,9 +120,16 @@ public function testConnection()
$this->storage->testConnection(shouldSave: true);
// Update component property to reflect the new validation status
$this->isUsable = $this->storage->is_usable;
return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.');
} catch (\Throwable $e) {
$this->dispatch('error', 'Failed to create storage.', $e->getMessage());
// Refresh model and sync to get the latest state
$this->storage->refresh();
$this->isUsable = $this->storage->is_usable;
$this->dispatch('error', 'Failed to test connection.', $e->getMessage());
}
}

View file

@ -3,10 +3,13 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public $storage = null;
public function mount()
@ -15,6 +18,7 @@ public function mount()
if (! $this->storage) {
abort(404);
}
$this->authorize('view', $this->storage);
}
public function render()

View file

@ -8,8 +8,6 @@
class Upgrade extends Component
{
public bool $showProgress = false;
public bool $updateInProgress = false;
public bool $isUpgradeAvailable = false;

View file

@ -1035,7 +1035,7 @@ public function isLogDrainEnabled()
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets);
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings->use_build_secrets.$this->settings->inject_build_args_to_dockerfile.$this->settings->include_source_commit_in_build);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
} else {

View file

@ -15,6 +15,8 @@ class ApplicationSetting extends Model
'is_container_label_escape_enabled' => 'boolean',
'is_container_label_readonly_enabled' => 'boolean',
'use_build_secrets' => 'boolean',
'inject_build_args_to_dockerfile' => 'boolean',
'include_source_commit_in_build' => 'boolean',
'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean',
'is_debug_enabled' => 'boolean',

View file

@ -190,11 +190,11 @@ private function get_real_environment_variables(?string $environment_variable =
return $environment_variable;
}
foreach ($sharedEnvsFound as $sharedEnv) {
$type = str($sharedEnv)->match('/(.*?)\./');
$type = str($sharedEnv)->trim()->match('/(.*?)\./');
if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) {
continue;
}
$variable = str($sharedEnv)->match('/\.(.*)/');
$variable = str($sharedEnv)->trim()->match('/\.(.*)/');
if ($type->value() === 'environment') {
$id = $resource->environment->id;
} elseif ($type->value() === 'project') {
@ -231,7 +231,7 @@ private function set_environment_variables(?string $environment_variable = null)
$environment_variable = trim($environment_variable);
$type = str($environment_variable)->after('{{')->before('.')->value;
if (str($environment_variable)->startsWith('{{'.$type) && str($environment_variable)->endsWith('}}')) {
return encrypt((string) str($environment_variable)->replace(' ', ''));
return encrypt($environment_variable);
}
return encrypt($environment_variable);

View file

@ -61,9 +61,14 @@ public function loadStorageOnServer()
$path = $path->after('.');
$path = $workdir.$path;
}
$isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
// Validate and escape path to prevent command injection
validateShellSafePath($path, 'storage path');
$escapedPath = escapeshellarg($path);
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
$content = instant_remote_process(["cat $path"], $server, false);
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
// Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
$content = '[binary file]';
@ -91,14 +96,19 @@ public function deleteStorageOnServer()
$path = $path->after('.');
$path = $workdir.$path;
}
$isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server);
// Validate and escape path to prevent command injection
validateShellSafePath($path, 'storage path');
$escapedPath = escapeshellarg($path);
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($path && $path != '/' && $path != '.' && $path != '..') {
if ($isFile === 'OK') {
$commands->push("rm -rf $path > /dev/null 2>&1 || true");
$commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
} elseif ($isDir === 'OK') {
$commands->push("rm -rf $path > /dev/null 2>&1 || true");
$commands->push("rmdir $path > /dev/null 2>&1 || true");
$commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
$commands->push("rmdir {$escapedPath} > /dev/null 2>&1 || true");
}
}
if ($commands->count() > 0) {
@ -135,10 +145,15 @@ public function saveStorageOnServer()
$path = $path->after('.');
$path = $workdir.$path;
}
$isFile = instant_remote_process(["test -f $path && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d $path && echo OK || echo NOK"], $server);
// Validate and escape path to prevent command injection
validateShellSafePath($path, 'storage path');
$escapedPath = escapeshellarg($path);
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK' && $this->is_directory) {
$content = instant_remote_process(["cat $path"], $server, false);
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
$this->is_directory = false;
$this->content = $content;
$this->save();
@ -151,8 +166,8 @@ public function saveStorageOnServer()
throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file. <br><br>Please delete the directory on the server or mark it as directory.');
}
instant_remote_process([
"rm -fr $path",
"touch $path",
"rm -fr {$escapedPath}",
"touch {$escapedPath}",
], $server, false);
FileStorageChanged::dispatch(data_get($server, 'team_id'));
}
@ -161,19 +176,19 @@ public function saveStorageOnServer()
$chown = data_get($this, 'chown');
if ($content) {
$content = base64_encode($content);
$commands->push("echo '$content' | base64 -d | tee $path > /dev/null");
$commands->push("echo '$content' | base64 -d | tee {$escapedPath} > /dev/null");
} else {
$commands->push("touch $path");
$commands->push("touch {$escapedPath}");
}
$commands->push("chmod +x $path");
$commands->push("chmod +x {$escapedPath}");
if ($chown) {
$commands->push("chown $chown $path");
$commands->push("chown $chown {$escapedPath}");
}
if ($chmod) {
$commands->push("chmod $chmod $path");
$commands->push("chmod $chmod {$escapedPath}");
}
} elseif ($isDir === 'NOK' && $this->is_directory) {
$commands->push("mkdir -p $path > /dev/null 2>&1 || true");
$commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true");
}
return instant_remote_process($commands, $server);

View file

@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Storage;
@ -41,6 +42,19 @@ public function awsUrl()
return "{$this->endpoint}/{$this->bucket}";
}
protected function path(): Attribute
{
return Attribute::make(
set: function (?string $value) {
if ($value === null || $value === '') {
return null;
}
return str($value)->trim()->start('/')->value();
}
);
}
public function testConnection(bool $shouldSave = false)
{
try {

View file

@ -24,7 +24,8 @@ class WebhookNotificationSettings extends Model
'backup_failure_webhook_notifications',
'scheduled_task_success_webhook_notifications',
'scheduled_task_failure_webhook_notifications',
'docker_cleanup_webhook_notifications',
'docker_cleanup_success_webhook_notifications',
'docker_cleanup_failure_webhook_notifications',
'server_disk_usage_webhook_notifications',
'server_reachable_webhook_notifications',
'server_unreachable_webhook_notifications',
@ -45,7 +46,8 @@ protected function casts(): array
'backup_failure_webhook_notifications' => 'boolean',
'scheduled_task_success_webhook_notifications' => 'boolean',
'scheduled_task_failure_webhook_notifications' => 'boolean',
'docker_cleanup_webhook_notifications' => 'boolean',
'docker_cleanup_success_webhook_notifications' => 'boolean',
'docker_cleanup_failure_webhook_notifications' => 'boolean',
'server_disk_usage_webhook_notifications' => 'boolean',
'server_reachable_webhook_notifications' => 'boolean',
'server_unreachable_webhook_notifications' => 'boolean',

View file

@ -0,0 +1,25 @@
<?php
namespace App\Policies;
use App\Models\InstanceSettings;
use App\Models\User;
class InstanceSettingsPolicy
{
/**
* Determine whether the user can view the instance settings.
*/
public function view(User $user, InstanceSettings $settings): bool
{
return isInstanceAdmin();
}
/**
* Determine whether the user can update the instance settings.
*/
public function update(User $user, InstanceSettings $settings): bool
{
return isInstanceAdmin();
}
}

View file

@ -50,6 +50,9 @@ class AuthServiceProvider extends ServiceProvider
// API Token policy
\Laravel\Sanctum\PersonalAccessToken::class => \App\Policies\ApiTokenPolicy::class,
// Instance settings policy
\App\Models\InstanceSettings::class => \App\Policies\InstanceSettingsPolicy::class,
// Team policy
\App\Models\Team::class => \App\Policies\TeamPolicy::class,

View file

@ -0,0 +1,94 @@
<?php
namespace App\View\Components\Forms;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
use Visus\Cuid2\Cuid2;
class EnvVarInput extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
public array $scopeUrls = [];
public function __construct(
public ?string $id = null,
public ?string $name = null,
public ?string $type = 'text',
public ?string $value = null,
public ?string $label = null,
public bool $required = false,
public bool $disabled = false,
public bool $readonly = false,
public ?string $helper = null,
public bool $allowToPeak = true,
public string $defaultClass = 'input',
public string $autocomplete = 'off',
public ?int $minlength = null,
public ?int $maxlength = null,
public bool $autofocus = false,
public ?string $canGate = null,
public mixed $canResource = null,
public bool $autoDisable = true,
public array $availableVars = [],
public ?string $projectUuid = null,
public ?string $environmentUuid = null,
) {
// Handle authorization-based disabling
if ($this->canGate && $this->canResource && $this->autoDisable) {
$hasPermission = Gate::allows($this->canGate, $this->canResource);
if (! $hasPermission) {
$this->disabled = true;
}
}
}
public function render(): View|Closure|string
{
// Store original ID for wire:model binding (property name)
$this->modelBinding = $this->id;
if (is_null($this->id)) {
$this->id = new Cuid2;
// Don't create wire:model binding for auto-generated IDs
$this->modelBinding = 'null';
}
// Generate unique HTML ID by adding random suffix
// This prevents duplicate IDs when multiple forms are on the same page
if ($this->modelBinding && $this->modelBinding !== 'null') {
// Use original ID with random suffix for uniqueness
$uniqueSuffix = new Cuid2;
$this->htmlId = $this->modelBinding.'-'.$uniqueSuffix;
} else {
$this->htmlId = (string) $this->id;
}
if (is_null($this->name)) {
$this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id;
}
if ($this->type === 'password') {
$this->defaultClass = $this->defaultClass.' pr-[2.8rem]';
}
$this->scopeUrls = [
'team' => route('shared-variables.team.index'),
'project' => route('shared-variables.project.index'),
'environment' => $this->projectUuid && $this->environmentUuid
? route('shared-variables.environment.show', [
'project_uuid' => $this->projectUuid,
'environment_uuid' => $this->environmentUuid,
])
: route('shared-variables.environment.index'),
'default' => route('shared-variables.index'),
];
return view('components.forms.env-var-input');
}
}

View file

@ -67,4 +67,14 @@
'alpine',
];
const NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK = [
'pgadmin',
'postgresus',
];
const NEEDS_TO_DISABLE_GZIP = [
'beszel' => ['beszel'],
];
const NEEDS_TO_DISABLE_STRIPPREFIX = [
'appwrite' => ['appwrite', 'appwrite-console', 'appwrite-realtime'],
];
const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment'];

View file

@ -962,6 +962,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
'--shm-size' => 'shm_size',
'--gpus' => 'gpus',
'--hostname' => 'hostname',
'--entrypoint' => 'entrypoint',
]);
foreach ($matches as $match) {
$option = $match[1];
@ -982,6 +983,38 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
$options[$option] = array_unique($options[$option]);
}
}
if ($option === '--entrypoint') {
$value = null;
// Match --entrypoint=value or --entrypoint value
// Handle quoted strings with escaped quotes: --entrypoint "python -c \"print('hi')\""
// Pattern matches: double-quoted (with escapes), single-quoted (with escapes), or unquoted values
if (preg_match(
'/--entrypoint(?:=|\s+)(?<raw>"(?:\\\\.|[^"])*"|\'(?:\\\\.|[^\'])*\'|[^\s]+)/',
$custom_docker_run_options,
$entrypoint_matches
)) {
$rawValue = $entrypoint_matches['raw'];
// Handle double-quoted strings: strip quotes and unescape special characters
if (str_starts_with($rawValue, '"') && str_ends_with($rawValue, '"')) {
$inner = substr($rawValue, 1, -1);
// Unescape backslash sequences: \" \$ \` \\
$value = preg_replace('/\\\\(["$`\\\\])/', '$1', $inner);
} elseif (str_starts_with($rawValue, "'") && str_ends_with($rawValue, "'")) {
// Handle single-quoted strings: just strip quotes (no unescaping per shell rules)
$value = substr($rawValue, 1, -1);
} else {
// Handle unquoted values
$value = $rawValue;
}
}
if ($value && trim($value) !== '') {
$options[$option][] = $value;
$options[$option] = array_values(array_unique($options[$option]));
}
continue;
}
if (isset($match[2]) && $match[2] !== '') {
$value = $match[2];
$options[$option][] = $value;
@ -1022,6 +1055,12 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) {
$compose_options->put($mapping[$option], $value[0]);
}
} elseif ($option === '--entrypoint') {
if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) {
// Docker compose accepts entrypoint as either a string or an array
// Keep it as a string for simplicity - docker compose will handle it
$compose_options->put($mapping[$option], $value[0]);
}
} elseif ($option === '--gpus') {
$payload = [
'driver' => 'nvidia',

View file

@ -1644,9 +1644,16 @@ function serviceParser(Service $resource): Collection
if ($value && get_class($value) === \Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
$fqdn = "$fqdn$path";
$url = "$url$path";
$fqdnValueForEnv = "$fqdnValueForEnv$path";
// Only add path if it's not already present (prevents duplication on subsequent parse() calls)
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
if (! str($url)->endsWith($path)) {
$url = "$url$path";
}
if (! str($fqdnValueForEnv)->endsWith($path)) {
$fqdnValueForEnv = "$fqdnValueForEnv$path";
}
}
}

View file

@ -6,6 +6,20 @@
use App\Models\Server;
use Symfony\Component\Yaml\Yaml;
/**
* Check if a network name is a Docker predefined system network.
* These networks cannot be created, modified, or managed by docker network commands.
*
* @param string $network Network name to check
* @return bool True if it's a predefined network that should be skipped
*/
function isDockerPredefinedNetwork(string $network): bool
{
// Only filter 'default' and 'host' to match existing codebase patterns
// See: bootstrap/helpers/parsers.php:891, bootstrap/helpers/shared.php:689,748
return in_array($network, ['default', 'host'], true);
}
function collectProxyDockerNetworksByServer(Server $server)
{
if (! $server->isFunctional()) {
@ -66,8 +80,12 @@ function collectDockerNetworksByServer(Server $server)
$networks->push($network);
$allNetworks->push($network);
}
$networks = collect($networks)->flatten()->unique();
$allNetworks = $allNetworks->flatten()->unique();
$networks = collect($networks)->flatten()->unique()->filter(function ($network) {
return ! isDockerPredefinedNetwork($network);
});
$allNetworks = $allNetworks->flatten()->unique()->filter(function ($network) {
return ! isDockerPredefinedNetwork($network);
});
if ($server->isSwarm()) {
if ($networks->count() === 0) {
$networks = collect(['coolify-overlay']);
@ -108,6 +126,37 @@ function connectProxyToNetworks(Server $server)
return $commands->flatten();
}
/**
* Ensures all required networks exist before docker compose up.
* This must be called BEFORE docker compose up since the compose file declares networks as external.
*
* @param Server $server The server to ensure networks on
* @return \Illuminate\Support\Collection Commands to create networks if they don't exist
*/
function ensureProxyNetworksExist(Server $server)
{
['allNetworks' => $networks] = collectDockerNetworksByServer($server);
if ($server->isSwarm()) {
$commands = $networks->map(function ($network) {
return [
"echo 'Ensuring network $network exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable $network",
];
});
} else {
$commands = $networks->map(function ($network) {
return [
"echo 'Ensuring network $network exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable $network",
];
});
}
return $commands->flatten();
}
function extractCustomProxyCommands(Server $server, string $existing_config): array
{
$custom_commands = [];
@ -188,8 +237,8 @@ function generateDefaultProxyConfiguration(Server $server, array $custom_command
$array_of_networks = collect([]);
$filtered_networks = collect([]);
$networks->map(function ($network) use ($array_of_networks, $filtered_networks) {
if ($network === 'host') {
return; // network-scoped alias is supported only for containers in user defined networks
if (isDockerPredefinedNetwork($network)) {
return; // Predefined networks cannot be used in network configuration
}
$array_of_networks[$network] = [

View file

@ -4,6 +4,7 @@
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Stringable;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
@ -339,3 +340,54 @@ function parseServiceEnvironmentVariable(string $key): array
'has_port' => $hasPort,
];
}
/**
* Apply service-specific application prerequisites after service parse.
*
* This function configures application-level settings that are required for
* specific one-click services to work correctly (e.g., disabling gzip for Beszel,
* disabling strip prefix for Appwrite services).
*
* Must be called AFTER $service->parse() since it requires applications to exist.
*
* @param Service $service The service to apply prerequisites to
*/
function applyServiceApplicationPrerequisites(Service $service): void
{
try {
// Extract service name from service name (format: "servicename-uuid")
$serviceName = str($service->name)->beforeLast('-')->value();
// Apply gzip disabling if needed
if (array_key_exists($serviceName, NEEDS_TO_DISABLE_GZIP)) {
$applicationNames = NEEDS_TO_DISABLE_GZIP[$serviceName];
foreach ($applicationNames as $applicationName) {
$application = $service->applications()->whereName($applicationName)->first();
if ($application) {
$application->is_gzip_enabled = false;
$application->save();
}
}
}
// Apply stripprefix disabling if needed
if (array_key_exists($serviceName, NEEDS_TO_DISABLE_STRIPPREFIX)) {
$applicationNames = NEEDS_TO_DISABLE_STRIPPREFIX[$serviceName];
foreach ($applicationNames as $applicationName) {
$application = $service->applications()->whereName($applicationName)->first();
if ($application) {
$application->is_stripprefix_enabled = false;
$application->save();
}
}
}
} catch (\Throwable $e) {
// Log error but don't throw - prerequisites are nice-to-have, not critical
Log::error('Failed to apply service application prerequisites', [
'service_id' => $service->id,
'service_name' => $service->name,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}

View file

@ -230,7 +230,7 @@ function get_route_parameters(): array
function get_latest_sentinel_version(): string
{
try {
$response = Http::get('https://cdn.coollabs.io/coolify/versions.json');
$response = Http::get(config('constants.coolify.versions_url'));
$versions = $response->json();
return data_get($versions, 'coolify.sentinel.version');
@ -3154,6 +3154,118 @@ function generateDockerComposeServiceName(mixed $services, int $pullRequestId =
return $collection;
}
function formatBytes(?int $bytes, int $precision = 2): string
{
if ($bytes === null || $bytes === 0) {
return '0 B';
}
// Handle negative numbers
if ($bytes < 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
$base = 1024;
$exponent = floor(log($bytes) / log($base));
$exponent = min($exponent, count($units) - 1);
$value = $bytes / pow($base, $exponent);
return round($value, $precision).' '.$units[$exponent];
}
/**
* Validates that a file path is safely within the /tmp/ directory.
* Protects against path traversal attacks by resolving the real path
* and verifying it stays within /tmp/.
*
* Note: On macOS, /tmp is often a symlink to /private/tmp, which is handled.
*/
function isSafeTmpPath(?string $path): bool
{
if (blank($path)) {
return false;
}
// URL decode to catch encoded traversal attempts
$decodedPath = urldecode($path);
// Minimum length check - /tmp/x is 6 chars
if (strlen($decodedPath) < 6) {
return false;
}
// Must start with /tmp/
if (! str($decodedPath)->startsWith('/tmp/')) {
return false;
}
// Quick check for obvious traversal attempts
if (str($decodedPath)->contains('..')) {
return false;
}
// Check for null bytes (directory traversal technique)
if (str($decodedPath)->contains("\0")) {
return false;
}
// Remove any trailing slashes for consistent validation
$normalizedPath = rtrim($decodedPath, '/');
// Normalize the path by removing redundant separators and resolving . and ..
// We'll do this manually since realpath() requires the path to exist
$parts = explode('/', $normalizedPath);
$resolvedParts = [];
foreach ($parts as $part) {
if ($part === '' || $part === '.') {
// Skip empty parts (from //) and current directory references
continue;
} elseif ($part === '..') {
// Parent directory - this should have been caught earlier but double-check
return false;
} else {
$resolvedParts[] = $part;
}
}
$resolvedPath = '/'.implode('/', $resolvedParts);
// Final check: resolved path must start with /tmp/
// And must have at least one component after /tmp/
if (! str($resolvedPath)->startsWith('/tmp/') || $resolvedPath === '/tmp') {
return false;
}
// Resolve the canonical /tmp path (handles symlinks like /tmp -> /private/tmp on macOS)
$canonicalTmpPath = realpath('/tmp');
if ($canonicalTmpPath === false) {
// If /tmp doesn't exist, something is very wrong, but allow non-existing paths
$canonicalTmpPath = '/tmp';
}
// Calculate dirname once to avoid redundant calls
$dirPath = dirname($resolvedPath);
// If the directory exists, resolve it via realpath to catch symlink attacks
if (is_dir($dirPath)) {
// For existing paths, resolve to absolute path to catch symlinks
$realDir = realpath($dirPath);
if ($realDir === false) {
return false;
}
// Check if the real directory is within /tmp (or its canonical path)
if (! str($realDir)->startsWith('/tmp') && ! str($realDir)->startsWith($canonicalTmpPath)) {
return false;
}
}
return true;
}
/**
* Transform colon-delimited status format to human-readable parentheses format.
*

View file

@ -23,24 +23,56 @@ function shouldChangeOwnership(string $path): bool
function parseCommandsByLineForSudo(Collection $commands, Server $server): array
{
$commands = $commands->map(function ($line) {
if (
! str(trim($line))->startsWith([
'cd',
'command',
'echo',
'true',
'if',
'fi',
])
) {
return "sudo $line";
$trimmedLine = trim($line);
// All bash keywords that should not receive sudo prefix
// Using word boundary matching to avoid prefix collisions (e.g., 'do' vs 'docker', 'if' vs 'ifconfig', 'fi' vs 'find')
$bashKeywords = [
'cd',
'command',
'declare',
'echo',
'export',
'local',
'readonly',
'return',
'true',
'if',
'fi',
'for',
'done',
'while',
'until',
'case',
'esac',
'select',
'then',
'else',
'elif',
'break',
'continue',
'do',
];
// Special case: comments (no collision risk with '#')
if (str_starts_with($trimmedLine, '#')) {
return $line;
}
if (str(trim($line))->startsWith('if')) {
return str_replace('if', 'if sudo', $line);
// Check all keywords with word boundary matching
// Match keyword followed by space, semicolon, or end of line
foreach ($bashKeywords as $keyword) {
if (preg_match('/^'.preg_quote($keyword, '/').'(\s|;|$)/', $trimmedLine)) {
// Special handling for 'if' - insert sudo after 'if '
if ($keyword === 'if') {
return preg_replace('/^(\s*)if\s+/', '$1if sudo ', $line);
}
return $line;
}
}
return $line;
return "sudo $line";
});
$commands = $commands->map(function ($line) use ($server) {

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0-beta.446',
'version' => '4.0.0-beta.453',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
@ -12,6 +12,9 @@
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'),
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
'releases_url' => 'https://cdn.coolify.io/releases.json',
],

View file

@ -1,47 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = DB::table('teams')->get();
foreach ($teams as $team) {
DB::table('webhook_notification_settings')->updateOrInsert(
['team_id' => $team->id],
[
'webhook_enabled' => false,
'webhook_url' => null,
'deployment_success_webhook_notifications' => false,
'deployment_failure_webhook_notifications' => true,
'status_change_webhook_notifications' => false,
'backup_success_webhook_notifications' => false,
'backup_failure_webhook_notifications' => true,
'scheduled_task_success_webhook_notifications' => false,
'scheduled_task_failure_webhook_notifications' => true,
'docker_cleanup_success_webhook_notifications' => false,
'docker_cleanup_failure_webhook_notifications' => true,
'server_disk_usage_webhook_notifications' => true,
'server_reachable_webhook_notifications' => false,
'server_unreachable_webhook_notifications' => true,
'server_patch_webhook_notifications' => false,
]
);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// We don't need to do anything in down() since the webhook_notification_settings
// table will be dropped by the create migration's down() method
}
};

View file

@ -1,36 +0,0 @@
<?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
{
// Check if table already exists (handles upgrades from v444 where this migration
// was named 2025_10_10_120000_create_cloud_init_scripts_table.php)
if (Schema::hasTable('cloud_init_scripts')) {
return;
}
Schema::create('cloud_init_scripts', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('script'); // Encrypted in the model
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cloud_init_scripts');
}
};

View file

@ -1,52 +0,0 @@
<?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
{
// Check if table already exists (handles upgrades from v444 where this migration
// was named 2025_10_10_120000_create_webhook_notification_settings_table.php)
if (Schema::hasTable('webhook_notification_settings')) {
return;
}
Schema::create('webhook_notification_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->boolean('webhook_enabled')->default(false);
$table->text('webhook_url')->nullable();
$table->boolean('deployment_success_webhook_notifications')->default(false);
$table->boolean('deployment_failure_webhook_notifications')->default(true);
$table->boolean('status_change_webhook_notifications')->default(false);
$table->boolean('backup_success_webhook_notifications')->default(false);
$table->boolean('backup_failure_webhook_notifications')->default(true);
$table->boolean('scheduled_task_success_webhook_notifications')->default(false);
$table->boolean('scheduled_task_failure_webhook_notifications')->default(true);
$table->boolean('docker_cleanup_success_webhook_notifications')->default(false);
$table->boolean('docker_cleanup_failure_webhook_notifications')->default(true);
$table->boolean('server_disk_usage_webhook_notifications')->default(true);
$table->boolean('server_reachable_webhook_notifications')->default(false);
$table->boolean('server_unreachable_webhook_notifications')->default(true);
$table->boolean('server_patch_webhook_notifications')->default(false);
$table->unique(['team_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('webhook_notification_settings');
}
};

View file

@ -0,0 +1,89 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Create table if it doesn't exist
if (! Schema::hasTable('webhook_notification_settings')) {
Schema::create('webhook_notification_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->boolean('webhook_enabled')->default(false);
$table->text('webhook_url')->nullable();
$table->boolean('deployment_success_webhook_notifications')->default(false);
$table->boolean('deployment_failure_webhook_notifications')->default(true);
$table->boolean('status_change_webhook_notifications')->default(false);
$table->boolean('backup_success_webhook_notifications')->default(false);
$table->boolean('backup_failure_webhook_notifications')->default(true);
$table->boolean('scheduled_task_success_webhook_notifications')->default(false);
$table->boolean('scheduled_task_failure_webhook_notifications')->default(true);
$table->boolean('docker_cleanup_success_webhook_notifications')->default(false);
$table->boolean('docker_cleanup_failure_webhook_notifications')->default(true);
$table->boolean('server_disk_usage_webhook_notifications')->default(true);
$table->boolean('server_reachable_webhook_notifications')->default(false);
$table->boolean('server_unreachable_webhook_notifications')->default(true);
$table->boolean('server_patch_webhook_notifications')->default(false);
$table->boolean('traefik_outdated_webhook_notifications')->default(true);
$table->unique(['team_id']);
});
}
// Populate webhook notification settings for existing teams (only if they don't already have settings)
DB::table('teams')->chunkById(100, function ($teams) {
foreach ($teams as $team) {
try {
// Check if settings already exist for this team
$exists = DB::table('webhook_notification_settings')
->where('team_id', $team->id)
->exists();
if (! $exists) {
// Only insert if no settings exist - don't overwrite existing preferences
DB::table('webhook_notification_settings')->insert([
'team_id' => $team->id,
'webhook_enabled' => false,
'webhook_url' => null,
'deployment_success_webhook_notifications' => false,
'deployment_failure_webhook_notifications' => true,
'status_change_webhook_notifications' => false,
'backup_success_webhook_notifications' => false,
'backup_failure_webhook_notifications' => true,
'scheduled_task_success_webhook_notifications' => false,
'scheduled_task_failure_webhook_notifications' => true,
'docker_cleanup_success_webhook_notifications' => false,
'docker_cleanup_failure_webhook_notifications' => true,
'server_disk_usage_webhook_notifications' => true,
'server_reachable_webhook_notifications' => false,
'server_unreachable_webhook_notifications' => true,
'server_patch_webhook_notifications' => false,
'traefik_outdated_webhook_notifications' => true,
]);
}
} catch (\Throwable $e) {
Log::error('Error creating webhook notification settings for team '.$team->id.': '.$e->getMessage());
}
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('webhook_notification_settings');
}
};

View file

@ -0,0 +1,33 @@
<?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
{
// Create table if it doesn't exist
if (! Schema::hasTable('cloud_init_scripts')) {
Schema::create('cloud_init_scripts', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('script'); // Encrypted in the model
$table->timestamps();
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cloud_init_scripts');
}
};

View file

@ -19,9 +19,13 @@ public function up(): void
$table->boolean('traefik_outdated_slack_notifications')->default(true);
});
Schema::table('webhook_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_webhook_notifications')->default(true);
});
// Only add if table exists and column doesn't exist
if (Schema::hasTable('webhook_notification_settings') &&
! Schema::hasColumn('webhook_notification_settings', 'traefik_outdated_webhook_notifications')) {
Schema::table('webhook_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_webhook_notifications')->default(true);
});
}
Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_telegram_notifications')->default(true);
@ -45,9 +49,13 @@ public function down(): void
$table->dropColumn('traefik_outdated_slack_notifications');
});
Schema::table('webhook_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_webhook_notifications');
});
// Only drop if table and column exist
if (Schema::hasTable('webhook_notification_settings') &&
Schema::hasColumn('webhook_notification_settings', 'traefik_outdated_webhook_notifications')) {
Schema::table('webhook_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_webhook_notifications');
});
}
Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_telegram_notifications');

View file

@ -0,0 +1,30 @@
<?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
{
Schema::table('application_settings', function (Blueprint $table) {
$table->boolean('inject_build_args_to_dockerfile')->default(true)->after('use_build_secrets');
$table->boolean('include_source_commit_in_build')->default(false)->after('inject_build_args_to_dockerfile');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->dropColumn('inject_build_args_to_dockerfile');
$table->dropColumn('include_source_commit_in_build');
});
}
};

View file

@ -288,9 +288,9 @@ if [ "$OS_TYPE" = 'amzn' ]; then
dnf install -y findutils >/dev/null
fi
LATEST_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $2}' | tr -d ',')
LATEST_HELPER_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $6}' | tr -d ',')
LATEST_REALTIME_VERSION=$(curl --silent $CDN/versions.json | grep -i version | xargs | awk '{print $8}' | tr -d ',')
LATEST_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $2}' | tr -d ',')
LATEST_HELPER_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $6}' | tr -d ',')
LATEST_REALTIME_VERSION=$(curl -L --silent $CDN/versions.json | grep -i version | xargs | awk '{print $8}' | tr -d ',')
if [ -z "$LATEST_HELPER_VERSION" ]; then
LATEST_HELPER_VERSION=latest
@ -705,10 +705,10 @@ else
fi
echo -e "5. Download required files from CDN. "
curl -fsSL $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml
curl -fsSL $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
curl -fsSL $CDN/.env.production -o /data/coolify/source/.env.production
curl -fsSL $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh
curl -fsSL -L $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml
curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production
curl -fsSL -L $CDN/upgrade.sh -o /data/coolify/source/upgrade.sh
echo -e "6. Setting up environment variable file"

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.445"
"version": "4.0.0-beta.453"
},
"nightly": {
"version": "4.0.0-beta.446"
"version": "4.0.0-beta.454"
},
"helper": {
"version": "1.0.12"
@ -13,7 +13,17 @@
"version": "1.0.10"
},
"sentinel": {
"version": "0.0.16"
"version": "0.0.18"
}
},
"traefik": {
"v3.6": "3.6.1",
"v3.5": "3.5.6",
"v3.4": "3.4.5",
"v3.3": "3.3.7",
"v3.2": "3.2.5",
"v3.1": "3.1.7",
"v3.0": "3.0.4",
"v2.11": "2.11.31"
}
}

View file

@ -18,6 +18,16 @@ @theme {
--color-base: #101010;
--color-warning: #fcd452;
--color-warning-50: #fefce8;
--color-warning-100: #fef9c3;
--color-warning-200: #fef08a;
--color-warning-300: #fde047;
--color-warning-400: #fcd452;
--color-warning-500: #facc15;
--color-warning-600: #ca8a04;
--color-warning-700: #a16207;
--color-warning-800: #854d0e;
--color-warning-900: #713f12;
--color-success: #22C55E;
--color-error: #dc2626;
--color-coollabs-50: #f5f0ff;

View file

@ -49,7 +49,7 @@ @utility input-sticky {
}
@utility input-sticky-active {
@apply text-black border-2 border-coollabs dark:text-white focus:bg-neutral-200 dark:focus:bg-coolgray-400 focus:border-coollabs;
@apply text-black border-2 border-coollabs dark:border-warning dark:text-white focus:bg-neutral-200 dark:focus:bg-coolgray-400 focus:border-coollabs dark:focus:border-warning;
}
/* Focus */
@ -154,7 +154,7 @@ @utility badge {
}
@utility badge-dashboard {
@apply absolute top-0 right-0 w-2.5 h-2.5 rounded-bl-full text-xs font-bold leading-none border border-neutral-200 dark:border-black;
@apply absolute top-1 right-1 w-2.5 h-2.5 rounded-full text-xs font-bold leading-none border border-neutral-200 dark:border-black;
}
@utility badge-success {
@ -229,6 +229,10 @@ @utility box-without-bg-without-border {
@apply flex p-2 transition-colors dark:hover:text-white hover:no-underline min-h-[4rem];
}
@utility coolbox {
@apply relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded-lg border border-neutral-200 dark:border-coolgray-400 hover:ring-2 dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem];
}
@utility on-box {
@apply rounded-sm hover:bg-neutral-300 dark:hover:bg-coolgray-500/20;
}

View file

@ -2,7 +2,7 @@
@php
$icons = [
'warning' => '<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>',
'warning' => '<svg class="w-5 h-5 text-warning-600 dark:text-warning-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>',
'danger' => '<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path></svg>',
@ -13,10 +13,10 @@
$colors = [
'warning' => [
'bg' => 'bg-yellow-50 dark:bg-yellow-900/30',
'border' => 'border-yellow-300 dark:border-yellow-800',
'title' => 'text-yellow-800 dark:text-yellow-300',
'text' => 'text-yellow-700 dark:text-yellow-200'
'bg' => 'bg-warning-50 dark:bg-warning-900/30',
'border' => 'border-warning-300 dark:border-warning-800',
'title' => 'text-warning-800 dark:text-warning-300',
'text' => 'text-warning-700 dark:text-warning-200'
],
'danger' => [
'bg' => 'bg-red-50 dark:bg-red-900/30',

View file

@ -0,0 +1,274 @@
<div class="w-full">
@if ($label)
<label class="flex gap-1 items-center mb-1 text-sm font-medium">{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
@if ($helper)
<x-helper :helper="$helper" />
@endif
</label>
@endif
<div class="relative" x-data="{
type: '{{ $type }}',
showDropdown: false,
suggestions: [],
selectedIndex: 0,
cursorPosition: 0,
currentScope: null,
availableScopes: ['team', 'project', 'environment'],
availableVars: @js($availableVars),
scopeUrls: @js($scopeUrls),
handleInput() {
const input = this.$refs.input;
if (!input) return;
const value = input.value || '';
this.cursorPosition = input.selectionStart || 0;
const textBeforeCursor = value.substring(0, this.cursorPosition);
const openBraces = '{' + '{';
const lastBraceIndex = textBeforeCursor.lastIndexOf(openBraces);
if (lastBraceIndex === -1) {
this.showDropdown = false;
return;
}
if (lastBraceIndex > 0 && textBeforeCursor[lastBraceIndex - 1] === '{') {
this.showDropdown = false;
return;
}
const textAfterBrace = textBeforeCursor.substring(lastBraceIndex);
const closeBraces = '}' + '}';
if (textAfterBrace.includes(closeBraces)) {
this.showDropdown = false;
return;
}
const content = textAfterBrace.substring(2).trim();
if (content === '') {
this.currentScope = null;
this.suggestions = this.availableScopes.map(scope => ({
type: 'scope',
value: scope,
display: scope
}));
this.selectedIndex = 0;
this.showDropdown = true;
} else if (content.includes('.')) {
const [scope, partial] = content.split('.');
if (!this.availableScopes.includes(scope)) {
this.showDropdown = false;
return;
}
this.currentScope = scope;
const scopeVars = this.availableVars[scope] || [];
const filtered = scopeVars.filter(v =>
v.toLowerCase().includes((partial || '').toLowerCase())
);
if (filtered.length === 0 && scopeVars.length === 0) {
this.suggestions = [];
this.showDropdown = true;
} else {
this.suggestions = filtered.map(varName => ({
type: 'variable',
value: varName,
display: `${scope}.${varName}`,
scope: scope
}));
this.selectedIndex = 0;
this.showDropdown = filtered.length > 0;
}
} else {
this.currentScope = null;
const filtered = this.availableScopes.filter(scope =>
scope.toLowerCase().includes(content.toLowerCase())
);
this.suggestions = filtered.map(scope => ({
type: 'scope',
value: scope,
display: scope
}));
this.selectedIndex = 0;
this.showDropdown = filtered.length > 0;
}
},
getScopeUrl(scope) {
return this.scopeUrls[scope] || this.scopeUrls['default'];
},
selectSuggestion(suggestion) {
const input = this.$refs.input;
if (!input) return;
const value = input.value || '';
const textBeforeCursor = value.substring(0, this.cursorPosition);
const textAfterCursor = value.substring(this.cursorPosition);
const openBraces = '{' + '{';
const lastBraceIndex = textBeforeCursor.lastIndexOf(openBraces);
if (suggestion.type === 'scope') {
const newText = value.substring(0, lastBraceIndex) +
openBraces + ' ' + suggestion.value + '.' +
textAfterCursor;
input.value = newText;
this.cursorPosition = lastBraceIndex + 3 + suggestion.value.length + 1;
this.$nextTick(() => {
input.setSelectionRange(this.cursorPosition, this.cursorPosition);
input.focus();
this.handleInput();
});
} else {
const closeBraces = '}' + '}';
const newText = value.substring(0, lastBraceIndex) +
openBraces + ' ' + suggestion.display + ' ' + closeBraces +
textAfterCursor;
input.value = newText;
this.cursorPosition = lastBraceIndex + 3 + suggestion.display.length + 3;
input.dispatchEvent(new Event('input'));
this.showDropdown = false;
this.selectedIndex = 0;
this.$nextTick(() => {
input.setSelectionRange(this.cursorPosition, this.cursorPosition);
input.focus();
});
}
},
handleKeydown(event) {
if (!this.showDropdown) return;
if (!this.suggestions || this.suggestions.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
this.selectedIndex = (this.selectedIndex + 1) % this.suggestions.length;
this.$nextTick(() => {
const el = document.getElementById('suggestion-' + this.selectedIndex);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.selectedIndex = this.selectedIndex <= 0 ? this.suggestions.length - 1 : this.selectedIndex - 1;
this.$nextTick(() => {
const el = document.getElementById('suggestion-' + this.selectedIndex);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
} else if (event.key === 'Enter' && this.showDropdown) {
event.preventDefault();
if (this.suggestions[this.selectedIndex]) {
this.selectSuggestion(this.suggestions[this.selectedIndex]);
}
} else if (event.key === 'Escape') {
this.showDropdown = false;
}
}
}"
@click.outside="showDropdown = false">
@if ($type === 'password' && $allowToPeak)
<div x-on:click="changePasswordFieldType"
class="flex absolute inset-y-0 right-0 items-center pr-2 cursor-pointer dark:hover:text-white z-10">
<svg 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 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<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>
</div>
@endif
<input
x-ref="input"
@input="handleInput()"
@keydown="handleKeydown($event)"
@click="handleInput()"
autocomplete="{{ $autocomplete }}"
{{ $attributes->merge(['class' => $defaultClass]) }}
@required($required)
@readonly($readonly)
@if ($modelBinding !== 'null')
wire:model="{{ $modelBinding }}"
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"
@endif
wire:loading.attr="disabled"
@if ($type === 'password')
:type="type"
@else
type="{{ $type }}"
@endif
@disabled($disabled)
@if ($htmlId !== 'null') id="{{ $htmlId }}" @endif
name="{{ $name }}"
placeholder="{{ $attributes->get('placeholder') }}"
@if ($autofocus) autofocus @endif>
{{-- Dropdown for suggestions --}}
<div x-show="showDropdown"
x-transition
class="absolute z-[60] w-full mt-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-400 rounded shadow-lg">
<template x-if="suggestions.length === 0 && currentScope">
<div class="px-3 py-2 text-sm text-neutral-500 dark:text-neutral-400">
<div>No shared variables found in <span class="font-semibold" x-text="currentScope"></span> scope.</div>
<a :href="getScopeUrl(currentScope)"
class="text-coollabs dark:text-warning hover:underline text-xs mt-1 inline-block"
target="_blank">
Add <span x-text="currentScope"></span> variables
</a>
</div>
</template>
<div x-show="suggestions.length > 0"
x-ref="dropdownList"
class="max-h-48 overflow-y-scroll"
style="scrollbar-width: thin;">
<template x-for="(suggestion, index) in suggestions" :key="index">
<div :id="'suggestion-' + index"
@click="selectSuggestion(suggestion)"
class="px-3 py-2 cursor-pointer hover:bg-neutral-100 dark:hover:bg-coolgray-200 flex items-center gap-2"
:class="{ 'bg-neutral-50 dark:bg-coolgray-300': index === selectedIndex }">
<template x-if="suggestion.type === 'scope'">
<span class="text-xs px-2 py-0.5 bg-coollabs/10 dark:bg-warning/10 text-coollabs dark:text-warning rounded">
SCOPE
</span>
</template>
<template x-if="suggestion.type === 'variable'">
<span class="text-xs px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded">
VAR
</span>
</template>
<span class="text-sm font-mono" x-text="suggestion.display"></span>
</div>
</template>
</div>
</div>
</div>
@if (!$label && $helper)
<x-helper :helper="$helper" />
@endif
@error($modelBinding)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>
@enderror
</div>

View file

@ -1,27 +1,22 @@
<div @class([
'transition-all duration-150 box-without-bg dark:bg-coolgray-100 bg-white group',
'hover:border-l-coollabs cursor-pointer' => !$upgrade,
'hover:border-l-red-500 cursor-not-allowed' => $upgrade,
])>
<div @class([
'coolbox group',
'!cursor-not-allowed hover:border-l-red-500' => $upgrade,
])>
<div class="flex items-center">
<div class="w-[4.5rem] h-[4.5rem] flex items-center justify-center text-black dark:text-white shrink-0">
<div class="w-[4.5rem] h-[4.5rem] flex items-center justify-center text-black dark:text-white shrink-0 rounded-lg overflow-hidden">
{{ $logo }}
</div>
<div class="flex flex-col pl-2 ">
<div class="dark:text-white text-md">
<div class="flex flex-col pl-3 space-y-1">
<div class="dark:text-white text-md font-medium">
{{ $title }}
</div>
@if ($upgrade)
<div>{{ $upgrade }}</div>
@else
<div class="text-xs font-bold dark:text-neutral-500 dark:group-hover:text-neutral-300">
<div class="text-xs dark:text-neutral-400 dark:group-hover:text-neutral-200 line-clamp-2">
{{ $description }}
</div>
@endif
</div>
</div>
@isset($documentation)
<div class="flex-1"></div>
{{ $documentation }}
@endisset
</div>

View file

@ -1,7 +1,13 @@
@props(['closeWithX' => false, 'fullScreen' => false])
<div x-data="{
slideOverOpen: false
}" {{ $attributes->merge(['class' => 'relative w-auto h-auto']) }}>
}"
x-init="$watch('slideOverOpen', value => {
if (!value) {
$dispatch('slideOverClosed')
}
})"
{{ $attributes->merge(['class' => 'relative w-auto h-auto']) }}>
{{ $slot }}
<template x-teleport="body">
<div x-show="slideOverOpen" @if (!$closeWithX) @keydown.window.escape="slideOverOpen=false" @endif

View file

@ -17,7 +17,7 @@
@if ($foundUsers->count() > 0)
<div class="flex flex-wrap gap-2 pt-4">
@foreach ($foundUsers as $user)
<div class="box w-64 group" wire:click="switchUser({{ $user->id }})">
<div class="coolbox w-64 group" wire:click="switchUser({{ $user->id }})">
<div class="flex flex-col gap-2">
<div class="box-title">{{ $user->name }}</div>
<div class="box-description">{{ $user->email }}</div>

View file

@ -35,7 +35,7 @@ class="flex items-center justify-center size-4 text-white rounded hover:bg-coolg
@if ($projects->count() > 0)
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
@foreach ($projects as $project)
<div class="relative gap-2 cursor-pointer box group">
<div class="relative gap-2 cursor-pointer coolbox group">
<a href="{{ $project->navigateTo() }}" class="absolute inset-0"></a>
<div class="flex flex-1 mx-6">
<div class="flex flex-col justify-center flex-1">
@ -103,7 +103,7 @@ class="flex items-center justify-center size-4 text-white rounded hover:bg-coolg
@foreach ($servers as $server)
<a href="{{ route('server.show', ['server_uuid' => data_get($server, 'uuid')]) }}"
@class([
'gap-2 border cursor-pointer box group',
'gap-2 border cursor-pointer coolbox group',
'border-red-500' =>
!$server->settings->is_reachable || $server->settings->force_disabled,
])>

View file

@ -17,7 +17,7 @@
@forelse ($servers as $server)
@forelse ($server->destinations() as $destination)
@if ($destination->getMorphClass() === 'App\Models\StandaloneDocker')
<a class="box group"
<a class="coolbox group"
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
<div class="flex flex-col justify-center mx-6">
<div class="box-title">{{ $destination->name }}</div>
@ -26,7 +26,7 @@
</a>
@endif
@if ($destination->getMorphClass() === 'App\Models\SwarmDocker')
<a class="box group"
<a class="coolbox group"
href="{{ route('destination.show', ['destination_uuid' => data_get($destination, 'uuid')]) }}">
<div class="flex flex-col mx-6">
<div class="box-title">{{ $destination->name }}</div>

View file

@ -328,7 +328,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
</div>
@if ($loadingServers)
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg"
<svg class="animate-spin h-5 w-5 text-warning-500" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
@ -342,7 +342,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@elseif (count($availableServers) > 0)
@foreach ($availableServers as $index => $server)
<button type="button" wire:click="selectServer({{ $server['id'] }}, true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0">
<div class="font-medium text-neutral-900 dark:text-white">
@ -359,7 +359,7 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
@endif
</div>
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
class="shrink-0 h-5 w-5 text-warning-500 dark:text-warning-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
@ -403,7 +403,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
</div>
@if ($loadingDestinations)
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg"
<svg class="animate-spin h-5 w-5 text-warning-500" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
@ -417,7 +417,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@elseif (count($availableDestinations) > 0)
@foreach ($availableDestinations as $index => $destination)
<button type="button" wire:click="selectDestination('{{ $destination['uuid'] }}', true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0">
<div class="font-medium text-neutral-900 dark:text-white">
@ -428,7 +428,7 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
class="shrink-0 h-5 w-5 text-warning-500 dark:text-warning-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
@ -472,7 +472,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
</div>
@if ($loadingProjects)
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg"
<svg class="animate-spin h-5 w-5 text-warning-500" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
@ -486,7 +486,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@elseif (count($availableProjects) > 0)
@foreach ($availableProjects as $index => $project)
<button type="button" wire:click="selectProject('{{ $project['uuid'] }}', true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0">
<div class="font-medium text-neutral-900 dark:text-white">
@ -503,7 +503,7 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
@endif
</div>
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
class="shrink-0 h-5 w-5 text-warning-500 dark:text-warning-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
@ -547,7 +547,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
</div>
@if ($loadingEnvironments)
<div class="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-coolgray-200 rounded-lg">
<svg class="animate-spin h-5 w-5 text-yellow-500" xmlns="http://www.w3.org/2000/svg"
<svg class="animate-spin h-5 w-5 text-warning-500" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
@ -561,7 +561,7 @@ class="text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:
@elseif (count($availableEnvironments) > 0)
@foreach ($availableEnvironments as $index => $environment)
<button type="button" wire:click="selectEnvironment('{{ $environment['uuid'] }}', true)"
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-b border-neutral-100 dark:border-coolgray-300 last:border-0">
<div class="flex items-center justify-between gap-3 min-h-[2.5rem]">
<div class="flex-1 min-w-0">
<div class="font-medium text-neutral-900 dark:text-white">
@ -578,7 +578,7 @@ class="search-result-item w-full text-left block px-4 py-3 min-h-[4rem] hover:bg
@endif
</div>
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400" fill="none"
class="shrink-0 h-5 w-5 text-warning-500 dark:text-warning-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
@ -616,7 +616,7 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr
@foreach ($searchResults as $result)
@if (!isset($result['is_creatable_suggestion']))
<a href="{{ $result['link'] ?? '#' }}"
class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-yellow-50 dark:focus:bg-yellow-900/20 border-transparent hover:border-coollabs focus:border-yellow-500 dark:focus:border-yellow-400">
class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-warning-50 dark:focus:bg-warning-900/20 border-transparent hover:border-coollabs focus:border-warning-500 dark:focus:border-warning-400">
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
@ -680,13 +680,13 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr
<!-- Category Items -->
@foreach ($items as $item)
<button type="button" wire:click="navigateToResource('{{ $item['type'] }}')"
class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-transparent hover:border-yellow-500 focus:border-yellow-500">
class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400" fill="none"
class="h-5 w-5 text-warning-600 dark:text-warning-400" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" />
@ -708,7 +708,7 @@ class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0">{{ $item['quickc
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
class="shrink-0 h-5 w-5 text-warning-500 dark:text-warning-400 self-center"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7" />
@ -733,7 +733,7 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr
</template>
<template x-for="(result, index) in searchResults" :key="index">
<a :href="result.link || '#'"
class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-yellow-50 dark:focus:bg-yellow-900/20 border-transparent hover:border-coollabs focus:border-yellow-500 dark:focus:border-yellow-400">
class="search-result-item block px-4 py-3 hover:bg-neutral-50 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-warning-50 dark:focus:bg-warning-900/20 border-transparent hover:border-coollabs focus:border-warning-500 dark:focus:border-warning-400">
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
@ -789,13 +789,13 @@ class="shrink-0 h-5 w-5 text-neutral-300 dark:text-neutral-600 self-center"
<template x-for="item in items" :key="item.type">
<button type="button" @click="$wire.navigateToResource(item.type)"
class="search-result-item w-full text-left block px-4 py-3 hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-colors focus:outline-none focus:bg-yellow-100 dark:focus:bg-yellow-900/30 border-transparent hover:border-yellow-500 focus:border-yellow-500">
class="search-result-item w-full text-left block px-4 py-3 hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors focus:outline-none focus:bg-warning-100 dark:focus:bg-warning-900/30 border-transparent hover:border-warning-500 focus:border-warning-500">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div
class="flex-shrink-0 w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/40 flex items-center justify-center">
class="flex-shrink-0 w-10 h-10 rounded-lg bg-warning-100 dark:bg-warning-900/40 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400"
class="h-5 w-5 text-warning-600 dark:text-warning-400"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M12 4v16m8-8H4" />
@ -818,7 +818,7 @@ class="text-xs text-neutral-500 dark:text-neutral-400 shrink-0"
</div>
</div>
<svg xmlns="http://www.w3.org/2000/svg"
class="shrink-0 h-5 w-5 text-yellow-500 dark:text-yellow-400 self-center"
class="shrink-0 h-5 w-5 text-warning-500 dark:text-warning-400 self-center"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M9 5l7 7-7 7" />

View file

@ -22,6 +22,14 @@
@endif
<x-forms.checkbox helper="Disable Docker build cache on every deployment." instantSave
id="disableBuildCache" label="Disable Build Cache" canGate="update" :canResource="$application" />
<x-forms.checkbox
helper="When enabled, Coolify automatically adds ARG statements to your Dockerfile for build-time variables. Disable this if you manage ARGs manually in your Dockerfile to preserve Docker build cache."
instantSave id="injectBuildArgsToDockerfile" label="Inject Build Args to Dockerfile" canGate="update"
:canResource="$application" />
<x-forms.checkbox
helper="When enabled, SOURCE_COMMIT (git commit hash) is available during Docker build. Disable to preserve cache across different commits - SOURCE_COMMIT will still be available at runtime."
instantSave id="includeSourceCommitInBuild" label="Include Source Commit in Build" canGate="update"
:canResource="$application" />
@if ($application->settings->is_container_label_readonly_enabled)
<x-forms.checkbox

View file

@ -386,7 +386,7 @@
@if ($this->detectedPortInfo)
@if ($this->detectedPortInfo['isEmpty'])
<div
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800">
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-warning-50 dark:bg-warning-900/20 text-warning-800 dark:text-warning-300 border border-warning-200 dark:border-warning-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
@ -402,7 +402,7 @@ class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-yellow-50 dark:bg-y
</div>
@elseif (!$this->detectedPortInfo['matches'])
<div
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800">
class="flex items-start gap-2 p-4 mb-4 text-sm rounded-lg bg-warning-50 dark:bg-warning-900/20 text-warning-800 dark:text-warning-300 border border-warning-200 dark:border-warning-800">
<svg class="w-5 h-5 shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"

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