Refactor & Catch DatabaseManagementService (#1671)

Co-authored-by: notCharles <charles@pelican.dev>
This commit is contained in:
MartinOscar 2025-09-06 22:57:11 +02:00 committed by GitHub
parent 420730ba1f
commit 2ef81eae1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 155 additions and 103 deletions

View File

@ -687,8 +687,22 @@ class EditServer extends EditRecord
->modalHeading(trans('admin/server.delete_db_heading')) ->modalHeading(trans('admin/server.delete_db_heading'))
->modalSubmitActionLabel(trans('filament-actions::delete.single.label')) ->modalSubmitActionLabel(trans('filament-actions::delete.single.label'))
->modalDescription(fn (Get $get) => trans('admin/server.delete_db', ['name' => $get('database')])) ->modalDescription(fn (Get $get) => trans('admin/server.delete_db', ['name' => $get('database')]))
->action(function (DatabaseManagementService $databaseManagementService, $record) { ->action(function (DatabaseManagementService $service, $record) {
$databaseManagementService->delete($record); try {
$service->delete($record);
Notification::make()
->title(trans('server/database.delete_notification', ['database' => $record->database]))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(trans('server/database.delete_notification_fail', ['database' => $record->database]))
->body($e->getMessage())
->danger()
->persistent()->send();
}
$this->fillForm(); $this->fillForm();
}) })
), ),
@ -737,21 +751,22 @@ class EditServer extends EditRecord
->label(fn () => DatabaseHost::query()->count() < 1 ? trans('admin/server.no_db_hosts') : trans('admin/server.create_database')) ->label(fn () => DatabaseHost::query()->count() < 1 ? trans('admin/server.no_db_hosts') : trans('admin/server.create_database'))
->color(fn () => DatabaseHost::query()->count() < 1 ? 'danger' : 'primary') ->color(fn () => DatabaseHost::query()->count() < 1 ? 'danger' : 'primary')
->modalSubmitActionLabel(trans('admin/server.create_database')) ->modalSubmitActionLabel(trans('admin/server.create_database'))
->action(function (array $data, DatabaseManagementService $service, Server $server, RandomWordService $randomWordService) { ->action(function (array $data, DatabaseManagementService $service, Server $server) {
if (empty($data['database'])) { $data['database'] ??= str_random(12);
$data['database'] = $randomWordService->word() . random_int(1, 420); $data['remote'] ??= '%';
}
if (empty($data['remote'])) {
$data['remote'] = '%';
}
$data['database'] = $service->generateUniqueDatabaseName($data['database'], $server->id); $data['database'] = $service->generateUniqueDatabaseName($data['database'], $server->id);
try { try {
$service->setValidateDatabaseLimit(false)->create($server, $data); $service->setValidateDatabaseLimit(false)->create($server, $data);
Notification::make()
->title(trans('server/database.create_notification', ['database' => $data['database']]))
->success()
->send();
} catch (Exception $e) { } catch (Exception $e) {
Notification::make() Notification::make()
->title(trans('admin/server.failed_to_create')) ->title(trans('server/database.create_notification_fail', ['database' => $data['database']]))
->body($e->getMessage()) ->body($e->getMessage())
->danger() ->danger()
->persistent()->send(); ->persistent()->send();

View File

@ -4,9 +4,10 @@ namespace App\Filament\Components\Forms\Actions;
use App\Facades\Activity; use App\Facades\Activity;
use App\Models\Database; use App\Models\Database;
use App\Services\Databases\DatabasePasswordService; use App\Services\Databases\DatabaseManagementService;
use Exception; use Exception;
use Filament\Actions\StaticAction; use Filament\Actions\StaticAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Set; use Filament\Forms\Set;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -36,10 +37,9 @@ class RotateDatabasePasswordAction extends Action
$this->requiresConfirmation(); $this->requiresConfirmation();
$this->action(function (DatabasePasswordService $service, Database $database, Set $set) { $this->action(function (DatabaseManagementService $service, Database $database, Set $set) {
try { try {
$service->handle($database); $service->rotatePassword($database);
$database->refresh(); $database->refresh();
$set('password', $database->password); $set('password', $database->password);
@ -57,7 +57,7 @@ class RotateDatabasePasswordAction extends Action
} catch (Exception $exception) { } catch (Exception $exception) {
Notification::make() Notification::make()
->title(trans('admin/databasehost.rotate_error')) ->title(trans('admin/databasehost.rotate_error'))
->body($exception->getMessage()) ->body(fn () => auth()->user()->canAccessPanel(Filament::getPanel('admin')) ? $exception->getMessage() : null)
->danger() ->danger()
->send(); ->send();

View File

@ -15,9 +15,11 @@ use App\Traits\Filament\CanCustomizeRelations;
use App\Traits\Filament\CanModifyForm; use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable; use App\Traits\Filament\CanModifyTable;
use App\Traits\Filament\HasLimitBadge; use App\Traits\Filament\HasLimitBadge;
use Exception;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\PageRegistration; use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteAction;
@ -122,7 +124,23 @@ class DatabaseResource extends Resource
ViewAction::make() ViewAction::make()
->modalHeading(fn (Database $database) => trans('server/database.viewing', ['database' => $database->database])), ->modalHeading(fn (Database $database) => trans('server/database.viewing', ['database' => $database->database])),
DeleteAction::make() DeleteAction::make()
->using(fn (Database $database, DatabaseManagementService $service) => $service->delete($database)), ->using(function (Database $database, DatabaseManagementService $service) {
try {
$service->delete($database);
Notification::make()
->title(trans('server/database.delete_notification', ['database' => $database->database]))
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title(trans('server/database.delete_notification_fail', ['database' => $database->database]))
->danger()
->send();
report($exception);
}
}),
]); ]);
} }

View File

@ -8,6 +8,7 @@ use App\Models\Server;
use App\Services\Databases\DatabaseManagementService; use App\Services\Databases\DatabaseManagementService;
use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Exception;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction; use Filament\Actions\CreateAction;
@ -15,6 +16,7 @@ use Filament\Facades\Filament;
use Filament\Forms\Components\Grid; use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\IconSize; use Filament\Support\Enums\IconSize;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -63,12 +65,24 @@ class ListDatabases extends ListRecords
]), ]),
]) ])
->action(function ($data, DatabaseManagementService $service) use ($server) { ->action(function ($data, DatabaseManagementService $service) use ($server) {
if (empty($data['database'])) { $data['database'] ??= Str::random(12);
$data['database'] = Str::random(12); $data['database'] = $service->generateUniqueDatabaseName($data['database'], $server->id);
}
$data['database'] = 's'. $server->id . '_' . $data['database'];
$service->create($server, $data); try {
$service->create($server, $data);
Notification::make()
->title(trans('server/database.create_notification', ['database' => $data['database']]))
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title(trans('server/database.create_notification_fail', ['database' => $data['database']]))
->danger()
->send();
report($exception);
}
}), }),
]; ];
} }

