feat(docker): container management over SSH (feature 3/8)
Manage the containers running ON a fleet server, agentlessly — the integrated Portainer.
List containers + state, view logs, start/stop/restart, all via `docker` over SSH.
- DockerService (over FleetService::runPrivileged → base64 transport, shell-injection-safe):
containers() parses `docker ps -a --format '{{json .}}'`; containerAction() with an op allow-list
(start|stop|restart|pause|unpause); logs() with a clamped tail; composeStacks() read-only. Every
container ref is validated `^[a-zA-Z0-9][\w.-]*$` before interpolation — blocks shell metacharacters
AND a leading-dash argument injection (mirrors serviceAction). A missing/errored docker → [] (never
throws to the UI).
- Docker\Index page (route /docker, sidebar "Docker" with a new box icon): lazy container list for the
active server, open to any role. Container actions are `operate`-gated (abort_unless per method) and
audited (docker.action). Reading LOGS is also operate-gated (container output can leak secrets —
consistent with gating file CONTENT reads); the list/state stays open. ContainerLogs modal re-gates
the read itself.
- lang/{de,en}/docker.php + audit.php docker.action + shell.nav_docker (de/en parity). No emoji.
Codex hardening applied: logs output is byte-capped (head -c 262144) AND mb_scrub'd before it reaches
a Livewire property (invalid UTF-8 / a huge line can't break the JSON snapshot); the ref regex uses
\A…\z anchors so a trailing newline can't slip past.
16 new tests: service (json parse, empty-on-unavailable, valid-op command, reject unknown-op / shell-
metachars / leading-dash / trailing-newline ref, logs clamp+byte-cap, available probe), component
(viewer browses the list; viewer 403 on action AND on logs open + modal read; operator/admin act +
audited; logs modal tail). 685 tests green, Pint, lang parity, Codex-reviewed (fixes applied).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
parent
0472b3531a
commit
35b7c15598
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Docker;
|
||||
|
||||
use App\Livewire\Concerns\WithFleetContext;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Services\DockerService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Layout;
|
||||
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.
|
||||
*/
|
||||
#[Layout('layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
use WithFleetContext;
|
||||
|
||||
/** @var array<int, array<string, string>> */
|
||||
public array $containers = [];
|
||||
|
||||
public bool $connected = false;
|
||||
|
||||
public bool $ready = false;
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return __('docker.title');
|
||||
}
|
||||
|
||||
public function load(DockerService $docker): void
|
||||
{
|
||||
$this->containers = [];
|
||||
$this->connected = false;
|
||||
|
||||
$active = $this->activeServer();
|
||||
if ($active && $active->credential_exists) {
|
||||
try {
|
||||
$this->containers = $docker->containers($active);
|
||||
$this->connected = true;
|
||||
} catch (Throwable) {
|
||||
$this->connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
$this->ready = true;
|
||||
}
|
||||
|
||||
public function action(string $id, string $op, DockerService $docker): void
|
||||
{
|
||||
abort_unless(Auth::user()?->can('operate'), 403);
|
||||
|
||||
$active = $this->activeServer();
|
||||
if (! $active || ! $active->credential_exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = $docker->containerAction($active, $id, $op);
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $active->id,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => 'docker.action',
|
||||
'target' => "{$op} {$id} · {$active->name}",
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
$this->dispatch('notify', message: $res['ok']
|
||||
? __('docker.action_ok', ['op' => $op, 'ref' => $id])
|
||||
: __('docker.action_failed', ['error' => $res['output']]));
|
||||
} catch (InvalidArgumentException) {
|
||||
$this->dispatch('notify', message: __('docker.invalid_ref'));
|
||||
}
|
||||
|
||||
$this->load($docker);
|
||||
}
|
||||
|
||||
/** Open the read-only logs modal for a container (the ref is re-validated in DockerService). */
|
||||
public function viewLogs(string $id): void
|
||||
{
|
||||
// Container logs can contain secrets (env dumps, tokens in traces), so reading them is an
|
||||
// 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) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('openModal', component: 'modals.container-logs', arguments: [
|
||||
'serverId' => $active->id,
|
||||
'ref' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.docker.index')->title($this->title());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\DockerService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use InvalidArgumentException;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
use Throwable;
|
||||
|
||||
/** Read-only container log tail. Loads lazily; the container ref is re-validated in DockerService. */
|
||||
class ContainerLogs extends ModalComponent
|
||||
{
|
||||
public int $serverId;
|
||||
|
||||
public string $ref;
|
||||
|
||||
public string $logs = '';
|
||||
|
||||
public bool $loaded = false;
|
||||
|
||||
public ?string $error = null;
|
||||
|
||||
public function mount(int $serverId, string $ref): void
|
||||
{
|
||||
$this->serverId = $serverId;
|
||||
$this->ref = $ref;
|
||||
}
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
public function load(DockerService $docker): void
|
||||
{
|
||||
// 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);
|
||||
if (! $server) {
|
||||
$this->error = __('common.server_not_found');
|
||||
$this->loaded = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->logs = $docker->logs($server, $this->ref, 200);
|
||||
} catch (InvalidArgumentException) {
|
||||
$this->error = __('docker.invalid_ref');
|
||||
} catch (Throwable $e) {
|
||||
$this->error = $e->getMessage();
|
||||
}
|
||||
|
||||
$this->loaded = true;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.container-logs');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Server;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Manage the containers running ON a fleet server, agentlessly over SSH. Every docker call goes
|
||||
* through FleetService::runPrivileged (base64 transport → shell-injection-safe); every container
|
||||
* id/name is validated before interpolation so a crafted name can neither inject a command nor a
|
||||
* leading-dash argument. Mirrors the FleetService::serviceAction discipline.
|
||||
*/
|
||||
class DockerService
|
||||
{
|
||||
public const ACTIONS = ['start', 'stop', 'restart', 'pause', 'unpause'];
|
||||
|
||||
public function __construct(private FleetService $fleet) {}
|
||||
|
||||
public function available(Server $server): bool
|
||||
{
|
||||
$res = $this->fleet->runPrivileged($server, 'command -v docker >/dev/null 2>&1 && echo yes || echo no');
|
||||
|
||||
return trim($res['output']) === 'yes';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id:string,name:string,image:string,state:string,status:string,ports:string}>
|
||||
*/
|
||||
public function containers(Server $server): array
|
||||
{
|
||||
$res = $this->fleet->runPrivileged($server, "docker ps -a --format '{{json .}}'");
|
||||
if (! $res['ok']) {
|
||||
return []; // docker missing / daemon down / permission → empty, never throw to the UI
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach (preg_split('/\r?\n/', trim($res['output'])) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
$row = json_decode($line, true);
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'id' => (string) ($row['ID'] ?? ''),
|
||||
'name' => (string) ($row['Names'] ?? ''),
|
||||
'image' => (string) ($row['Image'] ?? ''),
|
||||
'state' => strtolower((string) ($row['State'] ?? '')),
|
||||
'status' => (string) ($row['Status'] ?? ''),
|
||||
'ports' => (string) ($row['Ports'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function containerAction(Server $server, string $id, string $op): array
|
||||
{
|
||||
if (! in_array($op, self::ACTIONS, true)) {
|
||||
throw new InvalidArgumentException('Unbekannte Aktion.');
|
||||
}
|
||||
$this->assertValidRef($id);
|
||||
|
||||
$res = $this->fleet->runPrivileged($server, "docker {$op} {$id}");
|
||||
|
||||
return ['ok' => $res['ok'], 'output' => trim($res['output'])];
|
||||
}
|
||||
|
||||
public function logs(Server $server, string $id, int $lines = 200): string
|
||||
{
|
||||
$this->assertValidRef($id);
|
||||
$lines = max(1, min(2000, $lines));
|
||||
|
||||
// Cap by BYTES too (not just lines): one huge line or a binary-ish log could otherwise bloat
|
||||
// the response or, via invalid UTF-8, break Livewire's JSON snapshot. head -c bounds it, and
|
||||
// mb_scrub drops any invalid byte sequence before it reaches a Livewire public property.
|
||||
$res = $this->fleet->runPrivileged($server, "docker logs --tail {$lines} {$id} 2>&1 | head -c 262144");
|
||||
|
||||
return mb_scrub(trim($res['output']), 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name:string,status:string}>
|
||||
*/
|
||||
public function composeStacks(Server $server): array
|
||||
{
|
||||
$res = $this->fleet->runPrivileged($server, 'docker compose ls --format json 2>/dev/null');
|
||||
if (! $res['ok']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = json_decode(trim($res['output']), true);
|
||||
if (! is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(fn ($r) => [
|
||||
'name' => (string) ($r['Name'] ?? ''),
|
||||
'status' => (string) ($r['Status'] ?? ''),
|
||||
], array_values(array_filter($rows, 'is_array')));
|
||||
}
|
||||
|
||||
/**
|
||||
* A container id or name — hex id or a docker name. MUST start alphanumeric (blocks a leading
|
||||
* dash = argument injection) and contain only [\w.-] (no shell metacharacters).
|
||||
*/
|
||||
private function assertValidRef(string $ref): void
|
||||
{
|
||||
// \A ... \z (not ^...$): \z anchors the ABSOLUTE end so a trailing newline can't sneak past.
|
||||
if (! preg_match('/\A[a-zA-Z0-9][\w.-]*\z/', $ref)) {
|
||||
throw new InvalidArgumentException('Ungueltige Container-Referenz.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ return [
|
|||
'fail2ban.configure' => 'fail2ban konfiguriert',
|
||||
'fail2ban.ignoreip_add' => 'fail2ban: Ausnahme hinzugefügt',
|
||||
'fail2ban.ignoreip_remove' => 'fail2ban: Ausnahme entfernt',
|
||||
'docker.action' => 'Container-Aktion',
|
||||
'file.edit' => 'Datei bearbeitet',
|
||||
'firewall.rule_add' => 'Firewall-Regel hinzugefügt',
|
||||
'firewall.rule_delete' => 'Firewall-Regel gelöscht',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'eyebrow' => 'Flotte',
|
||||
'title' => 'Docker',
|
||||
'containers_title' => 'Container',
|
||||
'connected' => 'Verbunden',
|
||||
'disconnected' => 'Getrennt',
|
||||
|
||||
'unavailable_title' => 'Docker nicht verfügbar',
|
||||
'unavailable_hint' => 'Kein Docker auf diesem Server, Daemon aus, oder keine Berechtigung.',
|
||||
'empty_title' => 'Keine Container',
|
||||
'empty_hint' => 'Auf diesem Server laufen keine Container.',
|
||||
|
||||
'logs' => 'Logs',
|
||||
'logs_subtitle' => 'letzte 200 Zeilen (nur lesen)',
|
||||
'logs_empty' => 'Keine Log-Ausgabe.',
|
||||
|
||||
'start' => 'Start',
|
||||
'stop' => 'Stopp',
|
||||
'restart' => 'Neustart',
|
||||
|
||||
'action_ok' => 'Aktion „:op“ auf :ref ausgeführt.',
|
||||
'action_failed' => 'Aktion fehlgeschlagen: :error',
|
||||
'invalid_ref' => 'Ungültige Container-Referenz.',
|
||||
];
|
||||
|
|
@ -22,6 +22,7 @@ return [
|
|||
'nav_terminal' => 'Terminal',
|
||||
'nav_settings' => 'Einstellungen',
|
||||
'nav_system' => 'System',
|
||||
'nav_docker' => 'Docker',
|
||||
'nav_alerts' => 'Alarme',
|
||||
'nav_threats' => 'Bedrohungen',
|
||||
'nav_versions' => 'Version',
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ return [
|
|||
'fail2ban.configure' => 'fail2ban configured',
|
||||
'fail2ban.ignoreip_add' => 'fail2ban: exception added',
|
||||
'fail2ban.ignoreip_remove' => 'fail2ban: exception removed',
|
||||
'docker.action' => 'Container action',
|
||||
'file.edit' => 'File edited',
|
||||
'firewall.rule_add' => 'Firewall rule added',
|
||||
'firewall.rule_delete' => 'Firewall rule deleted',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'eyebrow' => 'Fleet',
|
||||
'title' => 'Docker',
|
||||
'containers_title' => 'Containers',
|
||||
'connected' => 'Connected',
|
||||
'disconnected' => 'Disconnected',
|
||||
|
||||
'unavailable_title' => 'Docker unavailable',
|
||||
'unavailable_hint' => 'No Docker on this server, the daemon is down, or no permission.',
|
||||
'empty_title' => 'No containers',
|
||||
'empty_hint' => 'No containers are running on this server.',
|
||||
|
||||
'logs' => 'Logs',
|
||||
'logs_subtitle' => 'last 200 lines (read-only)',
|
||||
'logs_empty' => 'No log output.',
|
||||
|
||||
'start' => 'Start',
|
||||
'stop' => 'Stop',
|
||||
'restart' => 'Restart',
|
||||
|
||||
'action_ok' => 'Action “:op” run on :ref.',
|
||||
'action_failed' => 'Action failed: :error',
|
||||
'invalid_ref' => 'Invalid container reference.',
|
||||
];
|
||||
|
|
@ -22,6 +22,7 @@ return [
|
|||
'nav_terminal' => 'Terminal',
|
||||
'nav_settings' => 'Settings',
|
||||
'nav_system' => 'System',
|
||||
'nav_docker' => 'Docker',
|
||||
'nav_alerts' => 'Alerts',
|
||||
'nav_threats' => 'Threats',
|
||||
'nav_versions' => 'Version',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
'dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
|
||||
'server' => '<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>',
|
||||
'box' => '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
|
||||
'cpu' => '<rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>',
|
||||
'folder' => '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
|
||||
'audit' => '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
<x-nav-item icon="dashboard" href="/" :active="request()->is('/')" data-tour="dashboard">{{ __('shell.nav_dashboard') }}</x-nav-item>
|
||||
<x-nav-item icon="server" href="/servers" :active="request()->is('servers*')" data-tour="servers">{{ __('shell.nav_servers') }}</x-nav-item>
|
||||
<x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item>
|
||||
<x-nav-item icon="box" href="/docker" :active="request()->is('docker*')">{{ __('shell.nav_docker') }}</x-nav-item>
|
||||
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item>
|
||||
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item>
|
||||
@can('manage-network')
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
@php
|
||||
// container state -> status-pill status
|
||||
$pill = fn (string $s) => match (true) {
|
||||
$s === 'running' => 'online',
|
||||
$s === 'paused' => 'warning',
|
||||
in_array($s, ['created', 'restarting'], true) => 'pending',
|
||||
default => 'offline', // exited / dead / removing
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4" @if (! $ready) wire:init="load" @endif>
|
||||
{{-- Header --}}
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<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>
|
||||
<x-status-pill :status="$connected ? 'online' : 'offline'">{{ $connected ? __('docker.connected') : __('docker.disconnected') }}</x-status-pill>
|
||||
</div>
|
||||
|
||||
<x-panel :title="__('docker.containers_title')" :subtitle="$this->activeServer()?->name" :padded="false">
|
||||
@if (! $ready)
|
||||
{{-- lazy skeleton --}}
|
||||
<div class="space-y-2 p-4 sm:p-5">
|
||||
@for ($i = 0; $i < 3; $i++)
|
||||
<div class="h-12 animate-pulse rounded-md bg-raised/50"></div>
|
||||
@endfor
|
||||
</div>
|
||||
@elseif (! $connected)
|
||||
<div class="px-4 py-10 text-center sm:px-5">
|
||||
<p class="text-sm text-ink-2">{{ __('docker.unavailable_title') }}</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('docker.unavailable_hint') }}</p>
|
||||
</div>
|
||||
@elseif (empty($containers))
|
||||
<div class="px-4 py-10 text-center sm:px-5">
|
||||
<p class="text-sm text-ink-2">{{ __('docker.empty_title') }}</p>
|
||||
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('docker.empty_hint') }}</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="divide-y divide-line">
|
||||
@foreach ($containers as $c)
|
||||
<div wire:key="ctr-{{ $c['id'] }}" class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
|
||||
<x-status-dot :status="$pill($c['state'])" :ping="$c['state'] === 'running'" class="h-2.5 w-2.5" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-mono text-sm text-ink">{{ $c['name'] }}</p>
|
||||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $c['image'] }}@if ($c['ports']) · {{ $c['ports'] }}@endif</p>
|
||||
</div>
|
||||
<x-status-pill :status="$pill($c['state'])" class="hidden sm:inline-flex">{{ $c['state'] ?: '—' }}</x-status-pill>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1.5 pl-5 sm:pl-0">
|
||||
@can('operate')
|
||||
<x-btn variant="secondary" wire:click="viewLogs('{{ $c['id'] }}')">{{ __('docker.logs') }}</x-btn>
|
||||
@if ($c['state'] === 'running')
|
||||
<x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'restart')" wire:loading.attr="disabled">{{ __('docker.restart') }}</x-btn>
|
||||
<x-btn variant="danger-soft" wire:click="action('{{ $c['id'] }}', 'stop')" wire:loading.attr="disabled">{{ __('docker.stop') }}</x-btn>
|
||||
@else
|
||||
<x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'start')" wire:loading.attr="disabled">{{ __('docker.start') }}</x-btn>
|
||||
@endif
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-panel>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<div wire:init="load">
|
||||
<div class="mb-4 flex items-center gap-2.5">
|
||||
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-md border border-line bg-inset text-ink-2">
|
||||
<x-icon name="box" class="h-4 w-4" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate font-display text-base font-semibold text-ink" title="{{ $ref }}">{{ $ref }}</h2>
|
||||
<p class="font-mono text-[11px] text-ink-3">{{ __('docker.logs_subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (! $loaded)
|
||||
<div class="h-64 animate-pulse rounded-md bg-raised/50"></div>
|
||||
@elseif ($error)
|
||||
<div class="flex items-center gap-2.5 rounded-md border border-offline/25 bg-offline/10 px-3.5 py-3">
|
||||
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
|
||||
<p class="text-sm text-ink-2">{{ $error }}</p>
|
||||
</div>
|
||||
@else
|
||||
<pre class="max-h-[60vh] overflow-auto rounded-md border border-line bg-void p-3 font-mono text-[11px] leading-relaxed text-ink-2">{{ $logs !== '' ? $logs : __('docker.logs_empty') }}</pre>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -9,6 +9,7 @@ use App\Livewire\Alerts;
|
|||
use App\Livewire\Audit;
|
||||
use App\Livewire\Auth;
|
||||
use App\Livewire\Dashboard;
|
||||
use App\Livewire\Docker;
|
||||
use App\Livewire\Files;
|
||||
use App\Livewire\Help;
|
||||
use App\Livewire\Release;
|
||||
|
|
@ -222,6 +223,7 @@ Route::middleware('auth')->group(function () {
|
|||
})->name('servers.history');
|
||||
|
||||
Route::get('/services', Services\Index::class)->name('services.index');
|
||||
Route::get('/docker', Docker\Index::class)->name('docker');
|
||||
Route::get('/files', Files\Index::class)->name('files.index');
|
||||
Route::get('/audit', Audit\Index::class)->name('audit.index');
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Docker\Index;
|
||||
use App\Livewire\Modals\ContainerLogs;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use App\Services\DockerService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DockerComponentTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function admin(): User
|
||||
{
|
||||
return User::factory()->create(['must_change_password' => false]);
|
||||
}
|
||||
|
||||
private function operator(): User
|
||||
{
|
||||
return User::factory()->operator()->create(['must_change_password' => false]);
|
||||
}
|
||||
|
||||
private function viewer(): User
|
||||
{
|
||||
return User::factory()->viewer()->create(['must_change_password' => false]);
|
||||
}
|
||||
|
||||
private function activeServer(): Server
|
||||
{
|
||||
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
|
||||
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
|
||||
session(['active_server_id' => $server->id]);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
private function stubDocker(array $containers = []): DockerService
|
||||
{
|
||||
$docker = Mockery::mock(DockerService::class);
|
||||
$docker->shouldReceive('containers')->andReturn($containers);
|
||||
app()->instance(DockerService::class, $docker);
|
||||
|
||||
return $docker;
|
||||
}
|
||||
|
||||
public function test_viewer_can_browse_the_container_list(): void
|
||||
{
|
||||
$this->actingAs($this->viewer());
|
||||
$this->activeServer();
|
||||
$this->stubDocker([['id' => 'abc', 'name' => 'web', 'image' => 'nginx', 'state' => 'running', 'status' => 'Up', 'ports' => '80/tcp']]);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->call('load')
|
||||
->assertOk()
|
||||
->assertSet('connected', true)
|
||||
->assertSee('web');
|
||||
}
|
||||
|
||||
public function test_viewer_cannot_run_a_container_action(): void
|
||||
{
|
||||
$this->actingAs($this->viewer());
|
||||
$this->activeServer();
|
||||
$docker = $this->stubDocker();
|
||||
$docker->shouldReceive('containerAction')->never(); // guard fires first
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->call('action', 'web', 'stop')
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_operator_can_run_a_container_action_and_it_is_audited(): void
|
||||
{
|
||||
$this->actingAs($this->operator());
|
||||
$server = $this->activeServer();
|
||||
$docker = $this->stubDocker();
|
||||
$docker->shouldReceive('containerAction')->once()
|
||||
->with(Mockery::type(Server::class), 'web', 'restart')
|
||||
->andReturn(['ok' => true, 'output' => 'web']);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->call('action', 'web', 'restart')
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('audit_events', ['action' => 'docker.action', 'server_id' => $server->id]);
|
||||
}
|
||||
|
||||
public function test_admin_can_run_a_container_action(): void
|
||||
{
|
||||
$this->actingAs($this->admin());
|
||||
$this->activeServer();
|
||||
$docker = $this->stubDocker();
|
||||
$docker->shouldReceive('containerAction')->once()->andReturn(['ok' => true, 'output' => '']);
|
||||
|
||||
Livewire::test(Index::class)->call('action', 'db', 'start')->assertOk();
|
||||
}
|
||||
|
||||
public function test_viewer_cannot_open_logs(): void
|
||||
{
|
||||
$this->actingAs($this->viewer());
|
||||
$this->activeServer();
|
||||
$this->stubDocker();
|
||||
|
||||
Livewire::test(Index::class)->call('viewLogs', 'web')->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_viewer_cannot_read_logs_in_the_modal(): void
|
||||
{
|
||||
$this->actingAs($this->viewer());
|
||||
$server = $this->activeServer();
|
||||
$docker = Mockery::mock(DockerService::class);
|
||||
$docker->shouldReceive('logs')->never(); // guard fires before the read
|
||||
app()->instance(DockerService::class, $docker);
|
||||
|
||||
Livewire::test(ContainerLogs::class, ['serverId' => $server->id, 'ref' => 'web'])
|
||||
->call('load')
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_logs_modal_loads_a_container_tail(): void
|
||||
{
|
||||
$this->actingAs($this->admin());
|
||||
$server = $this->activeServer();
|
||||
$docker = Mockery::mock(DockerService::class);
|
||||
$docker->shouldReceive('logs')->once()->with(Mockery::type(Server::class), 'web', 200)->andReturn('log line 1');
|
||||
app()->instance(DockerService::class, $docker);
|
||||
|
||||
Livewire::test(ContainerLogs::class, ['serverId' => $server->id, 'ref' => 'web'])
|
||||
->call('load')
|
||||
->assertOk()
|
||||
->assertSet('logs', 'log line 1');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\DockerService;
|
||||
use App\Services\FleetService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use InvalidArgumentException;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DockerServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function server(): Server
|
||||
{
|
||||
return Server::create(['name' => 'box', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
|
||||
}
|
||||
|
||||
/** DockerService over a mocked FleetService::runPrivileged; returns [$docker, $fleet]. */
|
||||
private function make(): array
|
||||
{
|
||||
$fleet = Mockery::mock(FleetService::class);
|
||||
|
||||
return [new DockerService($fleet), $fleet];
|
||||
}
|
||||
|
||||
public function test_containers_parses_the_docker_ps_json(): void
|
||||
{
|
||||
[$docker, $fleet] = $this->make();
|
||||
$json = json_encode(['ID' => 'abc123', 'Names' => 'web', 'Image' => 'nginx', 'State' => 'running', 'Status' => 'Up 2h', 'Ports' => '80/tcp'])."\n"
|
||||
.json_encode(['ID' => 'def456', 'Names' => 'db', 'Image' => 'mariadb', 'State' => 'exited', 'Status' => 'Exited (0)', 'Ports' => '']);
|
||||
$fleet->shouldReceive('runPrivileged')->once()->andReturn(['ok' => true, 'output' => $json]);
|
||||
|
||||
$rows = $docker->containers($this->server());
|
||||
|
||||
$this->assertCount(2, $rows);
|
||||
$this->assertSame('web', $rows[0]['name']);
|
||||
$this->assertSame('running', $rows[0]['state']);
|
||||
$this->assertSame('exited', $rows[1]['state']);
|
||||
}
|
||||
|
||||
public function test_containers_returns_empty_when_docker_is_unavailable(): void
|
||||
{
|
||||
[$docker, $fleet] = $this->make();
|
||||
$fleet->shouldReceive('runPrivileged')->andReturn(['ok' => false, 'output' => 'docker: command not found']);
|
||||
|
||||
$this->assertSame([], $docker->containers($this->server()));
|
||||
}
|
||||
|
||||
public function test_container_action_runs_the_right_command_for_a_valid_ref(): void
|
||||
{
|
||||
[$docker, $fleet] = $this->make();
|
||||
$fleet->shouldReceive('runPrivileged')->once()
|
||||
->with(Mockery::type(Server::class), 'docker restart web1')
|
||||
->andReturn(['ok' => true, 'output' => 'web1']);
|
||||
|
||||
$res = $docker->containerAction($this->server(), 'web1', 'restart');
|
||||
|
||||
$this->assertTrue($res['ok']);
|
||||
}
|
||||
|
||||
public function test_container_action_rejects_an_unknown_op(): void
|
||||
{
|
||||
[$docker, $fleet] = $this->make();
|
||||
$fleet->shouldReceive('runPrivileged')->never();
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
$docker->containerAction($this->server(), 'web', 'rm -rf');
|
||||
}
|
||||
|
||||
public function test_container_action_rejects_a_ref_with_shell_metacharacters(): void
|
||||
{
|
||||
[$docker, $fleet] = $this->make();
|
||||
$fleet->shouldReceive('runPrivileged')->never();
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
$docker->containerAction($this->server(), 'web; rm -rf /', 'stop');
|
||||
}
|
||||
|
||||
public function test_container_action_rejects_a_leading_dash_ref(): void
|
||||
{
|
||||
[$docker, $fleet] = $this->make();
|
||||
$fleet->shouldReceive('runPrivileged')->never();
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
$docker->containerAction($this->server(), '--help', 'stop'); // arg injection
|
||||
}
|
||||
|
||||
public function test_logs_clamps_the_line_count_and_validates_the_ref(): void
|
||||
{
|
||||
[$docker, $fleet] = $this->make();
|
||||
$fleet->shouldReceive('runPrivileged')->once()
|
||||
->with(Mockery::type(Server::class), 'docker logs --tail 2000 web 2>&1 | head -c 262144')
|
||||
->andReturn(['ok' => true, 'output' => 'log line']);
|
||||
|
||||
$this->assertSame('log line', $docker->logs($this->server(), 'web', 99999)); // clamped to 2000, byte-capped
|
||||
}
|
||||
|
||||
public function test_container_action_rejects_a_ref_with_a_trailing_newline(): void
|
||||
{
|
||||
[$docker, $fleet] = $this->make();
|
||||
$fleet->shouldReceive('runPrivileged')->never();
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
|
||||
$docker->containerAction($this->server(), "web\n", 'stop'); // \z anchor blocks the trailing newline
|
||||
}
|
||||
|
||||
public function test_available_reflects_the_probe(): void
|
||||
{
|
||||
[$docker, $fleet] = $this->make();
|
||||
$fleet->shouldReceive('runPrivileged')->andReturn(['ok' => true, 'output' => 'yes']);
|
||||
|
||||
$this->assertTrue($docker->available($this->server()));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue