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
21 changes: 12 additions & 9 deletions src/Console/Commands/InstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ protected function captureDomain(): void
}

/** Read a key straight out of the target app's .env, or null when absent. */
protected function envValue(string $key): ?string
public function envValue(string $key): ?string
{
$env = @file_get_contents($this->path('.env'));

Expand Down Expand Up @@ -733,20 +733,23 @@ protected function successCalloutContent(array $steps): array
/** @param array<string, string> $resumeOptions */
public function displayFailure(?string $step = null, array $resumeOptions = []): void
{
callout(label: 'Installation did not finish', content: $this->failureCalloutContent($step, $resumeOptions));
callout(label: 'Installation did not finish', content: $this->failureCalloutContent($step));

// Printed outside the callout on purpose: Prompts hard-wraps box content to the
// terminal width, and a wrapped command carries the box borders into whatever
// the user pastes. This has to survive a copy-paste to be worth printing.
$this->line('');
$this->line($this->resumeCommand($resumeOptions));
$this->line('');
}

/**
* @param array<string, string> $resumeOptions
* @return array<int, string>
*/
protected function failureCalloutContent(?string $step, array $resumeOptions): array
/** @return array<int, string> */
protected function failureCalloutContent(?string $step): array
{
return array_values(array_filter([
$step ? "Failed at: {$step}" : null,
'Your application directory is intact — nothing was rolled back.',
'Fix the problem reported above, then resume with:',
' '.$this->resumeCommand($resumeOptions),
'Fix the problem reported above, then resume with the command below.',
]));
}

Expand Down
65 changes: 65 additions & 0 deletions src/Environments/DockerEnvironment.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ protected function boot(InstallCommand $command): int
return $this->fail('Generating application key');
}

if (! $this->ensureDatabaseUser($command)) {
return $this->fail('Preparing the database user');
}

if (! $this->runMigrations($command)) {
return $this->fail('Running migrations');
}
Expand Down Expand Up @@ -639,6 +643,67 @@ protected function generateAppKey(InstallCommand $command): bool
return $this->execInContainer($command, ['php', 'artisan', 'key:generate', '--force']);
}

/**
* Create the database and user this app expects, if MySQL never did.
*
* MYSQL_USER / MYSQL_DATABASE are only honoured when MySQL initialises an *empty*
* data directory. A volume left over from an earlier install keeps whatever user it
* was first created with, so an app whose slug has since changed authenticates as a
* user that does not exist. Root still works (compose seeds MYSQL_ROOT_PASSWORD
* from DB_PASSWORD), so fix it up rather than making the user destroy the volume.
*/
protected function ensureDatabaseUser(InstallCommand $command): bool
{
$database = (string) $command->envValue('DB_DATABASE');
$user = (string) $command->envValue('DB_USERNAME');
$password = (string) $command->envValue('DB_PASSWORD');

$sql = $this->databaseRepairSql($database, $user, $password);

if ($sql === null) {
return true;
}

$process = new Process(
['docker', 'compose', 'exec', '-T', 'mysql', 'mysql', '-uroot', '-p'.$password, '-e', $sql],
$command->targetPath(),
);
$process->setTimeout(60);
$process->run();

if ($process->isSuccessful()) {
return true;
}

// Root did not work either, so the volume predates this password too.
$command->error("Could not prepare database user \"{$user}\".");
$command->line(' The MySQL volume was created with different credentials.');
$command->line(' Reset it (this destroys local database data):');
$command->line(' <fg=yellow>docker compose down -v && docker compose up -d</>');

return false;
}

/**
* The idempotent SQL that creates this app's database and user, or null when the
* names are not plain identifiers — those are the user's own arrangement, and
* interpolating them would be an injection.
*/
protected function databaseRepairSql(string $database, string $user, string $password): ?string
{
if (preg_match('/^[A-Za-z0-9_-]+$/', $database) !== 1 || preg_match('/^[A-Za-z0-9_-]+$/', $user) !== 1) {
return null;
}

$quoted = str_replace("'", "''", $password);

return "CREATE DATABASE IF NOT EXISTS `{$database}`; "
."CREATE USER IF NOT EXISTS '{$user}'@'%' IDENTIFIED BY '{$quoted}'; "
."ALTER USER '{$user}'@'%' IDENTIFIED BY '{$quoted}'; "
."GRANT ALL PRIVILEGES ON `{$database}`.* TO '{$user}'@'%'; "
.'FLUSH PRIVILEGES;';
}

