diff --git a/app/Services/DockerService.php b/app/Services/DockerService.php index b7f4346..7bcbee0 100644 --- a/app/Services/DockerService.php +++ b/app/Services/DockerService.php @@ -16,11 +16,20 @@ 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, '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'; } @@ -34,7 +43,7 @@ class DockerService // 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, "docker ps -a --format '{$fmt}'"); + $res = $this->fleet->runPrivileged($server, self::PATH_PREFIX."docker ps -a --format '{$fmt}'"); if (! $res['ok']) { // Surface the REAL reason (daemon down / permission / rootless socket) rather than a @@ -88,7 +97,7 @@ class DockerService } $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'])]; } @@ -101,7 +110,7 @@ class DockerService // 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"); + $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'); } @@ -111,7 +120,7 @@ class DockerService */ 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']) { return []; } diff --git a/tests/Feature/DockerServiceTest.php b/tests/Feature/DockerServiceTest.php index 02a8cea..ce84422 100644 --- a/tests/Feature/DockerServiceTest.php +++ b/tests/Feature/DockerServiceTest.php @@ -67,7 +67,7 @@ class DockerServiceTest extends TestCase { [$docker, $fleet] = $this->make(); $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']); $res = $docker->containerAction($this->server(), 'web1', 'restart'); @@ -106,7 +106,7 @@ class DockerServiceTest extends TestCase { [$docker, $fleet] = $this->make(); $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']); $this->assertSame('log line', $docker->logs($this->server(), 'web', 99999)); // clamped to 2000, byte-capped