View File

@ -6,7 +6,6 @@ use Illuminate\Http\Response;
use App\Models\Server; use App\Models\Server;
use App\Models\Database; use App\Models\Database;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Services\Databases\DatabasePasswordService;
use App\Services\Databases\DatabaseManagementService; use App\Services\Databases\DatabaseManagementService;
use App\Transformers\Api\Application\ServerDatabaseTransformer; use App\Transformers\Api\Application\ServerDatabaseTransformer;
use App\Http\Controllers\Api\Application\ApplicationApiController; use App\Http\Controllers\Api\Application\ApplicationApiController;
@ -24,7 +23,6 @@ class DatabaseController extends ApplicationApiController
*/ */
public function __construct( public function __construct(
private DatabaseManagementService $databaseManagementService, private DatabaseManagementService $databaseManagementService,
private DatabasePasswordService $databasePasswordService
) { ) {
parent::__construct(); parent::__construct();
} }
@ -66,7 +64,7 @@ class DatabaseController extends ApplicationApiController
*/ */
public function resetPassword(ServerDatabaseWriteRequest $request, Server $server, Database $database): JsonResponse public function resetPassword(ServerDatabaseWriteRequest $request, Server $server, Database $database): JsonResponse
{ {
$this->databasePasswordService->handle($database); $this->databaseManagementService->rotatePassword($database);
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
} }

View File

