Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions app/Jobs/CheckTraefikVersionForServerJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Events\ProxyStatusChangedUI;
use App\Models\Server;
use App\Notifications\Server\TraefikVersionOutdated;
use InvalidArgumentException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
Expand All @@ -25,8 +26,14 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
*/
public function __construct(
public Server $server,
public array $traefikVersions
) {}
public array $traefikVersions,
public bool $shouldNotify = true,
public ?string $checkedAt = null
) {
if (! $this->shouldNotify && is_null($this->checkedAt)) {
throw new InvalidArgumentException('Batched Traefik version checks require a shared checkedAt timestamp.');
}
}

/**
* Execute the job.
Expand Down Expand Up @@ -150,7 +157,7 @@ private function storeOutdatedInfo(string $current, string $latest, string $type
'current' => $current,
'latest' => $latest,
'type' => $type,
'checked_at' => now()->toIso8601String(),
'checked_at' => $this->checkedAt ?? now()->toIso8601String(),
];

// For minor upgrades, add the upgrade_target field (e.g., "v3.6")
Expand All @@ -166,8 +173,9 @@ private function storeOutdatedInfo(string $current, string $latest, string $type

$this->server->update(['traefik_outdated_info' => $outdatedInfo]);

// Send immediate notification to the team
$this->sendNotification($outdatedInfo);
if ($this->shouldNotify) {
$this->sendNotification($outdatedInfo);
}
}

/**
Expand Down
21 changes: 16 additions & 5 deletions app/Jobs/CheckTraefikVersionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

use App\Enums\ProxyTypes;
use App\Models\Server;
use Illuminate\Bus\Batch;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Bus;

class CheckTraefikVersionJob implements ShouldBeEncrypted, ShouldQueue
{
Expand Down Expand Up @@ -37,10 +39,19 @@ public function handle(): void
return;
}

// Dispatch individual server check jobs in parallel
// Each job will send immediate notifications when outdated Traefik is detected
foreach ($servers as $server) {
CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions);
}
$checkedAt = now()->toIso8601String();
$jobs = $servers
->map(fn (Server $server) => new CheckTraefikVersionForServerJob($server, $traefikVersions, false, $checkedAt))
->all();

Bus::batch($jobs)
->finally(function (Batch $batch) use ($checkedAt): void {
if ($batch->cancelled()) {
return;
}

NotifyOutdatedTraefikServersJob::dispatch($checkedAt);
})
->dispatch();
}
}
63 changes: 63 additions & 0 deletions app/Jobs/NotifyOutdatedTraefikServersJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace App\Jobs;

use App\Enums\ProxyTypes;
use App\Models\Server;
use App\Notifications\Server\TraefikVersionOutdated;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class NotifyOutdatedTraefikServersJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public $tries = 3;

public function __construct(public string $checkedAt)
{
$this->onQueue('high');
}

public function handle(): void
{
$servers = Server::whereNotNull('proxy')
->with('team')
->whereProxyType(ProxyTypes::TRAEFIK->value)
->whereRelation('settings', 'is_reachable', true)
->whereRelation('settings', 'is_usable', true)
->get();

$outdatedServers = $servers->filter(function (Server $server): bool {
$outdatedInfo = $server->traefik_outdated_info;

if (! $outdatedInfo || ($outdatedInfo['checked_at'] ?? null) !== $this->checkedAt) {
return false;
}

$server->outdatedInfo = $outdatedInfo;

return true;
});

if ($outdatedServers->isEmpty()) {
return;
}

$outdatedServers
->groupBy('team_id')
->each(function ($teamServers): void {
$team = $teamServers->first()?->team;

if (! $team) {
return;
}

$team->notify(new TraefikVersionOutdated($teamServers->values()));
});
}
}
59 changes: 41 additions & 18 deletions tests/Feature/CheckTraefikVersionJobTest.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
<?php

use App\Enums\ProxyTypes;
use Illuminate\Bus\PendingBatch;
use App\Jobs\CheckTraefikVersionForServerJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\NotifyOutdatedTraefikServersJob;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Server\TraefikVersionOutdated;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Notification;

uses(RefreshDatabase::class);
Expand Down Expand Up @@ -180,39 +186,56 @@
expect($grouped[$team2->id])->toHaveCount(1);
});

it('server check job exists and has correct structure', function () {
expect(class_exists(\App\Jobs\CheckTraefikVersionForServerJob::class))->toBeTrue();
it('scheduled traefik scan jobs exist and have correct structure', function () {
expect(class_exists(CheckTraefikVersionForServerJob::class))->toBeTrue();
expect(class_exists(NotifyOutdatedTraefikServersJob::class))->toBeTrue();

// Verify CheckTraefikVersionForServerJob has required properties
$reflection = new \ReflectionClass(\App\Jobs\CheckTraefikVersionForServerJob::class);
$reflection = new \ReflectionClass(CheckTraefikVersionForServerJob::class);
expect($reflection->hasProperty('tries'))->toBeTrue();
expect($reflection->hasProperty('timeout'))->toBeTrue();
expect($reflection->hasProperty('shouldNotify'))->toBeTrue();
expect($reflection->hasProperty('checkedAt'))->toBeTrue();

// Verify it implements ShouldQueue
$interfaces = class_implements(\App\Jobs\CheckTraefikVersionForServerJob::class);
$interfaces = class_implements(CheckTraefikVersionForServerJob::class);
expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldQueue::class);
});

it('sends immediate notifications when outdated traefik is detected', function () {
// Notifications are now sent immediately from CheckTraefikVersionForServerJob
// when outdated Traefik is detected, rather than being aggregated and delayed
it('dispatches server checks in a batch without immediate notifications', function () {
Bus::fake();

$team = Team::factory()->create();
$server = Server::factory()->make([
'name' => 'Server 1',
$server1 = Server::factory()->create([
'team_id' => $team->id,
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
]);
$server1->settings->update(['is_reachable' => true, 'is_usable' => true]);

$server->outdatedInfo = [
'current' => '3.5.0',
'latest' => '3.5.6',
'type' => 'patch_update',
];
$server2 = Server::factory()->create([
'team_id' => $team->id,
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
]);
$server2->settings->update(['is_reachable' => true, 'is_usable' => true]);

// Each server triggers its own notification immediately
$notification = new TraefikVersionOutdated(collect([$server]));
$batchCheckedAt = null;

expect($notification->servers)->toHaveCount(1);
expect($notification->servers->first()->outdatedInfo['type'])->toBe('patch_update');
(new CheckTraefikVersionJob)->handle();

Bus::assertBatched(function (PendingBatch $batch) use (&$batchCheckedAt) {
if (count($batch->jobs) !== 2) {
return false;
}

$jobs = collect($batch->jobs);
$batchCheckedAt = $jobs->first()?->checkedAt;

return $jobs->every(function ($job) use ($batchCheckedAt) {
return $job instanceof CheckTraefikVersionForServerJob
&& $job->shouldNotify === false
&& $job->checkedAt === $batchCheckedAt;
});
});
});

it('notification generates correct server proxy URLs', function () {
Expand Down
154 changes: 154 additions & 0 deletions tests/Feature/NotifyOutdatedTraefikServersJobTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php

use App\Enums\ProxyTypes;
use App\Jobs\NotifyOutdatedTraefikServersJob;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Server\TraefikVersionOutdated;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;

uses(RefreshDatabase::class);

beforeEach(function () {
Notification::fake();
});

it('has correct queue and retry configuration', function () {
$checkedAt = '2026-03-29T00:00:00+00:00';
$job = new NotifyOutdatedTraefikServersJob($checkedAt);

expect($job->tries)->toBe(3);
expect($job->queue)->toBe('high');
expect($job->checkedAt)->toBe($checkedAt);
});

it('sends one aggregated notification per team for the same batch', function () {
$checkedAt = '2026-03-29T00:00:00+00:00';

$team1 = Team::factory()->create();
$team2 = Team::factory()->create();
$team1->emailNotificationSettings->update([
'use_instance_email_settings' => true,
'traefik_outdated_email_notifications' => true,
]);
$team2->emailNotificationSettings->update([
'use_instance_email_settings' => true,
'traefik_outdated_email_notifications' => true,
]);

$server1 = Server::factory()->create([
'team_id' => $team1->id,
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
'traefik_outdated_info' => [
'current' => '3.5.0',
'latest' => '3.5.6',
'type' => 'patch_update',
'checked_at' => $checkedAt,
],
]);
$server1->settings->update(['is_reachable' => true, 'is_usable' => true]);

$server2 = Server::factory()->create([
'team_id' => $team1->id,
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
'traefik_outdated_info' => [
'current' => '3.5.6',
'latest' => '3.6.2',
'type' => 'minor_upgrade',
'upgrade_target' => 'v3.6',
'checked_at' => $checkedAt,
],
]);
$server2->settings->update(['is_reachable' => true, 'is_usable' => true]);

$server3 = Server::factory()->create([
'team_id' => $team2->id,
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
'traefik_outdated_info' => [
'current' => '3.4.0',
'latest' => '3.4.9',
'type' => 'patch_update',
'checked_at' => $checkedAt,
],
]);
$server3->settings->update(['is_reachable' => true, 'is_usable' => true]);

$previousBatchServer = Server::factory()->create([
'team_id' => $team1->id,
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
'traefik_outdated_info' => [
'current' => '3.3.0',
'latest' => '3.3.9',
'type' => 'patch_update',
'checked_at' => '2026-03-22T00:00:00+00:00',
],
]);
$previousBatchServer->settings->update(['is_reachable' => true, 'is_usable' => true]);

$unusableServer = Server::factory()->create([
'team_id' => $team2->id,
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
'traefik_outdated_info' => [
'current' => '3.4.1',
'latest' => '3.4.9',
'type' => 'patch_update',
'checked_at' => $checkedAt,
],
]);
$unusableServer->settings->update(['is_reachable' => true, 'is_usable' => false]);

$otherProxyServer = Server::factory()->create([
'team_id' => $team2->id,
'proxy' => ['type' => ProxyTypes::CADDY->value],
'traefik_outdated_info' => [
'current' => '3.4.1',
'latest' => '3.4.9',
'type' => 'patch_update',
'checked_at' => $checkedAt,
],
]);
$otherProxyServer->settings->update(['is_reachable' => true, 'is_usable' => true]);

$job = new NotifyOutdatedTraefikServersJob($checkedAt);
$job->handle();

Notification::assertSentTo($team1, TraefikVersionOutdated::class, function (TraefikVersionOutdated $notification) use ($server1, $server2) {
return $notification->servers->pluck('id')->sort()->values()->all() === collect([$server1->id, $server2->id])->sort()->values()->all();
});

Notification::assertSentTo($team2, TraefikVersionOutdated::class, function (TraefikVersionOutdated $notification) use ($server3) {
return $notification->servers->pluck('id')->all() === [$server3->id];
});

$notificationCount = count(Notification::sent($team1, TraefikVersionOutdated::class))
+ count(Notification::sent($team2, TraefikVersionOutdated::class));

expect($notificationCount)->toBe(2);
});

it('does not send notifications when no servers match the batch timestamp', function () {
$checkedAt = '2026-03-29T00:00:00+00:00';
$team = Team::factory()->create();
$team->emailNotificationSettings->update([
'use_instance_email_settings' => true,
'traefik_outdated_email_notifications' => true,
]);

$server = Server::factory()->create([
'team_id' => $team->id,
'proxy' => ['type' => ProxyTypes::TRAEFIK->value],
'traefik_outdated_info' => [
'current' => '3.5.0',
'latest' => '3.5.6',
'type' => 'patch_update',
'checked_at' => '2026-03-22T00:00:00+00:00',
],
]);
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);

$job = new NotifyOutdatedTraefikServersJob($checkedAt);
$job->handle();

Notification::assertNothingSent();
});
Loading