diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index af649692b2..9ac26b243c 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -58,7 +58,7 @@ class StopApplication } if ($dockerCleanup) { - CleanupDocker::dispatch($server, false, false); + CleanupDocker::dispatchAfterStop($server); } } catch (\Exception $e) { $errors[] = $e->getMessage(); diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index e830678fb1..ecdffdeda3 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -41,7 +41,7 @@ class StopDatabase } if ($dockerCleanup) { - CleanupDocker::dispatch($server, false, false); + CleanupDocker::dispatchAfterStop($server); } if ($database->is_public) { diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php index cde75e30de..201f86b367 100644 --- a/app/Actions/Server/CleanupDocker.php +++ b/app/Actions/Server/CleanupDocker.php @@ -7,6 +7,7 @@ use App\Support\Actions\UniqueUntilProcessingJobDecorator; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Decorators\JobDecorator; use Lorisleiva\Actions\Decorators\UniqueJobDecorator; @@ -34,6 +35,12 @@ class CleanupDocker implements ShouldBeUnique */ public const QUEUED_DELAY = 60; + /** + * A resource stop does not queue a cleanup when a cleanup finished on the server in this + * window. The scheduled cleanup still runs. + */ + public const STOP_CLEANUP_COOLDOWN = 3600; + public int $jobTries = 1; /** @@ -71,6 +78,24 @@ class CleanupDocker implements ShouldBeUnique return new UniqueUntilProcessingJobDecorator(static::class, ...$arguments); } + public static function lastRunCacheKey(Server $server): string + { + return 'docker-cleanup-last-run-'.$server->uuid; + } + + /** + * Cleanup after a resource stop: skipped when a cleanup ran recently, and the queued run + * only cleans when the disk usage is at or above the server cleanup threshold. + */ + public static function dispatchAfterStop(Server $server): void + { + if (Cache::has(self::lastRunCacheKey($server))) { + return; + } + + static::dispatch($server, false, false, true); + } + public function getJobUniqueId(Server $server): string { return $server->uuid; @@ -95,9 +120,16 @@ class CleanupDocker implements ShouldBeUnique ]; } - public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false) + public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false, bool $onlyAboveThreshold = false) { $this->deadline = now()->getTimestamp() + self::REMOTE_COMMANDS_DEADLINE; + + if ($onlyAboveThreshold) { + $diskUsage = $server->getDiskUsage(); + if (is_numeric($diskUsage) && (int) $diskUsage < $server->settings->docker_cleanup_threshold) { + return []; + } + } $helperImageVersion = getHelperVersion(); $helperImage = coolifyHelperImage(); $helperImageWithVersion = "$helperImage:$helperImageVersion"; @@ -155,6 +187,8 @@ class CleanupDocker implements ShouldBeUnique } } + Cache::put(self::lastRunCacheKey($server), true, self::STOP_CLEANUP_COOLDOWN); + return $cleanupLog; } diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index f9d65d1ed1..ce6d7e25ff 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -62,7 +62,7 @@ class StopService $service->deleteConnectedNetworks(); } if ($dockerCleanup) { - CleanupDocker::dispatch($server, false, false); + CleanupDocker::dispatchAfterStop($server); } } catch (\Exception $e) { return $e->getMessage(); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 1b3ba70694..ba762aff74 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2926,7 +2926,7 @@ class ApplicationsController extends Controller ), new OA\Parameter(name: 'delete_configurations', in: 'query', required: false, description: 'Delete configurations.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_volumes', in: 'query', required: false, description: 'Delete volumes.', schema: new OA\Schema(type: 'boolean', default: true)), - new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_connected_networks', in: 'query', required: false, description: 'Delete connected networks.', schema: new OA\Schema(type: 'boolean', default: true)), ], responses: [ @@ -4553,7 +4553,7 @@ class ApplicationsController extends Controller new OA\Parameter( name: 'docker_cleanup', in: 'query', - description: 'Perform docker cleanup (prune networks, volumes, etc.).', + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', schema: new OA\Schema( type: 'boolean', default: true, diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 91c68d6e4c..ed3e18ae17 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2738,7 +2738,7 @@ class DatabasesController extends Controller ), new OA\Parameter(name: 'delete_configurations', in: 'query', required: false, description: 'Delete configurations.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_volumes', in: 'query', required: false, description: 'Delete volumes.', schema: new OA\Schema(type: 'boolean', default: true)), - new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_connected_networks', in: 'query', required: false, description: 'Delete connected networks.', schema: new OA\Schema(type: 'boolean', default: true)), ], responses: [ @@ -3428,7 +3428,7 @@ class DatabasesController extends Controller new OA\Parameter( name: 'docker_cleanup', in: 'query', - description: 'Perform docker cleanup (prune networks, volumes, etc.).', + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', schema: new OA\Schema( type: 'boolean', default: true, diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 1737357c2c..74872c17ae 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1005,7 +1005,7 @@ class ServicesController extends Controller new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Service UUID', schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'delete_configurations', in: 'query', required: false, description: 'Delete configurations.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_volumes', in: 'query', required: false, description: 'Delete volumes.', schema: new OA\Schema(type: 'boolean', default: true)), - new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup.', schema: new OA\Schema(type: 'boolean', default: true)), + new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_connected_networks', in: 'query', required: false, description: 'Delete connected networks.', schema: new OA\Schema(type: 'boolean', default: true)), new OA\Parameter(name: 'delete_from_coolify_only', in: 'query', required: false, description: 'Remove only Coolify metadata without deleting Docker resources.', schema: new OA\Schema(type: 'boolean', default: false)), ], @@ -2184,7 +2184,7 @@ class ServicesController extends Controller new OA\Parameter( name: 'docker_cleanup', in: 'query', - description: 'Perform docker cleanup (prune networks, volumes, etc.).', + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', schema: new OA\Schema( type: 'boolean', default: true, diff --git a/openapi.json b/openapi.json index 9e6bf53c3e..f7a5c98442 100644 --- a/openapi.json +++ b/openapi.json @@ -2919,7 +2919,7 @@ { "name": "docker_cleanup", "in": "query", - "description": "Run docker cleanup.", + "description": "Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.", "required": false, "schema": { "type": "boolean", @@ -4359,7 +4359,7 @@ { "name": "docker_cleanup", "in": "query", - "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "description": "Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.", "schema": { "type": "boolean", "default": true @@ -6512,7 +6512,7 @@ { "name": "docker_cleanup", "in": "query", - "description": "Run docker cleanup.", + "description": "Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.", "required": false, "schema": { "type": "boolean", @@ -8768,7 +8768,7 @@ { "name": "docker_cleanup", "in": "query", - "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "description": "Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.", "schema": { "type": "boolean", "default": true @@ -18942,7 +18942,7 @@ { "name": "docker_cleanup", "in": "query", - "description": "Run docker cleanup.", + "description": "Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.", "required": false, "schema": { "type": "boolean", @@ -19895,7 +19895,7 @@ { "name": "docker_cleanup", "in": "query", - "description": "Perform docker cleanup (prune networks, volumes, etc.).", + "description": "Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.", "schema": { "type": "boolean", "default": true diff --git a/openapi.yaml b/openapi.yaml index 68950d480a..5ad716cc32 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1931,7 +1931,7 @@ paths: - name: docker_cleanup in: query - description: 'Run docker cleanup.' + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.' required: false schema: type: boolean @@ -2841,7 +2841,7 @@ paths: - name: docker_cleanup in: query - description: 'Perform docker cleanup (prune networks, volumes, etc.).' + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.' schema: type: boolean default: true @@ -4203,7 +4203,7 @@ paths: - name: docker_cleanup in: query - description: 'Run docker cleanup.' + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.' required: false schema: type: boolean @@ -5765,7 +5765,7 @@ paths: - name: docker_cleanup in: query - description: 'Perform docker cleanup (prune networks, volumes, etc.).' + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.' schema: type: boolean default: true @@ -11971,7 +11971,7 @@ paths: - name: docker_cleanup in: query - description: 'Run docker cleanup.' + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.' required: false schema: type: boolean @@ -12547,7 +12547,7 @@ paths: - name: docker_cleanup in: query - description: 'Perform docker cleanup (prune networks, volumes, etc.).' + description: 'Run docker cleanup when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.' schema: type: boolean default: true diff --git a/tests/Feature/DockerCleanupJobTest.php b/tests/Feature/DockerCleanupJobTest.php index eeedb60559..85607cabf4 100644 --- a/tests/Feature/DockerCleanupJobTest.php +++ b/tests/Feature/DockerCleanupJobTest.php @@ -292,6 +292,40 @@ it('accepts a new queued cleanup once the waiting one is lost', function () { Queue::assertPushed(UniqueUntilProcessingJobDecorator::class, 2); }); +it('does not queue a cleanup after a stop when a cleanup ran on the server recently', function () { + Queue::fake(); + $cleanedServer = dockerCleanupReachableServer(); + $otherServer = dockerCleanupReachableServer(); + Cache::put(CleanupDocker::lastRunCacheKey($cleanedServer), true, CleanupDocker::STOP_CLEANUP_COOLDOWN); + + CleanupDocker::dispatchAfterStop($cleanedServer); + CleanupDocker::dispatchAfterStop($otherServer); + + Queue::assertPushed(UniqueUntilProcessingJobDecorator::class, 1); + Queue::assertPushed(UniqueUntilProcessingJobDecorator::class, fn (JobDecorator $job) => $job->getParameters()[0]->is($otherServer)); +}); + +it('cleans after a stop only when the disk usage reaches the server threshold', function (string $diskUsage, bool $cleans) { + $server = dockerCleanupReachableServer(); + $server->settings()->update(['docker_cleanup_threshold' => 80]); + $commands = collect(); + Process::fake(function ($process) use ($commands, $diskUsage) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $commands->push($command); + + return Process::result(output: str_contains($command, 'df / --output=pcent') ? $diskUsage : ''); + }); + + CleanupDocker::run($server->fresh(), false, false, true); + + expect($commands->contains(fn (string $command) => str_contains($command, 'docker builder prune')))->toBe($cleans) + ->and(Cache::has(CleanupDocker::lastRunCacheKey($server)))->toBe($cleans); +})->with([ + 'below threshold' => ['79', false], + 'at threshold' => ['80', true], + 'unknown usage' => ['', true], +]); + it('scans application images with a fixed number of remote commands and keeps the retention rules', function () { $server = dockerCleanupReachableServer(); $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();