Merge remote-tracking branch 'origin/main'

This commit is contained in:
Andras Bacsai 2026-09-07 14:55:31 +02:00
commit e5c75db84a
13 changed files with 349 additions and 4 deletions

View file

@ -5,6 +5,7 @@
use App\Jobs\ApiTokenExpirationWarningJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckMissingDatabaseBackupsJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob;
@ -53,6 +54,7 @@ protected function schedule(Schedule $schedule): void
->runInBackground();
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
$this->scheduleInstance->job(new CheckMissingDatabaseBackupsJob)->hourly()->onOneServer();
if (isDev()) {
// Instance Jobs

View file

@ -769,6 +769,7 @@ public function update_by_uuid(Request $request)
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
'missing_backup_notification_days' => ['type' => 'integer', 'description' => 'Alert after this many days without an execution; 0 disables alerts', 'minimum' => 0, 'maximum' => 365, 'default' => 0],
],
),
)
@ -805,7 +806,7 @@ public function update_by_uuid(Request $request)
)]
public function create_backup(Request $request)
{
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout'];
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -833,6 +834,7 @@ public function create_backup(Request $request)
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
'missing_backup_notification_days' => 'integer|min:0|max:365',
]);
if ($validator->fails()) {
@ -1025,6 +1027,7 @@ public function create_backup(Request $request)
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
'missing_backup_notification_days' => ['type' => 'integer', 'description' => 'Alert after this many days without an execution; 0 disables alerts', 'minimum' => 0, 'maximum' => 365],
],
),
)
@ -1054,7 +1057,7 @@ public function create_backup(Request $request)
)]
public function update_backup(Request $request)
{
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout'];
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid', 'timeout', 'missing_backup_notification_days'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -1080,6 +1083,7 @@ public function update_backup(Request $request)
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
'missing_backup_notification_days' => 'integer|min:0|max:365',
]);
if ($validator->fails()) {
return response()->json([
@ -4885,6 +4889,8 @@ function () use ($database) {
'id',
'created_at',
'updated_at',
'last_execution_at',
'missing_backup_notification_sent_at',
])->fill([
'uuid' => new_public_id(),
'database_id' => $newDatabase->id,

View file

@ -0,0 +1,59 @@
<?php
namespace App\Jobs;
use App\Models\ScheduledDatabaseBackup;
use App\Notifications\Database\BackupMissing;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class CheckMissingDatabaseBackupsJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
ScheduledDatabaseBackup::query()
->with(['team', 'database', 'latest_log'])
->where('enabled', true)
->where('missing_backup_notification_days', '>', 0)
->chunkById(100, function ($backups): void {
foreach ($backups as $backup) {
$this->notifyIfMissing($backup);
}
});
}
private function notifyIfMissing(ScheduledDatabaseBackup $backup): void
{
$lastExecutionAt = $backup->last_execution_at ?? $backup->latest_log?->created_at;
$lastActivityAt = $lastExecutionAt ?? $backup->created_at;
if (! $lastActivityAt || $lastActivityAt->isAfter(now()->subDays($backup->missing_backup_notification_days))) {
return;
}
if ($backup->missing_backup_notification_sent_at?->greaterThanOrEqualTo($lastActivityAt)) {
return;
}
if (! $backup->team) {
Log::warning("Cannot send missing backup notification for backup {$backup->id}: team not found");
return;
}
if ($backup->team->getEnabledChannels('backup_failure') === []) {
return;
}
$backup->team->notify(new BackupMissing($backup, $lastExecutionAt));
$backup->forceFill(['missing_backup_notification_sent_at' => now()])->save();
}
}

View file

@ -85,6 +85,9 @@ class BackupEdit extends Component
#[Validate(['required', 'int', 'min:60', 'max:36000'])]
public int|string $timeout = 3600;
#[Validate(['required', 'integer', 'min:0', 'max:365'])]
public int $missingBackupNotificationDays = 0;
public function getListeners(): array
{
// Keep "Backup Now" in sync when the database starts/stops without a full page refresh.
@ -152,6 +155,7 @@ private function syncData(bool $toModel = false): void
$this->backup->databases_to_backup = $this->databasesToBackup;
$this->backup->dump_all = $this->dumpAll;
$this->backup->timeout = $this->timeout;
$this->backup->missing_backup_notification_days = $this->missingBackupNotificationDays;
$this->customValidate();
$this->backup->save();
} else {
@ -170,6 +174,7 @@ private function syncData(bool $toModel = false): void
$this->databasesToBackup = $this->backup->databases_to_backup;
$this->dumpAll = $this->backup->dump_all;
$this->timeout = $this->backup->timeout;
$this->missingBackupNotificationDays = $this->backup->missing_backup_notification_days;
}
}

View file

@ -14,6 +14,9 @@ protected function casts(): array
'dump_all' => 'boolean',
'database_backup_retention_max_storage_locally' => 'float',
'database_backup_retention_max_storage_s3' => 'float',
'missing_backup_notification_days' => 'integer',
'missing_backup_notification_sent_at' => 'datetime',
'last_execution_at' => 'datetime',
];
}
@ -37,6 +40,7 @@ protected function casts(): array
'database_backup_retention_max_storage_s3',
'timeout',
'disable_local_backup',
'missing_backup_notification_days',
];
public static function ownedByCurrentTeam()

