feat(backups): add scheduled persistent volume backups

This commit is contained in:
Andras Bacsai committed 2026-07-15 15:47:57 +02:00
1 parent 05dc2c65f6
commit 63961e0799
36 files changed
+3506 -31

No files matched your search

+107
View File
@@ -4,6 +4,8 @@ namespace App\Jobs;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\ScheduledVolumeBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\Server;
use App\Models\Team;
use Cron\CronExpression;
@@ -109,6 +111,16 @@ class ScheduledJobManager implements ShouldQueue
]);
}
try {
$this->recoverPausedVolumeBackupContainers();
$this->processScheduledVolumeBackups();
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to process scheduled volume backups', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
// Process Docker cleanups - don't let failures stop the job manager
try {
$this->processDockerCleanups();
@@ -341,6 +353,101 @@ class ScheduledJobManager implements ShouldQueue
}
}
private function processScheduledVolumeBackups(): void
{
ScheduledVolumeBackup::query()
->with(['volume', 'team.subscription'])
->where('enabled', true)
->chunkById(self::CHUNK_SIZE, function ($backups): void {
foreach ($backups as $backup) {
$this->processScheduledVolumeBackup($backup);
}
});
}
private function recoverPausedVolumeBackupContainers(): void
{
ScheduledVolumeBackupExecution::query()
->where(fn (Builder $query) => $query
->where('pause_recovery_pending', true)
->orWhere('s3_cleanup_pending', true))
->chunkById(self::CHUNK_SIZE, function ($executions): void {
foreach ($executions as $execution) {
VolumeBackupRecoveryJob::dispatch($execution);
}
});
}
private function processScheduledVolumeBackup(ScheduledVolumeBackup $backup): void
{
try {
$server = $backup->server();
if ($backup->executions()
->where(fn (Builder $query) => $query
->where('pause_recovery_pending', true)
->orWhere('s3_cleanup_pending', true))
->exists()) {
$this->skippedCount++;
$this->logSkip('volume_backup', 'container_recovery_pending', [
'backup_id' => $backup->id,
'team_id' => $backup->team_id,
]);
return;
}
if (! $backup->volume || ! $server) {
$backup->delete();
$this->skippedCount++;
$this->logSkip('volume_backup', 'resource_deleted', [
'backup_id' => $backup->id,
'team_id' => $backup->team_id,
]);
return;
}
if (! $server->isFunctional()) {
$this->skippedCount++;
$this->logSkip('volume_backup', 'server_not_functional', [
'backup_id' => $backup->id,
'team_id' => $backup->team_id,
'server_id' => $server->id,
]);
return;
}
if (isCloud() && $backup->team_id !== 0 && ! data_get($backup, 'team.subscription.stripe_invoice_paid', false)) {
$this->skippedCount++;
$this->logSkip('volume_backup', 'subscription_unpaid', [
'backup_id' => $backup->id,
'team_id' => $backup->team_id,
'server_id' => $server->id,
]);
return;
}
if ($this->shouldDispatch($backup->frequency, $server, "scheduled-volume-backup:{$backup->id}")) {
VolumeBackupJob::dispatch($backup);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Volume backup dispatched', [
'backup_id' => $backup->id,
'volume_id' => $backup->local_persistent_volume_id,
'team_id' => $backup->team_id,
'server_id' => $server->id,
]);
}
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing volume backup', [
'backup_id' => $backup->id,
'error' => $e->getMessage(),
]);
}
}
private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string
{
if (blank(data_get($backup, 'database'))) {