diff --git a/app/Console/Commands/CleanupRedis.php b/app/Console/Commands/CleanupRedis.php index a13cda0b8..f6a2de75b 100644 --- a/app/Console/Commands/CleanupRedis.php +++ b/app/Console/Commands/CleanupRedis.php @@ -7,9 +7,9 @@ class CleanupRedis extends Command { - protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup}'; + protected $signature = 'cleanup:redis {--dry-run : Show what would be deleted without actually deleting} {--skip-overlapping : Skip overlapping queue cleanup} {--clear-locks : Clear stale WithoutOverlapping locks}'; - protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, and related data)'; + protected $description = 'Cleanup Redis (Horizon jobs, metrics, overlapping queues, cache locks, and related data)'; public function handle() { @@ -56,6 +56,13 @@ public function handle() $deletedCount += $overlappingCleaned; } + // Clean up stale cache locks (WithoutOverlapping middleware) + if ($this->option('clear-locks')) { + $this->info('Cleaning up stale cache locks...'); + $locksCleaned = $this->cleanupCacheLocks($dryRun); + $deletedCount += $locksCleaned; + } + if ($dryRun) { $this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys"); } else { @@ -273,4 +280,56 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun) return $cleanedCount; } + + private function cleanupCacheLocks(bool $dryRun): int + { + $cleanedCount = 0; + + // Use the default Redis connection (database 0) where cache locks are stored + $redis = Redis::connection('default'); + + // Get all keys matching WithoutOverlapping lock pattern + $allKeys = $redis->keys('*'); + $lockKeys = []; + + foreach ($allKeys as $key) { + // Match cache lock keys: they contain 'laravel-queue-overlap' + if (preg_match('/overlap/i', $key)) { + $lockKeys[] = $key; + } + } + if (empty($lockKeys)) { + $this->info(' No cache locks found.'); + + return 0; + } + + $this->info(' Found '.count($lockKeys).' cache lock(s)'); + + foreach ($lockKeys as $lockKey) { + // Check TTL to identify stale locks + $ttl = $redis->ttl($lockKey); + + // TTL = -1 means no expiration (stale lock!) + // TTL = -2 means key doesn't exist + // TTL > 0 means lock is valid and will expire + if ($ttl === -1) { + if ($dryRun) { + $this->warn(" Would delete STALE lock (no expiration): {$lockKey}"); + } else { + $redis->del($lockKey); + $this->info(" ✓ Deleted STALE lock: {$lockKey}"); + } + $cleanedCount++; + } elseif ($ttl > 0) { + $this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}"); + } + } + + if ($cleanedCount === 0) { + $this->info(' No stale locks found (all locks have expiration set)'); + } + + return $cleanedCount; + } } diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index 6e8d18f61..4bc818f0a 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -73,7 +73,7 @@ public function handle() $this->cleanupUnusedNetworkFromCoolifyProxy(); try { - $this->call('cleanup:redis'); + $this->call('cleanup:redis', ['--clear-locks' => true]); } catch (\Throwable $e) { echo "Error in cleanup:redis command: {$e->getMessage()}\n"; } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index a624348c0..971c1d806 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -459,7 +459,7 @@ private function decide_what_to_do() private function post_deployment() { GetContainersStatus::dispatch($this->server); - $this->next(ApplicationDeploymentStatus::FINISHED->value); + $this->completeDeployment(); if ($this->pull_request_id !== 0) { if ($this->application->is_github_based()) { ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED); @@ -517,6 +517,10 @@ private function deploy_dockerimage_buildpack() $this->generate_image_names(); $this->prepare_builder_image(); $this->generate_compose_file(); + + // Save runtime environment variables (including empty .env file if no variables defined) + $this->save_runtime_environment_variables(); + $this->rolling_update(); } @@ -1004,7 +1008,7 @@ private function just_restart() $this->generate_image_names(); $this->check_image_locally_or_remotely(); $this->should_skip_build(); - $this->next(ApplicationDeploymentStatus::FINISHED->value); + $this->completeDeployment(); } private function should_skip_build() @@ -1222,9 +1226,9 @@ private function save_runtime_environment_variables() // Handle empty environment variables if ($environment_variables->isEmpty()) { - // For Docker Compose, we need to create an empty .env file + // For Docker Compose and Docker Image, we need to create an empty .env file // because we always reference it in the compose file - if ($this->build_pack === 'dockercompose') { + if ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerimage') { $this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).'); // Create empty .env file @@ -1628,7 +1632,7 @@ private function health_check() return; } if ($this->application->custom_healthcheck_found) { - $this->application_deployment_queue->addLogEntry('Custom healthcheck found, skipping default healthcheck.'); + $this->application_deployment_queue->addLogEntry('Custom healthcheck found in Dockerfile.'); } if ($this->container_name) { $counter = 1; @@ -2354,16 +2358,22 @@ private function generate_compose_file() ]; // Always use .env file $docker_compose['services'][$this->container_name]['env_file'] = ['.env']; - $docker_compose['services'][$this->container_name]['healthcheck'] = [ - 'test' => [ - 'CMD-SHELL', - $this->generate_healthcheck_commands(), - ], - 'interval' => $this->application->health_check_interval.'s', - 'timeout' => $this->application->health_check_timeout.'s', - 'retries' => $this->application->health_check_retries, - 'start_period' => $this->application->health_check_start_period.'s', - ]; + + // Only add Coolify healthcheck if no custom HEALTHCHECK found in Dockerfile + // If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used + // If healthcheck is disabled, no healthcheck will be added + if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) { + $docker_compose['services'][$this->container_name]['healthcheck'] = [ + 'test' => [ + 'CMD-SHELL', + $this->generate_healthcheck_commands(), + ], + 'interval' => $this->application->health_check_interval.'s', + 'timeout' => $this->application->health_check_timeout.'s', + 'retries' => $this->application->health_check_retries, + 'start_period' => $this->application->health_check_start_period.'s', + ]; + } if (! is_null($this->application->limits_cpuset)) { data_set($docker_compose, 'services.'.$this->container_name.'.cpuset', $this->application->limits_cpuset); @@ -3013,9 +3023,7 @@ private function stop_running_container(bool $force = false) $this->application_deployment_queue->addLogEntry('----------------------------------------'); } $this->application_deployment_queue->addLogEntry('New container is not healthy, rolling back to the old container.'); - $this->application_deployment_queue->update([ - 'status' => ApplicationDeploymentStatus::FAILED->value, - ]); + $this->failDeployment(); $this->graceful_shutdown_container($this->container_name); } } @@ -3649,42 +3657,116 @@ private function checkForCancellation(): void } } - private function next(string $status) + /** + * Transition deployment to a new status with proper validation and side effects. + * This is the single source of truth for status transitions. + */ + private function transitionToStatus(ApplicationDeploymentStatus $status): void { - // Refresh to get latest status - $this->application_deployment_queue->refresh(); - - // Never allow changing status from FAILED or CANCELLED_BY_USER to anything else - if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) { - $this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview)); - + if ($this->isInTerminalState()) { return; } + + $this->updateDeploymentStatus($status); + $this->handleStatusTransition($status); + queue_next_deployment($this->application); + } + + /** + * Check if deployment is in a terminal state (FAILED or CANCELLED). + * Terminal states cannot be changed. + */ + private function isInTerminalState(): bool + { + $this->application_deployment_queue->refresh(); + + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) { + return true; + } + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { - // Job was cancelled, stop execution $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); throw new \RuntimeException('Deployment cancelled by user', 69420); } + return false; + } + + /** + * Update the deployment status in the database. + */ + private function updateDeploymentStatus(ApplicationDeploymentStatus $status): void + { $this->application_deployment_queue->update([ - 'status' => $status, + 'status' => $status->value, ]); + } - queue_next_deployment($this->application); + /** + * Execute status-specific side effects (events, notifications, additional deployments). + */ + private function handleStatusTransition(ApplicationDeploymentStatus $status): void + { + match ($status) { + ApplicationDeploymentStatus::FINISHED => $this->handleSuccessfulDeployment(), + ApplicationDeploymentStatus::FAILED => $this->handleFailedDeployment(), + default => null, + }; + } - if ($status === ApplicationDeploymentStatus::FINISHED->value) { - event(new ApplicationConfigurationChanged($this->application->team()->id)); + /** + * Handle side effects when deployment succeeds. + */ + private function handleSuccessfulDeployment(): void + { + event(new ApplicationConfigurationChanged($this->application->team()->id)); - if (! $this->only_this_server) { - $this->deploy_to_additional_destinations(); - } - $this->application->environment->project->team?->notify(new DeploymentSuccess($this->application, $this->deployment_uuid, $this->preview)); + if (! $this->only_this_server) { + $this->deploy_to_additional_destinations(); } + + $this->sendDeploymentNotification(DeploymentSuccess::class); + } + + /** + * Handle side effects when deployment fails. + */ + private function handleFailedDeployment(): void + { + $this->sendDeploymentNotification(DeploymentFailed::class); + } + + /** + * Send deployment status notification to the team. + */ + private function sendDeploymentNotification(string $notificationClass): void + { + $this->application->environment->project->team?->notify( + new $notificationClass($this->application, $this->deployment_uuid, $this->preview) + ); + } + + /** + * Complete deployment successfully. + * Sends success notification and triggers additional deployments if needed. + */ + private function completeDeployment(): void + { + $this->transitionToStatus(ApplicationDeploymentStatus::FINISHED); + } + + /** + * Fail the deployment. + * Sends failure notification and queues next deployment. + */ + private function failDeployment(): void + { + $this->transitionToStatus(ApplicationDeploymentStatus::FAILED); } public function failed(Throwable $exception): void { - $this->next(ApplicationDeploymentStatus::FAILED->value); + $this->failDeployment(); $this->application_deployment_queue->addLogEntry('Oops something is not okay, are you okay? 😢', 'stderr'); if (str($exception->getMessage())->isNotEmpty()) { $this->application_deployment_queue->addLogEntry($exception->getMessage(), 'stderr'); diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php index 18ca0008c..9937444b8 100644 --- a/app/Jobs/ScheduledJobManager.php +++ b/app/Jobs/ScheduledJobManager.php @@ -52,7 +52,8 @@ public function middleware(): array { return [ (new WithoutOverlapping('scheduled-job-manager')) - ->releaseAfter(60), // Release the lock after 60 seconds if job fails + ->expireAfter(60) // Lock expires after 1 minute to prevent stale locks + ->dontRelease(), // Don't re-queue on lock conflict ]; } diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index ac2b9213b..7912c4b85 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -107,7 +107,7 @@ public function mount() if ($this->selectedServerType === 'remote') { if ($this->privateKeys->isEmpty()) { - $this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get(); + $this->privateKeys = PrivateKey::ownedAndOnlySShKeys(['name'])->where('id', '!=', 0)->get(); } if ($this->servers->isEmpty()) { $this->servers = Server::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get(); @@ -186,7 +186,7 @@ public function setServerType(string $type) return $this->validateServer('localhost'); } elseif ($this->selectedServerType === 'remote') { - $this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get(); + $this->privateKeys = PrivateKey::ownedAndOnlySShKeys(['name'])->where('id', '!=', 0)->get(); // Auto-select first key if available for better UX if ($this->privateKeys->count() > 0) { $this->selectedExistingPrivateKey = $this->privateKeys->first()->id; diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index 62c93611e..5231438e5 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -58,6 +58,11 @@ public function checkStatus() } } + public function manualCheckStatus() + { + $this->checkStatus(); + } + public function force_deploy_without_cache() { $this->authorize('deploy', $this->application); diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 6a287f8cc..8d3d8e294 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -62,6 +62,11 @@ public function checkStatus() } } + public function manualCheckStatus() + { + $this->checkStatus(); + } + public function mount() { $this->parameters = get_route_parameters(); diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 4ad3b9b29..0afcf94e6 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -102,6 +102,36 @@ public function loadServices() : asset($default_logo), ] + (array) $service; })->all(); + + // Extract unique categories from services + $categories = collect($services) + ->pluck('category') + ->filter() + ->unique() + ->map(function ($category) { + // Handle multiple categories separated by comma + if (str_contains($category, ',')) { + return collect(explode(',', $category))->map(fn ($cat) => trim($cat)); + } + + return [$category]; + }) + ->flatten() + ->unique() + ->map(function ($category) { + // Format common acronyms to uppercase + $acronyms = ['ai', 'api', 'ci', 'cd', 'cms', 'crm', 'erp', 'iot', 'vpn', 'vps', 'dns', 'ssl', 'tls', 'ssh', 'ftp', 'http', 'https', 'smtp', 'imap', 'pop3', 'sql', 'nosql', 'json', 'xml', 'yaml', 'csv', 'pdf', 'sms', 'mfa', '2fa', 'oauth', 'saml', 'jwt', 'rest', 'soap', 'grpc', 'graphql', 'websocket', 'webrtc', 'p2p', 'b2b', 'b2c', 'seo', 'sem', 'ppc', 'roi', 'kpi', 'ui', 'ux', 'ide', 'sdk', 'api', 'cli', 'gui', 'cdn', 'ddos', 'dos', 'xss', 'csrf', 'sqli', 'rce', 'lfi', 'rfi', 'ssrf', 'xxe', 'idor', 'owasp', 'gdpr', 'hipaa', 'pci', 'dss', 'iso', 'nist', 'cve', 'cwe', 'cvss']; + $lower = strtolower($category); + + if (in_array($lower, $acronyms)) { + return strtoupper($category); + } + + return $category; + }) + ->sort(SORT_NATURAL | SORT_FLAG_CASE) + ->values() + ->all(); $gitBasedApplications = [ [ 'id' => 'public', @@ -147,14 +177,14 @@ public function loadServices() 'id' => 'postgresql', 'name' => 'PostgreSQL', 'description' => 'PostgreSQL is an object-relational database known for its robustness, advanced features, and strong standards compliance.', - 'logo' => ' + 'logo' => ' ', ], [ 'id' => 'mysql', 'name' => 'MySQL', 'description' => 'MySQL is an open-source relational database management system. ', - 'logo' => ' + 'logo' => ' @@ -165,43 +195,44 @@ public function loadServices() 'id' => 'mariadb', 'name' => 'MariaDB', 'description' => 'MariaDB is a community-developed, commercially supported fork of the MySQL relational database management system, intended to remain free and open-source.', - 'logo' => '', + 'logo' => '', ], [ 'id' => 'redis', 'name' => 'Redis', 'description' => 'Redis is a source-available, in-memory storage, used as a distributed, in-memory key–value database, cache and message broker, with optional durability.', - 'logo' => '', + 'logo' => '', ], [ 'id' => 'keydb', 'name' => 'KeyDB', 'description' => 'KeyDB is a database that offers high performance, low latency, and scalability for various data structures and workloads.', - 'logo' => '
', + 'logo' => '
', ], [ 'id' => 'dragonfly', 'name' => 'Dragonfly', 'description' => 'Dragonfly DB is a drop-in Redis replacement that delivers 25x more throughput and 12x faster snapshotting than Redis.', - 'logo' => '
', + 'logo' => '
', ], [ 'id' => 'mongodb', 'name' => 'MongoDB', 'description' => 'MongoDB is a source-available, cross-platform, document-oriented database program.', - 'logo' => '', + 'logo' => '', ], [ 'id' => 'clickhouse', 'name' => 'ClickHouse', 'description' => 'ClickHouse is a column-oriented database that supports real-time analytics, business intelligence, observability, ML and GenAI, and more.', - 'logo' => '
', + 'logo' => '
', ], ]; return [ 'services' => $services, + 'categories' => $categories, 'gitBasedApplications' => $gitBasedApplications, 'dockerBasedApplications' => $dockerBasedApplications, 'databases' => $databases, diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index 3492da324..c8a08d8f9 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -54,6 +54,11 @@ public function checkStatus() } } + public function manualCheckStatus() + { + $this->checkStatus(); + } + public function serviceChecked() { try { diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index ca5c588f8..7a9b58b70 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -74,12 +74,16 @@ class ByHetzner extends Component #[Locked] public Collection $saved_cloud_init_scripts; + public bool $from_onboarding = false; + public function mount() { $this->authorize('viewAny', CloudProviderToken::class); $this->loadTokens(); $this->loadSavedCloudInitScripts(); $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + if ($this->private_keys->count() > 0) { $this->private_key_id = $this->private_keys->first()->id; } @@ -131,7 +135,7 @@ public function handleTokenAdded($tokenId) public function handlePrivateKeyCreated($keyId) { // Refresh private keys list - $this->private_keys = PrivateKey::ownedByCurrentTeam()->get(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); // Auto-select the new key $this->private_key_id = $keyId; @@ -246,12 +250,6 @@ private function loadHetznerData(string $token) // Get images and sort by name $images = $hetznerService->getImages(); - ray('Raw images from Hetzner API', [ - 'total_count' => count($images), - 'types' => collect($images)->pluck('type')->unique()->values(), - 'sample' => array_slice($images, 0, 3), - ]); - $this->images = collect($images) ->filter(function ($image) { // Only system images @@ -269,20 +267,8 @@ private function loadHetznerData(string $token) ->sortBy('name') ->values() ->toArray(); - - ray('Filtered images', [ - 'filtered_count' => count($this->images), - 'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(), - ]); - // Load SSH keys from Hetzner $this->hetznerSshKeys = $hetznerService->getSshKeys(); - - ray('Hetzner SSH Keys', [ - 'total_count' => count($this->hetznerSshKeys), - 'keys' => $this->hetznerSshKeys, - ]); - $this->loading_data = false; } catch (\Throwable $e) { $this->loading_data = false; @@ -299,9 +285,9 @@ private function getCpuVendorInfo(array $serverType): ?string } elseif (str_starts_with($name, 'cpx')) { return 'AMD EPYC™'; } elseif (str_starts_with($name, 'cx')) { - return 'Intel® Xeon®'; + return 'Intel®/AMD'; } elseif (str_starts_with($name, 'cax')) { - return 'Ampere® Altra®'; + return 'Ampere®'; } return null; @@ -574,6 +560,11 @@ public function submit() $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + if ($this->from_onboarding) { + // When in onboarding, use wire:navigate for proper modal handling + return $this->redirect(route('server.show', $server->uuid)); + } + return redirect()->route('server.show', $server->uuid); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 832123d5a..be38ae1d8 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -85,19 +85,8 @@ public function submit() // Handle allowed IPs with subnet support and 0.0.0.0 special case $this->allowed_ips = str($this->allowed_ips)->replaceEnd(',', '')->trim(); - // Check if user entered 0.0.0.0 or left field empty (both allow access from anywhere) - $allowsFromAnywhere = false; - if (empty($this->allowed_ips)) { - $allowsFromAnywhere = true; - } elseif ($this->allowed_ips === '0.0.0.0' || str_contains($this->allowed_ips, '0.0.0.0')) { - $allowsFromAnywhere = true; - } - - // Check if it's 0.0.0.0 (allow all) or empty - if ($this->allowed_ips === '0.0.0.0' || empty($this->allowed_ips)) { - // Keep as is - empty means no restriction, 0.0.0.0 means allow all - } else { - // Validate and clean up the entries + // Only validate and clean up if we have IPs and it's not 0.0.0.0 (allow all) + if (! empty($this->allowed_ips) && ! in_array('0.0.0.0', array_map('trim', explode(',', $this->allowed_ips)))) { $invalidEntries = []; $validEntries = str($this->allowed_ips)->trim()->explode(',')->map(function ($entry) use (&$invalidEntries) { $entry = str($entry)->trim()->toString(); @@ -133,7 +122,6 @@ public function submit() return; } - // Also check if we have no valid entries after filtering if ($validEntries->isEmpty()) { $this->dispatch('error', 'No valid IP addresses or subnets provided'); @@ -144,14 +132,6 @@ public function submit() } $this->instantSave(); - - // Show security warning if allowing access from anywhere - if ($allowsFromAnywhere) { - $message = empty($this->allowed_ips) - ? 'Empty IP allowlist allows API access from anywhere.

