From bbfd6610590343c9a24d090e9d0dffb996b6f5da Mon Sep 17 00:00:00 2001 From: roble Date: Thu, 25 Jun 2026 20:22:35 +0100 Subject: [PATCH] feat: enhance Docker environment with SSL support and update nginx configuration --- src/Environments/DockerEnvironment.php | 50 ++++++- stubs/docker/docker/nginx-no-ssl.conf | 86 +++++++++++ .../Environments/DockerEnvironmentTest.php | 136 +++++++++++++++++- 3 files changed, 264 insertions(+), 8 deletions(-) create mode 100644 stubs/docker/docker/nginx-no-ssl.conf diff --git a/src/Environments/DockerEnvironment.php b/src/Environments/DockerEnvironment.php index f038799..0bf5d1a 100644 --- a/src/Environments/DockerEnvironment.php +++ b/src/Environments/DockerEnvironment.php @@ -7,8 +7,12 @@ use Saucebase\Installer\Environments\Contracts\Environment; use Symfony\Component\Process\Process; +use function Laravel\Prompts\confirm; + class DockerEnvironment implements Environment { + protected bool $ssl = true; + public function name(): string { return 'docker'; @@ -38,6 +42,15 @@ public function missingPrerequisites(): array public function run(InstallCommand $command): int { + $this->promptForSsl($command); + + if ($this->ssl && ! $this->commandExists('mkcert')) { + $command->error('mkcert is required for SSL. Install it with: brew install mkcert'); + $command->line('Then re-run: php artisan saucebase:install --driver=docker'); + + return InstallCommand::FAILURE; + } + $this->publishStubs($command); $this->generateSsl($command); @@ -61,14 +74,36 @@ public function run(InstallCommand $command): int return InstallCommand::SUCCESS; } + protected function promptForSsl(InstallCommand $command): void + { + $this->ssl = $command->option('force') + ? true + : confirm( + label: 'Enable HTTPS with SSL?', + default: true, + hint: 'Requires mkcert. Install with: brew install mkcert', + ); + } + protected function publishStubs(InstallCommand $command): void { $command->info('Publishing Docker stubs...'); Artisan::call('vendor:publish', ['--tag' => 'saucebase-docker', '--no-interaction' => true]); + + if (! $this->ssl) { + copy( + __DIR__.'/../../../stubs/docker/docker/nginx-no-ssl.conf', + base_path('docker/nginx.conf'), + ); + } } protected function generateSsl(InstallCommand $command): void { + if (! $this->ssl) { + return; + } + $certFile = base_path('docker/ssl/app.pem'); $keyFile = base_path('docker/ssl/app.key.pem'); @@ -186,7 +221,7 @@ protected function setDockerEnvDefaults(InstallCommand $command): void return; } - $modified = $this->applyDockerEnvDefaults($original); + $modified = $this->applyDockerEnvDefaults($original, $this->ssl); if ($modified !== $original) { file_put_contents($path, $modified); @@ -194,7 +229,7 @@ protected function setDockerEnvDefaults(InstallCommand $command): void } } - protected function applyDockerEnvDefaults(string $env): string + protected function applyDockerEnvDefaults(string $env, bool $ssl = true): string { $slug = 'saucebase'; if (preg_match('/^APP_SLUG=([^\s]+)/m', $env, $m)) { @@ -215,6 +250,17 @@ protected function applyDockerEnvDefaults(string $env): string $env .= "\nMAIL_MAILER=smtp"; } + // Set APP_URL to match the chosen SSL mode + $defaultUrl = $ssl ? 'https://localhost' : 'http://localhost'; + if (preg_match('/^APP_URL=(.*)$/m', $env, $m)) { + $url = trim($m[1], "\"'"); + if (preg_match('#^https?://localhost(:\d+)?/?$#', $url)) { + $env = preg_replace('/^APP_URL=.*$/m', "APP_URL={$defaultUrl}", $env); + } + } else { + $env .= "\nAPP_URL={$defaultUrl}"; + } + // Set missing or blank values; respect anything the user has already configured $defaults = [ 'DB_HOST' => 'mysql', diff --git a/stubs/docker/docker/nginx-no-ssl.conf b/stubs/docker/docker/nginx-no-ssl.conf new file mode 100644 index 0000000..d91f3ab --- /dev/null +++ b/stubs/docker/docker/nginx-no-ssl.conf @@ -0,0 +1,86 @@ +server { + listen 80; + server_name localhost; + root /var/www/public; + index index.php index.html; + + charset utf-8; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/x-javascript + application/xml + application/xml+rss + application/xhtml+xml + application/x-font-ttf + application/x-font-opentype + application/vnd.ms-fontobject + image/svg+xml + image/x-icon + application/rss+xml + application/atom+xml; + gzip_min_length 256; + gzip_disable "msie6"; + + client_max_body_size 512M; + client_body_buffer_size 128k; + client_body_timeout 300s; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + location ~ ^/storage/.+\.(png|jpg|jpeg|gif|webp)$ { + try_files $uri /index.php?$query_string; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location ~* \.(js|css|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + try_files $uri =404; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass app:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param SERVER_PORT 80; + + fastcgi_read_timeout 300; + fastcgi_send_timeout 300; + fastcgi_connect_timeout 300; + + fastcgi_buffers 16 16k; + fastcgi_buffer_size 32k; + fastcgi_busy_buffers_size 64k; + } + + location ~ /\.(?!well-known).* { + deny all; + } + + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } +} diff --git a/tests/Feature/Environments/DockerEnvironmentTest.php b/tests/Feature/Environments/DockerEnvironmentTest.php index 41598b4..e2b468b 100644 --- a/tests/Feature/Environments/DockerEnvironmentTest.php +++ b/tests/Feature/Environments/DockerEnvironmentTest.php @@ -249,6 +249,70 @@ protected function dockerComposeAvailable(): bool // run() failure propagation // ------------------------------------------------------------------------- + public function test_run_returns_failure_when_ssl_requested_but_mkcert_missing(): void + { + $env = new class extends DockerEnvironment + { + protected function promptForSsl(InstallCommand $command): void + { + $this->ssl = true; + } + + protected function commandExists(string $name): bool + { + return $name !== 'mkcert'; + } + + protected function dockerComposeAvailable(): bool + { + return true; + } + }; + + $result = $env->run(new FakeInstallCommand(null, [], [])); + + $this->assertSame(Command::FAILURE, $result); + } + + public function test_run_skips_mkcert_check_when_ssl_disabled(): void + { + $spy = (object) ['publishCalled' => false]; + + $env = new class($spy) extends DockerEnvironment + { + public function __construct(private object $spy) {} + + protected function promptForSsl(InstallCommand $command): void + { + $this->ssl = false; + } + + protected function commandExists(string $name): bool + { + return false; // mkcert missing — but ssl is off so should not matter + } + + protected function publishStubs(InstallCommand $command): void + { + $this->spy->publishCalled = true; + } + + protected function generateSsl(InstallCommand $command): void {} + + protected function setDockerEnvDefaults(InstallCommand $command): void {} + + protected function startDocker(InstallCommand $command): bool + { + return false; // stop here — we just need to confirm it passed the mkcert gate + } + }; + + $result = $env->run(new FakeInstallCommand(null, [], [])); + + $this->assertSame(Command::FAILURE, $result); // failed at startDocker, not mkcert + $this->assertTrue($spy->publishCalled, 'publishStubs must be reached when ssl is disabled'); + } + public function test_run_returns_failure_and_skips_composer_when_docker_fails_to_start(): void { $spy = (object) ['composerCalled' => false]; @@ -257,6 +321,8 @@ public function test_run_returns_failure_and_skips_composer_when_docker_fails_to { public function __construct(private object $spy) {} + protected function promptForSsl(InstallCommand $command): void {} + protected function publishStubs(InstallCommand $command): void {} protected function generateSsl(InstallCommand $command): void {} @@ -290,6 +356,8 @@ public function test_run_returns_failure_when_composer_install_fails_in_containe { public function __construct(private object $spy) {} + protected function promptForSsl(InstallCommand $command): void {} + protected function publishStubs(InstallCommand $command): void {} protected function generateSsl(InstallCommand $command): void {} @@ -332,7 +400,7 @@ public function test_replaces_sqlite_connection_with_mysql(): void public function test_leaves_mysql_connection_unchanged(): void { - $input = "DB_CONNECTION=mysql\nDB_HOST=mysql\nDB_PORT=3306\nDB_DATABASE=myapp\nDB_USERNAME=myapp\nDB_PASSWORD=secret\nMAIL_MAILER=smtp\n"; + $input = "APP_URL=https://localhost\nDB_CONNECTION=mysql\nDB_HOST=mysql\nDB_PORT=3306\nDB_DATABASE=myapp\nDB_USERNAME=myapp\nDB_PASSWORD=secret\nMAIL_MAILER=smtp\n"; $result = $this->applyDefaults($input); $this->assertSame($input, $result); @@ -415,10 +483,57 @@ public function test_appends_mail_mailer_when_missing(): void $this->assertStringContainsString('MAIL_MAILER=smtp', $result); } + public function test_sets_https_url_when_ssl_enabled(): void + { + $result = $this->applyDefaults("APP_URL=http://localhost\n", ssl: true); + + $this->assertStringContainsString('APP_URL=https://localhost', $result); + $this->assertStringNotContainsString('APP_URL=http://localhost', $result); + } + + public function test_sets_http_url_when_ssl_disabled(): void + { + $result = $this->applyDefaults("APP_URL=https://localhost\n", ssl: false); + + $this->assertStringContainsString('APP_URL=http://localhost', $result); + $this->assertStringNotContainsString('APP_URL=https://localhost', $result); + } + + public function test_replaces_http_localhost_with_port_when_ssl_enabled(): void + { + $result = $this->applyDefaults("APP_URL=http://localhost:8000\n", ssl: true); + + $this->assertStringContainsString('APP_URL=https://localhost', $result); + $this->assertStringNotContainsString('http://localhost:8000', $result); + } + + public function test_appends_https_url_when_missing_and_ssl_enabled(): void + { + $result = $this->applyDefaults("APP_NAME=Test\n", ssl: true); + + $this->assertStringContainsString('APP_URL=https://localhost', $result); + } + + public function test_appends_http_url_when_missing_and_ssl_disabled(): void + { + $result = $this->applyDefaults("APP_NAME=Test\n", ssl: false); + + $this->assertStringContainsString('APP_URL=http://localhost', $result); + } + + public function test_leaves_custom_app_url_unchanged_regardless_of_ssl(): void + { + $input = "APP_URL=https://myapp.test\n"; + + $this->assertStringContainsString('APP_URL=https://myapp.test', $this->applyDefaults($input, ssl: true)); + $this->assertStringContainsString('APP_URL=https://myapp.test', $this->applyDefaults($input, ssl: false)); + } + public function test_real_env_example_pattern_produces_valid_docker_env(): void { $input = implode("\n", [ 'APP_SLUG=acme', + 'APP_URL=http://localhost', 'DB_CONNECTION=sqlite', '# DB_HOST=localhost', '# DB_DATABASE=${APP_SLUG}', @@ -428,7 +543,7 @@ public function test_real_env_example_pattern_produces_valid_docker_env(): void '', ]); - $result = $this->applyDefaults($input); + $result = $this->applyDefaults($input, ssl: true); $this->assertStringContainsString('DB_CONNECTION=mysql', $result); $this->assertStringContainsString('DB_DATABASE=acme', $result); @@ -436,23 +551,24 @@ public function test_real_env_example_pattern_produces_valid_docker_env(): void $this->assertStringContainsString('DB_HOST=mysql', $result); $this->assertStringContainsString('DB_PASSWORD=secret', $result); $this->assertStringContainsString('MAIL_MAILER=smtp', $result); + $this->assertStringContainsString('APP_URL=https://localhost', $result); } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- - private function applyDefaults(string $env): string + private function applyDefaults(string $env, bool $ssl = true): string { $exposed = new class extends DockerEnvironment { - public function applyDockerEnvDefaults(string $env): string + public function applyDockerEnvDefaults(string $env, bool $ssl = true): string { - return parent::applyDockerEnvDefaults($env); + return parent::applyDockerEnvDefaults($env, $ssl); } }; - return $exposed->applyDockerEnvDefaults($env); + return $exposed->applyDockerEnvDefaults($env, $ssl); } /** @@ -508,4 +624,12 @@ public function option($key = null): string|array|bool|null return $this->fakeOptions; } + + public function error($string, $verbosity = null): void {} + + public function line($string, $style = null, $verbosity = null): void {} + + public function info($string, $verbosity = null): void {} + + public function warn($string, $verbosity = null): void {} }