diff --git a/app/Livewire/Server/DockerImages.php b/app/Livewire/Server/DockerImages.php new file mode 100644 index 0000000000..dab873c650 --- /dev/null +++ b/app/Livewire/Server/DockerImages.php @@ -0,0 +1,161 @@ +server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->parameters = get_route_parameters(); + } catch (\Throwable) { + return redirect()->route('server.index'); + } + } + + public function load(): void + { + try { + if (! $this->server->isFunctional()) { + return; + } + $containersByImage = $this->containersByImage(); + $this->images = format_docker_command_output_to_json( + instant_remote_process(["docker image ls --no-trunc --format '{{json .}}'"], $this->server, false) + ) + ->map(fn (array $image) => $this->summarize($image, $containersByImage)) + ->values() + ->all(); + $this->usage = $this->diskUsage(); + } catch (\Throwable $e) { + handleError($e, $this); + } finally { + $this->loaded = true; + } + } + + public function delete(string $reference, string $password = ''): void + { + try { + $this->authorize('update', $this->server); + $image = collect($this->images)->firstWhere('reference', $reference); + if (! $image) { + throw new \RuntimeException('Unknown image, refresh the page and try again.'); + } + if ($image['containers'] !== []) { + throw new \RuntimeException('This image is used by '.implode(', ', $image['containers']).'. Remove those containers first.'); + } + instant_remote_process(['docker image rm '.escapeshellarg($reference)], $this->server); + auditLog('ui.server.docker_image_deleted', [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'image' => $reference, + ]); + $this->dispatch('success', "Deleted {$reference}."); + $this->load(); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + /** + * Map every image ID to the containers using it. Containers are inspected instead of + * read from `docker ps --format {{.Image}}`, because that column shows whatever + * reference the container was started with, which may be a tag that has since moved. + * + * @return array> + */ + private function containersByImage(): array + { + $output = instant_remote_process( + ["docker ps -a --no-trunc --format '{{.ID}}' | xargs -r docker container inspect --format '{{.Image}}#{{.Name}}' 2>/dev/null || true"], + $this->server, + false + ); + + return str($output ?? '')->explode("\n") + ->map(fn ($line) => explode('#', trim($line), 2)) + ->filter(fn ($parts) => count($parts) === 2 && $parts[0] !== '') + ->groupBy(fn ($parts) => $parts[0]) + ->map(fn ($rows) => $rows->map(fn ($parts) => ltrim($parts[1], '/'))->sort()->values()->all()) + ->all(); + } + + /** + * Total and reclaimable image size as reported by Docker itself, so shared layers + * are not counted twice the way summing the per-image sizes would. + */ + private function diskUsage(): ?array + { + $row = format_docker_command_output_to_json( + instant_remote_process(["docker system df --format '{{json .}}'"], $this->server, false) + )->firstWhere('Type', 'Images'); + + if (! $row) { + return null; + } + + return [ + 'size' => data_get($row, 'Size'), + 'reclaimable' => data_get($row, 'Reclaimable'), + ]; + } + + private function summarize(array $image, array $containersByImage): array + { + $repository = (string) data_get($image, 'Repository'); + $tag = (string) data_get($image, 'Tag'); + $id = (string) data_get($image, 'ID'); + $isDangling = $repository === '' || $tag === ''; + + return [ + 'id' => $id, + 'short_id' => str($id)->after('sha256:')->limit(12, '')->value(), + 'repository' => $repository, + 'tag' => $tag, + 'dangling' => $isDangling, + // Remove the tag when there is one: an image ID with several tags cannot be + // removed by ID, Docker refuses with "image is referenced in multiple repositories". + 'reference' => $isDangling ? $id : "{$repository}:{$tag}", + 'size' => data_get($image, 'Size'), + 'created' => data_get($image, 'CreatedSince'), + 'containers' => $containersByImage[$id] ?? [], + ]; + } + + public function render() + { + $search = trim($this->search); + $images = collect($this->images); + if ($search !== '') { + $images = $images->filter(fn (array $image) => str($image['repository'].':'.$image['tag'].' '.$image['short_id']) + ->contains($search, ignoreCase: true)); + } + + return view('livewire.server.docker-images', [ + // Not "images": Livewire shares public properties with the view, so that name is + // already taken by the unfiltered array. + 'visibleImages' => $images->values(), + ]); + } +} diff --git a/resources/css/app.css b/resources/css/app.css index 746162b112..8fe70dd276 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -3152,6 +3152,11 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(9rem, 1fr) minmax(12rem, 1.5fr) 7rem minmax(9rem, auto); } +.server-images-table-grid { + grid-template-columns: minmax(10rem, 1.6fr) minmax(5rem, 0.8fr) 7.5rem 6.5rem 4.5rem minmax(8rem, 1fr) 6.5rem; + min-width: 55rem; +} + .package-updates-table-grid { grid-template-columns: minmax(12rem, 1.4fr) minmax(10rem, 1fr) 6rem; } diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 3645dd2a27..23352ab5a1 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -119,6 +119,14 @@ 'group' => 'Networking', 'visible' => $server->team->usesSwarm() && ! $server->isBuildServer() && ! $server->settings->is_cloudflare_tunnel, ], + [ + 'label' => 'Images', + 'route' => 'server.docker-images', + 'active' => $activeMenu === 'docker-images', + 'icon' => 'layers', + 'group' => 'Operations', + 'visible' => $server->isFunctional(), + ], [ 'label' => 'Docker Cleanup', 'route' => 'server.docker-cleanup', diff --git a/resources/views/livewire/server/docker-images.blade.php b/resources/views/livewire/server/docker-images.blade.php new file mode 100644 index 0000000000..948783902f --- /dev/null +++ b/resources/views/livewire/server/docker-images.blade.php @@ -0,0 +1,110 @@ +
+ + {{ data_get_str($server, 'name')->limit(10) }} > Images | Coolify + + + + +
+ + +
+ + + @if ($usage) + + + @endif + + + Refresh + + + +
+
+ + +
+
+ + @if (!$loaded) +
+ +
+ @elseif ($visibleImages->isEmpty()) +
+ +
+ @else +
+
+ Repository + Tag + Image ID + Created + Size + Used by + Actions +
+ @foreach ($visibleImages as $image) +
+
+ {{ $image['repository'] }} +
+
+ {{ $image['tag'] }} +
+
+ + {{ $image['short_id'] }} + + +
+
+ {{ $image['created'] }} +
+
{{ $image['size'] }}
+
+ @if ($image['containers'] === []) + + @else + + {{ implode(', ', $image['containers']) }} + + @endif +
+
+ @if ($image['containers'] === []) + @can('update', $server) + + @endcan + @endif +
+
+ @endforeach +
+ @endif +
+
+
+
diff --git a/routes/web.php b/routes/web.php index a3ca3630f1..f1e125f683 100644 --- a/routes/web.php +++ b/routes/web.php @@ -64,6 +64,7 @@ use App\Livewire\Server\CreatePage as ServerCreatePage; use App\Livewire\Server\Delete as DeleteServer; use App\Livewire\Server\Destinations as ServerDestinations; use App\Livewire\Server\DockerCleanup; +use App\Livewire\Server\DockerImages; use App\Livewire\Server\Index as ServerIndex; use App\Livewire\Server\LogDrains; use App\Livewire\Server\PrivateKey\Show as PrivateKeyShow; @@ -396,6 +397,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs'); Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal'); Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup'); + Route::get('/images', DockerImages::class)->name('server.docker-images'); Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource'); Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource'); Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource'); diff --git a/tests/Feature/ServerDockerImagesTest.php b/tests/Feature/ServerDockerImagesTest.php new file mode 100644 index 0000000000..51f139930b --- /dev/null +++ b/tests/Feature/ServerDockerImagesTest.php @@ -0,0 +1,103 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true]); + $this->withoutVite(); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], [])); +}); + +$nginxId = 'sha256:'.str_repeat('a', 64); +$danglingId = 'sha256:'.str_repeat('b', 64); + +$fakeDocker = function (string $nginxId, string $danglingId) { + Process::fake([ + '*docker image ls*' => Process::result(output: implode("\n", [ + json_encode(['Repository' => 'nginx', 'Tag' => '1.27', 'ID' => $nginxId, 'Size' => '52.5MB', 'CreatedSince' => '3 weeks ago']), + json_encode(['Repository' => '', 'Tag' => '', 'ID' => $danglingId, 'Size' => '11MB', 'CreatedSince' => '2 days ago']), + ])), + '*docker ps -a*' => Process::result(output: "{$nginxId}#/web\n"), + '*docker system df*' => Process::result(output: json_encode([ + 'Type' => 'Images', 'TotalCount' => '2', 'Active' => '1', 'Size' => '63.5MB', 'Reclaimable' => '11MB (17%)', + ])), + '*docker image rm*' => Process::result(output: 'Deleted: '.$danglingId), + ]); +}; + +it('renders the images page for a server', function () use ($fakeDocker, $nginxId, $danglingId) { + $fakeDocker($nginxId, $danglingId); + + $this->get(route('server.docker-images', ['server_uuid' => $this->server->uuid])) + ->assertOk() + ->assertSee('Images'); +}); + +it('lists images and marks the ones no container uses', function () use ($fakeDocker, $nginxId, $danglingId) { + $fakeDocker($nginxId, $danglingId); + + Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid]) + ->call('load') + ->assertSet('loaded', true) + ->assertSet('images.0.repository', 'nginx') + ->assertSet('images.0.reference', 'nginx:1.27') + ->assertSet('images.0.short_id', str_repeat('a', 12)) + ->assertSet('images.0.size', '52.5MB') + ->assertSet('images.0.containers', ['web']) + ->assertSet('images.1.reference', $danglingId) + ->assertSet('images.1.dangling', true) + ->assertSet('images.1.containers', []) + ->assertSet('usage.size', '63.5MB') + ->assertSet('usage.reclaimable', '11MB (17%)') + ->assertSee('Unused') + ->assertSee('web'); +}); + +it('deletes an unused image', function () use ($fakeDocker, $nginxId, $danglingId) { + $fakeDocker($nginxId, $danglingId); + + Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid]) + ->call('load') + ->call('delete', $danglingId) + ->assertDispatched('success'); + + Process::assertRan(fn ($process) => str_contains($process->command, "docker image rm '{$danglingId}'")); +}); + +it('refuses to delete an image that a container is using', function () use ($fakeDocker, $nginxId, $danglingId) { + $fakeDocker($nginxId, $danglingId); + + Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid]) + ->call('load') + ->call('delete', 'nginx:1.27') + ->assertDispatched('error'); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'docker image rm')); +}); + +it('filters images by the search term', function () use ($fakeDocker, $nginxId, $danglingId) { + $fakeDocker($nginxId, $danglingId); + + Livewire::test(DockerImages::class, ['server_uuid' => $this->server->uuid]) + ->call('load') + ->set('search', 'nginx') + ->assertSee('1.27') + ->assertDontSee(str_repeat('b', 12)); +});