Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 7 additions & 4 deletions app/Jobs/CheckTraefikVersionForServerJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ 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
) {}

/**
Expand Down Expand Up @@ -150,7 +152,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 +168,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
23 changes: 21 additions & 2 deletions app/Jobs/CheckTraefikVersionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,29 @@ public function handle(): void
return;
}

$checkedAt = now()->toIso8601String();

// 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);
CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions, false, $checkedAt);
}

$delaySeconds = $this->calculateNotificationDelay($servers->count());
if (isDev()) {
$delaySeconds = 1;
}

NotifyOutdatedTraefikServersJob::dispatch($checkedAt)->delay(now()->addSeconds($delaySeconds));
}

protected function calculateNotificationDelay(int $serverCount): int
{
$minDelay = config('constants.server_checks.notification_delay_min');
$maxDelay = config('constants.server_checks.notification_delay_max');
$scalingFactor = config('constants.server_checks.notification_delay_scaling');

$calculatedDelay = (int) ($serverCount * $scalingFactor);

return min($maxDelay, max($minDelay, $calculatedDelay));
}
}
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\Models\Team;
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')
->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, int|string $teamId): void {
$team = Team::find($teamId);

if (! $team) {
return;
}

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

use App\Enums\ProxyTypes;
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\Notification;
use Illuminate\Support\Facades\Queue;

uses(RefreshDatabase::class);

Expand Down Expand Up @@ -180,39 +185,71 @@
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('calculates delay seconds correctly for notification job', function () {
$job = new CheckTraefikVersionJob;
$reflection = new \ReflectionMethod(CheckTraefikVersionJob::class, 'calculateNotificationDelay');
$reflection->setAccessible(true);

$minDelay = config('constants.server_checks.notification_delay_min');
$maxDelay = config('constants.server_checks.notification_delay_max');
$scalingFactor = config('constants.server_checks.notification_delay_scaling');

foreach ([10, 100, 1000, 5000] as $serverCount) {
$delaySeconds = $reflection->invoke($job, $serverCount);

expect($delaySeconds)->toBeGreaterThanOrEqual($minDelay);
expect($delaySeconds)->toBeLessThanOrEqual($maxDelay);
expect($delaySeconds)->toBe(min($maxDelay, max($minDelay, (int) ($serverCount * $scalingFactor))));
}
});

it('dispatches server checks without immediate notifications and queues one aggregation job', function () {
Queue::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();

Queue::assertPushed(CheckTraefikVersionForServerJob::class, 2);
Queue::assertPushed(CheckTraefikVersionForServerJob::class, function (CheckTraefikVersionForServerJob $job) use (&$batchCheckedAt) {
if ($batchCheckedAt === null) {
$batchCheckedAt = $job->checkedAt;
}

return $job->shouldNotify === false && $job->checkedAt === $batchCheckedAt;
});
Queue::assertPushed(NotifyOutdatedTraefikServersJob::class, function (NotifyOutdatedTraefikServersJob $job) use (&$batchCheckedAt) {
return $batchCheckedAt !== null && $job->checkedAt === $batchCheckedAt && ! is_null($job->delay);
});
});

it('notification generates correct server proxy URLs', function () {
Expand Down
11 changes: 11 additions & 0 deletions tests/Unit/CheckTraefikVersionForServerJobTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@
expect($job->timeout)->toBe(60);
expect($job->server)->toBe($server);
expect($job->traefikVersions)->toBe($this->traefikVersions);
expect($job->shouldNotify)->toBeTrue();
expect($job->checkedAt)->toBeNull();
});

it('can suppress immediate notifications for batched scans', function () {
$server = \Mockery::mock(Server::class)->makePartial();
$checkedAt = '2026-03-29T00:00:00+00:00';
$job = new CheckTraefikVersionForServerJob($server, $this->traefikVersions, false, $checkedAt);

expect($job->shouldNotify)->toBeFalse();
expect($job->checkedAt)->toBe($checkedAt);
});

it('parses version strings correctly', function () {
Expand Down
Loading