diff --git a/app/Commands/ProvisionCommand.php b/app/Commands/ProvisionCommand.php index ceafd51..4605fe9 100644 --- a/app/Commands/ProvisionCommand.php +++ b/app/Commands/ProvisionCommand.php @@ -22,12 +22,14 @@ use App\Services\Forge\Pipeline\EnsureJobScheduled; use App\Services\Forge\Pipeline\FindServer; use App\Services\Forge\Pipeline\FindSite; +use App\Services\Forge\Pipeline\ImportDatabaseFromSql; use App\Services\Forge\Pipeline\InstallGitRepository; use App\Services\Forge\Pipeline\NginxTemplateSearchReplace; use App\Services\Forge\Pipeline\ObtainLetsEncryptCertification; use App\Services\Forge\Pipeline\OrCreateNewSite; use App\Services\Forge\Pipeline\PutCommentOnPullRequest; use App\Services\Forge\Pipeline\RunOptionalCommands; +use App\Services\Forge\Pipeline\SeedDatabase; use App\Services\Forge\Pipeline\UpdateDeployScript; use App\Services\Forge\Pipeline\UpdateEnvironmentVariables; use App\Traits\Outputifier; @@ -56,7 +58,9 @@ public function handle(ForgeService $service): void EnableQuickDeploy::class, UpdateEnvironmentVariables::class, UpdateDeployScript::class, + ImportDatabaseFromSql::class, DeploySite::class, + SeedDatabase::class, RunOptionalCommands::class, EnsureJobScheduled::class, PutCommentOnPullRequest::class, diff --git a/app/Rules/DBSeed.php b/app/Rules/DBSeed.php new file mode 100644 index 0000000..14d54c4 --- /dev/null +++ b/app/Rules/DBSeed.php @@ -0,0 +1,16 @@ +site->username, $this->site->name); } + + public function waitForSiteCommand(SiteCommand $site_command): SiteCommand + { + $waiter = app()->makeWith(ForgeSiteCommandWaiter::class, ['forge' => $this->forge]); + return $waiter->waitFor($site_command); + } } diff --git a/app/Services/Forge/ForgeSetting.php b/app/Services/Forge/ForgeSetting.php index 24173fb..2d3631d 100644 --- a/app/Services/Forge/ForgeSetting.php +++ b/app/Services/Forge/ForgeSetting.php @@ -14,6 +14,7 @@ namespace App\Services\Forge; use App\Rules\BranchNameRegex; +use App\Rules\DBSeed; use App\Traits\Outputifier; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; @@ -123,6 +124,21 @@ class ForgeSetting */ public ?string $dbName; + /** + * Flag / seeder to seed database. + */ + public bool|string $dbSeed; + + /** + * Path of file to import into database. + */ + public ?string $dbImportSql; + + /** + * Flag to import database on deployment. + */ + public bool $dbImportOnDeployment; + /** * Flag to auto-source environment variables in deployment. */ @@ -215,7 +231,7 @@ protected function validate(array $configurations): \Illuminate\Validation\Valid 'domain' => ['required'], 'git_provider' => ['required'], 'repository' => ['required'], - 'branch' => ['required', new BranchNameRegex], + 'branch' => ['required', new BranchNameRegex()], 'project_type' => ['string'], 'php_version' => ['nullable', 'string'], 'subdomain_pattern' => ['nullable', 'string'], @@ -226,6 +242,9 @@ protected function validate(array $configurations): \Illuminate\Validation\Valid 'job_scheduler_required' => ['boolean'], 'db_creation_required' => ['boolean'], 'db_name' => ['nullable', 'string'], + 'db_import_sql' => ['nullable', 'string'], + 'db_import_seed' => [new DBSeed()], + 'db_import_on_deployment' => ['boolean'], 'auto_source_required' => ['boolean'], 'ssl_required' => ['boolean'], 'wait_on_ssl' => ['boolean'], diff --git a/app/Services/Forge/ForgeSiteCommandWaiter.php b/app/Services/Forge/ForgeSiteCommandWaiter.php new file mode 100644 index 0000000..0cbacc1 --- /dev/null +++ b/app/Services/Forge/ForgeSiteCommandWaiter.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Services\Forge; + +use Laravel\Forge\Forge; +use Laravel\Forge\Resources\SiteCommand; +use Illuminate\Support\Sleep; + +class ForgeSiteCommandWaiter +{ + /** + * The number of seconds to wait between querying Forge for the command status. + */ + public int $retrySeconds = 10; + + /** + * The number of attempts to make before returning the command. + */ + public int $maxAttempts = 60; + + /** + * The current number of attempts. + */ + protected int $attempts = 0; + + public function __construct(public Forge $forge) + { + } + + public function waitFor(SiteCommand $site_command): SiteCommand + { + $this->attempts = 0; + + while ( + $this->commandIsRunning($site_command) + && $this->attempts++ < $this->maxAttempts + ) { + Sleep::for($this->retrySeconds)->seconds(); + + $site_command = $this->forge->getSiteCommand( + $site_command->serverId, + $site_command->siteId, + $site_command->id + )[0]; + } + + return $site_command; + } + + protected function commandIsRunning(SiteCommand $site_command): bool + { + return !isset($site_command->status) + || in_array($site_command->status, ['running', 'waiting']); + } + +} diff --git a/app/Services/Forge/Pipeline/ImportDatabaseFromSql.php b/app/Services/Forge/Pipeline/ImportDatabaseFromSql.php new file mode 100644 index 0000000..18ecbf4 --- /dev/null +++ b/app/Services/Forge/Pipeline/ImportDatabaseFromSql.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Services\Forge\Pipeline; + +use App\Services\Forge\ForgeService; +use App\Traits\Outputifier; +use Closure; + +class ImportDatabaseFromSql +{ + use Outputifier; + + public function __invoke(ForgeService $service, Closure $next) + { + if (!($file = $service->setting->dbImportSql)) { + return $next($service); + } + + if (!$service->siteNewlyMade && !$service->setting->dbImportOnDeployment) { + return $next($service); + } + + return $this->attemptImport($service, $next, $file); + } + + public function attemptImport(ForgeService $service, Closure $next, string $file) + { + $this->information(sprintf('Importing database from %s.', $file)); + + $content = $this->buildImportCommandContent($service, $file); + + $site_command = $service->waitForSiteCommand( + $service->forge->executeSiteCommand( + $service->setting->server, + $service->site->id, + ['command' => $content] + ) + ); + + if ($site_command->status === 'failed') { + $this->fail(sprintf('---> Database import failed with message: %s', $site_command->output)); + return $next; + + } elseif ($site_command->status !== 'finished') { + $this->fail('---> Database import did not finish in time.'); + return $next; + } + + return $next($service); + } + + public function buildImportCommandContent(ForgeService $service, string $file): string + { + $extract = match(pathinfo($file, PATHINFO_EXTENSION)) { + 'gz' => "gunzip < {$file}", + 'zip' => "unzip -p {$file}", + default => "cat {$file}" + }; + + return implode(' ', [ + $extract, + '|', + $this->buildDatabaseConnection($service) + ]); + } + + protected function buildDatabaseConnection(ForgeService $service): string + { + if (str_contains($service->server->databaseType, 'postgres')) { + return sprintf( + 'pgsql postgres://%s:%s%s/%s', + $service->database['DB_USERNAME'], + $service->database['DB_PASSWORD'], + isset($service->database['DB_HOST']) + ? sprintf( + '@%s:%s', + $service->database['DB_HOST'], + $service->database['DB_PORT'] ?? '5432', + ) + : '', + $service->getFormattedDatabaseName(), + ); + } + + return sprintf( + '%s -u %s -p%s %s %s %s', + str_contains($service->server->databaseType, 'mariadb') ? 'mariadb' : 'mysql', + $service->database['DB_USERNAME'], + $service->database['DB_PASSWORD'], + isset($service->database['DB_HOST']) ? '-h ' . $service->database['DB_HOST'] : '', + isset($service->database['DB_PORT']) ? '-P ' . $service->database['DB_PORT'] : '', + $service->getFormattedDatabaseName(), + ); + } +} diff --git a/app/Services/Forge/Pipeline/SeedDatabase.php b/app/Services/Forge/Pipeline/SeedDatabase.php new file mode 100644 index 0000000..2a532ce --- /dev/null +++ b/app/Services/Forge/Pipeline/SeedDatabase.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Services\Forge\Pipeline; + +use App\Services\Forge\ForgeService; +use App\Traits\Outputifier; +use Closure; + +class SeedDatabase +{ + use Outputifier; + + public function __invoke(ForgeService $service, Closure $next) + { + if (!($seeder = $service->setting->dbSeed)) { + return $next($service); + } + + if (!$service->siteNewlyMade) { + return $next($service); + } + + return $this->attemptSeed($service, $next, $seeder); + } + + public function attemptSeed(ForgeService $service, Closure $next) + { + $this->information(sprintf('Seeding database.')); + + $content = $this->buildImportCommandContent($service); + + $site_command = $service->waitForSiteCommand( + $service->forge->executeSiteCommand( + $service->setting->server, + $service->site->id, + ['command' => $content] + ) + ); + + if ($site_command->status === 'failed') { + $this->fail(sprintf('---> Database seed failed with message: %s', $site_command->output)); + return $next; + + } elseif ($site_command->status !== 'finished') { + $this->fail('---> Database seed did not finish in time.'); + return $next; + } + + return $next($service); + } + + public function buildImportCommandContent(ForgeService $service): string + { + $seeder = ''; + if (is_string($service->setting->dbSeed)) { + $seeder = sprintf( + '--class=%s', + $service->setting->dbSeed + ); + } + + return trim(sprintf( + '%s artisan db:seed %s', + $this->phpExecutable($service->site->phpVersion ?? 'php'), + $seeder + )); + } + + /** + * Forge's phpVersion strings don't exactly map to the executable. + * For example php83 corresponds to the php8.3 executable. + * This workaround assumes no minor versions above 9! + */ + protected function phpExecutable(string $phpVersion): string + { + return preg_replace_callback('/\d$/', fn ($matches) => '.' . $matches[0], $phpVersion); + } +} diff --git a/config/forge.php b/config/forge.php index 0b71ebb..7e6b933 100644 --- a/config/forge.php +++ b/config/forge.php @@ -58,6 +58,15 @@ // Override default database and database username, if needed. Defaults to the site name. 'db_name' => env('FORGE_DB_NAME', null), + // Seed the database (default: false). + 'db_seed' => env('FORGE_DB_SEED', false), + + // Import the database via a SQL file (default: null). + 'db_import_sql' => env('FORGE_DB_IMPORT_SQL', null), + + // Flag to perform database import on deployment (default: false). + 'db_import_on_deployment' => env('FORGE_DB_IMPORT_ON_DEPLOYMENT', false), + // Flag to enable SSL certification (default: false). 'ssl_required' => env('FORGE_SSL_REQUIRED', false), diff --git a/tests/Pest.php b/tests/Pest.php index 68e2388..e44793f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,11 @@ timeoutSeconds = 0; + foreach ($settings as $name => $value) { + $setting->{$name} = $value; + } + + $forge = Mockery::mock(Forge::class); + $forge->shouldReceive('setTimeout') + ->with($setting->timeoutSeconds); + + $service = Mockery::mock(ForgeService::class, [$setting, $forge])->makePartial(); + $service->site = new Site($site_attributes); + $service->server = new Server($server_attributes); + + return $service; } diff --git a/tests/Unit/Services/Forge/ForgeSiteCommandWaiterTest.php b/tests/Unit/Services/Forge/ForgeSiteCommandWaiterTest.php new file mode 100644 index 0000000..c19ffdc --- /dev/null +++ b/tests/Unit/Services/Forge/ForgeSiteCommandWaiterTest.php @@ -0,0 +1,63 @@ +maxAttempts = 3; + $waiter->retrySeconds = 5; + + Sleep::fake(); + + $forge->shouldReceive('getSiteCommand') + ->times($waiter->maxAttempts) + ->andReturn([$site_command]); + + $site_command = $waiter->waitFor($site_command); + + Sleep::assertSequence([ + Sleep::for($waiter->retrySeconds)->seconds(), + Sleep::for($waiter->retrySeconds)->seconds(), + Sleep::for($waiter->retrySeconds)->seconds(), + ]); +}); + +test('it waits until the command is no longer running', function () { + + $forge = Mockery::mock(Forge::class); + $site_command = Mockery::mock(SiteCommand::class); + $finished_command = Mockery::mock(SiteCommand::class); + $finished_command->status = 'finished'; + + $waiter = new ForgeSiteCommandWaiter($forge); + $waiter->maxAttempts = 10; + $waiter->retrySeconds = 5; + + Sleep::fake(); + + $forge->shouldReceive('getSiteCommand') + ->times(3) + ->andReturn( + [$site_command], + [$site_command], + [$finished_command] + ); + + $site_command = $waiter->waitFor($site_command); + + expect($site_command->status)->toBe($finished_command->status); + + Sleep::assertSequence([ + Sleep::for($waiter->retrySeconds)->seconds(), + Sleep::for($waiter->retrySeconds)->seconds(), + Sleep::for($waiter->retrySeconds)->seconds(), + ]); + +}); diff --git a/tests/Unit/Services/Forge/Pipeline/ImportDatabaseFromSqlTest.php b/tests/Unit/Services/Forge/Pipeline/ImportDatabaseFromSqlTest.php new file mode 100644 index 0000000..0bdc627 --- /dev/null +++ b/tests/Unit/Services/Forge/Pipeline/ImportDatabaseFromSqlTest.php @@ -0,0 +1,221 @@ + true, + 'dbImportOnDeployment' => false, + ]); + + $pipe = Mockery::mock(ImportDatabaseFromSql::class) + ->makePartial(); + $pipe->shouldReceive('attemptImport') + ->never(); + + $next = fn () => true; + expect($pipe($service, $next))->toBe(true); +}); + +test('it skips import when siteNewlyMade is true and file is not present', function () { + + $service = configureMockService([ + 'dbImportOnDeployment' => false, + 'dbImportSql' => null, + ]); + + $service->siteNewlyMade = true; + + $pipe = Mockery::mock(ImportDatabaseFromSql::class) + ->makePartial(); + $pipe->shouldReceive('attemptImport') + ->never(); + + $next = fn () => true; + expect($pipe($service, $next))->toBe(true); +}); + +test('it attempts import when siteNewlyMade is false, dbImportOnDeployment is true, and file is present', function () { + + $service = configureMockService([ + 'dbImportOnDeployment' => true, + 'dbImportSql' => 'xyz.sql', + ]); + + $next = fn () => true; + $pipe = Mockery::mock(ImportDatabaseFromSql::class) + ->makePartial(); + $pipe->shouldReceive('attemptImport') + ->once() + ->andReturn($next()); + + expect($pipe($service, $next))->toBe(true); +}); + +test('it attempts import when siteNewlyMade is true and file is present', function () { + + $service = configureMockService([ + 'dbImportOnDeployment' => false, + 'dbImportSql' => 'xyz.sql', + ]); + $service->siteNewlyMade = true; + + $next = fn () => true; + $pipe = Mockery::mock(ImportDatabaseFromSql::class) + ->makePartial(); + $pipe->shouldReceive('attemptImport') + ->once() + ->andReturn($next()); + + expect($pipe($service, $next))->toBe(true); +}); + +test('it generates import command', function (string $databaseType, string $file, string $expected) { + + $service = configureMockService( + settings: [ + 'dbName' => 'my_db', + ], + server_attributes: [ + 'databaseType' => $databaseType + ] + ); + $service->setDatabase([ + 'DB_USERNAME' => 'foo', + 'DB_PASSWORD' => 'bar', + 'DB_HOST' => '1.2.3.4', + 'DB_PORT' => 1234, + ]); + + $pipe = new ImportDatabaseFromSql(); + + expect($pipe->buildImportCommandContent($service, '/path/to/' . $file)) + ->toBe($expected); +})->with([ + 'mysql with .gz extension' => ['mysql', 'db.sql.gz', 'gunzip < /path/to/db.sql.gz | mysql -u foo -pbar -h 1.2.3.4 -P 1234 my_db'], + 'mysql with .zip extension' => ['mysql', 'db.sql.zip', 'unzip -p /path/to/db.sql.zip | mysql -u foo -pbar -h 1.2.3.4 -P 1234 my_db'], + 'mysql with .sql extension' => ['mysql', 'db.sql', 'cat /path/to/db.sql | mysql -u foo -pbar -h 1.2.3.4 -P 1234 my_db'], + + 'mariadb with .gz extension' => ['mariadb', 'db.sql.gz', 'gunzip < /path/to/db.sql.gz | mariadb -u foo -pbar -h 1.2.3.4 -P 1234 my_db'], + 'mariadb with .zip extension' => ['mariadb', 'db.sql.zip', 'unzip -p /path/to/db.sql.zip | mariadb -u foo -pbar -h 1.2.3.4 -P 1234 my_db'], + 'mariadb with .sql extension' => ['mariadb', 'db.sql', 'cat /path/to/db.sql | mariadb -u foo -pbar -h 1.2.3.4 -P 1234 my_db'], + + 'postgres with .gz extension' => ['postgres', 'db.sql.gz', 'gunzip < /path/to/db.sql.gz | pgsql postgres://foo:bar@1.2.3.4:1234/my_db'], + 'postgres with .zip extension' => ['postgres', 'db.sql.zip', 'unzip -p /path/to/db.sql.zip | pgsql postgres://foo:bar@1.2.3.4:1234/my_db'], + 'postgres with .sql extension' => ['postgres', 'db.sql', 'cat /path/to/db.sql | pgsql postgres://foo:bar@1.2.3.4:1234/my_db'], +]); + +test('it executes import command with finished response', function () { + + $service = configureMockService( + settings: [ + 'dbName' => 'my_db', + 'server' => 1, + ], + site_attributes: [ + 'id' => 2, + ], + server_attributes: [ + 'databaseType' => 'mysql' + ] + ); + $service->setDatabase([ + 'DB_USERNAME' => 'foo', + 'DB_PASSWORD' => 'bar', + 'DB_HOST' => '1.2.3.4', + 'DB_PORT' => 1234, + ]); + + $site_command = Mockery::mock(SiteCommand::class); + $site_command->status = 'finished'; + + $service->forge->shouldReceive('executeSiteCommand') + ->with(1, 2, ['command' => 'cat x.sql | mysql -u foo -pbar -h 1.2.3.4 -P 1234 my_db']) + ->once() + ->andReturn($site_command); + + $next = fn () => true; + + $pipe = new ImportDatabaseFromSql(); + $result = $pipe->attemptImport( + $service, + $next, + 'x.sql' + ); + + expect($result)->toBe(true); +}); + +test('it executes import command with failure status', function () { + + $service = configureMockService( + settings: [ + 'dbName' => 'my_db', + 'server' => 1, + ], + site_attributes: [ + 'id' => 2, + ], + server_attributes: [ + 'databaseType' => 'mysql' + ] + ); + + $site_command = Mockery::mock(SiteCommand::class); + $site_command->status = 'failed'; + $site_command->output = 'oops'; + + $service->forge->shouldReceive('executeSiteCommand') + ->once() + ->andReturn($site_command); + + $next = fn () => true; + + $pipe = new ImportDatabaseFromSql(); + $result = $pipe->attemptImport( + $service, + $next, + 'x.sql' + ); + + expect($result)->toBe($next); +}); + +test('it executes import command with missing status', function () { + + $service = configureMockService( + settings: [ + 'dbName' => 'my_db', + 'server' => 1 + ], + site_attributes: [ + 'id' => 2, + ], + server_attributes: [ + 'databaseType' => 'mysql' + ] + ); + + $site_command = Mockery::mock(SiteCommand::class); + + $service->forge->shouldReceive('executeSiteCommand') + ->once() + ->andReturn($site_command); + + $service->shouldReceive('waitForSiteCommand') + ->with($site_command) + ->andReturn($site_command); + + $next = fn () => true; + + $pipe = new ImportDatabaseFromSql(); + $result = $pipe->attemptImport( + $service, + $next, + 'x.sql' + ); + + expect($result)->toBe($next); +}); diff --git a/tests/Unit/Services/Forge/Pipeline/SeedDatabaseTest.php b/tests/Unit/Services/Forge/Pipeline/SeedDatabaseTest.php new file mode 100644 index 0000000..1a35571 --- /dev/null +++ b/tests/Unit/Services/Forge/Pipeline/SeedDatabaseTest.php @@ -0,0 +1,167 @@ + true + ]); + $service->siteNewlyMade = true; + + $next = fn () => true; + $pipe = Mockery::mock(SeedDatabase::class) + ->makePartial(); + $pipe->shouldReceive('attemptSeed') + ->once() + ->andReturn($next()); + + expect($pipe($service, $next))->toBe(true); +}); + +test('it skips import when siteNewlyMade is false', function () { + + $service = configureMockService([ + 'dbSeed' => true + ]); + $service->siteNewlyMade = false; + + $next = fn () => true; + $pipe = Mockery::mock(SeedDatabase::class) + ->makePartial(); + $pipe->shouldReceive('attemptSeed') + ->never(); + + expect($pipe($service, $next))->toBe(true); +}); + +test('it generates import command without phpVersion', function () { + + $service = configureMockService([ + 'dbSeed' => true, + ]); + $service->siteNewlyMade = true; + + $pipe = new SeedDatabase(); + + expect($pipe->buildImportCommandContent($service)) + ->toBe('php artisan db:seed'); +}); + +test('it generates import command with phpVersion', function () { + + $service = configureMockService( + settings: [ + 'dbSeed' => true, + ], + site_attributes: [ + 'phpVersion' => 'php81', + ] + ); + $service->siteNewlyMade = true; + + $pipe = new SeedDatabase(); + + expect($pipe->buildImportCommandContent($service)) + ->toBe('php8.1 artisan db:seed'); +}); + +test('it generates import command with custom seeder', function () { + + $service = configureMockService([ + 'dbSeed' => 'FooSeeder', + ]); + $service->siteNewlyMade = true; + + $pipe = new SeedDatabase(); + + expect($pipe->buildImportCommandContent($service)) + ->toBe('php artisan db:seed --class=FooSeeder'); +}); + +test('it executes import command with finished response', function () { + + $service = configureMockService( + settings: [ + 'dbSeed' => true, + 'server' => 1, + ], + site_attributes: [ + 'id' => 2, + ] + ); + $service->siteNewlyMade = true; + + $site_command = Mockery::mock(SiteCommand::class); + $site_command->status = 'finished'; + + $service->forge->shouldReceive('executeSiteCommand') + ->with(1, 2, ['command' => 'php artisan db:seed']) + ->once() + ->andReturn($site_command); + + $next = fn () => true; + + $pipe = new SeedDatabase(); + $result = $pipe->attemptSeed( + $service, + $next + ); + + expect($result)->toBe(true); +}); + +test('it executes import command with failure status', function () { + + $service = configureMockService([ + 'dbSeed' => true, + 'server' => 1, + ]); + + $site_command = Mockery::mock(SiteCommand::class); + $site_command->status = 'failed'; + $site_command->output = 'oops'; + + $service->forge->shouldReceive('executeSiteCommand') + ->once() + ->andReturn($site_command); + + $next = fn () => true; + + $pipe = new SeedDatabase(); + $result = $pipe->attemptSeed( + $service, + $next + ); + + expect($result)->toBe($next); +}); + +test('it executes import command with missing status', function () { + + $service = configureMockService([ + 'dbSeed' => true, + 'server' => 1, + ]); + + $site_command = Mockery::mock(SiteCommand::class); + + $service->forge->shouldReceive('executeSiteCommand') + ->once() + ->andReturn($site_command); + + $service->shouldReceive('waitForSiteCommand') + ->with($site_command) + ->andReturn($site_command); + + $next = fn () => true; + + $pipe = new SeedDatabase(); + $result = $pipe->attemptSeed( + $service, + $next + ); + + expect($result)->toBe($next); +});