mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(backups): back up the coolify database before an update (#12093)
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Services\CoolifyUpgradeStatus;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -22,6 +24,7 @@ class UpdateCoolify
|
||||
public function handle($manual_update = false)
|
||||
{
|
||||
if (isDev()) {
|
||||
$this->backupDatabase();
|
||||
Sleep::for(10)->seconds();
|
||||
|
||||
return;
|
||||
@@ -110,11 +113,28 @@ class UpdateCoolify
|
||||
);
|
||||
}
|
||||
|
||||
$this->backupDatabase();
|
||||
$this->update();
|
||||
$settings->new_version_available = false;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
private function backupDatabase(): void
|
||||
{
|
||||
$backup = StandalonePostgresql::find(0)?->scheduledBackups()->first();
|
||||
if (! $backup || ! instanceSettings()->is_backup_before_update_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Runs inline so the update waits for the result. dispatchSync() would go through the job's
|
||||
// WithoutOverlapping middleware, which silently drops the run while the scheduled backup is running.
|
||||
$job = new DatabaseBackupJob($backup);
|
||||
$job->handle();
|
||||
if ($job->backup_log?->status !== 'success') {
|
||||
throw new \Exception('The database backup failed, so the update was not started.');
|
||||
}
|
||||
}
|
||||
|
||||
private function update()
|
||||
{
|
||||
$this->ensureNoUpgradeIsRunning();
|
||||
|
||||
@@ -46,6 +46,8 @@ class SettingsBackup extends Component
|
||||
#[Validate(['required'])]
|
||||
public string $postgres_password;
|
||||
|
||||
public bool $is_backup_before_update_enabled;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (! isInstanceAdmin()) {
|
||||
@@ -75,6 +77,7 @@ class SettingsBackup extends Component
|
||||
$this->executions = $this->backup?->executions ?? [];
|
||||
}
|
||||
$this->settings = $settings;
|
||||
$this->is_backup_before_update_enabled = $settings->is_backup_before_update_enabled;
|
||||
$this->s3s = $s3s;
|
||||
}
|
||||
|
||||
@@ -136,6 +139,27 @@ class SettingsBackup extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->settings);
|
||||
$this->settings->is_backup_before_update_enabled = $this->is_backup_before_update_enabled;
|
||||
$changedFields = auditChangedFields($this->settings);
|
||||
$this->settings->save();
|
||||
if ($changedFields !== []) {
|
||||
auditLog('ui.instance.settings.updated', [
|
||||
'team_id' => null,
|
||||
'resource' => 'instance',
|
||||
'section' => 'backup',
|
||||
'changed_fields' => $changedFields,
|
||||
]);
|
||||
}
|
||||
$this->dispatch('success', 'Backup updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,7 @@ class InstanceSettings extends Model
|
||||
'public_port_max',
|
||||
'do_not_track',
|
||||
'is_auto_update_enabled',
|
||||
'is_backup_before_update_enabled',
|
||||
'is_registration_enabled',
|
||||
'disable_registration_when_oauth_enabled',
|
||||
'next_channel',
|
||||
@@ -90,6 +91,7 @@ class InstanceSettings extends Model
|
||||
|
||||
'allowed_ip_ranges' => 'array',
|
||||
'is_auto_update_enabled' => 'boolean',
|
||||
'is_backup_before_update_enabled' => 'boolean',
|
||||
'is_registration_enabled' => 'boolean',
|
||||
'disable_registration_when_oauth_enabled' => 'boolean',
|
||||
'auto_update_frequency' => 'string',
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('instance_settings', function (Blueprint $table) {
|
||||
$table->boolean('is_backup_before_update_enabled')->default(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('instance_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('is_backup_before_update_enabled');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -8,7 +8,8 @@
|
||||
@if ($server->isFunctional())
|
||||
@if (isset($database) && isset($backup))
|
||||
<form wire:submit="submit">
|
||||
<x-unsaved-bar action="submit" />
|
||||
{{-- Exclude is_backup_before_update_enabled (instantSave) so the bar does not flash. --}}
|
||||
<x-unsaved-bar action="submit" targets="description" />
|
||||
|
||||
<x-application.settings-section title="Instance database">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
@@ -26,6 +27,18 @@
|
||||
<livewire:project.database.backup-edit :backup="$backup" :available-s3-storages="$s3s"
|
||||
:status="data_get($database, 'status')" />
|
||||
|
||||
<x-application.settings-section title="Backup before update"
|
||||
description="Back up the Coolify database before an update is installed. If the backup fails, the update is not installed.">
|
||||
<div class="max-w-md">
|
||||
<x-forms.listbox id="is_backup_before_update_enabled" label="Database backup"
|
||||
onChange="instantSave" :options="[
|
||||
['value' => true, 'label' => 'Enabled'],
|
||||
['value' => false, 'label' => 'Disabled'],
|
||||
]"
|
||||
helper="Runs this backup once before every manual or automatic update, in addition to the schedule." />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<livewire:project.database.backup-executions :backup="$backup" />
|
||||
@else
|
||||
<x-application.settings-section title="Instance backup">
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\SettingsBackup;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
$rootTeam = Team::factory()->create(['id' => 0]);
|
||||
Server::factory()->create(['id' => 0, 'team_id' => 0, 'ip' => '127.0.0.1']);
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
Once::flush();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$rootTeam->members()->attach($this->user->id, ['role' => 'admin']);
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => 0]]);
|
||||
});
|
||||
|
||||
test('instance admin can turn the backup before update off', function () {
|
||||
Livewire::test(SettingsBackup::class)
|
||||
->assertSet('is_backup_before_update_enabled', true)
|
||||
->set('is_backup_before_update_enabled', false)
|
||||
->call('instantSave')
|
||||
->assertDispatched('success');
|
||||
|
||||
expect(InstanceSettings::find(0)->is_backup_before_update_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('team member cannot change the backup before update', function () {
|
||||
$component = Livewire::test(SettingsBackup::class);
|
||||
Team::find(0)->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
|
||||
$this->user->refresh();
|
||||
|
||||
$component->set('is_backup_before_update_enabled', false)
|
||||
->call('instantSave')
|
||||
->assertDispatched('error', 'This action is unauthorized.');
|
||||
|
||||
expect(InstanceSettings::find(0)->is_backup_before_update_enabled)->toBeTrue();
|
||||
});
|
||||
@@ -3,7 +3,11 @@
|
||||
use App\Actions\Server\UpdateCoolify;
|
||||
use App\Livewire\Settings\Updates;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Support\RemoteProcessCommand;
|
||||
@@ -11,8 +15,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
@@ -53,6 +59,40 @@ function updateCoolifyTestActionWithStatus(string $status): UpdateCoolify
|
||||
};
|
||||
}
|
||||
|
||||
function updateCoolifyTestCreateInstanceBackup(): void
|
||||
{
|
||||
Storage::fake('ssh-keys');
|
||||
Notification::fake();
|
||||
Server::query()->whereKey(0)->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => 0])->id]);
|
||||
Server::flushIdentityMap();
|
||||
StandalonePostgresql::forceCreate([
|
||||
'id' => 0,
|
||||
'name' => 'coolify-db',
|
||||
'postgres_password' => 'password',
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'destination_id' => 0,
|
||||
]);
|
||||
ScheduledDatabaseBackup::create([
|
||||
'frequency' => '0 0 * * *',
|
||||
'database_id' => 0,
|
||||
'database_type' => StandalonePostgresql::class,
|
||||
'team_id' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
function updateCoolifyTestPrepareUpdate(array $settings = []): void
|
||||
{
|
||||
Queue::fake();
|
||||
config([
|
||||
'app.env' => 'testing',
|
||||
'constants.coolify.version' => '4.0.9',
|
||||
'constants.ssh.mux_enabled' => false,
|
||||
]);
|
||||
Http::fake(['*' => Http::response(['coolify' => ['v4' => ['version' => '4.0.10']]], 200)]);
|
||||
updateCoolifyTestCreateRootServerAndSettings($settings);
|
||||
updateCoolifyTestCreateInstanceBackup();
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
@@ -363,3 +403,33 @@ it('starts an upgrade when the previous upgrade status is older than the lock ex
|
||||
expect(RemoteProcessCommand::read(Activity::query()->latest('id')->first()))
|
||||
->toContain("bash /data/coolify/source/upgrade.sh '4.0.10'");
|
||||
});
|
||||
|
||||
it('backs up the instance database before the update', function () {
|
||||
updateCoolifyTestPrepareUpdate();
|
||||
Process::fake(['*du -b*' => '128', '*' => '']);
|
||||
|
||||
(new UpdateCoolify)->handle(manual_update: true);
|
||||
|
||||
Process::assertRan(fn ($process) => str_contains($process->command, 'pg_dump'));
|
||||
expect(RemoteProcessCommand::read(Activity::query()->latest('id')->first()))
|
||||
->toContain("bash /data/coolify/source/upgrade.sh '4.0.10'");
|
||||
});
|
||||
|
||||
it('skips the backup before the update when it is disabled', function () {
|
||||
updateCoolifyTestPrepareUpdate(['is_backup_before_update_enabled' => false]);
|
||||
Process::fake();
|
||||
|
||||
(new UpdateCoolify)->handle(manual_update: true);
|
||||
|
||||
Process::assertNotRan(fn ($process) => str_contains($process->command, 'pg_dump'));
|
||||
expect(Activity::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('does not start the update when the instance backup fails', function () {
|
||||
updateCoolifyTestPrepareUpdate();
|
||||
Process::fake(['*pg_dump*' => Process::result(exitCode: 1), '*' => '']);
|
||||
|
||||
expect(fn () => (new UpdateCoolify)->handle(manual_update: true))
|
||||
->toThrow(Exception::class, 'The database backup failed, so the update was not started.')
|
||||
->and(Activity::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user