protected function runMigrations(InstallCommand $command): bool
{
$fresh = $command->option('fresh');
Expand Down
62 changes: 53 additions & 9 deletions tests/Feature/Environments/DockerEnvironmentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -453,22 +453,22 @@ public function rewriteCrossModuleImports(): void {}

/** Verbatim `docker ps` output from a machine running a conflicting Saucebase stack. */
private const DOCKER_PS = <<<'OUTPUT'
|whatsthere-queue-1|whatsthere
9000/tcp|whatsthere-app-1|whatsthere
0.0.0.0:6379->6379/tcp, [::]:6379->6379/tcp|whatsthere-redis-1|whatsthere
0.0.0.0:3306->3306/tcp, [::]:3306->3306/tcp|whatsthere-mysql-1|whatsthere
0.0.0.0:1025->1025/tcp, [::]:1025->1025/tcp, 0.0.0.0:8025->8025/tcp, [::]:8025->8025/tcp|whatsthere-mailpit-1|whatsthere
|my-app-example-queue-1|my-app-example
9000/tcp|my-app-example-app-1|my-app-example
0.0.0.0:6379->6379/tcp, [::]:6379->6379/tcp|my-app-example-redis-1|my-app-example
0.0.0.0:3306->3306/tcp, [::]:3306->3306/tcp|my-app-example-mysql-1|my-app-example
0.0.0.0:1025->1025/tcp, [::]:1025->1025/tcp, 0.0.0.0:8025->8025/tcp, [::]:8025->8025/tcp|my-app-example-mailpit-1|my-app-example
OUTPUT;

public function test_parses_published_ports_to_their_owning_compose_project(): void
{
$owners = $this->exposed()->exposedParseDockerPortOwners(self::DOCKER_PS);

$this->assertSame(['container' => 'whatsthere-redis-1', 'project' => 'whatsthere'], $owners[6379]);
$this->assertSame(['container' => 'whatsthere-mysql-1', 'project' => 'whatsthere'], $owners[3306]);
$this->assertSame(['container' => 'my-app-example-redis-1', 'project' => 'my-app-example'], $owners[6379]);
$this->assertSame(['container' => 'my-app-example-mysql-1', 'project' => 'my-app-example'], $owners[3306]);
// One container can publish several ports.
$this->assertSame(['container' => 'whatsthere-mailpit-1', 'project' => 'whatsthere'], $owners[1025]);
$this->assertSame(['container' => 'whatsthere-mailpit-1', 'project' => 'whatsthere'], $owners[8025]);
$this->assertSame(['container' => 'my-app-example-mailpit-1', 'project' => 'my-app-example'], $owners[1025]);
$this->assertSame(['container' => 'my-app-example-mailpit-1', 'project' => 'my-app-example'], $owners[8025]);
$this->assertCount(4, $owners);
}

Expand Down Expand Up @@ -982,6 +982,45 @@ public function test_certificate_hosts_do_not_repeat_localhost(): void
$this->assertSame(['localhost', '*.localhost', '127.0.0.1', '::1'], $hosts);
}

// -------------------------------------------------------------------------
// Database user repair
// -------------------------------------------------------------------------

public function test_repair_sql_creates_the_database_and_user_this_app_expects(): void
{
// MySQL only honours MYSQL_USER/MYSQL_DATABASE on an empty data directory, so a
// volume from an earlier install keeps the user it was first created with.
$sql = $this->exposed()->exposedDatabaseRepairSql('my-app-example', 'my-app-example', 'secret');

$this->assertStringContainsString('CREATE DATABASE IF NOT EXISTS `my-app-example`;', $sql);
$this->assertStringContainsString("CREATE USER IF NOT EXISTS 'my-app-example'@'%'", $sql);
$this->assertStringContainsString('GRANT ALL PRIVILEGES ON `my-app-example`.*', $sql);
}

public function test_repair_sql_resets_the_password_so_a_stale_user_still_converges(): void
{
$sql = $this->exposed()->exposedDatabaseRepairSql('app', 'app', 'secret');

// CREATE USER IF NOT EXISTS alone leaves an existing user's old password.
$this->assertStringContainsString("ALTER USER 'app'@'%' IDENTIFIED BY 'secret'", $sql);
}

public function test_repair_sql_escapes_quotes_in_the_password(): void
{
$sql = $this->exposed()->exposedDatabaseRepairSql('app', 'app', "pa'ss");

$this->assertStringContainsString("IDENTIFIED BY 'pa''ss'", $sql);
}

public function test_repair_is_skipped_for_identifiers_it_cannot_safely_interpolate(): void
{
$env = $this->exposed();

$this->assertNull($env->exposedDatabaseRepairSql('app`; DROP DATABASE x; --', 'app', 'secret'));
$this->assertNull($env->exposedDatabaseRepairSql('app', "ro'ot", 'secret'));
$this->assertNull($env->exposedDatabaseRepairSql('', '', 'secret'));
}

