feat(docker): show the Clusev host's own containers (host target)

The Docker page only ever targeted fleet servers, so an operator whose
containers run ON the Clusev host itself (the clusev stack + anything
else on that machine) saw nothing — the page has "nothing to do with
the fleet" from their point of view. It now has a target switch:

  • Clusev host (default when a host SSH login exists) — lists the
    containers on the machine Clusev runs on, reached over the local
    Docker gateway with the existing encrypted HostCredential, the same
    login the host terminal uses.
  • the active fleet server — the previous behaviour, still available.

Mechanics:
- HostCredential::toServer() builds a TRANSIENT Server (never persisted)
  carrying the host login; DockerService/FleetService run unchanged
  against it. The logs modal resolves serverId 0 to this host server.
- VerifiesHostKey only pins (writes ssh_host_key) for persisted fleet
  servers now; a transient host connection no longer spawns a junk
  fleet row on every request. The host is the local gateway — no
  network path to MITM — so skipping the pin there is safe.
- The `app` service (dev + prod compose) gains the
  host.docker.internal:host-gateway mapping the terminal sidecar already
  had, so the PHP layer can reach the host's sshd. Without it the app
  container cannot resolve the host at all — the real reason host Docker
  never worked.
- Absent runtime still renders the clean "not installed" state; a
  host target with no login yet shows a setup hint.

