From 6f58ad4b97fae14988af030a20b0f10d32cbc6d3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:54:17 +0200 Subject: [PATCH 1/4] fix(backups): keep service DB context after deleting a schedule Cache the related database before deleting the backup so authorization, server lookup, and the service redirect do not touch a deleted morph. Skip rendering after delete and cover the service-database path. --- app/Livewire/Project/Database/BackupEdit.php | 24 ++++----- tests/Feature/BackupEditValidationTest.php | 57 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 4a709d2b96..4a09fc0463 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -180,7 +180,8 @@ class BackupEdit extends Component public function delete($password, $selectedActions = []) { - $this->authorize('manageBackups', $this->backup->database); + $database = $this->backup->database; + $this->authorize('manageBackups', $database); if (! verifyPasswordConfirmation($password, $this)) { return 'The provided password is incorrect.'; @@ -188,10 +189,10 @@ class BackupEdit extends Component try { $server = null; - if ($this->backup->database instanceof ServiceDatabase) { - $server = $this->backup->database->service->destination->server; - } elseif ($this->backup->database->destination && $this->backup->database->destination->server) { - $server = $this->backup->database->destination->server; + if ($database instanceof ServiceDatabase) { + $server = $database->service->destination->server; + } elseif ($database->destination && $database->destination->server) { + $server = $database->destination->server; } $filenames = $this->backup->executions() @@ -213,15 +214,14 @@ class BackupEdit extends Component } $this->backup->delete(); + $this->skipRender(); - if ($this->backup->database->getMorphClass() === ServiceDatabase::class) { - $serviceDatabase = $this->backup->database; - + if ($database instanceof ServiceDatabase) { return redirectRoute($this, 'project.service.database.backups', [ - 'project_uuid' => $this->parameters['project_uuid'], - 'environment_uuid' => $this->parameters['environment_uuid'], - 'service_uuid' => $serviceDatabase->service->uuid, - 'stack_service_uuid' => $serviceDatabase->uuid, + 'project_uuid' => $database->service->project()->uuid, + 'environment_uuid' => $database->service->environment->uuid, + 'service_uuid' => $database->service->uuid, + 'stack_service_uuid' => $database->uuid, ]); } else { return redirectRoute($this, 'project.database.backup.index', [ diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 03af1bea4f..f17d25add8 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -8,6 +8,8 @@ use App\Models\Project; use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\Server; +use App\Models\Service; +use App\Models\ServiceDatabase; use App\Models\StandaloneDocker; use App\Models\StandalonePostgresql; use App\Models\Team; @@ -343,6 +345,61 @@ it('disables S3 backup when saved without a selected S3 storage', function () { expect($backup->s3_storage_id)->toBeNull(); }); +it('deletes a service database backup schedule without rendering the deleted relationship', function () { + InstanceSettings::get()->update(['disable_two_step_confirmation' => true]); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'server_id' => $server->id, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $database = ServiceDatabase::create([ + 'service_id' => $service->id, + 'name' => 'postgres', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'enabled' => true, + 'save_s3' => false, + 'database_backup_retention_amount_locally' => 0, + 'database_backup_retention_days_locally' => 0, + 'database_backup_retention_max_storage_locally' => 0, + 'database_backup_retention_amount_s3' => 0, + 'database_backup_retention_days_s3' => 0, + 'database_backup_retention_max_storage_s3' => 0, + 'dump_all' => false, + 'timeout' => 3600, + 'missing_backup_notification_days' => 0, + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $this->team->id, + ]); + $parameters = [ + 'project_uuid' => $project->uuid, + 'environment_uuid' => $environment->uuid, + 'service_uuid' => $service->uuid, + 'stack_service_uuid' => $database->uuid, + ]; + + $component = Livewire::test(BackupEdit::class, [ + 'backup' => $backup, + 'availableS3Storages' => collect(), + 'section' => 'danger', + ]); + $component + ->call('delete', '') + ->assertRedirectToRoute('project.service.database.backups', $parameters); + + expect(ScheduledDatabaseBackup::find($backup->id))->toBeNull(); +}); + it('cascades to disabling local backup deletion when S3 is force-disabled', function () { $backup = createBackupForEditValidationTest($this->team, [ 'disable_local_backup' => true, From 7d11bc92f7b8eb7430bcd92f3cb652c4d6c8137f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:25:20 +0200 Subject: [PATCH 2/4] feat(logs): accept `all` for log lines and keep `-1` API log endpoints and the GetLogs UI now treat `all` as unbounded output, with `-1` remaining as a compatibility alias. MCP still falls back to a positive default. Traefik version checks run from CheckForUpdatesJob instead of a weekly schedule. --- .ai/lessons.md | 9 ++++ app/Console/Kernel.php | 3 -- .../Api/ApplicationsController.php | 11 +++-- .../Controllers/Api/DatabasesController.php | 11 +++-- .../Api/ServiceApplicationsController.php | 18 ++++++-- .../Api/ServiceDatabasesController.php | 11 ++++- .../Controllers/Api/ServicesController.php | 11 +++-- app/Jobs/CheckForUpdatesJob.php | 2 + app/Livewire/Project/Shared/GetLogs.php | 21 ++++++--- app/Mcp/Concerns/ResolvesResource.php | 4 +- bootstrap/helpers/docker.php | 15 +++++-- .../project/shared/get-logs.blade.php | 6 ++- tests/Feature/CheckForUpdatesTraefikTest.php | 35 +++++++++++++++ tests/Feature/GetLogsCommandInjectionTest.php | 23 ++++++++++ tests/Unit/Api/LogEndpointHelpersTest.php | 45 ++++++++++++++++++- 15 files changed, 186 insertions(+), 39 deletions(-) create mode 100644 tests/Feature/CheckForUpdatesTraefikTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index 5a2e565f3f..f4c7c22956 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -1,5 +1,10 @@ # Lessons +## Check prior fixes before changing a repeated symptom +- When a reported regression matches a recent fix, inspect that fix and reproduce why it no longer works before adding another workaround. +- Do not claim a redirect or lifecycle root cause from an effects assertion alone. Prove the reported HTTP or browser failure first. +- Preserve SPA navigation when it is a product requirement. Do not replace it with a full-page redirect to mask a deletion race; fix the ordering or state race instead. + ## Confirm which surface becomes the modal - When a user wants two settings pages replaced by a modal, identify the parent page that owns the trigger and confirm that the complete child settings view moves into that modal. - Do not make one child page a modal inside the other child page when the user wants both child URLs removed. @@ -82,3 +87,7 @@ ## Keep modal actions in the footer - When a modal has a large editable body, put preview, validation, and save controls in a fixed footer. Keep the title bar for the title and close action. + +## Prefer named API values over numeric sentinels +- When an API option means an unbounded or special mode, expose a clear named value such as `all`. +- Keep an existing numeric sentinel such as `-1` only as a compatibility alias unless the user requests a breaking change. diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 79660c2490..a86b26ce5c 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -6,7 +6,6 @@ use App\Jobs\ApiTokenExpirationWarningJob; use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckMissingDatabaseBackupsJob; -use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CleanupInstanceStuffsJob; use App\Jobs\CleanupOrphanedPreviewContainersJob; use App\Jobs\CleanupStaleMultiplexedConnections; @@ -91,8 +90,6 @@ class Kernel extends ConsoleKernel $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer(); - $this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer(); - $this->scheduleInstance->command('cleanup:database --yes')->daily(); $this->scheduleInstance->command('uploads:clear')->everyTwoMinutes(); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 67fd515bcd..bf68713e94 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2410,13 +2410,12 @@ class ApplicationsController extends Controller new OA\Parameter( name: 'lines', in: 'query', - description: 'Number of lines to show from the end of the logs.', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', required: false, - schema: new OA\Schema( - type: 'integer', - format: 'int32', - default: 100, - ) + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) ), new OA\Parameter( name: 'show_timestamps', diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 881aa5a897..c9b28e5a2f 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2433,13 +2433,12 @@ class DatabasesController extends Controller new OA\Parameter( name: 'lines', in: 'query', - description: 'Number of lines to show from the end of the logs.', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', required: false, - schema: new OA\Schema( - type: 'integer', - format: 'int32', - default: 100, - ) + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) ), new OA\Parameter( name: 'show_timestamps', diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index c827e818d5..dda70c27eb 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -402,9 +402,12 @@ class ServiceApplicationsController extends Controller new OA\Parameter( name: 'lines', in: 'query', - description: 'Number of lines to show from the end of the logs.', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', required: false, - schema: new OA\Schema(type: 'integer', format: 'int32', default: 100) + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) ), ], responses: [ @@ -451,7 +454,16 @@ class ServiceApplicationsController extends Controller parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'app_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), - new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', + required: false, + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]), + ), ], responses: [ new OA\Response( diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php index 480ff4e557..1304e0d22b 100644 --- a/app/Http/Controllers/Api/ServiceDatabasesController.php +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -288,7 +288,16 @@ class ServiceDatabasesController extends Controller parameters: [ new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'database_uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), - new OA\Parameter(name: 'lines', in: 'query', required: false, schema: new OA\Schema(type: 'integer', format: 'int32', default: 100)), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', + required: false, + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]), + ), ], responses: [ new OA\Response(response: 200, description: 'Logs.', content: new OA\JsonContent(type: 'object', properties: [new OA\Property(property: 'logs', type: 'string')])), diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 1a199c07ec..5763f3c276 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -869,13 +869,12 @@ class ServicesController extends Controller new OA\Parameter( name: 'lines', in: 'query', - description: 'Number of lines to show from the end of the logs.', + description: 'Number of lines to show from the end of the logs. Use `all` to return all logs. `-1` remains available as a compatibility alias.', required: false, - schema: new OA\Schema( - type: 'integer', - format: 'int32', - default: 100, - ) + schema: new OA\Schema(oneOf: [ + new OA\Schema(type: 'integer', format: 'int32', default: 100, minimum: -1, maximum: 10000), + new OA\Schema(type: 'string', enum: ['all']), + ]) ), new OA\Parameter( name: 'show_timestamps', diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php index 8da2426da7..b4cb7fe705 100644 --- a/app/Jobs/CheckForUpdatesJob.php +++ b/app/Jobs/CheckForUpdatesJob.php @@ -73,6 +73,8 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue // Invalidate cache to ensure fresh data is loaded invalidate_versions_cache(); + CheckTraefikVersionJob::dispatch(); + // Only mark new version available if Coolify version actually increased if (version_compare($latest_version, $current_version, '>')) { // New version available diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index e1e5413719..3da0c75876 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -131,6 +131,12 @@ class GetLogs extends Component $this->streamLogs = ! $this->streamLogs; } + public function showAllLogs(): void + { + $this->numberOfLines = -1; + $this->getLogs(true); + } + public function getLogs($refresh = false) { if (! Server::ownedByCurrentTeam()->where('id', $this->server->id)->exists()) { @@ -149,22 +155,25 @@ class GetLogs extends Component if (! $refresh && ! $this->expandByDefault && ($this->resource?->getMorphClass() === Service::class || str($this->container)->contains('-pr-'))) { return; } - if ($this->numberOfLines <= 0 || is_null($this->numberOfLines)) { + $logTail = $this->numberOfLines === -1 ? 'all' : $this->numberOfLines; + if ($logTail !== 'all' && ($logTail <= 0 || is_null($logTail))) { $this->numberOfLines = 1000; + $logTail = $this->numberOfLines; } - if ($this->numberOfLines > self::MAX_LOG_LINES) { + if ($logTail !== 'all' && $logTail > self::MAX_LOG_LINES) { $this->numberOfLines = self::MAX_LOG_LINES; + $logTail = $this->numberOfLines; } if ($this->container) { if ($this->showTimeStamps) { if ($this->server->isSwarm()) { - $command = "docker service logs -n {$this->numberOfLines} -t {$this->container}"; + $command = "docker service logs -n {$logTail} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } } else { - $command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; + $command = "docker logs -n {$logTail} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; @@ -172,13 +181,13 @@ class GetLogs extends Component } } else { if ($this->server->isSwarm()) { - $command = "docker service logs -n {$this->numberOfLines} {$this->container}"; + $command = "docker service logs -n {$logTail} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } } else { - $command = "docker logs -n {$this->numberOfLines} {$this->container}"; + $command = "docker logs -n {$logTail} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; diff --git a/app/Mcp/Concerns/ResolvesResource.php b/app/Mcp/Concerns/ResolvesResource.php index 7695fb9e25..eb002234a8 100644 --- a/app/Mcp/Concerns/ResolvesResource.php +++ b/app/Mcp/Concerns/ResolvesResource.php @@ -98,6 +98,8 @@ trait ResolvesResource */ protected function normalizeMcpLogLines(mixed $lines): int { - return normalizeLogLines($lines, default: 100, max: 500); + $lines = normalizeLogLines($lines, default: 100, max: 500); + + return is_int($lines) && $lines > 0 ? $lines : 100; } } diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 613a104e0e..02dee81b70 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1540,10 +1540,17 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable } } -function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int +function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int|string { + if ($lines === 'all') { + return 'all'; + } + $lines = filter_var($lines, FILTER_VALIDATE_INT); - if ($lines === false || $lines <= 0) { + if ($lines === -1) { + return 'all'; + } + if ($lines === false || $lines < -1) { return $default; } @@ -1555,7 +1562,7 @@ function parseLogTimestampFlag(mixed $showTimestamps): bool return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; } -function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string +function buildContainerLogsCommand(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string { $command = "docker logs -n {$lines}"; if ($server->isSwarm()) { @@ -1569,7 +1576,7 @@ function buildContainerLogsCommand(Server $server, string $container_id, int $li return "{$command} ".escapeshellarg($container_id).' 2>&1'; } -function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string +function getContainerLogs(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string { $output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server); $output = removeAnsiColors($output); diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index b5e9c21039..ec924cd80a 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -484,9 +484,11 @@
Lines - +
diff --git a/tests/Feature/CheckForUpdatesTraefikTest.php b/tests/Feature/CheckForUpdatesTraefikTest.php new file mode 100644 index 0000000000..ac53a5a8cb --- /dev/null +++ b/tests/Feature/CheckForUpdatesTraefikTest.php @@ -0,0 +1,35 @@ + Http::response([ + 'coolify' => ['v4' => ['version' => '4.0.10']], + 'traefik' => ['v3.7' => '3.7.13'], + ]), + ]); + File::shouldReceive('exists')->andReturn(false); + File::shouldReceive('put')->once(); + + InstanceSettings::forceCreate(['id' => 0]); + + config([ + 'app.env' => 'production', + 'constants.coolify.self_hosted' => true, + 'constants.coolify.version' => '4.0.10', + ]); + + (new CheckForUpdatesJob)->handle(); + + Bus::assertDispatched(CheckTraefikVersionJob::class); +}); diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php index 2c86d73284..f7aac5fcb4 100644 --- a/tests/Feature/GetLogsCommandInjectionTest.php +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -74,6 +74,29 @@ describe('GetLogs locked properties', function () { }); describe('GetLogs Livewire action validation', function () { + test('getLogs requests all logs when the line count is minus one', function () { + $this->server->settings->fill([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ])->save(); + $server = Server::with('settings')->findOrFail($this->server->id); + + Process::fake(['*' => Process::result(output: 'all logs')]); + + Livewire::test(GetLogs::class, [ + 'server' => $server, + 'resource' => $this->application, + 'container' => 'test-container', + ]) + ->assertSee('All') + ->assertSeeHtml('title="Show all logs"') + ->call('showAllLogs') + ->assertSet('numberOfLines', -1); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker logs -n all')); + }); + test('getLogs marks ANSI-colored output truncated based on raw bytes', function () { $this->server->settings->fill([ 'is_reachable' => true, diff --git a/tests/Unit/Api/LogEndpointHelpersTest.php b/tests/Unit/Api/LogEndpointHelpersTest.php index c4173bd699..e298def5dd 100644 --- a/tests/Unit/Api/LogEndpointHelpersTest.php +++ b/tests/Unit/Api/LogEndpointHelpersTest.php @@ -1,5 +1,6 @@ toBe(100) ->and(normalizeLogLines(''))->toBe(100) ->and(normalizeLogLines('abc'))->toBe(100) - ->and(normalizeLogLines('0'))->toBe(100) + ->and(normalizeLogLines('all'))->toBe('all') + ->and(normalizeLogLines('ALL'))->toBe(100) + ->and(normalizeLogLines('-1'))->toBe('all') + ->and(normalizeLogLines('0'))->toBe(0) ->and(normalizeLogLines('-5'))->toBe(100) ->and(normalizeLogLines('50'))->toBe(50) ->and(normalizeLogLines('50000'))->toBe(10000); }); +it('keeps MCP log requests bounded', function () { + $normalizer = new class + { + use ResolvesResource; + + public function normalize(mixed $lines): int + { + return $this->normalizeMcpLogLines($lines); + } + }; + + expect($normalizer->normalize('all'))->toBe(100) + ->and($normalizer->normalize('-1'))->toBe(100) + ->and($normalizer->normalize('0'))->toBe(100) + ->and($normalizer->normalize('501'))->toBe(500); +}); + +it('documents the named all logs option on every REST log endpoint', function (string $controller) { + $source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php"); + + expect($source)->toContain('Use `all` to return all logs. `-1` remains available as a compatibility alias.'); +})->with([ + 'applications' => 'ApplicationsController', + 'databases' => 'DatabasesController', + 'services' => 'ServicesController', + 'service applications' => 'ServiceApplicationsController', + 'service databases' => 'ServiceDatabasesController', +]); + it('normalizes service resource log line counts before invoking Docker', function (string $controller, mixed $lines, int $expectedLines) { $source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php"); @@ -63,6 +96,16 @@ it('builds docker log commands with options before an escaped container id', fun ->toBe("docker logs -n 25 --timestamps 'container-1' 2>&1"); }); +it('builds docker log commands for all and zero lines', function () { + $server = new Server; + $server->settings = ['is_swarm_manager' => false]; + + expect(buildContainerLogsCommand($server, 'container-1', 'all')) + ->toBe("docker logs -n all 'container-1' 2>&1") + ->and(buildContainerLogsCommand($server, 'container-1', 0)) + ->toBe("docker logs -n 0 'container-1' 2>&1"); +}); + it('builds swarm service log commands with options before an escaped service id', function () { if (! function_exists('buildContainerLogsCommand')) { expect(function_exists('buildContainerLogsCommand'))->toBeTrue(); From 59eacc9ff8362e007104cd3549b3f4518ba70ff4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:09:01 +0200 Subject: [PATCH 3/4] fix(services): keep Traefik subtype from stored service type Label service apps as application even after the compose image looks like a database. Do not auto-heal existing deployments. --- .ai/lessons.md | 4 +++ bootstrap/helpers/parsers.php | 2 +- .../TraefikServiceDockerNetworkLabelTest.php | 35 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/.ai/lessons.md b/.ai/lessons.md index f4c7c22956..711eb544a8 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -91,3 +91,7 @@ ## Prefer named API values over numeric sentinels - When an API option means an unbounded or special mode, expose a clear named value such as `all`. - Keep an existing numeric sentinel such as `-1` only as a compatibility alias unless the user requests a breaking change. + +## Do not auto-heal existing deployments without a request +- When a parser or label fix can apply only after container recreation, keep the change limited to new deployments and later user-initiated redeployments unless the user explicitly asks for live reconciliation. +- Do not add status lookup fallbacks that alter existing deployment behavior when the requested scope is new deployments only. diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index e50b852eca..067a27bc20 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -2546,7 +2546,7 @@ function serviceParser(Service $resource): Collection projectName: $resource->project()->name, resourceName: $resource->name, type: 'service', - subType: $isDatabase ? 'database' : 'application', + subType: $savedService instanceof ServiceDatabase ? 'database' : 'application', subId: $savedService->id, subName: $savedService->human_name ?? $savedService->name, environment: $resource->environment->name, diff --git a/tests/Feature/TraefikServiceDockerNetworkLabelTest.php b/tests/Feature/TraefikServiceDockerNetworkLabelTest.php index c098a2e93a..cdd4136e65 100644 --- a/tests/Feature/TraefikServiceDockerNetworkLabelTest.php +++ b/tests/Feature/TraefikServiceDockerNetworkLabelTest.php @@ -47,3 +47,38 @@ YAML, expect($labels->values()->all())->toContain("traefik.docker.network={$service->uuid}"); }); + +it('labels an existing service application from its stored type after its image changes', function () { + Bus::fake(); + + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $service = Service::factory()->create([ + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => <<<'YAML' +services: + app: + image: postgres:17 +YAML, + ]); + + $serviceApplication = ServiceApplication::create([ + 'name' => 'app', + 'service_id' => $service->id, + 'image' => 'nginx:latest', + ]); + + $parsedCompose = serviceParser($service); + $labels = collect(data_get($parsedCompose, 'services.app.labels')); + + expect($serviceApplication->fresh()->image)->toBe('postgres:17') + ->and($labels->values()->all()) + ->toContain('coolify.service.subType=application') + ->not->toContain('coolify.service.subType=database'); +}); From fde274c358627a4f1f6f9fe1ff29b0c018002bbe Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:02:49 +0200 Subject: [PATCH 4/4] chore(development): switch MinIO client to AIStor image --- .ai/lessons.md | 4 ++++ docker-compose-maxio.dev.yml | 4 ++-- docker-compose.dev-multi.yml | 2 +- docker-compose.dev.yml | 2 +- docker/development/Dockerfile | 4 +--- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.ai/lessons.md b/.ai/lessons.md index 711eb544a8..826e1bb5a6 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -95,3 +95,7 @@ ## Do not auto-heal existing deployments without a request - When a parser or label fix can apply only after container recreation, keep the change limited to new deployments and later user-initiated redeployments unless the user explicitly asks for live reconciliation. - Do not add status lookup fallbacks that alter existing deployment behavior when the requested scope is new deployments only. + +## Trace image replacements through build stages +- When replacing a container image for development, inspect both Compose services and every development Dockerfile `FROM` stage. +- A successful Compose pull does not prove the application build is free of the old image; validate the complete build dependency chain. diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml index d408ff94fc..19bc9e6a20 100644 --- a/docker-compose-maxio.dev.yml +++ b/docker-compose-maxio.dev.yml @@ -162,7 +162,7 @@ services: networks: - coolify # maxio-init: - # image: minio/mc:latest + # image: quay.io/minio/aistor/mc:latest # pull_policy: always # container_name: coolify-maxio-init # restart: no @@ -182,7 +182,7 @@ services: # networks: # - coolify minio-init: - image: minio/mc:latest + image: quay.io/minio/aistor/mc:latest pull_policy: always container_name: coolify-minio-init restart: no diff --git a/docker-compose.dev-multi.yml b/docker-compose.dev-multi.yml index 3ce7b4bf3c..8ddcfc8f92 100644 --- a/docker-compose.dev-multi.yml +++ b/docker-compose.dev-multi.yml @@ -195,7 +195,7 @@ services: minio-init: profiles: ["minio"] - image: minio/mc:latest + image: quay.io/minio/aistor/mc:latest pull_policy: always restart: "no" depends_on: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 76aa0b88eb..276912717d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -150,7 +150,7 @@ services: networks: - coolify minio-init: - image: minio/mc:latest + image: quay.io/minio/aistor/mc:latest pull_policy: always container_name: coolify-minio-init restart: no diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index eb4970bd8c..ee9105ee02 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -1,8 +1,6 @@ # Versions # https://hub.docker.com/r/serversideup/php/tags?name=8.4-fpm-nginx-alpine ARG SERVERSIDEUP_PHP_VERSION=8.4-fpm-nginx-alpine -# https://github.com/minio/mc/releases -ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z # https://github.com/cloudflare/cloudflared/releases ARG CLOUDFLARED_VERSION=2025.7.0 # https://www.postgresql.org/support/versioning/ @@ -14,7 +12,7 @@ ARG NGINX_VERSION=1.31.2-r1 # ================================================================= # Get MinIO client # ================================================================= -FROM minio/mc:${MINIO_VERSION} AS minio-client +FROM quay.io/minio/aistor/mc:latest AS minio-client # ================================================================= # Final Stage: Production image