diff --git a/docs/sandbox-cli.md b/docs/sandbox-cli.md index 232a5565..72f16dbf 100644 --- a/docs/sandbox-cli.md +++ b/docs/sandbox-cli.md @@ -31,7 +31,7 @@ When creation finishes, QuickInstall asks whether to start the board. Open: ```text -http://localhost:8081/ +http://localhost:8081/demo/ ``` Admin login: diff --git a/src/QuickInstall/Sandbox/Application.php b/src/QuickInstall/Sandbox/Application.php index 1f7fdd71..35111eea 100644 --- a/src/QuickInstall/Sandbox/Application.php +++ b/src/QuickInstall/Sandbox/Application.php @@ -298,7 +298,7 @@ private function boardCreate(array $args): int echo "Created board scaffold: $name\n"; echo "Compose: {$paths['compose']}\n"; echo "Install config: {$paths['install_config']}\n"; - echo "URL after start: http://localhost:$port/\n"; + echo "URL after start: {$created['board']['url']}\n"; if ($populate !== 'none') { echo "Populate preset: $populate (runs on board:start)\n"; diff --git a/src/QuickInstall/Sandbox/BoardRunner.php b/src/QuickInstall/Sandbox/BoardRunner.php index 8100397c..aee8613b 100644 --- a/src/QuickInstall/Sandbox/BoardRunner.php +++ b/src/QuickInstall/Sandbox/BoardRunner.php @@ -44,7 +44,7 @@ public function start(string $name): void throw new RuntimeException('SQLite boards support populate:none, tiny, or development only. Use mariadb, mysql, or postgres for heavier fixture presets.'); } $this->seedIfNeeded($name, $board['populate'] ?? 'none'); - $this->waitUntilHttpReady($name, $board['url'] ?? ''); + $this->waitUntilHttpReady($name, $board['health_url'] ?? ($board['url'] ?? '')); } public function stop(string $name): void diff --git a/src/QuickInstall/Sandbox/BoardService.php b/src/QuickInstall/Sandbox/BoardService.php index 521bb177..5b775c00 100644 --- a/src/QuickInstall/Sandbox/BoardService.php +++ b/src/QuickInstall/Sandbox/BoardService.php @@ -79,13 +79,17 @@ public function create(string $name, string $version = 'latest', string $db = 'm { throw new RuntimeException("Unable to determine PHP runtime for source: {$source['source_key']}"); } - $config = [ 'phpbb' => $source['version'], 'phpbb_source' => $source['source_key'], 'php' => $php, 'db' => $db, 'port' => $port, + 'server_name' => 'localhost', + 'script_path' => '/' . $name, + 'cookie_path' => '/' . $name . '/', + 'scoped_path' => true, + 'postgres_data_subdir' => $db === 'postgres', 'populate' => $populate, 'debug' => $debug, 'admin_name' => 'admin', @@ -121,7 +125,13 @@ public function create(string $name, string $version = 'latest', string $db = 'm 'php' => $php, 'db' => $db, 'port' => $port, - 'url' => "http://localhost:$port/", + 'server_name' => $config['server_name'], + 'script_path' => $config['script_path'], + 'cookie_path' => $config['cookie_path'], + 'scoped_path' => $config['scoped_path'], + 'postgres_data_subdir' => $config['postgres_data_subdir'], + 'url' => "http://localhost:$port/$name/", + 'health_url' => "http://127.0.0.1:$port/$name/", 'path' => $boardDir, 'populate' => $populate, 'debug' => $debug, diff --git a/src/QuickInstall/Sandbox/DockerComposeWriter.php b/src/QuickInstall/Sandbox/DockerComposeWriter.php index df250f72..f47ee44e 100644 --- a/src/QuickInstall/Sandbox/DockerComposeWriter.php +++ b/src/QuickInstall/Sandbox/DockerComposeWriter.php @@ -35,11 +35,13 @@ public function write(string $name, array $config): array $compose = $runtimeDir . '/compose.yml'; $dockerfile = $runtimeDir . '/Dockerfile'; $entrypoint = $runtimeDir . '/entrypoint.sh'; + $apacheConfig = $runtimeDir . '/apache.conf'; $this->writeFile($installConfig, $this->installConfig($name, $config)); $this->writeFile($compose, $this->compose($name, $config)); $this->writeFile($dockerfile, $this->dockerfile($config)); $this->writeFile($entrypoint, $this->entrypoint($config)); + $this->writeFile($apacheConfig, $this->apacheConfig($name, $config)); if ((PHP_OS_FAMILY !== 'Windows') && !chmod($entrypoint, 0755)) { throw new RuntimeException("Unable to make entrypoint executable: $entrypoint"); @@ -50,6 +52,7 @@ public function write(string $name, array $config): array 'install_config' => $installConfig, 'dockerfile' => $dockerfile, 'entrypoint' => $entrypoint, + 'apache_config' => $apacheConfig, ]; } @@ -72,6 +75,8 @@ private function installConfig(string $name, array $config): string $adminPass = $this->yamlString($config['admin_pass']); $adminEmail = $this->yamlString($config['admin_email']); $boardName = $this->yamlString($name); + $serverName = $this->yamlString($config['server_name'] ?? 'localhost'); + $scriptPath = $this->yamlString($config['script_path'] ?? '/'); return <<databaseService($config['db'], $name, $config['php']); + $dbService = $this->databaseService($config['db'], $name, $config['php'], !empty($config['postgres_data_subdir'])); $sourcePath = $this->project->sourcePath($config['phpbb_source'] ?? $config['phpbb']); $boardPath = $this->project->boardPath($name); $extensionVolumes = $this->extensionVolumes($config['extensions'] ?? []); $styleVolumes = $this->styleVolumes($config['styles'] ?? []); + $cookiePath = $config['cookie_path'] ?? '/'; + $rewriteBase = rtrim($config['script_path'] ?? '/', '/') . '/'; $dbPath = $this->project->workspacePath('db/' . $name); if (!is_dir($dbPath) && !mkdir($dbPath, 0775, true) && !is_dir($dbPath)) { @@ -135,7 +142,7 @@ private function compose(string $name, array $config): string # Boards are intentionally unavailable to other network devices. - "127.0.0.1:{$config['port']}:80" volumes: -{$this->bindVolume($sourcePath, '/opt/phpbb-source', true)}{$this->bindVolume($boardPath, '/var/www/html')}{$extensionVolumes}{$styleVolumes}{$this->bindVolume('./install-config.yml', '/opt/quickinstall/install-config.yml', true)}{$this->bindVolume('./entrypoint.sh', '/opt/quickinstall/entrypoint.sh', true)} +{$this->bindVolume($sourcePath, '/opt/phpbb-source', true)}{$this->bindVolume($boardPath, '/var/www/html')}{$extensionVolumes}{$styleVolumes}{$this->bindVolume('./install-config.yml', '/opt/quickinstall/install-config.yml', true)}{$this->bindVolume('./entrypoint.sh', '/opt/quickinstall/entrypoint.sh', true)}{$this->bindVolume('./apache.conf', '/etc/apache2/conf-enabled/quickinstall.conf', true)} entrypoint: ["/bin/sh", "/opt/quickinstall/entrypoint.sh"] depends_on: db: @@ -144,6 +151,8 @@ private function compose(string $name, array $config): string QUICKINSTALL_PHPBB_VERSION: "{$config['phpbb']}" QUICKINSTALL_POPULATE: "{$config['populate']}" QUICKINSTALL_BOARD_TIMEZONE: "{$config['board_timezone']}" + QUICKINSTALL_COOKIE_PATH: "$cookiePath" + QUICKINSTALL_REWRITE_BASE: "$rewriteBase" $dbService @@ -173,6 +182,27 @@ private function extensionVolumes(array $extensions): string return $volumes; } + /** Routes a scoped board URL to phpBB's unchanged document root. */ + private function apacheConfig(string $name, array $config): string + { + $serverName = $config['server_name'] ?? 'localhost'; + if (empty($config['scoped_path'])) + { + return "ServerName $serverName\nLimitRequestFieldSize 65536\n"; + } + + $path = '/' . $name; + $pattern = preg_quote($path, '#'); + return <<bindVolume($dbPath, '/var/lib/postgresql/data')} healthcheck: - test: ["CMD-SHELL", "pg_isready -U phpbb -d phpbb"] + test: ["CMD-SHELL", "psql -U phpbb -d phpbb -tAc 'SELECT 1' 2>/dev/null | grep -qx 1"] interval: 5s timeout: 5s retries: 20 diff --git a/tests/Integration/ApplicationTest.php b/tests/Integration/ApplicationTest.php index 9cb3c0e3..ba6395e8 100644 --- a/tests/Integration/ApplicationTest.php +++ b/tests/Integration/ApplicationTest.php @@ -158,6 +158,7 @@ public function testBoardCreateRepeatsStartPromptUntilYesOrNo(): void $result = $this->runApplication($root, ['qi', 'board:create', 'demo', '--phpbb', '3.3.14', '--port', (string) $this->availablePort()], "maybe\nn\n"); self::assertSame(0, $result['exit_code']); + self::assertMatchesRegularExpression('#URL after start: http://localhost:\d+/demo/#', $result['output']); self::assertStringContainsString('Please answer y or n.', $result['output']); self::assertSame(2, substr_count($result['output'], 'Run this command now? [Y/n]: ')); } diff --git a/tests/Unit/BoardRunnerTest.php b/tests/Unit/BoardRunnerTest.php index f367595a..e3f9711d 100644 --- a/tests/Unit/BoardRunnerTest.php +++ b/tests/Unit/BoardRunnerTest.php @@ -245,6 +245,19 @@ public function testStartAllowsSqliteDevelopmentSeededBoards(): void self::assertSame([['demo', 'development']], $runner->seedIfNeededRuns); } + public function testStartUsesLoopbackHealthUrlWhenAvailable(): void + { + [$project] = $this->projectWithBoard([ + 'url' => 'http://demo.localhost:8080/', + 'health_url' => 'http://127.0.0.1:8080/', + ]); + $runner = new TestBoardRunner($project); + + $runner->start('demo'); + + self::assertSame([['demo', 'http://127.0.0.1:8080/']], $runner->httpWaits); + } + public function testStartRejectsHeavySqliteSeededBoards(): void { [$project] = $this->projectWithBoard([ diff --git a/tests/Unit/BoardServiceTest.php b/tests/Unit/BoardServiceTest.php index 79eaf422..ef2ce69d 100644 --- a/tests/Unit/BoardServiceTest.php +++ b/tests/Unit/BoardServiceTest.php @@ -25,6 +25,13 @@ public function testCreateWritesBoardAndRuntimeConfig(): void self::assertSame('demo', $result['board']['name']); self::assertSame('postgres', $result['board']['db']); self::assertSame(8090, $result['board']['port']); + self::assertSame('localhost', $result['board']['server_name']); + self::assertSame('/demo', $result['board']['script_path']); + self::assertSame('/demo/', $result['board']['cookie_path']); + self::assertTrue($result['board']['scoped_path']); + self::assertTrue($result['board']['postgres_data_subdir']); + self::assertSame('http://localhost:8090/demo/', $result['board']['url']); + self::assertSame('http://127.0.0.1:8090/demo/', $result['board']['health_url']); self::assertSame('tiny', $result['board']['populate']); self::assertTrue($result['board']['debug']); self::assertSame('America/Los_Angeles', $result['board']['board_timezone']); diff --git a/tests/Unit/DockerComposeWriterTest.php b/tests/Unit/DockerComposeWriterTest.php index ec0339fe..175555cd 100644 --- a/tests/Unit/DockerComposeWriterTest.php +++ b/tests/Unit/DockerComposeWriterTest.php @@ -22,13 +22,20 @@ public function testWritesDatabaseRuntimeFiles(string $board, string $db, array self::assertFileExists($paths['install_config']); self::assertFileExists($paths['dockerfile']); self::assertFileExists($paths['entrypoint']); + self::assertFileExists($paths['apache_config']); $entrypoint = file_get_contents($paths['entrypoint']); self::assertStringContainsString('apache2-foreground', $entrypoint); self::assertStringContainsString('new DateTimeZone((string) getenv("QUICKINSTALL_BOARD_TIMEZONE"))', $entrypoint); self::assertStringContainsString('config:set board_timezone "$QUICKINSTALL_BOARD_TIMEZONE"', $entrypoint); self::assertStringContainsString('config:set board_timezone UTC', $entrypoint); + self::assertStringContainsString('config:set cookie_path "${QUICKINSTALL_COOKIE_PATH:-/}"', $entrypoint); + self::assertStringContainsString('RewriteBase ${QUICKINSTALL_REWRITE_BASE}', $entrypoint); self::assertStringContainsString("is unsupported by this PHP runtime; using UTC.", $entrypoint); self::assertStringContainsString('QUICKINSTALL_BOARD_TIMEZONE: "America/Los_Angeles"', file_get_contents($paths['compose'])); + self::assertStringContainsString('QUICKINSTALL_COOKIE_PATH: "/demo/"', file_get_contents($paths['compose'])); + self::assertStringContainsString('QUICKINSTALL_REWRITE_BASE: "/demo/"', file_get_contents($paths['compose'])); + self::assertStringNotContainsString("\t", file_get_contents($paths['compose'])); + self::assertStringContainsString('a2enmod rewrite', file_get_contents($paths['dockerfile'])); $output = file_get_contents($paths['compose']) . "\n" . file_get_contents($paths['install_config']) . "\n" . file_get_contents($paths['dockerfile']); foreach ($expectedContains as $expected) @@ -68,6 +75,8 @@ public function databaseRuntimeProvider(): array 'mysql', [ 'image: mysql:8.0', + 'server_name: "localhost"', + 'script_path: "/demo"', 'server_port: 8081', 'dbms: mysqli', 'docker-php-ext-install mysqli pdo_mysql', @@ -89,6 +98,8 @@ public function databaseRuntimeProvider(): array 'postgres', [ 'image: postgres:16', + 'PGDATA: "/var/lib/postgresql/data/pgdata"', + "psql -U phpbb -d phpbb -tAc 'SELECT 1'", 'dbms: postgres', 'docker-php-ext-install pgsql pdo_pgsql', ], @@ -157,6 +168,51 @@ public function testQuotesYamlSignificantInstallerValues(): void self::assertStringContainsString(' name: "demo"', $installConfig); } + public function testExistingBoardConfigWithoutServerNameKeepsLocalhost(): void + { + $config = $this->config(); + unset($config['server_name']); + unset($config['script_path'], $config['scoped_path']); + + $root = $this->createTempProjectRoot(); + $project = new Project($root); + $project->init(); + mkdir($project->boardPath('demo'), 0775, true); + $paths = (new DockerComposeWriter($project))->write('demo', $config); + + self::assertStringContainsString('server_name: "localhost"', file_get_contents($paths['install_config'])); + self::assertStringContainsString('script_path: "/"', file_get_contents($paths['install_config'])); + self::assertStringNotContainsString('Alias ', file_get_contents($paths['apache_config'])); + } + + public function testExistingPostgresConfigKeepsRootDataDirectory(): void + { + $config = $this->config(['db' => 'postgres']); + unset($config['postgres_data_subdir']); + + $root = $this->createTempProjectRoot(); + $project = new Project($root); + $project->init(); + mkdir($project->boardPath('demo'), 0775, true); + $paths = (new DockerComposeWriter($project))->write('demo', $config); + $compose = file_get_contents($paths['compose']); + + self::assertStringNotContainsString('PGDATA:', $compose); + self::assertStringContainsString("psql -U phpbb -d phpbb -tAc 'SELECT 1'", $compose); + } + + public function testScopedBoardWritesApacheRoute(): void + { + [, $paths] = $this->writeBoard('demo'); + $apacheConfig = file_get_contents($paths['apache_config']); + + self::assertStringContainsString('RedirectMatch 302 "^/$" "/demo/"', $apacheConfig); + self::assertStringContainsString('RedirectMatch 302 "^/demo$" "/demo/"', $apacheConfig); + self::assertStringContainsString('Alias "/demo/" "/var/www/html/"', $apacheConfig); + self::assertStringContainsString('LimitRequestFieldSize 65536', $apacheConfig); + self::assertStringContainsString('target: "/etc/apache2/conf-enabled/quickinstall.conf"', file_get_contents($paths['compose'])); + } + public function testEntrypointExcludesBindMountsFromOwnershipChanges(): void { [, $paths] = $this->writeBoard('demo', [ @@ -204,6 +260,11 @@ private function config(array $overrides = []): array 'php' => '8.1', 'db' => 'mariadb', 'port' => 8081, + 'server_name' => 'localhost', + 'script_path' => '/demo', + 'cookie_path' => '/demo/', + 'scoped_path' => true, + 'postgres_data_subdir' => true, 'populate' => 'none', 'admin_name' => 'admin', 'admin_pass' => 'password',