fleet->runPrivileged($server, self::PATH_PREFIX.'command -v docker >/dev/null 2>&1 && echo yes || echo no'); return trim($res['output']) === 'yes'; } /** * @return array */ public function containers(Server $server): array { // Tab-separated columns (NOT `{{json .}}`): works on much older Docker AND avoids a JSON // key-casing dependency. `.State` was added late, so we derive state from `.Status` text — // this is the whole reason a modern-only `{{json .}}` could return nothing on an older host. $fmt = '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'; $res = $this->fleet->runPrivileged($server, self::PATH_PREFIX."docker ps -a --format '{$fmt}'"); if (! $res['ok']) { $err = $this->firstLine($res['output']); // Distinguish "docker binary absent" (a native server, no runtime) from a real fault // right here, so callers get the honest "not installed" state from THIS single `docker // ps` call instead of paying a second SSH round-trip on a separate available() probe. if (preg_match('/docker:\s.*not found/i', $err)) { throw new DockerNotInstalled($err); } // Surface the REAL reason (daemon down / permission / rootless socket) rather than a // misleading empty list — the component shows this instead of "no containers". throw new RuntimeException($err ?: 'docker ps failed'); } $out = []; foreach (preg_split('/\r?\n/', trim($res['output'])) ?: [] as $line) { if (trim($line) === '') { continue; } $p = explode("\t", $line); $status = trim($p[3] ?? ''); $name = trim($p[1] ?? ''); $out[] = [ 'id' => trim($p[0] ?? ''), 'name' => $name, 'display' => self::displayName($name), 'image' => trim($p[2] ?? ''), 'state' => $this->stateFromStatus($status), 'status' => $status, 'ports' => trim($p[4] ?? ''), ]; } return $out; } /** * Display-only container name: strip the compose replica suffix ("clusev-app-1" -> "clusev-app"). * NEVER use for docker commands — actions/logs keep the real name/id. */ public static function displayName(string $name): string { return preg_replace('/-\d+$/', '', $name) ?: $name; } /** Derive a coarse state from docker's Status text (portable across versions that lack .State). */ private function stateFromStatus(string $status): string { $s = strtolower($status); return match (true) { str_contains($s, '(paused)') => 'paused', str_starts_with($s, 'up') => 'running', str_starts_with($s, 'created') => 'created', str_starts_with($s, 'restarting') => 'restarting', default => 'exited', // Exited / Dead / Removal }; } private function firstLine(string $out): string { return trim(preg_split('/\r?\n/', trim($out))[0] ?? ''); } 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, self::PATH_PREFIX."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, self::PATH_PREFIX."docker logs --tail {$lines} {$id} 2>&1 | head -c 262144"); return mb_scrub(trim($res['output']), 'UTF-8'); } /** * @return array */ public function composeStacks(Server $server): array { $res = $this->fleet->runPrivileged($server, self::PATH_PREFIX.'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.'); } } }