View file

@ -6,6 +6,13 @@
class ScheduledDatabaseBackupExecution extends BaseModel
{
protected static function booted(): void
{
static::created(function (ScheduledDatabaseBackupExecution $execution): void {
$execution->scheduledDatabaseBackup()->update(['last_execution_at' => $execution->created_at ?? now()]);
});
}
protected $fillable = [
'uuid',
'scheduled_database_backup_id',

View file

@ -9,6 +9,7 @@
use App\Notifications\Application\StatusChanged;
use App\Notifications\Container\ContainerRestarted;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupMissing;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\ScheduledTask\TaskFailed;
use App\Notifications\ScheduledTask\TaskSuccess;
@ -40,7 +41,8 @@ public function send($notifiable, $notification): void
RestartLimitReached::class => $settings->telegram_notifications_restart_limit_reached_thread_id,
BackupSuccess::class => $settings->telegram_notifications_backup_success_thread_id,
BackupFailed::class => $settings->telegram_notifications_backup_failure_thread_id,
BackupFailed::class,
BackupMissing::class => $settings->telegram_notifications_backup_failure_thread_id,
TaskSuccess::class => $settings->telegram_notifications_scheduled_task_success_thread_id,
TaskFailed::class => $settings->telegram_notifications_scheduled_task_failure_thread_id,

View file

@ -0,0 +1,83 @@
<?php
namespace App\Notifications\Database;
use App\Models\ScheduledDatabaseBackup;
use App\Notifications\CustomEmailNotification;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Carbon;
class BackupMissing extends CustomEmailNotification
{
public string $databaseName;
public function __construct(public ScheduledDatabaseBackup $backup, public ?Carbon $lastExecutionAt)
{
$this->onQueue('high');
$this->databaseName = $backup->database?->name ?? $backup->description ?? $backup->uuid;
}
public function via(object $notifiable): array
{
return $notifiable->getEnabledChannels('backup_failure');
}
public function toMail(): MailMessage
{
return (new MailMessage)
->subject("Coolify: [ACTION REQUIRED] No recent backup for {$this->databaseName}")
->view('emails.backup-missing', $this->messageData());
}
public function toDiscord(): DiscordMessage
{
return new DiscordMessage(
title: ':warning: Scheduled database backup missing',
description: $this->description(),
color: DiscordMessage::errorColor(),
isCritical: true,
);
}
public function toTelegram(): array
{
return ['message' => 'Coolify: '.$this->description()];
}
public function toPushover(): PushoverMessage
{
return new PushoverMessage(title: 'Scheduled database backup missing', level: 'error', message: $this->description());
}
public function toSlack(): SlackMessage
{
return new SlackMessage(title: 'Scheduled database backup missing', description: $this->description(), color: SlackMessage::errorColor());
}
public function toWebhook(): array
{
return array_merge($this->messageData(), [
'success' => false,
'message' => 'Scheduled database backup missing',
'event' => 'backup_missing',
'backup_uuid' => $this->backup->uuid,
]);
}
private function description(): string
{
return "The enabled backup schedule for {$this->databaseName} has produced no executions in the last {$this->backup->missing_backup_notification_days} day(s).";
}
private function messageData(): array
{
return [
'database_name' => $this->databaseName,
'days' => $this->backup->missing_backup_notification_days,
'last_execution_at' => $this->lastExecutionAt?->toDateTimeString(),
];
}
}

View file

@ -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
{
public function up(): void
{
Schema::table('scheduled_database_backups', function (Blueprint $table) {
$table->unsignedInteger('missing_backup_notification_days')->default(0);
$table->timestamp('missing_backup_notification_sent_at')->nullable();
$table->timestamp('last_execution_at')->nullable();
});
}
public function down(): void
{
Schema::table('scheduled_database_backups', function (Blueprint $table) {
$table->dropColumn([
'missing_backup_notification_days',
'missing_backup_notification_sent_at',
'last_execution_at',
]);
});
}
};

View file

@ -0,0 +1,7 @@
The enabled backup schedule for {{ $database_name }} has produced no executions in the last {{ $days }} day(s).
@if ($last_execution_at)
The last execution was at {{ $last_execution_at }}.
@else
This schedule has never produced an execution.
@endif

View file

@ -91,6 +91,9 @@ class="chip-remove"
required />
<x-forms.input label="Timeout" id="timeout" type="number" min="60"
helper="Maximum backup runtime in seconds." required />
<x-forms.input label="Missing backup alert after" id="missingBackupNotificationDays" type="number"
min="0" max="365" suffix="days" canGate="manageBackups" :canResource="$backup->database"
helper="Notify through backup failure channels after this many days without an execution. Use 0 to disable." required />
</div>
</div>
</section>

View file

@ -138,6 +138,16 @@
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$backup = $database->scheduledBackups()->create([
'team_id' => $this->team->id,
'enabled' => true,
'frequency' => '0 0 * * *',
'save_s3' => false,
]);
$backup->forceFill([
'last_execution_at' => now()->subDay(),
'missing_backup_notification_sent_at' => now(),
])->save();
$response = $this->withHeaders($this->headers)
->postJson("/api/v1/databases/{$database->uuid}/clone", [
@ -149,11 +159,14 @@
->assertJsonPath('message', 'Database cloned.');
$cloned = StandalonePostgresql::where('uuid', $response->json('uuid'))->first();
$clonedBackup = $cloned->scheduledBackups()->sole();
expect($cloned)->not->toBeNull()
->and($cloned->name)->toBe('cloned-db')
->and($cloned->environment_id)->toBe($database->environment_id)
->and($cloned->destination_id)->toBe($this->destination->id)
->and(str($cloned->status)->startsWith('exited'))->toBeTrue();
->and(str($cloned->status)->startsWith('exited'))->toBeTrue()
->and($clonedBackup->last_execution_at)->toBeNull()
->and($clonedBackup->missing_backup_notification_sent_at)->toBeNull();
});
test('creates renamed volumes when cloning a database with clone_volumes', function () {

View file

@ -0,0 +1,126 @@
<?php
use App\Jobs\CheckMissingDatabaseBackupsJob;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\Team;
use App\Notifications\Database\BackupMissing;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class);
afterEach(fn () => Carbon::setTestNow());
function missingBackupSchedule(Team $team, array $attributes = []): ScheduledDatabaseBackup
{
$backup = ScheduledDatabaseBackup::create(array_merge([
'enabled' => true,
'frequency' => '0 0 * * *',
'save_s3' => false,
'database_type' => 'App\\Models\\StandalonePostgresql',
'database_id' => 999,
'team_id' => $team->id,
'missing_backup_notification_days' => 2,
], $attributes));
ScheduledDatabaseBackup::whereKey($backup->id)->update(['created_at' => now()->subDays(3)]);
return $backup->refresh();
}
function teamWithBackupFailureNotifications(): Team
{
$team = Team::create(['name' => 'Test team']);
$team->emailNotificationSettings->update([
'smtp_enabled' => true,
'backup_failure_email_notifications' => true,
]);
return $team;
}
it('notifies the team when an enabled backup has no executions for the configured days', function () {
Notification::fake();
$team = teamWithBackupFailureNotifications();
$backup = missingBackupSchedule($team);
(new CheckMissingDatabaseBackupsJob)->handle();
Notification::assertSentTo($team, BackupMissing::class, fn (BackupMissing $notification) => $notification->backup->is($backup));
expect($backup->fresh()->missing_backup_notification_sent_at)->not->toBeNull();
});
it('does not notify for recent disabled or unconfigured backup schedules', function () {
Notification::fake();
$team = teamWithBackupFailureNotifications();
missingBackupSchedule($team, ['enabled' => false]);
missingBackupSchedule($team, ['missing_backup_notification_days' => 0]);
$recent = missingBackupSchedule($team);
ScheduledDatabaseBackup::whereKey($recent->id)->update(['created_at' => now()->subDay()]);
(new CheckMissingDatabaseBackupsJob)->handle();
Notification::assertNothingSent();
});
it('notifies once per period without executions and rearms after another execution', function () {
Notification::fake();
$team = teamWithBackupFailureNotifications();
$backup = missingBackupSchedule($team);
(new CheckMissingDatabaseBackupsJob)->handle();
(new CheckMissingDatabaseBackupsJob)->handle();
Notification::assertSentToTimes($team, BackupMissing::class, 1);
Carbon::setTestNow(now()->addMinute());
$execution = ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
'database_name' => 'app',
]);
Carbon::setTestNow(now()->addDays(3));
(new CheckMissingDatabaseBackupsJob)->handle();
Notification::assertSentToTimes($team, BackupMissing::class, 2);
});
it('preserves the last execution checkpoint when execution history is deleted', function () {
Notification::fake();
$team = teamWithBackupFailureNotifications();
$backup = missingBackupSchedule($team);
(new CheckMissingDatabaseBackupsJob)->handle();
Carbon::setTestNow(now()->addMinute());
$execution = ScheduledDatabaseBackupExecution::create([
'scheduled_database_backup_id' => $backup->id,
'status' => 'success',
'database_name' => 'app',
]);
$execution->delete();
Carbon::setTestNow(now()->addDays(3));
(new CheckMissingDatabaseBackupsJob)->handle();
Notification::assertSentToTimes($team, BackupMissing::class, 2);
});
it('waits to mark an incident sent until a notification channel is enabled', function () {
Notification::fake();
$team = Team::create(['name' => 'Test team']);
$backup = missingBackupSchedule($team);
(new CheckMissingDatabaseBackupsJob)->handle();
expect($backup->fresh()->missing_backup_notification_sent_at)->toBeNull();
$team->emailNotificationSettings->update([
'smtp_enabled' => true,
'backup_failure_email_notifications' => true,
]);
(new CheckMissingDatabaseBackupsJob)->handle();
Notification::assertSentTo($team, BackupMissing::class);
});