Merge branch 'next' into feature/signoz

This commit is contained in:
🏔️ Peak
2025-08-11 13:46:10 +02:00
committed by GitHub
253 changed files with 6686 additions and 2381 deletions
+2
View File
@@ -21,7 +21,9 @@ Coolify implements **defense-in-depth security** with multiple layers of protect
- **Supported Providers**: - **Supported Providers**:
- Google OAuth - Google OAuth
- Microsoft Azure AD - Microsoft Azure AD
- Clerk
- Authentik - Authentik
- Discord
- GitHub (via GitHub Apps) - GitHub (via GitHub Apps)
- GitLab - GitLab
+1 -1
View File
@@ -90,7 +90,7 @@ alwaysApply: false
- **Purpose**: OAuth provider integration - **Purpose**: OAuth provider integration
- **Providers**: - **Providers**:
- GitHub, GitLab, Google - GitHub, GitLab, Google
- Microsoft Azure, Authentik - Microsoft Azure, Authentik, Discord, Clerk
- Custom OAuth implementations - Custom OAuth implementations
## Background Processing ## Background Processing
+201 -1
View File
@@ -2,7 +2,190 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.0.0-beta.419] - 2025-06-16 ## [unreleased]
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(bump)* Update composer deps
- *(version)* Bump Coolify version to 4.0.0-beta.420.6
## [4.0.0-beta.420.5] - 2025-07-08
### 🚀 Features
- *(scheduling)* Add command to manually run scheduled database backups and tasks with options for chunking, delays, and dry runs
### 🐛 Bug Fixes
- *(versions)* Update coolify version numbers in versions.json and constants.php to 4.0.0-beta.420.5 and 4.0.0-beta.420.6
- *(database)* Ensure internal port defaults correctly for unsupported database types in StartDatabaseProxy
### 🚜 Refactor
- *(postgresql)* Improve layout and spacing in SSL and Proxy configuration sections for better UI consistency
## [4.0.0-beta.420.4] - 2025-07-08
### 🐛 Bug Fixes
- *(service)* Update Postiz compose configuration for improved server availability
- *(install.sh)* Use IPV4_PUBLIC_IP variable in output instead of repeated curl
- *(env)* Generate literal env variables better
- *(deployment)* Update x-data initialization in deployment view for improved functionality
- *(deployment)* Enhance COOLIFY_URL and COOLIFY_FQDN variable generation for better compatibility
- *(deployment)* Improve docker-compose domain handling and environment variable generation
- *(deployment)* Refactor domain parsing and environment variable generation using Spatie URL library
- *(deployment)* Update COOLIFY_URL and COOLIFY_FQDN generation to use Spatie URL library for improved accuracy
- *(scheduling)* Change redis cleanup command frequency from hourly to weekly for better resource management
### 🚜 Refactor
- *(previews)* Streamline preview URL generation by utilizing application method
- *(application)* Adjust layout and spacing in general application view for improved UI
### 📚 Documentation
- Update changelog
- Update changelog
## [4.0.0-beta.420.3] - 2025-07-03
### 📚 Documentation
- Update changelog
## [4.0.0-beta.420.2] - 2025-07-03
### 🚀 Features
- *(template)* Added excalidraw (#6095)
- *(template)* Add excalidraw service configuration with documentation and tags
### 🐛 Bug Fixes
- *(terminal)* Ensure shell execution only uses valid shell if available in terminal command
- *(ui)* Improve destination selection description for clarity in resource segregation
- *(jobs)* Update middleware to use expireAfter for WithoutOverlapping in multiple job classes
- Removing eager loading (#6071)
- *(template)* Adjust health check interval and retries for excalidraw service
- *(ui)* Env variable settings wrong order
- *(service)* Ensure configuration changes are properly tracked and dispatched
### 🚜 Refactor
- *(ui)* Enhance project cloning interface with improved table layout for server and resource selection
- *(terminal)* Simplify command construction for SSH execution
- *(settings)* Streamline instance admin checks and initialization of settings in Livewire components
- *(policy)* Optimize team membership checks in S3StoragePolicy
- *(popup)* Improve styling and structure of the small popup component
- *(shared)* Enhance FQDN generation logic for services in newParser function
- *(redis)* Enhance CleanupRedis command with dry-run option and improved key deletion logic
- *(init)* Standardize method naming conventions and improve command structure in Init.php
- *(shared)* Improve error handling in getTopLevelNetworks function to return network name on invalid docker-compose.yml
- *(database)* Improve error handling for unsupported database types in StartDatabaseProxy
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(versions)* Bump coolify and nightly versions to 4.0.0-beta.420.3 and 4.0.0-beta.420.4 respectively
- *(versions)* Update coolify and nightly versions to 4.0.0-beta.420.4 and 4.0.0-beta.420.5 respectively
## [4.0.0-beta.420.1] - 2025-06-26
### 🐛 Bug Fixes
- *(server)* Prepend 'mux_' to UUID in muxFilename method for consistent naming
- *(ui)* Enhance terminal access messaging to clarify server functionality and terminal status
- *(database)* Proxy ssl port if ssl is enabled
### 🚜 Refactor
- *(ui)* Separate views for instance settings to separate paths to make it cleaner
- *(ui)* Remove unnecessary step3ButtonText attributes from modal confirmation components for cleaner code
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(versions)* Update Coolify versions to 4.0.0-beta.420.2 and 4.0.0-beta.420.3 in multiple files
## [4.0.0-beta.420] - 2025-06-26
### 🚀 Features
- *(service)* Add Miniflux service (#5843)
- *(service)* Add Pingvin Share service (#5969)
- *(auth)* Add Discord OAuth Provider (#5552)
- *(auth)* Add Clerk OAuth Provider (#5553)
- *(auth)* Add Zitadel OAuth Provider (#5490)
- *(core)* Set custom API rate limit (#5984)
- *(service)* Enhance service status handling and UI updates
- *(cleanup)* Add functionality to delete teams with no members or servers in CleanupStuckedResources command
- *(ui)* Add heart icon and enhance popup messaging for sponsorship support
- *(settings)* Add sponsorship popup toggle and corresponding database migration
- *(migrations)* Add optimized indexes to activity_log for improved query performance
### 🐛 Bug Fixes
- *(service)* Audiobookshelf healthcheck command (#5993)
- *(service)* Downgrade Evolution API phone version (#5977)
- *(service)* Pingvinshare-with-clamav
- *(ssh)* Scp requires square brackets for ipv6 (#6001)
- *(github)* Changing github app breaks the webhook. it does not anymore
- *(parser)* Improve FQDN generation and update environment variable handling
- *(ui)* Enhance status refresh buttons with loading indicators
- *(ui)* Update confirmation button text for stopping database and service
- *(routes)* Update middleware for deploy route to use 'api.ability:deploy'
- *(ui)* Refine API token creation form and update helper text for clarity
- *(ui)* Adjust layout of deployments section for improved alignment
- *(ui)* Adjust project grid layout and refine server border styling for better visibility
- *(ui)* Update border styling for consistency across components and enhance loading indicators
- *(ui)* Add padding to section headers in settings views for improved spacing
- *(ui)* Reduce gap between input fields in email settings for better alignment
- *(docker)* Conditionally enable gzip compression in Traefik labels based on configuration
- *(parser)* Enable gzip compression conditionally for Pocketbase images and streamline service creation logic
- *(ui)* Update padding for trademarks policy and enhance spacing in advanced settings section
- *(ui)* Correct closing tag for sponsorship link in layout popups
- *(ui)* Refine wording in sponsorship donation prompt in layout popups
- *(ui)* Update navbar icon color and enhance popup layout for sponsorship support
- *(ui)* Add target="_blank" to sponsorship links in layout popups for improved user experience
- *(models)* Refine comment wording in User model for clarity on user deletion criteria
- *(models)* Improve user deletion logic in User model to handle team member roles and prevent deletion if user is alone in root team
- *(ui)* Update wording in sponsorship prompt for clarity and engagement
- *(shared)* Refactor gzip handling for Pocketbase in newParser function for improved clarity
### 🚜 Refactor
- *(service)* Update Hoarder to their new name karakeep (#5964)
- *(service)* Karakeep naming and formatting
- *(service)* Improve miniflux
- *(core)* Rename API rate limit ENV
- *(ui)* Simplify container selection form in execute-container-command view
- *(email)* Streamline SMTP and resend settings logic for improved clarity
- *(invitation)* Rename methods for consistency and enhance invitation deletion logic
- *(user)* Streamline user deletion process and enhance team management logic
### 📚 Documentation
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(service)* Update Evolution API image to the official one (#6031)
- *(versions)* Bump coolify versions to v4.0.0-beta.420 and v4.0.0-beta.421
- *(dependencies)* Update composer dependencies to latest versions including resend-laravel to ^0.19.0 and aws-sdk-php to 3.347.0
- *(versions)* Update Coolify version to 4.0.0-beta.420.1 and add new services (karakeep, miniflux, pingvinshare) to service templates
## [4.0.0-beta.419] - 2025-06-17
### 🚀 Features ### 🚀 Features
@@ -50,6 +233,11 @@ All notable changes to this project will be documented in this file.
- *(proxy-dashboard)* Implement ProxyDashboardCacheService to manage Traefik dashboard cache; clear cache on configuration changes and proxy actions - *(proxy-dashboard)* Implement ProxyDashboardCacheService to manage Traefik dashboard cache; clear cache on configuration changes and proxy actions
- *(terminal-connection)* Enhance terminal connection handling with auto-connect feature and improved status messaging - *(terminal-connection)* Enhance terminal connection handling with auto-connect feature and improved status messaging
- *(terminal)* Implement resize handling with ResizeObserver for improved terminal responsiveness - *(terminal)* Implement resize handling with ResizeObserver for improved terminal responsiveness
- *(migration)* Add is_sentinel_enabled column to server_settings with default true
- *(seeder)* Dispatch StartProxy action for each server in ProductionSeeder
- *(seeder)* Add CheckAndStartSentinelJob dispatch for each server in ProductionSeeder
- *(seeder)* Conditionally dispatch StartProxy action based on proxy check result
- *(service)* Update Changedetection template (#5937)
### 🐛 Bug Fixes ### 🐛 Bug Fixes
@@ -121,6 +309,11 @@ All notable changes to this project will be documented in this file.
- *(terminal)* Now it should work - *(terminal)* Now it should work
- *(degraded-status)* Remove unnecessary whitespace in badge element for cleaner HTML - *(degraded-status)* Remove unnecessary whitespace in badge element for cleaner HTML
- *(routes)* Add name to security route for improved route management - *(routes)* Add name to security route for improved route management
- *(migration)* Update default value handling for is_sentinel_enabled column in server_settings
- *(seeder)* Conditionally dispatch CheckAndStartSentinelJob based on server's sentinel status
- *(service)* Disable healthcheck logging for Gotenberg (#6005)
- *(service)* Joplin volume name (#5930)
- *(server)* Update sentinelUpdatedAt assignment to use server's sentinel_updated_at property
### 💼 Other ### 💼 Other
@@ -198,6 +391,9 @@ All notable changes to this project will be documented in this file.
- *(navigation)* Remove wire:navigate directive from configuration links for cleaner HTML structure - *(navigation)* Remove wire:navigate directive from configuration links for cleaner HTML structure
- *(proxy)* Update StartProxy calls to use named parameter for async option - *(proxy)* Update StartProxy calls to use named parameter for async option
- *(clone-project)* Enhance server retrieval by including destinations and filtering out build servers - *(clone-project)* Enhance server retrieval by including destinations and filtering out build servers
- *(ui)* Terminal
- *(ui)* Remove terminal header from execute-container-command view
- *(ui)* Remove unnecessary padding from deployment, backup, and logs sections
### 📚 Documentation ### 📚 Documentation
@@ -205,6 +401,7 @@ All notable changes to this project will be documented in this file.
- *(service)* Add new docs link for zipline (#5912) - *(service)* Add new docs link for zipline (#5912)
- Update changelog - Update changelog
- Update changelog - Update changelog
- Update changelog
### 🎨 Styling ### 🎨 Styling
@@ -236,6 +433,9 @@ All notable changes to this project will be documented in this file.
- *(api)* Update API docs - *(api)* Update API docs
- *(dependencies)* Update package versions in composer.json and composer.lock for improved compatibility and performance - *(dependencies)* Update package versions in composer.json and composer.lock for improved compatibility and performance
- *(dependencies)* Update package versions in package.json and package-lock.json for improved stability and features - *(dependencies)* Update package versions in package.json and package-lock.json for improved stability and features
- *(version)* Update coolify-realtime to version 1.0.9 in docker-compose and versions files
- *(version)* Update coolify version to 4.0.0-beta.420 and nightly version to 4.0.0-beta.421
- *(service)* Changedetection remove unused code
## [4.0.0-beta.417] - 2025-05-07 ## [4.0.0-beta.417] - 2025-05-07
+87
View File
@@ -0,0 +1,87 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Coolify is an open-source, self-hostable platform for deploying applications and managing servers - an alternative to Heroku/Netlify/Vercel. It's built with Laravel (PHP) and uses Docker for containerization.
## Development Commands
### Frontend Development
- `npm run dev` - Start Vite development server for frontend assets
- `npm run build` - Build frontend assets for production
### Backend Development
- `php artisan serve` - Start Laravel development server
- `php artisan migrate` - Run database migrations
- `php artisan queue:work` - Start queue worker for background jobs
- `php artisan horizon` - Start Laravel Horizon for queue monitoring
- `php artisan tinker` - Start interactive PHP REPL
### Code Quality
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
- `./vendor/bin/pest` - Run Pest tests
## Architecture Overview
### Technology Stack
- **Backend**: Laravel 12 (PHP 8.4)
- **Frontend**: Livewire + Alpine.js + Tailwind CSS
- **Database**: PostgreSQL 15
- **Cache/Queue**: Redis 7
- **Real-time**: Soketi (WebSocket server)
- **Containerization**: Docker & Docker Compose
### Key Components
#### Core Models
- `Application` - Deployed applications with Git integration
- `Server` - Remote servers managed by Coolify
- `Service` - Docker Compose services
- `Database` - Standalone database instances (PostgreSQL, MySQL, MongoDB, Redis, etc.)
- `Team` - Multi-tenancy support
- `Project` - Grouping of environments and resources
#### Job System
- Uses Laravel Horizon for queue management
- Key jobs: `ApplicationDeploymentJob`, `ServerCheckJob`, `DatabaseBackupJob`
- `ScheduledJobManager` and `ServerResourceManager` handle job scheduling
#### Deployment Flow
1. Git webhook triggers deployment
2. `ApplicationDeploymentJob` handles build and deployment
3. Docker containers are managed on target servers
4. Proxy configuration (Nginx/Traefik) is updated
#### Server Management
- SSH-based server communication via `ExecuteRemoteCommand` trait
- Docker installation and management
- Proxy configuration generation
- Resource monitoring and cleanup
### Directory Structure
- `app/Actions/` - Domain-specific actions (Application, Database, Server, etc.)
- `app/Jobs/` - Background queue jobs
- `app/Livewire/` - Frontend components (full-stack with Livewire)
- `app/Models/` - Eloquent models
- `bootstrap/helpers/` - Helper functions for various domains
- `database/migrations/` - Database schema evolution
## Development Guidelines
### Code Organization
- Use Actions pattern for complex business logic
- Livewire components handle UI and user interactions
- Jobs handle asynchronous operations
- Traits provide shared functionality (e.g., `ExecuteRemoteCommand`)
### Testing
- Uses Pest for testing framework
- Tests located in `tests/` directory
### Deployment and Docker
- Applications are deployed using Docker containers
- Configuration generated dynamically based on application settings
- Supports multiple deployment targets and proxy configurations
+1 -1
View File
@@ -49,7 +49,7 @@ class StopApplication
} }
if ($dockerCleanup) { if ($dockerCleanup) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, false, false);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
return $e->getMessage(); return $e->getMessage();
@@ -27,6 +27,8 @@ class StartDatabaseProxy
$server = data_get($database, 'destination.server'); $server = data_get($database, 'destination.server');
$containerName = data_get($database, 'uuid'); $containerName = data_get($database, 'uuid');
$proxyContainerName = "{$database->uuid}-proxy"; $proxyContainerName = "{$database->uuid}-proxy";
$isSSLEnabled = $database->enable_ssl ?? false;
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$databaseType = $database->databaseType(); $databaseType = $database->databaseType();
$network = $database->service->uuid; $network = $database->service->uuid;
@@ -42,6 +44,12 @@ class StartDatabaseProxy
'standalone-mongodb' => 27017, 'standalone-mongodb' => 27017,
default => throw new \Exception("Unsupported database type: $databaseType"), default => throw new \Exception("Unsupported database type: $databaseType"),
}; };
if ($isSSLEnabled) {
$internalPort = match ($databaseType) {
'standalone-redis', 'standalone-keydb', 'standalone-dragonfly' => 6380,
default => $internalPort,
};
}
$configuration_dir = database_proxy_dir($database->uuid); $configuration_dir = database_proxy_dir($database->uuid);
if (isDev()) { if (isDev()) {
+2 -2
View File
@@ -18,7 +18,7 @@ class StopDatabase
{ {
use AsAction; use AsAction;
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $isDeleteOperation = false, bool $dockerCleanup = true) public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $dockerCleanup = true)
{ {
try { try {
$server = $database->destination->server; $server = $database->destination->server;
@@ -29,7 +29,7 @@ class StopDatabase
$this->stopContainer($database, $database->uuid, 30); $this->stopContainer($database, $database->uuid, 30);
if ($dockerCleanup) { if ($dockerCleanup) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, false, false);
} }
if ($database->is_public) { if ($database->is_public) {
+1 -1
View File
@@ -66,7 +66,7 @@ class CheckProxy
if ($server->id === 0) { if ($server->id === 0) {
$ip = 'host.docker.internal'; $ip = 'host.docker.internal';
} }
$portsToCheck = ['80', '443']; $portsToCheck = [];
try { try {
if ($server->proxyType() !== ProxyTypes::NONE->value) { if ($server->proxyType() !== ProxyTypes::NONE->value) {
+3 -3
View File
@@ -11,7 +11,7 @@ class CleanupDocker
public string $jobQueue = 'high'; public string $jobQueue = 'high';
public function handle(Server $server) public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false)
{ {
$settings = instanceSettings(); $settings = instanceSettings();
$realtimeImage = config('constants.coolify.realtime_image'); $realtimeImage = config('constants.coolify.realtime_image');
@@ -36,11 +36,11 @@ class CleanupDocker
"docker images --filter before=$realtimeImageWithoutPrefixVersion --filter reference=$realtimeImageWithoutPrefix | grep $realtimeImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f", "docker images --filter before=$realtimeImageWithoutPrefixVersion --filter reference=$realtimeImageWithoutPrefix | grep $realtimeImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
]; ];
if ($server->settings->delete_unused_volumes) { if ($deleteUnusedVolumes) {
$commands[] = 'docker volume prune -af'; $commands[] = 'docker volume prune -af';
} }
if ($server->settings->delete_unused_networks) { if ($deleteUnusedNetworks) {
$commands[] = 'docker network prune -f'; $commands[] = 'docker network prune -f';
} }
+1 -1
View File
@@ -29,7 +29,7 @@ class UpdateCoolify
if (! $this->server) { if (! $this->server) {
return; return;
} }
CleanupDocker::dispatch($this->server); CleanupDocker::dispatch($this->server, false, false);
$this->latestVersion = get_latest_version_of_coolify(); $this->latestVersion = get_latest_version_of_coolify();
$this->currentVersion = config('constants.coolify.version'); $this->currentVersion = config('constants.coolify.version');
if (! $manual_update) { if (! $manual_update) {
+2 -2
View File
@@ -11,7 +11,7 @@ class DeleteService
{ {
use AsAction; use AsAction;
public function handle(Service $service, bool $deleteConfigurations, bool $deleteVolumes, bool $dockerCleanup, bool $deleteConnectedNetworks) public function handle(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations, bool $dockerCleanup)
{ {
try { try {
$server = data_get($service, 'server'); $server = data_get($service, 'server');
@@ -71,7 +71,7 @@ class DeleteService
$service->forceDelete(); $service->forceDelete();
if ($dockerCleanup) { if ($dockerCleanup) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, false, false);
} }
} }
} }
+2 -1
View File
@@ -19,6 +19,7 @@ class StartService
StopService::run(service: $service, dockerCleanup: false); StopService::run(service: $service, dockerCleanup: false);
} }
$service->saveComposeConfigs(); $service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
$commands[] = 'cd '.$service->workdir(); $commands[] = 'cd '.$service->workdir();
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'"; $commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
if ($pullLatestImages) { if ($pullLatestImages) {
@@ -41,6 +42,6 @@ class StartService
} }
} }
return remote_process($commands, $service->server, type_uuid: $service->uuid); return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
} }
} }
+3 -3
View File
@@ -14,7 +14,7 @@ class StopService
public string $jobQueue = 'high'; public string $jobQueue = 'high';
public function handle(Service $service, bool $isDeleteOperation = false, bool $dockerCleanup = true) public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true)
{ {
try { try {
$server = $service->destination->server; $server = $service->destination->server;
@@ -36,11 +36,11 @@ class StopService
$this->stopContainersInParallel($containersToStop, $server); $this->stopContainersInParallel($containersToStop, $server);
} }
if ($isDeleteOperation) { if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks(); $service->deleteConnectedNetworks();
} }
if ($dockerCleanup) { if ($dockerCleanup) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, false, false);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
return $e->getMessage(); return $e->getMessage();
+251 -7
View File
@@ -7,26 +7,270 @@ use Illuminate\Support\Facades\Redis;
class CleanupRedis extends Command class CleanupRedis extends Command
{ {
protected $signature = 'cleanup:redis'; protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup}';
protected $description = 'Cleanup Redis'; protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, and related data)';
public function handle() public function handle()
{ {
$redis = Redis::connection('horizon'); $redis = Redis::connection('horizon');
$keys = $redis->keys('*');
$prefix = config('horizon.prefix'); $prefix = config('horizon.prefix');
$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;
// Get all keys with the horizon prefix
$keys = $redis->keys('*');
$totalKeys = count($keys);
$this->info("Scanning {$totalKeys} keys for cleanup...");
foreach ($keys as $key) { foreach ($keys as $key) {
$keyWithoutPrefix = str_replace($prefix, '', $key); $keyWithoutPrefix = str_replace($prefix, '', $key);
$type = $redis->command('type', [$keyWithoutPrefix]); $type = $redis->command('type', [$keyWithoutPrefix]);
// Handle hash-type keys (individual jobs)
if ($type === 5) { if ($type === 5) {
$data = $redis->command('hgetall', [$keyWithoutPrefix]); if ($this->shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)) {
$status = data_get($data, 'status'); $deletedCount++;
if ($status === 'completed') { }
$redis->command('del', [$keyWithoutPrefix]); }
// Handle other key types (metrics, lists, etc.)
else {
if ($this->shouldDeleteOtherKey($redis, $keyWithoutPrefix, $key, $dryRun)) {
$deletedCount++;
} }
} }
} }
// Clean up overlapping queues if not skipped
if (! $skipOverlapping) {
$this->info('Cleaning up overlapping queues...');
$overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
$deletedCount += $overlappingCleaned;
}
if ($dryRun) {
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
} else {
$this->info("Deleted {$deletedCount} out of {$totalKeys} keys");
}
}
private function shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)
{
$data = $redis->command('hgetall', [$keyWithoutPrefix]);
$status = data_get($data, 'status');
// Delete completed and failed jobs
if (in_array($status, ['completed', 'failed'])) {
if ($dryRun) {
$this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})");
} else {
$redis->command('del', [$keyWithoutPrefix]);
$this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})");
}
return true;
}
return false;
}
private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryRun)
{
// Clean up various Horizon data structures
$patterns = [
'recent_jobs' => 'Recent jobs list',
'failed_jobs' => 'Failed jobs list',
'completed_jobs' => 'Completed jobs list',
'job_classes' => 'Job classes metrics',
'queues' => 'Queue metrics',
'processes' => 'Process metrics',
'supervisors' => 'Supervisor data',
'metrics' => 'General metrics',
'workload' => 'Workload data',
];
foreach ($patterns as $pattern => $description) {
if (str_contains($keyWithoutPrefix, $pattern)) {
if ($dryRun) {
$this->line("Would delete {$description}: {$keyWithoutPrefix}");
} else {
$redis->command('del', [$keyWithoutPrefix]);
$this->line("Deleted {$description}: {$keyWithoutPrefix}");
}
return true;
}
}
// Clean up old timestamped data (older than 7 days)
if (preg_match('/(\d{10})/', $keyWithoutPrefix, $matches)) {
$timestamp = (int) $matches[1];
$weekAgo = now()->subDays(7)->timestamp;
if ($timestamp < $weekAgo) {
if ($dryRun) {
$this->line("Would delete old timestamped data: {$keyWithoutPrefix}");
} else {
$redis->command('del', [$keyWithoutPrefix]);
$this->line("Deleted old timestamped data: {$keyWithoutPrefix}");
}
return true;
}
}
return false;
}
private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
{
$cleanedCount = 0;
$queueKeys = [];
// Find all queue-related keys
$allKeys = $redis->keys('*');
foreach ($allKeys as $key) {
$keyWithoutPrefix = str_replace($prefix, '', $key);
if (str_contains($keyWithoutPrefix, 'queue:') || preg_match('/queues?[:\-]/', $keyWithoutPrefix)) {
$queueKeys[] = $keyWithoutPrefix;
}
}
$this->info('Found '.count($queueKeys).' queue-related keys');
// Group queues by name pattern to find duplicates
$queueGroups = [];
foreach ($queueKeys as $queueKey) {
// Extract queue name (remove timestamps, suffixes)
$baseName = preg_replace('/[:\-]\d+$/', '', $queueKey);
$baseName = preg_replace('/[:\-](pending|reserved|delayed|processing)$/', '', $baseName);
if (! isset($queueGroups[$baseName])) {
$queueGroups[$baseName] = [];
}
$queueGroups[$baseName][] = $queueKey;
}
// Process each group for overlaps
foreach ($queueGroups as $baseName => $keys) {
if (count($keys) > 1) {
$cleanedCount += $this->deduplicateQueueGroup($redis, $baseName, $keys, $dryRun);
}
// Also check for duplicate jobs within individual queues
foreach ($keys as $queueKey) {
$cleanedCount += $this->deduplicateQueueContents($redis, $queueKey, $dryRun);
}
}
return $cleanedCount;
}
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) {
// Prefer keys without timestamps (they're usually the main queue)
$aHasTimestamp = preg_match('/\d{10}/', $a);
$bHasTimestamp = preg_match('/\d{10}/', $b);
if ($aHasTimestamp && ! $bHasTimestamp) {
return 1;
}
if (! $aHasTimestamp && $bHasTimestamp) {
return -1;
}
// If both have timestamps, prefer the newer one
if ($aHasTimestamp && $bHasTimestamp) {
preg_match('/(\d{10})/', $a, $aMatches);
preg_match('/(\d{10})/', $b, $bMatches);
return ($bMatches[1] ?? 0) <=> ($aMatches[1] ?? 0);
}
return strcmp($a, $b);
});
// Keep the first (preferred) key, remove others that are empty or redundant
$keepKey = array_shift($keys);
foreach ($keys as $redundantKey) {
$type = $redis->command('type', [$redundantKey]);
$shouldDelete = false;
if ($type === 1) { // LIST type
$length = $redis->command('llen', [$redundantKey]);
if ($length == 0) {
$shouldDelete = true;
}
} elseif ($type === 3) { // SET type
$count = $redis->command('scard', [$redundantKey]);
if ($count == 0) {
$shouldDelete = true;
}
} elseif ($type === 4) { // ZSET type
$count = $redis->command('zcard', [$redundantKey]);
if ($count == 0) {
$shouldDelete = true;
}
}
if ($shouldDelete) {
if ($dryRun) {
$this->line(" Would delete empty queue: {$redundantKey}");
} else {
$redis->command('del', [$redundantKey]);
$this->line(" Deleted empty queue: {$redundantKey}");
}
$cleanedCount++;
}
}
return $cleanedCount;
}
private function deduplicateQueueContents($redis, $queueKey, $dryRun)
{
$cleanedCount = 0;
$type = $redis->command('type', [$queueKey]);
if ($type === 1) { // LIST type - common for job queues
$length = $redis->command('llen', [$queueKey]);
if ($length > 1) {
$items = $redis->command('lrange', [$queueKey, 0, -1]);
$uniqueItems = array_unique($items);
if (count($uniqueItems) < count($items)) {
$duplicates = count($items) - count($uniqueItems);
if ($dryRun) {
$this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}");
} else {
// 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;
}
}
}
return $cleanedCount;
} }
} }
@@ -20,6 +20,7 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql; use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use App\Models\Team;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class CleanupStuckedResources extends Command class CleanupStuckedResources extends Command
@@ -36,6 +37,12 @@ class CleanupStuckedResources extends Command
private function cleanup_stucked_resources() private function cleanup_stucked_resources()
{ {
try { try {
$teams = Team::all()->filter(function ($team) {
return $team->members()->count() === 0 && $team->servers()->count() === 0;
});
foreach ($teams as $team) {
$team->delete();
}
$servers = Server::all()->filter(function ($server) { $servers = Server::all()->filter(function ($server) {
return $server->isFunctional(); return $server->isFunctional();
}); });
+38 -40
View File
@@ -36,24 +36,20 @@ class Init extends Command
$this->servers = Server::all(); $this->servers = Server::all();
if (! isCloud()) { if (! isCloud()) {
$this->send_alive_signal(); $this->sendAliveSignal();
get_public_ips(); get_public_ips();
} }
// Backward compatibility // Backward compatibility
$this->replace_slash_in_environment_name(); $this->replaceSlashInEnvironmentName();
$this->restore_coolify_db_backup(); $this->restoreCoolifyDbBackup();
$this->update_user_emails(); $this->updateUserEmails();
// //
$this->update_traefik_labels(); $this->updateTraefikLabels();
if (! isCloud() || $this->option('force-cloud')) { if (! isCloud() || $this->option('force-cloud')) {
$this->cleanup_unused_network_from_coolify_proxy(); $this->cleanupUnusedNetworkFromCoolifyProxy();
}
if (isCloud()) {
$this->cleanup_unnecessary_dynamic_proxy_configuration();
} else {
$this->cleanup_in_progress_application_deployments();
} }
$this->call('cleanup:redis'); $this->call('cleanup:redis');
$this->call('cleanup:stucked-resources'); $this->call('cleanup:stucked-resources');
@@ -66,33 +62,35 @@ class Init extends Command
if (isCloud()) { if (isCloud()) {
try { try {
$this->cleanupUnnecessaryDynamicProxyConfiguration();
$this->pullTemplatesFromCDN(); $this->pullTemplatesFromCDN();
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Could not pull templates from CDN: {$e->getMessage()}\n"; echo "Could not pull templates from CDN: {$e->getMessage()}\n";
} }
return;
} }
if (! isCloud()) { try {
try { $this->cleanupInProgressApplicationDeployments();
$this->pullTemplatesFromCDN(); $this->pullTemplatesFromCDN();
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Could not pull templates from CDN: {$e->getMessage()}\n"; echo "Could not pull templates from CDN: {$e->getMessage()}\n";
} }
try { try {
$localhost = $this->servers->where('id', 0)->first(); $localhost = $this->servers->where('id', 0)->first();
$localhost->setupDynamicProxyConfiguration(); $localhost->setupDynamicProxyConfiguration();
} catch (\Throwable $e) { } catch (\Throwable $e) {
echo "Could not setup dynamic configuration: {$e->getMessage()}\n"; echo "Could not setup dynamic configuration: {$e->getMessage()}\n";
} }
$settings = instanceSettings(); $settings = instanceSettings();
if (! is_null(config('constants.coolify.autoupdate', null))) { if (! is_null(config('constants.coolify.autoupdate', null))) {
if (config('constants.coolify.autoupdate') == true) { if (config('constants.coolify.autoupdate') == true) {
echo "Enabling auto-update\n"; echo "Enabling auto-update\n";
$settings->update(['is_auto_update_enabled' => true]); $settings->update(['is_auto_update_enabled' => true]);
} else { } else {
echo "Disabling auto-update\n"; echo "Disabling auto-update\n";
$settings->update(['is_auto_update_enabled' => false]); $settings->update(['is_auto_update_enabled' => false]);
}
} }
} }
} }
@@ -117,7 +115,7 @@ class Init extends Command
Artisan::call('optimize'); Artisan::call('optimize');
} }
private function update_user_emails() private function updateUserEmails()
{ {
try { try {
User::whereRaw('email ~ \'[A-Z]\'')->get()->each(function (User $user) { User::whereRaw('email ~ \'[A-Z]\'')->get()->each(function (User $user) {
@@ -128,7 +126,7 @@ class Init extends Command
} }
} }
private function update_traefik_labels() private function updateTraefikLabels()
{ {
try { try {
Server::where('proxy->type', 'TRAEFIK_V2')->update(['proxy->type' => 'TRAEFIK']); Server::where('proxy->type', 'TRAEFIK_V2')->update(['proxy->type' => 'TRAEFIK']);
@@ -137,7 +135,7 @@ class Init extends Command
} }
} }
private function cleanup_unnecessary_dynamic_proxy_configuration() private function cleanupUnnecessaryDynamicProxyConfiguration()
{ {
foreach ($this->servers as $server) { foreach ($this->servers as $server) {
try { try {
@@ -158,7 +156,7 @@ class Init extends Command
} }
} }
private function cleanup_unused_network_from_coolify_proxy() private function cleanupUnusedNetworkFromCoolifyProxy()
{ {
foreach ($this->servers as $server) { foreach ($this->servers as $server) {
if (! $server->isFunctional()) { if (! $server->isFunctional()) {
@@ -197,7 +195,7 @@ class Init extends Command
} }
} }
private function restore_coolify_db_backup() private function restoreCoolifyDbBackup()
{ {
if (version_compare('4.0.0-beta.179', config('constants.coolify.version'), '<=')) { if (version_compare('4.0.0-beta.179', config('constants.coolify.version'), '<=')) {
try { try {
@@ -223,7 +221,7 @@ class Init extends Command
} }
} }
private function send_alive_signal() private function sendAliveSignal()
{ {
$id = config('app.id'); $id = config('app.id');
$version = config('constants.coolify.version'); $version = config('constants.coolify.version');
@@ -241,7 +239,7 @@ class Init extends Command
} }
} }
private function cleanup_in_progress_application_deployments() private function cleanupInProgressApplicationDeployments()
{ {
// Cleanup any failed deployments // Cleanup any failed deployments
try { try {
@@ -258,7 +256,7 @@ class Init extends Command
} }
} }
private function replace_slash_in_environment_name() private function replaceSlashInEnvironmentName()
{ {
if (version_compare('4.0.0-beta.298', config('constants.coolify.version'), '<=')) { if (version_compare('4.0.0-beta.298', config('constants.coolify.version'), '<=')) {
$environments = Environment::all(); $environments = Environment::all();
@@ -0,0 +1,247 @@
<?php
namespace App\Console\Commands;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\ScheduledTaskJob;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class RunScheduledJobsManually extends Command
{
protected $signature = 'schedule:run-manual
{--type=all : Type of jobs to run (all, backups, tasks)}
{--frequency= : Filter by frequency (daily, hourly, weekly, monthly, yearly, or cron expression)}
{--chunk=5 : Number of jobs to process in each batch}
{--delay=30 : Delay in seconds between batches}
{--max= : Maximum number of jobs to process (useful for testing)}
{--dry-run : Show what would be executed without actually running jobs}';
protected $description = 'Manually run scheduled database backups and tasks when cron fails';
public function handle()
{
$type = $this->option('type');
$frequency = $this->option('frequency');
$chunkSize = (int) $this->option('chunk');
$delay = (int) $this->option('delay');
$maxJobs = $this->option('max') ? (int) $this->option('max') : null;
$dryRun = $this->option('dry-run');
$this->info('Starting manual execution of scheduled jobs...'.($dryRun ? ' (DRY RUN)' : ''));
$this->info("Type: {$type}".($frequency ? ", Frequency: {$frequency}" : '').", Chunk size: {$chunkSize}, Delay: {$delay}s".($maxJobs ? ", Max jobs: {$maxJobs}" : '').($dryRun ? ', Dry run: enabled' : ''));
if ($dryRun) {
$this->warn('DRY RUN MODE: No jobs will actually be dispatched');
}
if ($type === 'all' || $type === 'backups') {
$this->runScheduledBackups($chunkSize, $delay, $maxJobs, $dryRun, $frequency);
}
if ($type === 'all' || $type === 'tasks') {
$this->runScheduledTasks($chunkSize, $delay, $maxJobs, $dryRun, $frequency);
}
$this->info('Completed manual execution of scheduled jobs.'.($dryRun ? ' (DRY RUN)' : ''));
}
private function runScheduledBackups(int $chunkSize, int $delay, ?int $maxJobs = null, bool $dryRun = false, ?string $frequency = null): void
{
$this->info('Processing scheduled database backups...');
$query = ScheduledDatabaseBackup::where('enabled', true);
if ($frequency) {
$query->where(function ($q) use ($frequency) {
// Handle human-readable frequency strings
if (in_array($frequency, ['daily', 'hourly', 'weekly', 'monthly', 'yearly', 'every_minute'])) {
$q->where('frequency', $frequency);
} else {
// Handle cron expressions
$q->where('frequency', $frequency);
}
});
}
$scheduled_backups = $query->get();
if ($scheduled_backups->isEmpty()) {
$this->info('No enabled scheduled backups found'.($frequency ? " with frequency '{$frequency}'" : '').'.');
return;
}
$finalScheduledBackups = collect();
foreach ($scheduled_backups as $scheduled_backup) {
if (blank(data_get($scheduled_backup, 'database'))) {
$this->warn("Deleting backup {$scheduled_backup->id} - missing database");
$scheduled_backup->delete();
continue;
}
$server = $scheduled_backup->server();
if (blank($server)) {
$this->warn("Deleting backup {$scheduled_backup->id} - missing server");
$scheduled_backup->delete();
continue;
}
if ($server->isFunctional() === false) {
$this->warn("Skipping backup {$scheduled_backup->id} - server not functional");
continue;
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
$this->warn("Skipping backup {$scheduled_backup->id} - subscription not paid");
continue;
}
$finalScheduledBackups->push($scheduled_backup);
}
if ($maxJobs && $finalScheduledBackups->count() > $maxJobs) {
$finalScheduledBackups = $finalScheduledBackups->take($maxJobs);
$this->info("Limited to {$maxJobs} scheduled backups for testing");
}
$this->info("Found {$finalScheduledBackups->count()} valid scheduled backups to process".($frequency ? " with frequency '{$frequency}'" : ''));
$chunks = $finalScheduledBackups->chunk($chunkSize);
foreach ($chunks as $index => $chunk) {
$this->info('Processing backup batch '.($index + 1).' of '.$chunks->count()." ({$chunk->count()} items)");
foreach ($chunk as $scheduled_backup) {
try {
if ($dryRun) {
$this->info("🔍 Would dispatch backup job for: {$scheduled_backup->name} (ID: {$scheduled_backup->id}, Frequency: {$scheduled_backup->frequency})");
} else {
DatabaseBackupJob::dispatch($scheduled_backup);
$this->info("✓ Dispatched backup job for: {$scheduled_backup->name} (ID: {$scheduled_backup->id}, Frequency: {$scheduled_backup->frequency})");
}
} catch (\Exception $e) {
$this->error("✗ Failed to dispatch backup job for {$scheduled_backup->id}: ".$e->getMessage());
Log::error('Error dispatching backup job: '.$e->getMessage());
}
}
if ($index < $chunks->count() - 1 && ! $dryRun) {
$this->info("Waiting {$delay} seconds before next batch...");
sleep($delay);
}
}
}
private function runScheduledTasks(int $chunkSize, int $delay, ?int $maxJobs = null, bool $dryRun = false, ?string $frequency = null): void
{
$this->info('Processing scheduled tasks...');
$query = ScheduledTask::where('enabled', true);
if ($frequency) {
$query->where(function ($q) use ($frequency) {
// Handle human-readable frequency strings
if (in_array($frequency, ['daily', 'hourly', 'weekly', 'monthly', 'yearly', 'every_minute'])) {
$q->where('frequency', $frequency);
} else {
// Handle cron expressions
$q->where('frequency', $frequency);
}
});
}
$scheduled_tasks = $query->get();
if ($scheduled_tasks->isEmpty()) {
$this->info('No enabled scheduled tasks found'.($frequency ? " with frequency '{$frequency}'" : '').'.');
return;
}
$finalScheduledTasks = collect();
foreach ($scheduled_tasks as $scheduled_task) {
$service = $scheduled_task->service;
$application = $scheduled_task->application;
$server = $scheduled_task->server();
if (blank($server)) {
$this->warn("Deleting task {$scheduled_task->id} - missing server");
$scheduled_task->delete();
continue;
}
if ($server->isFunctional() === false) {
$this->warn("Skipping task {$scheduled_task->id} - server not functional");
continue;
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
$this->warn("Skipping task {$scheduled_task->id} - subscription not paid");
continue;
}
if (! $service && ! $application) {
$this->warn("Deleting task {$scheduled_task->id} - missing service and application");
$scheduled_task->delete();
continue;
}
if ($application && str($application->status)->contains('running') === false) {
$this->warn("Skipping task {$scheduled_task->id} - application not running");
continue;
}
if ($service && str($service->status)->contains('running') === false) {
$this->warn("Skipping task {$scheduled_task->id} - service not running");
continue;
}
$finalScheduledTasks->push($scheduled_task);
}
if ($maxJobs && $finalScheduledTasks->count() > $maxJobs) {
$finalScheduledTasks = $finalScheduledTasks->take($maxJobs);
$this->info("Limited to {$maxJobs} scheduled tasks for testing");
}
$this->info("Found {$finalScheduledTasks->count()} valid scheduled tasks to process".($frequency ? " with frequency '{$frequency}'" : ''));
$chunks = $finalScheduledTasks->chunk($chunkSize);
foreach ($chunks as $index => $chunk) {
$this->info('Processing task batch '.($index + 1).' of '.$chunks->count()." ({$chunk->count()} items)");
foreach ($chunk as $scheduled_task) {
try {
if ($dryRun) {
$this->info("🔍 Would dispatch task job for: {$scheduled_task->name} (ID: {$scheduled_task->id}, Frequency: {$scheduled_task->frequency})");
} else {
ScheduledTaskJob::dispatch($scheduled_task);
$this->info("✓ Dispatched task job for: {$scheduled_task->name} (ID: {$scheduled_task->id}, Frequency: {$scheduled_task->frequency})");
}
} catch (\Exception $e) {
$this->error("✗ Failed to dispatch task job for {$scheduled_task->id}: ".$e->getMessage());
Log::error('Error dispatching task job: '.$e->getMessage());
}
}
if ($index < $chunks->count() - 1 && ! $dryRun) {
$this->info("Waiting {$delay} seconds before next batch...");
sleep($delay);
}
}
}
}
+278
View File
@@ -0,0 +1,278 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class ViewScheduledLogs extends Command
{
protected $signature = 'logs:scheduled
{--lines=50 : Number of lines to display}
{--follow : Follow the log file (tail -f)}
{--date= : Specific date (Y-m-d format, defaults to today)}
{--task-name= : Filter by task name (partial match)}
{--task-id= : Filter by task ID}
{--backup-name= : Filter by backup name (partial match)}
{--backup-id= : Filter by backup ID}
{--errors : View error logs only}
{--all : View both normal and error logs}
{--hourly : Filter hourly jobs}
{--daily : Filter daily jobs}
{--weekly : Filter weekly jobs}
{--monthly : Filter monthly jobs}
{--frequency= : Filter by specific cron expression}';
protected $description = 'View scheduled backups and tasks logs with optional filtering';
public function handle()
{
$date = $this->option('date') ?: now()->format('Y-m-d');
$logPaths = $this->getLogPaths($date);
if (empty($logPaths)) {
$this->showAvailableLogFiles($date);
return;
}
$lines = $this->option('lines');
$follow = $this->option('follow');
// Build grep filters
$filters = $this->buildFilters();
$filterDescription = $this->getFilterDescription();
$logTypeDescription = $this->getLogTypeDescription();
if ($follow) {
$this->info("Following {$logTypeDescription} logs for {$date}{$filterDescription} (Press Ctrl+C to stop)...");
$this->line('');
if (count($logPaths) === 1) {
$logPath = $logPaths[0];
if ($filters) {
passthru("tail -f {$logPath} | grep -E '{$filters}'");
} else {
passthru("tail -f {$logPath}");
}
} else {
// Multiple files - use multitail or tail with process substitution
$logPathsStr = implode(' ', $logPaths);
if ($filters) {
passthru("tail -f {$logPathsStr} | grep -E '{$filters}'");
} else {
passthru("tail -f {$logPathsStr}");
}
}
} else {
$this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:");
$this->line('');
if (count($logPaths) === 1) {
$logPath = $logPaths[0];
if ($filters) {
passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'");
} else {
passthru("tail -n {$lines} {$logPath}");
}
} else {
// Multiple files - concatenate and sort by timestamp
$logPathsStr = implode(' ', $logPaths);
if ($filters) {
passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'");
} else {
passthru("tail -n {$lines} {$logPathsStr} | sort");
}
}
}
}
private function getLogPaths(string $date): array
{
$paths = [];
if ($this->option('errors')) {
// Error logs only
$errorPath = storage_path("logs/scheduled-errors-{$date}.log");
if (File::exists($errorPath)) {
$paths[] = $errorPath;
}
} elseif ($this->option('all')) {
// Both normal and error logs
$normalPath = storage_path("logs/scheduled-{$date}.log");
$errorPath = storage_path("logs/scheduled-errors-{$date}.log");
if (File::exists($normalPath)) {
$paths[] = $normalPath;
}
if (File::exists($errorPath)) {
$paths[] = $errorPath;
}
} else {
// Normal logs only (default)
$normalPath = storage_path("logs/scheduled-{$date}.log");
if (File::exists($normalPath)) {
$paths[] = $normalPath;
}
}
return $paths;
}
private function showAvailableLogFiles(string $date): void
{
$logType = $this->getLogTypeDescription();
$this->warn("No {$logType} logs found for date {$date}");
// Show available log files
$normalFiles = File::glob(storage_path('logs/scheduled-*.log'));
$errorFiles = File::glob(storage_path('logs/scheduled-errors-*.log'));
if (! empty($normalFiles) || ! empty($errorFiles)) {
$this->info('Available scheduled log files:');
if (! empty($normalFiles)) {
$this->line(' Normal logs:');
foreach ($normalFiles as $file) {
$basename = basename($file);
$this->line(" - {$basename}");
}
}
if (! empty($errorFiles)) {
$this->line(' Error logs:');
foreach ($errorFiles as $file) {
$basename = basename($file);
$this->line(" - {$basename}");
}
}
}
}
private function getLogTypeDescription(): string
{
if ($this->option('errors')) {
return 'error';
} elseif ($this->option('all')) {
return 'all';
} else {
return 'normal';
}
}
private function buildFilters(): ?string
{
$filters = [];
if ($taskName = $this->option('task-name')) {
$filters[] = '"task_name":"[^"]*'.preg_quote($taskName, '/').'[^"]*"';
}
if ($taskId = $this->option('task-id')) {
$filters[] = '"task_id":'.preg_quote($taskId, '/');
}
if ($backupName = $this->option('backup-name')) {
$filters[] = '"backup_name":"[^"]*'.preg_quote($backupName, '/').'[^"]*"';
}
if ($backupId = $this->option('backup-id')) {
$filters[] = '"backup_id":'.preg_quote($backupId, '/');
}
// Frequency filters
if ($this->option('hourly')) {
$filters[] = $this->getFrequencyPattern('hourly');
}
if ($this->option('daily')) {
$filters[] = $this->getFrequencyPattern('daily');
}
if ($this->option('weekly')) {
$filters[] = $this->getFrequencyPattern('weekly');
}
if ($this->option('monthly')) {
$filters[] = $this->getFrequencyPattern('monthly');
}
if ($frequency = $this->option('frequency')) {
$filters[] = '"frequency":"'.preg_quote($frequency, '/').'"';
}
return empty($filters) ? null : implode('|', $filters);
}
private function getFrequencyPattern(string $type): string
{
$patterns = [
'hourly' => [
'0 \* \* \* \*', // 0 * * * *
'@hourly', // @hourly
],
'daily' => [
'0 0 \* \* \*', // 0 0 * * *
'@daily', // @daily
'@midnight', // @midnight
],
'weekly' => [
'0 0 \* \* [0-6]', // 0 0 * * 0-6 (any day of week)
'@weekly', // @weekly
],
'monthly' => [
'0 0 1 \* \*', // 0 0 1 * * (first of month)
'@monthly', // @monthly
],
];
$typePatterns = $patterns[$type] ?? [];
// For grep, we need to match the frequency field in JSON
return '"frequency":"('.implode('|', $typePatterns).')"';
}
private function getFilterDescription(): string
{
$descriptions = [];
if ($taskName = $this->option('task-name')) {
$descriptions[] = "task name: {$taskName}";
}
if ($taskId = $this->option('task-id')) {
$descriptions[] = "task ID: {$taskId}";
}
if ($backupName = $this->option('backup-name')) {
$descriptions[] = "backup name: {$backupName}";
}
if ($backupId = $this->option('backup-id')) {
$descriptions[] = "backup ID: {$backupId}";
}
// Frequency filters
if ($this->option('hourly')) {
$descriptions[] = 'hourly jobs';
}
if ($this->option('daily')) {
$descriptions[] = 'daily jobs';
}
if ($this->option('weekly')) {
$descriptions[] = 'weekly jobs';
}
if ($this->option('monthly')) {
$descriptions[] = 'monthly jobs';
}
if ($frequency = $this->option('frequency')) {
$descriptions[] = "frequency: {$frequency}";
}
return empty($descriptions) ? '' : ' (filtered by '.implode(', ', $descriptions).')';
}
}
+9 -192
View File
@@ -6,23 +6,16 @@ use App\Jobs\CheckAndStartSentinelJob;
use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckHelperImageJob;
use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\DockerCleanupJob;
use App\Jobs\PullTemplatesFromCDN; use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob; use App\Jobs\RegenerateSslCertJob;
use App\Jobs\ScheduledTaskJob; use App\Jobs\ScheduledJobManager;
use App\Jobs\ServerCheckJob; use App\Jobs\ServerResourceManager;
use App\Jobs\ServerPatchCheckJob;
use App\Jobs\ServerStorageCheckJob;
use App\Jobs\UpdateCoolifyJob; use App\Jobs\UpdateCoolifyJob;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel; use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class Kernel extends ConsoleKernel class Kernel extends ConsoleKernel
@@ -52,7 +45,7 @@ class Kernel extends ConsoleKernel
} }
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly(); // $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$this->scheduleInstance->command('cleanup:redis')->hourly(); $this->scheduleInstance->command('cleanup:redis')->weekly();
if (isDev()) { if (isDev()) {
// Instance Jobs // Instance Jobs
@@ -61,10 +54,10 @@ class Kernel extends ConsoleKernel
$this->scheduleInstance->job(new CheckHelperImageJob)->everyTenMinutes()->onOneServer(); $this->scheduleInstance->job(new CheckHelperImageJob)->everyTenMinutes()->onOneServer();
// Server Jobs // Server Jobs
$this->checkResources(); $this->scheduleInstance->job(new ServerResourceManager)->everyMinute()->onOneServer();
$this->checkScheduledBackups(); // Scheduled Jobs (Backups & Tasks)
$this->checkScheduledTasks(); $this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes(); $this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
@@ -79,12 +72,12 @@ class Kernel extends ConsoleKernel
$this->scheduleUpdates(); $this->scheduleUpdates();
// Server Jobs // Server Jobs
$this->checkResources(); $this->scheduleInstance->job(new ServerResourceManager)->everyMinute()->onOneServer();
$this->pullImages(); $this->pullImages();
$this->checkScheduledBackups(); // Scheduled Jobs (Backups & Tasks)
$this->checkScheduledTasks(); $this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily(); $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
@@ -135,182 +128,6 @@ class Kernel extends ConsoleKernel
} }
} }
private function checkResources(): void
{
if (isCloud()) {
$servers = $this->allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
$own = Team::find(0)->servers;
$servers = $servers->merge($own);
} else {
$servers = $this->allServers->get();
}
foreach ($servers as $server) {
try {
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
if (validate_timezone($serverTimezone) === false) {
$serverTimezone = config('app.timezone');
}
// Sentinel check
$lastSentinelUpdate = $server->sentinel_updated_at;
if (Carbon::parse($lastSentinelUpdate)->isBefore(now()->subSeconds($server->waitBeforeDoingSshCheck()))) {
// Check container status every minute if Sentinel does not activated
if (isCloud()) {
$this->scheduleInstance->job(new ServerCheckJob($server))->timezone($serverTimezone)->everyFiveMinutes()->onOneServer();
} else {
$this->scheduleInstance->job(new ServerCheckJob($server))->timezone($serverTimezone)->everyMinute()->onOneServer();
}
// $this->scheduleInstance->job(new \App\Jobs\ServerCheckNewJob($server))->everyFiveMinutes()->onOneServer();
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
}
$this->scheduleInstance->job(new ServerStorageCheckJob($server))->cron($serverDiskUsageCheckFrequency)->timezone($serverTimezone)->onOneServer();
}
$dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) {
$dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency];
}
$this->scheduleInstance->job(new DockerCleanupJob($server))->cron($dockerCleanupFrequency)->timezone($serverTimezone)->onOneServer();
// Server patch check - weekly
$this->scheduleInstance->job(new ServerPatchCheckJob($server))->weekly()->timezone($serverTimezone)->onOneServer();
// Cleanup multiplexed connections every hour
// $this->scheduleInstance->job(new ServerCleanupMux($server))->hourly()->onOneServer();
// Temporary solution until we have better memory management for Sentinel
if ($server->isSentinelEnabled()) {
$this->scheduleInstance->job(function () use ($server) {
$server->restartContainer('coolify-sentinel');
})->daily()->onOneServer();
}
} catch (\Exception $e) {
Log::error('Error checking resources: '.$e->getMessage());
}
}
}
private function checkScheduledBackups(): void
{
$scheduled_backups = ScheduledDatabaseBackup::where('enabled', true)->get();
if ($scheduled_backups->isEmpty()) {
return;
}
$finalScheduledBackups = collect();
foreach ($scheduled_backups as $scheduled_backup) {
if (blank(data_get($scheduled_backup, 'database'))) {
$scheduled_backup->delete();
continue;
}
$server = $scheduled_backup->server();
if (blank($server)) {
$scheduled_backup->delete();
continue;
}
if ($server->isFunctional() === false) {
continue;
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
continue;
}
$finalScheduledBackups->push($scheduled_backup);
}
foreach ($finalScheduledBackups as $scheduled_backup) {
try {
if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) {
$scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency];
}
$server = $scheduled_backup->server();
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
if (validate_timezone($serverTimezone) === false) {
$serverTimezone = config('app.timezone');
}
if (isset(VALID_CRON_STRINGS[$scheduled_backup->frequency])) {
$scheduled_backup->frequency = VALID_CRON_STRINGS[$scheduled_backup->frequency];
}
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
$this->scheduleInstance->job(new DatabaseBackupJob(
backup: $scheduled_backup
))->cron($scheduled_backup->frequency)->timezone($serverTimezone)->onOneServer();
} catch (\Exception $e) {
Log::error('Error scheduling backup: '.$e->getMessage());
Log::error($e->getTraceAsString());
}
}
}
private function checkScheduledTasks(): void
{
$scheduled_tasks = ScheduledTask::where('enabled', true)->get();
if ($scheduled_tasks->isEmpty()) {
return;
}
$finalScheduledTasks = collect();
foreach ($scheduled_tasks as $scheduled_task) {
$service = $scheduled_task->service;
$application = $scheduled_task->application;
$server = $scheduled_task->server();
if (blank($server)) {
$scheduled_task->delete();
continue;
}
if ($server->isFunctional() === false) {
continue;
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
continue;
}
if (! $service && ! $application) {
$scheduled_task->delete();
continue;
}
if ($application && str($application->status)->contains('running') === false) {
continue;
}
if ($service && str($service->status)->contains('running') === false) {
continue;
}
$finalScheduledTasks->push($scheduled_task);
}
foreach ($finalScheduledTasks as $scheduled_task) {
try {
$server = $scheduled_task->server();
if (isset(VALID_CRON_STRINGS[$scheduled_task->frequency])) {
$scheduled_task->frequency = VALID_CRON_STRINGS[$scheduled_task->frequency];
}
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
if (validate_timezone($serverTimezone) === false) {
$serverTimezone = config('app.timezone');
}
$this->scheduleInstance->job(new ScheduledTaskJob(
task: $scheduled_task
))->cron($scheduled_task->frequency)->timezone($serverTimezone)->onOneServer();
} catch (\Exception $e) {
Log::error('Error scheduling task: '.$e->getMessage());
Log::error($e->getTraceAsString());
}
}
}
protected function commands(): void protected function commands(): void
{ {
$this->load(__DIR__.'/Commands'); $this->load(__DIR__.'/Commands');
+2 -1
View File
@@ -7,8 +7,9 @@ use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Laravel\Horizon\Contracts\Silenced;
class BackupCreated implements ShouldBroadcast class BackupCreated implements ShouldBroadcast, Silenced
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
+2 -1
View File
@@ -7,8 +7,9 @@ use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Laravel\Horizon\Contracts\Silenced;
class ServiceChecked implements ShouldBroadcast class ServiceChecked implements ShouldBroadcast, Silenced
{ {
use Dispatchable, InteractsWithSockets, SerializesModels; use Dispatchable, InteractsWithSockets, SerializesModels;
+5 -1
View File
@@ -103,7 +103,11 @@ class SshMultiplexingHelper
} }
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true); $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
$scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}"; if ($server->isIpv6()) {
$scp_command .= "{$source} {$server->user}@[{$server->ip}]:{$dest}";
} else {
$scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}";
}
return $scp_command; return $scp_command;
} }
@@ -1699,10 +1699,10 @@ class ApplicationsController extends Controller
DeleteResourceJob::dispatch( DeleteResourceJob::dispatch(
resource: $application, resource: $application,
deleteConfigurations: $request->query->get('delete_configurations', true),
deleteVolumes: $request->query->get('delete_volumes', true), deleteVolumes: $request->query->get('delete_volumes', true),
dockerCleanup: $request->query->get('docker_cleanup', true), deleteConnectedNetworks: $request->query->get('delete_connected_networks', true),
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) deleteConfigurations: $request->query->get('delete_configurations', true),
dockerCleanup: $request->query->get('docker_cleanup', true)
); );
return response()->json([ return response()->json([
@@ -1608,10 +1608,10 @@ class DatabasesController extends Controller
DeleteResourceJob::dispatch( DeleteResourceJob::dispatch(
resource: $database, resource: $database,
deleteConfigurations: $request->query->get('delete_configurations', true),
deleteVolumes: $request->query->get('delete_volumes', true), deleteVolumes: $request->query->get('delete_volumes', true),
dockerCleanup: $request->query->get('docker_cleanup', true), deleteConnectedNetworks: $request->query->get('delete_connected_networks', true),
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) deleteConfigurations: $request->query->get('delete_configurations', true),
dockerCleanup: $request->query->get('docker_cleanup', true)
); );
return response()->json([ return response()->json([
@@ -510,10 +510,10 @@ class ServicesController extends Controller
DeleteResourceJob::dispatch( DeleteResourceJob::dispatch(
resource: $service, resource: $service,
deleteConfigurations: $request->query->get('delete_configurations', true),
deleteVolumes: $request->query->get('delete_volumes', true), deleteVolumes: $request->query->get('delete_volumes', true),
dockerCleanup: $request->query->get('docker_cleanup', true), deleteConnectedNetworks: $request->query->get('delete_connected_networks', true),
deleteConnectedNetworks: $request->query->get('delete_connected_networks', true) deleteConfigurations: $request->query->get('delete_configurations', true),
dockerCleanup: $request->query->get('docker_cleanup', true)
); );
return response()->json([ return response()->json([
+1 -1
View File
@@ -144,7 +144,7 @@ class Controller extends BaseController
} }
} }
public function revoke_invitation() public function revokeInvitation()
{ {
$invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail(); $invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail();
$user = User::whereEmail($invitation->email)->firstOrFail(); $user = User::whereEmail($invitation->email)->firstOrFail();
+2 -1
View File
@@ -143,12 +143,13 @@ class Bitbucket extends Controller
]); ]);
$pr_app->generate_preview_fqdn_compose(); $pr_app->generate_preview_fqdn_compose();
} else { } else {
ApplicationPreview::create([ $pr_app = ApplicationPreview::create([
'git_type' => 'bitbucket', 'git_type' => 'bitbucket',
'application_id' => $application->id, 'application_id' => $application->id,
'pull_request_id' => $pull_request_id, 'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url, 'pull_request_html_url' => $pull_request_html_url,
]); ]);
$pr_app->generate_preview_fqdn();
} }
} }
$result = queue_application_deployment( $result = queue_application_deployment(
+2 -1
View File
@@ -175,12 +175,13 @@ class Gitea extends Controller
]); ]);
$pr_app->generate_preview_fqdn_compose(); $pr_app->generate_preview_fqdn_compose();
} else { } else {
ApplicationPreview::create([ $pr_app = ApplicationPreview::create([
'git_type' => 'gitea', 'git_type' => 'gitea',
'application_id' => $application->id, 'application_id' => $application->id,
'pull_request_id' => $pull_request_id, 'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url, 'pull_request_html_url' => $pull_request_html_url,
]); ]);
$pr_app->generate_preview_fqdn();
} }
} }
$result = queue_application_deployment( $result = queue_application_deployment(
+2 -1
View File
@@ -183,12 +183,13 @@ class Github extends Controller
]); ]);
$pr_app->generate_preview_fqdn_compose(); $pr_app->generate_preview_fqdn_compose();
} else { } else {
ApplicationPreview::create([ $pr_app = ApplicationPreview::create([
'git_type' => 'github', 'git_type' => 'github',
'application_id' => $application->id, 'application_id' => $application->id,
'pull_request_id' => $pull_request_id, 'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url, 'pull_request_html_url' => $pull_request_html_url,
]); ]);
$pr_app->generate_preview_fqdn();
} }
} }
+2 -1
View File
@@ -202,12 +202,13 @@ class Gitlab extends Controller
]); ]);
$pr_app->generate_preview_fqdn_compose(); $pr_app->generate_preview_fqdn_compose();
} else { } else {
ApplicationPreview::create([ $pr_app = ApplicationPreview::create([
'git_type' => 'gitlab', 'git_type' => 'gitlab',
'application_id' => $application->id, 'application_id' => $application->id,
'pull_request_id' => $pull_request_id, 'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url, 'pull_request_html_url' => $pull_request_html_url,
]); ]);
$pr_app->generate_preview_fqdn();
} }
} }
$result = queue_application_deployment( $result = queue_application_deployment(
+1 -1
View File
@@ -12,6 +12,6 @@ class VerifyCsrfToken extends Middleware
* @var array<int, string> * @var array<int, string>
*/ */
protected $except = [ protected $except = [
// 'webhooks/*',
]; ];
} }
+70 -39
View File
@@ -30,6 +30,7 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Sleep; use Illuminate\Support\Sleep;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RuntimeException; use RuntimeException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
use Throwable; use Throwable;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
@@ -228,7 +229,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Set preview fqdn // Set preview fqdn
if ($this->pull_request_id !== 0) { if ($this->pull_request_id !== 0) {
$this->preview = $this->application->generate_preview_fqdn($this->pull_request_id); $this->preview = ApplicationPreview::findPreviewByApplicationAndPullId($this->application->id, $this->pull_request_id);
if ($this->preview) {
if ($this->application->build_pack === 'dockercompose') {
$this->preview->generate_preview_fqdn_compose();
} else {
$this->preview->generate_preview_fqdn();
}
}
if ($this->application->is_github_based()) { if ($this->application->is_github_based()) {
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::IN_PROGRESS); ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::IN_PROGRESS);
} }
@@ -471,7 +479,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} else { } else {
$composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'));
$this->save_environment_variables(); $this->save_environment_variables();
if (! is_null($this->env_filename)) { if (filled($this->env_filename)) {
$services = collect(data_get($composeFile, 'services', [])); $services = collect(data_get($composeFile, 'services', []));
$services = $services->map(function ($service, $name) { $services = $services->map(function ($service, $name) {
$service['env_file'] = [$this->env_filename]; $service['env_file'] = [$this->env_filename];
@@ -480,7 +488,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}); });
$composeFile['services'] = $services->toArray(); $composeFile['services'] = $services->toArray();
} }
if (is_null($composeFile)) { if (empty($composeFile)) {
$this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.'); $this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.');
$this->fail('Failed to parse docker-compose file.'); $this->fail('Failed to parse docker-compose file.');
@@ -887,10 +895,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function save_environment_variables() private function save_environment_variables()
{ {
$envs = collect([]); $envs = collect([]);
$local_branch = $this->branch;
if ($this->pull_request_id !== 0) {
$local_branch = "pull/{$this->pull_request_id}/head";
}
$sort = $this->application->settings->is_env_sorting_enabled; $sort = $this->application->settings->is_env_sorting_enabled;
if ($sort) { if ($sort) {
$sorted_environment_variables = $this->application->environment_variables->sortBy('key'); $sorted_environment_variables = $this->application->environment_variables->sortBy('key');
@@ -899,6 +903,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$sorted_environment_variables = $this->application->environment_variables->sortBy('id'); $sorted_environment_variables = $this->application->environment_variables->sortBy('id');
$sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id'); $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id');
} }
if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
$sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
}
$ports = $this->application->main_port(); $ports = $this->application->main_port();
$coolify_envs = $this->generate_coolify_env_variables(); $coolify_envs = $this->generate_coolify_env_variables();
$coolify_envs->each(function ($item, $key) use ($envs) { $coolify_envs->each(function ($item, $key) use ($envs) {
@@ -908,17 +920,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->env_filename = '.env'; $this->env_filename = '.env';
foreach ($sorted_environment_variables as $env) { foreach ($sorted_environment_variables as $env) {
$real_value = $env->real_value; $envs->push($env->key.'='.$env->real_value);
if ($env->version === '4.0.0-beta.239') {
$real_value = $env->real_value;
} else {
if ($env->is_literal || $env->is_multiline) {
$real_value = '\''.$real_value.'\'';
} else {
$real_value = escapeEnvVariables($env->real_value);
}
}
$envs->push($env->key.'='.$real_value);
} }
// Add PORT if not exists, use the first port as default // Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') { if ($this->build_pack !== 'dockercompose') {
@@ -930,20 +932,28 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) { if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) {
$envs->push('HOST=0.0.0.0'); $envs->push('HOST=0.0.0.0');
} }
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
}
}
}
} else { } else {
$this->env_filename = ".env-pr-$this->pull_request_id"; $this->env_filename = ".env-pr-$this->pull_request_id";
foreach ($sorted_environment_variables_preview as $env) { foreach ($sorted_environment_variables_preview as $env) {
$real_value = $env->real_value; $envs->push($env->key.'='.$env->real_value);
if ($env->version === '4.0.0-beta.239') {
$real_value = $env->real_value;
} else {
if ($env->is_literal || $env->is_multiline) {
$real_value = '\''.$real_value.'\'';
} else {
$real_value = escapeEnvVariables($env->real_value);
}
}
$envs->push($env->key.'='.$real_value);
} }
// Add PORT if not exists, use the first port as default // Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') { if ($this->build_pack !== 'dockercompose') {
@@ -956,6 +966,23 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$envs->push('HOST=0.0.0.0'); $envs->push('HOST=0.0.0.0');
} }
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]);
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
}
}
}
} }
if ($envs->isEmpty()) { if ($envs->isEmpty()) {
$this->env_filename = null; $this->env_filename = null;
@@ -1367,9 +1394,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$fqdn = $this->preview->fqdn; $fqdn = $this->preview->fqdn;
} }
if (isset($fqdn)) { if (isset($fqdn)) {
$this->coolify_variables .= "COOLIFY_FQDN={$fqdn} "; $url = Url::fromString($fqdn);
$url = str($fqdn)->replace('http://', '')->replace('https://', ''); $fqdn = $url->getHost();
$this->coolify_variables .= "COOLIFY_URL={$url} "; $url = $url->withHost($fqdn)->withPort(null)->__toString();
if ((int) $this->application->compose_parsing_version >= 3) {
$this->coolify_variables .= "COOLIFY_URL={$url} ";
$this->coolify_variables .= "COOLIFY_FQDN={$fqdn} ";
} else {
$this->coolify_variables .= "COOLIFY_URL={$fqdn} ";
$this->coolify_variables .= "COOLIFY_FQDN={$url} ";
}
} }
if (isset($this->application->git_branch)) { if (isset($this->application->git_branch)) {
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} "; $this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
@@ -1381,8 +1415,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if (is_object($this->source) && $this->source->getMorphClass() === \App\Models\GithubApp::class && $this->source->is_public === false) { if (is_object($this->source) && $this->source->getMorphClass() === \App\Models\GithubApp::class && $this->source->is_public === false) {
$repository = githubApi($this->source, "repos/{$this->customRepository}"); $repository = githubApi($this->source, "repos/{$this->customRepository}");
$data = data_get($repository, 'data'); $data = data_get($repository, 'data');
if (isset($data->id)) { $repository_project_id = data_get($data, 'id');
$repository_project_id = $data->id; if (isset($repository_project_id)) {
if (blank($this->application->repository_project_id) || $this->application->repository_project_id !== $repository_project_id) { if (blank($this->application->repository_project_id) || $this->application->repository_project_id !== $repository_project_id) {
$this->application->repository_project_id = $repository_project_id; $this->application->repository_project_id = $repository_project_id;
$this->application->save(); $this->application->save();
@@ -1715,10 +1749,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
{ {
$this->create_workdir(); $this->create_workdir();
$ports = $this->application->main_port(); $ports = $this->application->main_port();
$onlyPort = null;
if (count($ports) > 0) {
$onlyPort = $ports[0];
}
$persistent_storages = $this->generate_local_persistent_volumes(); $persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->application->fileStorages()->get(); $persistent_file_volumes = $this->application->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names();
@@ -2253,9 +2283,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->application_deployment_queue->addLogEntry('Building docker image completed.'); $this->application_deployment_queue->addLogEntry('Building docker image completed.');
} }
private function graceful_shutdown_container(string $containerName, int $timeout = 30) private function graceful_shutdown_container(string $containerName)
{ {
try { try {
$timeout = isDev() ? 1 : 30;
$this->execute_remote_command( $this->execute_remote_command(
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
+1 -1
View File
@@ -23,7 +23,7 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping('cleanup-instance-stuffs'))->dontRelease()]; return [(new WithoutOverlapping('cleanup-instance-stuffs'))->expireAfter(60)->dontRelease()];
} }
public function handle(): void public function handle(): void
+28 -5
View File
@@ -23,6 +23,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Throwable;
use Visus\Cuid2\Cuid2;
class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{ {
@@ -60,9 +62,16 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public ?S3Storage $s3 = null; public ?S3Storage $s3 = null;
public $timeout = 3600;
public string $backup_log_uuid;
public function __construct(public ScheduledDatabaseBackup $backup) public function __construct(public ScheduledDatabaseBackup $backup)
{ {
$this->onQueue('high'); $this->onQueue('high');
$this->timeout = $backup->timeout;
$this->backup_log_uuid = (string) new Cuid2;
} }
public function handle(): void public function handle(): void
@@ -219,12 +228,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->mongo_root_username = str($rootUsername)->after('MONGO_INITDB_ROOT_USERNAME=')->value(); $this->mongo_root_username = str($rootUsername)->after('MONGO_INITDB_ROOT_USERNAME=')->value();
} }
} }
\Log::info('MongoDB credentials extracted from environment', [
'has_username' => filled($this->mongo_root_username),
'has_password' => filled($this->mongo_root_password),
]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
\Log::warning('Failed to extract MongoDB environment variables', ['error' => $e->getMessage()]);
// Continue without env vars - will be handled in backup_standalone_mongodb method // Continue without env vars - will be handled in backup_standalone_mongodb method
} }
} }
@@ -288,6 +293,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} }
$this->backup_location = $this->backup_dir.$this->backup_file; $this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([ $this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $database, 'database_name' => $database,
'filename' => $this->backup_location, 'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id, 'scheduled_database_backup_id' => $this->backup->id,
@@ -307,6 +313,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->backup_file = "/mongo-dump-$databaseName-".Carbon::now()->timestamp.'.tar.gz'; $this->backup_file = "/mongo-dump-$databaseName-".Carbon::now()->timestamp.'.tar.gz';
$this->backup_location = $this->backup_dir.$this->backup_file; $this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([ $this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $databaseName, 'database_name' => $databaseName,
'filename' => $this->backup_location, 'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id, 'scheduled_database_backup_id' => $this->backup->id,
@@ -319,6 +326,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} }
$this->backup_location = $this->backup_dir.$this->backup_file; $this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([ $this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $database, 'database_name' => $database,
'filename' => $this->backup_location, 'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id, 'scheduled_database_backup_id' => $this->backup->id,
@@ -331,6 +339,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} }
$this->backup_location = $this->backup_dir.$this->backup_file; $this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([ $this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $database, 'database_name' => $database,
'filename' => $this->backup_location, 'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id, 'scheduled_database_backup_id' => $this->backup->id,
@@ -574,4 +583,18 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
return "{$helperImage}:{$latestVersion}"; return "{$helperImage}:{$latestVersion}";
} }
public function failed(?Throwable $exception): void
{
$log = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->first();
if ($log) {
$log->update([
'status' => 'failed',
'message' => 'Job failed: '.($exception?->getMessage() ?? 'Unknown error'),
'size' => 0,
'filename' => null,
]);
}
}
} }
+69 -10
View File
@@ -8,6 +8,7 @@ use App\Actions\Server\CleanupDocker;
use App\Actions\Service\DeleteService; use App\Actions\Service\DeleteService;
use App\Actions\Service\StopService; use App\Actions\Service\StopService;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Service; use App\Models\Service;
use App\Models\StandaloneClickhouse; use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly; use App\Models\StandaloneDragonfly;
@@ -30,11 +31,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct( public function __construct(
public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource, public Application|ApplicationPreview|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
public bool $deleteConfigurations = true,
public bool $deleteVolumes = true, public bool $deleteVolumes = true,
public bool $dockerCleanup = true, public bool $deleteConnectedNetworks = true,
public bool $deleteConnectedNetworks = true public bool $deleteConfigurations = true,
public bool $dockerCleanup = true
) { ) {
$this->onQueue('high'); $this->onQueue('high');
} }
@@ -42,9 +43,16 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
public function handle() public function handle()
{ {
try { try {
// Handle ApplicationPreview instances separately
if ($this->resource instanceof ApplicationPreview) {
$this->deleteApplicationPreview();
return;
}
switch ($this->resource->type()) { switch ($this->resource->type()) {
case 'application': case 'application':
StopApplication::run($this->resource, previewDeployments: true); StopApplication::run($this->resource, previewDeployments: true, dockerCleanup: $this->dockerCleanup);
break; break;
case 'standalone-postgresql': case 'standalone-postgresql':
case 'standalone-redis': case 'standalone-redis':
@@ -54,11 +62,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
case 'standalone-keydb': case 'standalone-keydb':
case 'standalone-dragonfly': case 'standalone-dragonfly':
case 'standalone-clickhouse': case 'standalone-clickhouse':
StopDatabase::run($this->resource, true); StopDatabase::run($this->resource, dockerCleanup: $this->dockerCleanup);
break; break;
case 'service': case 'service':
StopService::run($this->resource, true); StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup);
DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks); DeleteService::run($this->resource, $this->deleteVolumes, $this->deleteConnectedNetworks, $this->deleteConfigurations, $this->dockerCleanup);
return; return;
} }
@@ -70,7 +78,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
$this->resource->deleteVolumes(); $this->resource->deleteVolumes();
$this->resource->persistentStorages()->delete(); $this->resource->persistentStorages()->delete();
} }
$this->resource->fileStorages()->delete(); $this->resource->fileStorages()->delete(); // these are file mounts which should probably have their own flag
$isDatabase = $this->resource instanceof StandalonePostgresql $isDatabase = $this->resource instanceof StandalonePostgresql
|| $this->resource instanceof StandaloneRedis || $this->resource instanceof StandaloneRedis
@@ -98,10 +106,61 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
if ($this->dockerCleanup) { if ($this->dockerCleanup) {
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server'); $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
if ($server) { if ($server) {
CleanupDocker::dispatch($server, true); CleanupDocker::dispatch($server, false, false);
} }
} }
Artisan::queue('cleanup:stucked-resources'); Artisan::queue('cleanup:stucked-resources');
} }
} }
private function deleteApplicationPreview()
{
$application = $this->resource->application;
$server = $application->destination->server;
$pull_request_id = $this->resource->pull_request_id;
// Ensure the preview is soft deleted (may already be done in Livewire component)
if (! $this->resource->trashed()) {
$this->resource->delete();
}
try {
if ($server->isSwarm()) {
instant_remote_process(["docker stack rm {$application->uuid}-{$pull_request_id}"], $server);
} else {
$containers = getCurrentApplicationContainerStatus($server, $application->id, $pull_request_id)->toArray();
$this->stopPreviewContainers($containers, $server);
}
} catch (\Throwable $e) {
// Log the error but don't fail the job
ray('Error stopping preview containers: '.$e->getMessage());
}
// Finally, force delete to trigger resource cleanup
$this->resource->forceDelete();
}
private function stopPreviewContainers(array $containers, $server, int $timeout = 30)
{
if (empty($containers)) {
return;
}
$containerNames = [];
foreach ($containers as $container) {
$containerNames[] = str_replace('/', '', $container['Names']);
}
$containerList = implode(' ', array_map('escapeshellarg', $containerNames));
$commands = [
"docker stop --time=$timeout $containerList",
"docker rm -f $containerList",
];
instant_remote_process(
command: $commands,
server: $server,
throwError: false
);
}
} }
+22 -5
View File
@@ -31,10 +31,15 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping('docker-cleanup-'.$this->server->uuid))->dontRelease()]; return [(new WithoutOverlapping('docker-cleanup-'.$this->server->uuid))->expireAfter(600)->dontRelease()];
} }
public function __construct(public Server $server, public bool $manualCleanup = false) {} public function __construct(
public Server $server,
public bool $manualCleanup = false,
public bool $deleteUnusedVolumes = false,
public bool $deleteUnusedNetworks = false
) {}
public function handle(): void public function handle(): void
{ {
@@ -50,7 +55,11 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
$this->usageBefore = $this->server->getDiskUsage(); $this->usageBefore = $this->server->getDiskUsage();
if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) { if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) {
$cleanup_log = CleanupDocker::run(server: $this->server); $cleanup_log = CleanupDocker::run(
server: $this->server,
deleteUnusedVolumes: $this->deleteUnusedVolumes,
deleteUnusedNetworks: $this->deleteUnusedNetworks
);
$usageAfter = $this->server->getDiskUsage(); $usageAfter = $this->server->getDiskUsage();
$message = ($this->manualCleanup ? 'Manual' : 'Forced').' Docker cleanup job executed successfully. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.'; $message = ($this->manualCleanup ? 'Manual' : 'Forced').' Docker cleanup job executed successfully. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.';
@@ -67,7 +76,11 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
} }
if (str($this->usageBefore)->isEmpty() || $this->usageBefore === null || $this->usageBefore === 0) { if (str($this->usageBefore)->isEmpty() || $this->usageBefore === null || $this->usageBefore === 0) {
$cleanup_log = CleanupDocker::run(server: $this->server); $cleanup_log = CleanupDocker::run(
server: $this->server,
deleteUnusedVolumes: $this->deleteUnusedVolumes,
deleteUnusedNetworks: $this->deleteUnusedNetworks
);
$message = 'Docker cleanup job executed successfully, but no disk usage could be determined.'; $message = 'Docker cleanup job executed successfully, but no disk usage could be determined.';
$this->execution_log->update([ $this->execution_log->update([
@@ -81,7 +94,11 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
} }
if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) { if ($this->usageBefore >= $this->server->settings->docker_cleanup_threshold) {
$cleanup_log = CleanupDocker::run(server: $this->server); $cleanup_log = CleanupDocker::run(
server: $this->server,
deleteUnusedVolumes: $this->deleteUnusedVolumes,
deleteUnusedNetworks: $this->deleteUnusedNetworks
);
$usageAfter = $this->server->getDiskUsage(); $usageAfter = $this->server->getDiskUsage();
$diskSaved = $this->usageBefore - $usageAfter; $diskSaved = $this->usageBefore - $usageAfter;
+3 -2
View File
@@ -21,8 +21,9 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Laravel\Horizon\Contracts\Silenced;
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
@@ -70,7 +71,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->dontRelease()]; return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()];
} }
public function backoff(): int public function backoff(): int
+1 -1
View File
@@ -23,7 +23,7 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->dontRelease()]; return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(60)->dontRelease()];
} }
public function __construct(public Server $server) {} public function __construct(public Server $server) {}
+229
View File
@@ -0,0 +1,229 @@
<?php
namespace App\Jobs;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
class ScheduledJobManager implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The time when this job execution started.
* Used to ensure all scheduled items are evaluated against the same point in time.
*/
private ?Carbon $executionTime = null;
/**
* Create a new job instance.
*/
public function __construct()
{
$this->onQueue($this->determineQueue());
}
private function determineQueue(): string
{
$preferredQueue = 'crons';
$fallbackQueue = 'high';
$configuredQueues = explode(',', env('HORIZON_QUEUES', 'high,default'));
return in_array($preferredQueue, $configuredQueues) ? $preferredQueue : $fallbackQueue;
}
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [
(new WithoutOverlapping('scheduled-job-manager'))
->releaseAfter(60), // Release the lock after 60 seconds if job fails
];
}
public function handle(): void
{
// Freeze the execution time at the start of the job
$this->executionTime = Carbon::now();
// Process backups - don't let failures stop task processing
try {
$this->processScheduledBackups();
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process scheduled backups', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
// Process tasks - don't let failures stop the job manager
try {
$this->processScheduledTasks();
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process scheduled tasks', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
private function processScheduledBackups(): void
{
$backups = ScheduledDatabaseBackup::with(['database'])
->where('enabled', true)
->get();
foreach ($backups as $backup) {
try {
// Apply the same filtering logic as the original
if (! $this->shouldProcessBackup($backup)) {
continue;
}
$server = $backup->server();
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($serverTimezone) === false) {
$serverTimezone = config('app.timezone');
}
$frequency = $backup->frequency;
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
if ($this->shouldRunNow($frequency, $serverTimezone)) {
DatabaseBackupJob::dispatch($backup);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing backup', [
'backup_id' => $backup->id,
'error' => $e->getMessage(),
]);
}
}
}
private function processScheduledTasks(): void
{
$tasks = ScheduledTask::with(['service', 'application'])
->where('enabled', true)
->get();
foreach ($tasks as $task) {
try {
if (! $this->shouldProcessTask($task)) {
continue;
}
$server = $task->server();
$serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
if (validate_timezone($serverTimezone) === false) {
$serverTimezone = config('app.timezone');
}
$frequency = $task->frequency;
if (isset(VALID_CRON_STRINGS[$frequency])) {
$frequency = VALID_CRON_STRINGS[$frequency];
}
if ($this->shouldRunNow($frequency, $serverTimezone)) {
ScheduledTaskJob::dispatch($task);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing task', [
'task_id' => $task->id,
'error' => $e->getMessage(),
]);
}
}
}
private function shouldProcessBackup(ScheduledDatabaseBackup $backup): bool
{
if (blank(data_get($backup, 'database'))) {
$backup->delete();
return false;
}
$server = $backup->server();
if (blank($server)) {
$backup->delete();
return false;
}
if ($server->isFunctional() === false) {
return false;
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
return false;
}
return true;
}
private function shouldProcessTask(ScheduledTask $task): bool
{
$service = $task->service;
$application = $task->application;
$server = $task->server();
if (blank($server)) {
$task->delete();
return false;
}
if ($server->isFunctional() === false) {
return false;
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
return false;
}
if (! $service && ! $application) {
$task->delete();
return false;
}
if ($application && str($application->status)->contains('running') === false) {
return false;
}
if ($service && str($service->status)->contains('running') === false) {
return false;
}
return true;
}
private function shouldRunNow(string $frequency, string $timezone): bool
{
$cron = new CronExpression($frequency);
// Use the frozen execution time, not the current time
// Fallback to current time if execution time is not set (shouldn't happen)
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone);
return $cron->isDue($executionTime);
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping('server-check-'.$this->server->uuid))->dontRelease()]; return [(new WithoutOverlapping('server-check-'.$this->server->uuid))->expireAfter(60)->dontRelease()];
} }
public function __construct(public Server $server) {} public function __construct(public Server $server) {}
+2 -2
View File
@@ -19,11 +19,11 @@ class ServerPatchCheckJob implements ShouldBeEncrypted, ShouldQueue
public $tries = 3; public $tries = 3;
public $timeout = 600; // 10 minutes timeout public $timeout = 600;
public function middleware(): array public function middleware(): array
{ {
return [(new WithoutOverlapping('server-patch-check-'.$this->server->uuid))->dontRelease()]; return [(new WithoutOverlapping('server-patch-check-'.$this->server->uuid))->expireAfter(600)->dontRelease()];
} }
public function __construct(public Server $server) {} public function __construct(public Server $server) {}
+162
View File
@@ -0,0 +1,162 @@
<?php
namespace App\Jobs;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
class ServerResourceManager implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The time when this job execution started.
*/
private ?Carbon $executionTime = null;
private InstanceSettings $settings;
private string $instanceTimezone;
/**
* Create a new job instance.
*/
public function __construct()
{
$this->onQueue('high');
}
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [
(new WithoutOverlapping('server-resource-manager'))
->releaseAfter(60),
];
}
public function handle(): void
{
// Freeze the execution time at the start of the job
$this->executionTime = Carbon::now();
$this->settings = instanceSettings();
$this->instanceTimezone = $this->settings->instance_timezone ?: config('app.timezone');
if (validate_timezone($this->instanceTimezone) === false) {
$this->instanceTimezone = config('app.timezone');
}
// Process server checks - don't let failures stop the job
try {
$this->processServerChecks();
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process server checks', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
private function processServerChecks(): void
{
$servers = $this->getServers();
foreach ($servers as $server) {
try {
$this->processServer($server);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing server', [
'server_id' => $server->id,
'server_name' => $server->name,
'error' => $e->getMessage(),
]);
}
}
}
private function getServers()
{
$allServers = Server::where('ip', '!=', '1.2.3.4');
if (isCloud()) {
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
$own = Team::find(0)->servers;
return $servers->merge($own);
} else {
return $allServers->get();
}
}
private function processServer(Server $server): void
{
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
if (validate_timezone($serverTimezone) === false) {
$serverTimezone = config('app.timezone');
}
// Sentinel check
$lastSentinelUpdate = $server->sentinel_updated_at;
if (Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($server->waitBeforeDoingSshCheck()))) {
// Dispatch ServerCheckJob if due
$checkFrequency = isCloud() ? '*/5 * * * *' : '* * * * *'; // Every 5 min for cloud, every minute for self-hosted
if ($this->shouldRunNow($checkFrequency, $serverTimezone)) {
ServerCheckJob::dispatch($server);
}
// Dispatch ServerStorageCheckJob if due
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
}
if ($this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone)) {
ServerStorageCheckJob::dispatch($server);
}
}
// Dispatch DockerCleanupJob if due
$dockerCleanupFrequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
if (isset(VALID_CRON_STRINGS[$dockerCleanupFrequency])) {
$dockerCleanupFrequency = VALID_CRON_STRINGS[$dockerCleanupFrequency];
}
if ($this->shouldRunNow($dockerCleanupFrequency, $serverTimezone)) {
DockerCleanupJob::dispatch($server, false, $server->settings->delete_unused_volumes, $server->settings->delete_unused_networks);
}
// Dispatch ServerPatchCheckJob if due (weekly)
if ($this->shouldRunNow('0 0 * * 0', $serverTimezone)) { // Weekly on Sunday at midnight
ServerPatchCheckJob::dispatch($server);
}
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
if ($server->isSentinelEnabled() && $this->shouldRunNow('0 0 * * *', $serverTimezone)) {
dispatch(function () use ($server) {
$server->restartContainer('coolify-sentinel');
});
}
}
private function shouldRunNow(string $frequency, string $timezone): bool
{
$cron = new CronExpression($frequency);
// Use the frozen execution time, not the current time
$baseTime = $this->executionTime ?? Carbon::now();
$executionTime = $baseTime->copy()->setTimezone($timezone);
return $cron->isDue($executionTime);
}
}
+2 -1
View File
@@ -11,8 +11,9 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Laravel\Horizon\Contracts\Silenced;
class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue class ServerStorageCheckJob implements ShouldBeEncrypted, ShouldQueue, Silenced
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+6 -9
View File
@@ -254,10 +254,9 @@ class Email extends Component
'smtpEncryption.required' => 'Encryption type is required.', 'smtpEncryption.required' => 'Encryption type is required.',
]); ]);
$this->settings->resend_enabled = false; if ($this->smtpEnabled) {
$this->settings->use_instance_email_settings = false; $this->settings->resend_enabled = $this->resendEnabled = false;
$this->resendEnabled = false; }
$this->useInstanceEmailSettings = false;
$this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_address = $this->smtpFromAddress;
@@ -293,11 +292,9 @@ class Email extends Component
'smtpFromAddress.email' => 'Please enter a valid email address.', 'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.', 'smtpFromName.required' => 'From Name is required.',
]); ]);
if ($this->resendEnabled) {
$this->settings->smtp_enabled = false; $this->settings->smtp_enabled = $this->smtpEnabled = false;
$this->settings->use_instance_email_settings = false; }
$this->smtpEnabled = false;
$this->useInstanceEmailSettings = false;
$this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_enabled = $this->resendEnabled;
$this->settings->resend_api_key = $this->resendApiKey; $this->settings->resend_api_key = $this->resendApiKey;
@@ -18,11 +18,13 @@ class Index extends Component
public int $skip = 0; public int $skip = 0;
public int $default_take = 10; public int $defaultTake = 10;
public bool $show_next = false; public bool $showNext = false;
public bool $show_prev = false; public bool $showPrev = false;
public int $currentPage = 1;
public ?string $pull_request_id = null; public ?string $pull_request_id = null;
@@ -51,68 +53,111 @@ class Index extends Component
if (! $application) { if (! $application) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }
['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->default_take); // Validate pull request ID from URL parameters
if ($this->pull_request_id !== null && $this->pull_request_id !== '') {
if (! is_numeric($this->pull_request_id) || (float) $this->pull_request_id <= 0 || (float) $this->pull_request_id != (int) $this->pull_request_id) {
$this->pull_request_id = null;
$this->dispatch('error', 'Invalid Pull Request ID in URL. Filter cleared.');
} else {
// Ensure it's stored as a string representation of a positive integer
$this->pull_request_id = (string) (int) $this->pull_request_id;
}
}
['deployments' => $deployments, 'count' => $count] = $application->deployments(0, $this->defaultTake, $this->pull_request_id);
$this->application = $application; $this->application = $application;
$this->deployments = $deployments; $this->deployments = $deployments;
$this->deployments_count = $count; $this->deployments_count = $count;
$this->current_url = url()->current(); $this->current_url = url()->current();
$this->show_pull_request_only(); $this->updateCurrentPage();
$this->show_more(); $this->showMore();
} }
private function show_pull_request_only() private function showMore()
{
if ($this->pull_request_id) {
$this->deployments = $this->deployments->where('pull_request_id', $this->pull_request_id);
}
}
private function show_more()
{ {
if ($this->deployments->count() !== 0) { if ($this->deployments->count() !== 0) {
$this->show_next = true; $this->showNext = true;
if ($this->deployments->count() < $this->default_take) { if ($this->deployments->count() < $this->defaultTake) {
$this->show_next = false; $this->showNext = false;
} }
return; return;
} }
} }
public function reload_deployments() public function reloadDeployments()
{ {
$this->load_deployments(); $this->loadDeployments();
} }
public function previous_page(?int $take = null) public function previousPage(?int $take = null)
{ {
if ($take) { if ($take) {
$this->skip = $this->skip - $take; $this->skip = $this->skip - $take;
} }
$this->skip = $this->skip - $this->default_take; $this->skip = $this->skip - $this->defaultTake;
if ($this->skip < 0) { if ($this->skip < 0) {
$this->show_prev = false; $this->showPrev = false;
$this->skip = 0; $this->skip = 0;
} }
$this->load_deployments(); $this->updateCurrentPage();
$this->loadDeployments();
} }
public function next_page(?int $take = null) public function nextPage(?int $take = null)
{ {
if ($take) { if ($take) {
$this->skip = $this->skip + $take; $this->skip = $this->skip + $take;
} }
$this->show_prev = true; $this->showPrev = true;
$this->load_deployments(); $this->updateCurrentPage();
$this->loadDeployments();
} }
public function load_deployments() public function loadDeployments()
{ {
['deployments' => $deployments, 'count' => $count] = $this->application->deployments($this->skip, $this->default_take); ['deployments' => $deployments, 'count' => $count] = $this->application->deployments($this->skip, $this->defaultTake, $this->pull_request_id);
$this->deployments = $deployments; $this->deployments = $deployments;
$this->deployments_count = $count; $this->deployments_count = $count;
$this->show_pull_request_only(); $this->showMore();
$this->show_more(); }
public function updatedPullRequestId($value)
{
// Sanitize and validate the pull request ID
if ($value !== null && $value !== '') {
// Check if it's numeric and positive
if (! is_numeric($value) || (float) $value <= 0 || (float) $value != (int) $value) {
$this->pull_request_id = null;
$this->dispatch('error', 'Invalid Pull Request ID. Please enter a valid positive number.');
return;
}
// Ensure it's stored as a string representation of a positive integer
$this->pull_request_id = (string) (int) $value;
} else {
$this->pull_request_id = null;
}
// Reset pagination when filter changes
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
}
public function clearFilter()
{
$this->pull_request_id = null;
$this->skip = 0;
$this->showPrev = false;
$this->updateCurrentPage();
$this->loadDeployments();
}
private function updateCurrentPage()
{
$this->currentPage = intval($this->skip / $this->defaultTake) + 1;
} }
public function render() public function render()
+139 -12
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Application;
use App\Actions\Application\GenerateConfig; use App\Actions\Application\GenerateConfig;
use App\Models\Application; use App\Models\Application;
use App\Models\EnvironmentVariable;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url; use Spatie\Url\Url;
@@ -156,6 +157,14 @@ class General extends Component
$this->application->settings->save(); $this->application->settings->save();
} }
$this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : []; $this->parsedServiceDomains = $this->application->docker_compose_domains ? json_decode($this->application->docker_compose_domains, true) : [];
// Convert service names with dots to use underscores for HTML form binding
$sanitizedDomains = [];
foreach ($this->parsedServiceDomains as $serviceName => $domain) {
$sanitizedKey = str($serviceName)->slug('_')->toString();
$sanitizedDomains[$sanitizedKey] = $domain;
}
$this->parsedServiceDomains = $sanitizedDomains;
$this->ports_exposes = $this->application->ports_exposes; $this->ports_exposes = $this->application->ports_exposes;
$this->is_preserve_repository_enabled = $this->application->settings->is_preserve_repository_enabled; $this->is_preserve_repository_enabled = $this->application->settings->is_preserve_repository_enabled;
$this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled; $this->is_container_label_escape_enabled = $this->application->settings->is_container_label_escape_enabled;
@@ -206,25 +215,21 @@ class General extends Component
} }
public function loadComposeFile($isInit = false) public function loadComposeFile($isInit = false, $showToast = true)
{ {
try { try {
if ($isInit && $this->application->docker_compose_raw) { if ($isInit && $this->application->docker_compose_raw) {
return; return;
} }
// Must reload the application to get the latest database changes
// Why? Not sure, but it works.
// $this->application->refresh();
['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit); ['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit);
if (is_null($this->parsedServices)) { if (is_null($this->parsedServices)) {
$this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.'); $showToast && $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
return; return;
} }
$this->application->parse(); $this->application->parse();
$this->dispatch('success', 'Docker compose file loaded.'); $showToast && $this->dispatch('success', 'Docker compose file loaded.');
$this->dispatch('compose_loaded'); $this->dispatch('compose_loaded');
$this->dispatch('refreshStorages'); $this->dispatch('refreshStorages');
$this->dispatch('refreshEnvs'); $this->dispatch('refreshEnvs');
@@ -242,12 +247,31 @@ class General extends Component
{ {
$uuid = new Cuid2; $uuid = new Cuid2;
$domain = generateFqdn($this->application->destination->server, $uuid); $domain = generateFqdn($this->application->destination->server, $uuid);
$this->parsedServiceDomains[$serviceName]['domain'] = $domain; $sanitizedKey = str($serviceName)->slug('_')->toString();
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); $this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain;
// Convert back to original service names for storage
$originalDomains = [];
foreach ($this->parsedServiceDomains as $key => $value) {
// Find the original service name by checking parsed services
$originalServiceName = $key;
if (isset($this->parsedServices['services'])) {
foreach ($this->parsedServices['services'] as $originalName => $service) {
if (str($originalName)->slug('_')->toString() === $key) {
$originalServiceName = $originalName;
break;
}
}
}
$originalDomains[$originalServiceName] = $value;
}
$this->application->docker_compose_domains = json_encode($originalDomains);
$this->application->save(); $this->application->save();
$this->dispatch('success', 'Domain generated.'); $this->dispatch('success', 'Domain generated.');
if ($this->application->build_pack === 'dockercompose') { if ($this->application->build_pack === 'dockercompose') {
$this->loadComposeFile(); $this->updateServiceEnvironmentVariables();
$this->loadComposeFile(showToast: false);
} }
return $domain; return $domain;
@@ -429,9 +453,25 @@ class General extends Component
$this->application->publish_directory = rtrim($this->application->publish_directory, '/'); $this->application->publish_directory = rtrim($this->application->publish_directory, '/');
} }
if ($this->application->build_pack === 'dockercompose') { if ($this->application->build_pack === 'dockercompose') {
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); // Convert sanitized service names back to original names for storage
$originalDomains = [];
foreach ($this->parsedServiceDomains as $key => $value) {
// Find the original service name by checking parsed services
$originalServiceName = $key;
if (isset($this->parsedServices['services'])) {
foreach ($this->parsedServices['services'] as $originalName => $service) {
if (str($originalName)->slug('_')->toString() === $key) {
$originalServiceName = $originalName;
break;
}
}
}
$originalDomains[$originalServiceName] = $value;
}
foreach ($this->parsedServiceDomains as $serviceName => $service) { $this->application->docker_compose_domains = json_encode($originalDomains);
foreach ($originalDomains as $serviceName => $service) {
$domain = data_get($service, 'domain'); $domain = data_get($service, 'domain');
if ($domain) { if ($domain) {
if (! validate_dns_entry($domain, $this->application->destination->server)) { if (! validate_dns_entry($domain, $this->application->destination->server)) {
@@ -446,6 +486,12 @@ class General extends Component
} }
$this->application->custom_labels = base64_encode($this->customLabels); $this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save(); $this->application->save();
// Update SERVICE_FQDN_ and SERVICE_URL_ environment variables for Docker Compose applications
if ($this->application->build_pack === 'dockercompose') {
$this->updateServiceEnvironmentVariables();
}
$showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!'); $showToaster && ! $warning && $this->dispatch('success', 'Application settings updated!');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$originalFqdn = $this->application->getOriginal('fqdn'); $originalFqdn = $this->application->getOriginal('fqdn');
@@ -471,4 +517,85 @@ class General extends Component
'Content-Disposition' => 'attachment; filename='.$fileName, 'Content-Disposition' => 'attachment; filename='.$fileName,
]); ]);
} }
private function updateServiceEnvironmentVariables()
{
$domains = collect(json_decode($this->application->docker_compose_domains, true)) ?? collect([]);
foreach ($domains as $serviceName => $service) {
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_');
$domain = data_get($service, 'domain');
if ($domain) {
// Create or update SERVICE_FQDN_ and SERVICE_URL_ variables
$fqdn = Url::fromString($domain);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
$fqdnValue = $fqdn->getScheme().'://'.$fqdn->getHost();
if ($path !== '/') {
$fqdnValue = $fqdnValue.$path;
}
$urlValue = str($domain)->after('://');
if ($path !== '/') {
$urlValue = $urlValue.$path;
}
// Create/update SERVICE_FQDN_
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
'key' => "SERVICE_FQDN_{$serviceNameFormatted}",
], [
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
// Create/update SERVICE_URL_
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
'key' => "SERVICE_URL_{$serviceNameFormatted}",
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
// Create/update port-specific variables if port exists
if ($port) {
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
'key' => "SERVICE_FQDN_{$serviceNameFormatted}_{$port}",
], [
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
'key' => "SERVICE_URL_{$serviceNameFormatted}_{$port}",
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
}
} else {
// Delete SERVICE_FQDN_ and SERVICE_URL_ variables if domain is removed
EnvironmentVariable::where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
->delete();
EnvironmentVariable::where('resourceable_type', Application::class)
->where('resourceable_id', $this->application->id)
->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
->delete();
}
}
}
} }
+20 -49
View File
@@ -3,11 +3,11 @@
namespace App\Livewire\Project\Application; namespace App\Livewire\Project\Application;
use App\Actions\Docker\GetContainersStatus; use App\Actions\Docker\GetContainersStatus;
use App\Jobs\DeleteResourceJob;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
class Previews extends Component class Previews extends Component
@@ -87,18 +87,9 @@ class Previews extends Component
return; return;
} }
$fqdn = generateFqdn($this->application->destination->server, $this->application->uuid); $preview->generate_preview_fqdn();
$url = Url::fromString($fqdn); $this->application->refresh();
$template = $this->application->preview_url_template; $this->dispatch('update_links');
$host = $url->getHost();
$schema = $url->getScheme();
$random = new Cuid2;
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $preview->pull_request_id, $preview_fqdn);
$preview_fqdn = "$schema://$preview_fqdn";
$preview->fqdn = $preview_fqdn;
$preview->save();
$this->dispatch('success', 'Domain generated.'); $this->dispatch('success', 'Domain generated.');
} }
@@ -128,7 +119,7 @@ class Previews extends Component
'pull_request_html_url' => $pull_request_html_url, 'pull_request_html_url' => $pull_request_html_url,
]); ]);
} }
$this->application->generate_preview_fqdn($pull_request_id); $found->generate_preview_fqdn();
$this->application->refresh(); $this->application->refresh();
$this->dispatch('update_links'); $this->dispatch('update_links');
$this->dispatch('success', 'Preview added.'); $this->dispatch('success', 'Preview added.');
@@ -215,48 +206,28 @@ class Previews extends Component
public function delete(int $pull_request_id) public function delete(int $pull_request_id)
{ {
try { try {
$server = $this->application->destination->server; $preview = ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id)
->first();
if ($this->application->destination->server->isSwarm()) { if (! $preview) {
instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server); $this->dispatch('error', 'Preview not found.');
} else {
$containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray(); return;
$this->stopContainers($containers, $server);
} }
ApplicationPreview::where('application_id', $this->application->id) // Soft delete immediately for instant UI feedback
->where('pull_request_id', $pull_request_id) $preview->delete();
->first()
->delete();
$this->application->refresh(); // Dispatch the job for async cleanup (container stopping + force delete)
DeleteResourceJob::dispatch($preview);
// Refresh the application and its previews relationship to reflect the soft delete
$this->application->load('previews');
$this->dispatch('update_links'); $this->dispatch('update_links');
$this->dispatch('success', 'Preview deleted.'); $this->dispatch('success', 'Preview deletion started. It may take a few moments to complete.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
} }
private function stopContainers(array $containers, $server, int $timeout = 30)
{
if (empty($containers)) {
return;
}
$containerNames = [];
foreach ($containers as $container) {
$containerNames[] = str_replace('/', '', $container['Names']);
}
$containerList = implode(' ', array_map('escapeshellarg', $containerNames));
$commands = [
"docker stop --time=$timeout $containerList",
"docker rm -f $containerList",
];
instant_remote_process(
command: $commands,
server: $server,
throwError: false
);
}
} }
@@ -38,9 +38,25 @@ class PreviewsCompose extends Component
$domain = $domains->first(function ($_, $key) { $domain = $domains->first(function ($_, $key) {
return $key === $this->serviceName; return $key === $this->serviceName;
}); });
if ($domain) {
$domain = data_get($domain, 'domain'); $domain_string = data_get($domain, 'domain');
$url = Url::fromString($domain);
// If no domain is set in the main application, generate a default domain
if (empty($domain_string)) {
$server = $this->preview->application->destination->server;
$template = $this->preview->application->preview_url_template;
$random = new Cuid2;
// Generate a unique domain like main app services do
$generated_fqdn = generateFqdn($server, $random);
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
} else {
// Use the existing domain from the main application
$url = Url::fromString($domain_string);
$template = $this->preview->application->preview_url_template; $template = $this->preview->application->preview_url_template;
$host = $url->getHost(); $host = $url->getHost();
$schema = $url->getScheme(); $schema = $url->getScheme();
@@ -49,12 +65,15 @@ class PreviewsCompose extends Component
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = "$schema://$preview_fqdn"; $preview_fqdn = "$schema://$preview_fqdn";
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true);
$docker_compose_domains[$this->serviceName]['domain'] = $this->service->domain = $preview_fqdn;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
} }
// Save the generated domain
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true);
$docker_compose_domains[$this->serviceName]['domain'] = $this->service->domain = $preview_fqdn;
$this->preview->docker_compose_domains = json_encode($docker_compose_domains);
$this->preview->save();
$this->dispatch('update_links'); $this->dispatch('update_links');
$this->dispatch('success', 'Domain generated.'); $this->dispatch('success', 'Domain generated.');
} }
+12 -1
View File
@@ -111,8 +111,19 @@ class Source extends Component
$this->application->update([ $this->application->update([
'source_id' => $sourceId, 'source_id' => $sourceId,
'source_type' => $sourceType, 'source_type' => $sourceType,
'repository_project_id' => null,
]); ]);
['repository' => $customRepository] = $this->application->customRepository();
$repository = githubApi($this->application->source, "repos/{$customRepository}");
$data = data_get($repository, 'data');
$repository_project_id = data_get($data, 'id');
if (isset($repository_project_id)) {
if ($this->application->repository_project_id !== $repository_project_id) {
$this->application->repository_project_id = $repository_project_id;
$this->application->save();
}
}
$this->application->refresh(); $this->application->refresh();
$this->getSources(); $this->getSources();
$this->dispatch('success', 'Source updated!'); $this->dispatch('success', 'Source updated!');
+2 -3
View File
@@ -56,7 +56,6 @@ class CloneMe extends Component
$this->project_id = $this->project->id; $this->project_id = $this->project->id;
$this->servers = currentTeam() $this->servers = currentTeam()
->servers() ->servers()
->with('destinations')
->get() ->get()
->reject(fn ($server) => $server->isBuildServer()); ->reject(fn ($server) => $server->isBuildServer());
$this->newName = str($this->project->name.'-clone-'.(string) new Cuid2)->slug(); $this->newName = str($this->project->name.'-clone-'.(string) new Cuid2)->slug();
@@ -455,7 +454,7 @@ class CloneMe extends Component
if ($this->cloneVolumeData) { if ($this->cloneVolumeData) {
try { try {
StopService::dispatch($application, false, false); StopService::dispatch($application);
$sourceVolume = $volume->name; $sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name; $targetVolume = $newPersistentVolume->name;
$sourceServer = $application->service->destination->server; $sourceServer = $application->service->destination->server;
@@ -509,7 +508,7 @@ class CloneMe extends Component
if ($this->cloneVolumeData) { if ($this->cloneVolumeData) {
try { try {
StopService::dispatch($database->service, false, false); StopService::dispatch($database->service);
$sourceVolume = $volume->name; $sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name; $targetVolume = $newPersistentVolume->name;
$sourceServer = $database->service->destination->server; $sourceServer = $database->service->destination->server;
@@ -73,6 +73,9 @@ class BackupEdit extends Component
#[Validate(['required', 'boolean'])] #[Validate(['required', 'boolean'])]
public bool $dumpAll = false; public bool $dumpAll = false;
#[Validate(['required', 'int', 'min:1', 'max:36000'])]
public int $timeout = 3600;
public function mount() public function mount()
{ {
try { try {
@@ -98,6 +101,7 @@ class BackupEdit extends Component
$this->backup->s3_storage_id = $this->s3StorageId; $this->backup->s3_storage_id = $this->s3StorageId;
$this->backup->databases_to_backup = $this->databasesToBackup; $this->backup->databases_to_backup = $this->databasesToBackup;
$this->backup->dump_all = $this->dumpAll; $this->backup->dump_all = $this->dumpAll;
$this->backup->timeout = $this->timeout;
$this->customValidate(); $this->customValidate();
$this->backup->save(); $this->backup->save();
} else { } else {
@@ -114,6 +118,7 @@ class BackupEdit extends Component
$this->s3StorageId = $this->backup->s3_storage_id; $this->s3StorageId = $this->backup->s3_storage_id;
$this->databasesToBackup = $this->backup->databases_to_backup; $this->databasesToBackup = $this->backup->databases_to_backup;
$this->dumpAll = $this->backup->dump_all; $this->dumpAll = $this->backup->dump_all;
$this->timeout = $this->backup->timeout;
} }
} }
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Database;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackup;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Livewire\Component; use Livewire\Component;
@@ -14,7 +15,19 @@ class BackupExecutions extends Component
public $database; public $database;
public $executions = []; public ?Collection $executions;
public int $executions_count = 0;
public int $skip = 0;
public int $defaultTake = 10;
public bool $showNext = false;
public bool $showPrev = false;
public int $currentPage = 1;
public $setDeletableBackup; public $setDeletableBackup;
@@ -40,6 +53,20 @@ class BackupExecutions extends Component
} }
} }
public function cleanupDeleted()
{
if ($this->backup) {
$deletedCount = $this->backup->executions()->where('local_storage_deleted', true)->count();
if ($deletedCount > 0) {
$this->backup->executions()->where('local_storage_deleted', true)->delete();
$this->refreshBackupExecutions();
$this->dispatch('success', "Cleaned up {$deletedCount} backup entries deleted from local storage.");
} else {
$this->dispatch('info', 'No backup entries found that are deleted from local storage.');
}
}
}
public function deleteBackup($executionId, $password) public function deleteBackup($executionId, $password)
{ {
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
@@ -85,18 +112,74 @@ class BackupExecutions extends Component
public function refreshBackupExecutions(): void public function refreshBackupExecutions(): void
{ {
if ($this->backup && $this->backup->exists) { $this->loadExecutions();
$this->executions = $this->backup->executions()->get()->toArray(); }
} else {
$this->executions = []; public function reloadExecutions()
{
$this->loadExecutions();
}
public function previousPage(?int $take = null)
{
if ($take) {
$this->skip = $this->skip - $take;
} }
$this->skip = $this->skip - $this->defaultTake;
if ($this->skip < 0) {
$this->showPrev = false;
$this->skip = 0;
}
$this->updateCurrentPage();
$this->loadExecutions();
}
public function nextPage(?int $take = null)
{
if ($take) {
$this->skip = $this->skip + $take;
}
$this->showPrev = true;
$this->updateCurrentPage();
$this->loadExecutions();
}
private function loadExecutions()
{
if ($this->backup && $this->backup->exists) {
['executions' => $executions, 'count' => $count] = $this->backup->executionsPaginated($this->skip, $this->defaultTake);
$this->executions = $executions;
$this->executions_count = $count;
} else {
$this->executions = collect([]);
$this->executions_count = 0;
}
$this->showMore();
}
private function showMore()
{
if ($this->executions->count() !== 0) {
$this->showNext = true;
if ($this->executions->count() < $this->defaultTake) {
$this->showNext = false;
}
return;
}
}
private function updateCurrentPage()
{
$this->currentPage = intval($this->skip / $this->defaultTake) + 1;
} }
public function mount(ScheduledDatabaseBackup $backup) public function mount(ScheduledDatabaseBackup $backup)
{ {
$this->backup = $backup; $this->backup = $backup;
$this->database = $backup->database; $this->database = $backup->database;
$this->refreshBackupExecutions(); $this->updateCurrentPage();
$this->loadExecutions();
} }
public function server() public function server()
@@ -121,8 +204,8 @@ class BackupExecutions extends Component
{ {
return view('livewire.project.database.backup-executions', [ return view('livewire.project.database.backup-executions', [
'checkboxes' => [ 'checkboxes' => [
['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently form S3 Storage'], ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'],
// ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'], // ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently from SFTP Storage'],
], ],
]); ]);
} }
+5 -6
View File
@@ -6,7 +6,6 @@ use App\Actions\Docker\GetContainersStatus;
use App\Actions\Service\StartService; use App\Actions\Service\StartService;
use App\Actions\Service\StopService; use App\Actions\Service\StopService;
use App\Enums\ProcessStatus; use App\Enums\ProcessStatus;
use App\Events\ServiceStatusChanged;
use App\Models\Service; use App\Models\Service;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Livewire\Component; use Livewire\Component;
@@ -64,7 +63,7 @@ class Heading extends Component
$this->service->databases->each(function ($database) { $this->service->databases->each(function ($database) {
$database->refresh(); $database->refresh();
}); });
if (is_null($this->service->config_hash) || $this->service->isConfigurationChanged()) { if (is_null($this->service->config_hash)) {
$this->service->isConfigurationChanged(true); $this->service->isConfigurationChanged(true);
} }
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
@@ -96,7 +95,7 @@ class Heading extends Component
public function start() public function start()
{ {
$activity = StartService::run($this->service, pullLatestImages: true); $activity = StartService::run($this->service, pullLatestImages: true);
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); $this->dispatch('activityMonitor', $activity->id);
} }
public function forceDeploy() public function forceDeploy()
@@ -112,7 +111,7 @@ class Heading extends Component
$activity->save(); $activity->save();
} }
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); $this->dispatch('activityMonitor', $activity->id);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
} }
@@ -136,7 +135,7 @@ class Heading extends Component
return; return;
} }
$activity = StartService::run($this->service, stopBeforeStart: true); $activity = StartService::run($this->service, stopBeforeStart: true);
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); $this->dispatch('activityMonitor', $activity->id);
} }
public function pullAndRestartEvent() public function pullAndRestartEvent()
@@ -148,7 +147,7 @@ class Heading extends Component
return; return;
} }
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); $this->dispatch('activityMonitor', $activity->id);
} }
public function render() public function render()
+3 -3
View File
@@ -99,10 +99,10 @@ class Danger extends Component
$this->resource->delete(); $this->resource->delete();
DeleteResourceJob::dispatch( DeleteResourceJob::dispatch(
$this->resource, $this->resource,
$this->delete_configurations,
$this->delete_volumes, $this->delete_volumes,
$this->docker_cleanup, $this->delete_connected_networks,
$this->delete_connected_networks $this->delete_configurations,
$this->docker_cleanup
); );
return redirect()->route('project.resource.index', [ return redirect()->route('project.resource.index', [
@@ -170,6 +170,7 @@ class Show extends Component
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Environment variable updated.'); $this->dispatch('success', 'Environment variable updated.');
$this->dispatch('envsUpdated'); $this->dispatch('envsUpdated');
$this->dispatch('configurationChanged');
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e); return handleError($e);
} }
@@ -137,6 +137,13 @@ class ExecuteContainerCommand extends Component
} }
} }
public function updatedSelectedContainer()
{
if ($this->selected_container !== 'default') {
$this->connectToContainer();
}
}
#[On('connectToServer')] #[On('connectToServer')]
public function connectToServer() public function connectToServer()
{ {
@@ -151,6 +158,9 @@ class ExecuteContainerCommand extends Component
data_get($server, 'name'), data_get($server, 'name'),
data_get($server, 'uuid') data_get($server, 'uuid')
); );
// Dispatch a frontend event to ensure terminal gets focus after connection
$this->dispatch('terminal-should-focus');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@@ -206,6 +216,9 @@ class ExecuteContainerCommand extends Component
data_get($container, 'container.Names'), data_get($container, 'container.Names'),
data_get($container, 'server.uuid') data_get($container, 'server.uuid')
); );
// Dispatch a frontend event to ensure terminal gets focus after connection
$this->dispatch('terminal-should-focus');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} finally { } finally {
@@ -412,7 +412,7 @@ class ResourceOperations extends Component
if ($this->cloneVolumeData) { if ($this->cloneVolumeData) {
try { try {
StopService::dispatch($application, false, false); StopService::dispatch($application);
$sourceVolume = $volume->name; $sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name; $targetVolume = $newPersistentVolume->name;
$sourceServer = $application->service->destination->server; $sourceServer = $application->service->destination->server;
@@ -454,7 +454,7 @@ class ResourceOperations extends Component
if ($this->cloneVolumeData) { if ($this->cloneVolumeData) {
try { try {
StopService::dispatch($database->service, false, false); StopService::dispatch($database->service);
$sourceVolume = $volume->name; $sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name; $targetVolume = $newPersistentVolume->name;
$sourceServer = $database->service->destination->server; $sourceServer = $database->service->destination->server;
+8 -3
View File
@@ -68,11 +68,16 @@ class Terminal extends Component
// Escape the identifier for shell usage // Escape the identifier for shell usage
$escapedIdentifier = escapeshellarg($identifier); $escapedIdentifier = escapeshellarg($identifier);
$command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$escapedIdentifier} sh -c 'PATH=\$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && if [ -f ~/.profile ]; then . ~/.profile; fi && if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
$command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'");
} else { } else {
$command = SshMultiplexingHelper::generateSshCommand($server, 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && if [ -f ~/.profile ]; then . ~/.profile; fi && if [ -n "$SHELL" ]; then exec $SHELL; else sh; fi'); $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
$command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand);
} }
// ssh command is sent back to frontend then to websocket // ssh command is sent back to frontend then to websocket
// this is done because the websocket connection is not available here // this is done because the websocket connection is not available here
// a better solution would be to remove websocket on NodeJS and work with something like // a better solution would be to remove websocket on NodeJS and work with something like
+1 -1
View File
@@ -71,7 +71,7 @@ class DockerCleanup extends Component
public function manualCleanup() public function manualCleanup()
{ {
try { try {
DockerCleanupJob::dispatch($this->server, true); DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks);
$this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.'); $this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
+9 -1
View File
@@ -19,7 +19,15 @@ class Proxy extends Component
public ?string $redirect_url = null; public ?string $redirect_url = null;
protected $listeners = ['saveConfiguration' => 'submit']; public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
'saveConfiguration' => 'submit',
"echo-private:team.{$teamId},ProxyStatusChangedUI" => '$refresh',
];
}
protected $rules = [ protected $rules = [
'server.settings.generate_exact_labels' => 'required|boolean', 'server.settings.generate_exact_labels' => 'required|boolean',
+1
View File
@@ -69,6 +69,7 @@ class Patches extends Component
{ {
if (! $this->packageManager || ! $this->osId) { if (! $this->packageManager || ! $this->osId) {
$this->dispatch('error', message: 'Run “Check for updates” first.'); $this->dispatch('error', message: 'Run “Check for updates” first.');
return; return;
} }
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace App\Livewire\Settings;
use App\Models\InstanceSettings;
use App\Models\Server;
use Auth;
use Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Advanced extends Component
{
#[Validate('required')]
public Server $server;
public InstanceSettings $settings;
#[Validate('boolean')]
public bool $is_registration_enabled;
#[Validate('boolean')]
public bool $do_not_track;
#[Validate('boolean')]
public bool $is_dns_validation_enabled;
#[Validate('nullable|string')]
public ?string $custom_dns_servers = null;
#[Validate('boolean')]
public bool $is_api_enabled;
#[Validate('nullable|string')]
public ?string $allowed_ips = null;
#[Validate('boolean')]
public bool $is_sponsorship_popup_enabled;
#[Validate('boolean')]
public bool $disable_two_step_confirmation;
public function mount()
{
if (! isInstanceAdmin()) {
return redirect()->route('dashboard');
}
$this->server = Server::findOrFail(0);
$this->settings = instanceSettings();
$this->custom_dns_servers = $this->settings->custom_dns_servers;
$this->allowed_ips = $this->settings->allowed_ips;
$this->do_not_track = $this->settings->do_not_track;
$this->is_registration_enabled = $this->settings->is_registration_enabled;
$this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled;
$this->is_api_enabled = $this->settings->is_api_enabled;
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
}
public function submit()
{
try {
$this->validate();
$this->custom_dns_servers = str($this->custom_dns_servers)->replaceEnd(',', '')->trim();
$this->custom_dns_servers = str($this->custom_dns_servers)->trim()->explode(',')->map(function ($dns) {
return str($dns)->trim()->lower();
})->unique()->implode(',');
$this->allowed_ips = str($this->allowed_ips)->replaceEnd(',', '')->trim();
$this->allowed_ips = str($this->allowed_ips)->trim()->explode(',')->map(function ($ip) {
return str($ip)->trim();
})->unique()->implode(',');
$this->instantSave();
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->settings->is_registration_enabled = $this->is_registration_enabled;
$this->settings->do_not_track = $this->do_not_track;
$this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled;
$this->settings->custom_dns_servers = $this->custom_dns_servers;
$this->settings->is_api_enabled = $this->is_api_enabled;
$this->settings->allowed_ips = $this->allowed_ips;
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function toggleTwoStepConfirmation($password): bool
{
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return false;
}
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation = true;
$this->settings->save();
$this->dispatch('success', 'Two step confirmation has been disabled.');
return true;
}
public function render()
{
return view('livewire.settings.advanced');
}
}
+15 -133
View File
@@ -2,11 +2,8 @@
namespace App\Livewire\Settings; namespace App\Livewire\Settings;
use App\Jobs\CheckForUpdatesJob;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Server; use App\Models\Server;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
@@ -15,10 +12,7 @@ class Index extends Component
{ {
public InstanceSettings $settings; public InstanceSettings $settings;
protected Server $server; public Server $server;
#[Validate('boolean')]
public bool $is_auto_update_enabled;
#[Validate('nullable|string|max:255')] #[Validate('nullable|string|max:255')]
public ?string $fqdn = null; public ?string $fqdn = null;
@@ -29,45 +23,18 @@ class Index extends Component
#[Validate('required|integer|min:1025|max:65535')] #[Validate('required|integer|min:1025|max:65535')]
public int $public_port_max; public int $public_port_max;
#[Validate('nullable|string')]
public ?string $custom_dns_servers = null;
#[Validate('nullable|string|max:255')] #[Validate('nullable|string|max:255')]
public ?string $instance_name = null; public ?string $instance_name = null;
#[Validate('nullable|string')]
public ?string $allowed_ips = null;
#[Validate('nullable|string')] #[Validate('nullable|string')]
public ?string $public_ipv4 = null; public ?string $public_ipv4 = null;
#[Validate('nullable|string')] #[Validate('nullable|string')]
public ?string $public_ipv6 = null; public ?string $public_ipv6 = null;
#[Validate('string')]
public string $auto_update_frequency;
#[Validate('string|required')]
public string $update_check_frequency;
#[Validate('required|string|timezone')] #[Validate('required|string|timezone')]
public string $instance_timezone; public string $instance_timezone;
#[Validate('boolean')]
public bool $do_not_track;
#[Validate('boolean')]
public bool $is_registration_enabled;
#[Validate('boolean')]
public bool $is_dns_validation_enabled;
#[Validate('boolean')]
public bool $is_api_enabled;
#[Validate('boolean')]
public bool $disable_two_step_confirmation;
public function render() public function render()
{ {
return view('livewire.settings.index'); return view('livewire.settings.index');
@@ -77,26 +44,16 @@ class Index extends Component
{ {
if (! isInstanceAdmin()) { if (! isInstanceAdmin()) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} else {
$this->settings = instanceSettings();
$this->fqdn = $this->settings->fqdn;
$this->public_port_min = $this->settings->public_port_min;
$this->public_port_max = $this->settings->public_port_max;
$this->custom_dns_servers = $this->settings->custom_dns_servers;
$this->instance_name = $this->settings->instance_name;
$this->allowed_ips = $this->settings->allowed_ips;
$this->public_ipv4 = $this->settings->public_ipv4;
$this->public_ipv6 = $this->settings->public_ipv6;
$this->do_not_track = $this->settings->do_not_track;
$this->is_auto_update_enabled = $this->settings->is_auto_update_enabled;
$this->is_registration_enabled = $this->settings->is_registration_enabled;
$this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled;
$this->is_api_enabled = $this->settings->is_api_enabled;
$this->auto_update_frequency = $this->settings->auto_update_frequency;
$this->update_check_frequency = $this->settings->update_check_frequency;
$this->instance_timezone = $this->settings->instance_timezone;
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
} }
$this->settings = instanceSettings();
$this->server = Server::findOrFail(0);
$this->fqdn = $this->settings->fqdn;
$this->public_port_min = $this->settings->public_port_min;
$this->public_port_max = $this->settings->public_port_max;
$this->instance_name = $this->settings->instance_name;
$this->public_ipv4 = $this->settings->public_ipv4;
$this->public_ipv6 = $this->settings->public_ipv6;
$this->instance_timezone = $this->settings->instance_timezone;
} }
#[Computed] #[Computed]
@@ -111,28 +68,12 @@ class Index extends Component
public function instantSave($isSave = true) public function instantSave($isSave = true)
{ {
$this->validate(); $this->validate();
if ($this->settings->is_auto_update_enabled === true) {
$this->validate([
'auto_update_frequency' => ['required', 'string'],
]);
}
$this->settings->fqdn = $this->fqdn; $this->settings->fqdn = $this->fqdn;
$this->settings->public_port_min = $this->public_port_min; $this->settings->public_port_min = $this->public_port_min;
$this->settings->public_port_max = $this->public_port_max; $this->settings->public_port_max = $this->public_port_max;
$this->settings->custom_dns_servers = $this->custom_dns_servers;
$this->settings->instance_name = $this->instance_name; $this->settings->instance_name = $this->instance_name;
$this->settings->allowed_ips = $this->allowed_ips;
$this->settings->public_ipv4 = $this->public_ipv4; $this->settings->public_ipv4 = $this->public_ipv4;
$this->settings->public_ipv6 = $this->public_ipv6; $this->settings->public_ipv6 = $this->public_ipv6;
$this->settings->do_not_track = $this->do_not_track;
$this->settings->is_auto_update_enabled = $this->is_auto_update_enabled;
$this->settings->is_registration_enabled = $this->is_registration_enabled;
$this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled;
$this->settings->is_api_enabled = $this->is_api_enabled;
$this->settings->auto_update_frequency = $this->auto_update_frequency;
$this->settings->update_check_frequency = $this->update_check_frequency;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->instance_timezone = $this->instance_timezone; $this->settings->instance_timezone = $this->instance_timezone;
if ($isSave) { if ($isSave) {
$this->settings->save(); $this->settings->save();
@@ -144,7 +85,6 @@ class Index extends Component
{ {
try { try {
$error_show = false; $error_show = false;
$this->server = Server::findOrFail(0);
$this->resetErrorBag(); $this->resetErrorBag();
if (! validate_timezone($this->instance_timezone)) { if (! validate_timezone($this->instance_timezone)) {
@@ -161,46 +101,15 @@ class Index extends Component
} }
$this->validate(); $this->validate();
if ($this->is_auto_update_enabled && ! validate_cron_expression($this->auto_update_frequency)) { if ($this->settings->is_dns_validation_enabled && $this->fqdn) {
$this->dispatch('error', 'Invalid Cron / Human expression for Auto Update Frequency.'); if (! validate_dns_entry($this->fqdn, $this->server)) {
if (empty($this->auto_update_frequency)) { $this->dispatch('error', "Validating DNS failed.<br><br>Make sure you have added the DNS records correctly.<br><br>{$this->fqdn}->{$this->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$this->auto_update_frequency = '0 0 * * *';
}
return;
}
if (! validate_cron_expression($this->update_check_frequency)) {
$this->dispatch('error', 'Invalid Cron / Human expression for Update Check Frequency.');
if (empty($this->update_check_frequency)) {
$this->update_check_frequency = '0 * * * *';
}
return;
}
if ($this->settings->is_dns_validation_enabled && $this->settings->fqdn) {
if (! validate_dns_entry($this->settings->fqdn, $this->server)) {
$this->dispatch('error', "Validating DNS failed.<br><br>Make sure you have added the DNS records correctly.<br><br>{$this->settings->fqdn}->{$this->server->ip}<br><br>Check this <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/dns-configuration'>documentation</a> for further help.");
$error_show = true; $error_show = true;
} }
} }
if ($this->settings->fqdn) { if ($this->fqdn) {
check_domain_usage(domain: $this->settings->fqdn); check_domain_usage(domain: $this->fqdn);
} }
$this->settings->custom_dns_servers = str($this->settings->custom_dns_servers)->replaceEnd(',', '')->trim();
$this->settings->custom_dns_servers = str($this->settings->custom_dns_servers)->trim()->explode(',')->map(function ($dns) {
return str($dns)->trim()->lower();
});
$this->settings->custom_dns_servers = $this->settings->custom_dns_servers->unique();
$this->settings->custom_dns_servers = $this->settings->custom_dns_servers->implode(',');
$this->settings->allowed_ips = str($this->settings->allowed_ips)->replaceEnd(',', '')->trim();
$this->settings->allowed_ips = str($this->settings->allowed_ips)->trim()->explode(',')->map(function ($ip) {
return str($ip)->trim();
});
$this->settings->allowed_ips = $this->settings->allowed_ips->unique();
$this->settings->allowed_ips = $this->settings->allowed_ips->implode(',');
$this->instantSave(isSave: false); $this->instantSave(isSave: false);
@@ -213,31 +122,4 @@ class Index extends Component
return handleError($e, $this); return handleError($e, $this);
} }
} }
public function checkManually()
{
CheckForUpdatesJob::dispatchSync();
$this->dispatch('updateAvailable');
$settings = instanceSettings();
if ($settings->new_version_available) {
$this->dispatch('success', 'New version available!');
} else {
$this->dispatch('success', 'No new version available.');
}
}
public function toggleTwoStepConfirmation($password): bool
{
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return false;
}
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation = true;
$this->settings->save();
$this->dispatch('success', 'Two step confirmation has been disabled.');
return true;
}
} }
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Livewire\Settings;
use App\Jobs\CheckForUpdatesJob;
use App\Models\InstanceSettings;
use App\Models\Server;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Updates extends Component
{
public InstanceSettings $settings;
public Server $server;
#[Validate('string')]
public string $auto_update_frequency;
#[Validate('string|required')]
public string $update_check_frequency;
#[Validate('boolean')]
public bool $is_auto_update_enabled;
public function mount()
{
$this->server = Server::findOrFail(0);
$this->settings = instanceSettings();
$this->auto_update_frequency = $this->settings->auto_update_frequency;
$this->update_check_frequency = $this->settings->update_check_frequency;
$this->is_auto_update_enabled = $this->settings->is_auto_update_enabled;
}
public function instantSave()
{
try {
if ($this->settings->is_auto_update_enabled === true) {
$this->validate([
'auto_update_frequency' => ['required', 'string'],
]);
}
$this->settings->auto_update_frequency = $this->auto_update_frequency;
$this->settings->update_check_frequency = $this->update_check_frequency;
$this->settings->is_auto_update_enabled = $this->is_auto_update_enabled;
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {
$this->resetErrorBag();
$this->validate();
if ($this->is_auto_update_enabled && ! validate_cron_expression($this->auto_update_frequency)) {
$this->dispatch('error', 'Invalid Cron / Human expression for Auto Update Frequency.');
if (empty($this->auto_update_frequency)) {
$this->auto_update_frequency = '0 0 * * *';
}
return;
}
if (! validate_cron_expression($this->update_check_frequency)) {
$this->dispatch('error', 'Invalid Cron / Human expression for Update Check Frequency.');
if (empty($this->update_check_frequency)) {
$this->update_check_frequency = '0 * * * *';
}
return;
}
$this->instantSave();
$this->server->setupDynamicProxyConfiguration();
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function checkManually()
{
CheckForUpdatesJob::dispatchSync();
$this->dispatch('updateAvailable');
$settings = instanceSettings();
if ($settings->new_version_available) {
$this->dispatch('success', 'New version available!');
} else {
$this->dispatch('success', 'No new version available.');
}
}
public function render()
{
return view('livewire.settings.updates');
}
}
+24 -25
View File
@@ -46,32 +46,31 @@ class SettingsBackup extends Component
{ {
if (! isInstanceAdmin()) { if (! isInstanceAdmin()) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} else {
$settings = instanceSettings();
$this->server = Server::findOrFail(0);
$this->database = StandalonePostgresql::whereName('coolify-db')->first();
$s3s = S3Storage::whereTeamId(0)->get() ?? [];
if ($this->database) {
$this->uuid = $this->database->uuid;
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->postgres_user = $this->database->postgres_user;
$this->postgres_password = $this->database->postgres_password;
if ($this->database->status !== 'running') {
$this->database->status = 'running';
$this->database->save();
}
$this->backup = $this->database->scheduledBackups->first();
if ($this->backup && ! $this->server->isFunctional()) {
$this->backup->enabled = false;
$this->backup->save();
}
$this->executions = $this->backup->executions;
}
$this->settings = $settings;
$this->s3s = $s3s;
} }
$settings = instanceSettings();
$this->server = Server::findOrFail(0);
$this->database = StandalonePostgresql::whereName('coolify-db')->first();
$s3s = S3Storage::whereTeamId(0)->get() ?? [];
if ($this->database) {
$this->uuid = $this->database->uuid;
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->postgres_user = $this->database->postgres_user;
$this->postgres_password = $this->database->postgres_password;
if ($this->database->status !== 'running') {
$this->database->status = 'running';
$this->database->save();
}
$this->backup = $this->database->scheduledBackups->first();
if ($this->backup && ! $this->server->isFunctional()) {
$this->backup->enabled = false;
$this->backup->save();
}
$this->executions = $this->backup->executions;
}
$this->settings = $settings;
$this->s3s = $s3s;
} }
public function addCoolifyDatabase() public function addCoolifyDatabase()
+4 -2
View File
@@ -31,7 +31,7 @@ class Form extends Component
'storage.endpoint' => 'Endpoint', 'storage.endpoint' => 'Endpoint',
]; ];
public function test_s3_connection() public function testConnection()
{ {
try { try {
$this->storage->testConnection(shouldSave: true); $this->storage->testConnection(shouldSave: true);
@@ -45,6 +45,8 @@ class Form extends Component
public function delete() public function delete()
{ {
try { try {
$this->authorize('delete', $this->storage);
$this->storage->delete(); $this->storage->delete();
return redirect()->route('storage.index'); return redirect()->route('storage.index');
@@ -57,7 +59,7 @@ class Form extends Component
{ {
$this->validate(); $this->validate();
try { try {
$this->test_s3_connection(); $this->testConnection();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
+12 -61
View File
@@ -3,7 +3,6 @@
namespace App\Livewire\Team; namespace App\Livewire\Team;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -53,30 +52,12 @@ class AdminView extends Component
} }
} }
private function finalizeDeletion(User $user, Team $team)
{
$servers = $team->servers;
foreach ($servers as $server) {
$resources = $server->definedResources();
foreach ($resources as $resource) {
$resource->forceDelete();
}
$server->forceDelete();
}
$projects = $team->projects;
foreach ($projects as $project) {
$project->forceDelete();
}
$team->members()->detach($user->id);
$team->delete();
}
public function delete($id, $password) public function delete($id, $password)
{ {
if (! isInstanceAdmin()) { if (! isInstanceAdmin()) {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
} }
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) { if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.'); $this->addError('password', 'The provided password is incorrect.');
@@ -84,52 +65,22 @@ class AdminView extends Component
return; return;
} }
} }
if (! auth()->user()->isInstanceAdmin()) { if (! auth()->user()->isInstanceAdmin()) {
return $this->dispatch('error', 'You are not authorized to delete users'); return $this->dispatch('error', 'You are not authorized to delete users');
} }
$user = User::find($id); $user = User::find($id);
$teams = $user->teams; if (! $user) {
foreach ($teams as $team) { return $this->dispatch('error', 'User not found');
$user_alone_in_team = $team->members->count() === 1; }
if ($team->id === 0) {
if ($user_alone_in_team) { try {
return $this->dispatch('error', 'User is alone in the root team, cannot delete'); $user->delete();
} $this->getUsers();
} } catch (\Exception $e) {
if ($user_alone_in_team) { return $this->dispatch('error', $e->getMessage());
$this->finalizeDeletion($user, $team);
continue;
}
if ($user->isOwner()) {
$found_other_owner_or_admin = $team->members->filter(function ($member) {
return $member->pivot->role === 'owner' || $member->pivot->role === 'admin';
})->where('id', '!=', $user->id)->first();
if ($found_other_owner_or_admin) {
$team->members()->detach($user->id);
continue;
} else {
$found_other_member_who_is_not_owner = $team->members->filter(function ($member) {
return $member->pivot->role === 'member';
})->first();
if ($found_other_member_who_is_not_owner) {
$found_other_member_who_is_not_owner->pivot->role = 'owner';
$found_other_member_who_is_not_owner->pivot->save();
$team->members()->detach($user->id);
} else {
$this->finalizeDeletion($user, $team);
}
continue;
}
} else {
$team->members()->detach($user->id);
}
} }
$user->delete();
$this->getUsers();
} }
public function render() public function render()
+8 -2
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Team; namespace App\Livewire\Team;
use App\Models\TeamInvitation; use App\Models\TeamInvitation;
use App\Models\User;
use Livewire\Component; use Livewire\Component;
class Invitations extends Component class Invitations extends Component
@@ -14,8 +15,13 @@ class Invitations extends Component
public function deleteInvitation(int $invitation_id) public function deleteInvitation(int $invitation_id)
{ {
try { try {
$initiation_found = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id); $invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id);
$initiation_found->delete(); $user = User::whereEmail($invitation->email)->first();
if (filled($user)) {
$user->deleteIfNotVerifiedAndForcePasswordReset();
}
$invitation->delete();
$this->refreshInvitations(); $this->refreshInvitations();
$this->dispatch('success', 'Invitation revoked.'); $this->dispatch('success', 'Invitation revoked.');
} catch (\Exception) { } catch (\Exception) {
+3 -3
View File
@@ -29,15 +29,15 @@ class InviteLink extends Component
public function viaEmail() public function viaEmail()
{ {
$this->generate_invite_link(sendEmail: true); $this->generateInviteLink(sendEmail: true);
} }
public function viaLink() public function viaLink()
{ {
$this->generate_invite_link(sendEmail: false); $this->generateInviteLink(sendEmail: false);
} }
private function generate_invite_link(bool $sendEmail = false) private function generateInviteLink(bool $sendEmail = false)
{ {
try { try {
$this->validate(); $this->validate();
+7 -30
View File
@@ -798,7 +798,7 @@ class Application extends BaseModel
public function previews() public function previews()
{ {
return $this->hasMany(ApplicationPreview::class); return $this->hasMany(ApplicationPreview::class)->orderBy('pull_request_id', 'desc');
} }
public function deployment_queue() public function deployment_queue()
@@ -836,9 +836,14 @@ class Application extends BaseModel
return ApplicationDeploymentQueue::where('application_id', $this->id)->where('created_at', '>=', now()->subDays(7))->orderBy('created_at', 'desc')->get(); return ApplicationDeploymentQueue::where('application_id', $this->id)->where('created_at', '>=', now()->subDays(7))->orderBy('created_at', 'desc')->get();
} }
public function deployments(int $skip = 0, int $take = 10) public function deployments(int $skip = 0, int $take = 10, ?string $pullRequestId = null)
{ {
$deployments = ApplicationDeploymentQueue::where('application_id', $this->id)->orderBy('created_at', 'desc'); $deployments = ApplicationDeploymentQueue::where('application_id', $this->id)->orderBy('created_at', 'desc');
if ($pullRequestId) {
$deployments = $deployments->where('pull_request_id', $pullRequestId);
}
$count = $deployments->count(); $count = $deployments->count();
$deployments = $deployments->skip($skip)->take($take)->get(); $deployments = $deployments->skip($skip)->take($take)->get();
@@ -1578,34 +1583,6 @@ class Application extends BaseModel
} }
} }
public function generate_preview_fqdn(int $pull_request_id)
{
$preview = ApplicationPreview::findPreviewByApplicationAndPullId($this->id, $pull_request_id);
if (is_null(data_get($preview, 'fqdn')) && $this->fqdn) {
if (str($this->fqdn)->contains(',')) {
$url = Url::fromString(str($this->fqdn)->explode(',')[0]);
$preview_fqdn = getFqdnWithoutPort(str($this->fqdn)->explode(',')[0]);
} else {
$url = Url::fromString($this->fqdn);
if (data_get($preview, 'fqdn')) {
$preview_fqdn = getFqdnWithoutPort(data_get($preview, 'fqdn'));
}
}
$template = $this->preview_url_template;
$host = $url->getHost();
$schema = $url->getScheme();
$random = new Cuid2;
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $pull_request_id, $preview_fqdn);
$preview_fqdn = "$schema://$preview_fqdn";
$preview->fqdn = $preview_fqdn;
$preview->save();
}
return $preview;
}
public static function getDomainsByUuid(string $uuid): array public static function getDomainsByUuid(string $uuid): array
{ {
$application = self::where('uuid', $uuid)->first(); $application = self::where('uuid', $uuid)->first();
+105 -13
View File
@@ -2,19 +2,25 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Url\Url; use Spatie\Url\Url;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
class ApplicationPreview extends BaseModel class ApplicationPreview extends BaseModel
{ {
use SoftDeletes;
protected $guarded = []; protected $guarded = [];
protected static function booted() protected static function booted()
{ {
static::deleting(function ($preview) { static::forceDeleting(function ($preview) {
$server = $preview->application->destination->server;
$application = $preview->application;
if (data_get($preview, 'application.build_pack') === 'dockercompose') { if (data_get($preview, 'application.build_pack') === 'dockercompose') {
$server = $preview->application->destination->server; // Docker Compose volume and network cleanup
$composeFile = $preview->application->parse(pull_request_id: $preview->pull_request_id); $composeFile = $application->parse(pull_request_id: $preview->pull_request_id);
$volumes = data_get($composeFile, 'volumes'); $volumes = data_get($composeFile, 'volumes');
$networks = data_get($composeFile, 'networks'); $networks = data_get($composeFile, 'networks');
$networkKeys = collect($networks)->keys(); $networkKeys = collect($networks)->keys();
@@ -26,7 +32,18 @@ class ApplicationPreview extends BaseModel
instant_remote_process(["docker network disconnect $key coolify-proxy"], $server, false); instant_remote_process(["docker network disconnect $key coolify-proxy"], $server, false);
instant_remote_process(["docker network rm $key"], $server, false); instant_remote_process(["docker network rm $key"], $server, false);
}); });
} else {
// Regular application volume cleanup
$persistentStorages = $preview->persistentStorages()->get() ?? collect();
if ($persistentStorages->count() > 0) {
foreach ($persistentStorages as $storage) {
instant_remote_process(["docker volume rm -f $storage->name"], $server, false);
}
}
} }
// Clean up persistent storage records
$preview->persistentStorages()->delete();
}); });
static::saving(function ($preview) { static::saving(function ($preview) {
if ($preview->isDirty('status')) { if ($preview->isDirty('status')) {
@@ -50,12 +67,23 @@ class ApplicationPreview extends BaseModel
return $this->belongsTo(Application::class); return $this->belongsTo(Application::class);
} }
public function generate_preview_fqdn_compose() public function persistentStorages()
{ {
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect(); return $this->morphMany(\App\Models\LocalPersistentVolume::class, 'resource');
foreach ($domains as $service_name => $domain) { }
$domain = data_get($domain, 'domain');
$url = Url::fromString($domain); public function generate_preview_fqdn()
{
if (is_null($this->fqdn) && $this->application->fqdn) {
if (str($this->application->fqdn)->contains(',')) {
$url = Url::fromString(str($this->application->fqdn)->explode(',')[0]);
$preview_fqdn = getFqdnWithoutPort(str($this->application->fqdn)->explode(',')[0]);
} else {
$url = Url::fromString($this->application->fqdn);
if ($this->fqdn) {
$preview_fqdn = getFqdnWithoutPort($this->fqdn);
}
}
$template = $this->application->preview_url_template; $template = $this->application->preview_url_template;
$host = $url->getHost(); $host = $url->getHost();
$schema = $url->getScheme(); $schema = $url->getScheme();
@@ -64,12 +92,76 @@ class ApplicationPreview extends BaseModel
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
$preview_fqdn = "$schema://$preview_fqdn"; $preview_fqdn = "$schema://$preview_fqdn";
$docker_compose_domains = data_get($this, 'docker_compose_domains'); $this->fqdn = $preview_fqdn;
$docker_compose_domains = json_decode($docker_compose_domains, true);
$docker_compose_domains[$service_name]['domain'] = $preview_fqdn;
$docker_compose_domains = json_encode($docker_compose_domains);
$this->docker_compose_domains = $docker_compose_domains;
$this->save(); $this->save();
} }
return $this;
}
public function generate_preview_fqdn_compose()
{
$services = collect(json_decode($this->application->docker_compose_domains)) ?? collect();
$docker_compose_domains = data_get($this, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?? [];
// Get all services from the parsed compose file to ensure all services have entries
$parsedServices = $this->application->parse(pull_request_id: $this->pull_request_id);
if (isset($parsedServices['services'])) {
foreach ($parsedServices['services'] as $serviceName => $service) {
if (! isDatabaseImage(data_get($service, 'image'))) {
// Remove PR suffix from service name to get original service name
$originalServiceName = str($serviceName)->replaceLast('-pr-'.$this->pull_request_id, '')->toString();
// Ensure all services have an entry, even if empty
if (! $services->has($originalServiceName)) {
$services->put($originalServiceName, ['domain' => '']);
}
}
}
}
foreach ($services as $service_name => $service_config) {
$domain_string = data_get($service_config, 'domain');
// If domain string is empty or null, don't auto-generate domain
// Only generate domains when main app already has domains set
if (empty($domain_string)) {
// Ensure service has an empty domain entry for form binding
$docker_compose_domains[$service_name]['domain'] = '';
continue;
}
$service_domains = str($domain_string)->explode(',')->map(fn ($d) => trim($d));
$preview_domains = [];
foreach ($service_domains as $domain) {
if (empty($domain)) {
continue;
}
$url = Url::fromString($domain);
$template = $this->application->preview_url_template;
$host = $url->getHost();
$schema = $url->getScheme();
$random = new Cuid2;
$preview_fqdn = str_replace('{{random}}', $random, $template);
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn);
$preview_fqdn = "$schema://$preview_fqdn";
$preview_domains[] = $preview_fqdn;
}
if (! empty($preview_domains)) {
$docker_compose_domains[$service_name]['domain'] = implode(',', $preview_domains);
} else {
// Ensure service has an empty domain entry for form binding
$docker_compose_domains[$service_name]['domain'] = '';
}
}
$this->docker_compose_domains = json_encode($docker_compose_domains);
$this->save();
} }
} }
+8 -1
View File
@@ -118,7 +118,14 @@ class EnvironmentVariable extends BaseModel
return null; return null;
} }
return $this->get_real_environment_variables($this->value, $resource); $real_value = $this->get_real_environment_variables($this->value, $resource);
if ($this->is_literal || $this->is_multiline) {
$real_value = '\''.$real_value.'\'';
} else {
$real_value = escapeEnvVariables($real_value);
}
return $real_value;
} }
); );
} }
+1
View File
@@ -27,6 +27,7 @@ class OauthSetting extends Model
case 'azure': case 'azure':
return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant); return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant);
case 'authentik': case 'authentik':
case 'clerk':
return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url); return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url);
default: default:
return filled($this->client_id) && filled($this->client_secret); return filled($this->client_id) && filled($this->client_secret);
+12
View File
@@ -36,6 +36,18 @@ class ScheduledDatabaseBackup extends BaseModel
return $this->hasMany(ScheduledDatabaseBackupExecution::class)->where('created_at', '>=', now()->subDays($days))->get(); return $this->hasMany(ScheduledDatabaseBackupExecution::class)->where('created_at', '>=', now()->subDays($days))->get();
} }
public function executionsPaginated(int $skip = 0, int $take = 10)
{
$executions = $this->hasMany(ScheduledDatabaseBackupExecution::class)->orderBy('created_at', 'desc');
$count = $executions->count();
$executions = $executions->skip($skip)->take($take)->get();
return [
'count' => $count,
'executions' => $executions,
];
}
public function server() public function server()
{ {
if ($this->database) { if ($this->database) {
+1 -1
View File
@@ -887,7 +887,7 @@ $schema://$host {
public function muxFilename() public function muxFilename()
{ {
return $this->uuid; return 'mux_'.$this->uuid;
} }
public function team() public function team()
+25 -16
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\ProcessStatus;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Spatie\Activitylog\Models\Activity;
use Spatie\Url\Url; use Spatie\Url\Url;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
@@ -116,6 +118,18 @@ class Service extends BaseModel
return (bool) str($this->status)->contains('exited'); return (bool) str($this->status)->contains('exited');
} }
public function isStarting(): bool
{
try {
$activity = Activity::where('properties->type_uuid', $this->uuid)->latest()->first();
$status = data_get($activity, 'properties.status');
return $status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value;
} catch (\Throwable) {
return false;
}
}
public function type() public function type()
{ {
return 'service'; return 'service';
@@ -159,6 +173,10 @@ class Service extends BaseModel
public function getStatusAttribute() public function getStatusAttribute()
{ {
if ($this->isStarting()) {
return 'starting:unhealthy';
}
$applications = $this->applications; $applications = $this->applications;
$databases = $this->databases; $databases = $this->databases;
@@ -1242,26 +1260,17 @@ class Service extends BaseModel
return 3; return 3;
}); });
$envs = collect([]);
foreach ($sorted as $env) { foreach ($sorted as $env) {
if (version_compare($env->version, '4.0.0-beta.347', '<=')) { $envs->push("{$env->key}={$env->real_value}");
$commands[] = "echo '{$env->key}={$env->real_value}' >> .env";
} else {
$real_value = $env->real_value;
if ($env->version === '4.0.0-beta.239') {
$real_value = $env->real_value;
} else {
if ($env->is_literal || $env->is_multiline) {
$real_value = '\''.$real_value.'\'';
} else {
$real_value = escapeEnvVariables($env->real_value);
}
}
$commands[] = "echo \"{$env->key}={$real_value}\" >> .env";
}
} }
if ($sorted->count() === 0) { if ($envs->count() === 0) {
$commands[] = 'touch .env'; $commands[] = 'touch .env';
} else {
$envs_base64 = base64_encode($envs->implode("\n"));
$commands[] = "echo '$envs_base64' | base64 -d | tee .env > /dev/null";
} }
instant_remote_process($commands, $this->server); instant_remote_process($commands, $this->server);
} }
+4
View File
@@ -33,6 +33,10 @@ class TeamInvitation extends Model
return true; return true;
} else { } else {
$this->delete(); $this->delete();
$user = User::whereEmail($this->email)->first();
if (filled($user)) {
$user->deleteIfNotVerifiedAndForcePasswordReset();
}
return false; return false;
} }
+87
View File
@@ -72,6 +72,93 @@ class User extends Authenticatable implements SendsEmail
$new_team = Team::create($team); $new_team = Team::create($team);
$user->teams()->attach($new_team, ['role' => 'owner']); $user->teams()->attach($new_team, ['role' => 'owner']);
}); });
static::deleting(function (User $user) {
\DB::transaction(function () use ($user) {
$teams = $user->teams;
foreach ($teams as $team) {
$user_alone_in_team = $team->members->count() === 1;
// Prevent deletion if user is alone in root team
if ($team->id === 0 && $user_alone_in_team) {
throw new \Exception('User is alone in the root team, cannot delete');
}
if ($user_alone_in_team) {
static::finalizeTeamDeletion($user, $team);
// Delete any pending team invitations for this user
TeamInvitation::whereEmail($user->email)->delete();
continue;
}
// Load the user's role for this team
$userRole = $team->members->where('id', $user->id)->first()?->pivot?->role;
if ($userRole === 'owner') {
$found_other_owner_or_admin = $team->members->filter(function ($member) use ($user) {
return ($member->pivot->role === 'owner' || $member->pivot->role === 'admin') && $member->id !== $user->id;
})->first();
if ($found_other_owner_or_admin) {
$team->members()->detach($user->id);
continue;
} else {
$found_other_member_who_is_not_owner = $team->members->filter(function ($member) {
return $member->pivot->role === 'member';
})->first();
if ($found_other_member_who_is_not_owner) {
$found_other_member_who_is_not_owner->pivot->role = 'owner';
$found_other_member_who_is_not_owner->pivot->save();
$team->members()->detach($user->id);
} else {
static::finalizeTeamDeletion($user, $team);
}
continue;
}
} else {
$team->members()->detach($user->id);
}
}
});
});
}
/**
* Finalize team deletion by cleaning up all associated resources
*/
private static function finalizeTeamDeletion(User $user, Team $team)
{
$servers = $team->servers;
foreach ($servers as $server) {
$resources = $server->definedResources();
foreach ($resources as $resource) {
$resource->forceDelete();
}
$server->forceDelete();
}
$projects = $team->projects;
foreach ($projects as $project) {
$project->forceDelete();
}
$team->members()->detach($user->id);
$team->delete();
}
/**
* Delete the user if they are not verified and have a force password reset.
* This is used to clean up users that have been invited, did not accept the invitation (and did not verify their email and have a force password reset).
*/
public function deleteIfNotVerifiedAndForcePasswordReset()
{
if ($this->hasVerifiedEmail() === false && $this->force_password_reset === true) {
$this->delete();
}
} }
public function recreate_personal_team() public function recreate_personal_team()
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Policies;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\User;
class S3StoragePolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, S3Storage $storage): bool
{
return $user->teams()->where('id', $storage->team_id)->exists();
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Server $server): bool
{
return $user->teams()->get()->firstWhere('id', $server->team_id) !== null;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, S3Storage $storage): bool
{
return $user->teams()->where('id', $storage->team_id)->exists();
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, S3Storage $storage): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, S3Storage $storage): bool
{
return false;
}
}
+6
View File
@@ -9,9 +9,12 @@ use Illuminate\Foundation\Events\MaintenanceModeEnabled;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use SocialiteProviders\Authentik\AuthentikExtendSocialite; use SocialiteProviders\Authentik\AuthentikExtendSocialite;
use SocialiteProviders\Azure\AzureExtendSocialite; use SocialiteProviders\Azure\AzureExtendSocialite;
use SocialiteProviders\Clerk\ClerkExtendSocialite;
use SocialiteProviders\Discord\DiscordExtendSocialite;
use SocialiteProviders\Google\GoogleExtendSocialite; use SocialiteProviders\Google\GoogleExtendSocialite;
use SocialiteProviders\Infomaniak\InfomaniakExtendSocialite; use SocialiteProviders\Infomaniak\InfomaniakExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled; use SocialiteProviders\Manager\SocialiteWasCalled;
use SocialiteProviders\Zitadel\ZitadelExtendSocialite;
class EventServiceProvider extends ServiceProvider class EventServiceProvider extends ServiceProvider
{ {
@@ -25,8 +28,11 @@ class EventServiceProvider extends ServiceProvider
SocialiteWasCalled::class => [ SocialiteWasCalled::class => [
AzureExtendSocialite::class.'@handle', AzureExtendSocialite::class.'@handle',
AuthentikExtendSocialite::class.'@handle', AuthentikExtendSocialite::class.'@handle',
ClerkExtendSocialite::class.'@handle',
DiscordExtendSocialite::class.'@handle',
GoogleExtendSocialite::class.'@handle', GoogleExtendSocialite::class.'@handle',
InfomaniakExtendSocialite::class.'@handle', InfomaniakExtendSocialite::class.'@handle',
ZitadelExtendSocialite::class.'@handle',
], ],
]; ];
+1 -1
View File
@@ -49,7 +49,7 @@ class RouteServiceProvider extends ServiceProvider
return Limit::perMinute(1000)->by($request->user()?->id ?: $request->ip()); return Limit::perMinute(1000)->by($request->user()?->id ?: $request->ip());
} }
return Limit::perMinute(200)->by($request->user()?->id ?: $request->ip()); return Limit::perMinute((int) config('api.rate_limit'))->by($request->user()?->id ?: $request->ip());
}); });
RateLimiter::for('5', function (Request $request) { RateLimiter::for('5', function (Request $request) {
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip()); return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
@@ -1,13 +1,13 @@
<?php <?php
namespace App\View\Components\services; namespace App\View\Components\Services;
use App\Models\Service; use App\Models\Service;
use Closure; use Closure;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\View\Component; use Illuminate\View\Component;
class advanced extends Component class Advanced extends Component
{ {
/** /**
* Create a new component instance. * Create a new component instance.
+3 -1
View File
@@ -359,7 +359,9 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
{ {
$labels = collect([]); $labels = collect([]);
$labels->push('traefik.enable=true'); $labels->push('traefik.enable=true');
$labels->push('traefik.http.middlewares.gzip.compress=true'); if ($is_gzip_enabled) {
$labels->push('traefik.http.middlewares.gzip.compress=true');
}
$labels->push('traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https'); $labels->push('traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https');
$is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null; $is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;
+55 -52
View File
@@ -119,70 +119,62 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$resourceFqdns = str($resource->fqdn)->explode(','); $resourceFqdns = str($resource->fqdn)->explode(',');
if ($resourceFqdns->count() === 1) { if ($resourceFqdns->count() === 1) {
$resourceFqdns = $resourceFqdns->first(); $resourceFqdns = $resourceFqdns->first();
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', ''); $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
$generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', $variableName)
->first();
$fqdn = Url::fromString($resourceFqdns); $fqdn = Url::fromString($resourceFqdns);
$port = $fqdn->getPort(); $port = $fqdn->getPort();
$path = $fqdn->getPath(); $path = $fqdn->getPath();
$fqdn = $fqdn->getScheme().'://'.$fqdn->getHost(); $fqdn = $fqdn->getScheme().'://'.$fqdn->getHost();
if ($generatedEnv) { $fqdnValue = ($path === '/') ? $fqdn : $fqdn.$path;
if ($path === '/') { EnvironmentVariable::updateOrCreate([
$generatedEnv->value = $fqdn; 'resourceable_type' => Service::class,
} else { 'resourceable_id' => $resource->service_id,
$generatedEnv->value = $fqdn.$path; 'key' => $variableName,
} ], [
$generatedEnv->save(); 'value' => $fqdnValue,
} 'is_build_time' => false,
'is_preview' => false,
]);
if ($port) { if ($port) {
$variableName = $variableName."_$port"; $variableName = $variableName."_$port";
$generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class) EnvironmentVariable::updateOrCreate([
->where('resourceable_id', $resource->service_id) 'resourceable_type' => Service::class,
->where('key', $variableName) 'resourceable_id' => $resource->service_id,
->first(); 'key' => $variableName,
if ($generatedEnv) { ], [
if ($path === '/') { 'value' => $fqdnValue,
$generatedEnv->value = $fqdn; 'is_build_time' => false,
} else { 'is_preview' => false,
$generatedEnv->value = $fqdn.$path; ]);
}
$generatedEnv->save();
}
} }
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', ''); $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
$generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', $variableName)
->first();
$url = Url::fromString($fqdn); $url = Url::fromString($fqdn);
$port = $url->getPort(); $port = $url->getPort();
$path = $url->getPath(); $path = $url->getPath();
$url = $url->getHost(); $url = $url->getHost();
if ($generatedEnv) { $urlValue = str($fqdn)->after('://');
$url = str($fqdn)->after('://'); if ($path !== '/') {
if ($path === '/') { $urlValue = $urlValue.$path;
$generatedEnv->value = $url;
} else {
$generatedEnv->value = $url.$path;
}
$generatedEnv->save();
} }
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
if ($port) { if ($port) {
$variableName = $variableName."_$port"; $variableName = $variableName."_$port";
$generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class) EnvironmentVariable::updateOrCreate([
->where('resourceable_id', $resource->service_id) 'resourceable_type' => Service::class,
->where('key', $variableName) 'resourceable_id' => $resource->service_id,
->first(); 'key' => $variableName,
if ($generatedEnv) { ], [
if ($path === '/') { 'value' => $urlValue,
$generatedEnv->value = $url; 'is_build_time' => false,
} else { 'is_preview' => false,
$generatedEnv->value = $url.$path; ]);
}
$generatedEnv->save();
}
} }
} elseif ($resourceFqdns->count() > 1) { } elseif ($resourceFqdns->count() > 1) {
foreach ($resourceFqdns as $fqdn) { foreach ($resourceFqdns as $fqdn) {
@@ -243,7 +235,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$port_env_url->save(); $port_env_url->save();
} }
} else { } else {
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', ''); $variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
$generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class) $generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id) ->where('resourceable_id', $resource->service_id)
->where('key', $variableName) ->where('key', $variableName)
@@ -254,7 +246,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$generatedEnv->value = $fqdn; $generatedEnv->value = $fqdn;
$generatedEnv->save(); $generatedEnv->save();
} }
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', ''); $variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
$generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class) $generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id) ->where('resourceable_id', $resource->service_id)
->where('key', $variableName) ->where('key', $variableName)
@@ -269,6 +261,17 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
} }
} }
} }
} else {
// If FQDN is removed, delete the corresponding environment variables
$serviceName = str($resource->name)->upper()->replace('-', '_');
EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceName}%")
->delete();
EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', 'LIKE', "SERVICE_URL_{$serviceName}%")
->delete();
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e); return handleError($e);
+80 -25
View File
@@ -599,7 +599,15 @@ function getTopLevelNetworks(Service|Application $resource)
try { try {
$yaml = Yaml::parse($resource->docker_compose_raw); $yaml = Yaml::parse($resource->docker_compose_raw);
} catch (\Exception $e) { } catch (\Exception $e) {
throw new \RuntimeException($e->getMessage()); // If the docker-compose.yml file is not valid, we will return the network name as the key
$topLevelNetworks = collect([
$resource->uuid => [
'name' => $resource->uuid,
'external' => true,
],
]);
return $topLevelNetworks->keys();
} }
$services = data_get($yaml, 'services'); $services = data_get($yaml, 'services');
$topLevelNetworks = collect(data_get($yaml, 'networks', [])); $topLevelNetworks = collect(data_get($yaml, 'networks', []));
@@ -653,9 +661,16 @@ function getTopLevelNetworks(Service|Application $resource)
try { try {
$yaml = Yaml::parse($resource->docker_compose_raw); $yaml = Yaml::parse($resource->docker_compose_raw);
} catch (\Exception $e) { } catch (\Exception $e) {
throw new \RuntimeException($e->getMessage()); // If the docker-compose.yml file is not valid, we will return the network name as the key
$topLevelNetworks = collect([
$resource->uuid => [
'name' => $resource->uuid,
'external' => true,
],
]);
return $topLevelNetworks->keys();
} }
$server = $resource->destination->server;
$topLevelNetworks = collect(data_get($yaml, 'networks', [])); $topLevelNetworks = collect(data_get($yaml, 'networks', []));
$services = data_get($yaml, 'services'); $services = data_get($yaml, 'services');
$definedNetwork = collect([$resource->uuid]); $definedNetwork = collect([$resource->uuid]);
@@ -2931,7 +2946,6 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
} catch (\Exception) { } catch (\Exception) {
return collect([]); return collect([]);
} }
$services = data_get($yaml, 'services', collect([])); $services = data_get($yaml, 'services', collect([]));
$topLevel = collect([ $topLevel = collect([
'volumes' => collect(data_get($yaml, 'volumes', [])), 'volumes' => collect(data_get($yaml, 'volumes', [])),
@@ -2991,12 +3005,6 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first(); $applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($applicationFound) { if ($applicationFound) {
$savedService = $applicationFound; $savedService = $applicationFound;
// $savedService = ServiceDatabase::firstOrCreate([
// 'name' => $applicationFound->name,
// 'image' => $applicationFound->image,
// 'service_id' => $applicationFound->service_id,
// ]);
// $applicationFound->delete();
} else { } else {
$savedService = ServiceDatabase::firstOrCreate([ $savedService = ServiceDatabase::firstOrCreate([
'name' => $serviceName, 'name' => $serviceName,
@@ -3007,15 +3015,22 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
$savedService = ServiceApplication::firstOrCreate([ $savedService = ServiceApplication::firstOrCreate([
'name' => $serviceName, 'name' => $serviceName,
'service_id' => $resource->id, 'service_id' => $resource->id,
], [
'is_gzip_enabled' => true,
]); ]);
} }
// Check if image changed // Check if image changed
if ($savedService->image !== $image) { if ($savedService->image !== $image) {
$savedService->image = $image; $savedService->image = $image;
$savedService->save(); $savedService->save();
} }
// Pocketbase does not need gzip for SSE.
if (str($savedService->image)->contains('pocketbase') && $savedService->is_gzip_enabled) {
$savedService->is_gzip_enabled = false;
$savedService->save();
}
} }
$environment = collect(data_get($service, 'environment', [])); $environment = collect(data_get($service, 'environment', []));
$buildArgs = collect(data_get($service, 'build.args', [])); $buildArgs = collect(data_get($service, 'build.args', []));
$environment = $environment->merge($buildArgs); $environment = $environment->merge($buildArgs);
@@ -3034,7 +3049,6 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
// Get all SERVICE_ variables from keys and values // Get all SERVICE_ variables from keys and values
$key = str($key); $key = str($key);
$value = str($value); $value = str($value);
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/'; $regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
preg_match_all($regex, $value, $valueMatches); preg_match_all($regex, $value, $valueMatches);
if (count($valueMatches[1]) > 0) { if (count($valueMatches[1]) > 0) {
@@ -3048,7 +3062,6 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
} }
} }
} }
// Get magic environments where we need to preset the FQDN // Get magic environments where we need to preset the FQDN
if ($key->startsWith('SERVICE_FQDN_')) { if ($key->startsWith('SERVICE_FQDN_')) {
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000 // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
@@ -3060,12 +3073,19 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
$port = null; $port = null;
} }
if ($isApplication) { if ($isApplication) {
$fqdn = generateFqdn($server, "$uuid"); $fqdn = $resource->fqdn;
if (blank($resource->fqdn)) {
$fqdn = generateFqdn($server, "$uuid");
}
} elseif ($isService) { } elseif ($isService) {
if ($fqdnFor) { if (blank($savedService->fqdn)) {
$fqdn = generateFqdn($server, "$fqdnFor-$uuid"); if ($fqdnFor) {
$fqdn = generateFqdn($server, "$fqdnFor-$uuid");
} else {
$fqdn = generateFqdn($server, "{$savedService->name}-$uuid");
}
} else { } else {
$fqdn = generateFqdn($server, "{$savedService->name}-$uuid"); $fqdn = str($savedService->fqdn)->after('://')->before(':')->prepend(str($savedService->fqdn)->before('://')->append('://'))->value();
} }
} }
@@ -3090,7 +3110,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
} }
if (substr_count(str($key)->value(), '_') === 2) { if (substr_count(str($key)->value(), '_') === 2) {
$resource->environment_variables()->firstOrCreate([ $resource->environment_variables()->updateOrCreate([
'key' => $key->value(), 'key' => $key->value(),
'resourceable_type' => get_class($resource), 'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id, 'resourceable_id' => $resource->id,
@@ -3102,7 +3122,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
} }
if (substr_count(str($key)->value(), '_') === 3) { if (substr_count(str($key)->value(), '_') === 3) {
$newKey = str($key)->beforeLast('_'); $newKey = str($key)->beforeLast('_');
$resource->environment_variables()->firstOrCreate([ $resource->environment_variables()->updateOrCreate([
'key' => $newKey->value(), 'key' => $newKey->value(),
'resourceable_type' => get_class($resource), 'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id, 'resourceable_id' => $resource->id,
@@ -3126,6 +3146,9 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
continue; continue;
} }
if ($command->value() === 'FQDN') { if ($command->value() === 'FQDN') {
if ($isApplication && $resource->build_pack === 'dockercompose') {
continue;
}
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value(); $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
if (str($fqdnFor)->contains('_')) { if (str($fqdnFor)->contains('_')) {
$fqdnFor = str($fqdnFor)->before('_'); $fqdnFor = str($fqdnFor)->before('_');
@@ -3141,6 +3164,13 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
'is_preview' => false, 'is_preview' => false,
]); ]);
} elseif ($command->value() === 'URL') { } elseif ($command->value() === 'URL') {
if ($isApplication && $resource->build_pack === 'dockercompose') {
continue;
}
// For services, only generate URL if explicit FQDN is set
if ($isService && blank($savedService->fqdn)) {
continue;
}
$fqdnFor = $key->after('SERVICE_URL_')->lower()->value(); $fqdnFor = $key->after('SERVICE_URL_')->lower()->value();
if (str($fqdnFor)->contains('_')) { if (str($fqdnFor)->contains('_')) {
$fqdnFor = str($fqdnFor)->before('_'); $fqdnFor = str($fqdnFor)->before('_');
@@ -3591,7 +3621,8 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
'is_required' => $isRequired, 'is_required' => $isRequired,
]); ]);
// Add the variable to the environment so it will be shown in the deployable compose file // Add the variable to the environment so it will be shown in the deployable compose file
$environment[$parsedKeyValue->value()] = $resource->environment_variables()->where('key', $parsedKeyValue)->where('resourceable_type', get_class($resource))->where('resourceable_id', $resource->id)->first()->value; // $environment[$parsedKeyValue->value()] = $resource->environment_variables()->where('key', $parsedKeyValue)->where('resourceable_type', get_class($resource))->where('resourceable_id', $resource->id)->first()->real_value;
$environment[$parsedKeyValue->value()] = $value;
continue; continue;
} }
@@ -3629,9 +3660,30 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
} }
if ($isApplication) { if ($isApplication) {
$domains = collect(json_decode($resource->docker_compose_domains)) ?? collect([]); if ($isPullRequest) {
$preview = $resource->previews()->find($preview_id);
$domains = collect(json_decode(data_get($preview, 'docker_compose_domains'))) ?? collect([]);
} else {
$domains = collect(json_decode($resource->docker_compose_domains)) ?? collect([]);
}
$fqdns = data_get($domains, "$serviceName.domain"); $fqdns = data_get($domains, "$serviceName.domain");
if ($fqdns) { // Generate SERVICE_FQDN & SERVICE_URL for dockercompose
if ($resource->build_pack === 'dockercompose') {
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$coolifyEnvironments->put('SERVICE_URL_'.str($forServiceName)->upper()->replace('-', '_'), $coolifyUrl->__toString());
$coolifyEnvironments->put('SERVICE_FQDN_'.str($forServiceName)->upper()->replace('-', '_'), $coolifyFqdn);
}
}
}
// If the domain is set, we need to generate the FQDNs for the preview
if (filled($fqdns)) {
$fqdns = str($fqdns)->explode(','); $fqdns = str($fqdns)->explode(',');
if ($isPullRequest) { if ($isPullRequest) {
$preview = $resource->previews()->find($preview_id); $preview = $resource->previews()->find($preview_id);
@@ -3663,7 +3715,6 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
} }
} }
} }
$defaultLabels = defaultLabels( $defaultLabels = defaultLabels(
id: $resource->id, id: $resource->id,
name: $containerName, name: $containerName,
@@ -3673,6 +3724,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
type: 'application', type: 'application',
environment: $resource->environment->name, environment: $resource->environment->name,
); );
} elseif ($isService) { } elseif ($isService) {
if ($savedService->serviceType()) { if ($savedService->serviceType()) {
$fqdns = generateServiceSpecificFqdns($savedService); $fqdns = generateServiceSpecificFqdns($savedService);
@@ -3694,10 +3746,13 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
} }
// Add COOLIFY_FQDN & COOLIFY_URL to environment // Add COOLIFY_FQDN & COOLIFY_URL to environment
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) { if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$coolifyEnvironments->put('COOLIFY_URL', $fqdns->implode(',')); $fqdnsWithoutPort = $fqdns->map(function ($fqdn) {
return str($fqdn)->after('://')->before(':')->prepend(str($fqdn)->before('://')->append('://'));
});
$coolifyEnvironments->put('COOLIFY_URL', $fqdnsWithoutPort->implode(','));
$urls = $fqdns->map(function ($fqdn) { $urls = $fqdns->map(function ($fqdn) {
return str($fqdn)->replace('http://', '')->replace('https://', ''); return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
}); });
$coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(',')); $coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(','));
} }
+15 -3
View File
@@ -22,15 +22,26 @@ function get_socialite_provider(string $provider)
return Socialite::driver('azure')->setConfig($azure_config); return Socialite::driver('azure')->setConfig($azure_config);
} }
if ($provider == 'authentik') { if ($provider == 'authentik' || $provider == 'clerk') {
$authentik_config = new \SocialiteProviders\Manager\Config( $authentik_clerk_config = new \SocialiteProviders\Manager\Config(
$oauth_setting->client_id, $oauth_setting->client_id,
$oauth_setting->client_secret, $oauth_setting->client_secret,
$oauth_setting->redirect_uri, $oauth_setting->redirect_uri,
['base_url' => $oauth_setting->base_url], ['base_url' => $oauth_setting->base_url],
); );
return Socialite::driver('authentik')->setConfig($authentik_config); return Socialite::driver($provider)->setConfig($authentik_clerk_config);
}
if ($provider == 'zitadel') {
$zitadel_config = new \SocialiteProviders\Manager\Config(
$oauth_setting->client_id,
$oauth_setting->client_secret,
$oauth_setting->redirect_uri,
['base_url' => $oauth_setting->base_url],
);
return Socialite::driver('zitadel')->setConfig($zitadel_config);
} }
if ($provider == 'google') { if ($provider == 'google') {
@@ -53,6 +64,7 @@ function get_socialite_provider(string $provider)
$provider_class_map = [ $provider_class_map = [
'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class, 'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class,
'discord' => \SocialiteProviders\Discord\Provider::class,
'github' => \Laravel\Socialite\Two\GithubProvider::class, 'github' => \Laravel\Socialite\Two\GithubProvider::class,
'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class, 'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class,
'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class, 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class,
+36 -33
View File
@@ -13,62 +13,65 @@
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"danharrin/livewire-rate-limiting": "^2.1.0", "danharrin/livewire-rate-limiting": "^2.1.0",
"doctrine/dbal": "^4.2.2", "doctrine/dbal": "^4.3.0",
"guzzlehttp/guzzle": "^7.9.2", "guzzlehttp/guzzle": "^7.9.3",
"laravel/fortify": "^1.25.4", "laravel/fortify": "^1.27.0",
"laravel/framework": "^12.4.1", "laravel/framework": "^12.20.0",
"laravel/horizon": "^5.30.3", "laravel/horizon": "^5.33.1",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.3",
"laravel/prompts": "^0.3.5|^0.3.5|^0.3.5", "laravel/prompts": "^0.3.6|^0.3.6|^0.3.6",
"laravel/sanctum": "^4.0.8", "laravel/sanctum": "^4.1.2",
"laravel/socialite": "^5.18.0", "laravel/socialite": "^5.21.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"laravel/ui": "^4.6.1", "laravel/ui": "^4.6.1",
"lcobucci/jwt": "^5.5.0", "lcobucci/jwt": "^5.5.0",
"league/flysystem-aws-s3-v3": "^3.29", "league/flysystem-aws-s3-v3": "^3.29",
"league/flysystem-sftp-v3": "^3.29", "league/flysystem-sftp-v3": "^3.30",
"livewire/livewire": "^3.5.20", "livewire/livewire": "^3.6.4",
"log1x/laravel-webfonts": "^2.0.1", "log1x/laravel-webfonts": "^2.0.1",
"lorisleiva/laravel-actions": "^2.8.6", "lorisleiva/laravel-actions": "^2.9.0",
"nubs/random-name-generator": "^2.2", "nubs/random-name-generator": "^2.2",
"phpseclib/phpseclib": "^3.0.43", "phpseclib/phpseclib": "^3.0.46",
"pion/laravel-chunk-upload": "^1.5.4", "pion/laravel-chunk-upload": "^1.5.6",
"poliander/cron": "^3.2.1", "poliander/cron": "^3.2.1",
"purplepixie/phpdns": "^2.2", "purplepixie/phpdns": "^2.2",
"pusher/pusher-php-server": "^7.2.7", "pusher/pusher-php-server": "^7.2.7",
"resend/resend-laravel": "^0.17.0", "resend/resend-laravel": "^0.19.0",
"sentry/sentry-laravel": "^4.13", "sentry/sentry-laravel": "^4.15.1",
"socialiteproviders/authentik": "^5.2", "socialiteproviders/authentik": "^5.2",
"socialiteproviders/clerk": "^5.0",
"socialiteproviders/discord": "^4.2",
"socialiteproviders/google": "^4.1", "socialiteproviders/google": "^4.1",
"socialiteproviders/infomaniak": "^4.0", "socialiteproviders/infomaniak": "^4.0",
"socialiteproviders/microsoft-azure": "^5.2", "socialiteproviders/microsoft-azure": "^5.2",
"spatie/laravel-activitylog": "^4.10.1", "socialiteproviders/zitadel": "^4.2",
"spatie/laravel-data": "^4.13.1", "spatie/laravel-activitylog": "^4.10.2",
"spatie/laravel-ray": "^1.39.1", "spatie/laravel-data": "^4.17.0",
"spatie/laravel-ray": "^1.40.2",
"spatie/laravel-schemaless-attributes": "^2.5.1", "spatie/laravel-schemaless-attributes": "^2.5.1",
"spatie/url": "^2.4", "spatie/url": "^2.4",
"stevebauman/purify": "^6.3", "stevebauman/purify": "^6.3.1",
"stripe/stripe-php": "^16.5.1", "stripe/stripe-php": "^16.6.0",
"symfony/yaml": "^7.2.3", "symfony/yaml": "^7.3.1",
"visus/cuid2": "^4.1.0", "visus/cuid2": "^4.1.0",
"yosymfony/toml": "^1.0.4", "yosymfony/toml": "^1.0.4",
"zircote/swagger-php": "^5.0.5" "zircote/swagger-php": "^5.1.4"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "^3.15.1", "barryvdh/laravel-debugbar": "^3.15.4",
"driftingly/rector-laravel": "^2.0.2", "driftingly/rector-laravel": "^2.0.5",
"fakerphp/faker": "^1.24.1", "fakerphp/faker": "^1.24.1",
"laravel/dusk": "^8.3.1", "laravel/dusk": "^8.3.3",
"laravel/pint": "^1.21", "laravel/pint": "^1.24",
"laravel/telescope": "^5.5", "laravel/telescope": "^5.10",
"mockery/mockery": "^1.6.12", "mockery/mockery": "^1.6.12",
"nunomaduro/collision": "^8.6.1", "nunomaduro/collision": "^8.8.2",
"pestphp/pest": "^3.8.0", "pestphp/pest": "^3.8.2",
"phpstan/phpstan": "^2.1.6", "phpstan/phpstan": "^2.1.18",
"rector/rector": "^2.0.9", "rector/rector": "^2.1.2",
"serversideup/spin": "^3.0.2", "serversideup/spin": "^3.0.2",
"spatie/laravel-ignition": "^2.9.1", "spatie/laravel-ignition": "^2.9.1",
"symfony/http-client": "^7.2.3" "symfony/http-client": "^7.3.1"
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
Generated
+632 -418
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
<?php
return [
'rate_limit' => env('API_RATE_LIMIT', 200),
];
+3 -3
View File
@@ -2,9 +2,9 @@
return [ return [
'coolify' => [ 'coolify' => [
'version' => '4.0.0-beta.420', 'version' => '4.0.0-beta.420.7',
'helper_version' => '1.0.8', 'helper_version' => '1.0.9',
'realtime_version' => '1.0.9', 'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true), 'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'), 'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
+8 -7
View File
@@ -182,14 +182,15 @@ return [
'defaults' => [ 'defaults' => [
's6' => [ 's6' => [
'connection' => 'redis', 'connection' => 'redis',
'queue' => ['high', 'default'], 'balance' => env('HORIZON_BALANCE', 'false'),
'balance' => env('HORIZON_BALANCE', 'auto'), 'queue' => env('HORIZON_QUEUES', 'high,default'),
'maxTime' => 0, 'maxTime' => 3600,
'maxJobs' => 0, 'maxJobs' => 400,
'memory' => 128, 'memory' => 128,
'tries' => 1, 'tries' => 1,
'timeout' => 3560,
'nice' => 0, 'nice' => 0,
'sleep' => 3,
'timeout' => 3600,
], ],
], ],
@@ -198,7 +199,7 @@ return [
's6' => [ 's6' => [
'autoScalingStrategy' => 'size', 'autoScalingStrategy' => 'size',
'minProcesses' => env('HORIZON_MIN_PROCESSES', 1), 'minProcesses' => env('HORIZON_MIN_PROCESSES', 1),
'maxProcesses' => env('HORIZON_MAX_PROCESSES', 6), 'maxProcesses' => env('HORIZON_MAX_PROCESSES', 4),
'balanceMaxShift' => env('HORIZON_BALANCE_MAX_SHIFT', 1), 'balanceMaxShift' => env('HORIZON_BALANCE_MAX_SHIFT', 1),
'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1), 'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1),
], ],
@@ -208,7 +209,7 @@ return [
's6' => [ 's6' => [
'autoScalingStrategy' => 'size', 'autoScalingStrategy' => 'size',
'minProcesses' => env('HORIZON_MIN_PROCESSES', 1), 'minProcesses' => env('HORIZON_MIN_PROCESSES', 1),
'maxProcesses' => env('HORIZON_MAX_PROCESSES', 6), 'maxProcesses' => env('HORIZON_MAX_PROCESSES', 4),
'balanceMaxShift' => env('HORIZON_BALANCE_MAX_SHIFT', 1), 'balanceMaxShift' => env('HORIZON_BALANCE_MAX_SHIFT', 1),
'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1), 'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1),
], ],
+14
View File
@@ -118,6 +118,20 @@ return [
'emergency' => [ 'emergency' => [
'path' => storage_path('logs/laravel.log'), 'path' => storage_path('logs/laravel.log'),
], ],
'scheduled' => [
'driver' => 'daily',
'path' => storage_path('logs/scheduled.log'),
'level' => 'debug',
'days' => 1,
],
'scheduled-errors' => [
'driver' => 'daily',
'path' => storage_path('logs/scheduled-errors.log'),
'level' => 'debug',
'days' => 7,
],
], ],
]; ];
+14
View File
@@ -46,6 +46,13 @@ return [
'redirect' => env('AUTHENTIK_REDIRECT_URI'), 'redirect' => env('AUTHENTIK_REDIRECT_URI'),
], ],
'clerk' => [
'client_id' => env('CLERK_CLIENT_ID'),
'client_secret' => env('CLERK_CLIENT_SECRET'),
'redirect' => env('CLERK_REDIRECT_URI'),
'base_url' => env('CLERK_BASE_URL'),
],
'google' => [ 'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'), 'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'), 'client_secret' => env('GOOGLE_CLIENT_SECRET'),
@@ -53,4 +60,11 @@ return [
'tenant' => env('GOOGLE_TENANT'), 'tenant' => env('GOOGLE_TENANT'),
], ],
'zitadel' => [
'client_id' => env('ZITADEL_CLIENT_ID'),
'client_secret' => env('ZITADEL_CLIENT_SECRET'),
'redirect' => env('ZITADEL_REDIRECT_URI'),
'base_url' => env('ZITADEL_BASE_URL'),
]
]; ];
@@ -0,0 +1,28 @@
<?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('instance_settings', function (Blueprint $table) {
$table->boolean('is_sponsorship_popup_enabled')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('is_sponsorship_popup_enabled');
});
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
try {
// Add specific index for type_uuid queries with ordering
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_activity_type_uuid_created_at ON activity_log ((properties->>\'type_uuid\'), created_at DESC)');
// Add specific index for status queries on properties
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_activity_properties_status ON activity_log ((properties->>\'status\'))');
} catch (\Exception $e) {
Log::error('Error adding optimized indexes to activity_log: '.$e->getMessage());
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
try {
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_activity_type_uuid_created_at');
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_activity_properties_status');
} catch (\Exception $e) {
Log::error('Error dropping optimized indexes from activity_log: '.$e->getMessage());
}
}
};
@@ -0,0 +1,28 @@
<?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_previews', function (Blueprint $table) {
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('application_previews', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};
@@ -0,0 +1,18 @@
<?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('scheduled_database_backups', function (Blueprint $table) {
$table->integer('timeout')->default(3600);
});
}
};
+3
View File
@@ -17,11 +17,14 @@ class OauthSettingSeeder extends Seeder
$providers = collect([ $providers = collect([
'azure', 'azure',
'bitbucket', 'bitbucket',
'clerk',
'discord',
'github', 'github',
'gitlab', 'gitlab',
'google', 'google',
'authentik', 'authentik',
'infomaniak', 'infomaniak',
'zitadel',
]); ]);
$isOauthSeeded = OauthSetting::count() > 0; $isOauthSeeded = OauthSetting::count() > 0;
+5 -5
View File
@@ -4,15 +4,15 @@ ARG BASE_IMAGE=alpine:3.21
# https://download.docker.com/linux/static/stable/ # https://download.docker.com/linux/static/stable/
ARG DOCKER_VERSION=28.0.0 ARG DOCKER_VERSION=28.0.0
# https://github.com/docker/compose/releases # https://github.com/docker/compose/releases
ARG DOCKER_COMPOSE_VERSION=2.34.0 ARG DOCKER_COMPOSE_VERSION=2.38.2
# https://github.com/docker/buildx/releases # https://github.com/docker/buildx/releases
ARG DOCKER_BUILDX_VERSION=0.22.0 ARG DOCKER_BUILDX_VERSION=0.25.0
# https://github.com/buildpacks/pack/releases # https://github.com/buildpacks/pack/releases
ARG PACK_VERSION=0.37.0 ARG PACK_VERSION=0.38.2
# https://github.com/railwayapp/nixpacks/releases # https://github.com/railwayapp/nixpacks/releases
ARG NIXPACKS_VERSION=1.34.1 ARG NIXPACKS_VERSION=1.39.0
# https://github.com/minio/mc/releases # https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-03-12T17-29-24Z ARG MINIO_VERSION=RELEASE.2025-05-21T01-59-54Z
FROM minio/mc:${MINIO_VERSION} AS minio-client FROM minio/mc:${MINIO_VERSION} AS minio-client
+1 -1
View File
@@ -2,7 +2,7 @@
# https://github.com/soketi/soketi/releases # https://github.com/soketi/soketi/releases
ARG SOKETI_VERSION=1.6-16-alpine ARG SOKETI_VERSION=1.6-16-alpine
# https://github.com/cloudflare/cloudflared/releases # https://github.com/cloudflare/cloudflared/releases
ARG CLOUDFLARED_VERSION=2025.5.0 ARG CLOUDFLARED_VERSION=2025.7.0
FROM quay.io/soketi/soketi:${SOKETI_VERSION} FROM quay.io/soketi/soketi:${SOKETI_VERSION}

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