diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index 0d9ca0153..98cce088b 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -48,7 +48,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $ ); $commands = [ - 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"', + 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"', $imagePruneCmd, 'docker builder prune -af', "docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f", diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index f3783696d..8241e5fba 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -747,7 +747,7 @@ public function create_backup(Request $request) } if ($request->filled('s3_storage_uuid')) { - $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); + $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists(); if (! $existsInTeam) { return response()->json([ 'message' => 'Validation failed.', @@ -774,7 +774,7 @@ public function create_backup(Request $request) // Convert s3_storage_uuid to s3_storage_id if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); + $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first(); if ($s3Storage) { $backupData['s3_storage_id'] = $s3Storage->id; } elseif ($request->boolean('save_s3')) { @@ -982,7 +982,7 @@ public function update_backup(Request $request) ], 422); } if ($request->filled('s3_storage_uuid')) { - $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); + $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists(); if (! $existsInTeam) { return response()->json([ 'message' => 'Validation failed.', @@ -1015,7 +1015,7 @@ public function update_backup(Request $request) // Convert s3_storage_uuid to s3_storage_id if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); + $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first(); if ($s3Storage) { $backupData['s3_storage_id'] = $s3Storage->id; } elseif ($request->boolean('save_s3')) { diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 2e99aa287..b28fcc744 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -66,6 +66,13 @@ public static function ownedByCurrentTeam(array $select = ['*']) return S3Storage::whereTeamId(currentTeam()->id)->select($selectArray->all())->orderBy('name'); } + public static function ownedByCurrentTeamAPI(int $teamId, array $select = ['*']) + { + $selectArray = collect($select)->concat(['id']); + + return S3Storage::whereTeamId($teamId)->select($selectArray->all())->orderBy('name'); + } + public function isUsable() { return $this->is_usable; diff --git a/tests/Feature/Api/DatabaseBackupCreationApiTest.php b/tests/Feature/Api/DatabaseBackupCreationApiTest.php index 70933ccf3..8882fec39 100644 --- a/tests/Feature/Api/DatabaseBackupCreationApiTest.php +++ b/tests/Feature/Api/DatabaseBackupCreationApiTest.php @@ -2,6 +2,8 @@ use App\Models\InstanceSettings; use App\Models\Project; +use App\Models\S3Storage; +use App\Models\ScheduledDatabaseBackup; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; @@ -53,6 +55,17 @@ 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); + + $this->s3Storage = S3Storage::create([ + 'name' => 'test-s3', + 'region' => 'us-east-1', + 'key' => 'test-key', + 'secret' => 'test-secret', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $this->team->id, + 'is_usable' => true, + ]); }); function backupHeaders(): array @@ -164,4 +177,101 @@ function backupHeaders(): array $response->assertStatus(404); $response->assertJson(['message' => 'Database not found.']); }); + + test('creates backup with s3 storage via API token', function () { + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => '0 2 * * 0', + 'save_s3' => true, + 's3_storage_uuid' => $this->s3Storage->uuid, + 'enabled' => true, + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid', 'message']); + + $backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first(); + expect($backup)->not->toBeNull(); + expect($backup->s3_storage_id)->toBe($this->s3Storage->id); + expect($backup->save_s3)->toBeTrue(); + expect($backup->team_id)->toBe($this->team->id); + }); + + test('rejects s3_storage_uuid from another team', function () { + $otherTeam = Team::factory()->create(); + $otherS3 = S3Storage::create([ + 'name' => 'other-s3', + 'region' => 'us-east-1', + 'key' => 'other-key', + 'secret' => 'other-secret', + 'bucket' => 'other-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $otherTeam->id, + 'is_usable' => true, + ]); + + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => '0 2 * * 0', + 'save_s3' => true, + 's3_storage_uuid' => $otherS3->uuid, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['s3_storage_uuid']); + }); +}); + +describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', function () { + test('updates backup to use s3 storage via API token', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'enabled' => true, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeaders(backupHeaders()) + ->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 'save_s3' => true, + 's3_storage_uuid' => $this->s3Storage->uuid, + ]); + + $response->assertStatus(200); + $backup->refresh(); + expect($backup->s3_storage_id)->toBe($this->s3Storage->id); + expect($backup->save_s3)->toBeTrue(); + }); + + test('rejects s3_storage_uuid from another team on update', function () { + $otherTeam = Team::factory()->create(); + $otherS3 = S3Storage::create([ + 'name' => 'other-s3', + 'region' => 'us-east-1', + 'key' => 'other-key', + 'secret' => 'other-secret', + 'bucket' => 'other-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $otherTeam->id, + 'is_usable' => true, + ]); + + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'enabled' => true, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + + $response = $this->withHeaders(backupHeaders()) + ->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 'save_s3' => true, + 's3_storage_uuid' => $otherS3->uuid, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['s3_storage_uuid']); + }); }); diff --git a/tests/Unit/Actions/Server/CleanupDockerTest.php b/tests/Unit/Actions/Server/CleanupDockerTest.php index fc8b8ab9b..1a6a0d3d6 100644 --- a/tests/Unit/Actions/Server/CleanupDockerTest.php +++ b/tests/Unit/Actions/Server/CleanupDockerTest.php @@ -437,6 +437,16 @@ expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build'); }); +it('container prune excludes persistent resource types', function () { + $sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php'); + + expect($sourceFile)->toContain('label!=coolify.type=database'); + expect($sourceFile)->toContain('label!=coolify.type=application'); + expect($sourceFile)->toContain('label!=coolify.type=service'); + expect($sourceFile)->toContain('label!=coolify.proxy=true'); + expect($sourceFile)->toContain('label=coolify.managed=true'); +}); + it('preserves build image for currently running tag', function () { $images = collect([ ['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],