From 2e0d4328867e312e70e3c204fe112b640b60838e Mon Sep 17 00:00:00 2001 From: DanielHemmati Date: Wed, 23 Apr 2025 15:56:34 +0200 Subject: [PATCH 001/407] add backup config info to --- app/Http/Controllers/Api/DatabasesController.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 504665f6a..452e24837 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; use App\Models\Project; +use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -78,7 +79,17 @@ public function databases(Request $request) foreach ($projects as $project) { $databases = $databases->merge($project->databases()); } - $databases = $databases->map(function ($database) { + + $backupConfig = ScheduledDatabaseBackup::with('latest_log')->get(); + $databases = $databases->map(function ($database) use ($backupConfig) { + $databaseBackupConfig = $backupConfig->where('database_id', $database->id)->first(); + + if ($databaseBackupConfig) { + $database->backup_configs = $databaseBackupConfig; + } else { + $database->backup_configs = null; + } + return $this->removeSensitiveData($database); }); From da487f609acfd8966ff8393e3c77dba64f358858 Mon Sep 17 00:00:00 2001 From: DanielHemmati Date: Wed, 23 Apr 2025 20:59:20 +0200 Subject: [PATCH 002/407] implmenet `Get /database/:uuid/backups` api --- .../Controllers/Api/DatabasesController.php | 63 +++++++++++++++++++ routes/api.php | 5 ++ 2 files changed, 68 insertions(+) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 452e24837..de8daa43e 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -96,6 +96,69 @@ public function databases(Request $request) return response()->json($databases); } + #[OA\Get( + summary: 'Get', + description: 'Get database by UUID.', + path: '/databases/{uuid}/backups', + operationId: 'get-database-backups-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get all backups for a database', + content: new OA\JsonContent( + type: 'string', + example: 'Content is very complex. Will be implemented later.', + ), + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function database_backup_details_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + if (! $request->uuid) { + return response()->json(['message' => 'UUID is required.'], 404); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $backupConfig = ScheduledDatabaseBackup::with('executions')->where('database_id', $database->id)->first(); + + return response()->json($this->removeSensitiveData($backupConfig)); + } + #[OA\Get( summary: 'Get', description: 'Get database by UUID.', diff --git a/routes/api.php b/routes/api.php index 8ac8aef14..409dd393f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -23,6 +23,10 @@ }); Route::post('/feedback', [OtherController::class, 'feedback']); +Route::get('/test', function () { + return response()->json(['message' => 'test']); +}); + Route::group([ 'middleware' => ['auth:sanctum', 'api.ability:write'], 'prefix' => 'v1', @@ -110,6 +114,7 @@ Route::post('/databases/keydb', [DatabasesController::class, 'create_database_keydb'])->middleware(['api.ability:write']); Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']); + Route::get('/databases/{uuid}/backups', [DatabasesController::class, 'database_backup_details_uuid'])->middleware(['api.ability:read']); Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); From 5dff22d3455146c7a46901da823d6c8a8c3c8d06 Mon Sep 17 00:00:00 2001 From: DanielHemmati Date: Thu, 24 Apr 2025 16:48:08 +0200 Subject: [PATCH 003/407] implement backup config via api --- .../Controllers/Api/DatabasesController.php | 61 ++++++++++++++++++- routes/api.php | 3 - 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index de8daa43e..ab0191581 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -288,6 +288,19 @@ public function database_by_uuid(Request $request) 'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'], 'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'], 'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'], + // WIP + 'save_s3' => ['type' => 'boolean', 'description' => 'Weather data is saved in s3 or not'], + 's3_storage_id' => ['type' => 'integer', 'description' => 'S3 storage id'], + 'enabled' => ['type' => 'boolean', 'description' => 'Weather the backup is enabled or not'], + 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], + 'dump_all' => ['type' => 'boolean', 'description' => 'Weather all databases are dumped or not'], + 'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'], + 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'], + 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'], + 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], + 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], + 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], + 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], ], ), ) @@ -313,12 +326,14 @@ public function database_by_uuid(Request $request) )] public function update_by_uuid(Request $request) { + $allowedBackupConfigsFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_id']; $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } + // this check if the request is a valid json $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -336,6 +351,18 @@ public function update_by_uuid(Request $request) 'limits_cpus' => 'string', 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', + 'save_s3' => 'boolean', + 'enabled' => 'boolean', + 'dump_all' => 'boolean', + 's3_storage_id' => 'integer|min:1|exists:s3_storages,id|nullable', + 'databases_to_backup' => 'string', + 'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly', + 'database_backup_retention_amount_locally' => 'integer|min:0', + 'database_backup_retention_days_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_amount_s3' => 'integer|min:0', + 'database_backup_retention_days_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'integer|min:0', ]); if ($validator->fails()) { @@ -347,6 +374,7 @@ public function update_by_uuid(Request $request) $uuid = $request->uuid; removeUnnecessaryFieldsFromRequest($request); $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + $backupConfig = ScheduledDatabaseBackup::where('database_id', $database->id)->first(); if (! $database) { return response()->json(['message' => 'Database not found.'], 404); } @@ -545,7 +573,7 @@ public function update_by_uuid(Request $request) } break; } - $extraFields = array_diff(array_keys($request->all()), $allowedFields); + $extraFields = array_diff(array_keys($request->all()), $allowedFields, $allowedBackupConfigsFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); if (! empty($extraFields)) { @@ -567,7 +595,36 @@ public function update_by_uuid(Request $request) $whatToDoWithDatabaseProxy = 'start'; } - $database->update($request->all()); + $backupPayload = $request->only($allowedBackupConfigsFields); + $databasePayload = $request->only($allowedFields); + + if ($databasePayload) { + $database->update($databasePayload); + } + + if ($backupPayload && ! $backupConfig) { + if ($database->type() === 'standalone-postgresql') { + $backupPayload['databases_to_backup'] = $database->postgres_db; + } elseif ($database->type() === 'standalone-mysql') { + $backupPayload['databases_to_backup'] = $database->mysql_database; + } elseif ($database->type() === 'standalone-mariadb') { + $backupPayload['databases_to_backup'] = $database->mariadb_database; + } elseif ($database->type() === 'standalone-mongodbs') { + $backupPayload['databases_to_backup'] = $database->mongo_initdb_database; + } + + $backupConfig = ScheduledDatabaseBackup::create([ + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'team_id' => $teamId, + 's3_storage_id' => $backupPayload['s3_storage_id'] ?? 1, + ...$backupPayload, + ]); + } + + if ($backupPayload && $backupConfig) { + $backupConfig->update($backupPayload); + } if ($whatToDoWithDatabaseProxy === 'start') { StartDatabaseProxy::dispatch($database); diff --git a/routes/api.php b/routes/api.php index 409dd393f..326399f30 100644 --- a/routes/api.php +++ b/routes/api.php @@ -23,9 +23,6 @@ }); Route::post('/feedback', [OtherController::class, 'feedback']); -Route::get('/test', function () { - return response()->json(['message' => 'test']); -}); Route::group([ 'middleware' => ['auth:sanctum', 'api.ability:write'], From 2a06a392d5174f278f20cf9533644d1e7fd2c747 Mon Sep 17 00:00:00 2001 From: DanielHemmati Date: Fri, 25 Apr 2025 11:46:02 +0200 Subject: [PATCH 004/407] Implement backup delete --- .../Controllers/Api/DatabasesController.php | 95 +++++++++++++++++++ routes/api.php | 1 + 2 files changed, 96 insertions(+) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index ab0191581..a25b07bf2 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1750,6 +1750,101 @@ public function delete_by_uuid(Request $request) ]); } + #[OA\Delete( + summary: 'Delete backup', + description: 'Deletes a backup by its database UUID and backup ID.', + path: '/databases/{uuid}/backups/{backup_id}', + operationId: 'delete-backup-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['backups'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + description: 'UUID of the database to delete', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'backup_id', + in: 'path', + required: true, + description: 'ID of the backup to delete', + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'delete_s3', + in: 'query', + required: false, + description: 'Whether to delete the backup from S3', + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Backup deleted.', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'message' => new OA\Schema(type: 'string', example: 'Backup deleted.'), + ] + ) + ), + new OA\Response( + response: 404, + description: 'Backup not found.', + content: new OA\JsonContent( + type: 'object', + properties: [ + 'message' => new OA\Schema(type: 'string', example: 'Backup not found.'), + ] + ) + ), + ] + )] + public function delete_backup_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + $backup = ScheduledDatabaseBackup::where('database_id', $database->id)->first(); + if (! $backup) { + return response()->json(['message' => 'Backup not found.'], 404); + } + $execution = $backup->executions()->where('id', $request->backup_id)->first(); + if (! $execution) { + return response()->json(['message' => 'Execution not found.'], 404); + } + + $deleteS3 = filter_var($request->query->get('delete_s3', false), FILTER_VALIDATE_BOOLEAN); + + try { + if ($execution->filename) { + deleteBackupsLocally($execution->filename, $database->destination->server); + + if ($deleteS3 && $backup->s3) { + deleteBackupsS3($execution->filename, $backup->s3); + } + } + + $execution->delete(); + + return response()->json([ + 'message' => 'Backup deleted.', + ]); + } catch (\Exception $e) { + return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500); + } + } + #[OA\Get( summary: 'Start', description: 'Start database. `Post` request is also accepted.', diff --git a/routes/api.php b/routes/api.php index 326399f30..1a1990513 100644 --- a/routes/api.php +++ b/routes/api.php @@ -114,6 +114,7 @@ Route::get('/databases/{uuid}/backups', [DatabasesController::class, 'database_backup_details_uuid'])->middleware(['api.ability:read']); Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::delete('/databases/{uuid}/backups/{backup_id}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']); From 81180af27d4f5870bd7e4253c7fd3804eeac2afb Mon Sep 17 00:00:00 2001 From: DanielHemmati Date: Fri, 25 Apr 2025 15:49:14 +0200 Subject: [PATCH 005/407] add ability to get backup now and get all schedule backup --- .../Controllers/Api/DatabasesController.php | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index a25b07bf2..9d007939d 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -9,6 +9,7 @@ use App\Actions\Database\StopDatabaseProxy; use App\Enums\NewDatabaseTypes; use App\Http\Controllers\Controller; +use App\Jobs\DatabaseBackupJob; use App\Jobs\DeleteResourceJob; use App\Models\Project; use App\Models\ScheduledDatabaseBackup; @@ -80,12 +81,11 @@ public function databases(Request $request) $databases = $databases->merge($project->databases()); } - $backupConfig = ScheduledDatabaseBackup::with('latest_log')->get(); - $databases = $databases->map(function ($database) use ($backupConfig) { - $databaseBackupConfig = $backupConfig->where('database_id', $database->id)->first(); + $databases = $databases->map(function ($database) { + $backupConfig = ScheduledDatabaseBackup::with('latest_log')->where('database_id', $database->id)->get(); - if ($databaseBackupConfig) { - $database->backup_configs = $databaseBackupConfig; + if ($backupConfig) { + $database->backup_configs = $backupConfig; } else { $database->backup_configs = null; } @@ -98,7 +98,7 @@ public function databases(Request $request) #[OA\Get( summary: 'Get', - description: 'Get database by UUID.', + description: 'Get backups details by database UUID.', path: '/databases/{uuid}/backups', operationId: 'get-database-backups-by-uuid', security: [ @@ -291,6 +291,7 @@ public function database_by_uuid(Request $request) // WIP 'save_s3' => ['type' => 'boolean', 'description' => 'Weather data is saved in s3 or not'], 's3_storage_id' => ['type' => 'integer', 'description' => 'S3 storage id'], + 'backup_now' => ['type' => 'boolean', 'description' => 'Weather to take a backup now or not'], 'enabled' => ['type' => 'boolean', 'description' => 'Weather the backup is enabled or not'], 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], 'dump_all' => ['type' => 'boolean', 'description' => 'Weather all databases are dumped or not'], @@ -326,7 +327,7 @@ public function database_by_uuid(Request $request) )] public function update_by_uuid(Request $request) { - $allowedBackupConfigsFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_id']; + $allowedBackupConfigsFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_id']; $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -352,6 +353,7 @@ public function update_by_uuid(Request $request) 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', 'save_s3' => 'boolean', + 'backup_now' => 'boolean|nullable', 'enabled' => 'boolean', 'dump_all' => 'boolean', 's3_storage_id' => 'integer|min:1|exists:s3_storages,id|nullable', @@ -573,7 +575,7 @@ public function update_by_uuid(Request $request) } break; } - $extraFields = array_diff(array_keys($request->all()), $allowedFields, $allowedBackupConfigsFields); + $extraFields = array_diff(array_keys($request->all()), $allowedFields, $allowedBackupConfigsFields, ['backup_now']); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); if (! empty($extraFields)) { @@ -620,10 +622,18 @@ public function update_by_uuid(Request $request) 's3_storage_id' => $backupPayload['s3_storage_id'] ?? 1, ...$backupPayload, ]); + + if ($request->backup_now) { + DatabaseBackupJob::dispatch($backupConfig); + } } if ($backupPayload && $backupConfig) { $backupConfig->update($backupPayload); + + if ($request->backup_now) { + DatabaseBackupJob::dispatch($backupConfig); + } } if ($whatToDoWithDatabaseProxy === 'start') { From 71ff19e746e59619ed2975877ea0754ada07b5cb Mon Sep 17 00:00:00 2001 From: DanielHemmati Date: Fri, 25 Apr 2025 15:53:23 +0200 Subject: [PATCH 006/407] get all of the backups --- app/Http/Controllers/Api/DatabasesController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 9d007939d..9c04d1d42 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -154,7 +154,7 @@ public function database_backup_details_uuid(Request $request) return response()->json(['message' => 'Database not found.'], 404); } - $backupConfig = ScheduledDatabaseBackup::with('executions')->where('database_id', $database->id)->first(); + $backupConfig = ScheduledDatabaseBackup::with('executions')->where('database_id', $database->id)->get(); return response()->json($this->removeSensitiveData($backupConfig)); } From b4119fe012052f5d083c0d849d2f2942eca02f40 Mon Sep 17 00:00:00 2001 From: DanielHemmati Date: Fri, 25 Apr 2025 16:43:05 +0200 Subject: [PATCH 007/407] change the order of update --- .../Controllers/Api/DatabasesController.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 9c04d1d42..389983920 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -604,6 +604,15 @@ public function update_by_uuid(Request $request) $database->update($databasePayload); } + if ($backupPayload && $backupConfig) { + $backupConfig->update($backupPayload); + + if ($request->backup_now) { + dd('test'); + DatabaseBackupJob::dispatch($backupConfig); + } + } + if ($backupPayload && ! $backupConfig) { if ($database->type() === 'standalone-postgresql') { $backupPayload['databases_to_backup'] = $database->postgres_db; @@ -628,14 +637,6 @@ public function update_by_uuid(Request $request) } } - if ($backupPayload && $backupConfig) { - $backupConfig->update($backupPayload); - - if ($request->backup_now) { - DatabaseBackupJob::dispatch($backupConfig); - } - } - if ($whatToDoWithDatabaseProxy === 'start') { StartDatabaseProxy::dispatch($database); } elseif ($whatToDoWithDatabaseProxy === 'stop') { From 166e5ad2271479b8ea6d8d7ea1a849fed85d0aad Mon Sep 17 00:00:00 2001 From: DanielHemmati Date: Fri, 25 Apr 2025 17:20:48 +0200 Subject: [PATCH 008/407] remove dd --- app/Http/Controllers/Api/DatabasesController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 389983920..4f62da8bf 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -608,7 +608,6 @@ public function update_by_uuid(Request $request) $backupConfig->update($backupPayload); if ($request->backup_now) { - dd('test'); DatabaseBackupJob::dispatch($backupConfig); } } From be104cd612cdf3e13523c0077bb4273cb95687a5 Mon Sep 17 00:00:00 2001 From: DanielHemmati Date: Thu, 22 May 2025 14:36:14 +0200 Subject: [PATCH 009/407] feat(api): add endpoint to update backup configuration by UUID and backup ID; modify response to include backup id --- .../Controllers/Api/DatabasesController.php | 153 +++++++++++++++++- routes/api.php | 1 + 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 4f62da8bf..7172e5aae 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -156,7 +156,7 @@ public function database_backup_details_uuid(Request $request) $backupConfig = ScheduledDatabaseBackup::with('executions')->where('database_id', $database->id)->get(); - return response()->json($this->removeSensitiveData($backupConfig)); + return response()->json($backupConfig); } #[OA\Get( @@ -288,7 +288,6 @@ public function database_by_uuid(Request $request) 'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'], 'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'], 'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'], - // WIP 'save_s3' => ['type' => 'boolean', 'description' => 'Weather data is saved in s3 or not'], 's3_storage_id' => ['type' => 'integer', 'description' => 'S3 storage id'], 'backup_now' => ['type' => 'boolean', 'description' => 'Weather to take a backup now or not'], @@ -647,6 +646,156 @@ public function update_by_uuid(Request $request) ]); } + #[OA\Patch( + summary: 'Update', + description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID', + path: '/databases/{uuid}/backups/{backup_id}', + operationId: 'update-database-backup-config-by-uuid-and-backup-id', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'backup_id', + in: 'path', + description: 'ID of the backup configuration.', + required: true, + schema: new OA\Schema( + type: 'integer', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Database backup configuration data', + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'save_s3' => ['type' => 'boolean', 'description' => 'Weather data is saved in s3 or not'], + 's3_storage_id' => ['type' => 'integer', 'description' => 'S3 storage id'], + 'backup_now' => ['type' => 'boolean', 'description' => 'Weather to take a backup now or not'], + 'enabled' => ['type' => 'boolean', 'description' => 'Weather the backup is enabled or not'], + 'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'], + 'dump_all' => ['type' => 'boolean', 'description' => 'Weather all databases are dumped or not'], + 'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'], + 'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'], + 'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'], + 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], + 'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'], + 'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'], + 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'], + ], + ), + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Database backup configuration updated', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function update_backup_config_by_uuid_and_backup_id(Request $request) + { + $backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_id']; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + // this check if the request is a valid json + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + $validator = customApiValidator($request->all(), [ + 'save_s3' => 'boolean', + 'backup_now' => 'boolean|nullable', + 'enabled' => 'boolean', + 'dump_all' => 'boolean', + 's3_storage_id' => 'integer|min:1|exists:s3_storages,id|nullable', + 'databases_to_backup' => 'string', + 'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly', + 'database_backup_retention_amount_locally' => 'integer|min:0', + 'database_backup_retention_days_locally' => 'integer|min:0', + 'database_backup_retention_max_storage_locally' => 'integer|min:0', + 'database_backup_retention_amount_s3' => 'integer|min:0', + 'database_backup_retention_days_s3' => 'integer|min:0', + 'database_backup_retention_max_storage_s3' => 'integer|min:0', + ]); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + if (! $request->uuid) { + return response()->json(['message' => 'UUID is required.'], 404); + } + $uuid = $request->uuid; + removeUnnecessaryFieldsFromRequest($request); + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $backupConfig = ScheduledDatabaseBackup::where('database_id', $database->id) + ->where('id', $request->backup_id) + ->first(); + if (! $backupConfig) { + return response()->json(['message' => 'Backup config not found.'], 404); + } + + $extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']); + if (! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $backupConfig->update($request->only($backupConfigFields)); + + if ($request->backup_now) { + DatabaseBackupJob::dispatch($backupConfig); + } + + return response()->json([ + 'message' => 'Database backup configuration updated', + ]); + } + #[OA\Post( summary: 'Create (PostgreSQL)', description: 'Create a new PostgreSQL database.', diff --git a/routes/api.php b/routes/api.php index 1a1990513..a5abe4b98 100644 --- a/routes/api.php +++ b/routes/api.php @@ -113,6 +113,7 @@ Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']); Route::get('/databases/{uuid}/backups', [DatabasesController::class, 'database_backup_details_uuid'])->middleware(['api.ability:read']); Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); + Route::patch('/databases/{uuid}/backups/{backup_id}', [DatabasesController::class, 'update_backup_config_by_uuid_and_backup_id'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{backup_id}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); From 2bf6a9cb2c324715b19d87e88babfba1ebc7ca30 Mon Sep 17 00:00:00 2001 From: DanielHemmati Date: Thu, 22 May 2025 14:39:36 +0200 Subject: [PATCH 010/407] undo changes to update_by_uuid method --- .../Controllers/Api/DatabasesController.php | 56 +------------------ 1 file changed, 2 insertions(+), 54 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 7172e5aae..4fa42c37d 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -326,7 +326,6 @@ public function database_by_uuid(Request $request) )] public function update_by_uuid(Request $request) { - $allowedBackupConfigsFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_id']; $allowedFields = ['name', 'description', 'image', 'public_port', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -351,19 +350,6 @@ public function update_by_uuid(Request $request) 'limits_cpus' => 'string', 'limits_cpuset' => 'string|nullable', 'limits_cpu_shares' => 'numeric', - 'save_s3' => 'boolean', - 'backup_now' => 'boolean|nullable', - 'enabled' => 'boolean', - 'dump_all' => 'boolean', - 's3_storage_id' => 'integer|min:1|exists:s3_storages,id|nullable', - 'databases_to_backup' => 'string', - 'frequency' => 'string|in:every_minute,hourly,daily,weekly,monthly,yearly', - 'database_backup_retention_amount_locally' => 'integer|min:0', - 'database_backup_retention_days_locally' => 'integer|min:0', - 'database_backup_retention_max_storage_locally' => 'integer|min:0', - 'database_backup_retention_amount_s3' => 'integer|min:0', - 'database_backup_retention_days_s3' => 'integer|min:0', - 'database_backup_retention_max_storage_s3' => 'integer|min:0', ]); if ($validator->fails()) { @@ -375,7 +361,6 @@ public function update_by_uuid(Request $request) $uuid = $request->uuid; removeUnnecessaryFieldsFromRequest($request); $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); - $backupConfig = ScheduledDatabaseBackup::where('database_id', $database->id)->first(); if (! $database) { return response()->json(['message' => 'Database not found.'], 404); } @@ -574,7 +559,7 @@ public function update_by_uuid(Request $request) } break; } - $extraFields = array_diff(array_keys($request->all()), $allowedFields, $allowedBackupConfigsFields, ['backup_now']); + $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); if (! empty($extraFields)) { @@ -596,44 +581,7 @@ public function update_by_uuid(Request $request) $whatToDoWithDatabaseProxy = 'start'; } - $backupPayload = $request->only($allowedBackupConfigsFields); - $databasePayload = $request->only($allowedFields); - - if ($databasePayload) { - $database->update($databasePayload); - } - - if ($backupPayload && $backupConfig) { - $backupConfig->update($backupPayload); - - if ($request->backup_now) { - DatabaseBackupJob::dispatch($backupConfig); - } - } - - if ($backupPayload && ! $backupConfig) { - if ($database->type() === 'standalone-postgresql') { - $backupPayload['databases_to_backup'] = $database->postgres_db; - } elseif ($database->type() === 'standalone-mysql') { - $backupPayload['databases_to_backup'] = $database->mysql_database; - } elseif ($database->type() === 'standalone-mariadb') { - $backupPayload['databases_to_backup'] = $database->mariadb_database; - } elseif ($database->type() === 'standalone-mongodbs') { - $backupPayload['databases_to_backup'] = $database->mongo_initdb_database; - } - - $backupConfig = ScheduledDatabaseBackup::create([ - 'database_id' => $database->id, - 'database_type' => $database->getMorphClass(), - 'team_id' => $teamId, - 's3_storage_id' => $backupPayload['s3_storage_id'] ?? 1, - ...$backupPayload, - ]); - - if ($request->backup_now) { - DatabaseBackupJob::dispatch($backupConfig); - } - } + $database->update($request->all()); if ($whatToDoWithDatabaseProxy === 'start') { StartDatabaseProxy::dispatch($database); From 7a110880c1e7bc36b4a841890912799746310945 Mon Sep 17 00:00:00 2001 From: jvdboog <110812872+jvdboog@users.noreply.github.com> Date: Sun, 20 Jul 2025 22:15:42 +0200 Subject: [PATCH 011/407] feat: Improve detection of special network modes --- bootstrap/helpers/shared.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 7ce511f2c..4e77b35c3 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -614,10 +614,14 @@ function getTopLevelNetworks(Service|Application $resource) $definedNetwork = collect([$resource->uuid]); $services = collect($services)->map(function ($service, $_) use ($topLevelNetworks, $definedNetwork) { $serviceNetworks = collect(data_get($service, 'networks', [])); - $hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false; + $networkMode = data_get($service, 'network_mode'); - // Only add 'networks' key if 'network_mode' is not 'host' - if (! $hasHostNetworkMode) { + $hasValidNetworkMode = + $networkMode === 'host' || + (is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:'))); + + // Only add 'networks' key if 'network_mode' is not 'host' or does not start with 'service:' or 'container:' + if (! $hasValidNetworkMode) { // Collect/create/update networks if ($serviceNetworks->count() > 0) { foreach ($serviceNetworks as $networkName => $networkDetails) { @@ -1502,7 +1506,12 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $serviceNetworks = collect(data_get($service, 'networks', [])); $serviceVariables = collect(data_get($service, 'environment', [])); $serviceLabels = collect(data_get($service, 'labels', [])); - $hasHostNetworkMode = data_get($service, 'network_mode') === 'host' ? true : false; + $networkMode = data_get($service, 'network_mode'); + + $hasValidNetworkMode = + $networkMode === 'host' || + (is_string($networkMode) && (str_starts_with($networkMode, 'service:') || str_starts_with($networkMode, 'container:'))); + if ($serviceLabels->count() > 0) { $removedLabels = collect([]); $serviceLabels = $serviceLabels->filter(function ($serviceLabel, $serviceLabelName) use ($removedLabels) { @@ -1613,7 +1622,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $savedService->ports = $collectedPorts->implode(','); $savedService->save(); - if (! $hasHostNetworkMode) { + if (! $hasValidNetworkMode) { // Add Coolify specific networks $definedNetworkExists = $topLevelNetworks->contains(function ($value, $_) use ($definedNetwork) { return $value == $definedNetwork; From 04e47acfd1093b868efc46e57e9eba1359b30c70 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 26 Aug 2025 16:23:52 +0000 Subject: [PATCH 012/407] docs: update changelog --- CHANGELOG.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63c4554fe..900369e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,23 @@ # Changelog ## [unreleased] -### 🚀 Features +### 🐛 Bug Fixes -- *(docs)* Expand Backlog.md guidelines with comprehensive usage instructions, CLI commands, and best practices for task management to enhance project organization and collaboration +- *(backups)* S3 backup upload is failing + +### ⚙️ Miscellaneous Tasks + +- *(core)* Update version + +## [4.0.0-beta.420.8] - 2025-08-26 ### 🚜 Refactor - *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility -### ⚙️ Miscellaneous Tasks +### 📚 Documentation -- *(versions)* Update coolify version to 4.0.0-beta.420.8 and nightly version to 4.0.0-beta.420.9 in versions.json and constants.php -- *(docker)* Update soketi image version to 1.0.10 in production and Windows configurations +- Update changelog ## [4.0.0-beta.420.7] - 2025-08-26 @@ -81,6 +86,7 @@ ### 🚀 Features - *(docs)* Add Backlog.md guidelines and project manager backlog agent; enhance CLAUDE.md with new links for task management - *(docs)* Add tasks for implementing Docker build caching and optimizing staging builds; include detailed acceptance criteria and implementation plans - *(docker)* Implement Docker cleanup processing in ScheduledJobManager; refactor server task scheduling to streamline cleanup job dispatching +- *(docs)* Expand Backlog.md guidelines with comprehensive usage instructions, CLI commands, and best practices for task management to enhance project organization and collaboration ### 🐛 Bug Fixes @@ -158,12 +164,15 @@ ### 🚜 Refactor - *(validation)* Implement centralized validation patterns across components - *(jobs)* Rename job classes to indicate deprecation status - Update check frequency logic for cloud and self-hosted environments; streamline server task scheduling and timezone handling +- *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility ### 📚 Documentation - *(claude)* Clarify that artisan commands should only be run inside the "coolify" container during development - Add AGENTS.md for project guidance and development instructions - Update changelog +- Update changelog +- Update changelog ### ⚙️ Miscellaneous Tasks @@ -186,6 +195,8 @@ ### ⚙️ Miscellaneous Tasks - *(cleanup)* Remove unused GitLab view files for change, new, and show pages - *(workflows)* Add backlog directory to build triggers for production and staging workflows - *(config)* Disable auto_commit in backlog configuration to prevent automatic commits +- *(versions)* Update coolify version to 4.0.0-beta.420.8 and nightly version to 4.0.0-beta.420.9 in versions.json and constants.php +- *(docker)* Update soketi image version to 1.0.10 in production and Windows configurations ### ◀️ Revert From f1f916cd8923453c61e439626a6edd0be9ff5c00 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 26 Aug 2025 16:48:57 +0000 Subject: [PATCH 013/407] docs: update changelog --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 900369e2c..91d6d4ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,22 @@ ## [unreleased] ### 🐛 Bug Fixes +- *(backups)* Rollback helper update for now + +### ⚙️ Miscellaneous Tasks + +- *(core)* Update version + +## [4.0.0-beta.420.9] - 2025-08-26 + +### 🐛 Bug Fixes + - *(backups)* S3 backup upload is failing +### 📚 Documentation + +- Update changelog + ### ⚙️ Miscellaneous Tasks - *(core)* Update version From e8ce635eba02a97dea94873ffc54e531921c6508 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 26 Aug 2025 19:47:05 +0000 Subject: [PATCH 014/407] docs: update changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91d6d4ecb..153776f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,22 @@ # Changelog ## [unreleased] +### 🚀 Features + +- *(policies)* Add EnvironmentVariablePolicy for managing environment variables ( it was missing ) + ### 🐛 Bug Fixes - *(backups)* Rollback helper update for now +### 📚 Documentation + +- Update changelog + ### ⚙️ Miscellaneous Tasks - *(core)* Update version +- *(versions)* Update coolify version to 4.0.0-beta.421 and nightly version to 4.0.0-beta.422 ## [4.0.0-beta.420.9] - 2025-08-26 From be86508aa3e005af47b6cadc0d7e0fd50f8c49cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 27 Aug 2025 15:17:06 +0000 Subject: [PATCH 015/407] docs: update changelog --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 153776f1a..4577287b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ # Changelog ## [unreleased] +### 🐛 Bug Fixes + +- *(parsers)* Replace hyphens with underscores in service names for consistency. this allows to properly parse custom domains in docker compose based applications +- *(parsers)* Implement parseDockerVolumeString function to handle various Docker volume formats and modes, including environment variables and Windows paths. Add unit tests for comprehensive coverage. +- *(git)* Submodule update command uses an unsupported option (#6454) +- *(service)* Swap URL for FQDN on matrix template (#6466) +- *(parsers)* Enhance volume string handling by preserving mode in application and service parsers. Update related unit tests for validation. +- *(docker)* Update parser version in FQDN generation for service-specific URLs + +### 🚜 Refactor + +- *(git)* Improve submodule cloning + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Update version +- Update development node version + +## [4.0.0-beta.421] - 2025-08-26 + ### 🚀 Features - *(policies)* Add EnvironmentVariablePolicy for managing environment variables ( it was missing ) From 886f910632562e8bbcf695e7cd24b6c8e08119b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 27 Aug 2025 16:39:19 +0000 Subject: [PATCH 016/407] docs: update changelog --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4577287b0..7003669db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ # Changelog ## [unreleased] +### 🚜 Refactor + +- *(parsers)* Remove unnecessary hyphen-to-underscore replacement for service names in serviceParser function + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.423 and nightly version to 4.0.0-beta.424 + +## [4.0.0-beta.422] - 2025-08-27 + ### 🐛 Bug Fixes - *(parsers)* Replace hyphens with underscores in service names for consistency. this allows to properly parse custom domains in docker compose based applications From d04dbf910b2e3e865323f1d792aa07643a819336 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 27 Aug 2025 19:37:02 +0000 Subject: [PATCH 017/407] docs: update changelog --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7003669db..c9582cd2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ # Changelog ## [unreleased] +### 🐛 Bug Fixes + +- *(parsers)* Do not modify service names, only for getting fqdns and related envs +- *(compose)* Temporary allow to edit volumes in apps (compose based) and services + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- Update coolify version to 4.0.0-beta.424 and nightly version to 4.0.0-beta.425 + +## [4.0.0-beta.423] - 2025-08-27 + ### 🚜 Refactor - *(parsers)* Remove unnecessary hyphen-to-underscore replacement for service names in serviceParser function From 3c126927d5dbcd757493afd12081fb08c61017f5 Mon Sep 17 00:00:00 2001 From: saurabhraghuvanshii Date: Thu, 28 Aug 2025 02:10:14 +0530 Subject: [PATCH 018/407] enhancement: allow deploy from container image hash --- app/Jobs/ApplicationDeploymentJob.php | 15 +- app/Livewire/Project/New/DockerImage.php | 4 + app/Services/DockerImageParser.php | 41 ++++++ .../project/application/general.blade.php | 6 +- .../project/new/docker-image.blade.php | 9 +- tests/Unit/DockerImageParserTest.php | 129 ++++++++++-------- 6 files changed, 145 insertions(+), 59 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 9037fa3e5..5a00a2dd6 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -410,7 +410,12 @@ private function deploy_dockerimage_buildpack() } else { $this->dockerImageTag = $this->application->docker_registry_image_tag; } - $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}."); + + // Check if this is an image hash deployment + $isImageHash = str($this->dockerImageTag)->startsWith('sha256-'); + $displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}"; + + $this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}."); $this->generate_image_names(); $this->prepare_builder_image(); $this->generate_compose_file(); @@ -801,7 +806,13 @@ private function generate_image_names() $this->production_image_name = "{$this->application->uuid}:latest"; } } elseif ($this->application->build_pack === 'dockerimage') { - $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; + // Check if this is an image hash deployment + if (str($this->dockerImageTag)->startsWith('sha256-')) { + $hash = str($this->dockerImageTag)->after('sha256-'); + $this->production_image_name = "{$this->dockerImage}@sha256:{$hash}"; + } else { + $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; + } } elseif ($this->pull_request_id !== 0) { if ($this->application->docker_registry_image_name) { $this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build"; diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 7d68ce068..d78c61904 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -45,6 +45,10 @@ public function submit() $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + + // Determine the image tag based on whether it's a hash or regular tag + $imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag(); + $application = Application::create([ 'name' => 'docker-image-'.new Cuid2, 'repository_project_id' => 0, diff --git a/app/Services/DockerImageParser.php b/app/Services/DockerImageParser.php index 1fd6625b3..1dd34c713 100644 --- a/app/Services/DockerImageParser.php +++ b/app/Services/DockerImageParser.php @@ -10,6 +10,8 @@ class DockerImageParser private string $tag = 'latest'; + private bool $isImageHash = false; + public function parse(string $imageString): self { // First split by : to handle the tag, but be careful with registry ports @@ -21,9 +23,13 @@ public function parse(string $imageString): self if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) { $mainPart = substr($imageString, 0, $lastColon); $this->tag = substr($imageString, $lastColon + 1); + + // Check if the tag is a SHA256 hash + $this->isImageHash = $this->isSha256Hash($this->tag); } else { $mainPart = $imageString; $this->tag = 'latest'; + $this->isImageHash = false; } // Split the main part by / to handle registry and image name @@ -41,6 +47,37 @@ public function parse(string $imageString): self return $this; } + /** + * Check if the given string is a SHA256 hash + */ + private function isSha256Hash(string $hash): bool + { + // SHA256 hashes are 64 characters long and contain only hexadecimal characters + return preg_match('/^[a-f0-9]{64}$/i', $hash) === 1; + } + + /** + * Check if the current tag is an image hash + */ + public function isImageHash(): bool + { + return $this->isImageHash; + } + + /** + * Get the full image name with hash if present + */ + public function getFullImageNameWithHash(): string + { + $imageName = $this->getFullImageNameWithoutTag(); + + if ($this->isImageHash) { + return $imageName.'@sha256:'.$this->tag; + } + + return $imageName.':'.$this->tag; + } + public function getFullImageNameWithoutTag(): string { if ($this->registryUrl) { @@ -73,6 +110,10 @@ public function toString(): string } $parts[] = $this->imageName; + if ($this->isImageHash) { + return implode('/', $parts).'@sha256:'.$this->tag; + } + return implode('/', $parts).':'.$this->tag; } } diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index b833fc7bb..398e94191 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -163,12 +163,14 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" @if ($application->destination->server->isSwarm()) - @else - @endif @else diff --git a/resources/views/livewire/project/new/docker-image.blade.php b/resources/views/livewire/project/new/docker-image.blade.php index 4cc86710a..af1005a88 100644 --- a/resources/views/livewire/project/new/docker-image.blade.php +++ b/resources/views/livewire/project/new/docker-image.blade.php @@ -6,6 +6,13 @@

Docker Image

Save - +
+ +
diff --git a/tests/Unit/DockerImageParserTest.php b/tests/Unit/DockerImageParserTest.php index 35dffbab4..f41a9b170 100644 --- a/tests/Unit/DockerImageParserTest.php +++ b/tests/Unit/DockerImageParserTest.php @@ -3,92 +3,113 @@ namespace Tests\Unit; use App\Services\DockerImageParser; -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\TestCase; +use Tests\TestCase; class DockerImageParserTest extends TestCase { - private DockerImageParser $parser; - - protected function setUp(): void + public function test_parses_regular_image_with_tag() { - parent::setUp(); - $this->parser = new DockerImageParser; + $parser = new DockerImageParser; + $parser->parse('nginx:latest'); + + $this->assertEquals('nginx', $parser->getImageName()); + $this->assertEquals('latest', $parser->getTag()); + $this->assertFalse($parser->isImageHash()); + $this->assertEquals('nginx:latest', $parser->toString()); } - #[Test] - public function it_parses_simple_image_name() + public function test_parses_image_with_sha256_hash() { - $this->parser->parse('nginx'); + $parser = new DockerImageParser; + $hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0'; + $parser->parse("ghcr.io/benjaminehowe/rail-disruptions:{$hash}"); - $this->assertEquals('', $this->parser->getRegistryUrl()); - $this->assertEquals('nginx', $this->parser->getImageName()); - $this->assertEquals('latest', $this->parser->getTag()); + $this->assertEquals('ghcr.io/benjaminehowe/rail-disruptions', $parser->getFullImageNameWithoutTag()); + $this->assertEquals($hash, $parser->getTag()); + $this->assertTrue($parser->isImageHash()); + $this->assertEquals("ghcr.io/benjaminehowe/rail-disruptions@sha256:{$hash}", $parser->toString()); + $this->assertEquals("ghcr.io/benjaminehowe/rail-disruptions@sha256:{$hash}", $parser->getFullImageNameWithHash()); } - #[Test] - public function it_parses_image_with_tag() + public function test_parses_registry_image_with_hash() { - $this->parser->parse('nginx:1.19'); + $parser = new DockerImageParser; + $hash = 'abc123def456789abcdef123456789abcdef123456789abcdef123456789abc1'; + $parser->parse("docker.io/library/nginx:{$hash}"); - $this->assertEquals('', $this->parser->getRegistryUrl()); - $this->assertEquals('nginx', $this->parser->getImageName()); - $this->assertEquals('1.19', $this->parser->getTag()); + $this->assertEquals('docker.io/library/nginx', $parser->getFullImageNameWithoutTag()); + $this->assertEquals($hash, $parser->getTag()); + $this->assertTrue($parser->isImageHash()); + $this->assertEquals("docker.io/library/nginx@sha256:{$hash}", $parser->toString()); } - #[Test] - public function it_parses_image_with_organization() + public function test_parses_image_without_tag_defaults_to_latest() { - $this->parser->parse('coollabs/coolify:latest'); + $parser = new DockerImageParser; + $parser->parse('nginx'); - $this->assertEquals('', $this->parser->getRegistryUrl()); - $this->assertEquals('coollabs/coolify', $this->parser->getImageName()); - $this->assertEquals('latest', $this->parser->getTag()); + $this->assertEquals('nginx', $parser->getImageName()); + $this->assertEquals('latest', $parser->getTag()); + $this->assertFalse($parser->isImageHash()); + $this->assertEquals('nginx:latest', $parser->toString()); } - #[Test] - public function it_parses_image_with_registry_url() + public function test_parses_registry_with_port() { - $this->parser->parse('ghcr.io/coollabs/coolify:v4'); + $parser = new DockerImageParser; + $parser->parse('registry.example.com:5000/myapp:latest'); - $this->assertEquals('ghcr.io', $this->parser->getRegistryUrl()); - $this->assertEquals('coollabs/coolify', $this->parser->getImageName()); - $this->assertEquals('v4', $this->parser->getTag()); + $this->assertEquals('registry.example.com:5000/myapp', $parser->getFullImageNameWithoutTag()); + $this->assertEquals('latest', $parser->getTag()); + $this->assertFalse($parser->isImageHash()); } - #[Test] - public function it_parses_image_with_port_in_registry() + public function test_parses_registry_with_port_and_hash() { - $this->parser->parse('localhost:5000/my-app:dev'); + $parser = new DockerImageParser; + $hash = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + $parser->parse("registry.example.com:5000/myapp:{$hash}"); - $this->assertEquals('localhost:5000', $this->parser->getRegistryUrl()); - $this->assertEquals('my-app', $this->parser->getImageName()); - $this->assertEquals('dev', $this->parser->getTag()); + $this->assertEquals('registry.example.com:5000/myapp', $parser->getFullImageNameWithoutTag()); + $this->assertEquals($hash, $parser->getTag()); + $this->assertTrue($parser->isImageHash()); + $this->assertEquals("registry.example.com:5000/myapp@sha256:{$hash}", $parser->toString()); } - #[Test] - public function it_parses_image_without_tag() + public function test_identifies_valid_sha256_hashes() { - $this->parser->parse('ghcr.io/coollabs/coolify'); + $parser = new DockerImageParser; - $this->assertEquals('ghcr.io', $this->parser->getRegistryUrl()); - $this->assertEquals('coollabs/coolify', $this->parser->getImageName()); - $this->assertEquals('latest', $this->parser->getTag()); + // Valid SHA256 hashes + $validHashes = [ + '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0', + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + ]; + + foreach ($validHashes as $hash) { + $parser->parse("image:{$hash}"); + $this->assertTrue($parser->isImageHash(), "Hash {$hash} should be recognized as valid SHA256"); + } } - #[Test] - public function it_converts_back_to_string() + public function test_identifies_invalid_sha256_hashes() { - $originalString = 'ghcr.io/coollabs/coolify:v4'; - $this->parser->parse($originalString); + $parser = new DockerImageParser; - $this->assertEquals($originalString, $this->parser->toString()); - } + // Invalid SHA256 hashes + $invalidHashes = [ + 'latest', + 'v1.2.3', + 'abc123', // too short + '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf', // too short + '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf00', // too long + '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cfg0', // invalid char + ]; - #[Test] - public function it_converts_to_string_with_default_tag() - { - $this->parser->parse('nginx'); - $this->assertEquals('nginx:latest', $this->parser->toString()); + foreach ($invalidHashes as $hash) { + $parser->parse("image:{$hash}"); + $this->assertFalse($parser->isImageHash(), "Hash {$hash} should not be recognized as valid SHA256"); + } } } From 2e85ce0e0e251e38523f9c7a0509f1b355d3e27e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 28 Aug 2025 09:49:58 +0200 Subject: [PATCH 019/407] refactor(urls): replace generateFqdn with generateUrl for consistent URL generation across applications --- app/Livewire/Project/Application/General.php | 2 +- app/Livewire/Project/Application/PreviewsCompose.php | 2 +- app/Livewire/Project/CloneMe.php | 2 +- app/Livewire/Project/New/DockerImage.php | 2 +- app/Livewire/Project/New/GithubPrivateRepository.php | 2 +- .../Project/New/GithubPrivateRepositoryDeployKey.php | 2 +- app/Livewire/Project/New/PublicGitRepository.php | 2 +- app/Livewire/Project/New/SimpleDockerfile.php | 2 +- app/Livewire/Project/Shared/ResourceOperations.php | 2 +- bootstrap/helpers/docker.php | 8 ++++---- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 3107ef4cb..2c8ebb9e2 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -430,7 +430,7 @@ public function getWildcardDomain() $server = data_get($this->application, 'destination.server'); if ($server) { - $fqdn = generateFqdn(server: $server, random: $this->application->uuid, parserVersion: $this->application->compose_parsing_version); + $fqdn = generateUrl(server: $server, random: $this->application->uuid); $this->application->fqdn = $fqdn; $this->application->save(); $this->resetDefaultLabels(); diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php index 0317ba7e7..2632509ea 100644 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ b/app/Livewire/Project/Application/PreviewsCompose.php @@ -60,7 +60,7 @@ public function generate() $random = new Cuid2; // Generate a unique domain like main app services do - $generated_fqdn = generateFqdn(server: $server, random: $random, parserVersion: $this->preview->application->compose_parsing_version); + $generated_fqdn = generateUrl(server: $server, random: $random); $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', str($generated_fqdn)->after('://'), $preview_fqdn); diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index 3c8c9843d..be9de139f 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -133,7 +133,7 @@ public function clone(string $type) $uuid = (string) new Cuid2; $url = $application->fqdn; if ($this->server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $url = generateFqdn(server: $this->server, random: $uuid, parserVersion: $application->compose_parsing_version); + $url = generateUrl(server: $this->server, random: $uuid); } $newApplication = $application->replicate([ diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 7d68ce068..dbb223de2 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -60,7 +60,7 @@ public function submit() 'health_check_enabled' => false, ]); - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->update([ 'name' => 'docker-image-'.$application->uuid, 'fqdn' => $fqdn, diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index a7aaa94a4..0f496e6db 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -208,7 +208,7 @@ public function submit() $application['docker_compose_location'] = $this->docker_compose_location; $application['base_directory'] = $this->base_directory; } - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->name = generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name, $application->uuid); diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index d76f7baaa..5ff8f9137 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -194,7 +194,7 @@ public function submit() $application->settings->is_static = $this->is_static; $application->settings->save(); - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->name = generate_random_name($application->uuid); $application->save(); diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index 8de998a96..f5978aea1 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -373,7 +373,7 @@ public function submit() $application->settings->is_static = $this->isStatic; $application->settings->save(); - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->fqdn = $fqdn; $application->save(); if ($this->checkCoolifyConfig) { diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index ebc9878dc..9cc4fbbe2 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -68,7 +68,7 @@ public function submit() 'source_type' => GithubApp::class, ]); - $fqdn = generateFqdn($destination->server, $application->uuid); + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); $application->update([ 'name' => 'dockerfile-'.$application->uuid, 'fqdn' => $fqdn, diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index c9b341eed..28a6380d5 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -66,7 +66,7 @@ public function cloneTo($destination_id) $url = $this->resource->fqdn; if ($server->proxyType() !== 'NONE' && $applicationSettings->is_container_label_readonly_enabled === true) { - $url = generateFqdn(server: $server, random: $uuid, parserVersion: $this->resource->compose_parsing_version); + $url = generateUrl(server: $server, random: $uuid); } $new_resource = $this->resource->replicate([ diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index ac707f7ab..f61abc806 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -256,12 +256,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource) if (str($MINIO_BROWSER_REDIRECT_URL->value ?? '')->isEmpty()) { $MINIO_BROWSER_REDIRECT_URL->update([ - 'value' => generateFqdn(server: $server, random: 'console-'.$uuid, parserVersion: $resource->service->compose_parsing_version, forceHttps: true), + 'value' => generateUrl(server: $server, random: 'console-'.$uuid, forceHttps: true), ]); } if (str($MINIO_SERVER_URL->value ?? '')->isEmpty()) { $MINIO_SERVER_URL->update([ - 'value' => generateFqdn(server: $server, random: 'minio-'.$uuid, parserVersion: $resource->service->compose_parsing_version, forceHttps: true), + 'value' => generateUrl(server: $server, random: 'minio-'.$uuid, forceHttps: true), ]); } $payload = collect([ @@ -279,12 +279,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource) if (str($LOGTO_ENDPOINT->value ?? '')->isEmpty()) { $LOGTO_ENDPOINT->update([ - 'value' => generateFqdn(server: $server, random: 'logto-'.$uuid, parserVersion: $resource->service->compose_parsing_version), + 'value' => generateUrl(server: $server, random: 'logto-'.$uuid), ]); } if (str($LOGTO_ADMIN_ENDPOINT->value ?? '')->isEmpty()) { $LOGTO_ADMIN_ENDPOINT->update([ - 'value' => generateFqdn(server: $server, random: 'logto-admin-'.$uuid, parserVersion: $resource->service->compose_parsing_version), + 'value' => generateUrl(server: $server, random: 'logto-admin-'.$uuid), ]); } $payload = collect([ From c156d5c705f8874278983e48878eef23cf1714c4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 28 Aug 2025 09:50:16 +0200 Subject: [PATCH 020/407] chore: update coolify version to 4.0.0-beta.425 and nightly version to 4.0.0-beta.426 --- config/constants.php | 2 +- versions.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 770d9ba3f..44b51b978 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.424', + 'version' => '4.0.0-beta.425', 'helper_version' => '1.0.10', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/versions.json b/versions.json index 6bcde3990..41f06d69b 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.424" + "version": "4.0.0-beta.425" }, "nightly": { - "version": "4.0.0-beta.425" + "version": "4.0.0-beta.426" }, "helper": { "version": "1.0.10" From e0aa28ba312520e6d89dcd38573791a9684bd6c0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 28 Aug 2025 10:00:19 +0200 Subject: [PATCH 021/407] refactor(domains): rename check_domain_usage to checkDomainUsage and update references across the application --- app/Livewire/Project/Application/General.php | 4 +- app/Livewire/Project/Application/Previews.php | 2 +- app/Livewire/Project/Service/EditDomain.php | 2 +- .../Service/ServiceApplicationView.php | 2 +- app/Livewire/Settings/Index.php | 2 +- bootstrap/helpers/domains.php | 78 +++++++++++++++++++ bootstrap/helpers/shared.php | 73 ----------------- 7 files changed, 84 insertions(+), 79 deletions(-) create mode 100644 bootstrap/helpers/domains.php diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 2c8ebb9e2..67731c87d 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -485,7 +485,7 @@ public function checkFqdns($showToaster = true) } } } - check_domain_usage(resource: $this->application); + checkDomainUsage(resource: $this->application); $this->application->fqdn = $domains->implode(','); $this->resetDefaultLabels(false); } @@ -588,7 +588,7 @@ public function submit($showToaster = true) } } } - check_domain_usage(resource: $this->application); + checkDomainUsage(resource: $this->application); $this->application->save(); $this->resetDefaultLabels(); } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 9164c1475..cc3c5ea46 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -63,7 +63,7 @@ public function save_preview($preview_id) $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

$preview->fqdn->{$this->application->destination->server->ip}

Check this documentation for further help."); $success = false; } - check_domain_usage(resource: $this->application, domain: $preview->fqdn); + checkDomainUsage(resource: $this->application, domain: $preview->fqdn); } if (! $preview) { diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index b7f73159e..1b24dc23a 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -37,7 +37,7 @@ public function submit() if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } - check_domain_usage(resource: $this->application); + checkDomainUsage(resource: $this->application); $this->validate(); $this->application->save(); updateCompose($this->application); diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 5e178374b..80fe59891 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -145,7 +145,7 @@ public function submit() if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } - check_domain_usage(resource: $this->application); + checkDomainUsage(resource: $this->application); $this->validate(); $this->application->save(); updateCompose($this->application); diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index bce343224..98e5ce8bd 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -108,7 +108,7 @@ public function submit() } } if ($this->fqdn) { - check_domain_usage(domain: $this->fqdn); + checkDomainUsage(domain: $this->fqdn); } $this->instantSave(isSave: false); diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php new file mode 100644 index 000000000..36e044344 --- /dev/null +++ b/bootstrap/helpers/domains.php @@ -0,0 +1,78 @@ +getMorphClass() === Application::class && $resource->build_pack === 'dockercompose') { + $domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain'); + $domains = collect($domains); + } else { + $domains = collect($resource->fqdns); + } + } elseif ($domain) { + $domains = collect([$domain]); + } else { + throw new \RuntimeException('No resource or FQDN provided.'); + } + $domains = $domains->map(function ($domain) { + if (str($domain)->endsWith('/')) { + $domain = str($domain)->beforeLast('/'); + } + + return str($domain); + }); + $apps = Application::all(); + foreach ($apps as $app) { + $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); + foreach ($list_of_domains as $domain) { + if (str($domain)->endsWith('/')) { + $domain = str($domain)->beforeLast('/'); + } + $naked_domain = str($domain)->value(); + if ($domains->contains($naked_domain)) { + if (data_get($resource, 'uuid')) { + if ($resource->uuid !== $app->uuid) { + throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->name}"); + } + } elseif ($domain) { + throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->name}"); + } + } + } + } + $apps = ServiceApplication::all(); + foreach ($apps as $app) { + $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); + foreach ($list_of_domains as $domain) { + if (str($domain)->endsWith('/')) { + $domain = str($domain)->beforeLast('/'); + } + $naked_domain = str($domain)->value(); + if ($domains->contains($naked_domain)) { + if (data_get($resource, 'uuid')) { + if ($resource->uuid !== $app->uuid) { + throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->service->name}"); + } + } elseif ($domain) { + throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->service->name}"); + } + } + } + } + if ($resource) { + $settings = instanceSettings(); + if (data_get($settings, 'fqdn')) { + $domain = data_get($settings, 'fqdn'); + if (str($domain)->endsWith('/')) { + $domain = str($domain)->beforeLast('/'); + } + $naked_domain = str($domain)->value(); + if ($domains->contains($naked_domain)) { + throw new \RuntimeException("Domain $naked_domain is already in use by this Coolify instance."); + } + } + } +} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 7a9b5df80..4743a811b 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1157,79 +1157,6 @@ function checkIfDomainIsAlreadyUsed(Collection|array $domains, ?string $teamId = } } } -function check_domain_usage(ServiceApplication|Application|null $resource = null, ?string $domain = null) -{ - if ($resource) { - if ($resource->getMorphClass() === \App\Models\Application::class && $resource->build_pack === 'dockercompose') { - $domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain'); - $domains = collect($domains); - } else { - $domains = collect($resource->fqdns); - } - } elseif ($domain) { - $domains = collect($domain); - } else { - throw new \RuntimeException('No resource or FQDN provided.'); - } - $domains = $domains->map(function ($domain) { - if (str($domain)->endsWith('/')) { - $domain = str($domain)->beforeLast('/'); - } - - return str($domain); - }); - $apps = Application::all(); - foreach ($apps as $app) { - $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); - foreach ($list_of_domains as $domain) { - if (str($domain)->endsWith('/')) { - $domain = str($domain)->beforeLast('/'); - } - $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { - if (data_get($resource, 'uuid')) { - if ($resource->uuid !== $app->uuid) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->name}"); - } - } elseif ($domain) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->name}"); - } - } - } - } - $apps = ServiceApplication::all(); - foreach ($apps as $app) { - $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); - foreach ($list_of_domains as $domain) { - if (str($domain)->endsWith('/')) { - $domain = str($domain)->beforeLast('/'); - } - $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { - if (data_get($resource, 'uuid')) { - if ($resource->uuid !== $app->uuid) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->service->name}"); - } - } elseif ($domain) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->service->name}"); - } - } - } - } - if ($resource) { - $settings = instanceSettings(); - if (data_get($settings, 'fqdn')) { - $domain = data_get($settings, 'fqdn'); - if (str($domain)->endsWith('/')) { - $domain = str($domain)->beforeLast('/'); - } - $naked_domain = str($domain)->value(); - if ($domains->contains($naked_domain)) { - throw new \RuntimeException("Domain $naked_domain is already in use by this Coolify instance."); - } - } - } -} function parseCommandsByLineForSudo(Collection $commands, Server $server): array { From 7fe6a4198d0fc250fe6876f0a18e742a7cef1fc0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 28 Aug 2025 10:11:56 +0200 Subject: [PATCH 022/407] fix(previews): simplify FQDN generation logic by removing unnecessary empty check --- app/Models/ApplicationPreview.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index aa31268f1..721b22216 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -74,7 +74,7 @@ public function persistentStorages() public function generate_preview_fqdn() { - if (empty($this->fqdn) && $this->application->fqdn) { + if ($this->application->fqdn) { if (str($this->application->fqdn)->contains(',')) { $url = Url::fromString(str($this->application->fqdn)->explode(',')[0]); $preview_fqdn = getFqdnWithoutPort(str($this->application->fqdn)->explode(',')[0]); From 643343785a40f79f73eb7cd374e563d6f61235cc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 28 Aug 2025 10:48:24 +0200 Subject: [PATCH 023/407] refactor(auth): simplify access control logic in CanAccessTerminal and ServerPolicy by allowing all users to perform actions --- app/Http/Middleware/CanAccessTerminal.php | 20 +++++++--------- app/Policies/ServerPolicy.php | 29 +++++++++++------------ app/Providers/AuthServiceProvider.php | 3 +-- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/app/Http/Middleware/CanAccessTerminal.php b/app/Http/Middleware/CanAccessTerminal.php index dcccd819b..348f389ea 100644 --- a/app/Http/Middleware/CanAccessTerminal.php +++ b/app/Http/Middleware/CanAccessTerminal.php @@ -15,17 +15,15 @@ class CanAccessTerminal */ public function handle(Request $request, Closure $next): Response { + if (! auth()->check()) { + abort(401, 'Authentication required'); + } + + // Only admins/owners can access terminal functionality + if (! auth()->user()->can('canAccessTerminal')) { + abort(403, 'Access to terminal functionality is restricted to team administrators'); + } + return $next($request); - - // if (! auth()->check()) { - // abort(401, 'Authentication required'); - // } - - // // Only admins/owners can access terminal functionality - // if (! auth()->user()->can('canAccessTerminal')) { - // abort(403, 'Access to terminal functionality is restricted to team administrators'); - // } - - // return $next($request); } } diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php index 5cc6b739f..6d2396a7d 100644 --- a/app/Policies/ServerPolicy.php +++ b/app/Policies/ServerPolicy.php @@ -28,7 +28,8 @@ public function view(User $user, Server $server): bool */ public function create(User $user): bool { - return $user->isAdmin(); + // return $user->isAdmin(); + return true; } /** @@ -36,7 +37,8 @@ public function create(User $user): bool */ public function update(User $user, Server $server): bool { - return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + return true; } /** @@ -44,7 +46,8 @@ public function update(User $user, Server $server): bool */ public function delete(User $user, Server $server): bool { - return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + return true; } /** @@ -68,7 +71,8 @@ public function forceDelete(User $user, Server $server): bool */ public function manageProxy(User $user, Server $server): bool { - return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + return true; } /** @@ -76,7 +80,8 @@ public function manageProxy(User $user, Server $server): bool */ public function manageSentinel(User $user, Server $server): bool { - return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + return true; } /** @@ -84,15 +89,8 @@ public function manageSentinel(User $user, Server $server): bool */ public function manageCaCertificate(User $user, Server $server): bool { - return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - } - - /** - * Determine whether the user can view terminal. - */ - public function viewTerminal(User $user, Server $server): bool - { - return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + return true; } /** @@ -100,6 +98,7 @@ public function viewTerminal(User $user, Server $server): bool */ public function viewSecurity(User $user, Server $server): bool { - return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); + return true; } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 3e76e6976..c017a580e 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -67,8 +67,7 @@ public function boot(): void // Register gate for terminal access Gate::define('canAccessTerminal', function ($user) { - // return $user->isAdmin() || $user->isOwner(); - return true; + return $user->isAdmin() || $user->isOwner(); }); } } From 80499a03d82a6030231fe042ca268b0e89060a16 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 28 Aug 2025 10:52:41 +0200 Subject: [PATCH 024/407] feat(domains): implement domain conflict detection and user confirmation modal across application components --- app/Livewire/Project/Application/General.php | 51 ++++++++++- app/Livewire/Project/Application/Previews.php | 33 ++++++- app/Livewire/Project/Service/EditDomain.php | 28 +++++- .../Service/ServiceApplicationView.php | 28 +++++- .../Shared/ExecuteContainerCommand.php | 3 - app/Livewire/Settings/Index.php | 26 +++++- bootstrap/helpers/domains.php | 77 ++++++++++++++-- .../domain-conflict-modal.blade.php | 91 +++++++++++++++++++ .../project/application/general.blade.php | 6 ++ .../project/application/previews.blade.php | 15 +++ .../livewire/project/resource/index.blade.php | 2 +- .../project/service/edit-domain.blade.php | 28 ++++-- .../service-application-view.blade.php | 14 +++ .../views/livewire/settings/index.blade.php | 14 +++ 14 files changed, 390 insertions(+), 26 deletions(-) create mode 100644 resources/views/components/domain-conflict-modal.blade.php diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 67731c87d..aa72b7c5f 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -51,9 +51,16 @@ class General extends Component public $parsedServiceDomains = []; + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + protected $listeners = [ 'resetDefaultLabels', 'configurationChanged' => '$refresh', + 'confirmDomainUsage', ]; protected function rules(): array @@ -485,10 +492,33 @@ public function checkFqdns($showToaster = true) } } } - checkDomainUsage(resource: $this->application); + + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return false; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + $this->application->fqdn = $domains->implode(','); $this->resetDefaultLabels(false); } + + return true; + } + + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submit(); } public function setRedirect() @@ -536,7 +566,9 @@ public function submit($showToaster = true) $this->application->parseHealthcheckFromDockerfile($this->application->dockerfile); } - $this->checkFqdns(); + if (! $this->checkFqdns()) { + return; // Stop if there are conflicts and user hasn't confirmed + } $this->application->save(); if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && ! $this->application->settings->is_container_label_readonly_enabled) { @@ -588,7 +620,20 @@ public function submit($showToaster = true) } } } - checkDomainUsage(resource: $this->application); + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + $this->application->save(); $this->resetDefaultLabels(); } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index cc3c5ea46..ebfd84489 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -25,6 +25,14 @@ class Previews extends Component public int $rate_limit_remaining; + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + + public $pendingPreviewId = null; + protected $rules = [ 'application.previews.*.fqdn' => 'string|nullable', ]; @@ -49,6 +57,16 @@ public function load_prs() } } + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + if ($this->pendingPreviewId) { + $this->save_preview($this->pendingPreviewId); + $this->pendingPreviewId = null; + } + } + public function save_preview($preview_id) { try { @@ -63,7 +81,20 @@ public function save_preview($preview_id) $this->dispatch('error', 'Validating DNS failed.', "Make sure you have added the DNS records correctly.

