Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 61 additions & 5 deletions src/Environments/DockerEnvironment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand All @@ -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: <fg=yellow>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
Expand Down
72 changes: 71 additions & 1 deletion tests/Feature/Environments/DockerEnvironmentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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;
}
};
}

Expand Down