feat: service database backups

This commit is contained in:
Andras Bacsai 2023-11-07 12:11:47 +01:00
parent 2976c72e09
commit 516e10ddf2
10 changed files with 173 additions and 41 deletions

View File

@ -104,6 +104,7 @@ public function clone()
$uuid = (string)new Cuid2(7);
$newDatabase = $database->replicate()->fill([
'uuid' => $uuid,
'status' => 'exited',
'environment_id' => $newEnvironment->id,
'destination_id' => $this->selectedServer,
]);
@ -111,15 +112,15 @@ public function clone()
$environmentVaribles = $database->environment_variables()->get();
foreach ($environmentVaribles as $environmentVarible) {
$payload = [];
if ($database->type() === 'standalone-postgres') {
if ($database->type() === 'standalone-postgresql') {
$payload['standalone_postgresql_id'] = $newDatabase->id;
} else if ($database->type() === 'standalone_redis') {
} else if ($database->type() === 'standalone-redis') {
$payload['standalone_redis_id'] = $newDatabase->id;
} else if ($database->type() === 'standalone_mongodb') {
} else if ($database->type() === 'standalone-mongodb') {
$payload['standalone_mongodb_id'] = $newDatabase->id;
} else if ($database->type() === 'standalone_mysql') {
} else if ($database->type() === 'standalone-mysql') {
$payload['standalone_mysql_id'] = $newDatabase->id;
}else if ($database->type() === 'standalone_mariadb') {
} else if ($database->type() === 'standalone-mariadb') {
$payload['standalone_mariadb_id'] = $newDatabase->id;
}
$newEnvironmentVariable = $environmentVarible->replicate()->fill($payload);
@ -134,6 +135,16 @@ public function clone()
'destination_id' => $this->selectedServer,
]);
$newService->save();
foreach ($newService->applications() as $application) {
$application->update([
'status' => 'exited',
]);
}
foreach ($newService->databases() as $database) {
$database->update([
'status' => 'exited',
]);
}
$newService->parse();
}
return redirect()->route('project.resources', [

View File

@ -3,6 +3,7 @@
namespace App\Http\Livewire\Project\Database;
use Livewire\Component;
use Spatie\Url\Url;
class BackupEdit extends Component
{
@ -43,14 +44,23 @@ public function delete()
{
// TODO: Delete backup from server and add a confirmation modal
$this->backup->delete();
if ($this->backup->database->getMorphClass() === 'App\Models\ServiceDatabase') {
$previousUrl = url()->previous();
$url = Url::fromString($previousUrl);
$url = $url->withoutQueryParameter('selectedBackupId');
$url = $url->withFragment('backups');
$url = $url->getPath() . "#{$url->getFragment()}";
return redirect()->to($url);
} else {
redirect()->route('project.database.backups.all', $this->parameters);
}
}
public function instantSave()
{
try {
$this->custom_validate();
$this->backup->save();
$this->backup->refresh();
$this->emit('success', 'Backup updated successfully');

View File

@ -19,7 +19,11 @@ public function deleteBackup($exeuctionId)
$this->emit('error', 'Backup execution not found.');
return;
}
if ($execution->scheduledDatabaseBackup->database->getMorphClass() === 'App\Models\ServiceDatabase') {
delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->service->destination->server);
} else {
delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->destination->server);
}
$execution->delete();
$this->emit('success', 'Backup deleted successfully.');
$this->emit('refreshBackupExecutions');
@ -33,7 +37,11 @@ public function download($exeuctionId)
return;
}
$filename = data_get($execution, 'filename');
if ($execution->scheduledDatabaseBackup->database->getMorphClass() === 'App\Models\ServiceDatabase') {
$server = $execution->scheduledDatabaseBackup->database->service->destination->server;
} else {
$server = $execution->scheduledDatabaseBackup->database->destination->server;
}
$privateKeyLocation = savePrivateKeyToFs($server);
$disk = Storage::build([
'driver' => 'sftp',

View File

@ -22,7 +22,8 @@ class CreateScheduledBackup extends Component
'frequency' => 'Backup Frequency',
'save_s3' => 'Save to S3',
];
public function mount() {
public function mount()
{
if ($this->s3s->count() > 0) {
$this->s3_storage_id = $this->s3s->first()->id;
}
@ -50,11 +51,16 @@ public function submit(): void
$payload['databases_to_backup'] = $this->database->postgres_db;
} else if ($this->database->type() === 'standalone-mysql') {
$payload['databases_to_backup'] = $this->database->mysql_database;
}else if ($this->database->type() === 'standalone-mariadb') {
} else if ($this->database->type() === 'standalone-mariadb') {
$payload['databases_to_backup'] = $this->database->mariadb_database;
}
ScheduledDatabaseBackup::create($payload);
$databaseBackup = ScheduledDatabaseBackup::create($payload);
if ($this->database->getMorphClass() === 'App\Models\ServiceDatabase') {
$this->emit('refreshScheduledBackups', $databaseBackup->id);
} else {
$this->emit('refreshScheduledBackups');
}
} catch (\Throwable $e) {
handleError($e, $this);
} finally {

View File

@ -8,13 +8,31 @@ class ScheduledBackups extends Component
{
public $database;
public $parameters;
public $type;
public $selectedBackup;
public $selectedBackupId;
protected $listeners = ['refreshScheduledBackups'];
protected $queryString = ['selectedBackupId'];
public function mount(): void
{
$this->parameters = get_route_parameters();
if ($this->selectedBackupId) {
$this->setSelectedBackup($this->selectedBackupId);
}
$this->parameters = get_route_parameters();
if ($this->database->getMorphClass() === 'App\Models\ServiceDatabase') {
$this->type = 'service-database';
} else {
$this->type = 'database';
}
}
public function setSelectedBackup($backupId) {
$this->selectedBackupId = $backupId;
$this->selectedBackup = $this->database->scheduledBackups->find($this->selectedBackupId);
if (is_null($this->selectedBackup)) {
$this->selectedBackupId = null;
}
}
public function delete($scheduled_backup_id): void
{
$this->database->scheduledBackups->find($scheduled_backup_id)->delete();
@ -22,9 +40,11 @@ public function delete($scheduled_backup_id): void
$this->refreshScheduledBackups();
}
public function refreshScheduledBackups(): void
public function refreshScheduledBackups(?int $id = null): void
{
ray('refreshScheduledBackups');
$this->database->refresh();
if ($id) {
$this->setSelectedBackup($id);
}
}
}

View File

@ -7,6 +7,7 @@
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Server;
use App\Models\ServiceDatabase;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
@ -32,7 +33,7 @@ class DatabaseBackupJob implements ShouldQueue, ShouldBeEncrypted
public ?Team $team = null;
public Server $server;
public ScheduledDatabaseBackup $backup;
public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb $database;
public StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|ServiceDatabase $database;
public ?string $container_name = null;
public ?ScheduledDatabaseBackupExecution $backup_log = null;
@ -48,10 +49,16 @@ public function __construct($backup)
{
$this->backup = $backup;
$this->team = Team::find($backup->team_id);
if (data_get($this->backup, 'database_type') === 'App\Models\ServiceDatabase') {
$this->database = data_get($this->backup, 'database');
$this->server = $this->database->service->server;
$this->s3 = $this->backup->s3;
} else {
$this->database = data_get($this->backup, 'database');
$this->server = $this->database->destination->server;
$this->s3 = $this->backup->s3;
}
}
public function middleware(): array
{
@ -73,14 +80,38 @@ public function handle(): void
$this->database->delete();
return;
}
$status = Str::of(data_get($this->database, 'status'));
if (!$status->startsWith('running') && $this->database->id !== 0) {
ray('database not running');
return;
}
if (data_get($this->backup, 'database_type') === 'App\Models\ServiceDatabase') {
$databaseType = $this->database->databaseType();
$serviceUuid = $this->database->service->uuid;
if ($databaseType === 'standalone-postgresql') {
$this->container_name = "postgresql-$serviceUuid";
$commands[] = "docker exec $this->container_name env | grep POSTGRES_";
$envs = instant_remote_process($commands, $this->server);
$databasesToBackup = Str::of($envs)->after('POSTGRES_DB=')->before("\n")->value();
$this->database->postgres_user = Str::of($envs)->after('POSTGRES_USER=')->before("\n")->value();
} else if ($databaseType === 'standalone-mysql') {
$this->container_name = "mysql-$serviceUuid";
$commands[] = "docker exec $this->container_name env | grep MYSQL_";
$envs = instant_remote_process($commands, $this->server);
$databasesToBackup = Str::of($envs)->after('MYSQL_DATABASE=')->before("\n")->value();
$this->database->mysql_root_password = Str::of($envs)->after('MYSQL_ROOT_PASSWORD=')->before("\n")->value();
} else if ($databaseType === 'standalone-mariadb') {
$this->container_name = "mariadb-$serviceUuid";
$commands[] = "docker exec $this->container_name env | grep MARIADB_";
$envs = instant_remote_process($commands, $this->server);
$databasesToBackup = Str::of($envs)->after('MARIADB_DATABASE=')->before("\n")->value();
$this->database->mysql_root_password = Str::of($envs)->after('MARIADB_ROOT_PASSWORD=')->before("\n")->value();
}
} else {
$this->container_name = $this->database->uuid;
$databaseType = $this->database->type();
$databasesToBackup = data_get($this->backup, 'databases_to_backup');
}
if (is_null($databasesToBackup)) {
if ($databaseType === 'standalone-postgresql') {
@ -116,7 +147,6 @@ public function handle(): void
return;
}
}
$this->container_name = $this->database->uuid;
$this->backup_dir = backup_dir() . "/databases/" . Str::of($this->team->name)->slug() . '-' . $this->team->id . '/' . $this->container_name;
if ($this->database->name === 'coolify-db') {
@ -314,7 +344,7 @@ private function remove_old_backups(): void
if ($this->backup->number_of_backups_locally === 0) {
$deletable = $this->backup->executions()->where('status', 'success');
} else {
$deletable = $this->backup->executions()->where('status', 'success')->orderByDesc('created_at')->skip($this->backup->number_of_backups_locally);
$deletable = $this->backup->executions()->where('status', 'success')->orderByDesc('created_at')->skip($this->backup->number_of_backups_locally - 1);
}
foreach ($deletable->get() as $execution) {
delete_backup_locally($execution->filename, $this->server);

View File

@ -20,6 +20,14 @@ public function type()
{
return 'service';
}
public function databaseType()
{
$image = str($this->image)->before(':');
if ($image->value() === 'postgres') {
$image = 'postgresql';
}
return "standalone-$image";
}
public function service()
{
return $this->belongsTo(Service::class);
@ -36,4 +44,8 @@ public function getFilesFromServer(bool $isInit = false)
{
getFilesystemVolumesFromServer($this, $isInit);
}
public function scheduledBackups()
{
return $this->morphMany(ScheduledDatabaseBackup::class, 'database');
}
}

View File

@ -1,12 +1,36 @@
<div class="flex flex-wrap gap-2">
<div>
<div class="flex flex-wrap gap-2">
@forelse($database->scheduledBackups as $backup)
@if ($type === 'database')
<a class="flex flex-col box"
href="{{ route('project.database.backups.executions', [...$parameters, 'backup_uuid' => $backup->uuid]) }}">
<div>Frequency: {{ $backup->frequency }}</div>
<div>Last backup: {{ data_get($backup->latest_log, 'status', 'No backup yet') }}</div>
<div>Number of backups to keep (locally): {{ $backup->number_of_backups_locally }}</div>
</a>
@else
<div @class([
'border-coollabs' =>
data_get($backup, 'id') === data_get($selectedBackup, 'id'),
'flex flex-col box border-l-2 border-transparent',
])
wire:click="setSelectedBackup('{{ data_get($backup, 'id') }}')">
<div>Frequency: {{ $backup->frequency }}</div>
<div>Last backup: {{ data_get($backup->latest_log, 'status', 'No backup yet') }}</div>
<div>Number of backups to keep (locally): {{ $backup->number_of_backups_locally }}</div>
</div>
@endif
@empty
<div>No scheduled backups configured.</div>
@endforelse
</div>
@if ($type === 'service-database' && $selectedBackup)
<div class="pt-10">
<livewire:project.database.backup-edit key="{{ $selectedBackup->id }}" :backup="$selectedBackup" :s3s="collect()"
:status="data_get($database, 'status')" />
<h3 class="py-4">Executions</h3>
<livewire:project.database.backup-executions key="{{ $selectedBackup->id }}" :backup="$selectedBackup"
:executions="$selectedBackup->executions" />
</div>
@endif
</div>

View File

@ -1,5 +1,5 @@
<div x-data="{ raw: true, activeTab: window.location.hash ? window.location.hash.substring(1) : 'service-stack' }" x-init="$wire.checkStatus" wire:poll.10000ms="checkStatus">
<livewire:project.service.navbar key="{{ now() }}" :service="$service" :parameters="$parameters" :query="$query" />
<livewire:project.service.navbar :service="$service" :parameters="$parameters" :query="$query" />
<livewire:project.service.compose-modal :raw="$service->docker_compose_raw" :actual="$service->docker_compose" />
<div class="flex h-full pt-6">
<div class="flex flex-col items-start gap-4 min-w-fit">

View File

@ -7,10 +7,14 @@
<button><- Back</button>
</a>
<a :class="activeTab === 'general' && 'text-white'"
@click.prevent="activeTab = 'general'; window.location.hash = 'general'" href="#">General</a>
@click.prevent="activeTab = 'general'; window.location.hash = 'general'; if(window.location.search) window.location.search = ''"
href="#">General</a>
<a :class="activeTab === 'storages' && 'text-white'"
@click.prevent="activeTab = 'storages'; window.location.hash = 'storages'" href="#">Storages
@click.prevent="activeTab = 'storages'; window.location.hash = 'storages'; if(window.location.search) window.location.search = ''"
href="#">Storages
</a>
<a :class="activeTab === 'backups' && 'text-white'"
@click.prevent="activeTab = 'backups'; window.location.hash = 'backups'" href="#">Backups</a>
@if (data_get($parameters, 'service_name'))
<a class="{{ request()->routeIs('project.service.logs') ? 'text-white' : '' }}"
href="{{ route('project.service.logs', $parameters) }}">
@ -43,8 +47,15 @@
</div>
<div class="pb-4">Persistent storage to preserve data between deployments.</div>
<span class="text-warning">Please modify storage layout in your Docker Compose file.</span>
<livewire:project.service.storage wire:key="application-{{ $serviceDatabase->id }}"
:resource="$serviceDatabase" />
<livewire:project.service.storage wire:key="application-{{ $serviceDatabase->id }}" :resource="$serviceDatabase" />
</div>
<div x-cloak x-show="activeTab === 'backups'">
<div class="flex gap-2 ">
<h2 class="pb-4">Scheduled Backups</h2>
<x-forms.button onclick="createScheduledBackup.showModal()">+ Add</x-forms.button>
</div>
<livewire:project.database.create-scheduled-backup :database="$serviceDatabase" :s3s="collect()" />
<livewire:project.database.scheduled-backups :database="$serviceDatabase" />
</div>
@endisset
</div>