# Service & Database Deployment Logging - Implementation Plan **Status:** Planning Complete **Branch:** `andrasbacsai/service-db-deploy-logs` **Target:** Add deployment history and logging for Services and Databases (similar to Applications) --- ## Current State Analysis ### Application Deployments (Working Model) **Model:** `ApplicationDeploymentQueue` - **Location:** `app/Models/ApplicationDeploymentQueue.php` - **Table:** `application_deployment_queues` - **Key Features:** - Stores deployment logs as JSON in `logs` column - Tracks status: queued, in_progress, finished, failed, cancelled-by-user - Stores metadata: deployment_uuid, commit, pull_request_id, server info - Has `addLogEntry()` method with sensitive data redaction - Relationships: belongsTo Application, server attribute accessor **Job:** `ApplicationDeploymentJob` - **Location:** `app/Jobs/ApplicationDeploymentJob.php` - Handles entire deployment lifecycle - Uses `addLogEntry()` to stream logs to database - Updates status throughout deployment **Helper Function:** `queue_application_deployment()` - **Location:** `bootstrap/helpers/applications.php` - Creates deployment queue record - Dispatches job if ready - Returns deployment status and UUID **API Endpoints:** - `GET /api/deployments` - List all running deployments - `GET /api/deployments/{uuid}` - Get specific deployment - `GET /api/deployments/applications/{uuid}` - List app deployment history - Sensitive data filtering based on permissions **Migration History:** - `2023_05_24_083426_create_application_deployment_queues_table.php` - `2023_06_23_114133_use_application_deployment_queues_as_activity.php` (added logs, current_process_id) - `2025_01_16_110406_change_commit_message_to_text_in_application_deployment_queues.php` --- ### Services (Current State - No History) **Model:** `Service` - **Location:** `app/Models/Service.php` - Represents Docker Compose services with multiple applications/databases **Action:** `StartService` - **Location:** `app/Actions/Service/StartService.php` - Executes commands via `remote_process()` - Returns Activity log (Spatie ActivityLog) - ephemeral, not stored - Fires `ServiceStatusChanged` event on completion **Current Behavior:** ```php public function handle(Service $service, bool $pullLatestImages, bool $stopBeforeStart) { $service->parse(); // ... build commands array return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); } ``` **Problem:** No persistent deployment history. Logs disappear after Activity TTL. --- ### Databases (Current State - No History) **Models:** 9 Standalone Database Types - `StandalonePostgresql` - `StandaloneRedis` - `StandaloneMongodb` - `StandaloneMysql` - `StandaloneMariadb` - `StandaloneKeydb` - `StandaloneDragonfly` - `StandaloneClickhouse` - (All in `app/Models/`) **Actions:** Type-Specific Start Actions - `StartPostgresql`, `StartRedis`, `StartMongodb`, etc. - **Location:** `app/Actions/Database/Start*.php` - Each builds docker-compose config, writes to disk, starts container - Uses `remote_process()` with `DatabaseStatusChanged` event **Dispatcher:** `StartDatabase` - **Location:** `app/Actions/Database/StartDatabase.php` - Routes to correct Start action based on database type **Current Behavior:** ```php // StartPostgresql example public function handle(StandalonePostgresql $database) { // ... build commands array return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); } ``` **Problem:** No persistent deployment history. Only real-time Activity logs. --- ## Architectural Decisions ### Why Separate Tables? **Decision:** Create `service_deployment_queues` and `database_deployment_queues` (two separate tables) **Reasoning:** 1. **Different Attributes:** - Services: multiple containers, docker-compose specific, pull_latest_images flag - Databases: type-specific configs, SSL settings, init scripts - Applications: git commits, pull requests, build cache 2. **Query Performance:** - Separate indexes per resource type - No polymorphic type checks in every query - Easier to optimize per-resource-type 3. **Type Safety:** - Explicit relationships and foreign keys (where possible) - IDE autocomplete and static analysis benefits 4. **Existing Pattern:** - Coolify already uses separate tables: `applications`, `services`, `standalone_*` - Consistent with codebase conventions **Alternative Considered:** Single `resource_deployments` polymorphic table - **Pros:** DRY, one model to maintain - **Cons:** Harder to query efficiently, less type-safe, complex indexes - **Decision:** Rejected in favor of clarity and performance --- ## Implementation Plan ### Phase 1: Database Schema (3 migrations) #### Migration 1: Create `service_deployment_queues` **File:** `database/migrations/YYYY_MM_DD_HHMMSS_create_service_deployment_queues_table.php` ```php Schema::create('service_deployment_queues', function (Blueprint $table) { $table->id(); $table->foreignId('service_id')->constrained()->onDelete('cascade'); $table->string('deployment_uuid')->unique(); $table->string('status')->default('queued'); // queued, in_progress, finished, failed, cancelled-by-user $table->text('logs')->nullable(); // JSON array like ApplicationDeploymentQueue $table->string('current_process_id')->nullable(); // For tracking background processes $table->boolean('pull_latest_images')->default(false); $table->boolean('stop_before_start')->default(false); $table->boolean('is_api')->default(false); // Triggered via API vs UI $table->string('server_id'); // Denormalized for performance $table->string('server_name'); // Denormalized for display $table->string('service_name'); // Denormalized for display $table->string('deployment_url')->nullable(); // URL to view deployment $table->timestamps(); // Indexes for common queries $table->index(['service_id', 'status']); $table->index('deployment_uuid'); $table->index('created_at'); }); ``` **Key Design Choices:** - `logs` as TEXT (JSON) - Same pattern as ApplicationDeploymentQueue - Denormalized server/service names for API responses without joins - `deployment_url` for direct link generation - Composite indexes for filtering by service + status --- #### Migration 2: Create `database_deployment_queues` **File:** `database/migrations/YYYY_MM_DD_HHMMSS_create_database_deployment_queues_table.php` ```php Schema::create('database_deployment_queues', function (Blueprint $table) { $table->id(); $table->string('database_id'); // String to support polymorphic relationship $table->string('database_type'); // StandalonePostgresql, StandaloneRedis, etc. $table->string('deployment_uuid')->unique(); $table->string('status')->default('queued'); $table->text('logs')->nullable(); $table->string('current_process_id')->nullable(); $table->boolean('is_api')->default(false); $table->string('server_id'); $table->string('server_name'); $table->string('database_name'); $table->string('deployment_url')->nullable(); $table->timestamps(); // Indexes for polymorphic relationship and queries $table->index(['database_id', 'database_type']); $table->index(['database_id', 'database_type', 'status']); $table->index('deployment_uuid'); $table->index('created_at'); }); ``` **Key Design Choices:** - Polymorphic relationship using `database_id` + `database_type` - Can't use foreignId constraint due to multiple target tables - Composite index on polymorphic keys for efficient queries --- #### Migration 3: Add Performance Indexes **File:** `database/migrations/YYYY_MM_DD_HHMMSS_add_deployment_queue_indexes.php` ```php Schema::table('service_deployment_queues', function (Blueprint $table) { $table->index(['server_id', 'status', 'created_at'], 'service_deployments_server_status_time'); }); Schema::table('database_deployment_queues', function (Blueprint $table) { $table->index(['server_id', 'status', 'created_at'], 'database_deployments_server_status_time'); }); ``` **Purpose:** Optimize queries like "all in-progress deployments on this server, newest first" --- ### Phase 2: Eloquent Models (2 new models) #### Model 1: ServiceDeploymentQueue **File:** `app/Models/ServiceDeploymentQueue.php` ```php ['type' => 'integer'], 'service_id' => ['type' => 'integer'], 'deployment_uuid' => ['type' => 'string'], 'status' => ['type' => 'string'], 'pull_latest_images' => ['type' => 'boolean'], 'stop_before_start' => ['type' => 'boolean'], 'is_api' => ['type' => 'boolean'], 'logs' => ['type' => 'string'], 'current_process_id' => ['type' => 'string'], 'server_id' => ['type' => 'string'], 'server_name' => ['type' => 'string'], 'service_name' => ['type' => 'string'], 'deployment_url' => ['type' => 'string'], 'created_at' => ['type' => 'string'], 'updated_at' => ['type' => 'string'], ], )] class ServiceDeploymentQueue extends Model { protected $guarded = []; public function service() { return $this->belongsTo(Service::class); } public function server(): Attribute { return Attribute::make( get: fn () => Server::find($this->server_id), ); } public function setStatus(string $status) { $this->update(['status' => $status]); } public function getOutput($name) { if (!$this->logs) { return null; } return collect(json_decode($this->logs))->where('name', $name)->first()?->output ?? null; } private function redactSensitiveInfo($text) { $text = remove_iip($text); // Remove internal IPs $service = $this->service; if (!$service) { return $text; } // Redact environment variables marked as sensitive $lockedVars = collect([]); if ($service->environment_variables) { $lockedVars = $service->environment_variables ->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') { $type = 'stderr'; } $message = str($message)->trim(); if ($message->startsWith('╔')) { $message = "\n" . $message; } $newLogEntry = [ 'command' => null, 'output' => $this->redactSensitiveInfo($message), 'type' => $type, 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, 'batch' => 1, ]; // Use transaction for atomicity DB::transaction(function () use ($newLogEntry) { $this->refresh(); if ($this->logs) { $previousLogs = json_decode($this->logs, associative: true, flags: JSON_THROW_ON_ERROR); $newLogEntry['order'] = count($previousLogs) + 1; $previousLogs[] = $newLogEntry; $this->logs = json_encode($previousLogs, flags: JSON_THROW_ON_ERROR); } else { $this->logs = json_encode([$newLogEntry], flags: JSON_THROW_ON_ERROR); } $this->saveQuietly(); }); } } ``` **Key Features:** - Exact same log structure as ApplicationDeploymentQueue - `addLogEntry()` with sensitive data redaction - Atomic log appends using DB transactions - OpenAPI schema for API documentation --- #### Model 2: DatabaseDeploymentQueue **File:** `app/Models/DatabaseDeploymentQueue.php` ```php ['type' => 'integer'], 'database_id' => ['type' => 'string'], 'database_type' => ['type' => 'string'], 'deployment_uuid' => ['type' => 'string'], 'status' => ['type' => 'string'], 'is_api' => ['type' => 'boolean'], 'logs' => ['type' => 'string'], 'current_process_id' => ['type' => 'string'], 'server_id' => ['type' => 'string'], 'server_name' => ['type' => 'string'], 'database_name' => ['type' => 'string'], 'deployment_url' => ['type' => 'string'], 'created_at' => ['type' => 'string'], 'updated_at' => ['type' => 'string'], ], )] class DatabaseDeploymentQueue extends Model { protected $guarded = []; public function database() { return $this->morphTo('database', 'database_type', 'database_id'); } public function server(): Attribute { return Attribute::make( get: fn () => Server::find($this->server_id), ); } public function setStatus(string $status) { $this->update(['status' => $status]); } public function getOutput($name) { if (!$this->logs) { return null; } return collect(json_decode($this->logs))->where('name', $name)->first()?->output ?? null; } private function redactSensitiveInfo($text) { $text = remove_iip($text); $database = $this->database; if (!$database) { return $text; } // Redact database-specific credentials $sensitivePatterns = collect([]); // Common database credential patterns if (method_exists($database, 'getConnectionString')) { $sensitivePatterns->push($database->getConnectionString()); } // Postgres/MySQL passwords $passwordFields = ['postgres_password', 'mysql_password', 'mariadb_password', 'mongo_password']; foreach ($passwordFields as $field) { if (isset($database->$field)) { $sensitivePatterns->push($database->$field); } } // Redact environment variables if ($database->environment_variables) { $lockedVars = $database->environment_variables ->where('is_shown_once', true) ->pluck('real_value') ->filter(); $sensitivePatterns = $sensitivePatterns->merge($lockedVars); } foreach ($sensitivePatterns as $value) { if (empty($value)) continue; $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') { $type = 'stderr'; } $message = str($message)->trim(); if ($message->startsWith('╔')) { $message = "\n" . $message; } $newLogEntry = [ 'command' => null, 'output' => $this->redactSensitiveInfo($message), 'type' => $type, 'timestamp' => Carbon::now('UTC'), 'hidden' => $hidden, 'batch' => 1, ]; DB::transaction(function () use ($newLogEntry) { $this->refresh(); if ($this->logs) { $previousLogs = json_decode($this->logs, associative: true, flags: JSON_THROW_ON_ERROR); $newLogEntry['order'] = count($previousLogs) + 1; $previousLogs[] = $newLogEntry; $this->logs = json_encode($previousLogs, flags: JSON_THROW_ON_ERROR); } else { $this->logs = json_encode([$newLogEntry], flags: JSON_THROW_ON_ERROR); } $this->saveQuietly(); }); } } ``` **Key Differences from ServiceDeploymentQueue:** - Polymorphic `database()` relationship - More extensive sensitive data redaction (database passwords, connection strings) - Handles all 9 database types --- ### Phase 3: Enums (2 new enums) #### Enum 1: ServiceDeploymentStatus **File:** `app/Enums/ServiceDeploymentStatus.php` ```php id; $server = $service->destination->server; $server_id = $server->id; $server_name = $server->name; // Generate deployment URL $deployment_link = Url::fromString($service->link() . "/deployment/{$deployment_uuid}"); $deployment_url = $deployment_link->getPath(); // Create deployment record $deployment = ServiceDeploymentQueue::create([ 'service_id' => $service_id, 'service_name' => $service->name, 'server_id' => $server_id, 'server_name' => $server_name, 'deployment_uuid' => $deployment_uuid, 'deployment_url' => $deployment_url, 'pull_latest_images' => $pullLatestImages, 'stop_before_start' => $stopBeforeStart, 'is_api' => $is_api, 'status' => ServiceDeploymentStatus::IN_PROGRESS->value, ]); return [ 'status' => 'started', 'message' => 'Service deployment started.', 'deployment_uuid' => $deployment_uuid, 'deployment' => $deployment, ]; } ``` **Purpose:** Create deployment queue record when service starts. Returns deployment object for passing to actions. --- #### Helper 2: queue_database_deployment() **File:** `bootstrap/helpers/databases.php` (add to existing file) ```php use App\Models\DatabaseDeploymentQueue; use App\Enums\DatabaseDeploymentStatus; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; function queue_database_deployment( StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, string $deployment_uuid, bool $is_api = false ): array { $database_id = $database->id; $database_type = $database->getMorphClass(); $server = $database->destination->server; $server_id = $server->id; $server_name = $server->name; // Generate deployment URL $deployment_link = Url::fromString($database->link() . "/deployment/{$deployment_uuid}"); $deployment_url = $deployment_link->getPath(); // Create deployment record $deployment = DatabaseDeploymentQueue::create([ 'database_id' => $database_id, 'database_type' => $database_type, 'database_name' => $database->name, 'server_id' => $server_id, 'server_name' => $server_name, 'deployment_uuid' => $deployment_uuid, 'deployment_url' => $deployment_url, 'is_api' => $is_api, 'status' => DatabaseDeploymentStatus::IN_PROGRESS->value, ]); return [ 'status' => 'started', 'message' => 'Database deployment started.', 'deployment_uuid' => $deployment_uuid, 'deployment' => $deployment, ]; } ``` --- ### Phase 5: Refactor Actions (11 files to update) #### Action 1: StartService (CRITICAL) **File:** `app/Actions/Service/StartService.php` **Before:** ```php public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { $service->parse(); // ... build commands return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); } ``` **After:** ```php use App\Models\ServiceDeploymentQueue; use Visus\Cuid2\Cuid2; public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { // Create deployment queue record $deployment_uuid = (string) new Cuid2(); $result = queue_service_deployment( service: $service, deployment_uuid: $deployment_uuid, pullLatestImages: $pullLatestImages, stopBeforeStart: $stopBeforeStart, is_api: false ); $deployment = $result['deployment']; // Existing logic $service->parse(); if ($stopBeforeStart) { StopService::run(service: $service, dockerCleanup: false); } $service->saveComposeConfigs(); $service->isConfigurationChanged(save: true); $commands[] = 'cd ' . $service->workdir(); $commands[] = "echo 'Saved configuration files to {$service->workdir()}.'"; // ... rest of command building // Pass deployment to remote_process for log streaming return remote_process( $commands, $service->server, type_uuid: $service->uuid, model: $deployment, // NEW - link to deployment queue callEventOnFinish: 'ServiceStatusChanged' ); } ``` **Key Changes:** 1. Generate deployment UUID at start 2. Call `queue_service_deployment()` helper 3. Pass `$deployment` as `model` parameter to `remote_process()` 4. Return value unchanged (Activity object) --- #### Actions 2-10: Database Start Actions (9 files) **Files to Update:** - `app/Actions/Database/StartPostgresql.php` - `app/Actions/Database/StartRedis.php` - `app/Actions/Database/StartMongodb.php` - `app/Actions/Database/StartMysql.php` - `app/Actions/Database/StartMariadb.php` - `app/Actions/Database/StartKeydb.php` - `app/Actions/Database/StartDragonfly.php` - `app/Actions/Database/StartClickhouse.php` **Pattern (using StartPostgresql as example):** **Before:** ```php public function handle(StandalonePostgresql $database) { $this->database = $database; // ... build docker-compose and commands return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged'); } ``` **After:** ```php use App\Models\DatabaseDeploymentQueue; use Visus\Cuid2\Cuid2; public function handle(StandalonePostgresql $database) { $this->database = $database; // Create deployment queue record $deployment_uuid = (string) new Cuid2(); $result = queue_database_deployment( database: $database, deployment_uuid: $deployment_uuid, is_api: false ); $deployment = $result['deployment']; // Existing logic (unchanged) $container_name = $this->database->uuid; $this->configuration_dir = database_configuration_dir() . '/' . $container_name; // ... rest of setup // Pass deployment to remote_process return remote_process( $this->commands, $database->destination->server, model: $deployment, // NEW callEventOnFinish: 'DatabaseStatusChanged' ); } ``` **Apply Same Pattern to All 9 Database Start Actions** --- #### Action 11: StartDatabase (Dispatcher) **File:** `app/Actions/Database/StartDatabase.php` **Before:** ```php public function handle(/* all database types */) { switch ($database->getMorphClass()) { case \App\Models\StandalonePostgresql::class: $activity = StartPostgresql::run($database); break; // ... other cases } return $activity; } ``` **After:** No changes needed - already returns Activity from Start* actions --- ### Phase 6: Update Remote Process Handler (CRITICAL) **File:** `app/Actions/CoolifyTask/PrepareCoolifyTask.php` **Current Behavior:** - Accepts `$model` parameter (currently only used for ApplicationDeploymentQueue) - Streams logs to Activity (Spatie ActivityLog) - Calls event on finish **Required Changes:** 1. Check if `$model` is `ServiceDeploymentQueue` or `DatabaseDeploymentQueue` 2. Call `addLogEntry()` on deployment model alongside Activity logs 3. Update deployment status on completion/failure **Pseudocode for Changes:** ```php // In log streaming section if ($model instanceof ApplicationDeploymentQueue || $model instanceof ServiceDeploymentQueue || $model instanceof DatabaseDeploymentQueue) { $model->addLogEntry($logMessage, $logType); } // On completion if ($model instanceof ServiceDeploymentQueue || $model instanceof DatabaseDeploymentQueue) { if ($exitCode === 0) { $model->setStatus('finished'); } else { $model->setStatus('failed'); } } ``` **Note:** Exact implementation depends on PrepareCoolifyTask structure. Need to review file in detail during implementation. --- ### Phase 7: API Endpoints (4 new endpoints + 2 updates) **File:** `app/Http/Controllers/Api/DeployController.php` #### Endpoint 1: List Service Deployments ```php #[OA\Get( summary: 'List service deployments', description: 'List deployment history for a specific service', path: '/deployments/services/{uuid}', operationId: 'list-deployments-by-service-uuid', security: [['bearerAuth' => []]], tags: ['Deployments'], parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'skip', in: 'query', description: 'Number of records to skip', schema: new OA\Schema(type: 'integer', minimum: 0, default: 0)), new OA\Parameter(name: 'take', in: 'query', description: 'Number of records to take', schema: new OA\Schema(type: 'integer', minimum: 1, default: 10)), ], responses: [ new OA\Response(response: 200, description: 'List of service deployments'), new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 404, ref: '#/components/responses/404'), ] )] public function get_service_deployments(Request $request) { $request->validate([ 'skip' => ['nullable', 'integer', 'min:0'], 'take' => ['nullable', 'integer', 'min:1'], ]); $service_uuid = $request->route('uuid', null); $skip = $request->get('skip', 0); $take = $request->get('take', 10); $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } $service = Service::where('uuid', $service_uuid) ->whereHas('environment.project.team', function($query) use ($teamId) { $query->where('id', $teamId); }) ->first(); if (is_null($service)) { return response()->json(['message' => 'Service not found'], 404); } $this->authorize('view', $service); $deployments = $service->deployments($skip, $take); return response()->json(serializeApiResponse($deployments)); } ``` #### Endpoint 2: Get Service Deployment by UUID ```php #[OA\Get( summary: 'Get service deployment', description: 'Get a specific service deployment by deployment UUID', path: '/deployments/services/deployment/{uuid}', operationId: 'get-service-deployment-by-uuid', security: [['bearerAuth' => []]], tags: ['Deployments'], parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Deployment UUID', schema: new OA\Schema(type: 'string')), ], responses: [ new OA\Response(response: 200, description: 'Service deployment details'), new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 404, ref: '#/components/responses/404'), ] )] public function service_deployment_by_uuid(Request $request) { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } $uuid = $request->route('uuid'); if (!$uuid) { return response()->json(['message' => 'UUID is required.'], 400); } $deployment = ServiceDeploymentQueue::where('deployment_uuid', $uuid)->first(); if (!$deployment) { return response()->json(['message' => 'Deployment not found.'], 404); } // Authorization check via service $service = $deployment->service; if (!$service) { return response()->json(['message' => 'Service not found.'], 404); } $this->authorize('view', $service); return response()->json($this->removeSensitiveData($deployment)); } ``` #### Endpoint 3: List Database Deployments ```php #[OA\Get( summary: 'List database deployments', description: 'List deployment history for a specific database', path: '/deployments/databases/{uuid}', operationId: 'list-deployments-by-database-uuid', security: [['bearerAuth' => []]], tags: ['Deployments'], parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Database UUID', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'skip', in: 'query', schema: new OA\Schema(type: 'integer', minimum: 0, default: 0)), new OA\Parameter(name: 'take', in: 'query', schema: new OA\Schema(type: 'integer', minimum: 1, default: 10)), ], responses: [ new OA\Response(response: 200, description: 'List of database deployments'), new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 404, ref: '#/components/responses/404'), ] )] public function get_database_deployments(Request $request) { $request->validate([ 'skip' => ['nullable', 'integer', 'min:0'], 'take' => ['nullable', 'integer', 'min:1'], ]); $database_uuid = $request->route('uuid', null); $skip = $request->get('skip', 0); $take = $request->get('take', 10); $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } // Find database across all types $database = getResourceByUuid($database_uuid, $teamId); if (!$database || !method_exists($database, 'deployments')) { return response()->json(['message' => 'Database not found'], 404); } $this->authorize('view', $database); $deployments = $database->deployments($skip, $take); return response()->json(serializeApiResponse($deployments)); } ``` #### Endpoint 4: Get Database Deployment by UUID ```php #[OA\Get( summary: 'Get database deployment', description: 'Get a specific database deployment by deployment UUID', path: '/deployments/databases/deployment/{uuid}', operationId: 'get-database-deployment-by-uuid', security: [['bearerAuth' => []]], tags: ['Deployments'], parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Deployment UUID', schema: new OA\Schema(type: 'string')), ], responses: [ new OA\Response(response: 200, description: 'Database deployment details'), new OA\Response(response: 401, ref: '#/components/responses/401'), new OA\Response(response: 404, ref: '#/components/responses/404'), ] )] public function database_deployment_by_uuid(Request $request) { $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } $uuid = $request->route('uuid'); if (!$uuid) { return response()->json(['message' => 'UUID is required.'], 400); } $deployment = DatabaseDeploymentQueue::where('deployment_uuid', $uuid)->first(); if (!$deployment) { return response()->json(['message' => 'Deployment not found.'], 404); } // Authorization check via database $database = $deployment->database; if (!$database) { return response()->json(['message' => 'Database not found.'], 404); } $this->authorize('view', $database); return response()->json($this->removeSensitiveData($deployment)); } ``` #### Update: removeSensitiveData() method ```php private function removeSensitiveData($deployment) { if (request()->attributes->get('can_read_sensitive', false) === false) { $deployment->makeHidden(['logs']); } return serializeApiResponse($deployment); } ``` **Note:** Already works for ServiceDeploymentQueue and DatabaseDeploymentQueue due to duck typing #### Update: deploy_resource() method **Before:** ```php case Service::class: StartService::run($resource); $message = "Service {$resource->name} started. It could take a while, be patient."; break; default: // Database StartDatabase::dispatch($resource); $message = "Database {$resource->name} started."; break; ``` **After:** ```php case Service::class: $this->authorize('deploy', $resource); $deployment_uuid = new Cuid2; // StartService now handles deployment queue creation internally StartService::run($resource); $message = "Service {$resource->name} deployment started."; break; default: // Database $this->authorize('manage', $resource); $deployment_uuid = new Cuid2; // Start actions now handle deployment queue creation internally StartDatabase::dispatch($resource); $message = "Database {$resource->name} deployment started."; break; ``` **Note:** deployment_uuid is now created inside actions, so API just returns message. If we want to return UUID to API, actions need to return deployment object. --- ### Phase 8: Model Relationships (2 model updates) #### Update 1: Service Model **File:** `app/Models/Service.php` **Add Method:** ```php /** * Get deployment history for this service */ public function deployments(int $skip = 0, int $take = 10) { return ServiceDeploymentQueue::where('service_id', $this->id) ->orderBy('created_at', 'desc') ->skip($skip) ->take($take) ->get(); } /** * Get latest deployment */ public function latestDeployment() { return ServiceDeploymentQueue::where('service_id', $this->id) ->orderBy('created_at', 'desc') ->first(); } ``` --- #### Update 2: All Standalone Database Models (9 files) **Files:** - `app/Models/StandalonePostgresql.php` - `app/Models/StandaloneRedis.php` - `app/Models/StandaloneMongodb.php` - `app/Models/StandaloneMysql.php` - `app/Models/StandaloneMariadb.php` - `app/Models/StandaloneKeydb.php` - `app/Models/StandaloneDragonfly.php` - `app/Models/StandaloneClickhouse.php` **Add Methods to Each:** ```php /** * Get deployment history for this database */ public function deployments(int $skip = 0, int $take = 10) { return DatabaseDeploymentQueue::where('database_id', $this->id) ->where('database_type', $this->getMorphClass()) ->orderBy('created_at', 'desc') ->skip($skip) ->take($take) ->get(); } /** * Get latest deployment */ public function latestDeployment() { return DatabaseDeploymentQueue::where('database_id', $this->id) ->where('database_type', $this->getMorphClass()) ->orderBy('created_at', 'desc') ->first(); } ``` --- ### Phase 9: Routes (4 new routes) **File:** `routes/api.php` **Add Routes:** ```php Route::middleware(['auth:sanctum'])->group(function () { // Existing routes... // Service deployment routes Route::get('/deployments/services/{uuid}', [DeployController::class, 'get_service_deployments']) ->name('deployments.services.list'); Route::get('/deployments/services/deployment/{uuid}', [DeployController::class, 'service_deployment_by_uuid']) ->name('deployments.services.show'); // Database deployment routes Route::get('/deployments/databases/{uuid}', [DeployController::class, 'get_database_deployments']) ->name('deployments.databases.list'); Route::get('/deployments/databases/deployment/{uuid}', [DeployController::class, 'database_deployment_by_uuid']) ->name('deployments.databases.show'); }); ``` --- ### Phase 10: Policies & Authorization (Optional - If needed) **Service Policy:** `app/Policies/ServicePolicy.php` - May need to add `viewDeployment` and `viewDeployments` methods if they don't exist - Check existing `view` gate - it should cover deployment viewing **Database Policies:** - Each StandaloneDatabase type may have its own policy - Verify `view` gate exists and covers deployment history access **Action Required:** Review existing policies during implementation. May not need changes if `view` gate is sufficient. --- ## Testing Strategy ### Unit Tests (Run outside Docker: `./vendor/bin/pest tests/Unit`) #### Test 1: ServiceDeploymentQueue Unit Test **File:** `tests/Unit/Models/ServiceDeploymentQueueTest.php` ```php create([ 'logs' => null, ]); $deployment->addLogEntry('Test message', 'stdout', false); expect($deployment->fresh()->logs)->not->toBeNull(); $logs = json_decode($deployment->fresh()->logs, true); expect($logs)->toHaveCount(1); expect($logs[0])->toHaveKeys(['command', 'output', 'type', 'timestamp', 'hidden', 'batch', 'order']); expect($logs[0]['output'])->toBe('Test message'); expect($logs[0]['type'])->toBe('stdout'); }); it('redacts sensitive environment variables in logs', function () { $service = Mockery::mock(Service::class); $envVar = new \StdClass(); $envVar->is_shown_once = true; $envVar->key = 'SECRET_KEY'; $envVar->real_value = 'super-secret-value'; $service->shouldReceive('getAttribute') ->with('environment_variables') ->andReturn(collect([$envVar])); $deployment = ServiceDeploymentQueue::factory()->create(); $deployment->setRelation('service', $service); $deployment->addLogEntry('Deploying with super-secret-value in logs', 'stdout'); $logs = json_decode($deployment->fresh()->logs, true); expect($logs[0]['output'])->toContain(REDACTED); expect($logs[0]['output'])->not->toContain('super-secret-value'); }); it('sets status correctly', function () { $deployment = ServiceDeploymentQueue::factory()->create(['status' => 'queued']); $deployment->setStatus('in_progress'); expect($deployment->fresh()->status)->toBe('in_progress'); $deployment->setStatus('finished'); expect($deployment->fresh()->status)->toBe('finished'); }); ``` #### Test 2: DatabaseDeploymentQueue Unit Test **File:** `tests/Unit/Models/DatabaseDeploymentQueueTest.php` ```php create([ 'logs' => null, ]); $deployment->addLogEntry('Starting database', 'stdout', false); $logs = json_decode($deployment->fresh()->logs, true); expect($logs)->toHaveCount(1); expect($logs[0]['output'])->toBe('Starting database'); }); it('redacts database credentials in logs', function () { $database = Mockery::mock(StandalonePostgresql::class); $database->shouldReceive('getAttribute') ->with('postgres_password') ->andReturn('db-password-123'); $database->shouldReceive('getAttribute') ->with('environment_variables') ->andReturn(collect([])); $database->shouldReceive('getMorphClass') ->andReturn(StandalonePostgresql::class); $deployment = DatabaseDeploymentQueue::factory()->create([ 'database_type' => StandalonePostgresql::class, ]); $deployment->setRelation('database', $database); $deployment->addLogEntry('Connecting with password db-password-123', 'stdout'); $logs = json_decode($deployment->fresh()->logs, true); expect($logs[0]['output'])->toContain(REDACTED); expect($logs[0]['output'])->not->toContain('db-password-123'); }); ``` --- ### Feature Tests (Run inside Docker: `docker exec coolify php artisan test`) #### Test 3: Service Deployment Integration Test **File:** `tests/Feature/ServiceDeploymentTest.php` ```php create(); // Mock remote_process to prevent actual SSH // (Implementation depends on existing test patterns) StartService::run($service); $deployment = ServiceDeploymentQueue::where('service_id', $service->id)->first(); expect($deployment)->not->toBeNull(); expect($deployment->service_name)->toBe($service->name); expect($deployment->status)->toBe('in_progress'); }); it('tracks multiple deployments for same service', function () { $service = Service::factory()->create(); StartService::run($service); StartService::run($service); $deployments = ServiceDeploymentQueue::where('service_id', $service->id)->get(); expect($deployments)->toHaveCount(2); }); ``` #### Test 4: Database Deployment Integration Test **File:** `tests/Feature/DatabaseDeploymentTest.php` ```php create(); // Mock remote_process StartPostgresql::run($database); $deployment = DatabaseDeploymentQueue::where('database_id', $database->id) ->where('database_type', StandalonePostgresql::class) ->first(); expect($deployment)->not->toBeNull(); expect($deployment->database_name)->toBe($database->name); }); // Repeat for other database types... ``` #### Test 5: API Endpoint Tests **File:** `tests/Feature/Api/DeploymentApiTest.php` ```php create(); $service = Service::factory()->create([ 'environment_id' => /* setup team/project/env */ ]); ServiceDeploymentQueue::factory()->count(3)->create([ 'service_id' => $service->id, ]); $response = $this->actingAs($user) ->getJson("/api/deployments/services/{$service->uuid}"); $response->assertSuccessful(); $response->assertJsonCount(3); }); it('requires authentication for service deployments', function () { $service = Service::factory()->create(); $response = $this->getJson("/api/deployments/services/{$service->uuid}"); $response->assertUnauthorized(); }); // Repeat for database endpoints... ``` --- ## Rollout Plan ### Phase Order (Safest to Riskiest) | Phase | Risk | Can Break Production? | Rollback Strategy | |-------|------|----------------------|-------------------| | 1. Schema | Low | No (new tables) | Drop tables | | 2. Models | Low | No (unused code) | Remove files | | 3. Enums | Low | No (unused code) | Remove files | | 4. Helpers | Low | No (unused code) | Remove functions | | 5. Actions | **HIGH** | **YES** | Revert to old actions | | 6. Remote Process | **CRITICAL** | **YES** | Revert changes | | 7. API | Medium | No (new endpoints) | Remove routes | | 8. Relationships | Low | No (new methods) | Remove methods | | 9. UI | Low | No (optional) | Remove components | | 10. Policies | Low | Maybe (if breaking existing) | Revert gates | ### Recommended Rollout Strategy **Week 1: Foundation (No Risk)** - Complete Phases 1-4 - Write and run all unit tests - Verify migrations work in dev/staging **Week 2: Critical Changes (High Risk)** - Complete Phase 5 (Actions) for **Services only** - Complete Phase 6 (Remote Process handler) for Services - Test extensively in staging - Monitor for errors **Week 3: Database Support** - Extend Phase 5 to all 9 database types - Update Phase 6 for database support - Test each database type individually **Week 4: API & Polish** - Complete Phases 7-10 - Feature tests - API documentation - User-facing features (if any) ### Testing Checkpoints **After Phase 4:** - ✅ Migrations apply cleanly - ✅ Models instantiate without errors - ✅ Unit tests pass **After Phase 5 (Services):** - ✅ Service start creates deployment queue - ✅ Service logs stream to deployment queue - ✅ Service deployments appear in database - ✅ No disruption to existing service starts **After Phase 5 (Databases):** - ✅ Each database type creates deployment queue - ✅ Database logs stream correctly - ✅ No errors on database start **After Phase 7:** - ✅ API endpoints return correct data - ✅ Authorization works correctly - ✅ Sensitive data is redacted --- ## Known Risks & Mitigation ### Risk 1: Breaking Existing Deployments **Probability:** Medium **Impact:** Critical **Mitigation:** - Test exhaustively in staging before production - Deploy during low-traffic window - Have rollback plan ready (git revert + migration rollback) - Monitor error logs closely after deploy ### Risk 2: Database Performance Impact **Probability:** Low **Impact:** Medium **Details:** Each deployment now writes logs to DB multiple times (via `addLogEntry()`) **Mitigation:** - Use `saveQuietly()` to avoid triggering events - JSON column is indexed for fast retrieval - Logs are text (compressed well by Postgres) - Add monitoring for slow queries ### Risk 3: Disk Space Growth **Probability:** Medium (long-term) **Impact:** Low **Details:** Deployment logs accumulate over time **Mitigation:** - Implement log retention policy (delete deployments older than X days/months) - Add background job to prune old deployment records - Monitor disk usage trends ### Risk 4: Polymorphic Relationship Complexity **Probability:** Low **Impact:** Low **Details:** DatabaseDeploymentQueue uses polymorphic relationship (9 database types) **Mitigation:** - Thorough testing of each database type - Composite indexes on (database_id, database_type) - Clear documentation of relationship structure ### Risk 5: Remote Process Integration **Probability:** High **Impact:** Critical **Details:** `PrepareCoolifyTask` is core to all deployments. Changes here affect everything. **Mitigation:** - Review `PrepareCoolifyTask` code in detail before changes - Add type checks (`instanceof`) to avoid breaking existing logic - Extensive testing of application deployments after changes - Keep changes minimal and focused --- ## Migration Strategy for Existing Data **Q: What about existing services/databases that have been deployed before?** **A:** No migration needed. This is a **new feature**, not a data migration. - Services/databases deployed before this change won't have history - New deployments (after feature is live) will be tracked - This is acceptable - deployment history starts "now" **Alternative (if history is critical):** - Could create fake deployment records for currently running resources - Not recommended - logs don't exist, would be misleading --- ## Performance Considerations ### Database Writes During Deployment **Current:** ~1 write per deployment (Activity log, TTL-based) **New:** ~1 write per deployment + N writes for log entries - Application deployments: ~50-200 log entries - Service deployments: ~10-30 log entries - Database deployments: ~5-15 log entries **Impact:** Minimal - Writes are async (queued) - Postgres handles small JSON updates efficiently - `saveQuietly()` skips event dispatching overhead ### Query Performance **Critical Queries:** - "Get deployment history for service/database" - indexed on (resource_id, status, created_at) - "Get deployment by UUID" - unique index on deployment_uuid - "Get all in-progress deployments" - composite index on (server_id, status, created_at) **Expected Performance:** - < 10ms for single deployment lookup - < 50ms for paginated history (10 records) - < 100ms for server-wide deployment status --- ## Storage Estimates **Per Deployment:** - Metadata: ~500 bytes - Logs (avg): ~50KB (application), ~10KB (service), ~5KB (database) **1000 deployments/day:** - Services: ~10MB/day = ~300MB/month - Databases: ~5MB/day = ~150MB/month - Total: ~450MB/month (highly compressible) **Retention Policy Recommendation:** - Keep all deployments for 30 days - Keep successful deployments for 90 days - Keep failed deployments for 180 days (for debugging) --- ## Alternative Approaches Considered ### Option 1: Unified Resource Deployments Table **Schema:** ```sql CREATE TABLE resource_deployments ( id BIGINT PRIMARY KEY, deployable_id INT, deployable_type VARCHAR(255), -- App\Models\Service, App\Models\StandalonePostgresql, etc. deployment_uuid VARCHAR(255) UNIQUE, -- ... rest of fields INDEX(deployable_id, deployable_type) ); ``` **Pros:** - Single model to maintain - DRY (Don't Repeat Yourself) - Easier to query "all deployments across all resources" **Cons:** - Polymorphic queries are slower - No foreign key constraints - Different resources have different deployment attributes - Harder to optimize indexes per resource type - More complex to reason about **Decision:** Rejected - Separate tables provide better type safety and performance --- ### Option 2: Reuse Activity Log (Spatie) **Approach:** Don't create deployment queue tables. Use existing Activity log with longer TTL. **Pros:** - Zero new code - Activity log already stores logs **Cons:** - Activity log is ephemeral (not designed for permanent history) - No structured deployment metadata (status, UUIDs, etc.) - Would need to change Activity TTL globally (affects all activities) - Mixing concerns (Activity = audit log, Deployment = business logic) **Decision:** Rejected - Activity log and deployment history serve different purposes --- ### Option 3: External Logging Service **Approach:** Stream logs to external service (S3, CloudWatch, etc.) **Pros:** - Offload storage from main database - Better for very large log volumes **Cons:** - Additional infrastructure complexity - Requires external dependencies - Harder to query deployment history - Not consistent with application deployment pattern **Decision:** Rejected - Keep it simple, follow existing patterns --- ## Future Enhancements (Out of Scope) ### 1. Deployment Queue System - Like application deployments, queue service/database starts - Respect server concurrent limits - **Complexity:** High - **Value:** Medium (services/databases deploy fast, queueing less critical) ### 2. UI for Deployment History - Livewire components to view past deployments - Similar to application deployment history page - **Complexity:** Medium - **Value:** High (nice-to-have, not critical for first release) ### 3. Deployment Comparison - Diff between two deployments (config changes) - **Complexity:** High - **Value:** Low ### 4. Deployment Rollback - Roll back service/database to previous deployment - **Complexity:** Very High (databases especially risky) - **Value:** Medium ### 5. Deployment Notifications - Notify on service/database deployment success/failure - **Complexity:** Low - **Value:** Medium --- ## Success Criteria ### Minimum Viable Product (MVP) ✅ Service deployments create deployment queue records ✅ Database deployments (all 9 types) create deployment queue records ✅ Logs stream to deployment queue during deployment ✅ Deployment status updates (in_progress → finished/failed) ✅ API endpoints to retrieve deployment history ✅ Sensitive data redaction in logs ✅ No disruption to existing application deployments ✅ All unit and feature tests pass ### Nice-to-Have (Post-MVP) ⚪ UI components for viewing deployment history ⚪ Deployment notifications ⚪ Log retention policy job ⚪ Deployment statistics/analytics --- ## Questions to Resolve Before Implementation 1. **Should we queue service/database starts (like applications)?** - Current: Services/databases start immediately - With queue: Respect server concurrent limits, better for cloud instance - **Recommendation:** Start without queue, add later if needed 2. **Should API deploy endpoints return deployment_uuid for services/databases?** - Current: Application deploys return deployment_uuid - Proposed: Services/databases should too - **Recommendation:** Yes, for consistency. Requires actions to return deployment object. 3. **What's the log retention policy?** - **Recommendation:** 90 days for all, with background job to prune 4. **Do we need UI in first release?** - **Recommendation:** No, API is sufficient. Add UI iteratively. 5. **Should we implement deployment cancellation?** - Applications support cancellation - **Recommendation:** Not in MVP, add later if requested --- ## Implementation Checklist ### Pre-Implementation - [ ] Review this plan with team - [ ] Get approval on architectural decisions - [ ] Resolve open questions - [ ] Set up staging environment for testing ### Phase 1: Schema - [ ] Create `create_service_deployment_queues_table` migration - [ ] Create `create_database_deployment_queues_table` migration - [ ] Create index optimization migration - [ ] Test migrations in dev - [ ] Run migrations in staging ### Phase 2: Models - [ ] Create `ServiceDeploymentQueue` model - [ ] Create `DatabaseDeploymentQueue` model - [ ] Add `$fillable`, `$guarded` properties - [ ] Implement `addLogEntry()`, `setStatus()`, `getOutput()` methods - [ ] Implement `redactSensitiveInfo()` methods - [ ] Add OpenAPI schemas ### Phase 3: Enums - [ ] Create `ServiceDeploymentStatus` enum - [ ] Create `DatabaseDeploymentStatus` enum ### Phase 4: Helpers - [ ] Add `queue_service_deployment()` to `bootstrap/helpers/services.php` - [ ] Add `queue_database_deployment()` to `bootstrap/helpers/databases.php` - [ ] Test helpers in Tinker ### Phase 5: Actions - [ ] Update `StartService` action - [ ] Update `StartPostgresql` action - [ ] Update `StartRedis` action - [ ] Update `StartMongodb` action - [ ] Update `StartMysql` action - [ ] Update `StartMariadb` action - [ ] Update `StartKeydb` action - [ ] Update `StartDragonfly` action - [ ] Update `StartClickhouse` action - [ ] Test each action in staging ### Phase 6: Remote Process - [ ] Review `PrepareCoolifyTask` code - [ ] Add type checks for ServiceDeploymentQueue - [ ] Add type checks for DatabaseDeploymentQueue - [ ] Add `addLogEntry()` calls - [ ] Add status update logic - [ ] Test with application deployments (ensure no regression) - [ ] Test with service deployments - [ ] Test with database deployments ### Phase 7: API - [ ] Add `get_service_deployments()` endpoint - [ ] Add `service_deployment_by_uuid()` endpoint - [ ] Add `get_database_deployments()` endpoint - [ ] Add `database_deployment_by_uuid()` endpoint - [ ] Update `deploy_resource()` to return deployment_uuid - [ ] Update `removeSensitiveData()` if needed - [ ] Add routes to `api.php` - [ ] Test endpoints with Postman/curl ### Phase 8: Relationships - [ ] Add `deployments()` method to `Service` model - [ ] Add `latestDeployment()` method to `Service` model - [ ] Add `deployments()` method to all 9 Standalone database models - [ ] Add `latestDeployment()` method to all 9 Standalone database models ### Phase 9: Tests - [ ] Write `ServiceDeploymentQueueTest` (unit) - [ ] Write `DatabaseDeploymentQueueTest` (unit) - [ ] Write `ServiceDeploymentTest` (feature) - [ ] Write `DatabaseDeploymentTest` (feature) - [ ] Write `DeploymentApiTest` (feature) - [ ] Run all tests, ensure passing - [ ] Run full test suite, ensure no regressions ### Phase 10: Documentation - [ ] Update API documentation - [ ] Update CLAUDE.md if needed - [ ] Add code comments for complex sections ### Deployment - [ ] Create PR with all changes - [ ] Code review - [ ] Test in staging (full regression suite) - [ ] Deploy to production during low-traffic window - [ ] Monitor error logs for 24 hours - [ ] Verify deployments are being tracked ### Post-Deployment - [ ] Monitor disk usage trends - [ ] Monitor query performance - [ ] Gather user feedback - [ ] Plan UI implementation (if needed) - [ ] Plan log retention job --- ## Contact & Support **Implementation Lead:** [Your Name] **Reviewer:** [Reviewer Name] **Questions:** Reference this document or ask in #dev channel --- **Last Updated:** 2025-10-30 **Status:** Planning Complete, Ready for Implementation **Next Step:** Review plan with team, get approval, begin Phase 1