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
50 changes: 48 additions & 2 deletions src/Environments/DockerEnvironment.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand All @@ -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');

Expand Down Expand Up @@ -186,15 +221,15 @@ 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);
$command->info('Docker database credentials written to .env.');
}
}

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)) {
Expand All @@ -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',
Expand Down
86 changes: 86 additions & 0 deletions stubs/docker/docker/nginx-no-ssl.conf
Original file line number Diff line number Diff line change
@@ -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;
}
}
136 changes: 130 additions & 6 deletions tests/Feature/Environments/DockerEnvironmentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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 {}
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}',
Expand All @@ -428,31 +543,32 @@ 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);
$this->assertStringContainsString('DB_USERNAME=acme', $result);
$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);
}

/**
Expand Down Expand Up @@ -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 {}
}