From 676a7ecb5e585939826aa719eb02f8dd1dde55e9 Mon Sep 17 00:00:00 2001 From: roble Date: Thu, 27 Aug 2026 11:37:44 +0100 Subject: [PATCH] feat: Enhance Docker environment handling by ensuring web port is published and adding related tests --- src/Environments/DockerEnvironment.php | 66 +++++++++++++++-- .../Environments/DockerEnvironmentTest.php | 72 ++++++++++++++++++- 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/src/Environments/DockerEnvironment.php b/src/Environments/DockerEnvironment.php index 9c82c55..99c4e55 100644 --- a/src/Environments/DockerEnvironment.php +++ b/src/Environments/DockerEnvironment.php @@ -591,10 +591,10 @@ protected function startDocker(InstallCommand $command): bool { $command->info('Starting Docker services (this may take a few minutes while pulling images and starting containers)...'); - $restart = new Process(['docker', 'compose', 'restart'], $command->targetPath()); - $restart->setTimeout(60); - $restart->run(); - + // No `docker compose restart` first: it stop/starts the existing containers, and + // a container that comes back up can lose its host port bindings. `up` then sees + // an unchanged config hash, considers it up to date, and never repairs it. + // `up -d` alone already creates what is missing and recreates what changed. $up = new Process(['docker', 'compose', 'up', '-d', '--wait', '--build'], $command->targetPath()); $up->setTimeout(30 * 60); // 30 minutes — first run pulls images + builds layers $up->run(fn ($_type, $buffer) => $command->line(trim($buffer))); @@ -606,7 +606,63 @@ protected function startDocker(InstallCommand $command): bool return false; } - return true; + return $this->ensureWebPortPublished($command); + } + + /** + * Confirm the web container is actually reachable on its published port. + * + * Every other install step runs through `docker compose exec`, which works fine + * against a container whose port bindings were dropped — so without this the whole + * install reports success while the site refuses connections. + */ + protected function ensureWebPortPublished(InstallCommand $command): bool + { + $ports = $this->resolvePorts($command); + $port = $this->ssl ? $ports['APP_HTTPS_PORT'] : $ports['APP_PORT']; + + if ($this->waitForPort($port)) { + return true; + } + + $command->warn("Nothing is listening on port {$port}; recreating the web container..."); + $this->recreateWebContainer($command); + + if ($this->waitForPort($port)) { + return true; + } + + $command->error("The web container is not publishing port {$port}."); + $command->line(' Reset the stack with: docker compose down && docker compose up -d'); + + return false; + } + + protected function waitForPort(int $port, int $attempts = 10): bool + { + for ($attempt = 0; $attempt < $attempts; $attempt++) { + if ($this->portInUse($port)) { + return true; + } + + if ($attempt < $attempts - 1) { + usleep(500_000); + } + } + + return false; + } + + protected function recreateWebContainer(InstallCommand $command): void + { + // --force-recreate is the only thing that fixes an already-running container + // with dropped bindings; a plain `up` leaves it alone. + $process = new Process( + ['docker', 'compose', 'up', '-d', '--force-recreate', 'nginx'], + $command->targetPath(), + ); + $process->setTimeout(120); + $process->run(); } protected function runComposerInContainer(InstallCommand $command): bool diff --git a/tests/Feature/Environments/DockerEnvironmentTest.php b/tests/Feature/Environments/DockerEnvironmentTest.php index b133f7a..14f68d1 100644 --- a/tests/Feature/Environments/DockerEnvironmentTest.php +++ b/tests/Feature/Environments/DockerEnvironmentTest.php @@ -1087,6 +1087,50 @@ public function test_repair_is_skipped_for_identifiers_it_cannot_safely_interpol $this->assertNull($env->exposedDatabaseRepairSql('', '', 'secret')); } + // ------------------------------------------------------------------------- + // Web port actually published + // ------------------------------------------------------------------------- + + public function test_start_succeeds_quietly_when_the_web_port_is_serving(): void + { + $env = $this->exposed(inUse: [443]); + $env->useSsl(true); + + $this->assertTrue($env->exposedEnsureWebPortPublished(new FakeInstallCommand(null, [], ['path' => '/nonexistent']))); + $this->assertSame(0, $env->recreateCount, 'a healthy stack must not be recreated'); + } + + public function test_a_container_running_without_its_bindings_is_recreated(): void + { + // `docker compose up` reports success for a running container that lost its port + // bindings, and will not repair it — every other step uses `exec`, so the install + // would otherwise finish "successfully" against an unreachable site. + $env = $this->exposed(inUse: []); + $env->useSsl(true); + $env->listeningAfterRecreate = [443]; + + $this->assertTrue($env->exposedEnsureWebPortPublished(new FakeInstallCommand(null, [], ['path' => '/nonexistent']))); + $this->assertSame(1, $env->recreateCount); + } + + public function test_start_fails_when_the_port_stays_unreachable(): void + { + $env = $this->exposed(inUse: []); + $env->useSsl(true); + + $this->assertFalse($env->exposedEnsureWebPortPublished(new FakeInstallCommand(null, [], ['path' => '/nonexistent']))); + $this->assertSame(1, $env->recreateCount, 'recreate is attempted exactly once'); + } + + public function test_the_http_port_is_checked_when_ssl_is_disabled(): void + { + $env = $this->exposed(inUse: [80]); + $env->useSsl(false); + + $this->assertTrue($env->exposedEnsureWebPortPublished(new FakeInstallCommand(null, [], ['path' => '/nonexistent']))); + $this->assertSame(0, $env->recreateCount); + } + // ------------------------------------------------------------------------- // Failure messaging // ------------------------------------------------------------------------- @@ -1167,7 +1211,7 @@ private function exposed(array $inUse = [], array $owners = []): object public bool $envRewritten = false; /** @param int[] $inUse */ - public function __construct(private array $inUse, public array $fakeOwners) {} + public function __construct(protected array $inUse, public array $fakeOwners) {} protected function portInUse(int $port): bool { @@ -1241,6 +1285,32 @@ public function exposedDatabaseRepairSql(string $db, string $user, string $passw { return $this->databaseRepairSql($db, $user, $password); } + + public int $recreateCount = 0; + + /** Ports that become reachable only after the web container is recreated. */ + public array $listeningAfterRecreate = []; + + protected function recreateWebContainer(InstallCommand $command): void + { + $this->recreateCount++; + $this->inUse = array_merge($this->inUse, $this->listeningAfterRecreate); + } + + protected function waitForPort(int $port, int $attempts = 10): bool + { + return parent::waitForPort($port, 1); // no sleeping in tests + } + + public function exposedEnsureWebPortPublished(InstallCommand $command): bool + { + return $this->ensureWebPortPublished($command); + } + + public function useSsl(bool $ssl): void + { + $this->ssl = $ssl; + } }; }