Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
22 changes: 17 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 $scanId = null
) {
if (! $this->shouldNotify && is_null($this->scanId)) {
throw new InvalidArgumentException('Batched Traefik version checks require a shared scan identifier.');
}
}

/**
* Execute the job.
Expand Down Expand Up @@ -142,7 +149,7 @@ private function getNewerBranchInfo(string $currentBranch): ?array
}

/**
* Store outdated information in database and send immediate notification.
* Store outdated information in database and optionally send an immediate notification.
*/
private function storeOutdatedInfo(string $current, string $latest, string $type, ?string $upgradeTarget = null, ?array $newerBranchInfo = null): void
{
Expand All @@ -153,6 +160,10 @@ private function storeOutdatedInfo(string $current, string $latest, string $type
'checked_at' => now()->toIso8601String(),
];

if ($this->scanId) {
$outdatedInfo['scan_id'] = $this->scanId;
}

// For minor upgrades, add the upgrade_target field (e.g., "v3.6")
if ($type === 'minor_upgrade' && $upgradeTarget) {
$outdatedInfo['upgrade_target'] = $upgradeTarget;
Expand All @@ -166,8 +177,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
58 changes: 54 additions & 4 deletions app/Jobs/CheckTraefikVersionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,26 @@

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;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Throwable;

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

private const SCAN_LOCK_KEY = 'traefik-version-scan';

private const SCAN_LOCK_TTL_SECONDS = 21600;

public $tries = 3;

public function handle(): void
Expand All @@ -37,10 +46,51 @@ 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);
$lock = Cache::lock(self::SCAN_LOCK_KEY, self::SCAN_LOCK_TTL_SECONDS);
if (! $lock->get()) {
return;
}

$lockOwner = $lock->owner();
$scanId = (string) Str::uuid();
$jobs = $servers
->map(fn (Server $server) => new CheckTraefikVersionForServerJob($server, $traefikVersions, false, $scanId))
->all();

try {
Bus::batch($jobs)
->finally(function (Batch $batch) use ($scanId, $lockOwner): void {
if ($batch->cancelled()) {
self::releaseScanLock($lockOwner);

return;
}

try {
NotifyOutdatedTraefikServersJob::dispatch($scanId, self::SCAN_LOCK_KEY, $lockOwner);
} catch (Throwable $exception) {
self::releaseScanLock($lockOwner);

throw $exception;
}
})
->dispatch();
} catch (Throwable $exception) {
self::releaseScanLock($lockOwner);

throw $exception;
}
}

private static function releaseScanLock(?string $lockOwner): void
{
if (! $lockOwner) {
return;
}

rescue(
fn () => Cache::restoreLock(self::SCAN_LOCK_KEY, $lockOwner)->release(),
report: false
);
}
}
93 changes: 93 additions & 0 deletions app/Jobs/NotifyOutdatedTraefikServersJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

namespace App\Jobs;

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

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

public $tries = 3;

public function __construct(
public string $scanId,
public ?string $lockKey = null,
public ?string $lockOwner = null
)
{
$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['scan_id'] ?? null) !== $this->scanId) {
return false;
}

$server->outdatedInfo = $outdatedInfo;

return true;
});

if ($outdatedServers->isEmpty()) {
$this->releaseScanLock();

return;
}

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

if (! $team) {
return;
}

$team->notify(new TraefikVersionOutdated($teamServers->values()));
});

$this->releaseScanLock();
}

public function failed(?Throwable $exception = null): void
{
$this->releaseScanLock();
}

private function releaseScanLock(): void
{
if (! $this->lockKey || ! $this->lockOwner) {
return;
}

rescue(
fn () => Cache::restoreLock($this->lockKey, $this->lockOwner)->release(),
report: false
);

$this->lockKey = null;
$this->lockOwner = null;
}
}
5 changes: 4 additions & 1 deletion app/Models/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
* latest: string,
* type: 'patch_update'|'minor_upgrade',
* checked_at: string,
* scan_id?: string,
* newer_branch_target?: string,
* newer_branch_latest?: string,
* upgrade_target?: string
Expand All @@ -58,6 +59,7 @@
* 'latest' => '3.5.2', // Latest patch version available
* 'type' => 'patch_update', // Update type identifier
* 'checked_at' => '2025-11-14T10:00:00Z', // ISO8601 timestamp
* 'scan_id' => '32f5f6d7-7ae0-4aa7-9fcb-3a0b75155eab', // (Optional) Batched scan identifier
* 'newer_branch_target' => 'v3.6', // (Optional) Available major/minor version
* 'newer_branch_latest' => '3.6.2' // (Optional) Latest version in that branch
* ]
Expand All @@ -70,7 +72,8 @@
* 'latest' => '3.6.2', // Latest version in target branch
* 'type' => 'minor_upgrade', // Update type identifier
* 'upgrade_target' => 'v3.6', // Target branch (with 'v' prefix)
* 'checked_at' => '2025-11-14T10:00:00Z' // ISO8601 timestamp
* 'checked_at' => '2025-11-14T10:00:00Z', // ISO8601 timestamp
* 'scan_id' => '32f5f6d7-7ae0-4aa7-9fcb-3a0b75155eab' // (Optional) Batched scan identifier
* ]
* ```
*
Expand Down
83 changes: 65 additions & 18 deletions tests/Feature/CheckTraefikVersionJobTest.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
<?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\Cache;
use Illuminate\Support\Facades\Notification;

uses(RefreshDatabase::class);
Expand Down Expand Up @@ -180,39 +187,79 @@
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('scanId'))->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]));
$batchScanId = null;

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

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

$jobs = collect($batch->jobs);
$batchScanId = $jobs->first()?->scanId;

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

it('skips dispatching a new batch while another traefik scan is still locked', function () {
Bus::fake();

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

$lock = Cache::lock('traefik-version-scan', 60);
expect($lock->get())->toBeTrue();

try {
(new CheckTraefikVersionJob)->handle();

Bus::assertNothingBatched();
} finally {
$lock->release();
}
});

it('notification generates correct server proxy URLs', function () {
Expand Down
Loading