fix(docker): find the docker binary regardless of the login shell PATH

A non-interactive SSH exec (and sudo's secure_path) frequently has a
minimal PATH (~/usr/bin:/bin). Docker installed in /usr/local/bin
(get.docker.com), /snap/bin (snap) or /usr/sbin is then unreachable, so
every call died with "sh: docker: not found" and the page rendered as
"Docker nicht verfügbar" even though Docker was installed and running.

Prepend a comprehensive PATH to every docker invocation (available,
containers, containerAction, logs, composeStacks) so the binary is
located independent of the remote login shell. Reproduced the exact
failure and the fix through the real FleetService base64+sh transport.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 21:33:45 +02:00
parent 7535ee2d63
commit 4ab255832d
2 changed files with 16 additions and 7 deletions

View File

@ -16,11 +16,20 @@ class DockerService
{ {
public const ACTIONS = ['start', 'stop', 'restart', 'pause', 'unpause']; 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 __construct(private FleetService $fleet) {}
public function available(Server $server): bool public function available(Server $server): bool
{ {
$res = $this->fleet->runPrivileged($server, 'command -v docker >/dev/null 2>&1 && echo yes || echo no'); $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 trim($res['output']) === 'yes';
} }
@ -34,7 +43,7 @@ class DockerService
// key-casing dependency. `.State` was added late, so we derive state from `.Status` text — // 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. // 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}}'; $fmt = '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}';
$res = $this->fleet->runPrivileged($server, "docker ps -a --format '{$fmt}'"); $res = $this->fleet->runPrivileged($server, self::PATH_PREFIX."docker ps -a --format '{$fmt}'");
if (! $res['ok']) { if (! $res['ok']) {
// Surface the REAL reason (daemon down / permission / rootless socket) rather than a // Surface the REAL reason (daemon down / permission / rootless socket) rather than a
@ -88,7 +97,7 @@ class DockerService
} }
$this->assertValidRef($id); $this->assertValidRef($id);
$res = $this->fleet->runPrivileged($server, "docker {$op} {$id}"); $res = $this->fleet->runPrivileged($server, self::PATH_PREFIX."docker {$op} {$id}");
return ['ok' => $res['ok'], 'output' => trim($res['output'])]; return ['ok' => $res['ok'], 'output' => trim($res['output'])];
} }
@ -101,7 +110,7 @@ class DockerService
// Cap by BYTES too (not just lines): one huge line or a binary-ish log could otherwise bloat // 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 // 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. // 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"); $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 mb_scrub(trim($res['output']), 'UTF-8');
} }
@ -111,7 +120,7 @@ class DockerService
*/ */
public function composeStacks(Server $server): array public function composeStacks(Server $server): array
{ {
$res = $this->fleet->runPrivileged($server, 'docker compose ls --format json 2>/dev/null'); $res = $this->fleet->runPrivileged($server, self::PATH_PREFIX.'docker compose ls --format json 2>/dev/null');
if (! $res['ok']) { if (! $res['ok']) {
return []; return [];
} }

View File

@ -67,7 +67,7 @@ class DockerServiceTest extends TestCase
{ {
[$docker, $fleet] = $this->make(); [$docker, $fleet] = $this->make();
$fleet->shouldReceive('runPrivileged')->once() $fleet->shouldReceive('runPrivileged')->once()
->with(Mockery::type(Server::class), 'docker restart web1') ->with(Mockery::type(Server::class), Mockery::on(fn ($cmd) => str_starts_with($cmd, 'export PATH=') && str_contains($cmd, 'docker restart web1')))
->andReturn(['ok' => true, 'output' => 'web1']); ->andReturn(['ok' => true, 'output' => 'web1']);
$res = $docker->containerAction($this->server(), 'web1', 'restart'); $res = $docker->containerAction($this->server(), 'web1', 'restart');
@ -106,7 +106,7 @@ class DockerServiceTest extends TestCase
{ {
[$docker, $fleet] = $this->make(); [$docker, $fleet] = $this->make();
$fleet->shouldReceive('runPrivileged')->once() $fleet->shouldReceive('runPrivileged')->once()
->with(Mockery::type(Server::class), 'docker logs --tail 2000 web 2>&1 | head -c 262144') ->with(Mockery::type(Server::class), Mockery::on(fn ($cmd) => str_starts_with($cmd, 'export PATH=') && str_contains($cmd, 'docker logs --tail 2000 web 2>&1 | head -c 262144')))
->andReturn(['ok' => true, 'output' => 'log line']); ->andReturn(['ok' => true, 'output' => 'log line']);
$this->assertSame('log line', $docker->logs($this->server(), 'web', 99999)); // clamped to 2000, byte-capped $this->assertSame('log line', $docker->logs($this->server(), 'web', 99999)); // clamped to 2000, byte-capped