From 2e0d4328867e312e70e3c204fe112b640b60838e Mon Sep 17 00:00:00 2001 From: DanielHemmati Date: Wed, 23 Apr 2025 15:56:34 +0200 Subject: [PATCH 001/103] 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\Enums\NewDatabaseTypes; 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 @@ class DatabasesController extends Controller 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/103] 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 @@ class DatabasesController extends Controller 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::group([ }); 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::group([ 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/103] 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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller )] 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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller $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 @@ class DatabasesController extends Controller } 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 @@ class DatabasesController extends Controller $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::group([ }); 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/103] 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 @@ class DatabasesController extends Controller ]); } + #[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::group([ 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/103] 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\StopDatabase; 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 @@ class DatabasesController extends Controller $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 @@ class DatabasesController extends Controller #[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 @@ class DatabasesController extends Controller // 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 @@ class DatabasesController extends Controller )] 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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller } 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 @@ class DatabasesController extends Controller '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/103] 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 @@ class DatabasesController extends Controller 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/103] 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 @@ class DatabasesController extends Controller $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 @@ class DatabasesController extends Controller } } - 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/103] 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 @@ class DatabasesController extends Controller $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/103] 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 @@ class DatabasesController extends Controller $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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller ]); } + #[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::group([ 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/103] 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 @@ class DatabasesController extends Controller )] 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 @@ class DatabasesController extends Controller '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 @@ class DatabasesController extends Controller $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 @@ class DatabasesController extends Controller } 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 @@ class DatabasesController extends Controller $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/103] 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 bfc8a25b726102f79776a900cdb41e793c240316 Mon Sep 17 00:00:00 2001 From: QarthO Date: Fri, 29 Aug 2025 09:09:03 -0400 Subject: [PATCH 012/103] move domain trimming before URL validation --- app/Livewire/Project/Application/General.php | 3 ++- app/Livewire/Project/Service/EditDomain.php | 3 ++- app/Livewire/Project/Service/ServiceApplicationView.php | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index aa72b7c5f..c55afb589 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -547,9 +547,10 @@ class General extends Component $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $domain = trim($domain); Url::fromString($domain, ['http', 'https']); - return str($domain)->trim()->lower(); + return str($domain)->lower(); }); $this->application->fqdn = $this->application->fqdn->unique()->implode(','); diff --git a/app/Livewire/Project/Service/EditDomain.php b/app/Livewire/Project/Service/EditDomain.php index 5ce170b99..7c718393d 100644 --- a/app/Livewire/Project/Service/EditDomain.php +++ b/app/Livewire/Project/Service/EditDomain.php @@ -41,9 +41,10 @@ class EditDomain extends Component $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $domain = trim($domain); Url::fromString($domain, ['http', 'https']); - return str($domain)->trim()->lower(); + return str($domain)->lower(); }); $this->application->fqdn = $this->application->fqdn->unique()->implode(','); $warning = sslipDomainWarning($this->application->fqdn); diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index 3ac12cfe9..e37b6ad86 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -149,9 +149,10 @@ class ServiceApplicationView extends Component $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim(); $this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) { + $domain = trim($domain); Url::fromString($domain, ['http', 'https']); - return str($domain)->trim()->lower(); + return str($domain)->lower(); }); $this->application->fqdn = $this->application->fqdn->unique()->implode(','); $warning = sslipDomainWarning($this->application->fqdn); From c0ffda37f285598a448e90f1a10dc174f4abc3ef Mon Sep 17 00:00:00 2001 From: Dominic Date: Sun, 31 Aug 2025 22:32:30 -0400 Subject: [PATCH 013/103] remove ~ from forbidden characters in git URLs --- app/Rules/ValidGitRepositoryUrl.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php index 3cbe9246e..c7ea208cc 100644 --- a/app/Rules/ValidGitRepositoryUrl.php +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -31,7 +31,7 @@ class ValidGitRepositoryUrl implements ValidationRule $dangerousChars = [ ';', '|', '&', '$', '`', '(', ')', '{', '}', '[', ']', '<', '>', '\n', '\r', '\0', '"', "'", - '\\', '!', '?', '*', '~', '^', '%', '=', '+', + '\\', '!', '?', '*', '^', '%', '=', '+', '#', // Comment character that could hide commands ]; From 758fe18d79be34768084ecced1020ff46a884370 Mon Sep 17 00:00:00 2001 From: Dominic Date: Wed, 3 Sep 2025 13:01:03 -0400 Subject: [PATCH 014/103] oops missed a check --- app/Rules/ValidGitRepositoryUrl.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Rules/ValidGitRepositoryUrl.php b/app/Rules/ValidGitRepositoryUrl.php index c7ea208cc..d549961dc 100644 --- a/app/Rules/ValidGitRepositoryUrl.php +++ b/app/Rules/ValidGitRepositoryUrl.php @@ -85,7 +85,7 @@ class ValidGitRepositoryUrl implements ValidationRule } // Validate SSH URL format (git@host:user/repo.git) - if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.]+$/', $value)) { + if (! preg_match('/^git@[a-zA-Z0-9\.\-]+:[a-zA-Z0-9\-_\/\.~]+$/', $value)) { $fail('The :attribute is not a valid SSH repository URL.'); return; From 0f030c5e546bcf6e149f85c01fb7e57117c89d0c Mon Sep 17 00:00:00 2001 From: Terijaki <590522+terijaki@users.noreply.github.com> Date: Sun, 7 Sep 2025 13:28:37 +0200 Subject: [PATCH 015/103] Change favicon image type to PNG and SVG Changing to the correct type. Incorrect type can cause issues with certain browsers. --- resources/views/layouts/base.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index ebb134324..af8353078 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -35,9 +35,9 @@ @endphp {{ $name }}{{ $title ?? 'Coolify' }} @env('local') - + @else - + @endenv @vite(['resources/js/app.js', 'resources/css/app.css']) From 4f8dfa598e75148b9c9756e4d363200fe84954a9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:43:51 +0200 Subject: [PATCH 016/103] refactor(server): remove debugging ray call from validateConnection method for cleaner code --- app/Models/Server.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/Models/Server.php b/app/Models/Server.php index 960091033..cc5315c6f 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -1082,7 +1082,6 @@ $schema://$host { public function validateConnection(bool $justCheckingNewKey = false) { - ray('validateConnection', $this->id); $this->disableSshMux(); if ($this->skipServer()) { From 2ef139bc4234edfde820bf326469dcdcf101fa2c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:18:35 +0200 Subject: [PATCH 017/103] fix(server): update server usability check to reflect actual Docker availability status --- app/Jobs/ServerConnectionCheckJob.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 167bcea38..8b55434f6 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -78,11 +78,11 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue } // Server is reachable, check if Docker is available - // $isUsable = $this->checkDockerAvailability(); + $isUsable = $this->checkDockerAvailability(); $this->server->settings->update([ 'is_reachable' => true, - 'is_usable' => true, + 'is_usable' => $isUsable, ]); } catch (\Throwable $e) { From 9bd80e4c07cec85237d514ec88f071907cb16669 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:31:57 +0200 Subject: [PATCH 018/103] fix(server): add build server check to disable Sentinel and update related logic --- app/Livewire/Server/Show.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index c95cc6122..202350220 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -298,11 +298,34 @@ class Show extends Component } } + public function updatedIsBuildServer($value) + { + try { + $this->authorize('update', $this->server); + if ($value === true && $this->isSentinelEnabled) { + $this->isSentinelEnabled = false; + $this->isMetricsEnabled = false; + $this->isSentinelDebugEnabled = false; + StopSentinel::dispatch($this->server); + $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.'); + } + $this->submit(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function updatedIsSentinelEnabled($value) { try { $this->authorize('manageSentinel', $this->server); if ($value === true) { + if ($this->isBuildServer) { + $this->isSentinelEnabled = false; + $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); + + return; + } $customImage = isDev() ? $this->sentinelCustomDockerImage : null; StartSentinel::run($this->server, true, null, $customImage); } else { From f9ed02a0b7b9990a761de2628af461493538880b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:33:32 +0200 Subject: [PATCH 019/103] fix(server): implement refreshServer method and update navbar event listener for improved server state management --- app/Livewire/Server/Navbar.php | 8 +++++++- app/Livewire/Server/Show.php | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index 055290580..beefed12a 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -32,7 +32,7 @@ class Navbar extends Component $teamId = auth()->user()->currentTeam()->id; return [ - 'refreshServerShow' => '$refresh', + 'refreshServerShow' => 'refreshServer', "echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification', ]; } @@ -134,6 +134,12 @@ class Navbar extends Component } + public function refreshServer() + { + $this->server->refresh(); + $this->server->load('settings'); + } + public function render() { return view('livewire.server.navbar'); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 202350220..473e0b60e 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -310,6 +310,8 @@ class Show extends Component $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.'); } $this->submit(); + // Dispatch event to refresh the navbar + $this->dispatch('refreshServerShow'); } catch (\Throwable $e) { return handleError($e, $this); } From 9e8fb36bc819a59ed5100b230ebf3aa2ca35433c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 16 Sep 2025 13:40:51 +0200 Subject: [PATCH 020/103] feat(deployment): implement cancellation checks during deployment process to enhance user control and prevent unnecessary execution --- app/Jobs/ApplicationDeploymentJob.php | 30 ++++++++++++++- .../Project/Application/DeploymentNavbar.php | 37 +++++++++++++++++-- app/Traits/ExecuteRemoteCommand.php | 23 +++++++++++- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e0e0f519e..207164ec0 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -250,6 +250,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { + // Check if deployment was cancelled before we even started + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + $this->application_deployment_queue->addLogEntry('Deployment was cancelled before starting.'); + + return; + } + $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, 'horizon_job_worker' => gethostname(), @@ -1146,6 +1154,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function rolling_update() { + $this->checkForCancellation(); if ($this->server->isSwarm()) { $this->application_deployment_queue->addLogEntry('Rolling update started.'); $this->execute_remote_command( @@ -1342,6 +1351,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function prepare_builder_image() { + $this->checkForCancellation(); $settings = instanceSettings(); $helperImage = config('constants.coolify.helper_image'); $helperImage = "{$helperImage}:{$settings->helper_version}"; @@ -1813,6 +1823,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function generate_compose_file() { + $this->checkForCancellation(); $this->create_workdir(); $ports = $this->application->main_port(); $persistent_storages = $this->generate_local_persistent_volumes(); @@ -2546,8 +2557,23 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); throw new RuntimeException('Post-deployment command: Could not find a valid container. Is the container name correct?'); } + /** + * Check if the deployment was cancelled and abort if it was + */ + private function checkForCancellation(): void + { + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); + throw new \RuntimeException('Deployment cancelled by user', 69420); + } + } + private function next(string $status) { + // Refresh to get latest status + $this->application_deployment_queue->refresh(); + // Never allow changing status from FAILED or CANCELLED_BY_USER to anything else if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) { $this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview)); @@ -2555,7 +2581,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); return; } if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { - return; + // Job was cancelled, stop execution + $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); + throw new \RuntimeException('Deployment cancelled by user', 69420); } $this->application_deployment_queue->update([ diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index 66f387fcf..dccd1e499 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -52,15 +52,24 @@ class DeploymentNavbar extends Component public function cancel() { - $kill_command = "docker rm -f {$this->application_deployment_queue->deployment_uuid}"; + $deployment_uuid = $this->application_deployment_queue->deployment_uuid; + $kill_command = "docker rm -f {$deployment_uuid}"; $build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id; $server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id; + + // First, mark the deployment as cancelled to prevent further processing + $this->application_deployment_queue->update([ + 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + ]); + try { if ($this->application->settings->is_build_server_enabled) { $server = Server::ownedByCurrentTeam()->find($build_server_id); } else { $server = Server::ownedByCurrentTeam()->find($server_id); } + + // Add cancellation log entry if ($this->application_deployment_queue->logs) { $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); @@ -77,13 +86,35 @@ class DeploymentNavbar extends Component 'logs' => json_encode($previous_logs, flags: JSON_THROW_ON_ERROR), ]); } - instant_remote_process([$kill_command], $server); + + // Try to stop the helper container if it exists + // Check if container exists first + $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); + + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + // Container exists, kill it + instant_remote_process([$kill_command], $server); + } else { + // Container hasn't started yet + $this->application_deployment_queue->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.'); + } + + // Also try to kill any running process if we have a process ID + if ($this->application_deployment_queue->current_process_id) { + try { + $processKillCommand = "kill -9 {$this->application_deployment_queue->current_process_id}"; + instant_remote_process([$processKillCommand], $server); + } catch (\Throwable $e) { + // Process might already be gone, that's ok + } + } } catch (\Throwable $e) { + // Still mark as cancelled even if cleanup fails return handleError($e, $this); } finally { $this->application_deployment_queue->update([ 'current_process_id' => null, - 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); next_after_cancel($server); } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 0e7961368..0c3414efe 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -46,6 +46,14 @@ trait ExecuteRemoteCommand } } + // Check for cancellation before executing commands + if (isset($this->application_deployment_queue)) { + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + throw new \RuntimeException('Deployment cancelled by user', 69420); + } + } + $maxRetries = config('constants.ssh.max_retries'); $attempt = 0; $lastError = null; @@ -73,6 +81,12 @@ trait ExecuteRemoteCommand // Add log entry for the retry if (isset($this->application_deployment_queue)) { $this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage); + + // Check for cancellation during retry wait + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + throw new \RuntimeException('Deployment cancelled by user during retry', 69420); + } } sleep($delay); @@ -85,6 +99,11 @@ trait ExecuteRemoteCommand // If we exhausted all retries and still failed if (! $commandExecuted && $lastError) { + // Now we can set the status to FAILED since all retries have been exhausted + if (isset($this->application_deployment_queue)) { + $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value; + $this->application_deployment_queue->save(); + } throw $lastError; } }); @@ -160,8 +179,8 @@ trait ExecuteRemoteCommand $process_result = $process->wait(); if ($process_result->exitCode() !== 0) { if (! $ignore_errors) { - $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value; - $this->application_deployment_queue->save(); + // Don't immediately set to FAILED - let the retry logic handle it + // This prevents premature status changes during retryable SSH errors throw new \RuntimeException($process_result->errorOutput()); } } From efbbe76310ee1ba288a64604315d051a2477cadd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:16:01 +0200 Subject: [PATCH 021/103] feat(deployment): add support for Docker BuildKit and build secrets to enhance security and flexibility during application deployment refactor(static-buildpack): seperate static buildpack for readability --- app/Jobs/ApplicationDeploymentJob.php | 670 +++++++++++++++++++++----- 1 file changed, 551 insertions(+), 119 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 207164ec0..192099bb3 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -167,6 +167,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $preserveRepository = false; + private bool $dockerBuildkitSupported = false; + + private Collection|string $build_secrets; + + private string $secrets_dir = ''; + public function tags() { // Do not remove this one, it needs to properly identify which worker is running the job @@ -183,6 +189,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application = Application::find($this->application_deployment_queue->application_id); $this->build_pack = data_get($this->application, 'build_pack'); $this->build_args = collect([]); + $this->build_secrets = ''; $this->deployment_uuid = $this->application_deployment_queue->deployment_uuid; $this->pull_request_id = $this->application_deployment_queue->pull_request_id; @@ -272,6 +279,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue // Make sure the private key is stored in the filesystem $this->server->privateKey->storeInFileSystem(); + // Check Docker Version + $this->checkDockerVersion(); + // Generate custom host<->ip mapping $allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server); @@ -344,6 +354,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { $this->write_deployment_configurations(); } + + // Cleanup build secrets if they were used + $this->cleanup_build_secrets(); + $this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}"); $this->graceful_shutdown_container($this->deployment_uuid); @@ -351,6 +365,47 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + private function checkDockerVersion(): void + { + // Use the build server if available, otherwise use the deployment server + $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; + + try { + // Check Docker version (BuildKit requires Docker 18.09+) + $dockerVersion = instant_remote_process( + ["docker version --format '{{.Server.Version}}'"], + $serverToCheck + ); + + // Parse version and check if >= 18.09 + $versionParts = explode('.', $dockerVersion); + $majorVersion = (int) $versionParts[0]; + $minorVersion = (int) ($versionParts[1] ?? 0); + + if ($majorVersion > 18 || ($majorVersion == 18 && $minorVersion >= 9)) { + // Test if BuildKit is available with secrets support + $buildkitTest = instant_remote_process( + ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], + $serverToCheck + ); + + if (trim($buildkitTest) === 'supported') { + $this->dockerBuildkitSupported = true; + $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; + $this->application_deployment_queue->addLogEntry("Docker BuildKit with secrets support detected on {$serverName}. Build secrets will be used for enhanced security."); + } else { + $this->application_deployment_queue->addLogEntry('Docker BuildKit secrets not available. Falling back to build arguments.'); + } + } else { + $this->application_deployment_queue->addLogEntry("Docker version {$dockerVersion} detected. BuildKit requires 18.09+. Using build arguments."); + } + } catch (\Exception $e) { + // If check fails, default to false + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry('Could not determine Docker BuildKit support. Using build arguments as fallback.'); + } + } + private function decide_what_to_do() { if ($this->restart_only) { @@ -479,11 +534,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } $this->generate_image_names(); $this->cleanup_git(); + + // Check for BuildKit support and generate build secrets + $this->checkDockerVersion(); + $this->generate_build_env_variables(); + $this->application->loadComposeFile(isInit: false); if ($this->application->settings->is_raw_compose_deployment_enabled) { $this->application->oldRawParser(); $yaml = $composeFile = $this->application->docker_compose_raw; $this->save_environment_variables(); + + // For raw compose, we cannot automatically add secrets configuration + // User must define it manually in their docker-compose file + if ($this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.'); + } } else { $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); $this->save_environment_variables(); @@ -502,6 +568,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return; } + + // Add build secrets to compose file if BuildKit is supported + if ($this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $composeFile = $this->add_build_secrets_to_compose($composeFile); + } + $yaml = Yaml::dump(convertToArray($composeFile), 10); } $this->docker_compose_base64 = base64_encode($yaml); @@ -513,11 +585,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry('Pulling & building required images.'); if ($this->docker_compose_custom_build_command) { + // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported + $build_command = $this->docker_compose_custom_build_command; + if ($this->dockerBuildkitSupported) { + $build_command = "DOCKER_BUILDKIT=1 {$build_command}"; + } $this->execute_remote_command( - [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$this->docker_compose_custom_build_command}"), 'hidden' => true], + [executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$build_command}"), 'hidden' => true], ); } else { $command = "{$this->coolify_variables} docker compose"; + // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported + if ($this->dockerBuildkitSupported) { + $command = "DOCKER_BUILDKIT=1 {$command}"; + } if (filled($this->env_filename)) { $command .= " --env-file {$this->workdir}/{$this->env_filename}"; } @@ -531,6 +612,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } + // Cleanup build secrets after build completes + if ($this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $this->cleanup_build_secrets(); + } + $this->stop_running_container(force: true); $this->application_deployment_queue->addLogEntry('Starting new application.'); $networkId = $this->application->uuid; @@ -616,6 +702,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->dockerfile_location = $this->application->dockerfile_location; } $this->prepare_builder_image(); + $this->checkDockerVersion(); $this->check_git_if_build_needed(); $this->generate_image_names(); $this->clone_repository(); @@ -630,6 +717,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->generate_build_env_variables(); $this->add_build_env_variables_to_dockerfile(); $this->build_image(); + $this->cleanup_build_secrets(); $this->push_to_docker_registry(); $this->rolling_update(); } @@ -677,7 +765,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->clone_repository(); $this->cleanup_git(); $this->generate_compose_file(); - $this->build_image(); + $this->build_static_image(); $this->push_to_docker_registry(); $this->rolling_update(); } @@ -2136,16 +2224,72 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } + private function build_static_image() + { + $this->application_deployment_queue->addLogEntry('----------------------------------------'); + $this->application_deployment_queue->addLogEntry('Static deployment. Copying static assets to the image.'); + if ($this->application->static_image) { + $this->pull_latest_image($this->application->static_image); + } + $dockerfile = base64_encode("FROM {$this->application->static_image} + WORKDIR /usr/share/nginx/html/ + LABEL coolify.deploymentId={$this->deployment_uuid} + COPY . . + RUN rm -f /usr/share/nginx/html/nginx.conf + RUN rm -f /usr/share/nginx/html/Dockerfile + RUN rm -f /usr/share/nginx/html/docker-compose.yaml + RUN rm -f /usr/share/nginx/html/.env + COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + if ($this->application->settings->is_spa) { + $nginx_config = base64_encode(defaultNginxConfiguration('spa')); + } else { + $nginx_config = base64_encode(defaultNginxConfiguration()); + } + } + $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"; + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), + 'hidden' => true, + ] + ); + $this->application_deployment_queue->addLogEntry('Building docker image completed.'); + } + private function build_image() { - // Add Coolify related variables to the build args - $this->environment_variables->filter(function ($key, $value) { - return str($key)->startsWith('COOLIFY_'); - })->each(function ($key, $value) { - $this->build_args->push("--build-arg '{$key}'"); - }); + // Add Coolify related variables to the build args/secrets + if ($this->dockerBuildkitSupported) { + // Coolify variables are already included in the secrets from generate_build_env_variables + // build_secrets is already a string at this point + } else { + // Traditional build args approach + $this->environment_variables->filter(function ($key, $value) { + return str($key)->startsWith('COOLIFY_'); + })->each(function ($key, $value) { + $this->build_args->push("--build-arg '{$key}'"); + }); - $this->build_args = $this->build_args->implode(' '); + $this->build_args = $this->build_args->implode(' '); + } $this->application_deployment_queue->addLogEntry('----------------------------------------'); if ($this->disableBuildCache) { @@ -2158,106 +2302,110 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.'); } - if ($this->application->settings->is_static || $this->application->build_pack === 'static') { + if ($this->application->settings->is_static) { if ($this->application->static_image) { $this->pull_latest_image($this->application->static_image); $this->application_deployment_queue->addLogEntry('Continuing with the building process.'); } - if ($this->application->build_pack === 'static') { - $dockerfile = base64_encode("FROM {$this->application->static_image} -WORKDIR /usr/share/nginx/html/ -LABEL coolify.deploymentId={$this->deployment_uuid} -COPY . . -RUN rm -f /usr/share/nginx/html/nginx.conf -RUN rm -f /usr/share/nginx/html/Dockerfile -RUN rm -f /usr/share/nginx/html/docker-compose.yaml -RUN rm -f /usr/share/nginx/html/.env -COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); - if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { - $nginx_config = base64_encode($this->application->custom_nginx_configuration); - } else { - if ($this->application->settings->is_spa) { - $nginx_config = base64_encode(defaultNginxConfiguration('spa')); + if ($this->application->build_pack === 'nixpacks') { + $this->nixpacks_plan = base64_encode($this->nixpacks_plan); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]); + if ($this->force_rebuild) { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), + 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, + ]); + if ($this->dockerBuildkitSupported) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_nixpacks_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; } else { - $nginx_config = base64_encode(defaultNginxConfiguration()); - } - } - } else { - if ($this->application->build_pack === 'nixpacks') { - $this->nixpacks_plan = base64_encode($this->nixpacks_plan); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->nixpacks_plan}' | base64 -d | tee /artifacts/thegameplan.json > /dev/null"), 'hidden' => true]); - if ($this->force_rebuild) { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --no-cache --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), - 'hidden' => true, - ], [ - executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), - 'hidden' => true, - ]); $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; + } + } else { + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), + 'hidden' => true, + ], [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), + 'hidden' => true, + ]); + if ($this->dockerBuildkitSupported) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_nixpacks_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; } else { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->build_image_name} {$this->workdir} -o {$this->workdir}"), - 'hidden' => true, - ], [ - executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), - 'hidden' => true, - ]); $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; } + } - $base64_build_command = base64_encode($build_command); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), - 'hidden' => true, - ] - ); - $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), + 'hidden' => true, + ] + ); + $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); + } else { + // Dockerfile buildpack + if ($this->dockerBuildkitSupported) { + // Use BuildKit with secrets + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"; + } else { + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"; + } } else { + // Traditional build with args if ($this->force_rebuild) { $build_command = "docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; - $base64_build_command = base64_encode($build_command); } else { $build_command = "docker build {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t $this->build_image_name {$this->workdir}"; - $base64_build_command = base64_encode($build_command); } - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), - 'hidden' => true, - ], - [ - executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), - 'hidden' => true, - ] - ); } - $dockerfile = base64_encode("FROM {$this->application->static_image} + $base64_build_command = base64_encode($build_command); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'cat /artifacts/build.sh'), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, 'bash /artifacts/build.sh'), + 'hidden' => true, + ] + ); + } + $dockerfile = base64_encode("FROM {$this->application->static_image} WORKDIR /usr/share/nginx/html/ LABEL coolify.deploymentId={$this->deployment_uuid} COPY --from=$this->build_image_name /app/{$this->application->publish_directory} . COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); - if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { - $nginx_config = base64_encode($this->application->custom_nginx_configuration); + if (str($this->application->custom_nginx_configuration)->isNotEmpty()) { + $nginx_config = base64_encode($this->application->custom_nginx_configuration); + } else { + if ($this->application->settings->is_spa) { + $nginx_config = base64_encode(defaultNginxConfiguration('spa')); } else { - if ($this->application->settings->is_spa) { - $nginx_config = base64_encode(defaultNginxConfiguration('spa')); - } else { - $nginx_config = base64_encode(defaultNginxConfiguration()); - } + $nginx_config = base64_encode(defaultNginxConfiguration()); } } $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; @@ -2285,10 +2433,21 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } else { // Pure Dockerfile based deployment if ($this->application->dockerfile) { - if ($this->force_rebuild) { - $build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + if ($this->dockerBuildkitSupported) { + // Use BuildKit with secrets (only if secrets exist) + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "DOCKER_BUILDKIT=1 docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } else { - $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + // Traditional build with args + if ($this->force_rebuild) { + $build_command = "docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( @@ -2317,7 +2476,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"; + if ($this->dockerBuildkitSupported) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_nixpacks_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"; + } } else { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "nixpacks build -c /artifacts/thegameplan.json --cache-key '{$this->application->uuid}' --no-error-without-start -n {$this->production_image_name} {$this->workdir} -o {$this->workdir}"), @@ -2326,7 +2492,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"; + if ($this->dockerBuildkitSupported) { + // Modify the nixpacks Dockerfile to use build secrets + $this->modify_nixpacks_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->production_image_name} {$this->build_args} {$this->workdir}"; + } } $base64_build_command = base64_encode($build_command); $this->execute_remote_command( @@ -2345,13 +2518,24 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ); $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); } else { - if ($this->force_rebuild) { - $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; - $base64_build_command = base64_encode($build_command); + // Dockerfile buildpack + if ($this->dockerBuildkitSupported) { + // Use BuildKit with secrets + $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; + if ($this->force_rebuild) { + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } else { - $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; - $base64_build_command = base64_encode($build_command); + // Traditional build with args + if ($this->force_rebuild) { + $build_command = "docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } else { + $build_command = "docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} --progress plain -t {$this->production_image_name} {$this->workdir}"; + } } + $base64_build_command = base64_encode($build_command); $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee /artifacts/build.sh > /dev/null"), @@ -2447,14 +2631,108 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $variables = collect([])->merge($this->env_args); } - $this->build_args = $variables->map(function ($value, $key) { - $value = escapeshellarg($value); + if ($this->dockerBuildkitSupported) { + // Generate build secrets instead of build args + $this->generate_build_secrets($variables); + // Ensure build_args is empty string when using secrets + $this->build_args = ''; + } else { + // Fallback to traditional build args + $this->build_args = $variables->map(function ($value, $key) { + $value = escapeshellarg($value); - return "--build-arg {$key}={$value}"; - }); + return "--build-arg {$key}={$value}"; + }); + } + } + + private function generate_build_secrets(Collection $variables) + { + $this->build_secrets = collect([]); + + // Only create secrets if there are variables to process + if ($variables->isEmpty()) { + $this->build_secrets = ''; + + return; + } + + $this->secrets_dir = "/tmp/.build_secrets_{$this->deployment_uuid}"; + + $this->execute_remote_command([executeInDocker($this->deployment_uuid, + "mkdir -p {$this->secrets_dir}" + ), 'hidden' => true]); + + // Generate a secret file for each environment variable + foreach ($variables as $key => $value) { + $secret_file = "{$this->secrets_dir}/{$key}"; + $escaped_value = base64_encode($value); + + $this->execute_remote_command([executeInDocker($this->deployment_uuid, + "echo '{$escaped_value}' | base64 -d > {$secret_file} && chmod 600 {$secret_file}" + ), 'hidden' => true]); + + $this->build_secrets->push("--secret id={$key},src={$secret_file}"); + } + + $this->build_secrets = $this->build_secrets->implode(' '); + } + + private function cleanup_build_secrets() + { + if ($this->dockerBuildkitSupported && $this->secrets_dir) { + // Clean up the secrets directory from the host + $this->execute_remote_command([executeInDocker($this->deployment_uuid, + "rm -rf {$this->secrets_dir}", + ), 'hidden' => true, 'ignore_errors' => true]); + } } private function add_build_env_variables_to_dockerfile() + { + if ($this->dockerBuildkitSupported) { + // When using BuildKit, we need to add the syntax directive and instructions on how to use secrets + $this->add_buildkit_secrets_to_dockerfile(); + } else { + // Traditional approach - add ARGs to the Dockerfile + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), + 'hidden' => true, + 'save' => 'dockerfile', + ]); + $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); + + // Include ALL environment variables as build args (deprecating is_build_time flag) + if ($this->pull_request_id === 0) { + // Get all environment variables except NIXPACKS_ prefixed ones + $envs = $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get(); + foreach ($envs as $env) { + if (data_get($env, 'is_multiline') === true) { + $dockerfile->splice(1, 0, ["ARG {$env->key}"]); + } else { + $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); + } + } + } else { + // Get all preview environment variables except NIXPACKS_ prefixed ones + $envs = $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + foreach ($envs as $env) { + if (data_get($env, 'is_multiline') === true) { + $dockerfile->splice(1, 0, ["ARG {$env->key}"]); + } else { + $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); + } + } + } + $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), + 'hidden' => true, + ]); + } + } + + private function add_buildkit_secrets_to_dockerfile() { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), @@ -2463,28 +2741,55 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ]); $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); - // Include ALL environment variables as build args (deprecating is_build_time flag) - if ($this->pull_request_id === 0) { - // Get all environment variables except NIXPACKS_ prefixed ones - $envs = $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get(); + // Check if BuildKit syntax is already present + $firstLine = $dockerfile->first(); + if (! str_starts_with($firstLine, '# syntax=')) { + // Add BuildKit syntax directive at the very beginning + $dockerfile->prepend('# syntax=docker/dockerfile:1'); + } + + // Create a comment block explaining how to use the secrets in RUN commands + $secretsComment = [ + '', + '# Build secrets are available. Use them in RUN commands like:', + '# For a single secret (inline environment variable):', + '# RUN --mount=type=secret,id=MY_SECRET MY_SECRET=$(cat /run/secrets/MY_SECRET) npm run build', + '', + '# For multiple secrets (inline environment variables):', + '# RUN --mount=type=secret,id=API_KEY --mount=type=secret,id=DB_URL \\', + '# API_KEY=$(cat /run/secrets/API_KEY) \\', + '# DB_URL=$(cat /run/secrets/DB_URL) \\', + '# npm run build', + '', + '# Note: Do NOT use export. Variables are set inline for the specific command only.', + '', + ]; + + // Get the environment variables to document which secrets are available + $envs = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + + if ($envs->count() > 0) { + $secretsComment[] = '# Available secrets:'; foreach ($envs as $env) { - if (data_get($env, 'is_multiline') === true) { - $dockerfile->splice(1, 0, ["ARG {$env->key}"]); - } else { - $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); - } + $secretsComment[] = "# - {$env->key}"; } - } else { - // Get all preview environment variables except NIXPACKS_ prefixed ones - $envs = $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); - foreach ($envs as $env) { - if (data_get($env, 'is_multiline') === true) { - $dockerfile->splice(1, 0, ["ARG {$env->key}"]); - } else { - $dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]); - } + $secretsComment[] = ''; + } + + // Find where to insert the comments (after FROM statement) + $fromIndex = $dockerfile->search(function ($line) { + return str_starts_with(trim(strtoupper($line)), 'FROM'); + }); + + if ($fromIndex !== false) { + // Insert comments after FROM statement + foreach (array_reverse($secretsComment) as $comment) { + $dockerfile->splice($fromIndex + 1, 0, [$comment]); } } + $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), @@ -2492,6 +2797,133 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ]); } + private function modify_nixpacks_dockerfile_for_secrets($dockerfile_path) + { + // Only process if we have secrets to mount + if (empty($this->build_secrets)) { + return; + } + + // Read the nixpacks-generated Dockerfile + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$dockerfile_path}"), + 'hidden' => true, + 'save' => 'nixpacks_dockerfile', + ]); + + $dockerfile = collect(str($this->saved_outputs->get('nixpacks_dockerfile'))->trim()->explode("\n")); + + // Add BuildKit syntax directive if not present + $firstLine = $dockerfile->first(); + if (! str_starts_with($firstLine, '# syntax=')) { + $dockerfile->prepend('# syntax=docker/dockerfile:1'); + } + + // Get the list of available secrets + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + + // Find all RUN commands and add secret mounts to them + $modified = false; + $dockerfile = $dockerfile->map(function ($line) use ($variables, &$modified) { + // Check if this is a RUN command + if (str_starts_with(trim($line), 'RUN')) { + + // Build the mount flags for all secrets + $mounts = []; + foreach ($variables as $env) { + $mounts[] = "--mount=type=secret,id={$env->key}"; + } + + if (! empty($mounts)) { + // Build inline environment variable assignments (no export) + $envAssignments = []; + foreach ($variables as $env) { + $envAssignments[] = "{$env->key}=\$(cat /run/secrets/{$env->key})"; + } + + // Replace RUN with RUN with mounts and inline env vars + $mountString = implode(' ', $mounts); + $envString = implode(' ', $envAssignments); + + // Extract the original command + $originalCommand = trim(substr($line, 3)); // Remove 'RUN' + + // Create the new RUN command with mounts and inline environment variables + // Format: RUN --mount=secret,id=X --mount=secret,id=Y KEY1=$(cat...) KEY2=$(cat...) original_command + $line = "RUN {$mountString} {$envString} {$originalCommand}"; + $modified = true; + } + } + + return $line; + }); + + if ($modified) { + // Write the modified Dockerfile back + $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"), + 'hidden' => true, + ]); + + $this->application_deployment_queue->addLogEntry('Modified Dockerfile to use build secrets: '.$dockerfile->implode("\n"), hidden: true); + } + } + + private function add_build_secrets_to_compose($composeFile) + { + // Get environment variables for secrets + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + + if ($variables->isEmpty()) { + return $composeFile; + } + + // Add top-level secrets definition + $secrets = []; + foreach ($variables as $env) { + $secrets[$env->key] = [ + 'file' => "{$this->secrets_dir}/{$env->key}", + ]; + } + + // Add build.secrets to services that have a build context + $services = data_get($composeFile, 'services', []); + foreach ($services as $serviceName => &$service) { + // Only add secrets if the service has a build context defined + if (isset($service['build'])) { + // Handle both string and array build configurations + if (is_string($service['build'])) { + // Convert string build to array format + $service['build'] = [ + 'context' => $service['build'], + ]; + } + // Add secrets to build configuration + if (! isset($service['build']['secrets'])) { + $service['build']['secrets'] = []; + } + foreach ($variables as $env) { + if (! in_array($env->key, $service['build']['secrets'])) { + $service['build']['secrets'][] = $env->key; + } + } + } + } + + // Update the compose file + $composeFile['services'] = $services; + $composeFile['secrets'] = $secrets; + + $this->application_deployment_queue->addLogEntry('Added build secrets configuration to docker-compose file.'); + + return $composeFile; + } + private function run_pre_deployment_command() { if (empty($this->application->pre_deployment_command)) { From c182cac032294b338db659d3daca3fa487d5e97d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:20:36 +0200 Subject: [PATCH 022/103] Update app/Jobs/ApplicationDeploymentJob.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/Jobs/ApplicationDeploymentJob.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 192099bb3..497dd160d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2665,14 +2665,16 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Generate a secret file for each environment variable foreach ($variables as $key => $value) { - $secret_file = "{$this->secrets_dir}/{$key}"; + // keep id as-is, sanitize only filename + $safe_filename = preg_replace('/[^A-Za-z0-9._-]/', '_', (string) $key); + $secret_file_path = "{$this->secrets_dir}/{$safe_filename}"; $escaped_value = base64_encode($value); $this->execute_remote_command([executeInDocker($this->deployment_uuid, - "echo '{$escaped_value}' | base64 -d > {$secret_file} && chmod 600 {$secret_file}" + "echo '{$escaped_value}' | base64 -d > {$secret_file_path} && chmod 600 {$secret_file_path}" ), 'hidden' => true]); - $this->build_secrets->push("--secret id={$key},src={$secret_file}"); + $this->build_secrets->push("--secret id={$key},src={$secret_file_path}"); } $this->build_secrets = $this->build_secrets->implode(' '); From 8542d33a2dc0c2151c100cabec0d7adb77c83700 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:20:51 +0200 Subject: [PATCH 023/103] refactor(deployment): conditionally cleanup build secrets based on Docker BuildKit support and remove redundant calls for improved efficiency --- app/Jobs/ApplicationDeploymentJob.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 497dd160d..456d63f96 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -355,8 +355,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->write_deployment_configurations(); } - // Cleanup build secrets if they were used - $this->cleanup_build_secrets(); + if ($this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $this->cleanup_build_secrets(); + } $this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}"); $this->graceful_shutdown_container($this->deployment_uuid); @@ -612,11 +613,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } - // Cleanup build secrets after build completes - if ($this->dockerBuildkitSupported && ! empty($this->build_secrets)) { - $this->cleanup_build_secrets(); - } - $this->stop_running_container(force: true); $this->application_deployment_queue->addLogEntry('Starting new application.'); $networkId = $this->application->uuid; @@ -717,7 +713,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->generate_build_env_variables(); $this->add_build_env_variables_to_dockerfile(); $this->build_image(); - $this->cleanup_build_secrets(); $this->push_to_docker_registry(); $this->rolling_update(); } @@ -2288,7 +2283,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->build_args->push("--build-arg '{$key}'"); }); - $this->build_args = $this->build_args->implode(' '); + $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection + ? $this->build_args->implode(' ') + : (string) $this->build_args; } $this->application_deployment_queue->addLogEntry('----------------------------------------'); From 6314fef8df9c5a90c44f519e8a62eeec7b45ae26 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:25:07 +0200 Subject: [PATCH 024/103] Update app/Jobs/ApplicationDeploymentJob.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/Jobs/ApplicationDeploymentJob.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 456d63f96..8851577e0 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2826,8 +2826,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Find all RUN commands and add secret mounts to them $modified = false; $dockerfile = $dockerfile->map(function ($line) use ($variables, &$modified) { - // Check if this is a RUN command - if (str_starts_with(trim($line), 'RUN')) { + $trim = ltrim($line); + // Only handle shell-form RUN; skip JSON-form and already-mounted lines + if (str_starts_with($trim, 'RUN') && !preg_match('/^RUN\s*\[/i', $trim) && !str_contains($line, '--mount=type=secret')) { // Build the mount flags for all secrets $mounts = []; @@ -2847,7 +2848,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $envString = implode(' ', $envAssignments); // Extract the original command - $originalCommand = trim(substr($line, 3)); // Remove 'RUN' + $originalCommand = trim(substr($trim, 3)); // Remove 'RUN' // Create the new RUN command with mounts and inline environment variables // Format: RUN --mount=secret,id=X --mount=secret,id=Y KEY1=$(cat...) KEY2=$(cat...) original_command From f084ded6e9e8c79cafc4548525ac7a9ee0259829 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:25:16 +0200 Subject: [PATCH 025/103] refactor(deployment): remove redundant environment variable documentation from Dockerfile comments to streamline the deployment process --- app/Jobs/ApplicationDeploymentJob.php | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 8851577e0..b8656e14a 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2764,19 +2764,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); '', ]; - // Get the environment variables to document which secrets are available - $envs = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); - - if ($envs->count() > 0) { - $secretsComment[] = '# Available secrets:'; - foreach ($envs as $env) { - $secretsComment[] = "# - {$env->key}"; - } - $secretsComment[] = ''; - } - // Find where to insert the comments (after FROM statement) $fromIndex = $dockerfile->search(function ($line) { return str_starts_with(trim(strtoupper($line)), 'FROM'); From f5e17337f40f8bc03eb89657cac81b1a8ee6d2f7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:26:12 +0200 Subject: [PATCH 026/103] Update app/Jobs/ApplicationDeploymentJob.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/Jobs/ApplicationDeploymentJob.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index b8656e14a..76507f0d7 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2873,8 +2873,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Add top-level secrets definition $secrets = []; foreach ($variables as $env) { + $safe_filename = preg_replace('/[^A-Za-z0-9._-]/', '_', (string) $env->key); $secrets[$env->key] = [ - 'file' => "{$this->secrets_dir}/{$env->key}", + 'file' => "{$this->secrets_dir}/{$safe_filename}", ]; } @@ -2904,7 +2905,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Update the compose file $composeFile['services'] = $services; - $composeFile['secrets'] = $secrets; + // merge with existing secrets if present + $existingSecrets = data_get($composeFile, 'secrets', []); + $composeFile['secrets'] = array_replace($existingSecrets, $secrets); $this->application_deployment_queue->addLogEntry('Added build secrets configuration to docker-compose file.'); From 87967b8734760fb367449d8cc9d5ad4feba3af05 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:08:29 +0200 Subject: [PATCH 027/103] refactor(deployment): streamline Docker BuildKit detection and environment variable handling for enhanced security during application deployment --- app/Jobs/ApplicationDeploymentJob.php | 235 ++++++++++---------------- 1 file changed, 85 insertions(+), 150 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 76507f0d7..a5a971ae5 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -171,8 +171,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private Collection|string $build_secrets; - private string $secrets_dir = ''; - public function tags() { // Do not remove this one, it needs to properly identify which worker is running the job @@ -279,8 +277,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue // Make sure the private key is stored in the filesystem $this->server->privateKey->storeInFileSystem(); - // Check Docker Version - $this->checkDockerVersion(); + $this->detectBuildKitCapabilities(); // Generate custom host<->ip mapping $allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server); @@ -355,10 +352,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->write_deployment_configurations(); } - if ($this->dockerBuildkitSupported && ! empty($this->build_secrets)) { - $this->cleanup_build_secrets(); - } - $this->application_deployment_queue->addLogEntry("Gracefully shutting down build container: {$this->deployment_uuid}"); $this->graceful_shutdown_container($this->deployment_uuid); @@ -366,25 +359,34 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } - private function checkDockerVersion(): void + private function detectBuildKitCapabilities(): void { - // Use the build server if available, otherwise use the deployment server $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; + $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; try { - // Check Docker version (BuildKit requires Docker 18.09+) $dockerVersion = instant_remote_process( ["docker version --format '{{.Server.Version}}'"], $serverToCheck ); - // Parse version and check if >= 18.09 $versionParts = explode('.', $dockerVersion); $majorVersion = (int) $versionParts[0]; $minorVersion = (int) ($versionParts[1] ?? 0); - if ($majorVersion > 18 || ($majorVersion == 18 && $minorVersion >= 9)) { - // Test if BuildKit is available with secrets support + if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Using traditional build arguments."); + + return; + } + + $buildkitEnabled = instant_remote_process( + ["docker buildx version >/dev/null 2>&1 && echo 'available' || echo 'not-available'"], + $serverToCheck + ); + + if (trim($buildkitEnabled) !== 'available') { $buildkitTest = instant_remote_process( ["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], $serverToCheck @@ -392,18 +394,35 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if (trim($buildkitTest) === 'supported') { $this->dockerBuildkitSupported = true; - $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; - $this->application_deployment_queue->addLogEntry("Docker BuildKit with secrets support detected on {$serverName}. Build secrets will be used for enhanced security."); + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}."); + $this->application_deployment_queue->addLogEntry('✓ Build secrets will be used for enhanced security during builds.'); } else { - $this->application_deployment_queue->addLogEntry('Docker BuildKit secrets not available. Falling back to build arguments.'); + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support enabled."); + $this->application_deployment_queue->addLogEntry('⚠ Using traditional build arguments (less secure but compatible).'); } } else { - $this->application_deployment_queue->addLogEntry("Docker version {$dockerVersion} detected. BuildKit requires 18.09+. Using build arguments."); + // Buildx is available, which means BuildKit is available + // Now specifically test for secrets support + $secretsTest = instant_remote_process( + ["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"], + $serverToCheck + ); + + if (trim($secretsTest) === 'supported') { + $this->dockerBuildkitSupported = true; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}."); + $this->application_deployment_queue->addLogEntry('✓ Build secrets will be used for enhanced security during builds.'); + } else { + $this->dockerBuildkitSupported = false; + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported."); + $this->application_deployment_queue->addLogEntry('⚠ Using traditional build arguments (less secure but compatible).'); + } } } catch (\Exception $e) { - // If check fails, default to false $this->dockerBuildkitSupported = false; - $this->application_deployment_queue->addLogEntry('Could not determine Docker BuildKit support. Using build arguments as fallback.'); + $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); + $this->application_deployment_queue->addLogEntry('⚠ Using traditional build arguments as fallback.'); } } @@ -536,8 +555,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->generate_image_names(); $this->cleanup_git(); - // Check for BuildKit support and generate build secrets - $this->checkDockerVersion(); + $this->detectBuildKitCapabilities(); $this->generate_build_env_variables(); $this->application->loadComposeFile(isInit: false); @@ -698,7 +716,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->dockerfile_location = $this->application->dockerfile_location; } $this->prepare_builder_image(); - $this->checkDockerVersion(); + $this->detectBuildKitCapabilities(); $this->check_git_if_build_needed(); $this->generate_image_names(); $this->clone_repository(); @@ -1441,16 +1459,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue // Get user home directory $this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server); $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); + + $env_flags = $this->generate_docker_env_flags_for_secrets(); + ray($env_flags); if ($this->use_build_server) { if ($this->dockerConfigFileExists === 'NOK') { throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); } - $runCommand = "docker run -d --name {$this->deployment_uuid} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { if ($this->dockerConfigFileExists === 'OK') { - $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { - $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$this->destination->network} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } } $this->application_deployment_queue->addLogEntry("Preparing container with helper image: $helperImage."); @@ -2629,12 +2650,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } if ($this->dockerBuildkitSupported) { - // Generate build secrets instead of build args $this->generate_build_secrets($variables); - // Ensure build_args is empty string when using secrets $this->build_args = ''; } else { - // Fallback to traditional build args $this->build_args = $variables->map(function ($value, $key) { $value = escapeshellarg($value); @@ -2643,57 +2661,45 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } + private function generate_docker_env_flags_for_secrets() + { + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + + if ($variables->isEmpty()) { + return ''; + } + + return $variables + ->map(function ($env) { + $escaped_value = escapeshellarg($env->real_value); + + return "-e {$env->key}={$escaped_value}"; + }) + ->implode(' '); + } + private function generate_build_secrets(Collection $variables) { - $this->build_secrets = collect([]); - - // Only create secrets if there are variables to process if ($variables->isEmpty()) { $this->build_secrets = ''; return; } - $this->secrets_dir = "/tmp/.build_secrets_{$this->deployment_uuid}"; - - $this->execute_remote_command([executeInDocker($this->deployment_uuid, - "mkdir -p {$this->secrets_dir}" - ), 'hidden' => true]); - - // Generate a secret file for each environment variable - foreach ($variables as $key => $value) { - // keep id as-is, sanitize only filename - $safe_filename = preg_replace('/[^A-Za-z0-9._-]/', '_', (string) $key); - $secret_file_path = "{$this->secrets_dir}/{$safe_filename}"; - $escaped_value = base64_encode($value); - - $this->execute_remote_command([executeInDocker($this->deployment_uuid, - "echo '{$escaped_value}' | base64 -d > {$secret_file_path} && chmod 600 {$secret_file_path}" - ), 'hidden' => true]); - - $this->build_secrets->push("--secret id={$key},src={$secret_file_path}"); - } - - $this->build_secrets = $this->build_secrets->implode(' '); - } - - private function cleanup_build_secrets() - { - if ($this->dockerBuildkitSupported && $this->secrets_dir) { - // Clean up the secrets directory from the host - $this->execute_remote_command([executeInDocker($this->deployment_uuid, - "rm -rf {$this->secrets_dir}", - ), 'hidden' => true, 'ignore_errors' => true]); - } + $this->build_secrets = $variables + ->map(function ($value, $key) { + return "--secret id={$key},env={$key}"; + }) + ->implode(' '); } private function add_build_env_variables_to_dockerfile() { if ($this->dockerBuildkitSupported) { - // When using BuildKit, we need to add the syntax directive and instructions on how to use secrets - $this->add_buildkit_secrets_to_dockerfile(); + // $this->add_buildkit_secrets_to_dockerfile(); } else { - // Traditional approach - add ARGs to the Dockerfile $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), 'hidden' => true, @@ -2701,9 +2707,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ]); $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); - // Include ALL environment variables as build args (deprecating is_build_time flag) if ($this->pull_request_id === 0) { - // Get all environment variables except NIXPACKS_ prefixed ones $envs = $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get(); foreach ($envs as $env) { if (data_get($env, 'is_multiline') === true) { @@ -2731,58 +2735,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } - private function add_buildkit_secrets_to_dockerfile() - { - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), - 'hidden' => true, - 'save' => 'dockerfile', - ]); - $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); - - // Check if BuildKit syntax is already present - $firstLine = $dockerfile->first(); - if (! str_starts_with($firstLine, '# syntax=')) { - // Add BuildKit syntax directive at the very beginning - $dockerfile->prepend('# syntax=docker/dockerfile:1'); - } - - // Create a comment block explaining how to use the secrets in RUN commands - $secretsComment = [ - '', - '# Build secrets are available. Use them in RUN commands like:', - '# For a single secret (inline environment variable):', - '# RUN --mount=type=secret,id=MY_SECRET MY_SECRET=$(cat /run/secrets/MY_SECRET) npm run build', - '', - '# For multiple secrets (inline environment variables):', - '# RUN --mount=type=secret,id=API_KEY --mount=type=secret,id=DB_URL \\', - '# API_KEY=$(cat /run/secrets/API_KEY) \\', - '# DB_URL=$(cat /run/secrets/DB_URL) \\', - '# npm run build', - '', - '# Note: Do NOT use export. Variables are set inline for the specific command only.', - '', - ]; - - // Find where to insert the comments (after FROM statement) - $fromIndex = $dockerfile->search(function ($line) { - return str_starts_with(trim(strtoupper($line)), 'FROM'); - }); - - if ($fromIndex !== false) { - // Insert comments after FROM statement - foreach (array_reverse($secretsComment) as $comment) { - $dockerfile->splice($fromIndex + 1, 0, [$comment]); - } - } - - $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), - 'hidden' => true, - ]); - } - private function modify_nixpacks_dockerfile_for_secrets($dockerfile_path) { // Only process if we have secrets to mount @@ -2810,36 +2762,25 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); - // Find all RUN commands and add secret mounts to them $modified = false; $dockerfile = $dockerfile->map(function ($line) use ($variables, &$modified) { $trim = ltrim($line); - // Only handle shell-form RUN; skip JSON-form and already-mounted lines - if (str_starts_with($trim, 'RUN') && !preg_match('/^RUN\s*\[/i', $trim) && !str_contains($line, '--mount=type=secret')) { - // Build the mount flags for all secrets + if (str_contains($line, '--mount=type=secret')) { + return $line; + } + + if (str_starts_with($trim, 'RUN')) { $mounts = []; foreach ($variables as $env) { - $mounts[] = "--mount=type=secret,id={$env->key}"; + $mounts[] = "--mount=type=secret,id={$env->key},env={$env->key}"; } if (! empty($mounts)) { - // Build inline environment variable assignments (no export) - $envAssignments = []; - foreach ($variables as $env) { - $envAssignments[] = "{$env->key}=\$(cat /run/secrets/{$env->key})"; - } - - // Replace RUN with RUN with mounts and inline env vars $mountString = implode(' ', $mounts); - $envString = implode(' ', $envAssignments); + $originalCommand = trim(substr($trim, 3)); - // Extract the original command - $originalCommand = trim(substr($trim, 3)); // Remove 'RUN' - - // Create the new RUN command with mounts and inline environment variables - // Format: RUN --mount=secret,id=X --mount=secret,id=Y KEY1=$(cat...) KEY2=$(cat...) original_command - $line = "RUN {$mountString} {$envString} {$originalCommand}"; + $line = "RUN {$mountString} {$originalCommand}"; $modified = true; } } @@ -2870,28 +2811,21 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); return $composeFile; } - // Add top-level secrets definition $secrets = []; foreach ($variables as $env) { - $safe_filename = preg_replace('/[^A-Za-z0-9._-]/', '_', (string) $env->key); $secrets[$env->key] = [ - 'file' => "{$this->secrets_dir}/{$safe_filename}", + 'environment' => $env->key, ]; } - // Add build.secrets to services that have a build context $services = data_get($composeFile, 'services', []); foreach ($services as $serviceName => &$service) { - // Only add secrets if the service has a build context defined if (isset($service['build'])) { - // Handle both string and array build configurations if (is_string($service['build'])) { - // Convert string build to array format $service['build'] = [ 'context' => $service['build'], ]; } - // Add secrets to build configuration if (! isset($service['build']['secrets'])) { $service['build']['secrets'] = []; } @@ -2903,13 +2837,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } - // Update the compose file $composeFile['services'] = $services; - // merge with existing secrets if present $existingSecrets = data_get($composeFile, 'secrets', []); + if ($existingSecrets instanceof \Illuminate\Support\Collection) { + $existingSecrets = $existingSecrets->toArray(); + } $composeFile['secrets'] = array_replace($existingSecrets, $secrets); - $this->application_deployment_queue->addLogEntry('Added build secrets configuration to docker-compose file.'); + $this->application_deployment_queue->addLogEntry('Added build secrets configuration to docker-compose file (using environment variables).'); return $composeFile; } From c1bee32f0991cc4192f7451665b39e35790d6edc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:34:38 +0200 Subject: [PATCH 028/103] feat(deployment): introduce 'use_build_secrets' setting for enhanced security during Docker builds and update related logic in deployment process --- app/Jobs/ApplicationDeploymentJob.php | 47 ++++++++++++------- .../Shared/EnvironmentVariable/All.php | 4 ++ ..._build_secrets_to_application_settings.php | 28 +++++++++++ .../shared/environment-variable/all.blade.php | 37 ++++++++++----- 4 files changed, 89 insertions(+), 27 deletions(-) create mode 100644 database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index a5a971ae5..cc2929f26 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -361,6 +361,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function detectBuildKitCapabilities(): void { + // If build secrets are not enabled, skip detection and use traditional args + if (! $this->application->settings->use_build_secrets) { + $this->dockerBuildkitSupported = false; + + return; + } + $serverToCheck = $this->use_build_server ? $this->build_server : $this->server; $serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})"; @@ -376,7 +383,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) { $this->dockerBuildkitSupported = false; - $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Using traditional build arguments."); + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+). Build secrets feature disabled."); return; } @@ -395,11 +402,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if (trim($buildkitTest) === 'supported') { $this->dockerBuildkitSupported = true; $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit secrets support detected on {$serverName}."); - $this->application_deployment_queue->addLogEntry('✓ Build secrets will be used for enhanced security during builds.'); + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); } else { $this->dockerBuildkitSupported = false; - $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support enabled."); - $this->application_deployment_queue->addLogEntry('⚠ Using traditional build arguments (less secure but compatible).'); + $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not have BuildKit secrets support."); + $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.'); } } else { // Buildx is available, which means BuildKit is available @@ -412,17 +419,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if (trim($secretsTest) === 'supported') { $this->dockerBuildkitSupported = true; $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}."); - $this->application_deployment_queue->addLogEntry('✓ Build secrets will be used for enhanced security during builds.'); + $this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.'); } else { $this->dockerBuildkitSupported = false; $this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with Buildx on {$serverName}, but secrets not supported."); - $this->application_deployment_queue->addLogEntry('⚠ Using traditional build arguments (less secure but compatible).'); + $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but not supported. Using traditional build arguments.'); } } } catch (\Exception $e) { $this->dockerBuildkitSupported = false; $this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}"); - $this->application_deployment_queue->addLogEntry('⚠ Using traditional build arguments as fallback.'); + $this->application_deployment_queue->addLogEntry('Build secrets feature is enabled but detection failed. Using traditional build arguments.'); } } @@ -555,7 +562,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->generate_image_names(); $this->cleanup_git(); - $this->detectBuildKitCapabilities(); $this->generate_build_env_variables(); $this->application->loadComposeFile(isInit: false); @@ -566,7 +572,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue // For raw compose, we cannot automatically add secrets configuration // User must define it manually in their docker-compose file - if ($this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { $this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.'); } } else { @@ -588,8 +594,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return; } - // Add build secrets to compose file if BuildKit is supported - if ($this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + // Add build secrets to compose file if enabled and BuildKit is supported + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { $composeFile = $this->add_build_secrets_to_compose($composeFile); } @@ -716,7 +722,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->dockerfile_location = $this->application->dockerfile_location; } $this->prepare_builder_image(); - $this->detectBuildKitCapabilities(); $this->check_git_if_build_needed(); $this->generate_image_names(); $this->clone_repository(); @@ -2336,11 +2341,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue executeInDocker($this->deployment_uuid, "cat {$this->workdir}/.nixpacks/Dockerfile"), 'hidden' => true, ]); - if ($this->dockerBuildkitSupported) { + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { // Modify the nixpacks Dockerfile to use build secrets $this->modify_nixpacks_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; + } elseif ($this->dockerBuildkitSupported) { + // BuildKit without secrets + $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; } else { $build_command = "docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile --progress plain -t {$this->build_image_name} {$this->build_args} {$this->workdir}"; } @@ -2649,10 +2657,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $variables = collect([])->merge($this->env_args); } - if ($this->dockerBuildkitSupported) { + // Check if build secrets are enabled and BuildKit is supported + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { $this->generate_build_secrets($variables); $this->build_args = ''; } else { + // Fall back to traditional build args $this->build_args = $variables->map(function ($value, $key) { $value = escapeshellarg($value); @@ -2663,6 +2673,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); private function generate_docker_env_flags_for_secrets() { + // Only generate env flags if build secrets are enabled + if (! $this->application->settings->use_build_secrets) { + return ''; + } + $variables = $this->pull_request_id === 0 ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); @@ -2737,8 +2752,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); private function modify_nixpacks_dockerfile_for_secrets($dockerfile_path) { - // Only process if we have secrets to mount - if (empty($this->build_secrets)) { + // Only process if build secrets are enabled and we have secrets to mount + if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) { return; } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 9429c5f25..a71400f4c 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -25,6 +25,8 @@ class All extends Component public bool $is_env_sorting_enabled = false; + public bool $use_build_secrets = false; + protected $listeners = [ 'saveKey' => 'submit', 'refreshEnvs', @@ -34,6 +36,7 @@ class All extends Component public function mount() { $this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false); + $this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false); $this->resourceClass = get_class($this->resource); $resourceWithPreviews = [\App\Models\Application::class]; $simpleDockerfile = filled(data_get($this->resource, 'dockerfile')); @@ -49,6 +52,7 @@ class All extends Component $this->authorize('manageEnvironment', $this->resource); $this->resource->settings->is_env_sorting_enabled = $this->is_env_sorting_enabled; + $this->resource->settings->use_build_secrets = $this->use_build_secrets; $this->resource->settings->save(); $this->getDevView(); $this->dispatch('success', 'Environment variable settings updated.'); diff --git a/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php b/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php new file mode 100644 index 000000000..b78f391fc --- /dev/null +++ b/database/migrations/2025_09_17_081112_add_use_build_secrets_to_application_settings.php @@ -0,0 +1,28 @@ +boolean('use_build_secrets')->default(false)->after('is_build_server_enabled'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('application_settings', function (Blueprint $table) { + $table->dropColumn('use_build_secrets'); + }); + } +}; diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 4518420dd..61e496d12 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -13,17 +13,32 @@ @endcan
Environment variables (secrets) for this resource.
- @if ($resourceClass === 'App\Models\Application' && data_get($resource, 'build_pack') !== 'dockercompose') -
- @can('manageEnvironment', $resource) - - @else - - @endcan + @if ($resourceClass === 'App\Models\Application') +
+ @if (data_get($resource, 'build_pack') !== 'dockercompose') +
+ @can('manageEnvironment', $resource) + + @else + + @endcan +
+ @endif +
+ @can('manageEnvironment', $resource) + + @else + + @endcan +
@endif @if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') From d7a7bac3f16804cbae1ab5ef52717c46d3b23213 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 17 Sep 2025 15:18:26 +0200 Subject: [PATCH 029/103] refactor(deployment): optimize BuildKit capabilities detection and remove unnecessary comments for cleaner deployment logic --- app/Jobs/ApplicationDeploymentJob.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index cc2929f26..bf9556d5d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -276,9 +276,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue try { // Make sure the private key is stored in the filesystem $this->server->privateKey->storeInFileSystem(); - - $this->detectBuildKitCapabilities(); - // Generate custom host<->ip mapping $allContainers = instant_remote_process(["docker network inspect {$this->destination->network} -f '{{json .Containers}}' "], $this->server); @@ -334,6 +331,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->build_server = $this->server; $this->original_server = $this->server; } + $this->detectBuildKitCapabilities(); $this->decide_what_to_do(); } catch (Exception $e) { if ($this->pull_request_id !== 0 && $this->application->is_github_based()) { @@ -1421,7 +1419,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } $this->build_image(); $this->push_to_docker_registry(); - // $this->stop_running_container(); $this->rolling_update(); } @@ -1466,7 +1463,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); $env_flags = $this->generate_docker_env_flags_for_secrets(); - ray($env_flags); + if ($this->use_build_server) { if ($this->dockerConfigFileExists === 'NOK') { throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); @@ -2713,7 +2710,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); private function add_build_env_variables_to_dockerfile() { if ($this->dockerBuildkitSupported) { - // $this->add_buildkit_secrets_to_dockerfile(); + // We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets } else { $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), From 844a67a006c3478f6bc31840476fbcee895169cb Mon Sep 17 00:00:00 2001 From: Yihang Wang Date: Thu, 18 Sep 2025 00:40:09 +0800 Subject: [PATCH 030/103] fix: hide sensitive email change fields in team member responses --- app/Http/Controllers/Api/TeamController.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index d4b24d8ab..e12d83542 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -179,6 +179,8 @@ class TeamController extends Controller $members = $team->members; $members->makeHidden([ 'pivot', + 'email_change_code', + 'email_change_code_expires_at', ]); return response()->json( @@ -264,6 +266,8 @@ class TeamController extends Controller $team = auth()->user()->currentTeam(); $team->members->makeHidden([ 'pivot', + 'email_change_code', + 'email_change_code_expires_at', ]); return response()->json( From 1f4255ef41bec2dd4c932ebbf02fd7a83a59924b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 17 Sep 2025 18:46:10 +0200 Subject: [PATCH 031/103] refactor(deployment): rename method for modifying Dockerfile to improve clarity and streamline build secrets integration --- app/Jobs/ApplicationDeploymentJob.php | 68 +++++++++++++-------------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index bf9556d5d..75f1e1568 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2340,7 +2340,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ]); if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { // Modify the nixpacks Dockerfile to use build secrets - $this->modify_nixpacks_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; } elseif ($this->dockerBuildkitSupported) { @@ -2359,7 +2359,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ]); if ($this->dockerBuildkitSupported) { // Modify the nixpacks Dockerfile to use build secrets - $this->modify_nixpacks_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->build_image_name} {$this->workdir}"; } else { @@ -2385,8 +2385,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm /artifacts/thegameplan.json'), 'hidden' => true]); } else { // Dockerfile buildpack - if ($this->dockerBuildkitSupported) { - // Use BuildKit with secrets + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$this->destination->network} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}"; @@ -2456,8 +2457,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } else { // Pure Dockerfile based deployment if ($this->application->dockerfile) { - if ($this->dockerBuildkitSupported) { - // Use BuildKit with secrets (only if secrets exist) + if ($this->dockerBuildkitSupported && $this->application->settings->use_build_secrets) { + // Modify the Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache --pull {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; @@ -2501,7 +2503,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ]); if ($this->dockerBuildkitSupported) { // Modify the nixpacks Dockerfile to use build secrets - $this->modify_nixpacks_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $build_command = "DOCKER_BUILDKIT=1 docker build --no-cache {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; } else { @@ -2517,7 +2519,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ]); if ($this->dockerBuildkitSupported) { // Modify the nixpacks Dockerfile to use build secrets - $this->modify_nixpacks_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); + $this->modify_dockerfile_for_secrets("{$this->workdir}/.nixpacks/Dockerfile"); $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $build_command = "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/.nixpacks/Dockerfile{$secrets_flags} --progress plain -t {$this->production_image_name} {$this->workdir}"; } else { @@ -2747,57 +2749,53 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } - private function modify_nixpacks_dockerfile_for_secrets($dockerfile_path) + private function modify_dockerfile_for_secrets($dockerfile_path) { // Only process if build secrets are enabled and we have secrets to mount if (! $this->application->settings->use_build_secrets || empty($this->build_secrets)) { return; } - // Read the nixpacks-generated Dockerfile + // Read the Dockerfile $this->execute_remote_command([ executeInDocker($this->deployment_uuid, "cat {$dockerfile_path}"), 'hidden' => true, - 'save' => 'nixpacks_dockerfile', + 'save' => 'dockerfile_content', ]); - $dockerfile = collect(str($this->saved_outputs->get('nixpacks_dockerfile'))->trim()->explode("\n")); + $dockerfile = str($this->saved_outputs->get('dockerfile_content'))->trim()->explode("\n"); // Add BuildKit syntax directive if not present - $firstLine = $dockerfile->first(); - if (! str_starts_with($firstLine, '# syntax=')) { + if (! str_starts_with($dockerfile->first(), '# syntax=')) { $dockerfile->prepend('# syntax=docker/dockerfile:1'); } - // Get the list of available secrets + // Get environment variables for secrets $variables = $this->pull_request_id === 0 ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); - $modified = false; - $dockerfile = $dockerfile->map(function ($line) use ($variables, &$modified) { - $trim = ltrim($line); + if ($variables->isEmpty()) { + return; + } - if (str_contains($line, '--mount=type=secret')) { + // Generate mount strings for all secrets + $mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' '); + + $modified = false; + $dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) { + $trimmed = ltrim($line); + + // Skip lines that already have secret mounts or are not RUN commands + if (str_contains($line, '--mount=type=secret') || ! str_starts_with($trimmed, 'RUN')) { return $line; } - if (str_starts_with($trim, 'RUN')) { - $mounts = []; - foreach ($variables as $env) { - $mounts[] = "--mount=type=secret,id={$env->key},env={$env->key}"; - } + // Add mount strings to RUN command + $originalCommand = trim(substr($trimmed, 3)); + $modified = true; - if (! empty($mounts)) { - $mountString = implode(' ', $mounts); - $originalCommand = trim(substr($trim, 3)); - - $line = "RUN {$mountString} {$originalCommand}"; - $modified = true; - } - } - - return $line; + return "RUN {$mountStrings} {$originalCommand}"; }); if ($modified) { @@ -2808,7 +2806,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); 'hidden' => true, ]); - $this->application_deployment_queue->addLogEntry('Modified Dockerfile to use build secrets: '.$dockerfile->implode("\n"), hidden: true); + $this->application_deployment_queue->addLogEntry('Modified Dockerfile to use build secrets.'); } } From b34dc11d8ed9040791b5a11c6bc7f0c083493fbe Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:30:49 +0200 Subject: [PATCH 032/103] fix(deployment): prevent removal of running containers for pull request deployments in case of failure --- app/Jobs/ApplicationDeploymentJob.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 75f1e1568..3d2fd5b04 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2979,8 +2979,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $code = $exception->getCode(); if ($code !== 69420) { // 69420 means failed to push the image to the registry, so we don't need to remove the new version as it is the currently running one - if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) { - // do not remove already running container + if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0) { + // do not remove already running container for PR deployments } else { $this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr'); $this->execute_remote_command( From c1799bdae60927fc05561267d635c1aa83b44562 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 18 Sep 2025 12:51:03 +0200 Subject: [PATCH 033/103] fix(docker): redirect stderr to stdout for container log retrieval to capture error messages --- bootstrap/helpers/docker.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index f61abc806..1491e4712 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1093,11 +1093,11 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100 { if ($server->isSwarm()) { $output = instant_remote_process([ - "docker service logs -n {$lines} {$container_id}", + "docker service logs -n {$lines} {$container_id} 2>&1", ], $server); } else { $output = instant_remote_process([ - "docker logs -n {$lines} {$container_id}", + "docker logs -n {$lines} {$container_id} 2>&1", ], $server); } @@ -1105,7 +1105,6 @@ function getContainerLogs(Server $server, string $container_id, int $lines = 100 return $output; } - function escapeEnvVariables($value) { $search = ['\\', "\r", "\t", "\x0", '"', "'"]; From 074c70c8ab5ea3802cae343b34a4840c4e4cc97c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:44:56 +0200 Subject: [PATCH 034/103] fix(clone): update destinations method call to ensure correct retrieval of selected destination --- app/Livewire/Project/CloneMe.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index a4f50ee06..3b3e42619 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -127,7 +127,7 @@ class CloneMe extends Component $databases = $this->environment->databases(); $services = $this->environment->services; foreach ($applications as $application) { - $selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations)->where('id', $this->selectedDestination)->first(); + $selectedDestination = $this->servers->flatMap(fn ($server) => $server->destinations())->where('id', $this->selectedDestination)->first(); clone_application($application, $selectedDestination, [ 'environment_id' => $environment->id, ], $this->cloneVolumeData); From 027aa0fffffb064f0012c1a028c1b1148cd17bd2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:45:29 +0200 Subject: [PATCH 035/103] chore(versions): bump coolify version to 4.0.0-beta.429 and nightly version to 4.0.0-beta.430 --- 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 0f3f928b8..224f2dfb5 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.428', + 'version' => '4.0.0-beta.429', 'helper_version' => '1.0.11', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/versions.json b/versions.json index fd5dccaf0..2379f2cd7 100644 --- a/versions.json +++ b/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.428" + "version": "4.0.0-beta.429" }, "nightly": { - "version": "4.0.0-beta.429" + "version": "4.0.0-beta.430" }, "helper": { "version": "1.0.11" From f515870f36a4275eaff388acd87c4488e8359590 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:51:08 +0200 Subject: [PATCH 036/103] fix(docker): enhance container status aggregation to include restarting and exited states --- app/Actions/Docker/GetContainersStatus.php | 24 +++++- app/Actions/Shared/ComplexStatusCheck.php | 94 +++++++++++++++++++--- openapi.json | 5 +- openapi.yaml | 4 +- 4 files changed, 112 insertions(+), 15 deletions(-) diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index ad7c4a606..f5d5f82b6 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -96,7 +96,11 @@ class GetContainersStatus } $containerStatus = data_get($container, 'State.Status'); $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); - $containerStatus = "$containerStatus ($containerHealth)"; + if ($containerStatus === 'restarting') { + $containerStatus = "restarting ($containerHealth)"; + } else { + $containerStatus = "$containerStatus ($containerHealth)"; + } $labels = Arr::undot(format_docker_labels_to_json($labels)); $applicationId = data_get($labels, 'coolify.applicationId'); if ($applicationId) { @@ -386,19 +390,33 @@ class GetContainersStatus return null; } - // Aggregate status: if any container is running, app is running $hasRunning = false; + $hasRestarting = false; $hasUnhealthy = false; + $hasExited = false; foreach ($relevantStatuses as $status) { - if (str($status)->contains('running')) { + if (str($status)->contains('restarting')) { + $hasRestarting = true; + } elseif (str($status)->contains('running')) { $hasRunning = true; if (str($status)->contains('unhealthy')) { $hasUnhealthy = true; } + } elseif (str($status)->contains('exited')) { + $hasExited = true; + $hasUnhealthy = true; } } + if ($hasRestarting) { + return 'degraded (unhealthy)'; + } + + if ($hasRunning && $hasExited) { + return 'degraded (unhealthy)'; + } + if ($hasRunning) { return $hasUnhealthy ? 'running (unhealthy)' : 'running (healthy)'; } diff --git a/app/Actions/Shared/ComplexStatusCheck.php b/app/Actions/Shared/ComplexStatusCheck.php index 5a7ba6637..e06136e3c 100644 --- a/app/Actions/Shared/ComplexStatusCheck.php +++ b/app/Actions/Shared/ComplexStatusCheck.php @@ -26,22 +26,22 @@ class ComplexStatusCheck continue; } } - $container = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false); - $container = format_docker_command_output_to_json($container); - if ($container->count() === 1) { - $container = $container->first(); - $containerStatus = data_get($container, 'State.Status'); - $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); + $containers = instant_remote_process(["docker container inspect $(docker container ls -q --filter 'label=coolify.applicationId={$application->id}' --filter 'label=coolify.pullRequestId=0') --format '{{json .}}'"], $server, false); + $containers = format_docker_command_output_to_json($containers); + + if ($containers->count() > 0) { + $statusToSet = $this->aggregateContainerStatuses($application, $containers); + if ($is_main_server) { $statusFromDb = $application->status; - if ($statusFromDb !== $containerStatus) { - $application->update(['status' => "$containerStatus:$containerHealth"]); + if ($statusFromDb !== $statusToSet) { + $application->update(['status' => $statusToSet]); } } else { $additional_server = $application->additional_servers()->wherePivot('server_id', $server->id); $statusFromDb = $additional_server->first()->pivot->status; - if ($statusFromDb !== $containerStatus) { - $additional_server->updateExistingPivot($server->id, ['status' => "$containerStatus:$containerHealth"]); + if ($statusFromDb !== $statusToSet) { + $additional_server->updateExistingPivot($server->id, ['status' => $statusToSet]); } } } else { @@ -57,4 +57,78 @@ class ComplexStatusCheck } } } + + private function aggregateContainerStatuses($application, $containers) + { + $dockerComposeRaw = data_get($application, 'docker_compose_raw'); + $excludedContainers = collect(); + + if ($dockerComposeRaw) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw); + $services = data_get($dockerCompose, 'services', []); + + foreach ($services as $serviceName => $serviceConfig) { + $excludeFromHc = data_get($serviceConfig, 'exclude_from_hc', false); + $restartPolicy = data_get($serviceConfig, 'restart', 'always'); + + if ($excludeFromHc || $restartPolicy === 'no') { + $excludedContainers->push($serviceName); + } + } + } catch (\Exception $e) { + // If we can't parse, treat all containers as included + } + } + + $hasRunning = false; + $hasRestarting = false; + $hasUnhealthy = false; + $hasExited = false; + $relevantContainerCount = 0; + + foreach ($containers as $container) { + $labels = data_get($container, 'Config.Labels', []); + $serviceName = data_get($labels, 'com.docker.compose.service'); + + if ($serviceName && $excludedContainers->contains($serviceName)) { + continue; + } + + $relevantContainerCount++; + $containerStatus = data_get($container, 'State.Status'); + $containerHealth = data_get($container, 'State.Health.Status', 'unhealthy'); + + if ($containerStatus === 'restarting') { + $hasRestarting = true; + $hasUnhealthy = true; + } elseif ($containerStatus === 'running') { + $hasRunning = true; + if ($containerHealth === 'unhealthy') { + $hasUnhealthy = true; + } + } elseif ($containerStatus === 'exited') { + $hasExited = true; + $hasUnhealthy = true; + } + } + + if ($relevantContainerCount === 0) { + return 'running:healthy'; + } + + if ($hasRestarting) { + return 'degraded:unhealthy'; + } + + if ($hasRunning && $hasExited) { + return 'degraded:unhealthy'; + } + + if ($hasRunning) { + return $hasUnhealthy ? 'running:unhealthy' : 'running:healthy'; + } + + return 'exited:unhealthy'; + } } diff --git a/openapi.json b/openapi.json index d5b3b14c4..2b0a81c6e 100644 --- a/openapi.json +++ b/openapi.json @@ -8360,7 +8360,10 @@ "is_preview": { "type": "boolean" }, - "is_buildtime_only": { + "is_runtime": { + "type": "boolean" + }, + "is_buildtime": { "type": "boolean" }, "is_shared": { diff --git a/openapi.yaml b/openapi.yaml index 69848d99a..9529fcf87 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5411,7 +5411,9 @@ components: type: boolean is_preview: type: boolean - is_buildtime_only: + is_runtime: + type: boolean + is_buildtime: type: boolean is_shared: type: boolean From f33df13c4eebe15c2c08500bf4a22860820a5d52 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 18 Sep 2025 18:14:54 +0200 Subject: [PATCH 037/103] feat(environment): replace is_buildtime_only with is_runtime and is_buildtime flags for environment variables, updating related logic and views --- .../Api/ApplicationsController.php | 40 +++++++---- .../Shared/EnvironmentVariable/Add.php | 16 +++-- .../Shared/EnvironmentVariable/All.php | 3 +- .../Shared/EnvironmentVariable/Show.php | 13 ++-- app/Models/Application.php | 6 +- app/Models/EnvironmentVariable.php | 34 +++++++++- ...ildtime_to_environment_variables_table.php | 67 +++++++++++++++++++ .../shared/environment-variable/add.blade.php | 13 ++-- .../environment-variable/show.blade.php | 50 +++++++++----- 9 files changed, 192 insertions(+), 50 deletions(-) create mode 100644 database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index b9c854ea1..cd640df17 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2532,8 +2532,11 @@ class ApplicationsController extends Controller if ($env->is_shown_once != $request->is_shown_once) { $env->is_shown_once = $request->is_shown_once; } - if ($request->has('is_buildtime_only') && $env->is_buildtime_only != $request->is_buildtime_only) { - $env->is_buildtime_only = $request->is_buildtime_only; + if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) { + $env->is_runtime = $request->is_runtime; + } + if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { + $env->is_buildtime = $request->is_buildtime; } $env->save(); @@ -2559,8 +2562,11 @@ class ApplicationsController extends Controller if ($env->is_shown_once != $request->is_shown_once) { $env->is_shown_once = $request->is_shown_once; } - if ($request->has('is_buildtime_only') && $env->is_buildtime_only != $request->is_buildtime_only) { - $env->is_buildtime_only = $request->is_buildtime_only; + if ($request->has('is_runtime') && $env->is_runtime != $request->is_runtime) { + $env->is_runtime = $request->is_runtime; + } + if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) { + $env->is_buildtime = $request->is_buildtime; } $env->save(); @@ -2723,8 +2729,11 @@ class ApplicationsController extends Controller if ($env->is_shown_once != $item->get('is_shown_once')) { $env->is_shown_once = $item->get('is_shown_once'); } - if ($item->has('is_buildtime_only') && $env->is_buildtime_only != $item->get('is_buildtime_only')) { - $env->is_buildtime_only = $item->get('is_buildtime_only'); + if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) { + $env->is_runtime = $item->get('is_runtime'); + } + if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { + $env->is_buildtime = $item->get('is_buildtime'); } $env->save(); } else { @@ -2735,7 +2744,8 @@ class ApplicationsController extends Controller 'is_literal' => $is_literal, 'is_multiline' => $is_multi_line, 'is_shown_once' => $is_shown_once, - 'is_buildtime_only' => $item->get('is_buildtime_only', false), + 'is_runtime' => $item->get('is_runtime', true), + 'is_buildtime' => $item->get('is_buildtime', true), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2753,8 +2763,11 @@ class ApplicationsController extends Controller if ($env->is_shown_once != $item->get('is_shown_once')) { $env->is_shown_once = $item->get('is_shown_once'); } - if ($item->has('is_buildtime_only') && $env->is_buildtime_only != $item->get('is_buildtime_only')) { - $env->is_buildtime_only = $item->get('is_buildtime_only'); + if ($item->has('is_runtime') && $env->is_runtime != $item->get('is_runtime')) { + $env->is_runtime = $item->get('is_runtime'); + } + if ($item->has('is_buildtime') && $env->is_buildtime != $item->get('is_buildtime')) { + $env->is_buildtime = $item->get('is_buildtime'); } $env->save(); } else { @@ -2765,7 +2778,8 @@ class ApplicationsController extends Controller 'is_literal' => $is_literal, 'is_multiline' => $is_multi_line, 'is_shown_once' => $is_shown_once, - 'is_buildtime_only' => $item->get('is_buildtime_only', false), + 'is_runtime' => $item->get('is_runtime', true), + 'is_buildtime' => $item->get('is_buildtime', true), 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2904,7 +2918,8 @@ class ApplicationsController extends Controller 'is_literal' => $request->is_literal ?? false, 'is_multiline' => $request->is_multiline ?? false, 'is_shown_once' => $request->is_shown_once ?? false, - 'is_buildtime_only' => $request->is_buildtime_only ?? false, + 'is_runtime' => $request->is_runtime ?? true, + 'is_buildtime' => $request->is_buildtime ?? true, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); @@ -2927,7 +2942,8 @@ class ApplicationsController extends Controller 'is_literal' => $request->is_literal ?? false, 'is_multiline' => $request->is_multiline ?? false, 'is_shown_once' => $request->is_shown_once ?? false, - 'is_buildtime_only' => $request->is_buildtime_only ?? false, + 'is_runtime' => $request->is_runtime ?? true, + 'is_buildtime' => $request->is_buildtime ?? true, 'resourceable_type' => get_class($application), 'resourceable_id' => $application->id, ]); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php index 9d5a5a39f..23a2cd59d 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php @@ -23,7 +23,9 @@ class Add extends Component public bool $is_literal = false; - public bool $is_buildtime_only = false; + public bool $is_runtime = true; + + public bool $is_buildtime = true; protected $listeners = ['clearAddEnv' => 'clear']; @@ -32,7 +34,8 @@ class Add extends Component 'value' => 'nullable', 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', - 'is_buildtime_only' => 'required|boolean', + 'is_runtime' => 'required|boolean', + 'is_buildtime' => 'required|boolean', ]; protected $validationAttributes = [ @@ -40,7 +43,8 @@ class Add extends Component 'value' => 'value', 'is_multiline' => 'multiline', 'is_literal' => 'literal', - 'is_buildtime_only' => 'buildtime only', + 'is_runtime' => 'runtime', + 'is_buildtime' => 'buildtime', ]; public function mount() @@ -56,7 +60,8 @@ class Add extends Component 'value' => $this->value, 'is_multiline' => $this->is_multiline, 'is_literal' => $this->is_literal, - 'is_buildtime_only' => $this->is_buildtime_only, + 'is_runtime' => $this->is_runtime, + 'is_buildtime' => $this->is_buildtime, 'is_preview' => $this->is_preview, ]); $this->clear(); @@ -68,6 +73,7 @@ class Add extends Component $this->value = ''; $this->is_multiline = false; $this->is_literal = false; - $this->is_buildtime_only = false; + $this->is_runtime = true; + $this->is_buildtime = true; } } diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index a71400f4c..639c025c7 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -221,7 +221,8 @@ class All extends Component $environment->value = $data['value']; $environment->is_multiline = $data['is_multiline'] ?? false; $environment->is_literal = $data['is_literal'] ?? false; - $environment->is_buildtime_only = $data['is_buildtime_only'] ?? false; + $environment->is_runtime = $data['is_runtime'] ?? true; + $environment->is_buildtime = $data['is_buildtime'] ?? true; $environment->is_preview = $data['is_preview'] ?? false; $environment->resourceable_id = $this->resource->id; $environment->resourceable_type = $this->resource->getMorphClass(); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index ab70b70f4..0d0467c13 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -38,7 +38,9 @@ class Show extends Component public bool $is_shown_once = false; - public bool $is_buildtime_only = false; + public bool $is_runtime = true; + + public bool $is_buildtime = true; public bool $is_required = false; @@ -58,7 +60,8 @@ class Show extends Component 'is_multiline' => 'required|boolean', 'is_literal' => 'required|boolean', 'is_shown_once' => 'required|boolean', - 'is_buildtime_only' => 'required|boolean', + 'is_runtime' => 'required|boolean', + 'is_buildtime' => 'required|boolean', 'real_value' => 'nullable', 'is_required' => 'required|boolean', ]; @@ -102,7 +105,8 @@ class Show extends Component } else { $this->validate(); $this->env->is_required = $this->is_required; - $this->env->is_buildtime_only = $this->is_buildtime_only; + $this->env->is_runtime = $this->is_runtime; + $this->env->is_buildtime = $this->is_buildtime; $this->env->is_shared = $this->is_shared; } $this->env->key = $this->key; @@ -117,7 +121,8 @@ class Show extends Component $this->is_multiline = $this->env->is_multiline; $this->is_literal = $this->env->is_literal; $this->is_shown_once = $this->env->is_shown_once; - $this->is_buildtime_only = $this->env->is_buildtime_only ?? false; + $this->is_runtime = $this->env->is_runtime ?? true; + $this->is_buildtime = $this->env->is_buildtime ?? true; $this->is_required = $this->env->is_required ?? false; $this->is_really_required = $this->env->is_really_required ?? false; $this->is_shared = $this->env->is_shared ?? false; diff --git a/app/Models/Application.php b/app/Models/Application.php index 1f48e0211..07df53687 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -932,11 +932,11 @@ class Application extends BaseModel public function isConfigurationChanged(bool $save = false) { - $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels); + $newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->custom_labels.$this->settings->use_build_secrets); if ($this->pull_request_id === 0 || $this->pull_request_id === null) { - $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal'])->sort()); + $newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } else { - $newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal'])->sort()); + $newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort()); } $newConfigHash = md5($newConfigHash); $oldConfigHash = data_get($this, 'config_hash'); diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 85fcdcecb..80399a16b 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -17,7 +17,8 @@ use OpenApi\Attributes as OA; 'is_literal' => ['type' => 'boolean'], 'is_multiline' => ['type' => 'boolean'], 'is_preview' => ['type' => 'boolean'], - 'is_buildtime_only' => ['type' => 'boolean'], + 'is_runtime' => ['type' => 'boolean'], + 'is_buildtime' => ['type' => 'boolean'], 'is_shared' => ['type' => 'boolean'], 'is_shown_once' => ['type' => 'boolean'], 'key' => ['type' => 'string'], @@ -37,13 +38,14 @@ class EnvironmentVariable extends BaseModel 'value' => 'encrypted', 'is_multiline' => 'boolean', 'is_preview' => 'boolean', - 'is_buildtime_only' => 'boolean', + 'is_runtime' => 'boolean', + 'is_buildtime' => 'boolean', 'version' => 'string', 'resourceable_type' => 'string', 'resourceable_id' => 'integer', ]; - protected $appends = ['real_value', 'is_shared', 'is_really_required']; + protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify']; protected static function booted() { @@ -137,6 +139,32 @@ class EnvironmentVariable extends BaseModel ); } + protected function isNixpacks(): Attribute + { + return Attribute::make( + get: function () { + if (str($this->key)->startsWith('NIXPACKS_')) { + return true; + } + + return false; + } + ); + } + + protected function isCoolify(): Attribute + { + return Attribute::make( + get: function () { + if (str($this->key)->startsWith('SERVICE_')) { + return true; + } + + return false; + } + ); + } + protected function isShared(): Attribute { return Attribute::make( diff --git a/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php b/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php new file mode 100644 index 000000000..6fd4bfed6 --- /dev/null +++ b/database/migrations/2025_09_18_080152_add_runtime_and_buildtime_to_environment_variables_table.php @@ -0,0 +1,67 @@ +boolean('is_runtime')->default(true)->after('is_buildtime_only'); + $table->boolean('is_buildtime')->default(true)->after('is_runtime'); + }); + + // Migrate existing data from is_buildtime_only to new fields + DB::table('environment_variables') + ->where('is_buildtime_only', true) + ->update([ + 'is_runtime' => false, + 'is_buildtime' => true, + ]); + + DB::table('environment_variables') + ->where('is_buildtime_only', false) + ->update([ + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + + // Remove the old is_buildtime_only column + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('is_buildtime_only'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + // Re-add the is_buildtime_only column + $table->boolean('is_buildtime_only')->default(false)->after('is_preview'); + }); + + // Restore data to is_buildtime_only based on new fields + DB::table('environment_variables') + ->where('is_runtime', false) + ->where('is_buildtime', true) + ->update(['is_buildtime_only' => true]); + + DB::table('environment_variables') + ->where('is_runtime', true) + ->update(['is_buildtime_only' => false]); + + // Remove new columns + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn(['is_runtime', 'is_buildtime']); + }); + } +}; diff --git a/resources/views/livewire/project/shared/environment-variable/add.blade.php b/resources/views/livewire/project/shared/environment-variable/add.blade.php index 5af9e6318..cd156634e 100644 --- a/resources/views/livewire/project/shared/environment-variable/add.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/add.blade.php @@ -3,15 +3,18 @@ - - @if (!$shared) - + @if (!$shared || $isNixpacks) + + @endif + + Save diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 688ddf7ee..6b2540b62 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -58,9 +58,12 @@
@if (!$is_redis_credential) @if ($type === 'service') - + + @else - - - @if ($is_multiline === false) - + @if (!$env->is_coolify) + + @if (!$env->is_nixpacks) + + + @if ($is_multiline === false) + + @endif + @endif @endif @endif @endif @@ -120,9 +130,12 @@
@if (!$is_redis_credential) @if ($type === 'service') - + + @else - + + @if ($is_multiline === false) Date: Thu, 18 Sep 2025 18:15:20 +0200 Subject: [PATCH 038/103] feat(deployment): handle buildtime and runtime variables during deployment --- app/Jobs/ApplicationDeploymentJob.php | 118 +++++++++++++++++++------- 1 file changed, 88 insertions(+), 30 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 3d2fd5b04..ae89649af 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -169,6 +169,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private bool $dockerBuildkitSupported = false; + private bool $skip_build = false; + private Collection|string $build_secrets; public function tags() @@ -566,7 +568,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if ($this->application->settings->is_raw_compose_deployment_enabled) { $this->application->oldRawParser(); $yaml = $composeFile = $this->application->docker_compose_raw; - $this->save_environment_variables(); + $this->generate_runtime_environment_variables(); // For raw compose, we cannot automatically add secrets configuration // User must define it manually in their docker-compose file @@ -575,7 +577,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } else { $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); - $this->save_environment_variables(); + $this->generate_runtime_environment_variables(); if (filled($this->env_filename)) { $services = collect(data_get($composeFile, 'services', [])); $services = $services->map(function ($service, $name) { @@ -759,6 +761,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->generate_compose_file(); $this->generate_build_env_variables(); $this->build_image(); + + // For Nixpacks, save runtime environment variables AFTER the build + // to prevent them from being accessible during the build process + $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } @@ -952,18 +958,17 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { if (str($this->saved_outputs->get('local_image_found'))->isNotEmpty()) { if ($this->is_this_additional_server) { + $this->skip_build = true; $this->application_deployment_queue->addLogEntry("Image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); $this->generate_compose_file(); $this->push_to_docker_registry(); $this->rolling_update(); - if ($this->restart_only) { - $this->post_deployment(); - } return true; } if (! $this->application->isConfigurationChanged()) { $this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped."); + $this->skip_build = true; $this->generate_compose_file(); $this->push_to_docker_registry(); $this->rolling_update(); @@ -1004,7 +1009,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } - private function save_environment_variables() + private function generate_runtime_environment_variables() { $envs = collect([]); $sort = $this->application->settings->is_env_sorting_enabled; @@ -1061,9 +1066,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } - // Filter out buildtime-only variables from runtime environment + // Filter runtime variables (only include variables that are available at runtime) $runtime_environment_variables = $sorted_environment_variables->filter(function ($env) { - return ! $env->is_buildtime_only; + return $env->is_runtime; }); // Sort runtime environment variables: those referencing SERVICE_ variables come after others @@ -1117,9 +1122,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } - // Filter out buildtime-only variables from runtime environment for preview + // Filter runtime variables for preview (only include variables that are available at runtime) $runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) { - return ! $env->is_buildtime_only; + return $env->is_runtime; }); // Sort runtime environment variables: those referencing SERVICE_ variables come after others @@ -1176,13 +1181,53 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } $this->env_filename = null; } else { - $envs_base64 = base64_encode($envs->implode("\n")); + // For Nixpacks builds, we save the .env file AFTER the build to prevent + // runtime-only variables from being accessible during the build process + if ($this->application->build_pack !== 'nixpacks' || $this->skip_build) { + $envs_base64 = base64_encode($envs->implode("\n")); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), + ], + + ); + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", + ] + ); + $this->server = $this->build_server; + } else { + $this->execute_remote_command( + [ + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", + ] + ); + } + } + } + $this->environment_variables = $envs; + } + + private function save_runtime_environment_variables() + { + // This method saves the .env file with runtime variables + // It should be called AFTER the build for Nixpacks to prevent runtime-only variables + // from being accessible during the build process + + if ($this->environment_variables && $this->environment_variables->isNotEmpty() && $this->env_filename) { + $envs_base64 = base64_encode($this->environment_variables->implode("\n")); + + // Write .env file to workdir (for container runtime) $this->execute_remote_command( [ executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), ], - ); + + // Write .env file to configuration directory if ($this->use_build_server) { $this->server = $this->original_server; $this->execute_remote_command( @@ -1199,7 +1244,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ); } } - $this->environment_variables = $envs; } private function elixir_finetunes() @@ -1418,6 +1462,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->add_build_env_variables_to_dockerfile(); } $this->build_image(); + // For Nixpacks, save runtime environment variables AFTER the build + if ($this->application->build_pack === 'nixpacks') { + $this->save_runtime_environment_variables(); + } $this->push_to_docker_registry(); $this->rolling_update(); } @@ -1681,6 +1729,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { $nixpacks_command = $this->nixpacks_build_cmd(); $this->application_deployment_queue->addLogEntry("Generating nixpacks configuration with: $nixpacks_command"); + $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $nixpacks_command), 'save' => 'nixpacks_plan', 'hidden' => true], [executeInDocker($this->deployment_uuid, "nixpacks detect {$this->workdir}"), 'save' => 'nixpacks_type', 'hidden' => true], @@ -1700,6 +1749,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $parsed = Toml::Parse($this->nixpacks_plan); // Do any modifications here + // We need to generate envs here because nixpacks need to know to generate a proper Dockerfile $this->generate_env_variables(); $merged_envs = collect(data_get($parsed, 'variables', []))->merge($this->env_args); $aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []); @@ -1872,13 +1922,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->env_args->put('SOURCE_COMMIT', $this->commit); $coolify_envs = $this->generate_coolify_env_variables(); - // Include ALL environment variables (both build-time and runtime) for all build packs - // This deprecates the need for is_build_time flag + // For build process, include only environment variables where is_buildtime = true if ($this->pull_request_id === 0) { - // Get all environment variables except NIXPACKS_ prefixed ones for non-nixpacks builds - $envs = $this->application->build_pack === 'nixpacks' - ? $this->application->runtime_environment_variables - : $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get(); + // Get environment variables that are marked as available during build + $envs = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); foreach ($envs as $env) { if (! is_null($env->real_value)) { @@ -1900,10 +1950,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } } else { - // Get all preview environment variables except NIXPACKS_ prefixed ones for non-nixpacks builds - $envs = $this->application->build_pack === 'nixpacks' - ? $this->application->runtime_environment_variables_preview - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + // Get preview environment variables that are marked as available during build + $envs = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); foreach ($envs as $env) { if (! is_null($env->real_value)) { @@ -1935,8 +1986,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->application->fileStorages()->get(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); - // $environment_variables = $this->generate_environment_variables($ports); - $this->save_environment_variables(); + $this->generate_runtime_environment_variables(); if (data_get($this->application, 'custom_labels')) { $this->application->parseContainerLabels(); $labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels))); @@ -2652,6 +2702,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if ($this->application->build_pack === 'nixpacks') { $variables = collect($this->nixpacks_plan_json->get('variables')); } else { + // Generate environment variables for build process (filters by is_buildtime = true) $this->generate_env_variables(); $variables = collect([])->merge($this->env_args); } @@ -2678,8 +2729,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); if ($variables->isEmpty()) { return ''; @@ -2722,7 +2773,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); if ($this->pull_request_id === 0) { - $envs = $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get(); + // Only add environment variables that are available during build + $envs = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); foreach ($envs as $env) { if (data_get($env, 'is_multiline') === true) { $dockerfile->splice(1, 0, ["ARG {$env->key}"]); @@ -2731,8 +2786,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } } else { - // Get all preview environment variables except NIXPACKS_ prefixed ones - $envs = $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + // Only add preview environment variables that are available during build + $envs = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); foreach ($envs as $env) { if (data_get($env, 'is_multiline') === true) { $dockerfile->splice(1, 0, ["ARG {$env->key}"]); From b0ff584ff4a08f990c0d1de8bbfa0127ec2c487a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 18 Sep 2025 18:17:37 +0200 Subject: [PATCH 039/103] fix(environment): correct grammatical errors in helper text for environment variable sorting checkbox --- .../project/shared/environment-variable/all.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 61e496d12..cee6b291d 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -19,11 +19,11 @@
@can('manageEnvironment', $resource) @else @endcan
From 711c16f0e6e14db62f59a7fef025c24aad3f25bc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 18 Sep 2025 18:25:36 +0200 Subject: [PATCH 040/103] refactor(environment): conditionally render Docker Build Secrets checkbox based on build pack type --- .../shared/environment-variable/all.blade.php | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index cee6b291d..6854ffaa4 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -28,17 +28,19 @@ @endcan
@endif -
- @can('manageEnvironment', $resource) - - @else - - @endcan -
+ @if (data_get($resource, 'build_pack') !== 'dockercompose') +
+ @can('manageEnvironment', $resource) + + @else + + @endcan +
+ @endif
@endif @if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') From 429c43f9e57c94aa703e9df62bf9513abd44e6c6 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Thu, 18 Sep 2025 19:13:45 +0200 Subject: [PATCH 041/103] chore: change order of runtime and buildtime --- .../project/shared/environment-variable/add.blade.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/add.blade.php b/resources/views/livewire/project/shared/environment-variable/add.blade.php index cd156634e..104cb8003 100644 --- a/resources/views/livewire/project/shared/environment-variable/add.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/add.blade.php @@ -4,11 +4,12 @@ @if (!$shared || $isNixpacks) - + From c0ddf73b753e54cf69396ec0581d2393fa5ce34a Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Thu, 18 Sep 2025 19:14:34 +0200 Subject: [PATCH 042/103] fix(ui): change order and fix ui on small screens --- .../environment-variable/show.blade.php | 171 +++++++++--------- 1 file changed, 84 insertions(+), 87 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 6b2540b62..d141463db 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -55,117 +55,114 @@
@endcan @can('update', $this->env) -
- @if (!$is_redis_credential) - @if ($type === 'service') - - - - - @else - @if ($is_shared) +
+
+ @if (!$is_redis_credential) + @if ($type === 'service') + + + @else - @if ($isSharedVariable) - + @if ($is_shared) + @else - @if (!$env->is_coolify) - - @if (!$env->is_nixpacks) - - - @if ($is_multiline === false) - + @if ($isSharedVariable) + + @else + @if (!$env->is_coolify) + @if (!$env->is_nixpacks) + + + @if ($is_multiline === false) + + @endif @endif + @endif @endif @endif @endif @endif - @endif -
- @if ($isDisabled) - - Update - - - Lock - - - @else - - Update - - - Lock - - - @endif +
+
+ @if ($isDisabled) + Update + Lock + + @else + Update + Lock + + @endif +
@else -
- @if (!$is_redis_credential) - @if ($type === 'service') - - +
+ @if (!$is_redis_credential) + @if ($type === 'service') + - - - @else - @if ($is_shared) + + + @else - @if ($isSharedVariable) - + @if ($is_shared) + @else - - - - @if ($is_multiline === false) - + @if ($isSharedVariable) + + @else + + + + @if ($is_multiline === false) + + @endif @endif @endif @endif @endif - @endif -
+
@endcan @endif From b64de1b5cd104952a4041720c591ed9ee8157b91 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Thu, 18 Sep 2025 19:56:46 +0200 Subject: [PATCH 043/103] fix: order for git deploy types --- .../project/shared/environment-variable/show.blade.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index d141463db..6598b66ff 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -83,6 +83,11 @@ + @endif + + @if (!$env->is_nixpacks) @if ($is_multiline === false) @endif @endif - @endif @endif @endif @@ -132,7 +134,6 @@ - Date: Fri, 19 Sep 2025 10:17:48 +0200 Subject: [PATCH 044/103] feat(search): implement global search functionality with caching and modal interface --- app/Livewire/GlobalSearch.php | 371 ++++++++++++++++++ app/Models/Application.php | 123 +++--- app/Models/Server.php | 3 +- app/Models/Service.php | 3 +- app/Models/StandaloneClickhouse.php | 8 +- app/Models/StandaloneDragonfly.php | 8 +- app/Models/StandaloneKeydb.php | 8 +- app/Models/StandaloneMariadb.php | 8 +- app/Models/StandaloneMongodb.php | 8 +- app/Models/StandaloneMysql.php | 8 +- app/Models/StandalonePostgresql.php | 8 +- app/Models/StandaloneRedis.php | 8 +- app/Traits/ClearsGlobalSearchCache.php | 53 +++ resources/views/components/navbar.blade.php | 27 +- .../views/livewire/global-search.blade.php | 236 +++++++++++ 15 files changed, 797 insertions(+), 83 deletions(-) create mode 100644 app/Livewire/GlobalSearch.php create mode 100644 app/Traits/ClearsGlobalSearchCache.php create mode 100644 resources/views/livewire/global-search.blade.php diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php new file mode 100644 index 000000000..3b3075fc9 --- /dev/null +++ b/app/Livewire/GlobalSearch.php @@ -0,0 +1,371 @@ +searchQuery = ''; + $this->isModalOpen = false; + $this->searchResults = []; + $this->allSearchableItems = []; + } + + public function openSearchModal() + { + $this->isModalOpen = true; + $this->loadSearchableItems(); + $this->dispatch('search-modal-opened'); + } + + public function closeSearchModal() + { + $this->isModalOpen = false; + $this->searchQuery = ''; + $this->searchResults = []; + } + + public static function getCacheKey($teamId) + { + return 'global_search_items_'.$teamId; + } + + public static function clearTeamCache($teamId) + { + Cache::forget(self::getCacheKey($teamId)); + } + + public function updatedSearchQuery() + { + $this->search(); + } + + private function loadSearchableItems() + { + // Try to get from Redis cache first + $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id); + + $this->allSearchableItems = Cache::remember($cacheKey, 300, function () { + $items = collect(); + $team = auth()->user()->currentTeam(); + + // Get all applications + $applications = Application::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($app) { + // Collect all FQDNs from the application + $fqdns = collect([]); + + // For regular applications + if ($app->fqdn) { + $fqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + } + + // For docker compose based applications + if ($app->build_pack === 'dockercompose' && $app->docker_compose_domains) { + try { + $composeDomains = json_decode($app->docker_compose_domains, true); + if (is_array($composeDomains)) { + foreach ($composeDomains as $serviceName => $domains) { + if (is_array($domains)) { + $fqdns = $fqdns->merge($domains); + } + } + } + } catch (\Exception $e) { + // Ignore JSON parsing errors + } + } + + $fqdnsString = $fqdns->implode(' '); + + return [ + 'id' => $app->id, + 'name' => $app->name, + 'type' => 'application', + 'uuid' => $app->uuid, + 'description' => $app->description, + 'link' => $app->link(), + 'project' => $app->environment->project->name ?? null, + 'environment' => $app->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($app->name.' '.$app->description.' '.$fqdnsString), + ]; + }); + + // Get all services + $services = Service::ownedByCurrentTeam() + ->with(['environment.project', 'applications']) + ->get() + ->map(function ($service) { + // Collect all FQDNs from service applications + $fqdns = collect([]); + foreach ($service->applications as $app) { + if ($app->fqdn) { + $appFqdns = collect(explode(',', $app->fqdn))->map(fn ($fqdn) => trim($fqdn)); + $fqdns = $fqdns->merge($appFqdns); + } + } + $fqdnsString = $fqdns->implode(' '); + + return [ + 'id' => $service->id, + 'name' => $service->name, + 'type' => 'service', + 'uuid' => $service->uuid, + 'description' => $service->description, + 'link' => $service->link(), + 'project' => $service->environment->project->name ?? null, + 'environment' => $service->environment->name ?? null, + 'fqdns' => $fqdns->take(2)->implode(', '), // Show first 2 FQDNs in UI + 'search_text' => strtolower($service->name.' '.$service->description.' '.$fqdnsString), + ]; + }); + + // Get all standalone databases + $databases = collect(); + + // PostgreSQL + $databases = $databases->merge( + StandalonePostgresql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'postgresql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' postgresql '.$db->description), + ]; + }) + ); + + // MySQL + $databases = $databases->merge( + StandaloneMysql::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mysql', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mysql '.$db->description), + ]; + }) + ); + + // MariaDB + $databases = $databases->merge( + StandaloneMariadb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mariadb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mariadb '.$db->description), + ]; + }) + ); + + // MongoDB + $databases = $databases->merge( + StandaloneMongodb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'mongodb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' mongodb '.$db->description), + ]; + }) + ); + + // Redis + $databases = $databases->merge( + StandaloneRedis::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'redis', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' redis '.$db->description), + ]; + }) + ); + + // KeyDB + $databases = $databases->merge( + StandaloneKeydb::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'keydb', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' keydb '.$db->description), + ]; + }) + ); + + // Dragonfly + $databases = $databases->merge( + StandaloneDragonfly::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'dragonfly', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' dragonfly '.$db->description), + ]; + }) + ); + + // Clickhouse + $databases = $databases->merge( + StandaloneClickhouse::ownedByCurrentTeam() + ->with(['environment.project']) + ->get() + ->map(function ($db) { + return [ + 'id' => $db->id, + 'name' => $db->name, + 'type' => 'database', + 'subtype' => 'clickhouse', + 'uuid' => $db->uuid, + 'description' => $db->description, + 'link' => $db->link(), + 'project' => $db->environment->project->name ?? null, + 'environment' => $db->environment->name ?? null, + 'search_text' => strtolower($db->name.' clickhouse '.$db->description), + ]; + }) + ); + + // Get all servers + $servers = Server::ownedByCurrentTeam() + ->get() + ->map(function ($server) { + return [ + 'id' => $server->id, + 'name' => $server->name, + 'type' => 'server', + 'uuid' => $server->uuid, + 'description' => $server->description, + 'link' => $server->url(), + 'project' => null, + 'environment' => null, + 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description), + ]; + }); + + // Merge all collections + $items = $items->merge($applications) + ->merge($services) + ->merge($databases) + ->merge($servers); + + return $items->toArray(); + }); + } + + private function search() + { + if (strlen($this->searchQuery) < 2) { + $this->searchResults = []; + + return; + } + + $query = strtolower($this->searchQuery); + + // Case-insensitive search in the items + $this->searchResults = collect($this->allSearchableItems) + ->filter(function ($item) use ($query) { + return str_contains($item['search_text'], $query); + }) + ->take(20) + ->values() + ->toArray(); + } + + public function render() + { + return view('livewire.global-search'); + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index 07df53687..094e5c82b 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\ApplicationDeploymentStatus; use App\Services\ConfigurationGenerator; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasConfiguration; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -110,7 +111,7 @@ use Visus\Cuid2\Cuid2; class Application extends BaseModel { - use HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasSafeStringAttribute, SoftDeletes; private static $parserVersion = '5'; @@ -123,66 +124,6 @@ class Application extends BaseModel 'http_basic_auth_password' => 'encrypted', ]; - public function customNetworkAliases(): Attribute - { - return Attribute::make( - set: function ($value) { - if (is_null($value) || $value === '') { - return null; - } - - // If it's already a JSON string, decode it - if (is_string($value) && $this->isJson($value)) { - $value = json_decode($value, true); - } - - // If it's a string but not JSON, treat it as a comma-separated list - if (is_string($value) && ! is_array($value)) { - $value = explode(',', $value); - } - - $value = collect($value) - ->map(function ($alias) { - if (is_string($alias)) { - return str_replace(' ', '-', trim($alias)); - } - - return null; - }) - ->filter() - ->unique() // Remove duplicate values - ->values() - ->toArray(); - - return empty($value) ? null : json_encode($value); - }, - get: function ($value) { - if (is_null($value)) { - return null; - } - - if (is_string($value) && $this->isJson($value)) { - return json_decode($value, true); - } - - return is_array($value) ? $value : []; - } - ); - } - - /** - * Check if a string is a valid JSON - */ - private function isJson($string) - { - if (! is_string($string)) { - return false; - } - json_decode($string); - - return json_last_error() === JSON_ERROR_NONE; - } - protected static function booted() { static::addGlobalScope('withRelations', function ($builder) { @@ -250,6 +191,66 @@ class Application extends BaseModel }); } + public function customNetworkAliases(): Attribute + { + return Attribute::make( + set: function ($value) { + if (is_null($value) || $value === '') { + return null; + } + + // If it's already a JSON string, decode it + if (is_string($value) && $this->isJson($value)) { + $value = json_decode($value, true); + } + + // If it's a string but not JSON, treat it as a comma-separated list + if (is_string($value) && ! is_array($value)) { + $value = explode(',', $value); + } + + $value = collect($value) + ->map(function ($alias) { + if (is_string($alias)) { + return str_replace(' ', '-', trim($alias)); + } + + return null; + }) + ->filter() + ->unique() // Remove duplicate values + ->values() + ->toArray(); + + return empty($value) ? null : json_encode($value); + }, + get: function ($value) { + if (is_null($value)) { + return null; + } + + if (is_string($value) && $this->isJson($value)) { + return json_decode($value, true); + } + + return is_array($value) ? $value : []; + } + ); + } + + /** + * Check if a string is a valid JSON + */ + private function isJson($string) + { + if (! is_string($string)) { + return false; + } + json_decode($string); + + return json_last_error() === JSON_ERROR_NONE; + } + public static function ownedByCurrentTeamAPI(int $teamId) { return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); diff --git a/app/Models/Server.php b/app/Models/Server.php index cc5315c6f..829a4b5aa 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -13,6 +13,7 @@ use App\Jobs\RegenerateSslCertJob; use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -55,7 +56,7 @@ use Visus\Cuid2\Cuid2; class Server extends BaseModel { - use HasFactory, SchemalessAttributesTrait, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, SchemalessAttributesTrait, SoftDeletes; public static $batch_counter = 0; diff --git a/app/Models/Service.php b/app/Models/Service.php index dd8d0ac7e..d42d471c6 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\ProcessStatus; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -41,7 +42,7 @@ use Visus\Cuid2\Cuid2; )] class Service extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; private static $parserVersion = '5'; diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 87c5c3422..146ee0a2d 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneClickhouse extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -43,6 +44,11 @@ class StandaloneClickhouse extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneClickhouse::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index 118c72726..90e7304f1 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneDragonfly extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -43,6 +44,11 @@ class StandaloneDragonfly extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneDragonfly::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 9d674b6c2..ad0cabf7e 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneKeydb extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -43,6 +44,11 @@ class StandaloneKeydb extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneKeydb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index 616d536c1..3d9e38147 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -10,7 +11,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMariadb extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -44,6 +45,11 @@ class StandaloneMariadb extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneMariadb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index b26b6c967..7cccd332a 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMongodb extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -46,6 +47,11 @@ class StandaloneMongodb extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneMongodb::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 7b6f1b94e..80269972f 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneMysql extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -44,6 +45,11 @@ class StandaloneMysql extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneMysql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index f13e6ffab..acde7a20c 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandalonePostgresql extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -44,6 +45,11 @@ class StandalonePostgresql extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandalonePostgresql::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + public function workdir() { return database_configuration_dir()."/{$this->uuid}"; diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index 9f7c96a08..001ebe36a 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class StandaloneRedis extends BaseModel { - use HasFactory, HasSafeStringAttribute, SoftDeletes; + use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes; protected $guarded = []; @@ -45,6 +46,11 @@ class StandaloneRedis extends BaseModel }); } + public static function ownedByCurrentTeam() + { + return StandaloneRedis::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name'); + } + protected function serverStatus(): Attribute { return Attribute::make( diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php new file mode 100644 index 000000000..fe6cbaa38 --- /dev/null +++ b/app/Traits/ClearsGlobalSearchCache.php @@ -0,0 +1,53 @@ +getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + }); + + static::created(function ($model) { + // Clear search cache when model is created + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + }); + + static::deleted(function ($model) { + // Clear search cache when model is deleted + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } + }); + } + + private function getTeamIdForCache() + { + // For database models, team is accessed through environment.project.team + if (method_exists($this, 'team')) { + $team = $this->team(); + if (filled($team)) { + return is_object($team) ? $team->id : null; + } + } + + // For models with direct team_id property + if (property_exists($this, 'team_id') || isset($this->team_id)) { + return $this->team_id; + } + + return null; + } +} diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index f61ea681e..1c5987e82 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -59,20 +59,20 @@ if (this.zoom === '90') { const style = document.createElement('style'); style.textContent = ` - html { - font-size: 93.75%; - } - - :root { - --vh: 1vh; - } - - @media (min-width: 1024px) { html { - font-size: 87.5%; + font-size: 93.75%; } - } - `; + + :root { + --vh: 1vh; + } + + @media (min-width: 1024px) { + html { + font-size: 87.5%; + } + } + `; document.head.appendChild(style); } } @@ -82,6 +82,9 @@
Coolify
+
+ +
diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php new file mode 100644 index 000000000..0792dadfb --- /dev/null +++ b/resources/views/livewire/global-search.blade.php @@ -0,0 +1,236 @@ +
+ +
+ +
+ + + +
From 575793709bfb256e922a1a58158dbc18fa46b974 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:22:24 +0200 Subject: [PATCH 045/103] feat(search): enable query logging for global search caching --- app/Livewire/GlobalSearch.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 3b3075fc9..dacc0d4db 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -69,6 +69,7 @@ class GlobalSearch extends Component $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id); $this->allSearchableItems = Cache::remember($cacheKey, 300, function () { + ray()->showQueries(); $items = collect(); $team = auth()->user()->currentTeam(); From f2236236039f966b856d9833f14dbf621d7e7a24 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:22:31 +0200 Subject: [PATCH 046/103] refactor(search): optimize cache clearing logic to only trigger on searchable field changes --- app/Traits/ClearsGlobalSearchCache.php | 42 +++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/app/Traits/ClearsGlobalSearchCache.php b/app/Traits/ClearsGlobalSearchCache.php index fe6cbaa38..0bcc5d319 100644 --- a/app/Traits/ClearsGlobalSearchCache.php +++ b/app/Traits/ClearsGlobalSearchCache.php @@ -8,16 +8,18 @@ trait ClearsGlobalSearchCache { protected static function bootClearsGlobalSearchCache() { - static::saved(function ($model) { - // Clear search cache when model is saved - $teamId = $model->getTeamIdForCache(); - if (filled($teamId)) { - GlobalSearch::clearTeamCache($teamId); + static::saving(function ($model) { + // Only clear cache if searchable fields are being changed + if ($model->hasSearchableChanges()) { + $teamId = $model->getTeamIdForCache(); + if (filled($teamId)) { + GlobalSearch::clearTeamCache($teamId); + } } }); static::created(function ($model) { - // Clear search cache when model is created + // Always clear cache when model is created $teamId = $model->getTeamIdForCache(); if (filled($teamId)) { GlobalSearch::clearTeamCache($teamId); @@ -25,7 +27,7 @@ trait ClearsGlobalSearchCache }); static::deleted(function ($model) { - // Clear search cache when model is deleted + // Always clear cache when model is deleted $teamId = $model->getTeamIdForCache(); if (filled($teamId)) { GlobalSearch::clearTeamCache($teamId); @@ -33,6 +35,32 @@ trait ClearsGlobalSearchCache }); } + private function hasSearchableChanges(): bool + { + // Define searchable fields based on model type + $searchableFields = ['name', 'description']; + + // Add model-specific searchable fields + if ($this instanceof \App\Models\Application) { + $searchableFields[] = 'fqdn'; + $searchableFields[] = 'docker_compose_domains'; + } elseif ($this instanceof \App\Models\Server) { + $searchableFields[] = 'ip'; + } elseif ($this instanceof \App\Models\Service) { + // Services don't have direct fqdn, but name and description are covered + } + // Database models only have name and description as searchable + + // Check if any searchable field is dirty + foreach ($searchableFields as $field) { + if ($this->isDirty($field)) { + return true; + } + } + + return false; + } + private function getTeamIdForCache() { // For database models, team is accessed through environment.project.team From 65f24de101b4764505662534ec83ded4bc40d57e Mon Sep 17 00:00:00 2001 From: ShadowArcanist Date: Fri, 19 Sep 2025 16:26:11 +0530 Subject: [PATCH 047/103] Changed Sentinel metrics color from yellow to blue + cyan (cpu + memory) --- resources/views/layouts/base.blade.php | 9 ++- .../livewire/project/shared/metrics.blade.php | 74 ++++++++++--------- .../views/livewire/server/charts.blade.php | 60 +++++++-------- 3 files changed, 75 insertions(+), 68 deletions(-) diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index ebb134324..c074412d3 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -138,7 +138,8 @@ } } let theme = localStorage.theme - let baseColor = '#FCD452' + let cpuColor = '#1e90ff' + let ramColor = '#00ced1' let textColor = '#ffffff' let editorBackground = '#181818' let editorTheme = 'blackboard' @@ -149,12 +150,14 @@ theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' } if (theme == 'dark') { - baseColor = '#FCD452' + cpuColor = '#1e90ff' + ramColor = '#00ced1' textColor = '#ffffff' editorBackground = '#181818' editorTheme = 'blackboard' } else { - baseColor = 'black' + cpuColor = '#1e90ff' + ramColor = '#00ced1' textColor = '#000000' editorBackground = '#ffffff' editorTheme = null diff --git a/resources/views/livewire/project/shared/metrics.blade.php b/resources/views/livewire/project/shared/metrics.blade.php index cfe83ded6..d6609d9e6 100644 --- a/resources/views/livewire/project/shared/metrics.blade.php +++ b/resources/views/livewire/project/shared/metrics.blade.php @@ -34,6 +34,7 @@ const optionsServerCpu = { stroke: { curve: 'straight', + width: 2, }, chart: { height: '150px', @@ -68,16 +69,16 @@ enabled: false, } }, - grid: { - show: true, - borderColor: '', - }, - colors: [baseColor], - xaxis: { - type: 'datetime', - }, - series: [{ - name: "CPU %", + grid: { + show: true, + borderColor: '', + }, + colors: [cpuColor], + xaxis: { + type: 'datetime', + }, + series: [{ + name: "CPU %", data: [] }], noData: { @@ -101,11 +102,11 @@ document.addEventListener('livewire:init', () => { Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => { checkTheme(); - serverCpuChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [baseColor], + serverCpuChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [cpuColor], xaxis: { type: 'datetime', labels: { @@ -143,6 +144,7 @@ const optionsServerMemory = { stroke: { curve: 'straight', + width: 2, }, chart: { height: '150px', @@ -177,22 +179,22 @@ enabled: false, } }, - grid: { - show: true, - borderColor: '', - }, - colors: [baseColor], - xaxis: { - type: 'datetime', - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - series: [{ - name: "Memory (MB)", + grid: { + show: true, + borderColor: '', + }, + colors: [ramColor], + xaxis: { + type: 'datetime', + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + series: [{ + name: "Memory (MB)", data: [] }], noData: { @@ -217,11 +219,11 @@ document.addEventListener('livewire:init', () => { Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => { checkTheme(); - serverMemoryChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [baseColor], + serverMemoryChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [ramColor], xaxis: { type: 'datetime', labels: { diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index b84e0240f..f5a2418fd 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -27,6 +27,7 @@ const optionsServerCpu = { stroke: { curve: 'straight', + width: 2, }, chart: { height: '150px', @@ -61,16 +62,16 @@ enabled: false, } }, - grid: { - show: true, - borderColor: '', - }, - colors: [baseColor], - xaxis: { - type: 'datetime', - }, - series: [{ - name: 'CPU %', + grid: { + show: true, + borderColor: '', + }, + colors: [cpuColor], + xaxis: { + type: 'datetime', + }, + series: [{ + name: 'CPU %', data: [] }], noData: { @@ -95,11 +96,11 @@ document.addEventListener('livewire:init', () => { Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => { checkTheme(); - serverCpuChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [baseColor], + serverCpuChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [cpuColor], xaxis: { type: 'datetime', labels: { @@ -138,6 +139,7 @@ const optionsServerMemory = { stroke: { curve: 'straight', + width: 2, }, chart: { height: '150px', @@ -172,15 +174,15 @@ enabled: false, } }, - grid: { - show: true, - borderColor: '', - }, - colors: [baseColor], - xaxis: { - type: 'datetime', - labels: { - show: true, + grid: { + show: true, + borderColor: '', + }, + colors: [ramColor], + xaxis: { + type: 'datetime', + labels: { + show: true, style: { colors: textColor, } @@ -212,11 +214,11 @@ document.addEventListener('livewire:init', () => { Livewire.on('refreshChartData-{!! $chartId !!}-memory', (chartData) => { checkTheme(); - serverMemoryChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [baseColor], + serverMemoryChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [ramColor], xaxis: { type: 'datetime', labels: { From bfaefed1aea4864eb30e6c813a919279bae4e785 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:45:37 +0200 Subject: [PATCH 048/103] refactor(environment): streamline rendering of Docker Build Secrets checkbox and adjust layout for environment variable settings --- .../shared/environment-variable/all.blade.php | 24 ++++++------- .../environment-variable/show.blade.php | 34 +++++++++---------- 2 files changed, 27 insertions(+), 31 deletions(-) diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 6854ffaa4..cee6b291d 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -28,19 +28,17 @@ @endcan
@endif - @if (data_get($resource, 'build_pack') !== 'dockercompose') -
- @can('manageEnvironment', $resource) - - @else - - @endcan -
- @endif +
+ @can('manageEnvironment', $resource) + + @else + + @endcan +
@endif @if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 6598b66ff..953bc59fa 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -78,22 +78,20 @@ @if ($isSharedVariable) @else - @if (!$env->is_coolify) - @if (!$env->is_nixpacks) - - @endif - - @if (!$env->is_nixpacks) - - @if ($is_multiline === false) - - @endif + @if (!$env->is_nixpacks) + + @endif + + @if (!$env->is_nixpacks) + + @if ($is_multiline === false) + @endif @endif @endif @@ -129,8 +127,8 @@ @if (!$is_redis_credential) @if ($type === 'service') + helper="Make this variable available during Docker build process. Useful for build secrets and dependencies." + label="Available at Buildtime" /> From 593c1b476743b0129d7a346c8232d835ecb18600 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:46:00 +0200 Subject: [PATCH 049/103] fix(deployment): enhance Dockerfile modification for build-time variables and secrets during deployment in case of docker compose buildpack --- app/Jobs/ApplicationDeploymentJob.php | 172 +++++++++++++++++++++++++- 1 file changed, 170 insertions(+), 2 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index ae89649af..c880057e5 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -606,6 +606,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}{$this->docker_compose_location} > /dev/null"), 'hidden' => true, ]); + + // Modify Dockerfiles for ARGs and build secrets + $this->modify_dockerfiles_for_compose($composeFile); // Build new container to limit downtime. $this->application_deployment_queue->addLogEntry('Pulling & building required images.'); @@ -632,6 +635,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } else { $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull"; } + + if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) { + $build_args_string = $this->build_args->implode(' '); + $command .= " {$build_args_string}"; + $this->application_deployment_queue->addLogEntry('Adding build arguments to Docker Compose build command.'); + } + $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $command), 'hidden' => true], ); @@ -2830,8 +2840,8 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Get environment variables for secrets $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); if ($variables->isEmpty()) { return; @@ -2868,6 +2878,164 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } } + private function modify_dockerfiles_for_compose($composeFile) + { + if ($this->application->build_pack !== 'dockercompose') { + return; + } + + $variables = $this->pull_request_id === 0 + ? $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get() + : $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) + ->get(); + + if ($variables->isEmpty()) { + $this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.'); + + return; + } + + $services = data_get($composeFile, 'services', []); + + foreach ($services as $serviceName => $service) { + if (! isset($service['build'])) { + continue; + } + + $context = '.'; + $dockerfile = 'Dockerfile'; + + if (is_string($service['build'])) { + $context = $service['build']; + } elseif (is_array($service['build'])) { + $context = data_get($service['build'], 'context', '.'); + $dockerfile = data_get($service['build'], 'dockerfile', 'Dockerfile'); + } + + $dockerfilePath = rtrim($context, '/').'/'.ltrim($dockerfile, '/'); + if (str_starts_with($dockerfilePath, './')) { + $dockerfilePath = substr($dockerfilePath, 2); + } + if (str_starts_with($dockerfilePath, '/')) { + $dockerfilePath = substr($dockerfilePath, 1); + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/{$dockerfilePath} && echo 'exists' || echo 'not found'"), + 'hidden' => true, + 'save' => 'dockerfile_check_'.$serviceName, + ]); + + if (str($this->saved_outputs->get('dockerfile_check_'.$serviceName))->trim()->toString() !== 'exists') { + $this->application_deployment_queue->addLogEntry("Dockerfile not found for service {$serviceName} at {$dockerfilePath}, skipping ARG injection."); + + continue; + } + + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}/{$dockerfilePath}"), + 'hidden' => true, + 'save' => 'dockerfile_content_'.$serviceName, + ]); + + $dockerfileContent = $this->saved_outputs->get('dockerfile_content_'.$serviceName); + if (! $dockerfileContent) { + continue; + } + + $dockerfile_lines = collect(str($dockerfileContent)->trim()->explode("\n")); + + $fromIndices = []; + $dockerfile_lines->each(function ($line, $index) use (&$fromIndices) { + if (str($line)->trim()->startsWith('FROM')) { + $fromIndices[] = $index; + } + }); + + if (empty($fromIndices)) { + $this->application_deployment_queue->addLogEntry("No FROM instruction found in Dockerfile for service {$serviceName}, skipping."); + + continue; + } + + $isMultiStage = count($fromIndices) > 1; + + $argsToAdd = collect([]); + foreach ($variables as $env) { + $argsToAdd->push("ARG {$env->key}"); + } + + ray($argsToAdd); + if ($argsToAdd->isEmpty()) { + $this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add."); + + continue; + } + + $totalAdded = 0; + $offset = 0; + + foreach ($fromIndices as $stageIndex => $fromIndex) { + $adjustedIndex = $fromIndex + $offset; + + $stageStart = $adjustedIndex + 1; + $stageEnd = isset($fromIndices[$stageIndex + 1]) + ? $fromIndices[$stageIndex + 1] + $offset + : $dockerfile_lines->count(); + + $existingStageArgs = collect([]); + for ($i = $stageStart; $i < $stageEnd; $i++) { + $line = $dockerfile_lines->get($i); + if (! $line || ! str($line)->trim()->startsWith('ARG')) { + break; + } + $parts = explode(' ', trim($line), 2); + if (count($parts) >= 2) { + $argPart = $parts[1]; + $keyValue = explode('=', $argPart, 2); + $existingStageArgs->push($keyValue[0]); + } + } + + $stageArgsToAdd = $argsToAdd->filter(function ($arg) use ($existingStageArgs) { + $key = str($arg)->after('ARG ')->trim()->toString(); + + return ! $existingStageArgs->contains($key); + }); + + if ($stageArgsToAdd->isNotEmpty()) { + $dockerfile_lines->splice($adjustedIndex + 1, 0, $stageArgsToAdd->toArray()); + $totalAdded += $stageArgsToAdd->count(); + $offset += $stageArgsToAdd->count(); + } + } + + if ($totalAdded > 0) { + $dockerfile_base64 = base64_encode($dockerfile_lines->implode("\n")); + $this->execute_remote_command([ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}/{$dockerfilePath} > /dev/null"), + 'hidden' => true, + ]); + + $stageInfo = $isMultiStage ? ' (multi-stage build, added to '.count($fromIndices).' stages)' : ''; + $this->application_deployment_queue->addLogEntry("Added {$totalAdded} ARG declarations to Dockerfile for service {$serviceName}{$stageInfo}."); + } else { + $this->application_deployment_queue->addLogEntry("Service {$serviceName}: All required ARG declarations already exist."); + } + + if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported && ! empty($this->build_secrets)) { + $fullDockerfilePath = "{$this->workdir}/{$dockerfilePath}"; + $this->modify_dockerfile_for_secrets($fullDockerfilePath); + $this->application_deployment_queue->addLogEntry("Modified Dockerfile for service {$serviceName} to use build secrets."); + } + } + } + private function add_build_secrets_to_compose($composeFile) { // Get environment variables for secrets From 99fd4b424d186c6557c3f48aa43708935c827bef Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 14:17:10 +0200 Subject: [PATCH 050/103] feat(environment): add dynamic checkbox options for environment variable settings based on user permissions and variable types --- .../environment-variable/show.blade.php | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index 953bc59fa..a04b477d5 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -21,6 +21,95 @@ step2ButtonText="Permanently Delete" /> @endcan + @can('update', $this->env) +
+
+ @if (!$is_redis_credential) + @if ($type === 'service') + + + + + @else + @if ($is_shared) + + @else + @if ($isSharedVariable) + + @else + @if (!$env->is_nixpacks) + + @endif + + @if (!$env->is_nixpacks) + + @if ($is_multiline === false) + + @endif + @endif + @endif + @endif + @endif + @endif +
+
+ @else +
+
+ @if (!$is_redis_credential) + @if ($type === 'service') + + + + + @else + @if ($is_shared) + + @else + @if ($isSharedVariable) + + @else + + + + @if ($is_multiline === false) + + @endif + @endif + @endif + @endif + @endif +
+
+ @endcan @else @can('update', $this->env) @if ($isDisabled) From 3f48dcb5750011c4ab6db724988e170c1b2bb314 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 19 Sep 2025 15:54:44 +0200 Subject: [PATCH 051/103] feat(redaction): implement sensitive information redaction in logs and commands --- app/Models/ApplicationDeploymentQueue.php | 43 +++++++++++++++++++- app/Traits/ExecuteRemoteCommand.php | 48 +++++++++++++++++++++-- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 2a9bea67a..8df6877ab 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -85,6 +85,47 @@ class ApplicationDeploymentQueue extends Model return str($this->commit_message)->value(); } + private function redactSensitiveInfo($text) + { + $text = remove_iip($text); + + $app = $this->application; + if (! $app) { + return $text; + } + + $lockedVars = collect([]); + + if ($app->environment_variables) { + $lockedVars = $lockedVars->merge( + $app->environment_variables + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + if ($this->pull_request_id !== 0 && $app->environment_variables_preview) { + $lockedVars = $lockedVars->merge( + $app->environment_variables_preview + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + foreach ($lockedVars as $key => $value) { + $escapedValue = preg_quote($value, '/'); + $text = preg_replace( + '/'.$escapedValue.'/', + REDACTED, + $text + ); + } + + return $text; + } + public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false) { if ($type === 'error') { @@ -96,7 +137,7 @@ class ApplicationDeploymentQueue extends Model } $newLogEntry = [ 'command' => null, - 'output' => remove_iip($message), + 'output' => $this->redactSensitiveInfo($message), 'type' => $type, 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 0c3414efe..f9df19c16 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -17,6 +17,46 @@ trait ExecuteRemoteCommand public static int $batch_counter = 0; + private function redact_sensitive_info($text) + { + $text = remove_iip($text); + + if (! isset($this->application)) { + return $text; + } + + $lockedVars = collect([]); + + if (isset($this->application->environment_variables)) { + $lockedVars = $lockedVars->merge( + $this->application->environment_variables + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + if (isset($this->pull_request_id) && $this->pull_request_id !== 0 && isset($this->application->environment_variables_preview)) { + $lockedVars = $lockedVars->merge( + $this->application->environment_variables_preview + ->where('is_shown_once', true) + ->pluck('real_value', 'key') + ->filter() + ); + } + + foreach ($lockedVars as $key => $value) { + $escapedValue = preg_quote($value, '/'); + $text = preg_replace( + '/'.$escapedValue.'/', + REDACTED, + $text + ); + } + + return $text; + } + public function execute_remote_command(...$commands) { static::$batch_counter++; @@ -74,7 +114,7 @@ trait ExecuteRemoteCommand // Track SSH retry event in Sentry $this->trackSshRetryEvent($attempt, $maxRetries, $delay, $errorMessage, [ 'server' => $this->server->name ?? $this->server->ip ?? 'unknown', - 'command' => remove_iip($command), + 'command' => $this->redact_sensitive_info($command), 'trait' => 'ExecuteRemoteCommand', ]); @@ -125,8 +165,8 @@ trait ExecuteRemoteCommand $sanitized_output = sanitize_utf8_text($output); $new_log_entry = [ - 'command' => remove_iip($command), - 'output' => remove_iip($sanitized_output), + 'command' => $this->redact_sensitive_info($command), + 'output' => $this->redact_sensitive_info($sanitized_output), 'type' => $customType ?? $type === 'err' ? 'stderr' : 'stdout', 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, @@ -194,7 +234,7 @@ trait ExecuteRemoteCommand $retryMessage = "SSH connection failed. Retrying... (Attempt {$attempt}/{$maxRetries}, waiting {$delay}s)\nError: {$errorMessage}"; $new_log_entry = [ - 'output' => remove_iip($retryMessage), + 'output' => $this->redact_sensitive_info($retryMessage), 'type' => 'stdout', 'timestamp' => Carbon::now('UTC'), 'hidden' => false, From 0ef0247e14ac0f5a808b9a21600070fe0dc3917f Mon Sep 17 00:00:00 2001 From: ShadowArcanist Date: Fri, 19 Sep 2025 22:40:08 +0530 Subject: [PATCH 052/103] Improved metrics graph tooltip to show usage in a better way and added timestamp to the tooltip --- app/Livewire/Project/Shared/Metrics.php | 12 +- resources/css/utilities.css | 17 ++ .../livewire/project/shared/metrics.blade.php | 277 ++++++++++-------- .../views/livewire/server/charts.blade.php | 54 +++- 4 files changed, 222 insertions(+), 138 deletions(-) diff --git a/app/Livewire/Project/Shared/Metrics.php b/app/Livewire/Project/Shared/Metrics.php index fdc35fc0f..9dc944f9d 100644 --- a/app/Livewire/Project/Shared/Metrics.php +++ b/app/Livewire/Project/Shared/Metrics.php @@ -8,7 +8,7 @@ class Metrics extends Component { public $resource; - public $chartId = 'container-cpu'; + public $chartId = 'metrics'; public $data; @@ -33,6 +33,16 @@ class Metrics extends Component try { $cpuMetrics = $this->resource->getCpuMetrics($this->interval); $memoryMetrics = $this->resource->getMemoryMetrics($this->interval); + + // Debug logging + \Log::info('Metrics loadData called', [ + 'chartId' => $this->chartId, + 'cpuMetrics' => $cpuMetrics, + 'memoryMetrics' => $memoryMetrics, + 'cpuEvent' => "refreshChartData-{$this->chartId}-cpu", + 'memoryEvent' => "refreshChartData-{$this->chartId}-memory" + ]); + $this->dispatch("refreshChartData-{$this->chartId}-cpu", [ 'seriesData' => $cpuMetrics, ]); diff --git a/resources/css/utilities.css b/resources/css/utilities.css index d09d7f49c..65869e02f 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -10,6 +10,23 @@ @apply hidden!; } +@utility apexcharts-tooltip-custom { + @apply bg-white dark:bg-coolgray-100 border border-neutral-200 dark:border-coolgray-300 rounded-lg shadow-lg p-3 text-sm; + min-width: 160px; +} + +@utility apexcharts-tooltip-custom-value { + @apply text-neutral-700 dark:text-neutral-300 mb-1; +} + +@utility apexcharts-tooltip-value-bold { + @apply font-bold text-black dark:text-white; +} + +@utility apexcharts-tooltip-custom-title { + @apply text-xs text-neutral-500 dark:text-neutral-400 font-medium; +} + @utility input-sticky { @apply block py-1.5 w-full text-sm text-black rounded-sm border-0 ring-1 ring-inset dark:bg-coolgray-100 dark:text-white ring-neutral-200 dark:ring-coolgray-300 focus:ring-2 focus:ring-neutral-400 dark:focus:ring-coolgray-300; } diff --git a/resources/views/livewire/project/shared/metrics.blade.php b/resources/views/livewire/project/shared/metrics.blade.php index d6609d9e6..9b08babb3 100644 --- a/resources/views/livewire/project/shared/metrics.blade.php +++ b/resources/views/livewire/project/shared/metrics.blade.php @@ -1,21 +1,20 @@
-
+

Metrics

Basic metrics for your container.
- @if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose') -
Metrics are not available for Docker Compose applications yet!
- @elseif(!$resource->destination->server->isMetricsEnabled()) -
Metrics are only available for servers with Sentinel & Metrics enabled!
-
Go to Server settings to - enable - it.
- @else - @if (!str($resource->status)->contains('running')) -
Metrics are only available when this resource is running!
+
+ @if ($resource->getMorphClass() === 'App\Models\Application' && $resource->build_pack === 'dockercompose') +
Metrics are not available for Docker Compose applications yet!
+ @elseif(!$resource->destination->server->isMetricsEnabled()) +
Metrics are only available for servers with Sentinel & Metrics enabled!
+
Go to Server settings to enable it.
@else - + @if (!str($resource->status)->contains('running')) +
Metrics are only available when this resource is running!
+ @else +
+ @@ -77,63 +76,76 @@ xaxis: { type: 'datetime', }, - series: [{ - name: "CPU %", - data: [] - }], - noData: { - text: 'Loading...', - style: { - color: textColor, - } - }, - tooltip: { - enabled: true, - marker: { - show: false, - } - }, - legend: { - show: false - } + series: [{ + name: "CPU %", + 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 '
' + + '
CPU: ' + value + '%
' + + '
' + timeString + '
' + + '
'; + } + }, + legend: { + show: false + } } - const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu); - serverCpuChart.render(); - document.addEventListener('livewire:init', () => { - Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => { - checkTheme(); - serverCpuChart.updateOptions({ - series: [{ - data: chartData[0].seriesData, - }], - colors: [cpuColor], - xaxis: { - type: 'datetime', - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - yaxis: { - show: true, - labels: { - show: true, - style: { - colors: textColor, - } - } - }, - noData: { - text: 'Loading...', - style: { - color: textColor, - } - } - }); - }); - }); + const serverCpuChart = new ApexCharts(document.getElementById(`{!! $chartId !!}-cpu`), optionsServerCpu); + serverCpuChart.render(); + Livewire.on('refreshChartData-{!! $chartId !!}-cpu', (chartData) => { + checkTheme(); + serverCpuChart.updateOptions({ + series: [{ + data: chartData[0].seriesData, + }], + colors: [cpuColor], + xaxis: { + type: 'datetime', + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + yaxis: { + show: true, + labels: { + show: true, + style: { + colors: textColor, + } + } + }, + noData: { + text: 'Loading...', + style: { + color: textColor, + } + } + }); + });

Memory (MB)

@@ -195,65 +207,80 @@ }, 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 053/103] 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 @@ @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 054/103] 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 @@ }); -

Memory (MB)

+

Memory (MB)

-

Memory (MB)

+

Memory Usage

-

Memory (%)

+

Memory Usage