Add database and database users sync (#537)

* Add database and database users sync

* get mysl users

* add mariadb and postgres

* fix phpstan
This commit is contained in:
Saeed Vaziry 2025-03-12 22:59:25 +01:00 committed by GitHub
parent 493cbb0849
commit 0f06d81aac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 383 additions and 65 deletions

View File

@ -0,0 +1,50 @@
<?php
namespace App\Actions\Database;
use App\Enums\DatabaseUserStatus;
use App\Models\DatabaseUser;
use App\Models\Server;
use App\SSH\Services\Database\Database;
class SyncDatabaseUsers
{
public function sync(Server $server): void
{
$service = $server->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();
}
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Actions\Database;
use App\Enums\DatabaseStatus;
use App\Models\Server;
use App\Models\Service;
use App\SSH\Services\Database\Database;
class SyncDatabases
{
public function sync(Server $server): void
{
$service = $server->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],
]);
}
}
}
}

View File

@ -2,6 +2,7 @@
namespace App\SSH\Services\Database; namespace App\SSH\Services\Database;
use App\Actions\Database\SyncDatabases;
use App\Enums\BackupStatus; use App\Enums\BackupStatus;
use App\Exceptions\ServiceInstallationFailed; use App\Exceptions\ServiceInstallationFailed;
use App\Exceptions\SSHError; use App\Exceptions\SSHError;
@ -16,6 +17,11 @@ abstract class AbstractDatabase extends AbstractService implements Database
*/ */
protected array $systemDbs = []; protected array $systemDbs = [];
/**
* @var array<string>
*/
protected array $systemUsers = [];
protected string $defaultCharset; protected string $defaultCharset;
protected string $separator = "\t"; protected string $separator = "\t";
@ -60,9 +66,8 @@ public function install(): void
$status = $this->service->server->systemd()->status($this->service->unit); $status = $this->service->server->systemd()->status($this->service->unit);
$this->service->validateInstall($status); $this->service->validateInstall($status);
$this->service->server->os()->cleanup(); $this->service->server->os()->cleanup();
/** @TODO implement post-install for services and move it there */
$this->updateCharsets(); app(SyncDatabases::class)->sync($this->service->server);
$this->syncDatabases();
} }
public function deletionRules(): array public function deletionRules(): array
@ -245,7 +250,7 @@ public function restoreBackup(BackupFile $backupFile, string $database): void
/** /**
* @throws SSHError * @throws SSHError
*/ */
public function updateCharsets(): void public function getCharsets(): array
{ {
$data = $this->service->server->ssh()->exec( $data = $this->service->server->ssh()->exec(
view($this->getScriptView('get-charsets')), 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]; $results[$charset]['list'] = $charsetCollations[$charset];
} }
ksort($results); ksort($results);
$data = array_merge( return [
$this->service->type_data ?? [], 'charsets' => $results,
['charsets' => $results, 'defaultCharset' => $this->defaultCharset] 'defaultCharset' => $this->defaultCharset,
); ];
$this->service->update(['type_data' => $data]);
} }
/** /**
* @throws SSHError * @throws SSHError
*/ */
public function syncDatabases(bool $createNew = true): void public function getDatabases(): array
{ {
$data = $this->service->server->ssh()->exec( $data = $this->service->server->ssh()->exec(
view($this->getScriptView('get-db-list')), view($this->getScriptView('get-db-list')),
@ -308,35 +310,36 @@ public function syncDatabases(bool $createNew = true): void
$databases = $this->tableToArray($data); $databases = $this->tableToArray($data);
foreach ($databases as $database) { return array_values(array_filter($databases, function ($database) {
if (in_array($database[0], $this->systemDbs)) { return ! in_array($database[0], $this->systemDbs);
continue; }));
} }
/** @var ?\App\Models\Database $db */ /**
$db = $this->service->server->databases() * @throws SSHError
->where('name', $database[0]) */
->first(); public function getUsers(): array
{
$data = $this->service->server->ssh()->exec(
view($this->getScriptView('get-users-list')),
'get-users-list'
);
if ($db === null) { $users = $this->tableToArray($data);
if ($createNew) {
$this->service->server->databases()->create([ $users = array_values(array_filter($users, function ($users) {
'name' => $database[0], return ! in_array($users[0], $this->systemUsers);
'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);
} }
continue; return $users;
}
if ($db->collation !== $database[2] || $db->charset !== $database[1]) {
$db->update([
'collation' => $database[2],
'charset' => $database[1],
]);
}
}
} }
/** /**

View File

@ -26,7 +26,18 @@ public function runBackup(BackupFile $backupFile): void;
public function restoreBackup(BackupFile $backupFile, string $database): void; public function restoreBackup(BackupFile $backupFile, string $database): void;
public function updateCharsets(): void; /**
* @return array<string, mixed>
*/
public function getCharsets(): array;
public function syncDatabases(bool $createNew = true): void; /**
* @return array<int, array<string>>
*/
public function getDatabases(): array;
/**
* @return array<int, array<string>>
*/
public function getUsers(): array;
} }

View File

@ -6,5 +6,11 @@ class Mariadb extends AbstractDatabase
{ {
protected array $systemDbs = ['information_schema', 'performance_schema', 'mysql', 'sys']; protected array $systemDbs = ['information_schema', 'performance_schema', 'mysql', 'sys'];
protected array $systemUsers = [
'root',
'mysql',
'mariadb.sys',
];
protected string $defaultCharset = 'utf8mb3'; protected string $defaultCharset = 'utf8mb3';
} }

View File

@ -6,5 +6,12 @@ class Mysql extends AbstractDatabase
{ {
protected array $systemDbs = ['information_schema', 'performance_schema', 'mysql', 'sys']; protected array $systemDbs = ['information_schema', 'performance_schema', 'mysql', 'sys'];
protected array $systemUsers = [
'root',
'mysql.session',
'mysql.sys',
'mysql.infoschema',
];
protected string $defaultCharset = 'utf8mb3'; protected string $defaultCharset = 'utf8mb3';
} }

View File

@ -6,6 +6,11 @@ class Postgresql extends AbstractDatabase
{ {
protected array $systemDbs = ['template0', 'template1', 'postgres']; protected array $systemDbs = ['template0', 'template1', 'postgres'];
/**
* @var string[]
*/
protected array $systemUsers = ['postgres'];
protected string $defaultCharset = 'UTF8'; protected string $defaultCharset = 'UTF8';
protected int $headerLines = 2; protected int $headerLines = 2;

View File

@ -3,6 +3,7 @@
namespace App\Web\Pages\Servers\Databases; namespace App\Web\Pages\Servers\Databases;
use App\Actions\Database\CreateDatabase; use App\Actions\Database\CreateDatabase;
use App\Actions\Database\SyncDatabases;
use App\Models\Database; use App\Models\Database;
use App\Models\Server; use App\Models\Server;
use App\Web\Contracts\HasSecondSubNav; use App\Web\Contracts\HasSecondSubNav;
@ -85,6 +86,26 @@ public static function getCollationInput(Server $server): Select
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ 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') Action::make('create')
->label('Create Database') ->label('Create Database')
->icon('heroicon-o-plus') ->icon('heroicon-o-plus')

View File

@ -4,6 +4,7 @@
use App\Actions\Database\CreateDatabase; use App\Actions\Database\CreateDatabase;
use App\Actions\Database\CreateDatabaseUser; use App\Actions\Database\CreateDatabaseUser;
use App\Actions\Database\SyncDatabaseUsers;
use App\Models\DatabaseUser; use App\Models\DatabaseUser;
use App\Web\Contracts\HasSecondSubNav; use App\Web\Contracts\HasSecondSubNav;
use App\Web\Pages\Servers\Page; use App\Web\Pages\Servers\Page;
@ -29,6 +30,26 @@ public function mount(): void
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ 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') Action::make('create')
->icon('heroicon-o-plus') ->icon('heroicon-o-plus')
->modalWidth(MaxWidth::Large) ->modalWidth(MaxWidth::Large)
@ -43,6 +64,7 @@ protected function getHeaderActions(): array
Checkbox::make('remote') Checkbox::make('remote')
->label('Remote') ->label('Remote')
->default(false) ->default(false)
->visible(in_array($this->server->database()->name, ['mysql', 'mariadb']))
->reactive(), ->reactive(),
TextInput::make('host') TextInput::make('host')
->label('Host') ->label('Host')

View File

@ -1,10 +1,11 @@
<?php <?php
use App\Actions\Database\SyncDatabases;
use App\Enums\ServerStatus; use App\Enums\ServerStatus;
use App\Models\Server; use App\Models\Server;
use App\SSH\Services\Database\Database;
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
return new class extends Migration return new class extends Migration
@ -23,17 +24,11 @@ public function up(): void
/** @var Server $server */ /** @var Server $server */
foreach ($servers as $server) { foreach ($servers as $server) {
$service = $server->database(); try {
app(SyncDatabases::class)->sync($server);
if (! $service) { } catch (Exception $e) {
continue; Log::error($e->getMessage());
} }
/** @var Database $db */
$db = $service->handler();
$db->syncDatabases(false);
$db->updateCharsets();
} }
} }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -76,6 +76,7 @@ public function test_see_databases_list(): void
{ {
$this->actingAs($this->user); $this->actingAs($this->user);
/** @var Database $database */
$database = Database::factory()->create([ $database = Database::factory()->create([
'server_id' => $this->server, 'server_id' => $this->server,
]); ]);
@ -91,6 +92,7 @@ public function test_delete_database(): void
SSH::fake(); SSH::fake();
/** @var Database $database */
$database = Database::factory()->create([ $database = Database::factory()->create([
'server_id' => $this->server, 'server_id' => $this->server,
]); ]);
@ -105,4 +107,17 @@ public function test_delete_database(): void
'id' => $database->id, '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();
}
} }

View File

@ -6,7 +6,7 @@
use App\SSH\Services\Database\Database; use App\SSH\Services\Database\Database;
use Tests\TestCase; use Tests\TestCase;
class UpdateCharsetsTest extends TestCase class GetCharsetsTest extends TestCase
{ {
protected static array $mysqlCharsets = [ protected static array $mysqlCharsets = [
'armscii8' => [ 'armscii8' => [
@ -46,15 +46,12 @@ public function test_update_charsets(string $name, string $version, string $outp
/** @var Database $databaseHandler */ /** @var Database $databaseHandler */
$databaseHandler = $database->handler(); $databaseHandler = $database->handler();
$databaseHandler->updateCharsets(); $charsets = $databaseHandler->getCharsets();
$database->refresh(); $this->assertEquals($expected, $charsets['charsets']);
$this->assertEquals($expected, $database->type_data['charsets']);
} }
/** /**
* @TODO Add more test cases
*
* @return array[] * @return array[]
*/ */
public static function data(): array public static function data(): array

View File

@ -6,12 +6,12 @@
use App\SSH\Services\Database\Database; use App\SSH\Services\Database\Database;
use Tests\TestCase; use Tests\TestCase;
class SyncDatabasesTest extends TestCase class GetDatabasesTest extends TestCase
{ {
/** /**
* @dataProvider data * @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 = $this->server->database();
$database->name = $name; $database->name = $name;
@ -22,17 +22,13 @@ public function test_sync_databases(string $name, string $version, string $outpu
/** @var Database $databaseHandler */ /** @var Database $databaseHandler */
$databaseHandler = $database->handler(); $databaseHandler = $database->handler();
$databaseHandler->syncDatabases(); $databases = $databaseHandler->getDatabases();
$this->assertDatabaseHas('databases', [ $this->assertIsArray($databases);
'server_id' => $this->server->id, $this->assertEquals('vito', $databases[0][0]);
'name' => 'vito',
]);
} }
/** /**
* @TODO Add more test cases
*
* @return array[] * @return array[]
*/ */
public static function data(): array public static function data(): array
@ -62,6 +58,18 @@ public static function data(): array
vito utf8mb3 utf8mb3_general_ci vito utf8mb3 utf8mb3_general_ci
EOD 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', 'postgresql',
'16', '16',

View File

@ -0,0 +1,85 @@
<?php
namespace Tests\Unit\SSH\Services\Database;
use App\Facades\SSH;
use App\SSH\Services\Database\Database;
use Tests\TestCase;
class GetUsersTest extends TestCase
{
/**
* @dataProvider data
*/
public function test_get_users(string $name, string $version, string $output): void
{
$database = $this->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
],
];
}
}