@ -6,7 +6,6 @@ use Illuminate\Http\Response;
use App\Models\Server; use App\Models\Server;
use App\Models\Database; use App\Models\Database;
use App\Facades\Activity; use App\Facades\Activity;
use App\Services\Databases\DatabasePasswordService;
use App\Transformers\Api\Client\DatabaseTransformer; use App\Transformers\Api\Client\DatabaseTransformer;
use App\Services\Databases\DatabaseManagementService; use App\Services\Databases\DatabaseManagementService;
use App\Services\Databases\DeployServerDatabaseService; use App\Services\Databases\DeployServerDatabaseService;
@ -26,7 +25,6 @@ class DatabaseController extends ClientApiController
public function __construct( public function __construct(
private DeployServerDatabaseService $deployDatabaseService, private DeployServerDatabaseService $deployDatabaseService,
private DatabaseManagementService $managementService, private DatabaseManagementService $managementService,
private DatabasePasswordService $passwordService
) { ) {
parent::__construct(); parent::__construct();
} }
@ -83,7 +81,7 @@ class DatabaseController extends ClientApiController
*/ */
public function rotatePassword(RotatePasswordRequest $request, Server $server, Database $database): array public function rotatePassword(RotatePasswordRequest $request, Server $server, Database $database): array
{ {
$this->passwordService->handle($database); $this->managementService->rotatePassword($database);
$database->refresh(); $database->refresh();
Activity::event('server:database.rotate-password') Activity::event('server:database.rotate-password')

View File

@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use PDOException;
/** /**
* @property int $id * @property int $id
@ -97,71 +98,97 @@ class Database extends Model implements Validatable
} }
/** /**
* @throws PDOException
*
* Run the provided statement against the database on a given connection. * Run the provided statement against the database on a given connection.
*/ */
private function run(string $statement): bool private function run(string $statement): void
{ {
return $this->host->buildConnection()->statement($statement); $this->host->buildConnection()->statement($statement);
} }
/** /**
* @throws PDOException
*
* Create a new database on a given connection. * Create a new database on a given connection.
*/ */
public function createDatabase(string $database): bool public function createDatabase(): self
{ {
return $this->run(sprintf('CREATE DATABASE IF NOT EXISTS `%s`', $database)); $this->run(sprintf('CREATE DATABASE IF NOT EXISTS `%s`', $this->database));
return $this;
} }
/** /**
* @throws PDOException
*
* Create a new database user on a given connection. * Create a new database user on a given connection.
*/ */
public function createUser(string $username, string $remote, string $password, ?int $max_connections): bool public function createUser(): self
{ {
$args = [$username, $remote, $password]; $args = [$this->username, $this->remote, $this->password];
$command = 'CREATE USER `%s`@`%s` IDENTIFIED BY \'%s\''; $command = 'CREATE USER `%s`@`%s` IDENTIFIED BY \'%s\'';
if (!empty($max_connections)) { if (!empty($this->max_connections)) {
$args[] = $max_connections; $args[] = $this->max_connections;
$command .= ' WITH MAX_USER_CONNECTIONS %s'; $command .= ' WITH MAX_USER_CONNECTIONS %s';
} }
return $this->run(sprintf($command, ...$args)); $this->run(sprintf($command, ...$args));
return $this;
} }
/** /**
* @throws PDOException
*
* Give a specific user access to a given database. * Give a specific user access to a given database.
*/ */
public function assignUserToDatabase(string $database, string $username, string $remote): bool public function assignUserToDatabase(): self
{ {
return $this->run(sprintf( $this->run(sprintf(
'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, REFERENCES, INDEX, LOCK TABLES, CREATE ROUTINE, ALTER ROUTINE, EXECUTE, CREATE TEMPORARY TABLES, CREATE VIEW, SHOW VIEW, EVENT, TRIGGER ON `%s`.* TO `%s`@`%s`', 'GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, REFERENCES, INDEX, LOCK TABLES, CREATE ROUTINE, ALTER ROUTINE, EXECUTE, CREATE TEMPORARY TABLES, CREATE VIEW, SHOW VIEW, EVENT, TRIGGER ON `%s`.* TO `%s`@`%s`',
$database, $this->database,
$username, $this->username,
$remote $this->remote
)); ));
return $this;
} }
/** /**
* @throws PDOException
*
* Flush the privileges for a given connection. * Flush the privileges for a given connection.
*/ */
public function flush(): bool public function flushPrivileges(): self
{ {
return $this->run('FLUSH PRIVILEGES'); $this->run('FLUSH PRIVILEGES');
return $this;
} }
/** /**
* @throws PDOException
*
* Drop a given database on a specific connection. * Drop a given database on a specific connection.
*/ */
public function dropDatabase(string $database): bool public function dropDatabase(): self
{ {
return $this->run(sprintf('DROP DATABASE IF EXISTS `%s`', $database)); $this->run(sprintf('DROP DATABASE IF EXISTS `%s`', $this->database));
return $this;
} }
/** /**
* @throws PDOException
*
* Drop a given user on a specific connection. * Drop a given user on a specific connection.
*/ */
public function dropUser(string $username, string $remote): bool public function dropUser(): self
{ {
return $this->run(sprintf('DROP USER IF EXISTS `%s`@`%s`', $username, $remote)); $this->run(sprintf('DROP USER IF EXISTS `%s`@`%s`', $this->username, $this->remote));
return $this;
} }
} }

View File

