Merge branch 'next' into feature/signoz

This commit is contained in:
🏔️ Peak 2025-08-11 13:46:10 +02:00 committed by GitHub
commit 72302d893e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
253 changed files with 6686 additions and 2381 deletions

View file

@ -21,7 +21,9 @@ Coolify implements **defense-in-depth security** with multiple layers of protect
- **Supported Providers**:
- Google OAuth
- Microsoft Azure AD
- Clerk
- Authentik
- Discord
- GitHub (via GitHub Apps)
- GitLab

View file

@ -90,7 +90,7 @@ alwaysApply: false
- **Purpose**: OAuth provider integration
- **Providers**:
- GitHub, GitLab, Google
- Microsoft Azure, Authentik
- Microsoft Azure, Authentik, Discord, Clerk
- Custom OAuth implementations
## Background Processing

View file

@ -2,7 +2,190 @@ # Changelog
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
@ -50,6 +233,11 @@ ### 🚀 Features
- *(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)* 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
@ -121,6 +309,11 @@ ### 🐛 Bug Fixes
- *(terminal)* Now it should work
- *(degraded-status)* Remove unnecessary whitespace in badge element for cleaner HTML
- *(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
@ -198,6 +391,9 @@ ### 🚜 Refactor
- *(navigation)* Remove wire:navigate directive from configuration links for cleaner HTML structure
- *(proxy)* Update StartProxy calls to use named parameter for async option
- *(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
@ -205,6 +401,7 @@ ### 📚 Documentation
- *(service)* Add new docs link for zipline (#5912)
- Update changelog
- Update changelog
- Update changelog
### 🎨 Styling
@ -236,6 +433,9 @@ ### ⚙️ Miscellaneous Tasks
- *(api)* Update API docs
- *(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
- *(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

87
CLAUDE.md Normal file
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

View file

@ -49,7 +49,7 @@ public function handle(Application $application, bool $previewDeployments = fals
}
if ($dockerCleanup) {
CleanupDocker::dispatch($server, true);
CleanupDocker::dispatch($server, false, false);
}
} catch (\Exception $e) {
return $e->getMessage();

View file

@ -27,6 +27,8 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
$server = data_get($database, 'destination.server');
$containerName = data_get($database, 'uuid');
$proxyContainerName = "{$database->uuid}-proxy";
$isSSLEnabled = $database->enable_ssl ?? false;
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
$databaseType = $database->databaseType();
$network = $database->service->uuid;
@ -42,6 +44,12 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
'standalone-mongodb' => 27017,
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);
if (isDev()) {

View file

@ -18,7 +18,7 @@ class StopDatabase
{
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 {
$server = $database->destination->server;
@ -29,7 +29,7 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
$this->stopContainer($database, $database->uuid, 30);
if ($dockerCleanup) {
CleanupDocker::dispatch($server, true);
CleanupDocker::dispatch($server, false, false);
}
if ($database->is_public) {

View file

@ -66,7 +66,7 @@ public function handle(Server $server, $fromUI = false): bool
if ($server->id === 0) {
$ip = 'host.docker.internal';
}
$portsToCheck = ['80', '443'];
$portsToCheck = [];
try {
if ($server->proxyType() !== ProxyTypes::NONE->value) {

View file

@ -11,7 +11,7 @@ class CleanupDocker
public string $jobQueue = 'high';
public function handle(Server $server)
public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false)
{
$settings = instanceSettings();
$realtimeImage = config('constants.coolify.realtime_image');
@ -36,11 +36,11 @@ public function handle(Server $server)
"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';
}
if ($server->settings->delete_unused_networks) {
if ($deleteUnusedNetworks) {
$commands[] = 'docker network prune -f';
}

View file

@ -29,7 +29,7 @@ public function handle($manual_update = false)
if (! $this->server) {
return;
}
CleanupDocker::dispatch($this->server);
CleanupDocker::dispatch($this->server, false, false);
$this->latestVersion = get_latest_version_of_coolify();
$this->currentVersion = config('constants.coolify.version');
if (! $manual_update) {

View file

@ -11,7 +11,7 @@ class DeleteService
{
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 {
$server = data_get($service, 'server');
@ -71,7 +71,7 @@ public function handle(Service $service, bool $deleteConfigurations, bool $delet
$service->forceDelete();
if ($dockerCleanup) {
CleanupDocker::dispatch($server, true);
CleanupDocker::dispatch($server, false, false);
}
}
}

View file

@ -19,6 +19,7 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
StopService::run(service: $service, dockerCleanup: false);
}
$service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
$commands[] = 'cd '.$service->workdir();
$commands[] = "echo 'Saved configuration files to {$service->workdir()}.'";
if ($pullLatestImages) {
@ -41,6 +42,6 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
}
}
return remote_process($commands, $service->server, type_uuid: $service->uuid);
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
}
}

View file

@ -14,7 +14,7 @@ class StopService
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 {
$server = $service->destination->server;
@ -36,11 +36,11 @@ public function handle(Service $service, bool $isDeleteOperation = false, bool $
$this->stopContainersInParallel($containersToStop, $server);
}
if ($isDeleteOperation) {
if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks();
}
if ($dockerCleanup) {
CleanupDocker::dispatch($server, true);
CleanupDocker::dispatch($server, false, false);
}
} catch (\Exception $e) {
return $e->getMessage();

View file

@ -7,26 +7,270 @@
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()
{
$redis = Redis::connection('horizon');
$keys = $redis->keys('*');
$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) {
$keyWithoutPrefix = str_replace($prefix, '', $key);
$type = $redis->command('type', [$keyWithoutPrefix]);
// Handle hash-type keys (individual jobs)
if ($type === 5) {
$data = $redis->command('hgetall', [$keyWithoutPrefix]);
$status = data_get($data, 'status');
if ($status === 'completed') {
$redis->command('del', [$keyWithoutPrefix]);
if ($this->shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)) {
$deletedCount++;
}
}
// 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;
}
}

View file

@ -20,6 +20,7 @@
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use Illuminate\Console\Command;
class CleanupStuckedResources extends Command
@ -36,6 +37,12 @@ public function handle()
private function cleanup_stucked_resources()
{
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) {
return $server->isFunctional();
});

View file

@ -36,24 +36,20 @@ public function handle()
$this->servers = Server::all();
if (! isCloud()) {
$this->send_alive_signal();
$this->sendAliveSignal();
get_public_ips();
}
// Backward compatibility
$this->replace_slash_in_environment_name();
$this->restore_coolify_db_backup();
$this->update_user_emails();
$this->replaceSlashInEnvironmentName();
$this->restoreCoolifyDbBackup();
$this->updateUserEmails();
//
$this->update_traefik_labels();
$this->updateTraefikLabels();
if (! isCloud() || $this->option('force-cloud')) {
$this->cleanup_unused_network_from_coolify_proxy();
}
if (isCloud()) {
$this->cleanup_unnecessary_dynamic_proxy_configuration();
} else {
$this->cleanup_in_progress_application_deployments();
$this->cleanupUnusedNetworkFromCoolifyProxy();
}
$this->call('cleanup:redis');
$this->call('cleanup:stucked-resources');
@ -66,33 +62,35 @@ public function handle()
if (isCloud()) {
try {
$this->cleanupUnnecessaryDynamicProxyConfiguration();
$this->pullTemplatesFromCDN();
} catch (\Throwable $e) {
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
}
return;
}
if (! isCloud()) {
try {
$this->pullTemplatesFromCDN();
} catch (\Throwable $e) {
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
}
try {
$localhost = $this->servers->where('id', 0)->first();
$localhost->setupDynamicProxyConfiguration();
} catch (\Throwable $e) {
echo "Could not setup dynamic configuration: {$e->getMessage()}\n";
}
$settings = instanceSettings();
if (! is_null(config('constants.coolify.autoupdate', null))) {
if (config('constants.coolify.autoupdate') == true) {
echo "Enabling auto-update\n";
$settings->update(['is_auto_update_enabled' => true]);
} else {
echo "Disabling auto-update\n";
$settings->update(['is_auto_update_enabled' => false]);
}
try {
$this->cleanupInProgressApplicationDeployments();
$this->pullTemplatesFromCDN();
} catch (\Throwable $e) {
echo "Could not pull templates from CDN: {$e->getMessage()}\n";
}
try {
$localhost = $this->servers->where('id', 0)->first();
$localhost->setupDynamicProxyConfiguration();
} catch (\Throwable $e) {
echo "Could not setup dynamic configuration: {$e->getMessage()}\n";
}
$settings = instanceSettings();
if (! is_null(config('constants.coolify.autoupdate', null))) {
if (config('constants.coolify.autoupdate') == true) {
echo "Enabling auto-update\n";
$settings->update(['is_auto_update_enabled' => true]);
} else {
echo "Disabling auto-update\n";
$settings->update(['is_auto_update_enabled' => false]);
}
}
}
@ -117,7 +115,7 @@ private function optimize()
Artisan::call('optimize');
}
private function update_user_emails()
private function updateUserEmails()
{
try {
User::whereRaw('email ~ \'[A-Z]\'')->get()->each(function (User $user) {
@ -128,7 +126,7 @@ private function update_user_emails()
}
}
private function update_traefik_labels()
private function updateTraefikLabels()
{
try {
Server::where('proxy->type', 'TRAEFIK_V2')->update(['proxy->type' => 'TRAEFIK']);
@ -137,7 +135,7 @@ private function update_traefik_labels()
}
}
private function cleanup_unnecessary_dynamic_proxy_configuration()
private function cleanupUnnecessaryDynamicProxyConfiguration()
{
foreach ($this->servers as $server) {
try {
@ -158,7 +156,7 @@ private function cleanup_unnecessary_dynamic_proxy_configuration()
}
}
private function cleanup_unused_network_from_coolify_proxy()
private function cleanupUnusedNetworkFromCoolifyProxy()
{
foreach ($this->servers as $server) {
if (! $server->isFunctional()) {
@ -197,7 +195,7 @@ private function cleanup_unused_network_from_coolify_proxy()
}
}
private function restore_coolify_db_backup()
private function restoreCoolifyDbBackup()
{
if (version_compare('4.0.0-beta.179', config('constants.coolify.version'), '<=')) {
try {
@ -223,7 +221,7 @@ private function restore_coolify_db_backup()
}
}
private function send_alive_signal()
private function sendAliveSignal()
{
$id = config('app.id');
$version = config('constants.coolify.version');
@ -241,7 +239,7 @@ private function send_alive_signal()
}
}
private function cleanup_in_progress_application_deployments()
private function cleanupInProgressApplicationDeployments()
{
// Cleanup any failed deployments
try {
@ -258,7 +256,7 @@ private function cleanup_in_progress_application_deployments()
}
}
private function replace_slash_in_environment_name()
private function replaceSlashInEnvironmentName()
{
if (version_compare('4.0.0-beta.298', config('constants.coolify.version'), '<=')) {
$environments = Environment::all();

View file

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

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

View file

@ -6,23 +6,16 @@
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\DatabaseBackupJob;
use App\Jobs\DockerCleanupJob;
use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob;
use App\Jobs\ScheduledTaskJob;
use App\Jobs\ServerCheckJob;
use App\Jobs\ServerPatchCheckJob;
use App\Jobs\ServerStorageCheckJob;
use App\Jobs\ScheduledJobManager;
use App\Jobs\ServerResourceManager;
use App\Jobs\UpdateCoolifyJob;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
class Kernel extends ConsoleKernel
@ -52,7 +45,7 @@ protected function schedule(Schedule $schedule): void
}
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$this->scheduleInstance->command('cleanup:redis')->hourly();
$this->scheduleInstance->command('cleanup:redis')->weekly();
if (isDev()) {
// Instance Jobs
@ -61,10 +54,10 @@ protected function schedule(Schedule $schedule): void
$this->scheduleInstance->job(new CheckHelperImageJob)->everyTenMinutes()->onOneServer();
// Server Jobs
$this->checkResources();
$this->scheduleInstance->job(new ServerResourceManager)->everyMinute()->onOneServer();
$this->checkScheduledBackups();
$this->checkScheduledTasks();
// Scheduled Jobs (Backups & Tasks)
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
@ -79,12 +72,12 @@ protected function schedule(Schedule $schedule): void
$this->scheduleUpdates();
// Server Jobs
$this->checkResources();
$this->scheduleInstance->job(new ServerResourceManager)->everyMinute()->onOneServer();
$this->pullImages();
$this->checkScheduledBackups();
$this->checkScheduledTasks();
// Scheduled Jobs (Backups & Tasks)
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
$this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
@ -135,182 +128,6 @@ private function scheduleUpdates(): void
}
}
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
{
$this->load(__DIR__.'/Commands');

View file

@ -7,8 +7,9 @@
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Horizon\Contracts\Silenced;
class BackupCreated implements ShouldBroadcast
class BackupCreated implements ShouldBroadcast, Silenced
{
use Dispatchable, InteractsWithSockets, SerializesModels;

View file

@ -7,8 +7,9 @@
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Laravel\Horizon\Contracts\Silenced;
class ServiceChecked implements ShouldBroadcast
class ServiceChecked implements ShouldBroadcast, Silenced
{
use Dispatchable, InteractsWithSockets, SerializesModels;

View file

@ -103,7 +103,11 @@ public static function generateScpCommand(Server $server, string $source, string
}
$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;
}

View file

@ -1699,10 +1699,10 @@ public function delete_by_uuid(Request $request)
DeleteResourceJob::dispatch(
resource: $application,
deleteConfigurations: $request->query->get('delete_configurations', 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([

View file

@ -1608,10 +1608,10 @@ public function delete_by_uuid(Request $request)
DeleteResourceJob::dispatch(
resource: $database,
deleteConfigurations: $request->query->get('delete_configurations', 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([

View file

@ -510,10 +510,10 @@ public function delete_by_uuid(Request $request)
DeleteResourceJob::dispatch(
resource: $service,
deleteConfigurations: $request->query->get('delete_configurations', 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([

View file

@ -144,7 +144,7 @@ public function acceptInvitation()
}
}
public function revoke_invitation()
public function revokeInvitation()
{
$invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail();
$user = User::whereEmail($invitation->email)->firstOrFail();

View file

@ -143,12 +143,13 @@ public function manual(Request $request)
]);
$pr_app->generate_preview_fqdn_compose();
} else {
ApplicationPreview::create([
$pr_app = ApplicationPreview::create([
'git_type' => 'bitbucket',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
]);
$pr_app->generate_preview_fqdn();
}
}
$result = queue_application_deployment(

View file

@ -175,12 +175,13 @@ public function manual(Request $request)
]);
$pr_app->generate_preview_fqdn_compose();
} else {
ApplicationPreview::create([
$pr_app = ApplicationPreview::create([
'git_type' => 'gitea',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
]);
$pr_app->generate_preview_fqdn();
}
}
$result = queue_application_deployment(

View file

@ -183,12 +183,13 @@ public function manual(Request $request)
]);
$pr_app->generate_preview_fqdn_compose();
} else {
ApplicationPreview::create([
$pr_app = ApplicationPreview::create([
'git_type' => 'github',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
]);
$pr_app->generate_preview_fqdn();
}
}

View file

@ -202,12 +202,13 @@ public function manual(Request $request)
]);
$pr_app->generate_preview_fqdn_compose();
} else {
ApplicationPreview::create([
$pr_app = ApplicationPreview::create([
'git_type' => 'gitlab',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
]);
$pr_app->generate_preview_fqdn();
}
}
$result = queue_application_deployment(

View file

@ -12,6 +12,6 @@ class VerifyCsrfToken extends Middleware
* @var array<int, string>
*/
protected $except = [
//
'webhooks/*',
];
}

View file

@ -30,6 +30,7 @@
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
use RuntimeException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Throwable;
use Visus\Cuid2\Cuid2;
@ -228,7 +229,14 @@ public function __construct(public int $application_deployment_queue_id)
// Set preview fqdn
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()) {
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::IN_PROGRESS);
}
@ -471,7 +479,7 @@ private function deploy_docker_compose_buildpack()
} else {
$composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'));
$this->save_environment_variables();
if (! is_null($this->env_filename)) {
if (filled($this->env_filename)) {
$services = collect(data_get($composeFile, 'services', []));
$services = $services->map(function ($service, $name) {
$service['env_file'] = [$this->env_filename];
@ -480,7 +488,7 @@ private function deploy_docker_compose_buildpack()
});
$composeFile['services'] = $services->toArray();
}
if (is_null($composeFile)) {
if (empty($composeFile)) {
$this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.');
$this->fail('Failed to parse docker-compose file.');
@ -887,10 +895,6 @@ private function check_image_locally_or_remotely()
private function save_environment_variables()
{
$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;
if ($sort) {
$sorted_environment_variables = $this->application->environment_variables->sortBy('key');
@ -899,6 +903,14 @@ private function save_environment_variables()
$sorted_environment_variables = $this->application->environment_variables->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();
$coolify_envs = $this->generate_coolify_env_variables();
$coolify_envs->each(function ($item, $key) use ($envs) {
@ -908,17 +920,7 @@ private function save_environment_variables()
$this->env_filename = '.env';
foreach ($sorted_environment_variables as $env) {
$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);
}
}
$envs->push($env->key.'='.$real_value);
$envs->push($env->key.'='.$env->real_value);
}
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
@ -930,20 +932,28 @@ private function save_environment_variables()
if ($this->application->environment_variables->where('key', 'HOST')->isEmpty()) {
$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 {
$this->env_filename = ".env-pr-$this->pull_request_id";
foreach ($sorted_environment_variables_preview as $env) {
$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);
}
}
$envs->push($env->key.'='.$real_value);
$envs->push($env->key.'='.$env->real_value);
}
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
@ -956,6 +966,23 @@ private function save_environment_variables()
$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()) {
$this->env_filename = null;
@ -1367,9 +1394,16 @@ private function set_coolify_variables()
$fqdn = $this->preview->fqdn;
}
if (isset($fqdn)) {
$this->coolify_variables .= "COOLIFY_FQDN={$fqdn} ";
$url = str($fqdn)->replace('http://', '')->replace('https://', '');
$this->coolify_variables .= "COOLIFY_URL={$url} ";
$url = Url::fromString($fqdn);
$fqdn = $url->getHost();
$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)) {
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
@ -1381,8 +1415,8 @@ private function check_git_if_build_needed()
if (is_object($this->source) && $this->source->getMorphClass() === \App\Models\GithubApp::class && $this->source->is_public === false) {
$repository = githubApi($this->source, "repos/{$this->customRepository}");
$data = data_get($repository, 'data');
if (isset($data->id)) {
$repository_project_id = $data->id;
$repository_project_id = data_get($data, 'id');
if (isset($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->save();
@ -1715,10 +1749,6 @@ private function generate_compose_file()
{
$this->create_workdir();
$ports = $this->application->main_port();
$onlyPort = null;
if (count($ports) > 0) {
$onlyPort = $ports[0];
}
$persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->application->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names();
@ -2253,9 +2283,10 @@ private function build_image()
$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 {
$timeout = isDev() ? 1 : 30;
$this->execute_remote_command(
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]

View file

@ -23,7 +23,7 @@ public function __construct() {}
public function middleware(): array
{
return [(new WithoutOverlapping('cleanup-instance-stuffs'))->dontRelease()];
return [(new WithoutOverlapping('cleanup-instance-stuffs'))->expireAfter(60)->dontRelease()];
}
public function handle(): void

View file

@ -23,6 +23,8 @@
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
use Throwable;
use Visus\Cuid2\Cuid2;
class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
@ -60,9 +62,16 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public ?S3Storage $s3 = null;
public $timeout = 3600;
public string $backup_log_uuid;
public function __construct(public ScheduledDatabaseBackup $backup)
{
$this->onQueue('high');
$this->timeout = $backup->timeout;
$this->backup_log_uuid = (string) new Cuid2;
}
public function handle(): void
@ -219,12 +228,8 @@ public function handle(): void
$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) {
\Log::warning('Failed to extract MongoDB environment variables', ['error' => $e->getMessage()]);
// Continue without env vars - will be handled in backup_standalone_mongodb method
}
}
@ -288,6 +293,7 @@ public function handle(): void
}
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
@ -307,6 +313,7 @@ public function handle(): void
$this->backup_file = "/mongo-dump-$databaseName-".Carbon::now()->timestamp.'.tar.gz';
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $databaseName,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
@ -319,6 +326,7 @@ public function handle(): void
}
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
@ -331,6 +339,7 @@ public function handle(): void
}
$this->backup_location = $this->backup_dir.$this->backup_file;
$this->backup_log = ScheduledDatabaseBackupExecution::create([
'uuid' => $this->backup_log_uuid,
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
@ -574,4 +583,18 @@ private function getFullImageName(): string
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,
]);
}
}
}

View file

@ -8,6 +8,7 @@
use App\Actions\Service\DeleteService;
use App\Actions\Service\StopService;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Service;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
@ -30,11 +31,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
public bool $deleteConfigurations = true,
public Application|ApplicationPreview|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
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');
}
@ -42,9 +43,16 @@ public function __construct(
public function handle()
{
try {
// Handle ApplicationPreview instances separately
if ($this->resource instanceof ApplicationPreview) {
$this->deleteApplicationPreview();
return;
}
switch ($this->resource->type()) {
case 'application':
StopApplication::run($this->resource, previewDeployments: true);
StopApplication::run($this->resource, previewDeployments: true, dockerCleanup: $this->dockerCleanup);
break;
case 'standalone-postgresql':
case 'standalone-redis':
@ -54,11 +62,11 @@ public function handle()
case 'standalone-keydb':
case 'standalone-dragonfly':
case 'standalone-clickhouse':
StopDatabase::run($this->resource, true);
StopDatabase::run($this->resource, dockerCleanup: $this->dockerCleanup);
break;
case 'service':
StopService::run($this->resource, true);
DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks);
StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup);
DeleteService::run($this->resource, $this->deleteVolumes, $this->deleteConnectedNetworks, $this->deleteConfigurations, $this->dockerCleanup);
return;
}
@ -70,7 +78,7 @@ public function handle()
$this->resource->deleteVolumes();
$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
|| $this->resource instanceof StandaloneRedis
@ -98,10 +106,61 @@ public function handle()
if ($this->dockerCleanup) {
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
if ($server) {
CleanupDocker::dispatch($server, true);
CleanupDocker::dispatch($server, false, false);
}
}
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
);
}
}

View file

@ -31,10 +31,15 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
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
{
@ -50,7 +55,11 @@ public function handle(): void
$this->usageBefore = $this->server->getDiskUsage();
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();
$message = ($this->manualCleanup ? 'Manual' : 'Forced').' Docker cleanup job executed successfully. Disk usage before: '.$this->usageBefore.'%, Disk usage after: '.$usageAfter.'%.';
@ -67,7 +76,11 @@ public function handle(): void
}
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.';
$this->execution_log->update([
@ -81,7 +94,11 @@ public function handle(): void
}
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();
$diskSaved = $this->usageBefore - $usageAfter;

View file

@ -21,8 +21,9 @@
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
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;
@ -70,7 +71,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue
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

View file

@ -23,7 +23,7 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
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) {}

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

View file

@ -28,7 +28,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
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) {}

View file

@ -19,11 +19,11 @@ class ServerPatchCheckJob implements ShouldBeEncrypted, ShouldQueue
public $tries = 3;
public $timeout = 600; // 10 minutes timeout
public $timeout = 600;
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) {}

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

View file

@ -11,8 +11,9 @@
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
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;

View file

@ -254,10 +254,9 @@ public function submitSmtp()
'smtpEncryption.required' => 'Encryption type is required.',
]);
$this->settings->resend_enabled = false;
$this->settings->use_instance_email_settings = false;
$this->resendEnabled = false;
$this->useInstanceEmailSettings = false;
if ($this->smtpEnabled) {
$this->settings->resend_enabled = $this->resendEnabled = false;
}
$this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_from_address = $this->smtpFromAddress;
@ -293,11 +292,9 @@ public function submitResend()
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
$this->settings->smtp_enabled = false;
$this->settings->use_instance_email_settings = false;
$this->smtpEnabled = false;
$this->useInstanceEmailSettings = false;
if ($this->resendEnabled) {
$this->settings->smtp_enabled = $this->smtpEnabled = false;
}
$this->settings->resend_enabled = $this->resendEnabled;
$this->settings->resend_api_key = $this->resendApiKey;

View file

@ -18,11 +18,13 @@ class Index extends Component
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;
@ -51,68 +53,111 @@ public function mount()
if (! $application) {
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->deployments = $deployments;
$this->deployments_count = $count;
$this->current_url = url()->current();
$this->show_pull_request_only();
$this->show_more();
$this->updateCurrentPage();
$this->showMore();
}
private function show_pull_request_only()
{
if ($this->pull_request_id) {
$this->deployments = $this->deployments->where('pull_request_id', $this->pull_request_id);
}
}
private function show_more()
private function showMore()
{
if ($this->deployments->count() !== 0) {
$this->show_next = true;
if ($this->deployments->count() < $this->default_take) {
$this->show_next = false;
$this->showNext = true;
if ($this->deployments->count() < $this->defaultTake) {
$this->showNext = false;
}
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) {
$this->skip = $this->skip - $take;
}
$this->skip = $this->skip - $this->default_take;
$this->skip = $this->skip - $this->defaultTake;
if ($this->skip < 0) {
$this->show_prev = false;
$this->showPrev = false;
$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) {
$this->skip = $this->skip + $take;
}
$this->show_prev = true;
$this->load_deployments();
$this->showPrev = true;
$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_count = $count;
$this->show_pull_request_only();
$this->show_more();
$this->showMore();
}
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()

View file

@ -4,6 +4,7 @@
use App\Actions\Application\GenerateConfig;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use Illuminate\Support\Collection;
use Livewire\Component;
use Spatie\Url\Url;
@ -156,6 +157,14 @@ public function mount()
$this->application->settings->save();
}
$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->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;
@ -206,25 +215,21 @@ public function instantSave()
}
public function loadComposeFile($isInit = false)
public function loadComposeFile($isInit = false, $showToast = true)
{
try {
if ($isInit && $this->application->docker_compose_raw) {
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);
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;
}
$this->application->parse();
$this->dispatch('success', 'Docker compose file loaded.');
$showToast && $this->dispatch('success', 'Docker compose file loaded.');
$this->dispatch('compose_loaded');
$this->dispatch('refreshStorages');
$this->dispatch('refreshEnvs');
@ -242,12 +247,31 @@ public function generateDomain(string $serviceName)
{
$uuid = new Cuid2;
$domain = generateFqdn($this->application->destination->server, $uuid);
$this->parsedServiceDomains[$serviceName]['domain'] = $domain;
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
$sanitizedKey = str($serviceName)->slug('_')->toString();
$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->dispatch('success', 'Domain generated.');
if ($this->application->build_pack === 'dockercompose') {
$this->loadComposeFile();
$this->updateServiceEnvironmentVariables();
$this->loadComposeFile(showToast: false);
}
return $domain;
@ -429,9 +453,25 @@ public function submit($showToaster = true)
$this->application->publish_directory = rtrim($this->application->publish_directory, '/');
}
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');
if ($domain) {
if (! validate_dns_entry($domain, $this->application->destination->server)) {
@ -446,6 +486,12 @@ public function submit($showToaster = true)
}
$this->application->custom_labels = base64_encode($this->customLabels);
$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!');
} catch (\Throwable $e) {
$originalFqdn = $this->application->getOriginal('fqdn');
@ -471,4 +517,85 @@ public function downloadConfig()
'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();
}
}
}
}

View file

@ -3,11 +3,11 @@
namespace App\Livewire\Project\Application;
use App\Actions\Docker\GetContainersStatus;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Illuminate\Support\Collection;
use Livewire\Component;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
class Previews extends Component
@ -87,18 +87,9 @@ public function generate_preview($preview_id)
return;
}
$fqdn = generateFqdn($this->application->destination->server, $this->application->uuid);
$url = Url::fromString($fqdn);
$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}}', $preview->pull_request_id, $preview_fqdn);
$preview_fqdn = "$schema://$preview_fqdn";
$preview->fqdn = $preview_fqdn;
$preview->save();
$preview->generate_preview_fqdn();
$this->application->refresh();
$this->dispatch('update_links');
$this->dispatch('success', 'Domain generated.');
}
@ -128,7 +119,7 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null)
'pull_request_html_url' => $pull_request_html_url,
]);
}
$this->application->generate_preview_fqdn($pull_request_id);
$found->generate_preview_fqdn();
$this->application->refresh();
$this->dispatch('update_links');
$this->dispatch('success', 'Preview added.');
@ -215,48 +206,28 @@ public function stop(int $pull_request_id)
public function delete(int $pull_request_id)
{
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()) {
instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server);
} else {
$containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray();
$this->stopContainers($containers, $server);
if (! $preview) {
$this->dispatch('error', 'Preview not found.');
return;
}
ApplicationPreview::where('application_id', $this->application->id)
->where('pull_request_id', $pull_request_id)
->first()
->delete();
// Soft delete immediately for instant UI feedback
$preview->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('success', 'Preview deleted.');
$this->dispatch('success', 'Preview deletion started. It may take a few moments to complete.');
} catch (\Throwable $e) {
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
);
}
}

