159 lines
6.4 KiB
PHP
159 lines
6.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Exceptions\DockerNotInstalled;
|
|
use App\Models\Server;
|
|
use InvalidArgumentException;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* 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'];
|
|
|
|
/**
|
|
* A non-interactive SSH exec (and sudo's secure_path) often has a MINIMAL PATH (~/usr/bin:/bin),
|
|
* so `docker` installed in /usr/local/bin (get.docker.com), /snap/bin (snap), or /usr/sbin isn't
|
|
* found → "sh: docker: not found" even though it IS installed. Prepend the common bin dirs to
|
|
* every docker call so the binary is located regardless of the login shell's PATH. Single-quoted
|
|
* so `${PATH:+:$PATH}` reaches the remote shell literally (not interpolated by PHP).
|
|
*/
|
|
private const PATH_PREFIX = 'export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin${PATH:+:$PATH}; ';
|
|
|
|
public function __construct(private FleetService $fleet) {}
|
|
|
|
public function available(Server $server): bool
|
|
{
|
|
$res = $this->fleet->runPrivileged($server, self::PATH_PREFIX.'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
|
|
{
|
|
// 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] ?? '');
|
|
$out[] = [
|
|
'id' => trim($p[0] ?? ''),
|
|
'name' => trim($p[1] ?? ''),
|
|
'image' => trim($p[2] ?? ''),
|
|
'state' => $this->stateFromStatus($status),
|
|
'status' => $status,
|
|
'ports' => trim($p[4] ?? ''),
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/** 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<int, array{name:string,status:string}>
|
|
*/
|
|
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.');
|
|
}
|
|
}
|
|
}
|