diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php index fa39f790932..cf447392f00 100644 --- a/app/Actions/Database/StartDatabaseProxy.php +++ b/app/Actions/Database/StartDatabaseProxy.php @@ -11,6 +11,7 @@ use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use App\Notifications\Container\ContainerRestarted; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -29,11 +30,15 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St $proxyContainerName = "{$database->uuid}-proxy"; $isSSLEnabled = $database->enable_ssl ?? false; - if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($database->getMorphClass() === ServiceDatabase::class) { $databaseType = $database->databaseType(); - $network = $database->service->uuid; - $server = data_get($database, 'service.destination.server'); - $containerName = "{$database->name}-{$database->service->uuid}"; + $network = $database->parentNetworkName(); + $server = $database->parentServer(); + $containerName = $database->currentContainerName(); + } + + if (! $network || ! $server || ! $containerName) { + throw new \Exception('Unable to resolve database network/container/server for proxy startup.'); } $internalPort = match ($databaseType) { 'standalone-mariadb', 'standalone-mysql' => 3306, @@ -129,10 +134,11 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St $database->update(['is_public' => false]); $team = data_get($database, 'environment.project.team') - ?? data_get($database, 'service.environment.project.team'); + ?? data_get($database, 'service.environment.project.team') + ?? $database->team(); $team?->notify( - new \App\Notifications\Container\ContainerRestarted( + new ContainerRestarted( "TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}", $server, ) diff --git a/app/Actions/Database/StopDatabaseProxy.php b/app/Actions/Database/StopDatabaseProxy.php index 96a10976621..77244b05355 100644 --- a/app/Actions/Database/StopDatabaseProxy.php +++ b/app/Actions/Database/StopDatabaseProxy.php @@ -24,8 +24,8 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St { $server = data_get($database, 'destination.server'); $uuid = $database->uuid; - if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { - $server = data_get($database, 'service.server'); + if ($database->getMorphClass() === ServiceDatabase::class) { + $server = $database->parentServer(); } instant_remote_process(["docker rm -f {$uuid}-proxy"], $server); diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index a2d08e1e852..cb765c657fd 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -93,7 +93,7 @@ public function handle(): void } if (data_get($this->backup, 'database_type') === ServiceDatabase::class) { $this->database = data_get($this->backup, 'database'); - $this->server = $this->database->service->server; + $this->server = $this->database->parentServer(); $this->s3 = $this->backup->s3; } else { $this->database = data_get($this->backup, 'database'); @@ -110,7 +110,8 @@ public function handle(): void BackupCreated::dispatch($this->team->id); $status = str(data_get($this->database, 'status')); - if (! $status->startsWith('running') && $this->database->id !== 0) { + $isApplicationBackedServiceDatabase = $this->database instanceof ServiceDatabase && filled($this->database->application_id); + if (! $isApplicationBackedServiceDatabase && ! $status->startsWith('running') && $this->database->id !== 0) { Log::info('DatabaseBackupJob skipped: database not running', [ 'backup_id' => $this->backup->id, 'database_id' => $this->database->id, @@ -121,11 +122,12 @@ public function handle(): void } if (data_get($this->backup, 'database_type') === ServiceDatabase::class) { $databaseType = $this->database->databaseType(); - $serviceUuid = $this->database->service->uuid; - $serviceName = str($this->database->service->name)->slug(); + $this->container_name = $this->database->currentContainerName(); + $this->directory_name = $this->database->backupDirectoryName(); + if (blank($this->container_name) || blank($this->directory_name)) { + throw new \Exception('Unable to resolve the running container for compose database backup.'); + } if (str($databaseType)->contains('postgres')) { - $this->container_name = "{$this->database->name}-$serviceUuid"; - $this->directory_name = $serviceName.'-'.$this->container_name; $commands[] = "docker exec $this->container_name env | grep POSTGRES_"; $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); @@ -155,8 +157,6 @@ public function handle(): void $this->postgres_password = str($this->postgres_password)->after('POSTGRES_PASSWORD=')->value(); } } elseif (str($databaseType)->contains('mysql')) { - $this->container_name = "{$this->database->name}-$serviceUuid"; - $this->directory_name = $serviceName.'-'.$this->container_name; $commands[] = "docker exec $this->container_name env | grep MYSQL_"; $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); @@ -178,8 +178,6 @@ public function handle(): void throw new \Exception('MYSQL_DATABASE not found'); } } elseif (str($databaseType)->contains('mariadb')) { - $this->container_name = "{$this->database->name}-$serviceUuid"; - $this->directory_name = $serviceName.'-'.$this->container_name; $commands[] = "docker exec $this->container_name env"; $envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); $envs = str($envs)->explode("\n"); @@ -216,8 +214,6 @@ public function handle(): void } } elseif (str($databaseType)->contains('mongo')) { $databasesToBackup = ['*']; - $this->container_name = "{$this->database->name}-$serviceUuid"; - $this->directory_name = $serviceName.'-'.$this->container_name; // Try to extract MongoDB credentials from environment variables try { @@ -674,7 +670,7 @@ private function upload_to_s3(): void $endpoint = $this->s3->endpoint; $this->s3->testConnection(shouldSave: true); if (data_get($this->backup, 'database_type') === ServiceDatabase::class) { - $network = $this->database->service->destination->network; + $network = $this->database->parentNetworkName(); } else { $network = $this->database->destination->network; } diff --git a/app/Livewire/Project/Application/ComposeDatabaseBackups.php b/app/Livewire/Project/Application/ComposeDatabaseBackups.php new file mode 100644 index 00000000000..21e65a2a20c --- /dev/null +++ b/app/Livewire/Project/Application/ComposeDatabaseBackups.php @@ -0,0 +1,61 @@ +parameters = get_route_parameters(); + $this->application = Application::whereUuid($this->parameters['application_uuid'])->first(); + if (! $this->application) { + return redirect()->route('dashboard'); + } + $this->authorize('view', $this->application); + + $this->serviceDatabase = $this->application->databases()->whereUuid($this->parameters['stack_service_uuid'])->first(); + if (! $this->serviceDatabase) { + return redirect()->route('project.application.configuration', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'application_uuid' => $this->parameters['application_uuid'], + ]); + } + + if (! $this->serviceDatabase->isBackupSolutionAvailable() && ! $this->serviceDatabase->is_migrated) { + return redirect()->route('project.application.configuration', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'application_uuid' => $this->parameters['application_uuid'], + ]); + } + + $dbType = $this->serviceDatabase->databaseType(); + $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; + $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.project.application.compose-database-backups'); + } +} diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index a18022882cc..251266b91fe 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use App\Models\ServiceDatabase; use Exception; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; @@ -144,8 +145,8 @@ public function delete($password, $selectedActions = []) try { $server = null; - if ($this->backup->database instanceof \App\Models\ServiceDatabase) { - $server = $this->backup->database->service->destination->server; + if ($this->backup->database instanceof ServiceDatabase) { + $server = $this->backup->database->parentServer(); } elseif ($this->backup->database->destination && $this->backup->database->destination->server) { $server = $this->backup->database->destination->server; } @@ -170,9 +171,18 @@ public function delete($password, $selectedActions = []) $this->backup->delete(); - if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) { + if ($this->backup->database->getMorphClass() === ServiceDatabase::class) { $serviceDatabase = $this->backup->database; + if ($serviceDatabase->application) { + return redirect()->route('project.application.compose-database.backups', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'application_uuid' => $serviceDatabase->application->uuid, + 'stack_service_uuid' => $serviceDatabase->uuid, + ]); + } + return redirect()->route('project.service.database.backups', [ 'project_uuid' => $this->parameters['project_uuid'], 'environment_uuid' => $this->parameters['environment_uuid'], @@ -182,7 +192,7 @@ public function delete($password, $selectedActions = []) } else { return redirect()->route('project.database.backup.index', $this->parameters); } - } catch (\Exception $e) { + } catch (Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); return handleError($e, $this); @@ -214,7 +224,7 @@ private function customValidate() $isValid = validate_cron_expression($this->backup->frequency); if (! $isValid) { - throw new \Exception('Invalid Cron / Human expression'); + throw new Exception('Invalid Cron / Human expression'); } $this->validate(); } diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 1dd93781dda..f67e102f66c 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use App\Models\ServiceDatabase; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Livewire\Component; @@ -78,8 +79,8 @@ public function deleteBackup($executionId, $password, $selectedActions = []) return; } - $server = $execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class - ? $execution->scheduledDatabaseBackup->database->service->destination->server + $server = $execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class + ? $execution->scheduledDatabaseBackup->database->parentServer() : $execution->scheduledDatabaseBackup->database->destination->server; try { @@ -185,8 +186,8 @@ public function server() if ($this->database) { $server = null; - if ($this->database instanceof \App\Models\ServiceDatabase) { - $server = $this->database->service->destination->server; + if ($this->database instanceof ServiceDatabase) { + $server = $this->database->parentServer(); } elseif ($this->database->destination && $this->database->destination->server) { $server = $this->database->destination->server; } diff --git a/app/Models/Application.php b/app/Models/Application.php index fef6f6e4c32..a93375ee80a 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -595,6 +595,11 @@ public function fileStorages() return $this->morphMany(LocalFileVolume::class, 'resource'); } + public function databases() + { + return $this->hasMany(ServiceDatabase::class); + } + public function type() { return 'application'; diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 6308bae8b7c..7c8d76afd3e 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -87,8 +87,7 @@ public function server() { if ($this->database) { if ($this->database instanceof ServiceDatabase) { - $destination = data_get($this->database->service, 'destination'); - $server = data_get($destination, 'server'); + $server = $this->database->parentServer(); } else { $destination = data_get($this->database, 'destination'); $server = data_get($destination, 'server'); diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 69801f9857d..c93b31ad069 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Str; class ServiceDatabase extends BaseModel { @@ -11,6 +12,7 @@ class ServiceDatabase extends BaseModel protected $fillable = [ 'service_id', + 'application_id', 'name', 'human_name', 'description', @@ -52,7 +54,10 @@ protected static function booted() public static function ownedByCurrentTeamAPI(int $teamId) { - return ServiceDatabase::whereRelation('service.environment.project.team', 'id', $teamId)->orderBy('name'); + return ServiceDatabase::where(function ($query) use ($teamId) { + $query->whereRelation('service.environment.project.team', 'id', $teamId) + ->orWhereRelation('application.environment.project.team', 'id', $teamId); + })->orderBy('name'); } /** @@ -61,7 +66,10 @@ public static function ownedByCurrentTeamAPI(int $teamId) */ public static function ownedByCurrentTeam() { - return ServiceDatabase::whereRelation('service.environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + return ServiceDatabase::where(function ($query) { + $query->whereRelation('service.environment.project.team', 'id', currentTeam()->id) + ->orWhereRelation('application.environment.project.team', 'id', currentTeam()->id); + })->orderBy('name'); } /** @@ -139,8 +147,12 @@ public function databaseType() public function getServiceDatabaseUrl() { $port = $this->public_port; - $realIp = $this->service->server->ip; - if ($this->service->server->isLocalhost() || isDev()) { + $server = $this->parentServer(); + if (! $server) { + return null; + } + $realIp = $server->ip; + if ($server->isLocalhost() || isDev()) { $realIp = base_ip(); } @@ -149,12 +161,20 @@ public function getServiceDatabaseUrl() public function team() { - return data_get($this, 'service.environment.project.team'); + return data_get($this, 'service.environment.project.team') + ?? data_get($this, 'application.environment.project.team'); } public function workdir() { - return service_configuration_dir()."/{$this->service->uuid}"; + if ($this->service) { + return service_configuration_dir()."/{$this->service->uuid}"; + } + if ($this->application) { + return base_configuration_dir()."/applications/{$this->application->uuid}"; + } + + return service_configuration_dir(); } public function service() @@ -162,6 +182,11 @@ public function service() return $this->belongsTo(Service::class); } + public function application() + { + return $this->belongsTo(Application::class); + } + public function persistentStorages() { return $this->morphMany(LocalPersistentVolume::class, 'resource'); @@ -191,4 +216,88 @@ public function isBackupSolutionAvailable() str($this->databaseType())->contains('mongo') || filled($this->custom_type); } + + public function parentServer() + { + return data_get($this, 'service.destination.server') + ?? data_get($this, 'application.destination.server'); + } + + public function parentDestination() + { + return data_get($this, 'service.destination') + ?? data_get($this, 'application.destination'); + } + + public function parentEnvironment() + { + return data_get($this, 'service.environment') + ?? data_get($this, 'application.environment'); + } + + public function parentProject() + { + return data_get($this, 'service.environment.project') + ?? data_get($this, 'application.environment.project'); + } + + public function parentNetworkName(int $pullRequestId = 0): ?string + { + if ($this->service) { + return $this->service->uuid; + } + if ($this->application) { + return $pullRequestId !== 0 + ? "{$this->application->uuid}-{$pullRequestId}" + : $this->application->uuid; + } + + return null; + } + + public function currentContainerName(int $pullRequestId = 0): ?string + { + if ($this->service) { + return "{$this->name}-{$this->service->uuid}"; + } + + if (! $this->application) { + return null; + } + + $server = $this->parentServer(); + if (! $server) { + return null; + } + + $serviceSlug = Str::slug($this->name); + $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pullRequestId, false) + ->filter(function ($container) use ($serviceSlug) { + $labels = data_get($container, 'Labels', ''); + + return str($labels)->contains("coolify.serviceName={$serviceSlug}"); + }); + + $runningContainer = $containers->first(function ($container) { + $status = (string) (data_get($container, 'State') ?: data_get($container, 'Status', '')); + + return str($status)->lower()->contains('running') || str($status)->contains('Up'); + }); + + return data_get($runningContainer ?: $containers->first(), 'Names'); + } + + public function backupDirectoryName(int $pullRequestId = 0): ?string + { + $containerName = $this->currentContainerName($pullRequestId); + if (! $containerName) { + return null; + } + + $parentName = $this->service + ? str($this->service->name)->slug()->value() + : ($this->application ? str($this->application->name)->slug()->value() : str($this->name)->slug()->value()); + + return $parentName.'-'.$containerName; + } } diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 5df36db3369..20c1624df65 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -3,6 +3,7 @@ use App\Models\EnvironmentVariable; use App\Models\S3Storage; use App\Models\Server; +use App\Models\ServiceDatabase; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDocker; use App\Models\StandaloneDragonfly; @@ -14,6 +15,7 @@ use App\Models\StandaloneRedis; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql @@ -23,7 +25,7 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $ $database->uuid = (new Cuid2); $database->name = 'postgresql-database-'.$database->uuid; $database->image = $databaseImage; - $database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->postgres_password = Str::password(length: 64, symbols: false); $database->environment_id = $environmentId; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -42,7 +44,7 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth $database->uuid = (new Cuid2); $database->name = 'redis-database-'.$database->uuid; - $redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $redis_password = Str::password(length: 64, symbols: false); if ($otherData && isset($otherData['redis_password'])) { $redis_password = $otherData['redis_password']; unset($otherData['redis_password']); @@ -81,7 +83,7 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o $database = new StandaloneMongodb; $database->uuid = (new Cuid2); $database->name = 'mongodb-database-'.$database->uuid; - $database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mongo_initdb_root_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -99,8 +101,8 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth $database = new StandaloneMysql; $database->uuid = (new Cuid2); $database->name = 'mysql-database-'.$database->uuid; - $database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); - $database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mysql_root_password = Str::password(length: 64, symbols: false); + $database->mysql_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -118,8 +120,8 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o $database = new StandaloneMariadb; $database->uuid = (new Cuid2); $database->name = 'mariadb-database-'.$database->uuid; - $database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false); - $database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->mariadb_root_password = Str::password(length: 64, symbols: false); + $database->mariadb_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -137,7 +139,7 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth $database = new StandaloneKeydb; $database->uuid = (new Cuid2); $database->name = 'keydb-database-'.$database->uuid; - $database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->keydb_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -155,7 +157,7 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $database = new StandaloneDragonfly; $database->uuid = (new Cuid2); $database->name = 'dragonfly-database-'.$database->uuid; - $database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->dragonfly_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -173,7 +175,7 @@ function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $database = new StandaloneClickhouse; $database->uuid = (new Cuid2); $database->name = 'clickhouse-database-'.$database->uuid; - $database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false); + $database->clickhouse_admin_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; $database->destination_id = $destination->id; $database->destination_type = $destination->getMorphClass(); @@ -279,7 +281,7 @@ function removeOldBackups($backup): void ->whereNull('s3_uploaded') ->delete(); - } catch (\Exception $e) { + } catch (Exception $e) { throw $e; } } @@ -345,8 +347,8 @@ function deleteOldBackupsLocally($backup): Collection $processedBackups = collect(); $server = null; - if ($backup->database_type === \App\Models\ServiceDatabase::class) { - $server = $backup->database->service->server; + if ($backup->database_type === ServiceDatabase::class) { + $server = $backup->database->parentServer(); } else { $server = $backup->database->destination->server; } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 123cf906a70..93cdfa293d2 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -681,6 +681,18 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $coolifyEnvironments = collect([]); $isDatabase = isDatabaseImage($image, $service); + if ($isDatabase && $pullRequestId === 0) { + ServiceDatabase::updateOrCreate( + [ + 'name' => $serviceName, + 'application_id' => $resource->id, + ], + [ + 'image' => $image, + 'service_id' => null, + ] + ); + } $volumesParsed = collect([]); $baseName = generateApplicationContainerName( diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index cd773f6a9f1..e863b476654 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -2916,6 +2916,18 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal // Decide if the service is a database $image = data_get_str($service, 'image'); $isDatabase = isDatabaseImage($image, $service); + if ($isDatabase && $pull_request_id === 0) { + ServiceDatabase::updateOrCreate( + [ + 'name' => $serviceName, + 'application_id' => $resource->id, + ], + [ + 'image' => $image, + 'service_id' => null, + ] + ); + } data_set($service, 'is_database', $isDatabase); // Collect/create/update networks diff --git a/database/migrations/2026_03_31_170000_add_application_id_to_service_databases_table.php b/database/migrations/2026_03_31_170000_add_application_id_to_service_databases_table.php new file mode 100644 index 00000000000..d4b7710c7cf --- /dev/null +++ b/database/migrations/2026_03_31_170000_add_application_id_to_service_databases_table.php @@ -0,0 +1,34 @@ +foreignId('application_id')->nullable()->after('service_id'); + $table->foreign('application_id')->references('id')->on('applications')->nullOnDelete(); + $table->foreignId('service_id')->nullable()->change(); + $table->index(['application_id', 'name']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('service_databases', function (Blueprint $table) { + $table->dropIndex('service_databases_application_id_name_index'); + $table->dropForeign(['application_id']); + $table->dropColumn('application_id'); + $table->foreignId('service_id')->nullable(false)->change(); + }); + } +}; diff --git a/resources/views/livewire/project/application/compose-database-backups.blade.php b/resources/views/livewire/project/application/compose-database-backups.blade.php new file mode 100644 index 00000000000..94608df40e8 --- /dev/null +++ b/resources/views/livewire/project/application/compose-database-backups.blade.php @@ -0,0 +1,25 @@ +
+ + {{ data_get_str($application, 'name')->limit(10) }} > + {{ data_get_str($serviceDatabase, 'name')->limit(10) }} > Backups | Coolify + +

Backups

+ + +
+
+

Scheduled Backups

+ @if (filled($serviceDatabase->custom_type) || !$serviceDatabase->is_migrated) + @can('update', $serviceDatabase) + + + + @endcan + @endif +
+

+ Manage backups for the {{ $serviceDatabase->name }} database service inside this Docker Compose application. +

+ +
+
diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index d743e346e03..34fecc9724f 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -69,6 +69,30 @@ Domain @endcan + @else + @php + $serviceDatabase = $application->databases()->where('name', $serviceName)->first(); + @endphp + @if ($serviceDatabase && $serviceDatabase->isBackupSolutionAvailable()) +
+
+
{{ $serviceName }}
+
+ This compose-managed database supports scheduled backups. +
+
+ + Manage Backups + +
+ @endif @endif @endforeach @endif diff --git a/routes/web.php b/routes/web.php index 6d70b422399..66a5ebcc4db 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,7 @@ use App\Livewire\Notifications\Telegram as NotificationTelegram; use App\Livewire\Notifications\Webhook as NotificationWebhook; use App\Livewire\Profile\Index as ProfileIndex; +use App\Livewire\Project\Application\ComposeDatabaseBackups as ApplicationComposeDatabaseBackups; use App\Livewire\Project\Application\Configuration as ApplicationConfiguration; use App\Livewire\Project\Application\Deployment\Index as DeploymentIndex; use App\Livewire\Project\Application\Deployment\Show as DeploymentShow; @@ -33,7 +34,6 @@ use App\Livewire\Project\Service\Index as ServiceIndex; use App\Livewire\Project\Shared\ExecuteContainerCommand; use App\Livewire\Project\Shared\Logs; -use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow; use App\Livewire\Project\Show as ProjectShow; use App\Livewire\Security\ApiTokens; use App\Livewire\Security\CloudInitScripts; @@ -232,6 +232,7 @@ Route::get('/deployment', DeploymentIndex::class)->name('project.application.deployment.index'); Route::get('/deployment/{deployment_uuid}', DeploymentShow::class)->name('project.application.deployment.show'); + Route::get('/compose-database/{stack_service_uuid}/backups', ApplicationComposeDatabaseBackups::class)->name('project.application.compose-database.backups'); Route::get('/logs', Logs::class)->name('project.application.logs'); Route::get('/terminal', ExecuteContainerCommand::class)->name('project.application.command')->middleware('can.access.terminal'); Route::get('/tasks/{task_uuid}', ApplicationConfiguration::class)->name('project.application.scheduled-tasks'); @@ -349,7 +350,7 @@ } $filename = data_get($execution, 'filename'); if ($execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class) { - $server = $execution->scheduledDatabaseBackup->database->service->destination->server; + $server = $execution->scheduledDatabaseBackup->database->parentServer(); } else { $server = $execution->scheduledDatabaseBackup->database->destination->server; } diff --git a/tests/Feature/ApplicationComposeDatabaseBackupParsingTest.php b/tests/Feature/ApplicationComposeDatabaseBackupParsingTest.php new file mode 100644 index 00000000000..45589e186d1 --- /dev/null +++ b/tests/Feature/ApplicationComposeDatabaseBackupParsingTest.php @@ -0,0 +1,82 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = $this->server->standaloneDockers()->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => <<<'YAML' +services: + web: + image: ghcr.io/acme/example-web:latest + environment: + APP_ENV: production + postgres: + image: postgres:16 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: secret + POSTGRES_DB: app +YAML, + 'compose_parsing_version' => '5', + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +test('application docker compose parsing persists service databases for compose-backed apps', function () { + $parsed = $this->application->parse(); + + expect($parsed)->not->toBeNull(); + + $database = ServiceDatabase::query() + ->where('application_id', $this->application->id) + ->where('name', 'postgres') + ->first(); + + expect($database)->not->toBeNull() + ->and($database->service_id)->toBeNull() + ->and($database->image)->toBe('postgres:16'); +}); + +test('scheduled backup resolves server for application-backed compose databases', function () { + $database = ServiceDatabase::create([ + 'name' => 'postgres', + 'image' => 'postgres:16', + 'application_id' => $this->application->id, + 'service_id' => null, + ]); + + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => '0 2 * * *', + 'database_type' => ServiceDatabase::class, + 'database_id' => $database->id, + ]); + + expect($backup->server())->not->toBeNull() + ->and($backup->server()->id)->toBe($this->server->id); +});