@ -93,15 +93,11 @@ class DatabaseManagementService
return $this->connection->transaction(function () use ($data) { return $this->connection->transaction(function () use ($data) {
$database = $this->createModel($data); $database = $this->createModel($data);
$database->createDatabase($database->database); $database
$database->createUser( ->createDatabase()
$database->username, ->createUser()
$database->remote, ->assignUserToDatabase()
$database->password, ->flushPrivileges();
$database->max_connections
);
$database->assignUserToDatabase($database->database, $database->username, $database->remote);
$database->flush();
Activity::event('server:database.create') Activity::event('server:database.create')
->subject($database) ->subject($database)
@ -115,14 +111,15 @@ class DatabaseManagementService
/** /**
* Delete a database from the given host server. * Delete a database from the given host server.
* *
* @throws \Exception * @throws \Throwable
*/ */
public function delete(Database $database): ?bool public function delete(Database $database): ?bool
{ {
return $this->connection->transaction(function () use ($database) { return $this->connection->transaction(function () use ($database) {
$database->dropDatabase($database->database); $database
$database->dropUser($database->username, $database->remote); ->dropDatabase()
$database->flush(); ->dropUser()
->flushPrivileges();
Activity::event('server:database.delete') Activity::event('server:database.delete')
->subject($database) ->subject($database)
@ -133,6 +130,28 @@ class DatabaseManagementService
}); });
} }
/**
* Updates a password for a given database.
*
* @throws \Exception
*/
public function rotatePassword(Database $database): void
{
$password = Utilities::randomStringWithSpecialCharacters(24);
$this->connection->transaction(function () use ($database, $password) {
$database->update([
'password' => $password,
]);
$database
->dropUser()
->createUser()
->assignUserToDatabase()
->flushPrivileges();
});
}
/** /**
* Create the database if there is not an identical match in the DB. While you can technically * Create the database if there is not an identical match in the DB. While you can technically
* have the same name across multiple hosts, for the sake of keeping this logic easy to understand * have the same name across multiple hosts, for the sake of keeping this logic easy to understand

View File

@ -1,40 +0,0 @@
<?php
namespace App\Services\Databases;
use App\Models\Database;
use App\Helpers\Utilities;
use Illuminate\Database\ConnectionInterface;
class DatabasePasswordService
{
/**
* DatabasePasswordService constructor.
*/
public function __construct(
private ConnectionInterface $connection,
) {}
/**
* Updates a password for a given database.
*/
public function handle(Database|int $database): void
{
if (is_int($database)) {
$database = Database::query()->findOrFail($database);
}
$password = Utilities::randomStringWithSpecialCharacters(24);
$this->connection->transaction(function () use ($database, $password) {
$database->update([
'password' => $password,
]);
$database->dropUser($database->username, $database->remote);
$database->createUser($database->username, $database->remote, $password, $database->max_connections);
$database->assignUserToDatabase($database->database, $database->username, $database->remote);
$database->flush();
});
}
}

View File

@ -19,4 +19,8 @@ return [
'database_host' => 'Database Host', 'database_host' => 'Database Host',
'database_host_select' => 'Select Database Host', 'database_host_select' => 'Select Database Host',
'jdbc' => 'JDBC Connection String', 'jdbc' => 'JDBC Connection String',
'create_notification' => 'Created :database',
'create_notification_fail' => 'Failed to create :database',
'delete_notification' => 'Deleted :database',
'delete_notification_fail' => 'Failed to delete :database',
]; ];

View File

@ -5,7 +5,6 @@ namespace App\Tests\Integration\Api\Client\Server\Database;
use App\Models\Subuser; use App\Models\Subuser;
use App\Models\Database; use App\Models\Database;
use App\Models\DatabaseHost; use App\Models\DatabaseHost;
use App\Services\Databases\DatabasePasswordService;
use App\Services\Databases\DatabaseManagementService; use App\Services\Databases\DatabaseManagementService;
use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase; use App\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
@ -33,8 +32,8 @@ class DatabaseAuthorizationTest extends ClientApiIntegrationTestCase
$database3 = Database::factory()->create(['server_id' => $server3->id, 'database_host_id' => $host->id]); $database3 = Database::factory()->create(['server_id' => $server3->id, 'database_host_id' => $host->id]);
$this $this
->mock($method === 'POST' ? DatabasePasswordService::class : DatabaseManagementService::class) ->mock(DatabaseManagementService::class)
->expects($method === 'POST' ? 'handle' : 'delete') ->expects($method === 'POST' ? 'rotatePassword' : 'delete')
->andReturn($method === 'POST' ? 'foo' : null); ->andReturn($method === 'POST' ? 'foo' : null);
// This is the only valid call for this test, accessing the database for the same // This is the only valid call for this test, accessing the database for the same