diff --git a/app/Actions/Database/SyncDatabaseUsers.php b/app/Actions/Database/SyncDatabaseUsers.php new file mode 100644 index 0000000..68d5e41 --- /dev/null +++ b/app/Actions/Database/SyncDatabaseUsers.php @@ -0,0 +1,50 @@ +database(); + if (! $service) { + return; + } + /** @var Database $handler */ + $handler = $service->handler(); + + $this->updateUsers($server, $handler); + } + + private function updateUsers(Server $server, Database $handler): void + { + $users = $handler->getUsers(); + foreach ($users as $user) { + $databases = $user[2] != 'NULL' ? explode(',', $user[2]) : []; + + /** @var ?DatabaseUser $databaseUser */ + $databaseUser = $server->databaseUsers() + ->where('username', $user[0]) + ->first(); + + if ($databaseUser === null) { + $server->databaseUsers()->create([ + 'username' => $user[0], + 'host' => $user[1], + 'databases' => $databases, + 'status' => DatabaseUserStatus::READY, + ]); + + continue; + } + + $databaseUser->databases = $databases; + $databaseUser->save(); + } + } +} diff --git a/app/Actions/Database/SyncDatabases.php b/app/Actions/Database/SyncDatabases.php new file mode 100644 index 0000000..6a913b0 --- /dev/null +++ b/app/Actions/Database/SyncDatabases.php @@ -0,0 +1,63 @@ +database(); + if (! $service) { + return; + } + /** @var Database $handler */ + $handler = $service->handler(); + + $this->updateCharsets($service, $handler); + $this->updateDatabases($server, $handler); + } + + private function updateCharsets(Service $service, Database $handler): void + { + $data = $service->type_data ?? []; + $charsets = $handler->getCharsets(); + $data['charsets'] = $charsets['charsets'] ?? []; + $data['defaultCharset'] = $charsets['defaultCharset'] ?? ''; + $service->type_data = $data; + $service->save(); + } + + private function updateDatabases(Server $server, Database $handler): void + { + $databases = $handler->getDatabases(); + foreach ($databases as $database) { + /** @var ?\App\Models\Database $db */ + $db = $server->databases() + ->where('name', $database[0]) + ->first(); + + if ($db === null) { + $server->databases()->create([ + 'name' => $database[0], + 'collation' => $database[2], + 'charset' => $database[1], + 'status' => DatabaseStatus::READY, + ]); + + continue; + } + + if ($db->collation !== $database[2] || $db->charset !== $database[1]) { + $db->update([ + 'collation' => $database[2], + 'charset' => $database[1], + ]); + } + } + } +} diff --git a/app/SSH/Services/Database/AbstractDatabase.php b/app/SSH/Services/Database/AbstractDatabase.php index 8c60bca..9b256bc 100755 --- a/app/SSH/Services/Database/AbstractDatabase.php +++ b/app/SSH/Services/Database/AbstractDatabase.php @@ -2,6 +2,7 @@ namespace App\SSH\Services\Database; +use App\Actions\Database\SyncDatabases; use App\Enums\BackupStatus; use App\Exceptions\ServiceInstallationFailed; use App\Exceptions\SSHError; @@ -16,6 +17,11 @@ abstract class AbstractDatabase extends AbstractService implements Database */ protected array $systemDbs = []; + /** + * @var array + */ + protected array $systemUsers = []; + protected string $defaultCharset; protected string $separator = "\t"; @@ -60,9 +66,8 @@ public function install(): void $status = $this->service->server->systemd()->status($this->service->unit); $this->service->validateInstall($status); $this->service->server->os()->cleanup(); - - $this->updateCharsets(); - $this->syncDatabases(); + /** @TODO implement post-install for services and move it there */ + app(SyncDatabases::class)->sync($this->service->server); } public function deletionRules(): array @@ -245,7 +250,7 @@ public function restoreBackup(BackupFile $backupFile, string $database): void /** * @throws SSHError */ - public function updateCharsets(): void + public function getCharsets(): array { $data = $this->service->server->ssh()->exec( view($this->getScriptView('get-charsets')), @@ -281,25 +286,22 @@ public function updateCharsets(): void } } - foreach ($results as $charset => $data) { + foreach ($results as $charset => $value) { $results[$charset]['list'] = $charsetCollations[$charset]; } ksort($results); - $data = array_merge( - $this->service->type_data ?? [], - ['charsets' => $results, 'defaultCharset' => $this->defaultCharset] - ); - - $this->service->update(['type_data' => $data]); - + return [ + 'charsets' => $results, + 'defaultCharset' => $this->defaultCharset, + ]; } /** * @throws SSHError */ - public function syncDatabases(bool $createNew = true): void + public function getDatabases(): array { $data = $this->service->server->ssh()->exec( view($this->getScriptView('get-db-list')), @@ -308,35 +310,36 @@ public function syncDatabases(bool $createNew = true): void $databases = $this->tableToArray($data); - foreach ($databases as $database) { - if (in_array($database[0], $this->systemDbs)) { - continue; - } + return array_values(array_filter($databases, function ($database) { + return ! in_array($database[0], $this->systemDbs); + })); + } - /** @var ?\App\Models\Database $db */ - $db = $this->service->server->databases() - ->where('name', $database[0]) - ->first(); + /** + * @throws SSHError + */ + public function getUsers(): array + { + $data = $this->service->server->ssh()->exec( + view($this->getScriptView('get-users-list')), + 'get-users-list' + ); - if ($db === null) { - if ($createNew) { - $this->service->server->databases()->create([ - 'name' => $database[0], - 'collation' => $database[2], - 'charset' => $database[1], - ]); - } + $users = $this->tableToArray($data); - continue; - } + $users = array_values(array_filter($users, function ($users) { + return ! in_array($users[0], $this->systemUsers); + })); - if ($db->collation !== $database[2] || $db->charset !== $database[1]) { - $db->update([ - 'collation' => $database[2], - 'charset' => $database[1], - ]); - } + foreach ($users as $key => $user) { + $databases = explode(',', $user[2]); + $databases = array_values(array_filter($databases, function ($database) { + return ! in_array($database, $this->systemDbs); + })); + $users[$key][2] = implode(',', $databases); } + + return $users; } /** diff --git a/app/SSH/Services/Database/Database.php b/app/SSH/Services/Database/Database.php index 8b02eeb..e72a272 100755 --- a/app/SSH/Services/Database/Database.php +++ b/app/SSH/Services/Database/Database.php @@ -26,7 +26,18 @@ public function runBackup(BackupFile $backupFile): void; public function restoreBackup(BackupFile $backupFile, string $database): void; - public function updateCharsets(): void; + /** + * @return array + */ + public function getCharsets(): array; - public function syncDatabases(bool $createNew = true): void; + /** + * @return array> + */ + public function getDatabases(): array; + + /** + * @return array> + */ + public function getUsers(): array; } diff --git a/app/SSH/Services/Database/Mariadb.php b/app/SSH/Services/Database/Mariadb.php index f007341..c46ca3b 100644 --- a/app/SSH/Services/Database/Mariadb.php +++ b/app/SSH/Services/Database/Mariadb.php @@ -6,5 +6,11 @@ class Mariadb extends AbstractDatabase { protected array $systemDbs = ['information_schema', 'performance_schema', 'mysql', 'sys']; + protected array $systemUsers = [ + 'root', + 'mysql', + 'mariadb.sys', + ]; + protected string $defaultCharset = 'utf8mb3'; } diff --git a/app/SSH/Services/Database/Mysql.php b/app/SSH/Services/Database/Mysql.php index 34e7add..d5b613a 100755 --- a/app/SSH/Services/Database/Mysql.php +++ b/app/SSH/Services/Database/Mysql.php @@ -6,5 +6,12 @@ class Mysql extends AbstractDatabase { protected array $systemDbs = ['information_schema', 'performance_schema', 'mysql', 'sys']; + protected array $systemUsers = [ + 'root', + 'mysql.session', + 'mysql.sys', + 'mysql.infoschema', + ]; + protected string $defaultCharset = 'utf8mb3'; } diff --git a/app/SSH/Services/Database/Postgresql.php b/app/SSH/Services/Database/Postgresql.php index 71f1f32..5133c9b 100644 --- a/app/SSH/Services/Database/Postgresql.php +++ b/app/SSH/Services/Database/Postgresql.php @@ -6,6 +6,11 @@ class Postgresql extends AbstractDatabase { protected array $systemDbs = ['template0', 'template1', 'postgres']; + /** + * @var string[] + */ + protected array $systemUsers = ['postgres']; + protected string $defaultCharset = 'UTF8'; protected int $headerLines = 2; diff --git a/app/Web/Pages/Servers/Databases/Index.php b/app/Web/Pages/Servers/Databases/Index.php index 821d20c..a4759ad 100644 --- a/app/Web/Pages/Servers/Databases/Index.php +++ b/app/Web/Pages/Servers/Databases/Index.php @@ -3,6 +3,7 @@ namespace App\Web\Pages\Servers\Databases; use App\Actions\Database\CreateDatabase; +use App\Actions\Database\SyncDatabases; use App\Models\Database; use App\Models\Server; use App\Web\Contracts\HasSecondSubNav; @@ -85,6 +86,26 @@ public static function getCollationInput(Server $server): Select protected function getHeaderActions(): array { return [ + Action::make('sync') + ->color('gray') + ->label('Sync Databases') + ->icon('heroicon-o-arrow-path') + ->authorize(fn () => auth()->user()?->can('create', [Database::class, $this->server])) + ->requiresConfirmation() + ->modalDescription('This will create databases that exist on the server but not in Vito.') + ->modalSubmitActionLabel('Sync') + ->action(function () { + run_action($this, function () { + app(SyncDatabases::class)->sync($this->server); + + $this->dispatch('$refresh'); + + Notification::make() + ->success() + ->title('Databases synced!') + ->send(); + }); + }), Action::make('create') ->label('Create Database') ->icon('heroicon-o-plus') diff --git a/app/Web/Pages/Servers/Databases/Users.php b/app/Web/Pages/Servers/Databases/Users.php index 84ab12e..02d548a 100644 --- a/app/Web/Pages/Servers/Databases/Users.php +++ b/app/Web/Pages/Servers/Databases/Users.php @@ -4,6 +4,7 @@ use App\Actions\Database\CreateDatabase; use App\Actions\Database\CreateDatabaseUser; +use App\Actions\Database\SyncDatabaseUsers; use App\Models\DatabaseUser; use App\Web\Contracts\HasSecondSubNav; use App\Web\Pages\Servers\Page; @@ -29,6 +30,26 @@ public function mount(): void protected function getHeaderActions(): array { return [ + Action::make('sync') + ->color('gray') + ->label('Sync Users') + ->icon('heroicon-o-arrow-path') + ->authorize(fn () => auth()->user()?->can('create', [DatabaseUser::class, $this->server])) + ->requiresConfirmation() + ->modalDescription('This will create db users that exist on the server but not in Vito.') + ->modalSubmitActionLabel('Sync') + ->action(function () { + run_action($this, function () { + app(SyncDatabaseUsers::class)->sync($this->server); + + $this->dispatch('$refresh'); + + Notification::make() + ->success() + ->title('Users synced!') + ->send(); + }); + }), Action::make('create') ->icon('heroicon-o-plus') ->modalWidth(MaxWidth::Large) @@ -43,6 +64,7 @@ protected function getHeaderActions(): array Checkbox::make('remote') ->label('Remote') ->default(false) + ->visible(in_array($this->server->database()->name, ['mysql', 'mariadb'])) ->reactive(), TextInput::make('host') ->label('Host') diff --git a/database/migrations/2025_02_15_120213_update_databases_table.php b/database/migrations/2025_02_15_120213_update_databases_table.php index 9bdadd0..c4b0af1 100644 --- a/database/migrations/2025_02_15_120213_update_databases_table.php +++ b/database/migrations/2025_02_15_120213_update_databases_table.php @@ -1,10 +1,11 @@ database(); - - if (! $service) { - continue; + try { + app(SyncDatabases::class)->sync($server); + } catch (Exception $e) { + Log::error($e->getMessage()); } - - /** @var Database $db */ - $db = $service->handler(); - - $db->syncDatabases(false); - $db->updateCharsets(); } } diff --git a/resources/views/ssh/services/database/mariadb/get-users-list.blade.php b/resources/views/ssh/services/database/mariadb/get-users-list.blade.php new file mode 100644 index 0000000..1eb72cd --- /dev/null +++ b/resources/views/ssh/services/database/mariadb/get-users-list.blade.php @@ -0,0 +1,9 @@ +if ! sudo mariadb -e "SELECT u.User, + u.Host, + (SELECT group_concat(distinct p.TABLE_SCHEMA) + FROM information_schema.SCHEMA_PRIVILEGES p + WHERE p.GRANTEE = concat('\'', u.User, '\'', '@', '\'', u.Host, '\'')) as Privileges +FROM mysql.user u;"; +then + echo 'VITO_SSH_ERROR' && exit 1 +fi diff --git a/resources/views/ssh/services/database/mysql/get-users-list.blade.php b/resources/views/ssh/services/database/mysql/get-users-list.blade.php new file mode 100644 index 0000000..f9df579 --- /dev/null +++ b/resources/views/ssh/services/database/mysql/get-users-list.blade.php @@ -0,0 +1,9 @@ +if ! sudo mysql -e "SELECT u.User, + u.Host, + (SELECT group_concat(distinct p.TABLE_SCHEMA) + FROM information_schema.SCHEMA_PRIVILEGES p + WHERE p.GRANTEE = concat('\'', u.User, '\'', '@', '\'', u.Host, '\'')) as Privileges +FROM mysql.user u;"; +then + echo 'VITO_SSH_ERROR' && exit 1 +fi diff --git a/resources/views/ssh/services/database/postgresql/get-users-list.blade.php b/resources/views/ssh/services/database/postgresql/get-users-list.blade.php new file mode 100644 index 0000000..12cd3fe --- /dev/null +++ b/resources/views/ssh/services/database/postgresql/get-users-list.blade.php @@ -0,0 +1,12 @@ +if ! sudo -u postgres psql -c "SELECT r.rolname AS username, + '' as host, + STRING_AGG(d.datname, ',') AS databases +FROM pg_roles r + JOIN + pg_database d ON has_database_privilege(r.rolname, d.datname, 'CONNECT') +WHERE r.rolcanlogin +GROUP BY r.rolname +ORDER BY r.rolname;"; +then + echo 'VITO_SSH_ERROR' && exit 1 +fi diff --git a/tests/Feature/DatabaseTest.php b/tests/Feature/DatabaseTest.php index 43f35f6..b997a82 100644 --- a/tests/Feature/DatabaseTest.php +++ b/tests/Feature/DatabaseTest.php @@ -76,6 +76,7 @@ public function test_see_databases_list(): void { $this->actingAs($this->user); + /** @var Database $database */ $database = Database::factory()->create([ 'server_id' => $this->server, ]); @@ -91,6 +92,7 @@ public function test_delete_database(): void SSH::fake(); + /** @var Database $database */ $database = Database::factory()->create([ 'server_id' => $this->server, ]); @@ -105,4 +107,17 @@ public function test_delete_database(): void 'id' => $database->id, ]); } + + public function test_sync_databases(): void + { + $this->actingAs($this->user); + + SSH::fake(); + + Livewire::test(Index::class, [ + 'server' => $this->server, + ]) + ->callAction('sync') + ->assertSuccessful(); + } } diff --git a/tests/Unit/SSH/Services/Database/UpdateCharsetsTest.php b/tests/Unit/SSH/Services/Database/GetCharsetsTest.php similarity index 94% rename from tests/Unit/SSH/Services/Database/UpdateCharsetsTest.php rename to tests/Unit/SSH/Services/Database/GetCharsetsTest.php index 676bf67..faca824 100644 --- a/tests/Unit/SSH/Services/Database/UpdateCharsetsTest.php +++ b/tests/Unit/SSH/Services/Database/GetCharsetsTest.php @@ -6,7 +6,7 @@ use App\SSH\Services\Database\Database; use Tests\TestCase; -class UpdateCharsetsTest extends TestCase +class GetCharsetsTest extends TestCase { protected static array $mysqlCharsets = [ 'armscii8' => [ @@ -46,15 +46,12 @@ public function test_update_charsets(string $name, string $version, string $outp /** @var Database $databaseHandler */ $databaseHandler = $database->handler(); - $databaseHandler->updateCharsets(); + $charsets = $databaseHandler->getCharsets(); - $database->refresh(); - $this->assertEquals($expected, $database->type_data['charsets']); + $this->assertEquals($expected, $charsets['charsets']); } /** - * @TODO Add more test cases - * * @return array[] */ public static function data(): array diff --git a/tests/Unit/SSH/Services/Database/SyncDatabasesTest.php b/tests/Unit/SSH/Services/Database/GetDatabasesTest.php similarity index 72% rename from tests/Unit/SSH/Services/Database/SyncDatabasesTest.php rename to tests/Unit/SSH/Services/Database/GetDatabasesTest.php index 50d4a58..883d019 100644 --- a/tests/Unit/SSH/Services/Database/SyncDatabasesTest.php +++ b/tests/Unit/SSH/Services/Database/GetDatabasesTest.php @@ -6,12 +6,12 @@ use App\SSH\Services\Database\Database; use Tests\TestCase; -class SyncDatabasesTest extends TestCase +class GetDatabasesTest extends TestCase { /** * @dataProvider data */ - public function test_sync_databases(string $name, string $version, string $output): void + public function test_get_databases(string $name, string $version, string $output): void { $database = $this->server->database(); $database->name = $name; @@ -22,17 +22,13 @@ public function test_sync_databases(string $name, string $version, string $outpu /** @var Database $databaseHandler */ $databaseHandler = $database->handler(); - $databaseHandler->syncDatabases(); + $databases = $databaseHandler->getDatabases(); - $this->assertDatabaseHas('databases', [ - 'server_id' => $this->server->id, - 'name' => 'vito', - ]); + $this->assertIsArray($databases); + $this->assertEquals('vito', $databases[0][0]); } /** - * @TODO Add more test cases - * * @return array[] */ public static function data(): array @@ -62,6 +58,18 @@ public static function data(): array vito utf8mb3 utf8mb3_general_ci EOD ], + [ + 'mariadb', + '11.4', + <<<'EOD' + database_name charset collation + information_schema utf8mb3 utf8mb3_general_ci + mysql utf8mb4 utf8mb4_uca1400_ai_ci + performance_schema utf8mb3 utf8mb3_general_ci + sys utf8mb3 utf8mb3_general_ci + vito utf8mb3 utf8mb3_general_ci + EOD + ], [ 'postgresql', '16', diff --git a/tests/Unit/SSH/Services/Database/GetUsersTest.php b/tests/Unit/SSH/Services/Database/GetUsersTest.php new file mode 100644 index 0000000..18eefc5 --- /dev/null +++ b/tests/Unit/SSH/Services/Database/GetUsersTest.php @@ -0,0 +1,85 @@ +server->database(); + $database->name = $name; + $database->version = $version; + $database->save(); + + SSH::fake($output); + + /** @var Database $databaseHandler */ + $databaseHandler = $database->handler(); + $users = $databaseHandler->getUsers(); + + $this->assertIsArray($users); + $this->assertEquals('vito', $users[0][0]); + } + + /** + * @return array[] + */ + public static function data(): array + { + return [ + [ + 'mysql', + '8.0', + <<<'EOD' + User Host Privileges + vito localhost mydb,testdb + mysql.infoschema localhost NULL + mysql.session localhost performance_schema + mysql.sys localhost sys + root localhost NULL + EOD + ], + [ + 'mysql', + '5.7', + <<<'EOD' + User Host Privileges + vito localhost mydb,testdb + mysql.infoschema localhost NULL + mysql.session localhost performance_schema + mysql.sys localhost sys + root localhost NULL + EOD + ], + [ + 'mariadb', + '11.4', + <<<'EOD' + User Host Privileges + mariadb.sys localhost NULL + mysql localhost NULL + root localhost NULL + vito localhost NULL + EOD + ], + [ + 'postgresql', + '16', + <<<'EOD' + username | host | databases + ----------+------+------------------------------------------ + postgres | | template1,template0,postgres,test,vitodb + vito | | template1,template0,postgres,test,vitodb + (2 rows) + EOD + ], + ]; + } +}