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