diff --git a/src/Console/Commands/InstallCommand.php b/src/Console/Commands/InstallCommand.php index e4fdd32..24646d8 100644 --- a/src/Console/Commands/InstallCommand.php +++ b/src/Console/Commands/InstallCommand.php @@ -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')); @@ -733,20 +733,23 @@ protected function successCalloutContent(array $steps): array /** @param array $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 $resumeOptions - * @return array - */ - protected function failureCalloutContent(?string $step, array $resumeOptions): array + /** @return array */ + 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.', ])); } diff --git a/src/Environments/DockerEnvironment.php b/src/Environments/DockerEnvironment.php index 2063b4b..922c677 100644 --- a/src/Environments/DockerEnvironment.php +++ b/src/Environments/DockerEnvironment.php @@ -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'); } @@ -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(' 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'); diff --git a/tests/Feature/Environments/DockerEnvironmentTest.php b/tests/Feature/Environments/DockerEnvironmentTest.php index e71545d..611f228 100644 --- a/tests/Feature/Environments/DockerEnvironmentTest.php +++ b/tests/Feature/Environments/DockerEnvironmentTest.php @@ -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); } @@ -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 // ------------------------------------------------------------------------- @@ -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); + } }; } diff --git a/tests/Feature/Environments/NativeEnvironmentTest.php b/tests/Feature/Environments/NativeEnvironmentTest.php index 02afb81..af29dc3 100644 --- a/tests/Feature/Environments/NativeEnvironmentTest.php +++ b/tests/Feature/Environments/NativeEnvironmentTest.php @@ -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; diff --git a/tests/Feature/InstallCommandIdentityTest.php b/tests/Feature/InstallCommandIdentityTest.php index 65909cf..d8f4011 100644 --- a/tests/Feature/InstallCommandIdentityTest.php +++ b/tests/Feature/InstallCommandIdentityTest.php @@ -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); } diff --git a/tests/Feature/InstallCommandResumeTest.php b/tests/Feature/InstallCommandResumeTest.php index 94b5fe6..f11b6ba 100644 --- a/tests/Feature/InstallCommandResumeTest.php +++ b/tests/Feature/InstallCommandResumeTest.php @@ -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']), ); } @@ -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); + } + } } diff --git a/tests/Feature/TestableInstallCommand.php b/tests/Feature/TestableInstallCommand.php index 788daaf..bc0fe39 100644 --- a/tests/Feature/TestableInstallCommand.php +++ b/tests/Feature/TestableInstallCommand.php @@ -44,10 +44,9 @@ public function exposedSuccessCalloutContent(array $steps): array return $this->successCalloutContent($steps); } - /** @param array $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