// -------------------------------------------------------------------------
// Failure messaging
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -1131,6 +1170,11 @@ public function exposedCertCoversHost(string $certFile, string $host): bool
{
return $this->certCoversHost($certFile, $host);
}

public function exposedDatabaseRepairSql(string $db, string $user, string $password): ?string
{
return $this->databaseRepairSql($db, $user, $password);
}
};
}

Expand Down
3 changes: 3 additions & 0 deletions tests/Feature/Environments/NativeEnvironmentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ public function promptForModules(): void {}

public function displaySuccess(array $steps = []): void {}

// The failure path prints the resume command; no output is bound here.
public function line($string, $style = null, $verbosity = null): void {}

public function install(): int
{
return Command::FAILURE;
Expand Down
4 changes: 2 additions & 2 deletions tests/Feature/InstallCommandIdentityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ public function test_names_the_app_after_its_directory(): void

public function test_leaves_a_single_word_name_unquoted(): void
{
$result = $this->apply("APP_NAME=Saucebase\n", dir: 'whatsthere');
$result = $this->apply("APP_NAME=Saucebase\n", dir: 'blog');

$this->assertStringContainsString('APP_NAME=Whatsthere', $result);
$this->assertStringContainsString('APP_NAME=Blog', $result);
$this->assertStringNotContainsString('"', $result);
}

Expand Down
29 changes: 20 additions & 9 deletions tests/Feature/InstallCommandResumeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ private function command(array $options, ?string $stack = 'vue', array $modules
public function test_resume_command_bakes_in_every_answer_so_nothing_is_re_prompted(): void
{
$command = $this->command(
['path' => '/tmp/whatsthere'],
['path' => '/tmp/my-app-example'],
modules: ['saucebase/auth', 'saucebase/billing'],
);

$this->assertSame(
'cd /tmp/whatsthere && saucebase install vue --driver=docker --ssl=yes --modules=saucebase/auth,saucebase/billing',
'cd /tmp/my-app-example && saucebase install vue --driver=docker --ssl=yes --modules=saucebase/auth,saucebase/billing',
$command->resumeCommand(['--driver' => 'docker', '--ssl' => 'yes']),
);
}
Expand Down Expand Up @@ -86,25 +86,36 @@ public function test_resume_command_escapes_shell_metacharacters_in_the_path():
public function test_resume_command_leaves_an_ordinary_path_unquoted(): void
{
$this->assertStringStartsWith(
'cd /tmp/whatsthere && ',
$this->command(['path' => '/tmp/whatsthere'])->resumeCommand(),
'cd /tmp/my-app-example && ',
$this->command(['path' => '/tmp/my-app-example'])->resumeCommand(),
);
}

public function test_failure_callout_names_the_step_and_the_resume_command(): void
public function test_failure_callout_names_the_step(): void
{
$content = $this->command(['path' => '/tmp/whatsthere'])
->exposedFailureCalloutContent('Starting Docker services', ['--driver' => 'docker']);
$content = $this->command(['path' => '/tmp/my-app-example'])
->exposedFailureCalloutContent('Starting Docker services');

$this->assertSame('Failed at: Starting Docker services', $content[0]);
$this->assertStringContainsString('directory', implode(' ', $content));
$this->assertStringContainsString('cd /tmp/whatsthere && saucebase install vue --driver=docker', end($content));
}

public function test_failure_callout_omits_the_step_line_when_the_step_is_unknown(): void
{
$content = $this->command(['path' => '/tmp/app'])->exposedFailureCalloutContent(null, []);
$content = $this->command(['path' => '/tmp/app'])->exposedFailureCalloutContent(null);

$this->assertStringNotContainsString('Failed at:', implode(' ', $content));
}

public function test_the_resume_command_is_never_wrapped_inside_the_callout(): void
{
// Prompts hard-wraps callout content and the borders end up in the paste, so
// the command must be printed outside the box.
$content = $this->command(['path' => '/tmp/my-app-example'])
->exposedFailureCalloutContent('Running migrations');

foreach ($content as $line) {
$this->assertStringNotContainsString('saucebase install', $line);
}
}
}
5 changes: 2 additions & 3 deletions tests/Feature/TestableInstallCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,9 @@ public function exposedSuccessCalloutContent(array $steps): array
return $this->successCalloutContent($steps);
}

/** @param array<string, string> $resumeOptions */
public function exposedFailureCalloutContent(?string $step, array $resumeOptions): array
public function exposedFailureCalloutContent(?string $step): array
{
return $this->failureCalloutContent($step, $resumeOptions);
return $this->failureCalloutContent($step);
}

public function exposedApplyIdentityToEnv(string $env, string $name, string $slug, string $host, bool $native): string
Expand Down