'$refresh', 'refreshFileStorages']; protected $rules = [ 'humanName' => 'nullable', 'description' => 'nullable', 'image' => 'required', 'excludeFromStatus' => 'required|boolean', 'publicPort' => 'nullable|integer|min:1|max:65535', 'publicPortTimeout' => 'nullable|integer|min:1', 'isPublic' => 'required|boolean', 'isLogDrainEnabled' => 'required|boolean', 'maxRestartCount' => 'integer|min:0', // Application-specific rules 'fqdn' => 'nullable', 'isGzipEnabled' => 'nullable|boolean', 'isStripprefixEnabled' => 'nullable|boolean', 'certificateResolverMode' => 'required|in:server,none,custom', 'certificateResolverName' => ['exclude_unless:certificateResolverMode,custom', 'required', 'string', 'max:255', 'regex:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_PATTERN, 'not_in:'.ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE], ]; protected $validationAttributes = [ 'certificateResolverMode' => 'certificate resolver', 'certificateResolverName' => 'certificate resolver name', ]; public function mount( ServiceApplication|ServiceDatabase|null $serviceApplication = null, bool $embedded = false, ) { try { $this->embedded = $embedded; $this->services = collect([]); if ($serviceApplication instanceof ServiceDatabase) { $this->service = $serviceApplication->service; $this->authorize('view', $this->service); $this->parameters = [ 'project_uuid' => $this->service->environment->project->uuid, 'environment_uuid' => $this->service->environment->uuid, 'service_uuid' => $this->service->uuid, 'stack_service_uuid' => $serviceApplication->uuid, ]; $this->query = request()->query(); $this->currentRoute = 'project.service.index'; $this->serviceDatabase = $serviceApplication; $this->serviceApplication = null; $this->resourceType = 'database'; $this->initializeDatabaseProperties(); $this->s3s = $this->serviceTeamS3Storages(); return; } if ($serviceApplication) { $this->service = $serviceApplication->service; $this->authorize('view', $this->service); $this->parameters = [ 'project_uuid' => $this->service->environment->project->uuid, 'environment_uuid' => $this->service->environment->uuid, 'service_uuid' => $this->service->uuid, 'stack_service_uuid' => $serviceApplication->uuid, ]; $this->query = request()->query(); $this->currentRoute = 'project.service.index'; $this->serviceApplication = $serviceApplication; $this->resourceType = 'application'; $this->initializeApplicationProperties(); $this->s3s = $this->serviceTeamS3Storages(); return; } $this->parameters = get_route_parameters(); $project = currentTeam() ->projects() ->select('id', 'uuid', 'team_id') ->where('uuid', $this->parameters['project_uuid']) ->firstOrFail(); $environment = $project->environments() ->select('id', 'uuid', 'name', 'project_id') ->where('uuid', $this->parameters['environment_uuid']) ->firstOrFail(); $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); $this->authorize('view', $this->service); // The settings of a stack service open in a modal on the configuration page. return redirect()->route( 'project.service.configuration', collect($this->parameters)->except('stack_service_uuid')->all(), ); } catch (\Throwable $e) { return handleError($e, $this); } } private function serviceTeamS3Storages(): Collection { return S3Storage::query() ->where('team_id', $this->service->team()->id) ->where('is_usable', true) ->get(); } private function initializeDatabaseProperties(): void { $this->server = $this->serviceDatabase->service->destination->server; if ($this->serviceDatabase->is_public) { $this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl(); } $this->refreshFileStorages(); $this->syncDatabaseData(false); } private function syncDatabaseData(bool $toModel = false): void { if ($toModel) { $this->serviceDatabase->human_name = $this->humanName; $this->serviceDatabase->description = $this->description; $this->serviceDatabase->image = $this->image; $this->serviceDatabase->exclude_from_status = $this->excludeFromStatus; $this->serviceDatabase->public_port = $this->publicPort ?: null; $this->serviceDatabase->public_port_timeout = $this->publicPortTimeout ?: null; $this->serviceDatabase->is_public = $this->isPublic; $this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled; } else { $this->humanName = $this->serviceDatabase->human_name; $this->description = $this->serviceDatabase->description; $this->image = $this->serviceDatabase->image; $this->excludeFromStatus = $this->serviceDatabase->exclude_from_status ?? false; $this->publicPort = $this->serviceDatabase->public_port; $this->publicPortTimeout = $this->serviceDatabase->public_port_timeout; $this->isPublic = $this->serviceDatabase->is_public ?? false; $this->isLogDrainEnabled = $this->serviceDatabase->is_log_drain_enabled ?? false; } } public function generateDockerCompose() { try { $this->authorize('update', $this->service); $this->service->parse(); } catch (\Throwable $e) { return handleError($e, $this); } } // Database-specific methods public function refreshFileStorages() { if ($this->serviceDatabase) { $this->fileStorages = $this->serviceDatabase->fileStorages()->get(); } } public function deleteDatabase($password, $selectedActions = []) { try { $this->authorize('delete', $this->serviceDatabase); if (! verifyPasswordConfirmation($password, $this)) { return 'The provided password is incorrect.'; } $containerRemoved = app(DeleteService::class)->removeSubresourceContainer($this->serviceDatabase); $this->serviceDatabase->delete(); $this->auditServiceSubResource('deleted', $this->serviceDatabase, ['container_removed' => $containerRemoved]); $containerRemoved ? $this->dispatch('success', 'Database deleted.') : $this->dispatch('warning', 'Database deleted from Coolify. The server does not respond, so its container is removed when the service starts again.'); return redirectRoute($this, 'project.service.configuration', $this->parameters); } catch (\Throwable $e) { return handleError($e, $this); } } public function instantSaveExclude() { try { $this->authorize('update', $this->serviceDatabase); $this->submitDatabase(); } catch (\Throwable $e) { return handleError($e, $this); } } public function instantSaveLogDrain() { try { $this->authorize('update', $this->serviceDatabase); if (! $this->serviceDatabase->service->destination->server->isLogDrainEnabled()) { $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); return; } $this->submitDatabase(); $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } catch (\Throwable $e) { return handleError($e, $this); } } public function convertToApplication() { try { $this->authorize('update', $this->serviceDatabase); $service = $this->serviceDatabase->service; $serviceDatabase = $this->serviceDatabase; // Check if application with same name already exists if ($service->applications()->where('name', $serviceDatabase->name)->exists()) { throw new \Exception('An application with this name already exists.'); } // Create new parameters removing database_uuid $redirectParams = collect($this->parameters) ->except('database_uuid') ->all(); $serviceApplication = DB::transaction(function () use ($service, $serviceDatabase) { $serviceApplication = $service->applications()->create([ 'name' => $serviceDatabase->name, 'human_name' => $serviceDatabase->human_name, 'description' => $serviceDatabase->description, 'exclude_from_status' => $serviceDatabase->exclude_from_status, 'is_log_drain_enabled' => $serviceDatabase->is_log_drain_enabled, 'image' => $serviceDatabase->image, 'service_id' => $service->id, 'is_migrated' => true, ]); $serviceDatabase->delete(); return $serviceApplication; }); $this->auditServiceSubResource('converted_to_application', $serviceDatabase, [ 'service_application_uuid' => $serviceApplication->uuid, ]); return redirectRoute($this, 'project.service.configuration', $redirectParams); } catch (\Throwable $e) { return handleError($e, $this); } } public function enablePublicAccess(): void { $this->isPublic = true; $this->instantSave(); } public function disablePublicAccess(): void { $this->isPublic = false; $this->instantSave(); } public function instantSave() { $this->authorize('update', $this->serviceDatabase); try { if ($this->isPublic) { if (! $this->publicPort) { $this->dispatch('error', 'Public port is required.'); $this->isPublic = false; return; } if (! str($this->serviceDatabase->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->isPublic = false; return; } $this->persistPublicAccess(); StartDatabaseProxy::run($this->serviceDatabase); $this->db_url_public = $this->serviceDatabase->getServiceDatabaseUrl(); $this->dispatch('success', 'Database is now publicly accessible.'); } else { $this->persistPublicAccess(); StopDatabaseProxy::run($this->serviceDatabase); $this->db_url_public = null; $this->dispatch('success', 'Database is no longer publicly accessible.'); } } catch (\Throwable $e) { $this->isPublic = ! $this->isPublic; $this->persistPublicAccess(); return handleError($e, $this); } } private function persistPublicAccess(): void { $this->serviceDatabase->fill([ 'is_public' => $this->isPublic, 'public_port' => $this->publicPort ?: null, 'public_port_timeout' => $this->publicPortTimeout ?: null, ]); $changedFields = auditChangedFields($this->serviceDatabase); $this->serviceDatabase->save(); $this->auditServiceSubResourceUpdate($this->serviceDatabase, $changedFields, [ 'is_public' => (bool) $this->serviceDatabase->is_public, 'public_port' => $this->serviceDatabase->public_port, ]); } public function submitDatabase() { try { $this->authorize('update', $this->serviceDatabase); $this->validate(); $this->syncDatabaseData(true); $changedFields = auditChangedFields($this->serviceDatabase); $this->serviceDatabase->save(); $this->auditServiceSubResourceUpdate($this->serviceDatabase, $changedFields); $this->serviceDatabase->refresh(); $this->syncDatabaseData(false); updateCompose($this->serviceDatabase); $this->dispatch('success', 'Database saved.'); } catch (\Throwable $e) { return handleError($e, $this); } finally { $this->dispatch('generateDockerCompose'); } } // Application-specific methods private function initializeApplicationProperties(): void { $this->requiredPort = $this->serviceApplication->getRequiredPort(); $this->syncApplicationData(false); } private function syncApplicationData(bool $toModel = false): void { if ($toModel) { $this->serviceApplication->human_name = $this->humanName; $this->serviceApplication->description = $this->description; $this->serviceApplication->setEditableUrls($this->fqdn); $this->serviceApplication->image = $this->image; $this->serviceApplication->exclude_from_status = $this->excludeFromStatus; $this->serviceApplication->is_log_drain_enabled = $this->isLogDrainEnabled; $this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled; $this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled; $this->serviceApplication->traefik_certificate_resolver = match ($this->certificateResolverMode) { 'none' => ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE, 'custom' => $this->certificateResolverName, default => null, }; if ($this->serviceApplication->max_restart_count !== (int) $this->maxRestartCount) { $this->serviceApplication->restart_limit_reached = false; } $this->serviceApplication->max_restart_count = $this->maxRestartCount; } else { $this->humanName = $this->serviceApplication->human_name; $this->description = $this->serviceApplication->description; $this->fqdn = $this->serviceApplication->url; $this->image = $this->serviceApplication->image; $this->excludeFromStatus = data_get($this->serviceApplication, 'exclude_from_status', false); $this->isLogDrainEnabled = data_get($this->serviceApplication, 'is_log_drain_enabled', false); $this->isGzipEnabled = data_get($this->serviceApplication, 'is_gzip_enabled', true); $this->isStripprefixEnabled = data_get($this->serviceApplication, 'is_stripprefix_enabled', true); $resolver = $this->serviceApplication->traefik_certificate_resolver; $this->certificateResolverMode = match (true) { blank($resolver) => 'server', $resolver === ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE => 'none', default => 'custom', }; $this->certificateResolverName = $this->certificateResolverMode === 'custom' ? $resolver : null; $this->maxRestartCount = $this->serviceApplication->max_restart_count ?? 0; } } private function validateCertificateResolver(): void { $this->validate([ 'certificateResolverMode' => $this->rules['certificateResolverMode'], 'certificateResolverName' => $this->rules['certificateResolverName'], ]); } public function saveMaxRestartCount(): void { $this->authorize('update', $this->serviceApplication); $validated = $this->validate([ 'maxRestartCount' => 'integer|min:0', ]); $this->serviceApplication->fill([ 'max_restart_count' => $validated['maxRestartCount'], 'restart_limit_reached' => false, ]); $changedFields = auditChangedFields($this->serviceApplication); $this->serviceApplication->save(); $this->auditServiceSubResourceUpdate($this->serviceApplication, $changedFields); $this->dispatch('success', 'Max restart count saved.'); } public function instantSaveApplication() { try { $this->authorize('update', $this->serviceApplication); $this->submitApplication(); } catch (\Throwable $e) { return handleError($e, $this); } } public function instantSaveApplicationSettings() { try { $this->authorize('update', $this->serviceApplication); $this->serviceApplication->is_gzip_enabled = $this->isGzipEnabled; $this->serviceApplication->is_stripprefix_enabled = $this->isStripprefixEnabled; $this->serviceApplication->exclude_from_status = $this->excludeFromStatus; $changedFields = auditChangedFields($this->serviceApplication); $this->serviceApplication->save(); $this->auditServiceSubResourceUpdate($this->serviceApplication, $changedFields); $this->dispatch('success', 'Settings saved.'); } catch (\Throwable $e) { return handleError($e, $this); } } public function instantSaveApplicationAdvanced() { try { $this->authorize('update', $this->serviceApplication); if (! $this->serviceApplication->service->destination->server->isLogDrainEnabled()) { $this->isLogDrainEnabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); return; } $this->validateCertificateResolver(); $this->syncApplicationData(true); $changedFields = auditChangedFields($this->serviceApplication); $this->serviceApplication->save(); $this->auditServiceSubResourceUpdate($this->serviceApplication, $changedFields); $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } catch (\Throwable $e) { return handleError($e, $this); } } public function deleteApplication($password, $selectedActions = []) { try { $this->authorize('delete', $this->serviceApplication); if (! verifyPasswordConfirmation($password, $this)) { return 'The provided password is incorrect.'; } $containerRemoved = app(DeleteService::class)->removeSubresourceContainer($this->serviceApplication); $this->serviceApplication->delete(); $this->auditServiceSubResource('deleted', $this->serviceApplication, ['container_removed' => $containerRemoved]); $containerRemoved ? $this->dispatch('success', 'Application deleted.') : $this->dispatch('warning', 'Application deleted from Coolify. The server does not respond, so its container is removed when the service starts again.'); return redirectRoute($this, 'project.service.configuration', $this->parameters); } catch (\Throwable $e) { return handleError($e, $this); } } public function convertToDatabase() { try { $this->authorize('update', $this->serviceApplication); $service = $this->serviceApplication->service; $serviceApplication = $this->serviceApplication; if ($service->databases()->where('name', $serviceApplication->name)->exists()) { throw new \Exception('A database with this name already exists.'); } $redirectParams = collect($this->parameters) ->except('database_uuid') ->all(); $serviceDatabase = DB::transaction(function () use ($service, $serviceApplication) { $serviceDatabase = $service->databases()->create([ 'name' => $serviceApplication->name, 'human_name' => $serviceApplication->human_name, 'description' => $serviceApplication->description, 'exclude_from_status' => $serviceApplication->exclude_from_status, 'is_log_drain_enabled' => $serviceApplication->is_log_drain_enabled, 'image' => $serviceApplication->image, 'service_id' => $service->id, 'is_migrated' => true, ]); $serviceApplication->delete(); return $serviceDatabase; }); $this->auditServiceSubResource('converted_to_database', $serviceApplication, [ 'service_database_uuid' => $serviceDatabase->uuid, ]); return redirectRoute($this, 'project.service.configuration', $redirectParams); } catch (\Throwable $e) { return handleError($e, $this); } } public function confirmDomainUsage() { $this->forceSaveDomains = true; $this->showDomainConflictModal = false; $this->submitApplication(); } public function confirmRemovePort() { $this->forceRemovePort = true; $this->showPortWarningModal = false; $this->submitApplication(); } public function cancelRemovePort() { $this->showPortWarningModal = false; $this->syncApplicationData(false); } public function submitApplication() { try { $persistedApplication = $this->serviceApplication->fresh(); $previousDnsHostnames = app(ManagedDnsRecordCleanup::class)->hostnamesOf($persistedApplication); $previousEditableUrls = $persistedApplication->url; $previousFqdn = $persistedApplication->fqdn; $previousPortOverrides = $persistedApplication->domain_port_overrides; $this->authorize('update', $this->serviceApplication); $this->validate([ 'fqdn' => ValidationPatterns::applicationDomainRules(), ]); $this->validateCertificateResolver(); $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn); $warning = sslipDomainWarning($this->fqdn); if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } $this->syncApplicationData(true); if (! $this->forceSaveDomains) { $result = checkDomainUsage(resource: $this->serviceApplication); if ($result['hasConflicts']) { $this->domainConflicts = $result['conflicts']; $this->showDomainConflictModal = true; return; } } else { $this->forceSaveDomains = false; } if (! $this->forceRemovePort) { $requiredPort = $this->serviceApplication->getRequiredPort(); if ($requiredPort !== null) { foreach (str($this->fqdn)->trim()->explode(',') as $fqdn) { $fqdn = trim((string) $fqdn); if ($fqdn === '') { continue; } if ($this->serviceApplication->portRequiresConfirmation($fqdn, $requiredPort, $previousEditableUrls)) { $this->requiredPort = $requiredPort; $this->showPortWarningModal = true; $this->serviceApplication->fqdn = $previousFqdn; $this->serviceApplication->domain_port_overrides = $previousPortOverrides; return; } } } } else { $this->forceRemovePort = false; } $this->validate(); $changedFields = auditChangedFields($this->serviceApplication); $this->serviceApplication->save(); $this->auditServiceSubResourceUpdate($this->serviceApplication, $changedFields); $this->serviceApplication->refresh(); app(ManagedDnsRecordCleanup::class)->queueReleaseOfRemovedHostnames($this->serviceApplication, $previousDnsHostnames, $this->service->team()->id); $this->syncApplicationData(false); updateCompose($this->serviceApplication); if (str($this->serviceApplication->fqdn)->contains(',')) { $this->dispatch('warning', 'Some services do not support multiple domains, which can lead to problems and is NOT RECOMMENDED.

Only use multiple domains if you know what you are doing.'); } else { ! $warning && $this->dispatch('success', 'Service saved.'); } $this->dispatch('generateDockerCompose'); } catch (\Throwable $e) { $originalFqdn = $this->serviceApplication->getOriginal('fqdn'); if ($originalFqdn !== $this->serviceApplication->fqdn) { $this->serviceApplication->fqdn = $originalFqdn; $this->syncApplicationData(false); } return handleError($e, $this); } } public function render() { $server = $this->resourceType === 'application' ? $this->service?->destination?->server : null; return view('livewire.project.service.index', [ 'showCertificateResolver' => $server?->proxyType() === ProxyTypes::TRAEFIK->value, 'serverCertificateResolver' => $server?->traefikCertificateResolver() ?? ServerSetting::TRAEFIK_CERTIFICATE_RESOLVER_NONE, ]); } /** * Records `ui.service_{application|database}.updated` with the attributes that the save changed. * * @param array $changedFields Fields changed by the save, captured before it. * @param array $extra */ private function auditServiceSubResourceUpdate(ServiceApplication|ServiceDatabase $resource, array $changedFields, array $extra = []): void { if ($changedFields === []) { return; } $this->auditServiceSubResource('updated', $resource, ['changed_fields' => $changedFields, ...$extra]); } /** * Mirrors the context of `api.service_database.updated` for service applications and databases. * * @param array $extra */ private function auditServiceSubResource(string $action, ServiceApplication|ServiceDatabase $resource, array $extra = []): void { $resourceKey = $resource instanceof ServiceApplication ? 'service_application' : 'service_database'; $service = $resource->service ?? $this->service; auditLog("ui.{$resourceKey}.{$action}", [ 'team_id' => $service?->team()?->id, 'service_uuid' => $service?->uuid, 'service_name' => $service?->name, "{$resourceKey}_uuid" => $resource->uuid, "{$resourceKey}_name" => $resource->name, ...$extra, ]); } }