This is not recommended for production environments!' - : 'Using 0.0.0.0 allows API access from anywhere.

This is not recommended for production environments!'; - $this->dispatch('warning', $message); - } } catch (\Exception $e) { return handleError($e, $this); } diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 351407dac..4bd0b798a 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -47,19 +47,19 @@ class Change extends Component public int $customPort; - public int $appId; + public ?int $appId = null; - public int $installationId; + public ?int $installationId = null; - public string $clientId; + public ?string $clientId = null; - public string $clientSecret; + public ?string $clientSecret = null; - public string $webhookSecret; + public ?string $webhookSecret = null; public bool $isSystemWide; - public int $privateKeyId; + public ?int $privateKeyId = null; public ?string $contents = null; @@ -78,16 +78,16 @@ class Change extends Component 'htmlUrl' => 'required|string', 'customUser' => 'required|string', 'customPort' => 'required|int', - 'appId' => 'required|int', - 'installationId' => 'required|int', - 'clientId' => 'required|string', - 'clientSecret' => 'required|string', - 'webhookSecret' => 'required|string', + 'appId' => 'nullable|int', + 'installationId' => 'nullable|int', + 'clientId' => 'nullable|string', + 'clientSecret' => 'nullable|string', + 'webhookSecret' => 'nullable|string', 'isSystemWide' => 'required|bool', 'contents' => 'nullable|string', 'metadata' => 'nullable|string', 'pullRequests' => 'nullable|string', - 'privateKeyId' => 'required|int', + 'privateKeyId' => 'nullable|int', ]; public function boot() @@ -148,47 +148,48 @@ public function checkPermissions() try { $this->authorize('view', $this->github_app); + // Validate required fields before attempting to fetch permissions + $missingFields = []; + + if (! $this->github_app->app_id) { + $missingFields[] = 'App ID'; + } + + if (! $this->github_app->private_key_id) { + $missingFields[] = 'Private Key'; + } + + if (! empty($missingFields)) { + $fieldsList = implode(', ', $missingFields); + $this->dispatch('error', "Cannot fetch permissions. Please set the following required fields first: {$fieldsList}"); + + return; + } + + // Verify the private key exists and is accessible + if (! $this->github_app->privateKey) { + $this->dispatch('error', 'Private Key not found. Please select a valid private key.'); + + return; + } + GithubAppPermissionJob::dispatchSync($this->github_app); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->dispatch('success', 'Github App permissions updated.'); } catch (\Throwable $e) { + // Provide better error message for unsupported key formats + $errorMessage = $e->getMessage(); + if (str_contains($errorMessage, 'DECODER routines::unsupported') || + str_contains($errorMessage, 'parse your key')) { + $this->dispatch('error', 'The selected private key format is not supported for GitHub Apps.

Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY).

