diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index d1d1302..12b0567 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -25,7 +25,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: composer-${{ hashFiles('**/composer.lock') }} diff --git a/app/Services/Forge/Actions/RecreateDatabase.php b/app/Services/Forge/Actions/RecreateDatabase.php new file mode 100644 index 0000000..288742b --- /dev/null +++ b/app/Services/Forge/Actions/RecreateDatabase.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Services\Forge\Actions; + +use App\Services\Forge\ForgeService; +use App\Traits\Outputifier; + +class RecreateDatabase +{ + use Outputifier; + + /** + * Handle database recreation and creation process + * + * @param ForgeService $service The forge service + * @param string $dbName The database name + * @param string $dbPassword Password for database creation + * @return bool True if database needs to be updated in the env file + */ + public function handle(ForgeService $service, string $dbName, string $dbPassword): bool + { + $databaseId = null; + foreach ($service->forge->databases($service->server->id) as $database) { + if (isset($database->name) && $database->name === $dbName) { + $databaseId = $database->id; + } + } + + // If the setting is not enabled, we skip the deletion of existing databases. + if (isset($databaseId) && !$service->setting->forceDeleteOldDatabase) { + $this->warning('It seems there is an existing database with the same name. Skipping database creation. Ensure to update the database password in the .env file manually.'); + return true; // Still need to update env vars even if we don't create DB + } + + if (isset($databaseId)) { + $this->information('Force deleting existing matched database.'); + + $service->forge->deleteDatabase($service->server->id, $databaseId); + $this->information('---> Existing database deleted: ' . $dbName); + } + + foreach ($service->forge->databaseUsers($service->server->id) as $user) { + if (isset($user->name) && $user->name === $dbName) { + $service->forge->deleteDatabaseUser($service->server->id, $user->id); + $this->information('---> Existing database user found and deleted: ' . $dbName); + } + } + + if (isset($databaseId)) { + $this->information('---> Waiting for the database deletion to complete...'); + $this->waitForDatabaseDeletion(); + } + + // Create the database with the provided password + $this->information('Creating database.'); + $this->createDatabase($service, $dbName, $dbPassword); + + return true; + } + + /** + * Wait for database deletion to complete + */ + protected function waitForDatabaseDeletion(): void + { + sleep(6); + } + + /** + * Create a new database on the server + */ + public function createDatabase(ForgeService $service, string $dbName, string $dbPassword): void + { + $service->forge->createDatabase($service->server->id, [ + 'name' => $dbName, + 'user' => $dbName, + 'password' => $dbPassword, + ]); + + $this->information('Database created: ' . $dbName); + } +} diff --git a/app/Services/Forge/ForgeSetting.php b/app/Services/Forge/ForgeSetting.php index ab03b51..bb2e358 100644 --- a/app/Services/Forge/ForgeSetting.php +++ b/app/Services/Forge/ForgeSetting.php @@ -129,6 +129,11 @@ class ForgeSetting */ public bool $dbCreationRequired; + /** + * Flag indicating if Harbor should remove the existing old database. + */ + public bool $forceDeleteOldDatabase; + /** * The name of the database to be created */ @@ -258,6 +263,7 @@ protected function validate(array $configurations): \Illuminate\Validation\Valid 'site_isolation_username' => ['nullable', 'string'], 'job_scheduler_required' => ['boolean'], 'db_creation_required' => ['boolean'], + 'force_delete_old_database' => ['boolean'], 'db_name' => ['nullable', 'string'], 'auto_source_required' => ['boolean'], 'ssl_required' => ['boolean'], diff --git a/app/Services/Forge/Pipeline/CreateDatabase.php b/app/Services/Forge/Pipeline/CreateDatabase.php index 8ea8203..e3bee7a 100644 --- a/app/Services/Forge/Pipeline/CreateDatabase.php +++ b/app/Services/Forge/Pipeline/CreateDatabase.php @@ -13,6 +13,7 @@ namespace App\Services\Forge\Pipeline; +use App\Services\Forge\Actions\RecreateDatabase; use App\Services\Forge\ForgeService; use App\Traits\Outputifier; use Closure; @@ -21,7 +22,14 @@ class CreateDatabase { use Outputifier; + + private RecreateDatabase $recreateDatabase; + public function __construct(RecreateDatabase $recreateDatabase = null) + { + $this->recreateDatabase = $recreateDatabase ?? new RecreateDatabase(); + } + public function __invoke(ForgeService $service, Closure $next) { if (! $service->setting->dbCreationRequired || ! $service->siteNewlyMade) { @@ -30,12 +38,9 @@ public function __invoke(ForgeService $service, Closure $next) $dbName = $service->getFormattedDatabaseName(); $dbPassword = Str::random(16); - - if (! $this->databaseExists($service, $dbName)) { - $this->information('Creating database.'); - - $this->createDatabase($service, $dbName, $dbPassword); - } + + // Handle DB recreation and creation in one call + $this->recreateDatabase->handle($service, $dbName, $dbPassword); $service->setDatabase([ 'DB_DATABASE' => $dbName, @@ -46,23 +51,7 @@ public function __invoke(ForgeService $service, Closure $next) return $next($service); } - protected function databaseExists(ForgeService $service, string $dbName): bool - { - foreach ($service->forge->databases($service->server->id) as $database) { - if ($database->name === $dbName) { - return true; - } - } - return false; - } - protected function createDatabase(ForgeService $service, string $dbName, string $dbPassword): void - { - $service->forge->createDatabase($service->server->id, [ - 'name' => $dbName, - 'user' => $dbName, - 'password' => $dbPassword, - ]); - } + } diff --git a/config/forge.php b/config/forge.php index 4b25727..c950789 100644 --- a/config/forge.php +++ b/config/forge.php @@ -61,6 +61,9 @@ // Flag indicating if a database should be created (default: false). 'db_creation_required' => env('FORGE_DB_CREATION_REQUIRED', false), + // Flag indicating if Harbor should remove the existing old database (default: false). + 'force_delete_old_database' => env('FORCE_DELETE_OLD_DATABASE', false), + // Flag to enable Quick Deploy (default: true). 'quick_deploy' => env('FORGE_QUICK_DEPLOY', false), diff --git a/tests/Unit/Services/Forge/Actions/RecreateDatabaseTest.php b/tests/Unit/Services/Forge/Actions/RecreateDatabaseTest.php new file mode 100644 index 0000000..4afe480 --- /dev/null +++ b/tests/Unit/Services/Forge/Actions/RecreateDatabaseTest.php @@ -0,0 +1,152 @@ +action = new RecreateDatabase(); + $this->serverId = '12345'; + $this->dbName = 'test_db'; + $this->dbPassword = 'test_password'; + + // Create mocks that will be used by all tests + $this->forgeMock = Mockery::mock(Forge::class); + $this->settingMock = Mockery::mock(ForgeSetting::class); + $this->server = new Server(['id' => $this->serverId]); + + // Setup base service mock that can be customized per test + $this->service = Mockery::mock(ForgeService::class); + $this->service->forge = $this->forgeMock; + $this->service->server = $this->server; +}); + +afterEach(function () { + Mockery::close(); +}); + +it('skips recreation when database exists and force delete disabled', function () { + // Configure setting for this specific test + $this->settingMock->forceDeleteOldDatabase = false; + $this->service->setting = $this->settingMock; + + // Mock database exists + $this->forgeMock->shouldReceive('databases') + ->once() + ->with($this->serverId) + ->andReturn([ + (object) ['name' => $this->dbName, 'id' => 99], + ]); + + // Database should not be deleted + $this->forgeMock->shouldNotReceive('deleteDatabase'); + $this->forgeMock->shouldNotReceive('deleteDatabaseUser'); + + // Database should not be created when skipping recreation + $this->forgeMock->shouldNotReceive('createDatabase'); + + // Act + $result = $this->action->handle($this->service, $this->dbName, $this->dbPassword); + + // Assert + expect($result)->toBeTrue(); // Should return true to indicate env vars need updating +}); + +it('recreates database when exists and force delete enabled', function () { + // Configure setting for this specific test + $this->settingMock->forceDeleteOldDatabase = true; + $this->service->setting = $this->settingMock; + + // Mock existing database + $dbId = 99; + $userId = 88; + + $this->forgeMock->shouldReceive('databases') + ->once() + ->with($this->serverId) + ->andReturn([ + (object) ['name' => $this->dbName, 'id' => $dbId], + ]); + + // Should delete the database + $this->forgeMock->shouldReceive('deleteDatabase') + ->once() + ->with($this->serverId, $dbId); + + // Mock existing user + $this->forgeMock->shouldReceive('databaseUsers') + ->once() + ->with($this->serverId) + ->andReturn([ + (object) ['name' => $this->dbName, 'id' => $userId], + ]); + + // Should delete the database user + $this->forgeMock->shouldReceive('deleteDatabaseUser') + ->once() + ->with($this->serverId, $userId); + + // Should create a new database + $this->forgeMock->shouldReceive('createDatabase') + ->once() + ->with($this->serverId, [ + 'name' => $this->dbName, + 'user' => $this->dbName, + 'password' => $this->dbPassword, + ]); + + // Create a partial mock to skip actual waiting + $actionSpy = Mockery::mock(RecreateDatabase::class)->makePartial(); + $actionSpy->shouldAllowMockingProtectedMethods(); + $actionSpy->shouldReceive('waitForDatabaseDeletion')->once()->andReturn(null); + + // Act + $result = $actionSpy->handle($this->service, $this->dbName, $this->dbPassword); + + // Assert + expect($result)->toBeTrue(); +}); + +it('creates new database when none exists', function () { + // Set the service to use the mocked setting + $this->service->setting = $this->settingMock; + + // No existing database + $this->forgeMock->shouldReceive('databases') + ->once() + ->with($this->serverId) + ->andReturn([ + (object) ['name' => 'other_db', 'id' => 55], + ]); + + // Check for users (none match) + $this->forgeMock->shouldReceive('databaseUsers') + ->once() + ->with($this->serverId) + ->andReturn([ + (object) ['name' => 'other_user', 'id' => 66], + ]); + + // Should create a new database + $this->forgeMock->shouldReceive('createDatabase') + ->once() + ->with($this->serverId, [ + 'name' => $this->dbName, + 'user' => $this->dbName, + 'password' => $this->dbPassword, + ]); + + // We should not wait for deletion since nothing was deleted + $actionSpy = Mockery::mock(RecreateDatabase::class)->makePartial(); + $actionSpy->shouldAllowMockingProtectedMethods(); + $actionSpy->shouldNotReceive('waitForDatabaseDeletion'); + + // Act + $result = $actionSpy->handle($this->service, $this->dbName, $this->dbPassword); + + // Assert + expect($result)->toBeTrue(); +});