Verified end-to-end in a browser: the page defaults to the host and
lists the full running stack (clusev-app/mariadb/redis/… + others) with
Logs/Restart/Stop actions. 748 tests green (5 new host-target tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 22:43:36 +02:00
parent 7e6c01d5fa
commit c253e79c0c
10 changed files with 256 additions and 23 deletions

View File

@ -4,6 +4,8 @@ namespace App\Livewire\Docker;
use App\Livewire\Concerns\WithFleetContext;
use App\Models\AuditEvent;
use App\Models\HostCredential;
use App\Models\Server;
use App\Services\DockerService;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
@ -13,15 +15,20 @@ use Livewire\Component;
use Throwable;
/**
* Container management for the active fleet server (agentless over SSH). Viewing the list is open
* to any role; container actions (start/stop/restart/pause) require `operate`, matching how systemd
* service actions are gated. Loads lazily via wire:init like Files/Services.
* Container management over SSH. Two targets: the CLUSEV HOST itself (the machine Clusev runs on,
* reached via the stored HostCredential over the local Docker gateway) and any fleet server. The
* host is the default when configured that is where the operator's own containers live. Viewing
* the list is open to any role; container actions (start/stop/restart) require `operate`, matching
* systemd service gating. Loads lazily via wire:init like Files/Services.
*/
#[Layout('layouts.app')]
class Index extends Component
{
use WithFleetContext;
/** '' = auto (host when a host login exists, else the active fleet server); or 'host' / 'server'. */
public string $target = '';
/** @var array<int, array<string, string>> */
public array $containers = [];
@ -29,9 +36,12 @@ class Index extends Component
public bool $ready = false;
/** Docker binary absent on the host (e.g. a native mail server) — an honest state, not an error. */
/** Docker binary absent on the target (e.g. a native mail server) — an honest state, not an error. */
public bool $notInstalled = false;
/** Host target selected but no host SSH login configured yet — points the operator to setup. */
public bool $hostNotConfigured = false;
/** The docker error (daemon down / permission / rootless), surfaced so "empty" isn't misleading. */
public ?string $error = null;
@ -40,22 +50,65 @@ class Index extends Component
return __('docker.title');
}
/** Whether a Clusev-host SSH login exists (controls the target switch + the default target). */
public function hostConfigured(): bool
{
return HostCredential::current() !== null;
}
/** Resolve '' to a concrete target: the host when one is configured, otherwise a fleet server. */
public function effectiveTarget(): string
{
if ($this->target === 'host' || $this->target === 'server') {
return $this->target;
}
return $this->hostConfigured() ? 'host' : 'server';
}
/** The Server the current target resolves to (transient host server, or the active fleet server). */
private function targetServer(): ?Server
{
return $this->effectiveTarget() === 'host'
? HostCredential::current()?->toServer()
: $this->activeServer();
}
/** Switch between the host and the active fleet server, then reload. */
public function setTarget(string $target, DockerService $docker): void
{
$this->target = in_array($target, ['host', 'server'], true) ? $target : '';
$this->ready = false;
$this->load($docker);
}
public function load(DockerService $docker): void
{
$this->containers = [];
$this->connected = false;
$this->notInstalled = false;
$this->hostNotConfigured = false;
$this->error = null;
$active = $this->activeServer();
if ($active && $active->credential_exists) {
$isHost = $this->effectiveTarget() === 'host';
$server = $this->targetServer();
if ($isHost && ! $server) {
$this->hostNotConfigured = true;
$this->ready = true;
return;
}
// Fleet servers need a stored credential; the host server always carries the host login.
if ($server && ($isHost || $server->credential_exists)) {
try {
// Probe first: a host with no docker binary (native mail/web server) gets a clean
// Probe first: a target with no docker binary (native mail/web server) gets a clean
// "not installed" panel instead of the raw "sh: docker: not found" shell error.
if (! $docker->available($active)) {
if (! $docker->available($server)) {
$this->notInstalled = true;
} else {
$this->containers = $docker->containers($active);
$this->containers = $docker->containers($server);
$this->connected = true;
}
} catch (Throwable $e) {
@ -71,19 +124,20 @@ class Index extends Component
{
abort_unless(Auth::user()?->can('operate'), 403);
$active = $this->activeServer();
if (! $active || ! $active->credential_exists) {
$isHost = $this->effectiveTarget() === 'host';
$server = $this->targetServer();
if (! $server || (! $isHost && ! $server->credential_exists)) {
return;
}
try {
$res = $docker->containerAction($active, $id, $op);
$res = $docker->containerAction($server, $id, $op);
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $active->id,
'server_id' => $server->id, // null for the host (transient) — the target label names it
'actor' => Auth::user()?->name ?? 'system',
'action' => 'docker.action',
'target' => "{$op} {$id} · {$active->name}",
'target' => "{$op} {$id} · {$server->name}",
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: $res['ok']
@ -103,13 +157,15 @@ class Index extends Component
// operate action — consistent with gating file CONTENT reads. The list itself stays open.
abort_unless(Auth::user()?->can('operate'), 403);
$active = $this->activeServer();
if (! $active) {
$isHost = $this->effectiveTarget() === 'host';
$server = $this->targetServer();
if (! $server) {
return;
}
$this->dispatch('openModal', component: 'modals.container-logs', arguments: [
'serverId' => $active->id,
// 0 is the sentinel the logs modal resolves to the Clusev host (no persisted id).
'serverId' => $isHost ? 0 : (int) $server->id,
'ref' => $id,
]);
}

View File

@ -2,6 +2,7 @@
namespace App\Livewire\Modals;
use App\Models\HostCredential;
use App\Models\Server;
use App\Services\DockerService;
use Illuminate\Support\Facades\Auth;
@ -38,7 +39,10 @@ class ContainerLogs extends ModalComponent
// Re-gate the actual read (not just the opener): logs can leak secrets → operate only.
abort_unless(Auth::user()?->can('operate'), 403);
$server = Server::find($this->serverId);
// serverId 0 = the Clusev host (transient server from the host login); otherwise a fleet server.
$server = $this->serverId === 0
? HostCredential::current()?->toServer()
: Server::find($this->serverId);
if (! $server) {
$this->error = __('common.server_not_found');
$this->loaded = true;

View File

@ -28,4 +28,29 @@ class HostCredential extends Model
{
return static::query()->orderBy('id')->first();
}
/**
* A TRANSIENT Server (never persisted) that points SSH tooling FleetService, DockerService
* at the Clusev host itself, reusing the encrypted host login. The SshCredential is likewise
* transient; its encrypted 'secret' cast round-trips the plaintext back for CredentialVault.
* verifyHostKey skips pinning a non-persisted server, so this creates no fleet row.
*/
public function toServer(): Server
{
$cred = new SshCredential(['username' => $this->username, 'auth_type' => $this->auth_type]);
$cred->secret = $this->secret;
if ($this->passphrase) {
$cred->passphrase = $this->passphrase;
}
$server = new Server([
'name' => 'Clusev Host',
'ip' => $this->host,
'ssh_port' => $this->port,
'status' => 'online',
]);
$server->setRelation('credential', $cred);
return $server;
}
}

View File

@ -32,7 +32,13 @@ trait VerifiesHostKey
return;
}
// trust on first use
// Trust on first use — but ONLY pin persisted fleet servers. A transient Server (the
// "Clusev host" built on the fly from HostCredential) must never be written to the DB:
// saving it would spawn a junk fleet row on every request. The host is reached over the
// local Docker gateway (host.docker.internal), which has no network path to MITM, so
// skipping the pin there is safe.
if ($server->exists) {
$server->forceFill(['ssh_host_key' => $hostKey])->save();
}
}
}

View File

@ -32,6 +32,10 @@ x-image: &image
security_opt:
- "no-new-privileges:true"
pids_limit: 1024
# Reach the Docker host's sshd from the PHP layer so the "Clusev host" Docker view (and any other
# host-targeted SSH) can talk to the real machine — same mapping the terminal sidecar uses.
extra_hosts:
- "host.docker.internal:host-gateway"
services:
app:

View File

@ -12,6 +12,11 @@ x-app: &app
APP_GID: "${HOST_GID:-1002}"
image: clusev-app:dev
restart: unless-stopped
# Reach the Docker host's sshd from the PHP layer so the "Clusev host" Docker view (and any
# other host-targeted SSH) can talk to the real machine (host.docker.internal → host gateway),
# the same mapping the terminal sidecar already uses.
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- .:/var/www/html
- ./run:/var/www/html/storage/app/restart-signal

View File

@ -7,6 +7,12 @@ return [
'connected' => 'Verbunden',
'disconnected' => 'Getrennt',
'target_label' => 'Ziel',
'host_target' => 'Clusev-Host',
'server_target' => 'Server',
'host_unconfigured_title' => 'Host-SSH nicht eingerichtet',
'host_unconfigured_hint' => 'Richte den Host-SSH-Zugang unter Terminal (Kachel „Clusev-Host“) ein, um die Container dieses Hosts zu sehen.',
'unavailable_title' => 'Docker nicht verfügbar',
'unavailable_hint' => 'Daemon aus oder keine Berechtigung.',
'not_installed_title' => 'Docker nicht installiert',

View File

@ -7,6 +7,12 @@ return [
'connected' => 'Connected',
'disconnected' => 'Disconnected',
'target_label' => 'Target',
'host_target' => 'Clusev host',
'server_target' => 'Server',
'host_unconfigured_title' => 'Host SSH not configured',
'host_unconfigured_hint' => 'Set up the host SSH login under Terminal (the “Clusev host” tile) to see this hosts containers.',
'unavailable_title' => 'Docker unavailable',
'unavailable_hint' => 'The daemon is down, or no permission.',
'not_installed_title' => 'Docker not installed',

View File

@ -6,6 +6,10 @@
in_array($s, ['created', 'restarting'], true) => 'pending',
default => 'offline', // exited / dead / removing
};
$hostOn = $this->hostConfigured();
$effTarget = $this->effectiveTarget();
$active = $this->activeServer();
$targetName = $effTarget === 'host' ? __('docker.host_target') : $active?->name;
@endphp
<div class="space-y-4" @if (! $ready) wire:init="load" @endif>
@ -15,10 +19,34 @@
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('docker.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('docker.title') }}</h2>
</div>
<div class="flex flex-wrap items-center gap-3">
{{-- Target switch: the Clusev host vs the active fleet server (only when a host login exists) --}}
@if ($hostOn)
<div class="inline-flex rounded-lg border border-line bg-surface p-0.5" role="tablist" aria-label="{{ __('docker.target_label') }}">
<button type="button" wire:click="setTarget('host')"
@class([
'rounded-md px-3 py-1 font-mono text-[11px] transition-colors',
'bg-accent/15 text-accent-text' => $effTarget === 'host',
'text-ink-3 hover:text-ink' => $effTarget !== 'host',
])
aria-selected="{{ $effTarget === 'host' ? 'true' : 'false' }}">{{ __('docker.host_target') }}</button>
<button type="button" wire:click="setTarget('server')" @disabled(! $active)
@class([
'rounded-md px-3 py-1 font-mono text-[11px] transition-colors',
'bg-accent/15 text-accent-text' => $effTarget === 'server',
'text-ink-3 hover:text-ink' => $effTarget !== 'server',
'cursor-not-allowed opacity-40' => ! $active,
])
aria-selected="{{ $effTarget === 'server' ? 'true' : 'false' }}">{{ $active?->name ?? __('docker.server_target') }}</button>
</div>
@endif
<x-status-pill :status="$connected ? 'online' : 'offline'">{{ $connected ? __('docker.connected') : __('docker.disconnected') }}</x-status-pill>
</div>
</div>
<x-panel :title="__('docker.containers_title')" :subtitle="$this->activeServer()?->name" :padded="false">
<x-panel :title="__('docker.containers_title')" :subtitle="$targetName" :padded="false">
@if (! $ready)
{{-- lazy skeleton --}}
<div class="space-y-2 p-4 sm:p-5">
@ -26,8 +54,17 @@
<div class="h-12 animate-pulse rounded-md bg-raised/50"></div>
@endfor
</div>
@elseif ($hostNotConfigured)
{{-- Host target picked but no host SSH login yet send the operator to setup. --}}
<div class="px-4 py-10 text-center sm:px-5">
<div class="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-raised text-ink-3">
<x-icon name="terminal" class="h-5 w-5" />
</div>
<p class="mt-3 text-sm text-ink-2">{{ __('docker.host_unconfigured_title') }}</p>
<p class="mx-auto mt-1 max-w-md font-mono text-[11px] text-ink-4">{{ __('docker.host_unconfigured_hint') }}</p>
</div>
@elseif ($notInstalled)
{{-- Honest, neutral state: the host simply has no docker (not a fault). --}}
{{-- Honest, neutral state: the target simply has no docker (not a fault). --}}
<div class="px-4 py-10 text-center sm:px-5">
<div class="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-raised text-ink-3">
<x-icon name="box" class="h-5 w-5" />

View File

@ -4,6 +4,7 @@ namespace Tests\Feature;
use App\Livewire\Docker\Index;
use App\Livewire\Modals\ContainerLogs;
use App\Models\HostCredential;
use App\Models\Server;
use App\Models\User;
use App\Services\DockerService;
@ -56,6 +57,89 @@ class DockerComponentTest extends TestCase
return $docker;
}
private function hostCred(): HostCredential
{
return HostCredential::create([
'host' => 'host.docker.internal', 'port' => 22,
'username' => 'root', 'auth_type' => 'password', 'secret' => 'x',
]);
}
public function test_defaults_to_the_clusev_host_when_a_host_login_is_configured(): void
{
// The host is where the operator's own containers live, so it is the default target.
$this->actingAs($this->viewer());
$this->hostCred();
$this->activeServer(); // a fleet server also exists, but the host wins the default
$this->stubDocker([['id' => 'h1', 'name' => 'clusev-app-1', 'image' => 'clusev-app:prod', 'state' => 'running', 'status' => 'Up', 'ports' => '']]);
Livewire::test(Index::class)
->call('load')
->assertOk()
->assertSet('connected', true)
->assertSee('clusev-app-1')
->assertSee(__('docker.host_target'));
}
public function test_switching_to_host_without_a_login_shows_the_unconfigured_state(): void
{
$this->actingAs($this->viewer());
$this->activeServer();
$this->stubDocker();
Livewire::test(Index::class)
->call('setTarget', 'host')
->assertOk()
->assertSet('hostNotConfigured', true)
->assertSet('connected', false)
->assertSee(__('docker.host_unconfigured_title'));
}
public function test_can_switch_from_the_host_to_the_active_fleet_server(): void
{
$this->actingAs($this->viewer());
$this->hostCred();
$this->activeServer();
$this->stubDocker([['id' => 's1', 'name' => 'web', 'image' => 'nginx', 'state' => 'running', 'status' => 'Up', 'ports' => '80/tcp']]);
Livewire::test(Index::class)
->call('setTarget', 'server')
->assertOk()
->assertSet('connected', true)
->assertSee('web');
}
public function test_operator_can_action_a_host_container_and_it_is_audited(): void
{
$this->actingAs($this->operator());
$this->hostCred(); // default target = host
$docker = $this->stubDocker();
$docker->shouldReceive('containerAction')->once()
->with(Mockery::type(Server::class), 'clusev-redis-1', 'restart')
->andReturn(['ok' => true, 'output' => '']);
Livewire::test(Index::class)
->call('action', 'clusev-redis-1', 'restart')
->assertOk();
// Host actions carry a null server_id (transient host server); the target label names it.
$this->assertDatabaseHas('audit_events', ['action' => 'docker.action', 'server_id' => null]);
}
public function test_logs_modal_loads_a_host_container_tail(): void
{
$this->actingAs($this->admin());
$this->hostCred();
$docker = Mockery::mock(DockerService::class);
$docker->shouldReceive('logs')->once()->with(Mockery::type(Server::class), 'clusev-app-1', 200)->andReturn('host log line');
app()->instance(DockerService::class, $docker);
Livewire::test(ContainerLogs::class, ['serverId' => 0, 'ref' => 'clusev-app-1'])
->call('load')
->assertOk()
->assertSet('logs', 'host log line');
}
public function test_viewer_can_browse_the_container_list(): void
{
$this->actingAs($this->viewer());