OpenSSH format keys (BEGIN OPENSSH PRIVATE KEY) are not supported.'); + + return; + } + return handleError($e, $this); } } - // public function check() - // { - - // Need administration:read:write permission - // https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#list-self-hosted-runners-for-a-repository - - // $github_access_token = generateGithubInstallationToken($this->github_app); - // $repositories = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/installation/repositories?per_page=100"); - // $runners_by_repository = collect([]); - // $repositories = $repositories->json()['repositories']; - // foreach ($repositories as $repository) { - // $runners_downloads = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/downloads"); - // $runners = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners"); - // $token = Http::withHeaders([ - // 'Authorization' => "Bearer $github_access_token", - // 'Accept' => 'application/vnd.github+json' - // ])->withBody(null)->post("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/registration-token"); - // $token = $token->json(); - // $remove_token = Http::withHeaders([ - // 'Authorization' => "Bearer $github_access_token", - // 'Accept' => 'application/vnd.github+json' - // ])->withBody(null)->post("{$this->github_app->api_url}/repos/{$repository['full_name']}/actions/runners/remove-token"); - // $remove_token = $remove_token->json(); - // $runners_by_repository->put($repository['full_name'], [ - // 'token' => $token, - // 'remove_token' => $remove_token, - // 'runners' => $runners->json(), - // 'runners_downloads' => $runners_downloads->json() - // ]); - // } - - // } - public function mount() { try { @@ -340,10 +341,13 @@ public function createGithubAppManually() $this->authorize('update', $this->github_app); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); - $this->github_app->app_id = '1234567890'; - $this->github_app->installation_id = '1234567890'; + $this->github_app->app_id = 1234567890; + $this->github_app->installation_id = 1234567890; $this->github_app->save(); - $this->dispatch('success', 'Github App updated.'); + + // Redirect to avoid Livewire morphing issues when view structure changes + return redirect()->route('source.github.show', ['github_app_uuid' => $this->github_app->uuid]) + ->with('success', 'Github App updated. You can now configure the details.'); } public function instantSave() diff --git a/app/Livewire/Source/Github/Create.php b/app/Livewire/Source/Github/Create.php index f5d851b64..2f1482c89 100644 --- a/app/Livewire/Source/Github/Create.php +++ b/app/Livewire/Source/Github/Create.php @@ -50,11 +50,9 @@ public function createGitHubApp() 'html_url' => $this->html_url, 'custom_user' => $this->custom_user, 'custom_port' => $this->custom_port, + 'is_system_wide' => $this->is_system_wide, 'team_id' => currentTeam()->id, ]; - if (isCloud()) { - $payload['is_system_wide'] = $this->is_system_wide; - } $github_app = GithubApp::create($payload); if (session('from')) { session(['from' => session('from') + ['source_id' => $github_app->id]]); diff --git a/app/Models/Application.php b/app/Models/Application.php index 9554d71a7..32459f752 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1804,7 +1804,22 @@ public function getFilesFromServer(bool $isInit = false) public function parseHealthcheckFromDockerfile($dockerfile, bool $isInit = false) { $dockerfile = str($dockerfile)->trim()->explode("\n"); - if (str($dockerfile)->contains('HEALTHCHECK') && ($this->isHealthcheckDisabled() || $isInit)) { + $hasHealthcheck = str($dockerfile)->contains('HEALTHCHECK'); + + // Always check if healthcheck was removed, regardless of health_check_enabled setting + if (! $hasHealthcheck && $this->custom_healthcheck_found) { + // HEALTHCHECK was removed from Dockerfile, reset to defaults + $this->custom_healthcheck_found = false; + $this->health_check_interval = 5; + $this->health_check_timeout = 5; + $this->health_check_retries = 10; + $this->health_check_start_period = 5; + $this->save(); + + return; + } + + if ($hasHealthcheck && ($this->isHealthcheckDisabled() || $isInit)) { $healthcheckCommand = null; $lines = $dockerfile->toArray(); foreach ($lines as $line) { diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 5550df81f..0d643306c 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -12,6 +12,7 @@ class GithubApp extends BaseModel protected $casts = [ 'is_public' => 'boolean', + 'is_system_wide' => 'boolean', 'type' => 'string', ]; diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index c5cbc6338..46531ed34 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -88,6 +88,16 @@ public static function ownedByCurrentTeam(array $select = ['*']) return self::whereTeamId($teamId)->select($selectArray->all()); } + public static function ownedAndOnlySShKeys(array $select = ['*']) + { + $teamId = currentTeam()->id; + $selectArray = collect($select)->concat(['id']); + + return self::whereTeamId($teamId) + ->where('is_git_related', false) + ->select($selectArray->all()); + } + public static function validatePrivateKey($privateKey) { try { diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index aa6de3897..dd4d6e631 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -88,7 +88,14 @@ public function getImages(): array public function getServerTypes(): array { - return $this->requestPaginated('get', '/server_types', 'server_types'); + $types = $this->requestPaginated('get', '/server_types', 'server_types'); + + // Filter out entries where "deprecated" is explicitly true + $filtered = array_filter($types, function ($type) { + return ! (isset($type['deprecated']) && $type['deprecated'] === true); + }); + + return array_values($filtered); } public function getSshKeys(): array diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 36243e119..382e2d015 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -51,6 +51,8 @@ const SPECIFIC_SERVICES = [ 'quay.io/minio/minio', 'minio/minio', + 'ghcr.io/coollabsio/minio', + 'coollabsio/minio', 'svhd/logto', ]; diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index aa7be3236..5df36db33 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -41,7 +41,13 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth $database = new StandaloneRedis; $database->uuid = (new Cuid2); $database->name = 'redis-database-'.$database->uuid; + $redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + if ($otherData && isset($otherData['redis_password'])) { + $redis_password = $otherData['redis_password']; + unset($otherData['redis_password']); + } + $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index f2260f0c6..01ae50f6b 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -358,6 +358,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int { $uuid = data_get($resource, 'uuid'); $compose = data_get($resource, 'docker_compose_raw'); + // Store original compose for later use to update docker_compose_raw with content removed + $originalCompose = $compose; if (! $compose) { return collect([]); } @@ -1297,7 +1299,34 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int return array_search($key, $customOrder); }); - $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2); + $cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2); + $resource->docker_compose = $cleanedCompose; + + // Update docker_compose_raw to remove content: from volumes only + // This keeps the original user input clean while preventing content reapplication + // Parse the original compose again to create a clean version without Coolify additions + try { + $originalYaml = Yaml::parse($originalCompose); + // Remove content, isDirectory, and is_directory from all volume definitions + if (isset($originalYaml['services'])) { + foreach ($originalYaml['services'] as $serviceName => &$service) { + if (isset($service['volumes'])) { + foreach ($service['volumes'] as $key => &$volume) { + if (is_array($volume)) { + unset($volume['content']); + unset($volume['isDirectory']); + unset($volume['is_directory']); + } + } + } + } + } + $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); + } catch (\Exception $e) { + // If parsing fails, keep the original docker_compose_raw unchanged + ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage()); + } + data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables_preview'); $resource->save(); @@ -1309,6 +1338,8 @@ function serviceParser(Service $resource): Collection { $uuid = data_get($resource, 'uuid'); $compose = data_get($resource, 'docker_compose_raw'); + // Store original compose for later use to update docker_compose_raw with content removed + $originalCompose = $compose; if (! $compose) { return collect([]); } @@ -2220,7 +2251,34 @@ function serviceParser(Service $resource): Collection return array_search($key, $customOrder); }); - $resource->docker_compose = Yaml::dump(convertToArray($topLevel), 10, 2); + $cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2); + $resource->docker_compose = $cleanedCompose; + + // Update docker_compose_raw to remove content: from volumes only + // This keeps the original user input clean while preventing content reapplication + // Parse the original compose again to create a clean version without Coolify additions + try { + $originalYaml = Yaml::parse($originalCompose); + // Remove content, isDirectory, and is_directory from all volume definitions + if (isset($originalYaml['services'])) { + foreach ($originalYaml['services'] as $serviceName => &$service) { + if (isset($service['volumes'])) { + foreach ($service['volumes'] as $key => &$volume) { + if (is_array($volume)) { + unset($volume['content']); + unset($volume['isDirectory']); + unset($volume['is_directory']); + } + } + } + } + } + $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); + } catch (\Exception $e) { + // If parsing fails, keep the original docker_compose_raw unchanged + ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage()); + } + data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables_preview'); $resource->save(); diff --git a/config/constants.php b/config/constants.php index bc81352b0..813594e61 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.437', + 'version' => '4.0.0-beta.438', 'helper_version' => '1.0.11', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fee17dad6..d76c91aa2 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -104,7 +104,7 @@ services: networks: - coolify minio: - image: minio/minio:latest + image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025 pull_policy: always container_name: coolify-minio command: server /data --console-address ":9001" diff --git a/public/svgs/home-assistant.svg b/public/svgs/home-assistant.svg new file mode 100644 index 000000000..7bce628cf --- /dev/null +++ b/public/svgs/home-assistant.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/svgs/metamcp.png b/public/svgs/metamcp.png new file mode 100644 index 000000000..e1eeb5c06 Binary files /dev/null and b/public/svgs/metamcp.png differ diff --git a/public/svgs/pocketid-logo.png b/public/svgs/pocketid-logo.png new file mode 100644 index 000000000..8aa7f00f9 Binary files /dev/null and b/public/svgs/pocketid-logo.png differ diff --git a/public/svgs/redisinsight.png b/public/svgs/redisinsight.png new file mode 100644 index 000000000..bc8056276 Binary files /dev/null and b/public/svgs/redisinsight.png differ diff --git a/public/svgs/rivet.svg b/public/svgs/rivet.svg new file mode 100644 index 000000000..342185b4d --- /dev/null +++ b/public/svgs/rivet.svg @@ -0,0 +1 @@ + diff --git a/public/svgs/siyuan.svg b/public/svgs/siyuan.svg new file mode 100644 index 000000000..fc15edd5e --- /dev/null +++ b/public/svgs/siyuan.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/svgs/sparkyfitness.svg b/public/svgs/sparkyfitness.svg new file mode 100644 index 000000000..7f599cef1 --- /dev/null +++ b/public/svgs/sparkyfitness.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/views/components/forms/datalist.blade.php b/resources/views/components/forms/datalist.blade.php index 5bb12aa8d..79a14d16f 100644 --- a/resources/views/components/forms/datalist.blade.php +++ b/resources/views/components/forms/datalist.blade.php @@ -14,14 +14,156 @@ @if ($multiple) {{-- Multiple Selection Mode with Alpine.js --}}
+ + {{-- Unified Input Container with Tags Inside --}} +
+ + {{-- Selected Tags Inside Input --}} + + + {{-- Search Input (Borderless, Inside Container) --}} + +
+ + {{-- Dropdown Options --}} +
+ + + + +
+ + {{-- Hidden datalist for options --}} + + {{ $slot }} + +
+ @else + {{-- Single Selection Mode with Alpine.js --}} +
- {{-- Unified Input Container with Tags Inside --}} -
+ + {{-- Input Container --}} +
+ }" wire:loading.class="opacity-50" wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"> - {{-- Selected Tags Inside Input --}} - +
- {{-- Search Input (Borderless, Inside Container) --}} - + + + + +
+ + {{-- Hidden datalist for options --}} + + {{ $slot }} + +
@endif - class="flex-1 min-w-[120px] text-sm border-0 outline-none bg-transparent p-0 focus:ring-0 placeholder:text-neutral-400 dark:placeholder:text-neutral-600 text-black dark:text-white" - /> - -{{-- Dropdown Options --}} -
- - - - -
- -{{-- Hidden datalist for options --}} - - {{ $slot }} - - -@else -{{-- Single Selection Mode with Alpine.js --}} -
- - {{-- Hidden input for form validation --}} - - - {{-- Input Container --}} -
- - {{-- Display Selected Value or Search Input --}} -
- - -
- - {{-- Dropdown Arrow --}} - -
- - {{-- Dropdown Options --}} -
- - - - -
- - {{-- Hidden datalist for options --}} - - {{ $slot }} - -
-@endif - -@error($modelBinding) - -@enderror - + @error($modelBinding) + + @enderror + \ No newline at end of file diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 46164840d..edff3b6bf 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -177,7 +177,7 @@ class="relative w-auto h-auto"> @endif