$preview->fqdn->{$this->application->destination->server->ip}

Check this documentation for further help."); $success = false; } - checkDomainUsage(resource: $this->application, domain: $preview->fqdn); + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application, domain: $preview->fqdn); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + $this->pendingPreviewId = $preview_id; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } } if (! $preview) { diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index 1b24dc23a..5ce170b99 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -12,6 +12,12 @@ class EditDomain extends Component public ServiceApplication $application; + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + protected $rules = [ 'application.fqdn' => 'nullable', 'application.required_fqdn' => 'required|boolean', @@ -22,6 +28,13 @@ public function mount() $this->application = ServiceApplication::find($this->applicationId); } + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submit(); + } + public function submit() { try { @@ -37,7 +50,20 @@ public function submit() if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } - checkDomainUsage(resource: $this->application); + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + $this->validate(); $this->application->save(); updateCompose($this->application); diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 80fe59891..3ac12cfe9 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -23,6 +23,12 @@ class ServiceApplicationView extends Component public $delete_volumes = true; + public $domainConflicts = []; + + public $showDomainConflictModal = false; + + public $forceSaveDomains = false; + protected $rules = [ 'application.human_name' => 'nullable', 'application.description' => 'nullable', @@ -129,6 +135,13 @@ public function convertToDatabase() } } + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submit(); + } + public function submit() { try { @@ -145,7 +158,20 @@ public function submit() if ($warning) { $this->dispatch('warning', __('warning.sslipdomain')); } - checkDomainUsage(resource: $this->application); + // Check for domain conflicts if not forcing save + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(resource: $this->application); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } + $this->validate(); $this->application->save(); updateCompose($this->application); diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 6833492a6..02062e1f7 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -33,9 +33,6 @@ class ExecuteContainerCommand extends Component public function mount() { - if (! auth()->user()->isAdmin()) { - abort(403); - } $this->parameters = get_route_parameters(); $this->containers = collect(); $this->servers = collect(); diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 98e5ce8bd..d05433082 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -35,6 +35,12 @@ class Index extends Component #[Validate('required|string|timezone')] public string $instance_timezone; + public array $domainConflicts = []; + + public bool $showDomainConflictModal = false; + + public bool $forceSaveDomains = false; + public function render() { return view('livewire.settings.index'); @@ -81,6 +87,13 @@ public function instantSave($isSave = true) } } + public function confirmDomainUsage() + { + $this->forceSaveDomains = true; + $this->showDomainConflictModal = false; + $this->submit(); + } + public function submit() { try { @@ -108,7 +121,18 @@ public function submit() } } if ($this->fqdn) { - checkDomainUsage(domain: $this->fqdn); + if (! $this->forceSaveDomains) { + $result = checkDomainUsage(domain: $this->fqdn); + if ($result['hasConflicts']) { + $this->domainConflicts = $result['conflicts']; + $this->showDomainConflictModal = true; + + return; + } + } else { + // Reset the force flag after using it + $this->forceSaveDomains = false; + } } $this->instantSave(isSave: false); diff --git a/bootstrap/helpers/domains.php b/bootstrap/helpers/domains.php index 36e044344..b562873b5 100644 --- a/bootstrap/helpers/domains.php +++ b/bootstrap/helpers/domains.php @@ -5,6 +5,14 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null) { + $conflicts = []; + + // Get the current team for filtering + $currentTeam = null; + if ($resource) { + $currentTeam = $resource->team(); + } + if ($resource) { if ($resource->getMorphClass() === Application::class && $resource->build_pack === 'dockercompose') { $domains = data_get(json_decode($resource->docker_compose_domains, true), '*.domain'); @@ -15,8 +23,9 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, } elseif ($domain) { $domains = collect([$domain]); } else { - throw new \RuntimeException('No resource or FQDN provided.'); + return ['conflicts' => [], 'hasConflicts' => false]; } + $domains = $domains->map(function ($domain) { if (str($domain)->endsWith('/')) { $domain = str($domain)->beforeLast('/'); @@ -24,7 +33,15 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, return str($domain); }); - $apps = Application::all(); + + // Filter applications by team if we have a current team + $appsQuery = Application::query(); + if ($currentTeam) { + $appsQuery = $appsQuery->whereHas('environment.project', function ($query) use ($currentTeam) { + $query->where('team_id', $currentTeam->id); + }); + } + $apps = $appsQuery->get(); foreach ($apps as $app) { $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); foreach ($list_of_domains as $domain) { @@ -35,15 +52,35 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, if ($domains->contains($naked_domain)) { if (data_get($resource, 'uuid')) { if ($resource->uuid !== $app->uuid) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->name}"); + $conflicts[] = [ + 'domain' => $naked_domain, + 'resource_name' => $app->name, + 'resource_link' => $app->link(), + 'resource_type' => 'application', + 'message' => "Domain $naked_domain is already in use by application '{$app->name}'", + ]; } } elseif ($domain) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->name}"); + $conflicts[] = [ + 'domain' => $naked_domain, + 'resource_name' => $app->name, + 'resource_link' => $app->link(), + 'resource_type' => 'application', + 'message' => "Domain $naked_domain is already in use by application '{$app->name}'", + ]; } } } } - $apps = ServiceApplication::all(); + + // Filter service applications by team if we have a current team + $serviceAppsQuery = ServiceApplication::query(); + if ($currentTeam) { + $serviceAppsQuery = $serviceAppsQuery->whereHas('service.environment.project', function ($query) use ($currentTeam) { + $query->where('team_id', $currentTeam->id); + }); + } + $apps = $serviceAppsQuery->get(); foreach ($apps as $app) { $list_of_domains = collect(explode(',', $app->fqdn))->filter(fn ($fqdn) => $fqdn !== ''); foreach ($list_of_domains as $domain) { @@ -54,14 +91,27 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, if ($domains->contains($naked_domain)) { if (data_get($resource, 'uuid')) { if ($resource->uuid !== $app->uuid) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->service->name}"); + $conflicts[] = [ + 'domain' => $naked_domain, + 'resource_name' => $app->service->name, + 'resource_link' => $app->service->link(), + 'resource_type' => 'service', + 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'", + ]; } } elseif ($domain) { - throw new \RuntimeException("Domain $naked_domain is already in use by another resource:

Link: {$app->service->name}"); + $conflicts[] = [ + 'domain' => $naked_domain, + 'resource_name' => $app->service->name, + 'resource_link' => $app->service->link(), + 'resource_type' => 'service', + 'message' => "Domain $naked_domain is already in use by service '{$app->service->name}'", + ]; } } } } + if ($resource) { $settings = instanceSettings(); if (data_get($settings, 'fqdn')) { @@ -71,8 +121,19 @@ function checkDomainUsage(ServiceApplication|Application|null $resource = null, } $naked_domain = str($domain)->value(); if ($domains->contains($naked_domain)) { - throw new \RuntimeException("Domain $naked_domain is already in use by this Coolify instance."); + $conflicts[] = [ + 'domain' => $naked_domain, + 'resource_name' => 'Coolify Instance', + 'resource_link' => '#', + 'resource_type' => 'instance', + 'message' => "Domain $naked_domain is already in use by this Coolify instance", + ]; } } } + + return [ + 'conflicts' => $conflicts, + 'hasConflicts' => count($conflicts) > 0, + ]; } diff --git a/resources/views/components/domain-conflict-modal.blade.php b/resources/views/components/domain-conflict-modal.blade.php new file mode 100644 index 000000000..218a7ef16 --- /dev/null +++ b/resources/views/components/domain-conflict-modal.blade.php @@ -0,0 +1,91 @@ +@props([ + 'conflicts' => [], + 'showModal' => false, + 'confirmAction' => 'confirmDomainUsage', +]) + +@if ($showModal && count($conflicts) > 0) +
+ +
+@endif diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index b833fc7bb..315385593 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -462,6 +462,12 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry" + + + @script

Memory (MB)

@@ -195,65 +207,80 @@ class="pt-5"> }, series: [{ name: "Memory (MB)", - data: [] - }], - noData: { - text: 'Loading...', - style: { - color: textColor, - } - }, - tooltip: { - enabled: true, - marker: { - show: false, - } - }, - legend: { - show: false - } + data: [] + }], + noData: { + text: 'Loading...', + style: { + color: textColor, + } + }, + tooltip: { + enabled: true, + marker: { + show: false, + }, + custom: function({ series, seriesIndex, dataPointIndex, w }) { + const value = series[seriesIndex][dataPointIndex]; + const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]; + const date = new Date(timestamp); + const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' + + String(date.getUTCMinutes()).padStart(2, '0') + ':' + + String(date.getUTCSeconds()).padStart(2, '0') + ', ' + + date.getUTCFullYear() + '-' + + String(date.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(date.getUTCDate()).padStart(2, '0'); + return '
' + + '
Memory: ' + value + ' MB
' + + '
' + timeString + '
' + + '
'; + } + }, + legend: { + show: false + } } - const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`), - optionsServerMemory); - serverMemoryChart.render(); - document.addEventListener('livewire:init', () => { - Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => { - checkTheme(); - serverMemoryChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [ramColor], - xaxis: { - type: 'datetime', - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - yaxis: { - min: 0, - show: true, - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - noData: { - text: 'Loading...', - style: { - color: textColor, - } - } - }); - }); - }); + const serverMemoryChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-memory`), + optionsServerMemory); + serverMemoryChart.render(); + Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => { + checkTheme(); + serverMemoryChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [ramColor], + xaxis: { + type: 'datetime', + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + yaxis: { + min: 0, + show: true, + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + noData: { + text: 'Loading...', + style: { + color: textColor, + } + } + }); + }); + @endif @endif + diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index f5a2418fd..2cb8e2c37 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -80,12 +80,27 @@ color: textColor, } }, - tooltip: { - enabled: true, - marker: { - show: false, - } - }, + tooltip: { + enabled: true, + marker: { + show: false, + }, + custom: function({ series, seriesIndex, dataPointIndex, w }) { + const value = series[seriesIndex][dataPointIndex]; + const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]; + const date = new Date(timestamp); + const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' + + String(date.getUTCMinutes()).padStart(2, '0') + ':' + + String(date.getUTCSeconds()).padStart(2, '0') + ', ' + + date.getUTCFullYear() + '-' + + String(date.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(date.getUTCDate()).padStart(2, '0'); + return '
' + + '
CPU: ' + value + '%
' + + '
' + timeString + '
' + + '
'; + } + }, legend: { show: false } @@ -198,12 +213,27 @@ color: textColor, } }, - tooltip: { - enabled: true, - marker: { - show: false, - } - }, + tooltip: { + enabled: true, + marker: { + show: false, + }, + custom: function({ series, seriesIndex, dataPointIndex, w }) { + const value = series[seriesIndex][dataPointIndex]; + const timestamp = w.globals.seriesX[seriesIndex][dataPointIndex]; + const date = new Date(timestamp); + const timeString = String(date.getUTCHours()).padStart(2, '0') + ':' + + String(date.getUTCMinutes()).padStart(2, '0') + ':' + + String(date.getUTCSeconds()).padStart(2, '0') + ', ' + + date.getUTCFullYear() + '-' + + String(date.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(date.getUTCDate()).padStart(2, '0'); + return '
' + + '
Memory: ' + value + '%
' + + '
' + timeString + '
' + + '
'; + } + }, legend: { show: false } From 610ef310341d7bc7384349f80f787b0b9ce3c41e Mon Sep 17 00:00:00 2001 From: ShadowArcanist Date: Fri, 19 Sep 2025 22:51:24 +0530 Subject: [PATCH 218/407] Hidden metrics charts grid borders on darkmode (it was too bright on darkmode) --- resources/css/utilities.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 65869e02f..694ad61a3 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -6,6 +6,10 @@ @utility apexcharts-tooltip-title { @apply hidden!; } +@utility apexcharts-grid-borders { + @apply dark:hidden!; +} + @utility apexcharts-xaxistooltip { @apply hidden!; } From a0f4566580eb982705f5ceba3efff8faddddce16 Mon Sep 17 00:00:00 2001 From: ShadowArcanist Date: Fri, 19 Sep 2025 22:55:25 +0530 Subject: [PATCH 219/407] Fixed Memory title on app metrics being larger than CPU title --- resources/views/livewire/project/shared/metrics.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/project/shared/metrics.blade.php b/resources/views/livewire/project/shared/metrics.blade.php index 9b08babb3..84e4595aa 100644 --- a/resources/views/livewire/project/shared/metrics.blade.php +++ b/resources/views/livewire/project/shared/metrics.blade.php @@ -148,7 +148,7 @@ class="pt-5"> }); -

Memory (MB)

+

Memory (MB)

-

Memory (MB)

+

Memory Usage

-

Memory (%)

+

Memory Usage