diff --git a/todos/service-database-deployment-logging.md b/todos/service-database-deployment-logging.md new file mode 100644 index 000000000..dd0790aec --- /dev/null +++ b/todos/service-database-deployment-logging.md @@ -0,0 +1,1916 @@ +# 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