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
parent
7e6c01d5fa
commit
c253e79c0c
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 host’s containers.',
|
||||
|
||||
'unavailable_title' => 'Docker unavailable',
|
||||
'unavailable_hint' => 'The daemon is down, or no permission.',
|
||||
'not_installed_title' => 'Docker not installed',
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue