feat(backups): back up the coolify database before an update (#12093)

This commit is contained in:
🏔️ Peak
2026-10-12 00:59:14 +02:00
committed by GitHub
parent e3d7c04be7
commit 856735613d
7 changed files with 205 additions and 1 deletions
+20
View File
@@ -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();
+24
View File
@@ -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 {
+2
View File
@@ -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',
@@ -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();
});
+70
View File
@@ -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);
});