View file

@ -38,9 +38,25 @@ public function generate()
$domain = $domains->first(function ($_, $key) {
return $key === $this->serviceName;
});
if ($domain) {
$domain = data_get($domain, 'domain');
$url = Url::fromString($domain);
$domain_string = data_get($domain, '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;
$host = $url->getHost();
$schema = $url->getScheme();
@ -49,12 +65,15 @@ public function generate()
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $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('success', 'Domain generated.');
}

View file

@ -111,8 +111,19 @@ public function changeSource($sourceId, $sourceType)
$this->application->update([
'source_id' => $sourceId,
'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->getSources();
$this->dispatch('success', 'Source updated!');

View file

@ -56,7 +56,6 @@ public function mount($project_uuid)
$this->project_id = $this->project->id;
$this->servers = currentTeam()
->servers()
->with('destinations')
->get()
->reject(fn ($server) => $server->isBuildServer());
$this->newName = str($this->project->name.'-clone-'.(string) new Cuid2)->slug();
@ -455,7 +454,7 @@ public function clone(string $type)
if ($this->cloneVolumeData) {
try {
StopService::dispatch($application, false, false);
StopService::dispatch($application);
$sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name;
$sourceServer = $application->service->destination->server;
@ -509,7 +508,7 @@ public function clone(string $type)
if ($this->cloneVolumeData) {
try {
StopService::dispatch($database->service, false, false);
StopService::dispatch($database->service);
$sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name;
$sourceServer = $database->service->destination->server;

View file

@ -73,6 +73,9 @@ class BackupEdit extends Component
#[Validate(['required', 'boolean'])]
public bool $dumpAll = false;
#[Validate(['required', 'int', 'min:1', 'max:36000'])]
public int $timeout = 3600;
public function mount()
{
try {
@ -98,6 +101,7 @@ public function syncData(bool $toModel = false)
$this->backup->s3_storage_id = $this->s3StorageId;
$this->backup->databases_to_backup = $this->databasesToBackup;
$this->backup->dump_all = $this->dumpAll;
$this->backup->timeout = $this->timeout;
$this->customValidate();
$this->backup->save();
} else {
@ -114,6 +118,7 @@ public function syncData(bool $toModel = false)
$this->s3StorageId = $this->backup->s3_storage_id;
$this->databasesToBackup = $this->backup->databases_to_backup;
$this->dumpAll = $this->backup->dump_all;
$this->timeout = $this->backup->timeout;
}
}

View file

@ -4,6 +4,7 @@
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
@ -14,7 +15,19 @@ class BackupExecutions extends Component
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;
@ -40,6 +53,20 @@ public function cleanupFailed()
}
}
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)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
@ -85,18 +112,74 @@ public function download_file($exeuctionId)
public function refreshBackupExecutions(): void
{
if ($this->backup && $this->backup->exists) {
$this->executions = $this->backup->executions()->get()->toArray();
} else {
$this->executions = [];
$this->loadExecutions();
}
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)
{
$this->backup = $backup;
$this->database = $backup->database;
$this->refreshBackupExecutions();
$this->updateCurrentPage();
$this->loadExecutions();
}
public function server()
@ -121,8 +204,8 @@ public function render()
{
return view('livewire.project.database.backup-executions', [
'checkboxes' => [
['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently form S3 Storage'],
// ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'],
['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'],
// ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently from SFTP Storage'],
],
]);
}

View file

@ -6,7 +6,6 @@
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Enums\ProcessStatus;
use App\Events\ServiceStatusChanged;
use App\Models\Service;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
@ -64,7 +63,7 @@ public function serviceChecked()
$this->service->databases->each(function ($database) {
$database->refresh();
});
if (is_null($this->service->config_hash) || $this->service->isConfigurationChanged()) {
if (is_null($this->service->config_hash)) {
$this->service->isConfigurationChanged(true);
}
$this->dispatch('configurationChanged');
@ -96,7 +95,7 @@ public function checkDeployments()
public function start()
{
$activity = StartService::run($this->service, pullLatestImages: true);
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
$this->dispatch('activityMonitor', $activity->id);
}
public function forceDeploy()
@ -112,7 +111,7 @@ public function forceDeploy()
$activity->save();
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
$this->dispatch('activityMonitor', $activity->id);
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
}
@ -136,7 +135,7 @@ public function restart()
return;
}
$activity = StartService::run($this->service, stopBeforeStart: true);
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
$this->dispatch('activityMonitor', $activity->id);
}
public function pullAndRestartEvent()
@ -148,7 +147,7 @@ public function pullAndRestartEvent()
return;
}
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
$this->dispatch('activityMonitor', $activity->id);
}
public function render()

View file

@ -99,10 +99,10 @@ public function delete($password)
$this->resource->delete();
DeleteResourceJob::dispatch(
$this->resource,
$this->delete_configurations,
$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', [

View file

@ -170,6 +170,7 @@ public function submit()
$this->syncData(true);
$this->dispatch('success', 'Environment variable updated.');
$this->dispatch('envsUpdated');
$this->dispatch('configurationChanged');
} catch (\Exception $e) {
return handleError($e);
}

View file

@ -137,6 +137,13 @@ public function loadContainers()
}
}
public function updatedSelectedContainer()
{
if ($this->selected_container !== 'default') {
$this->connectToContainer();
}
}
#[On('connectToServer')]
public function connectToServer()
{
@ -151,6 +158,9 @@ public function connectToServer()
data_get($server, 'name'),
data_get($server, 'uuid')
);
// Dispatch a frontend event to ensure terminal gets focus after connection
$this->dispatch('terminal-should-focus');
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
@ -206,6 +216,9 @@ public function connectToContainer()
data_get($container, 'container.Names'),
data_get($container, 'server.uuid')
);
// Dispatch a frontend event to ensure terminal gets focus after connection
$this->dispatch('terminal-should-focus');
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {

View file

@ -412,7 +412,7 @@ public function cloneTo($destination_id)
if ($this->cloneVolumeData) {
try {
StopService::dispatch($application, false, false);
StopService::dispatch($application);
$sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name;
$sourceServer = $application->service->destination->server;
@ -454,7 +454,7 @@ public function cloneTo($destination_id)
if ($this->cloneVolumeData) {
try {
StopService::dispatch($database->service, false, false);
StopService::dispatch($database->service);
$sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name;
$sourceServer = $database->service->destination->server;

View file

@ -68,11 +68,16 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
// Escape the identifier for shell usage
$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 {
$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
// 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

View file

@ -71,7 +71,7 @@ public function instantSave()
public function manualCleanup()
{
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.');
} catch (\Throwable $e) {
return handleError($e, $this);

View file

@ -19,7 +19,15 @@ class Proxy extends Component
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 = [
'server.settings.generate_exact_labels' => 'required|boolean',

View file

@ -69,6 +69,7 @@ public function updateAllPackages()
{
if (! $this->packageManager || ! $this->osId) {
$this->dispatch('error', message: 'Run “Check for updates” first.');
return;
}

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

View file

@ -2,11 +2,8 @@
namespace App\Livewire\Settings;
use App\Jobs\CheckForUpdatesJob;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -15,10 +12,7 @@ class Index extends Component
{
public InstanceSettings $settings;
protected Server $server;
#[Validate('boolean')]
public bool $is_auto_update_enabled;
public Server $server;
#[Validate('nullable|string|max:255')]
public ?string $fqdn = null;
@ -29,45 +23,18 @@ class Index extends Component
#[Validate('required|integer|min:1025|max:65535')]
public int $public_port_max;
#[Validate('nullable|string')]
public ?string $custom_dns_servers = null;
#[Validate('nullable|string|max:255')]
public ?string $instance_name = null;
#[Validate('nullable|string')]
public ?string $allowed_ips = null;
#[Validate('nullable|string')]
public ?string $public_ipv4 = null;
#[Validate('nullable|string')]
public ?string $public_ipv6 = null;
#[Validate('string')]
public string $auto_update_frequency;
#[Validate('string|required')]
public string $update_check_frequency;
#[Validate('required|string|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()
{
return view('livewire.settings.index');
@ -77,26 +44,16 @@ public function mount()
{
if (! isInstanceAdmin()) {
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]
@ -111,28 +68,12 @@ public function timezones(): array
public function instantSave($isSave = true)
{
$this->validate();
if ($this->settings->is_auto_update_enabled === true) {
$this->validate([
'auto_update_frequency' => ['required', 'string'],
]);
}
$this->settings->fqdn = $this->fqdn;
$this->settings->public_port_min = $this->public_port_min;
$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->allowed_ips = $this->allowed_ips;
$this->settings->public_ipv4 = $this->public_ipv4;
$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;
if ($isSave) {
$this->settings->save();
@ -144,7 +85,6 @@ public function submit()
{
try {
$error_show = false;
$this->server = Server::findOrFail(0);
$this->resetErrorBag();
if (! validate_timezone($this->instance_timezone)) {
@ -161,46 +101,15 @@ public function submit()
}
$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;
}
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.");
if ($this->settings->is_dns_validation_enabled && $this->fqdn) {
if (! validate_dns_entry($this->fqdn, $this->server)) {
$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.");
$error_show = true;
}
}
if ($this->settings->fqdn) {
check_domain_usage(domain: $this->settings->fqdn);
if ($this->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);
@ -213,31 +122,4 @@ public function submit()
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;
}
}

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

View file

@ -46,32 +46,31 @@ public function mount()
{
if (! isInstanceAdmin()) {
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()

View file

@ -31,7 +31,7 @@ class Form extends Component
'storage.endpoint' => 'Endpoint',
];
public function test_s3_connection()
public function testConnection()
{
try {
$this->storage->testConnection(shouldSave: true);
@ -45,6 +45,8 @@ public function test_s3_connection()
public function delete()
{
try {
$this->authorize('delete', $this->storage);
$this->storage->delete();
return redirect()->route('storage.index');
@ -57,7 +59,7 @@ public function submit()
{
$this->validate();
try {
$this->test_s3_connection();
$this->testConnection();
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -3,7 +3,6 @@
namespace App\Livewire\Team;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
@ -53,30 +52,12 @@ public function getUsers()
}
}
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)
{
if (! isInstanceAdmin()) {
return redirect()->route('dashboard');
}
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
@ -84,52 +65,22 @@ public function delete($id, $password)
return;
}
}
if (! auth()->user()->isInstanceAdmin()) {
return $this->dispatch('error', 'You are not authorized to delete users');
}
$user = User::find($id);
$teams = $user->teams;
foreach ($teams as $team) {
$user_alone_in_team = $team->members->count() === 1;
if ($team->id === 0) {
if ($user_alone_in_team) {
return $this->dispatch('error', 'User is alone in the root team, cannot delete');
}
}
if ($user_alone_in_team) {
$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);
}
if (! $user) {
return $this->dispatch('error', 'User not found');
}
try {
$user->delete();
$this->getUsers();
} catch (\Exception $e) {
return $this->dispatch('error', $e->getMessage());
}
$user->delete();
$this->getUsers();
}
public function render()

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Livewire\Component;
class Invitations extends Component
@ -14,8 +15,13 @@ class Invitations extends Component
public function deleteInvitation(int $invitation_id)
{
try {
$initiation_found = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id);
$initiation_found->delete();
$invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id);
$user = User::whereEmail($invitation->email)->first();
if (filled($user)) {
$user->deleteIfNotVerifiedAndForcePasswordReset();
}
$invitation->delete();
$this->refreshInvitations();
$this->dispatch('success', 'Invitation revoked.');
} catch (\Exception) {

View file

@ -29,15 +29,15 @@ public function mount()
public function viaEmail()
{
$this->generate_invite_link(sendEmail: true);
$this->generateInviteLink(sendEmail: true);
}
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 {
$this->validate();

View file

@ -798,7 +798,7 @@ public function environment()
public function previews()
{
return $this->hasMany(ApplicationPreview::class);
return $this->hasMany(ApplicationPreview::class)->orderBy('pull_request_id', 'desc');
}
public function deployment_queue()
@ -836,9 +836,14 @@ public function get_last_days_deployments()
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');
if ($pullRequestId) {
$deployments = $deployments->where('pull_request_id', $pullRequestId);
}
$count = $deployments->count();
$deployments = $deployments->skip($skip)->take($take)->get();
@ -1578,34 +1583,6 @@ public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false
}
}
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
{
$application = self::where('uuid', $uuid)->first();

View file

@ -2,19 +2,25 @@
namespace App\Models;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
class ApplicationPreview extends BaseModel
{
use SoftDeletes;
protected $guarded = [];
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') {
$server = $preview->application->destination->server;
$composeFile = $preview->application->parse(pull_request_id: $preview->pull_request_id);
// Docker Compose volume and network cleanup
$composeFile = $application->parse(pull_request_id: $preview->pull_request_id);
$volumes = data_get($composeFile, 'volumes');
$networks = data_get($composeFile, 'networks');
$networkKeys = collect($networks)->keys();
@ -26,7 +32,18 @@ protected static function booted()
instant_remote_process(["docker network disconnect $key coolify-proxy"], $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) {
if ($preview->isDirty('status')) {
@ -50,12 +67,23 @@ public function application()
return $this->belongsTo(Application::class);
}
public function generate_preview_fqdn_compose()
public function persistentStorages()
{
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect();
foreach ($domains as $service_name => $domain) {
$domain = data_get($domain, 'domain');
$url = Url::fromString($domain);
return $this->morphMany(\App\Models\LocalPersistentVolume::class, 'resource');
}
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;
$host = $url->getHost();
$schema = $url->getScheme();
@ -64,12 +92,76 @@ public function generate_preview_fqdn_compose()
$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";
$docker_compose_domains = data_get($this, 'docker_compose_domains');
$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->fqdn = $preview_fqdn;
$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();
}
}

View file

@ -118,7 +118,14 @@ public function realValue(): Attribute
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;
}
);
}

View file

@ -27,6 +27,7 @@ public function couldBeEnabled(): bool
case 'azure':
return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant);
case 'authentik':
case 'clerk':
return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url);
default:
return filled($this->client_id) && filled($this->client_secret);

View file

@ -36,6 +36,18 @@ public function get_last_days_backup_status($days = 7)
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()
{
if ($this->database) {

View file

@ -887,7 +887,7 @@ public function privateKey()
public function muxFilename()
{
return $this->uuid;
return 'mux_'.$this->uuid;
}
public function team()

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\ProcessStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -9,6 +10,7 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA;
use Spatie\Activitylog\Models\Activity;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@ -116,6 +118,18 @@ public function isExited()
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()
{
return 'service';
@ -159,6 +173,10 @@ public function deleteConnectedNetworks()
public function getStatusAttribute()
{
if ($this->isStarting()) {
return 'starting:unhealthy';
}
$applications = $this->applications;
$databases = $this->databases;
@ -1242,26 +1260,17 @@ public function saveComposeConfigs()
return 3;
});
$envs = collect([]);
foreach ($sorted as $env) {
if (version_compare($env->version, '4.0.0-beta.347', '<=')) {
$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";
}
$envs->push("{$env->key}={$env->real_value}");
}
if ($sorted->count() === 0) {
if ($envs->count() === 0) {
$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);
}

View file

@ -33,6 +33,10 @@ public function isValid()
return true;
} else {
$this->delete();
$user = User::whereEmail($this->email)->first();
if (filled($user)) {
$user->deleteIfNotVerifiedAndForcePasswordReset();
}
return false;
}

View file

@ -72,6 +72,93 @@ protected static function boot()
$new_team = Team::create($team);
$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()

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

View file

@ -9,9 +9,12 @@
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use SocialiteProviders\Authentik\AuthentikExtendSocialite;
use SocialiteProviders\Azure\AzureExtendSocialite;
use SocialiteProviders\Clerk\ClerkExtendSocialite;
use SocialiteProviders\Discord\DiscordExtendSocialite;
use SocialiteProviders\Google\GoogleExtendSocialite;
use SocialiteProviders\Infomaniak\InfomaniakExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled;
use SocialiteProviders\Zitadel\ZitadelExtendSocialite;
class EventServiceProvider extends ServiceProvider
{
@ -25,8 +28,11 @@ class EventServiceProvider extends ServiceProvider
SocialiteWasCalled::class => [
AzureExtendSocialite::class.'@handle',
AuthentikExtendSocialite::class.'@handle',
ClerkExtendSocialite::class.'@handle',
DiscordExtendSocialite::class.'@handle',
GoogleExtendSocialite::class.'@handle',
InfomaniakExtendSocialite::class.'@handle',
ZitadelExtendSocialite::class.'@handle',
],
];

View file

@ -49,7 +49,7 @@ protected function configureRateLimiting(): void
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) {
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());

View file

@ -1,13 +1,13 @@
<?php
namespace App\View\Components\services;
namespace App\View\Components\Services;
use App\Models\Service;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class advanced extends Component
class Advanced extends Component
{
/**
* Create a new component instance.

View file

@ -359,7 +359,9 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
{
$labels = collect([]);
$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');
$is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null;

View file

@ -119,70 +119,62 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$resourceFqdns = str($resource->fqdn)->explode(',');
if ($resourceFqdns->count() === 1) {
$resourceFqdns = $resourceFqdns->first();
$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();
$variableName = 'SERVICE_FQDN_'.str($resource->name)->upper()->replace('-', '_');
$fqdn = Url::fromString($resourceFqdns);
$port = $fqdn->getPort();
$path = $fqdn->getPath();
$fqdn = $fqdn->getScheme().'://'.$fqdn->getHost();
if ($generatedEnv) {
if ($path === '/') {
$generatedEnv->value = $fqdn;
} else {
$generatedEnv->value = $fqdn.$path;
}
$generatedEnv->save();
}
$fqdnValue = ($path === '/') ? $fqdn : $fqdn.$path;
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
if ($port) {
$variableName = $variableName."_$port";
$generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', $variableName)
->first();
if ($generatedEnv) {
if ($path === '/') {
$generatedEnv->value = $fqdn;
} else {
$generatedEnv->value = $fqdn.$path;
}
$generatedEnv->save();
}
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $fqdnValue,
'is_build_time' => false,
'is_preview' => false,
]);
}
$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();
$variableName = 'SERVICE_URL_'.str($resource->name)->upper()->replace('-', '_');
$url = Url::fromString($fqdn);
$port = $url->getPort();
$path = $url->getPath();
$url = $url->getHost();
if ($generatedEnv) {
$url = str($fqdn)->after('://');
if ($path === '/') {
$generatedEnv->value = $url;
} else {
$generatedEnv->value = $url.$path;
}
$generatedEnv->save();
$urlValue = str($fqdn)->after('://');
if ($path !== '/') {
$urlValue = $urlValue.$path;
}
EnvironmentVariable::updateOrCreate([
'resourceable_type' => Service::class,
'resourceable_id' => $resource->service_id,
'key' => $variableName,
], [
'value' => $urlValue,
'is_build_time' => false,
'is_preview' => false,
]);
if ($port) {
$variableName = $variableName."_$port";
$generatedEnv = EnvironmentVariable::where('resourceable_type', Service::class)
->where('resourceable_id', $resource->service_id)
->where('key', $variableName)
->first();
if ($generatedEnv) {
if ($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,
]);
}
} elseif ($resourceFqdns->count() > 1) {
foreach ($resourceFqdns as $fqdn) {
@ -243,7 +235,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$port_env_url->save();
}
} else {
$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)
@ -254,7 +246,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
$generatedEnv->value = $fqdn;
$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)
@ -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) {
return handleError($e);

View file

@ -599,7 +599,15 @@ function getTopLevelNetworks(Service|Application $resource)
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
} 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');
$topLevelNetworks = collect(data_get($yaml, 'networks', []));
@ -653,9 +661,16 @@ function getTopLevelNetworks(Service|Application $resource)
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
} 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', []));
$services = data_get($yaml, 'services');
$definedNetwork = collect([$resource->uuid]);
@ -2931,7 +2946,6 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
} catch (\Exception) {
return collect([]);
}
$services = data_get($yaml, 'services', collect([]));
$topLevel = collect([
'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();
if ($applicationFound) {
$savedService = $applicationFound;
// $savedService = ServiceDatabase::firstOrCreate([
// 'name' => $applicationFound->name,
// 'image' => $applicationFound->image,
// 'service_id' => $applicationFound->service_id,
// ]);
// $applicationFound->delete();
} else {
$savedService = ServiceDatabase::firstOrCreate([
'name' => $serviceName,
@ -3007,15 +3015,22 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
$savedService = ServiceApplication::firstOrCreate([
'name' => $serviceName,
'service_id' => $resource->id,
], [
'is_gzip_enabled' => true,
]);
}
// Check if image changed
if ($savedService->image !== $image) {
$savedService->image = $image;
$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', []));
$buildArgs = collect(data_get($service, 'build.args', []));
$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
$key = str($key);
$value = str($value);
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
preg_match_all($regex, $value, $valueMatches);
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
if ($key->startsWith('SERVICE_FQDN_')) {
// 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;
}
if ($isApplication) {
$fqdn = generateFqdn($server, "$uuid");
$fqdn = $resource->fqdn;
if (blank($resource->fqdn)) {
$fqdn = generateFqdn($server, "$uuid");
}
} elseif ($isService) {
if ($fqdnFor) {
$fqdn = generateFqdn($server, "$fqdnFor-$uuid");
if (blank($savedService->fqdn)) {
if ($fqdnFor) {
$fqdn = generateFqdn($server, "$fqdnFor-$uuid");
} else {
$fqdn = generateFqdn($server, "{$savedService->name}-$uuid");
}
} 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) {
$resource->environment_variables()->firstOrCreate([
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'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) {
$newKey = str($key)->beforeLast('_');
$resource->environment_variables()->firstOrCreate([
$resource->environment_variables()->updateOrCreate([
'key' => $newKey->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
@ -3126,6 +3146,9 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
continue;
}
if ($command->value() === 'FQDN') {
if ($isApplication && $resource->build_pack === 'dockercompose') {
continue;
}
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
if (str($fqdnFor)->contains('_')) {
$fqdnFor = str($fqdnFor)->before('_');
@ -3141,6 +3164,13 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
'is_preview' => false,
]);
} 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();
if (str($fqdnFor)->contains('_')) {
$fqdnFor = str($fqdnFor)->before('_');
@ -3591,7 +3621,8 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
'is_required' => $isRequired,
]);
// 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;
}
@ -3629,9 +3660,30 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
}
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");
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(',');
if ($isPullRequest) {
$preview = $resource->previews()->find($preview_id);
@ -3663,7 +3715,6 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
}
}
}
$defaultLabels = defaultLabels(
id: $resource->id,
name: $containerName,
@ -3673,6 +3724,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int
type: 'application',
environment: $resource->environment->name,
);
} elseif ($isService) {
if ($savedService->serviceType()) {
$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
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) {
return str($fqdn)->replace('http://', '')->replace('https://', '');
return str($fqdn)->replace('http://', '')->replace('https://', '')->before(':');
});
$coolifyEnvironments->put('COOLIFY_FQDN', $urls->implode(','));
}

View file

@ -22,15 +22,26 @@ function get_socialite_provider(string $provider)
return Socialite::driver('azure')->setConfig($azure_config);
}
if ($provider == 'authentik') {
$authentik_config = new \SocialiteProviders\Manager\Config(
if ($provider == 'authentik' || $provider == 'clerk') {
$authentik_clerk_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('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') {
@ -53,6 +64,7 @@ function get_socialite_provider(string $provider)
$provider_class_map = [
'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class,
'discord' => \SocialiteProviders\Discord\Provider::class,
'github' => \Laravel\Socialite\Two\GithubProvider::class,
'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class,
'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class,

View file

@ -13,62 +13,65 @@
"require": {
"php": "^8.4",
"danharrin/livewire-rate-limiting": "^2.1.0",
"doctrine/dbal": "^4.2.2",
"guzzlehttp/guzzle": "^7.9.2",
"laravel/fortify": "^1.25.4",
"laravel/framework": "^12.4.1",
"laravel/horizon": "^5.30.3",
"laravel/pail": "^1.2.2",
"laravel/prompts": "^0.3.5|^0.3.5|^0.3.5",
"laravel/sanctum": "^4.0.8",
"laravel/socialite": "^5.18.0",
"doctrine/dbal": "^4.3.0",
"guzzlehttp/guzzle": "^7.9.3",
"laravel/fortify": "^1.27.0",
"laravel/framework": "^12.20.0",
"laravel/horizon": "^5.33.1",
"laravel/pail": "^1.2.3",
"laravel/prompts": "^0.3.6|^0.3.6|^0.3.6",
"laravel/sanctum": "^4.1.2",
"laravel/socialite": "^5.21.0",
"laravel/tinker": "^2.10.1",
"laravel/ui": "^4.6.1",
"lcobucci/jwt": "^5.5.0",
"league/flysystem-aws-s3-v3": "^3.29",
"league/flysystem-sftp-v3": "^3.29",
"livewire/livewire": "^3.5.20",
"league/flysystem-sftp-v3": "^3.30",
"livewire/livewire": "^3.6.4",
"log1x/laravel-webfonts": "^2.0.1",
"lorisleiva/laravel-actions": "^2.8.6",
"lorisleiva/laravel-actions": "^2.9.0",
"nubs/random-name-generator": "^2.2",
"phpseclib/phpseclib": "^3.0.43",
"pion/laravel-chunk-upload": "^1.5.4",
"phpseclib/phpseclib": "^3.0.46",
"pion/laravel-chunk-upload": "^1.5.6",
"poliander/cron": "^3.2.1",
"purplepixie/phpdns": "^2.2",
"pusher/pusher-php-server": "^7.2.7",
"resend/resend-laravel": "^0.17.0",
"sentry/sentry-laravel": "^4.13",
"resend/resend-laravel": "^0.19.0",
"sentry/sentry-laravel": "^4.15.1",
"socialiteproviders/authentik": "^5.2",
"socialiteproviders/clerk": "^5.0",
"socialiteproviders/discord": "^4.2",
"socialiteproviders/google": "^4.1",
"socialiteproviders/infomaniak": "^4.0",
"socialiteproviders/microsoft-azure": "^5.2",
"spatie/laravel-activitylog": "^4.10.1",
"spatie/laravel-data": "^4.13.1",
"spatie/laravel-ray": "^1.39.1",
"socialiteproviders/zitadel": "^4.2",
"spatie/laravel-activitylog": "^4.10.2",
"spatie/laravel-data": "^4.17.0",
"spatie/laravel-ray": "^1.40.2",
"spatie/laravel-schemaless-attributes": "^2.5.1",
"spatie/url": "^2.4",
"stevebauman/purify": "^6.3",
"stripe/stripe-php": "^16.5.1",
"symfony/yaml": "^7.2.3",
"stevebauman/purify": "^6.3.1",
"stripe/stripe-php": "^16.6.0",
"symfony/yaml": "^7.3.1",
"visus/cuid2": "^4.1.0",
"yosymfony/toml": "^1.0.4",
"zircote/swagger-php": "^5.0.5"
"zircote/swagger-php": "^5.1.4"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.15.1",
"driftingly/rector-laravel": "^2.0.2",
"barryvdh/laravel-debugbar": "^3.15.4",
"driftingly/rector-laravel": "^2.0.5",
"fakerphp/faker": "^1.24.1",
"laravel/dusk": "^8.3.1",
"laravel/pint": "^1.21",
"laravel/telescope": "^5.5",
"laravel/dusk": "^8.3.3",
"laravel/pint": "^1.24",
"laravel/telescope": "^5.10",
"mockery/mockery": "^1.6.12",
"nunomaduro/collision": "^8.6.1",
"pestphp/pest": "^3.8.0",
"phpstan/phpstan": "^2.1.6",
"rector/rector": "^2.0.9",
"nunomaduro/collision": "^8.8.2",
"pestphp/pest": "^3.8.2",
"phpstan/phpstan": "^2.1.18",
"rector/rector": "^2.1.2",
"serversideup/spin": "^3.0.2",
"spatie/laravel-ignition": "^2.9.1",
"symfony/http-client": "^7.2.3"
"symfony/http-client": "^7.3.1"
},
"minimum-stability": "stable",
"prefer-stable": true,

1050
composer.lock generated

File diff suppressed because it is too large Load diff

5
config/api.php Normal file
View file

@ -0,0 +1,5 @@
<?php
return [
'rate_limit' => env('API_RATE_LIMIT', 200),
];

View file

@ -2,9 +2,9 @@
return [
'coolify' => [
'version' => '4.0.0-beta.420',
'helper_version' => '1.0.8',
'realtime_version' => '1.0.9',
'version' => '4.0.0-beta.420.7',
'helper_version' => '1.0.9',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),

View file

@ -182,14 +182,15 @@
'defaults' => [
's6' => [
'connection' => 'redis',
'queue' => ['high', 'default'],
'balance' => env('HORIZON_BALANCE', 'auto'),
'maxTime' => 0,
'maxJobs' => 0,
'balance' => env('HORIZON_BALANCE', 'false'),
'queue' => env('HORIZON_QUEUES', 'high,default'),
'maxTime' => 3600,
'maxJobs' => 400,
'memory' => 128,
'tries' => 1,
'timeout' => 3560,
'nice' => 0,
'sleep' => 3,
'timeout' => 3600,
],
],
@ -198,7 +199,7 @@
's6' => [
'autoScalingStrategy' => 'size',
'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),
'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1),
],
@ -208,7 +209,7 @@
's6' => [
'autoScalingStrategy' => 'size',
'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),
'balanceCooldown' => env('HORIZON_BALANCE_COOLDOWN', 1),
],

View file

@ -118,6 +118,20 @@
'emergency' => [
'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,
],
],
];

View file

@ -46,6 +46,13 @@
'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' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
@ -53,4 +60,11 @@
'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'),
]
];

View file

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

View file

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

View file

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

View file

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

View file

@ -17,11 +17,14 @@ public function run(): void
$providers = collect([
'azure',
'bitbucket',
'clerk',
'discord',
'github',
'gitlab',
'google',
'authentik',
'infomaniak',
'zitadel',
]);
$isOauthSeeded = OauthSetting::count() > 0;

View file

@ -4,15 +4,15 @@ ARG BASE_IMAGE=alpine:3.21
# https://download.docker.com/linux/static/stable/
ARG DOCKER_VERSION=28.0.0
# 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
ARG DOCKER_BUILDX_VERSION=0.22.0
ARG DOCKER_BUILDX_VERSION=0.25.0
# https://github.com/buildpacks/pack/releases
ARG PACK_VERSION=0.37.0
ARG PACK_VERSION=0.38.2
# https://github.com/railwayapp/nixpacks/releases
ARG NIXPACKS_VERSION=1.34.1
ARG NIXPACKS_VERSION=1.39.0
# 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

View file

@ -2,7 +2,7 @@
# https://github.com/soketi/soketi/releases
ARG SOKETI_VERSION=1.6-16-alpine
# 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}

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