diff --git a/app/Console/Commands/ScheduledJobDiagnostics.php b/app/Console/Commands/ScheduledJobDiagnostics.php index 77881284c..61f26265a 100644 --- a/app/Console/Commands/ScheduledJobDiagnostics.php +++ b/app/Console/Commands/ScheduledJobDiagnostics.php @@ -9,6 +9,7 @@ use App\Models\Team; use Illuminate\Console\Command; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; class ScheduledJobDiagnostics extends Command @@ -203,7 +204,6 @@ private function inspectServerJobs(?string $serverFilter): void } $dedupKeys = [ - "sentinel-restart:{$server->id}" => '0 0 * * *', "server-patch-check:{$server->id}" => '0 0 * * 0', "server-check:{$server->id}" => isCloud() ? '*/5 * * * *' : '* * * * *', "server-storage-check:{$server->id}" => data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *'), @@ -235,7 +235,7 @@ private function inspectServerJobs(?string $serverFilter): void $this->newLine(); } - private function getServers(?string $serverFilter): \Illuminate\Support\Collection + private function getServers(?string $serverFilter): Collection { $query = Server::with('settings')->where('ip', '!=', '1.2.3.4'); diff --git a/app/Http/Controllers/Api/ServerSentinelController.php b/app/Http/Controllers/Api/ServerSentinelController.php index f52bdb4c3..fb40745c9 100644 --- a/app/Http/Controllers/Api/ServerSentinelController.php +++ b/app/Http/Controllers/Api/ServerSentinelController.php @@ -12,7 +12,6 @@ class ServerSentinelController extends Controller { private const ALLOWED_FIELDS = [ - 'is_sentinel_enabled', 'is_metrics_enabled', 'is_sentinel_debug_enabled', 'sentinel_token', @@ -36,7 +35,7 @@ private function transform(Server $server): array { $settings = $server->settings; $payload = [ - 'is_sentinel_enabled' => (bool) $settings->is_sentinel_enabled, + 'is_sentinel_enabled' => $server->isSentinelEnabled(), 'is_metrics_enabled' => (bool) $settings->is_metrics_enabled, 'is_sentinel_debug_enabled' => (bool) $settings->is_sentinel_debug_enabled, 'sentinel_metrics_refresh_rate_seconds' => (int) $settings->sentinel_metrics_refresh_rate_seconds, @@ -69,7 +68,7 @@ private function transform(Server $server): array description: 'Sentinel settings.', content: new OA\JsonContent( properties: [ - new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'), + new OA\Property(property: 'is_sentinel_enabled', type: 'boolean', readOnly: true, description: 'Sentinel is mandatory on regular managed servers.'), new OA\Property(property: 'is_metrics_enabled', type: 'boolean'), new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'), new OA\Property(property: 'sentinel_token', type: 'string', description: 'Only present with read:sensitive.'), @@ -118,7 +117,6 @@ public function show(Request $request): JsonResponse required: true, content: new OA\JsonContent( properties: [ - new OA\Property(property: 'is_sentinel_enabled', type: 'boolean'), new OA\Property(property: 'is_metrics_enabled', type: 'boolean'), new OA\Property(property: 'is_sentinel_debug_enabled', type: 'boolean'), new OA\Property(property: 'sentinel_token', type: 'string'), @@ -158,7 +156,6 @@ public function update(Request $request): JsonResponse $this->authorize('update', $server); $validator = customApiValidator($request->all(), [ - 'is_sentinel_enabled' => 'boolean', 'is_metrics_enabled' => 'boolean', 'is_sentinel_debug_enabled' => 'boolean', 'sentinel_token' => ['string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'], @@ -189,29 +186,12 @@ public function update(Request $request): JsonResponse } $settings = $server->settings; - $enablingSentinel = $request->has('is_sentinel_enabled') - && $request->boolean('is_sentinel_enabled') - && ! $settings->is_sentinel_enabled; - - if ($enablingSentinel && $server->isBuildServer()) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['is_sentinel_enabled' => ['Sentinel cannot be enabled on build servers.']], - ], 422); - } - foreach (self::ALLOWED_FIELDS as $field) { if ($request->has($field)) { $settings->{$field} = $request->input($field); } } - // Disabling Sentinel also clears related toggles (matches Livewire toggleSentinel). - if ($request->has('is_sentinel_enabled') && ! $request->boolean('is_sentinel_enabled')) { - $settings->is_metrics_enabled = false; - $settings->is_sentinel_debug_enabled = false; - } - $settings->save(); auditLog('api.server.sentinel.updated', [ diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php index 67c222c24..171d4e694 100644 --- a/app/Jobs/ServerManagerJob.php +++ b/app/Jobs/ServerManagerJob.php @@ -166,14 +166,6 @@ private function processServerTasks(Server $server): void } } - $isSentinelEnabled = $server->isSentinelEnabled(); - $shouldRestartSentinel = $isSentinelEnabled && shouldRunCronNow('0 0 * * *', $serverTimezone, "sentinel-restart:{$server->id}", $this->executionTime); - // Dispatch Sentinel restart if due (daily for Sentinel-enabled servers) - - if ($shouldRestartSentinel) { - CheckAndStartSentinelJob::dispatch($server); - } - // Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled) // When Sentinel is active, PushServerUpdateJob handles storage checks with real-time data if ($sentinelOutOfSync) { @@ -195,7 +187,6 @@ private function processServerTasks(Server $server): void ServerPatchCheckJob::dispatch($server); } - // Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates. // Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob. } diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php index af2588dda..987b53e7f 100644 --- a/app/Jobs/ValidateAndInstallServerJob.php +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -202,6 +202,9 @@ public function handle(): void // Broadcast events to update UI ServerValidated::dispatch($this->server->team_id, $this->server->uuid); ServerReachabilityChanged::dispatch($this->server); + if ($this->server->isSentinelEnabled()) { + CheckAndStartSentinelJob::dispatch($this->server); + } } catch (\Throwable $e) { Log::error('ValidateAndInstallServer: Exception occurred', [ diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php index 2d4742eb6..f07799fbe 100644 --- a/app/Livewire/Server/Sentinel.php +++ b/app/Livewire/Server/Sentinel.php @@ -2,8 +2,6 @@ namespace App\Livewire\Server; -use App\Actions\Server\StartSentinel; -use App\Actions\Server\StopSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; @@ -34,8 +32,6 @@ class Sentinel extends Component #[Validate(['nullable', 'url'])] public ?string $sentinelCustomUrl = null; - public bool $isSentinelEnabled; - public bool $isSentinelDebugEnabled; public ?string $sentinelCustomDockerImage = null; @@ -64,7 +60,6 @@ private function syncData(bool $toModel = false): void $this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays; $this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds; $this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl; - $this->server->settings->is_sentinel_enabled = $this->isSentinelEnabled; $this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled; $this->server->settings->save(); } else { @@ -74,7 +69,6 @@ private function syncData(bool $toModel = false): void $this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days; $this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds; $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; - $this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled; $this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled; $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; } @@ -103,33 +97,6 @@ public function restartSentinel() } } - public function toggleSentinel(): void - { - try { - $this->authorize('manageSentinel', $this->server); - if (! $this->isSentinelEnabled) { - if ($this->server->isBuildServer()) { - $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); - - return; - } - $customImage = isDev() ? $this->sentinelCustomDockerImage : null; - StartSentinel::run($this->server, true, null, $customImage); - $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; - $this->isSentinelEnabled = true; - } else { - $this->isSentinelEnabled = false; - $this->isMetricsEnabled = false; - $this->isSentinelDebugEnabled = false; - StopSentinel::dispatch($this->server); - } - $this->submit(); - $this->dispatch('refreshServerShow'); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - public function regenerateSentinelToken() { try { diff --git a/app/Livewire/Server/Sentinel/Logs.php b/app/Livewire/Server/Sentinel/Logs.php index 49739ac6d..1190cd59a 100644 --- a/app/Livewire/Server/Sentinel/Logs.php +++ b/app/Livewire/Server/Sentinel/Logs.php @@ -2,7 +2,6 @@ namespace App\Livewire\Server\Sentinel; -use App\Actions\Server\StartSentinel; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\View\View; @@ -30,35 +29,6 @@ public function mount(): void $this->authorize('viewSentinel', $this->server); } - public function enableSentinel(): void - { - $this->authorize('manageSentinel', $this->server); - - try { - $this->server->refresh(); - if ($this->server->isBuildServer()) { - $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); - - return; - } - if ($this->server->isSwarm()) { - $this->dispatch('error', 'Sentinel cannot be enabled on Swarm servers.'); - - return; - } - if ($this->server->isSentinelEnabled()) { - return; - } - - StartSentinel::run($this->server, true); - $this->server->refresh(); - $this->dispatch('refreshServerShow'); - $this->dispatch('success', 'Sentinel has been enabled.'); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - public function render(): View { return view('livewire.server.sentinel.logs'); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 38bbe24e7..b58050cef 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -2,7 +2,6 @@ namespace App\Livewire\Server; -use App\Actions\Server\StartSentinel; use App\Actions\Server\StopSentinel; use App\Events\ServerReachabilityChanged; use App\Models\CloudProviderToken; @@ -67,8 +66,6 @@ class Show extends Component public ?string $sentinelCustomUrl = null; - public bool $isSentinelEnabled; - public bool $isSentinelDebugEnabled; public ?string $sentinelCustomDockerImage = null; @@ -161,7 +158,6 @@ protected function rules(): array 'sentinelMetricsHistoryDays' => 'required|integer|min:1', 'sentinelPushIntervalSeconds' => 'required|integer|min:10', 'sentinelCustomUrl' => 'nullable|url', - 'isSentinelEnabled' => 'required', 'isSentinelDebugEnabled' => 'required', 'serverTimezone' => 'required', ]; @@ -265,7 +261,6 @@ private function syncData(bool $toModel = false): void $this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays; $this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds; $this->server->settings->sentinel_custom_url = $this->sentinelCustomUrl; - $this->server->settings->is_sentinel_enabled = $this->isSentinelEnabled; $this->server->settings->is_sentinel_debug_enabled = $this->isSentinelDebugEnabled; if (! validate_timezone($this->serverTimezone)) { @@ -296,7 +291,6 @@ private function syncData(bool $toModel = false): void $this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days; $this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds; $this->sentinelCustomUrl = $this->server->settings->sentinel_custom_url; - $this->isSentinelEnabled = $this->server->settings->is_sentinel_enabled; $this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled; $this->sentinelUpdatedAt = $this->server->sentinel_updated_at; $this->serverTimezone = $this->server->settings->server_timezone; @@ -425,10 +419,10 @@ public function updatedIsBuildServer($value) return; } - if ($value === true && $this->isSentinelEnabled) { - $this->isSentinelEnabled = false; + if ($value === true && $this->server->isSentinelEnabled()) { $this->isMetricsEnabled = false; $this->isSentinelDebugEnabled = false; + $this->server->settings->is_sentinel_enabled = false; StopSentinel::dispatch($this->server); $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.'); } @@ -440,30 +434,6 @@ public function updatedIsBuildServer($value) } } - public function updatedIsSentinelEnabled($value) - { - try { - $this->authorize('manageSentinel', $this->server); - if ($value === true) { - if ($this->isBuildServer) { - $this->isSentinelEnabled = false; - $this->dispatch('error', 'Sentinel cannot be enabled on build servers.'); - - return; - } - $customImage = isDev() ? $this->sentinelCustomDockerImage : null; - StartSentinel::run($this->server, true, null, $customImage); - } else { - $this->isMetricsEnabled = false; - $this->isSentinelDebugEnabled = false; - StopSentinel::dispatch($this->server); - } - $this->submit(); - } catch (\Throwable $e) { - return handleError($e, $this); - } - } - public function regenerateSentinelToken() { try { diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index db62bff2d..33b77418d 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -5,6 +5,7 @@ use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; use App\Events\ServerValidated; +use App\Jobs\CheckAndStartSentinelJob; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -275,6 +276,9 @@ public function validateDockerVersion() $this->dispatch('refreshServerShow'); $this->dispatch('refreshBoardingIndex'); ServerValidated::dispatch($this->server->team_id, $this->server->uuid); + if ($this->server->isSentinelEnabled()) { + CheckAndStartSentinelJob::dispatch($this->server); + } $this->dispatch('success', 'Server validated, proxy is starting in a moment.'); $proxyShouldRun = CheckProxy::run($this->server, true); if (! $proxyShouldRun) { diff --git a/app/Models/Server.php b/app/Models/Server.php index dccbed15e..6795c4ac9 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -972,17 +972,20 @@ public function isSentinelLive() return Carbon::parse($this->sentinel_updated_at)->isAfter(now()->subSeconds($this->waitBeforeDoingSshCheck())); } - public function isSentinelEnabled() + public function isSentinelEnabled(): bool { - return ($this->isMetricsEnabled() || $this->isServerApiEnabled()) && ! $this->isBuildServer(); + return ! $this->isBuildServer() + && ! $this->isSwarm() + && ! $this->isForceDisabled() + && ! $this->isTransferredAway(); } - public function isMetricsEnabled() + public function isMetricsEnabled(): bool { return $this->settings->is_metrics_enabled; } - public function isServerApiEnabled() + public function isServerApiEnabled(): bool { return $this->settings->is_sentinel_enabled; } diff --git a/app/Services/ServerTransfer/ServerTransferClaimer.php b/app/Services/ServerTransfer/ServerTransferClaimer.php index d1d984a19..76cb4a5e8 100644 --- a/app/Services/ServerTransfer/ServerTransferClaimer.php +++ b/app/Services/ServerTransfer/ServerTransferClaimer.php @@ -54,7 +54,7 @@ public function claim(Server $server, bool $writeRemote = true, bool $rebindSent if ($rebindSentinel && $server->settings) { $server->settings->sentinel_custom_url = $instanceUrl; $server->settings->ensureValidSentinelToken(); - // Leave sentinel disabled until operator enables metrics; endpoint is ready. + $server->settings->is_sentinel_enabled = true; $server->settings->save(); $sentinelRebound = true; } diff --git a/database/migrations/2026_09_08_202212_enable_sentinel_for_existing_regular_servers.php b/database/migrations/2026_09_08_202212_enable_sentinel_for_existing_regular_servers.php new file mode 100644 index 000000000..3c557fe44 --- /dev/null +++ b/database/migrations/2026_09_08_202212_enable_sentinel_for_existing_regular_servers.php @@ -0,0 +1,31 @@ +where('is_sentinel_enabled', false) + ->where('is_build_server', false) + ->where('is_swarm_manager', false) + ->where('is_swarm_worker', false) + ->where('force_disabled', false) + ->where('is_reachable', true) + ->where('is_usable', true) + ->update(['is_sentinel_enabled' => true]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Existing values cannot be distinguished from values enabled before this migration. + } +}; diff --git a/database/seeders/SentinelSeeder.php b/database/seeders/SentinelSeeder.php index ebae97078..fd1f8fb09 100644 --- a/database/seeders/SentinelSeeder.php +++ b/database/seeders/SentinelSeeder.php @@ -2,6 +2,7 @@ namespace Database\Seeders; +use App\Jobs\CheckAndStartSentinelJob; use App\Models\Server; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Log; @@ -13,6 +14,10 @@ public function run() Server::chunk(100, function ($servers) { foreach ($servers as $server) { try { + if ($server->isSentinelEnabled()) { + $server->settings->is_sentinel_enabled = true; + $server->settings->saveQuietly(); + } if (str($server->settings->sentinel_token)->isEmpty()) { $server->settings->generateSentinelToken(ignoreEvent: true); } @@ -25,11 +30,10 @@ public function run() } if (str($server->settings->sentinel_custom_url)->isEmpty()) { - $url = $server->settings->generateSentinelUrl(ignoreEvent: true); - if (str($url)->isEmpty()) { - $server->settings->is_sentinel_enabled = false; - $server->settings->save(); - } + $server->settings->generateSentinelUrl(ignoreEvent: true); + } + if ($server->isFunctional() && $server->isSentinelEnabled() && filled($server->settings->sentinel_custom_url)) { + CheckAndStartSentinelJob::dispatch($server); } } catch (\Throwable $e) { Log::error('Error seeding sentinel: '.$e->getMessage()); diff --git a/resources/views/livewire/project/shared/metrics.blade.php b/resources/views/livewire/project/shared/metrics.blade.php index 62417d42c..8b0ca2b75 100644 --- a/resources/views/livewire/project/shared/metrics.blade.php +++ b/resources/views/livewire/project/shared/metrics.blade.php @@ -25,7 +25,7 @@ - Enable Sentinel and metrics for this server before collecting application usage data. + Enable metrics for this server before collecting application usage data. @elseif (!str($resource->status)->contains('running')) diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 5fabd3678..bb99c9980 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -276,14 +276,14 @@ class="server-settings-workspace application-settings-workspace mt-4 grid w-full @else - - Configure Sentinel + View Sentinel diff --git a/resources/views/livewire/server/sentinel.blade.php b/resources/views/livewire/server/sentinel.blade.php index e5041fb4b..a72519a47 100644 --- a/resources/views/livewire/server/sentinel.blade.php +++ b/resources/views/livewire/server/sentinel.blade.php @@ -1,44 +1,31 @@
- @if ($isSentinelEnabled) - {{-- Scope dirty tracking to savable form fields only. Without wire:target, - Livewire compares the entire component snapshot — so dev-only x-init - `$wire.set('sentinelCustomDockerImage', …)` (and similar) briefly - flashes this bar on every page open. --}} - - @endif + {{-- Scope dirty tracking to savable form fields only. Without wire:target, + Livewire compares the entire component snapshot — so dev-only x-init + `$wire.set('sentinelCustomDockerImage', …)` (and similar) briefly + flashes this bar on every page open. --}} +
- @if (!$isSentinelEnabled) - - Enable Sentinel - - @else - - - - {{ $server->isSentinelLive() ? 'Restart' : 'Sync' }} - - - Disable - - @endif + + + + {{ $server->isSentinelLive() ? 'Restart' : 'Sync' }} +
- @if ($isSentinelEnabled && !$server->isSentinelLive()) + @if (!$server->isSentinelLive()) Sync Sentinel to apply its current configuration and restore health reporting. - @elseif ($isSentinelEnabled) + @else
@@ -51,10 +38,6 @@ class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-10

- @else - @endif
diff --git a/resources/views/livewire/server/sentinel/logs.blade.php b/resources/views/livewire/server/sentinel/logs.blade.php index 37f8367fd..8211633e3 100644 --- a/resources/views/livewire/server/sentinel/logs.blade.php +++ b/resources/views/livewire/server/sentinel/logs.blade.php @@ -21,14 +21,8 @@ class="logs-section-status-badge" /> displayName="Sentinel" :collapsible="false" />
@else - - - Enable Sentinel - - - @endif
diff --git a/tests/Feature/Api/ServerSubsystemsApiTest.php b/tests/Feature/Api/ServerSubsystemsApiTest.php index e31c8a051..0359984fd 100644 --- a/tests/Feature/Api/ServerSubsystemsApiTest.php +++ b/tests/Feature/Api/ServerSubsystemsApiTest.php @@ -12,6 +12,11 @@ uses(RefreshDatabase::class); beforeEach(function () { + config([ + 'app.maintenance.store' => 'array', + 'cache.default' => 'array', + 'cache.stores.redis.driver' => 'array', + ]); InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); $this->team = Team::factory()->create(); @@ -234,6 +239,17 @@ function serverSubsystemsHeaders(): array ->and((bool) $settings->is_sentinel_debug_enabled)->toBeTrue(); }); + test('PATCH rejects disabling mandatory Sentinel', function () { + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [ + 'is_sentinel_enabled' => false, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('is_sentinel_enabled'); + + expect($this->server->fresh()->isSentinelEnabled())->toBeTrue(); + }); + test('other-team sentinel endpoints return 404', function () { $this->withHeaders(serverSubsystemsHeaders()) ->getJson("/api/v1/servers/{$this->otherServer->uuid}/sentinel") diff --git a/tests/Feature/Jobs/CheckAndStartSentinelJobTest.php b/tests/Feature/Jobs/CheckAndStartSentinelJobTest.php index 53d277f52..e9867f94b 100644 --- a/tests/Feature/Jobs/CheckAndStartSentinelJobTest.php +++ b/tests/Feature/Jobs/CheckAndStartSentinelJobTest.php @@ -1,6 +1,5 @@ insert(['id' => 0]); $user = User::factory()->create(); $server = Server::factory()->create([ @@ -19,7 +18,38 @@ 'is_sentinel_enabled' => false, ]); - (new CheckAndStartSentinelJob($server))->handle(); - - expect((bool) $server->settings->fresh()->is_sentinel_enabled)->toBeFalse(); + expect($server->fresh()->isSentinelEnabled())->toBeTrue(); +}); + +it('does not enable Sentinel for excluded server types', function (array $settings, array $metadata = []) { + DB::table('instance_settings')->insert(['id' => 0]); + $user = User::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $user->teams()->first()->id, + 'server_metadata' => $metadata, + ]); + $server->settings->update(array_merge([ + 'is_metrics_enabled' => false, + 'is_sentinel_enabled' => true, + ], $settings)); + + expect($server->fresh()->isSentinelEnabled())->toBeFalse(); +})->with([ + 'build server' => [['is_build_server' => true]], + 'swarm manager' => [['is_swarm_manager' => true]], + 'swarm worker' => [['is_swarm_worker' => true]], + 'transferred server' => [[], ['transfer' => ['status' => 'transferred']]], + 'force-disabled server' => [['force_disabled' => true]], +]); + +it('keeps metrics optional while Sentinel remains enabled', function () { + DB::table('instance_settings')->insert(['id' => 0]); + $user = User::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $user->teams()->first()->id, + ]); + $server->settings->update(['is_metrics_enabled' => false]); + + expect($server->fresh()->isSentinelEnabled())->toBeTrue() + ->and((bool) $server->settings->fresh()->is_metrics_enabled)->toBeFalse(); }); diff --git a/tests/Feature/Livewire/SentinelComponentTest.php b/tests/Feature/Livewire/SentinelComponentTest.php index 01250910a..477e84e03 100644 --- a/tests/Feature/Livewire/SentinelComponentTest.php +++ b/tests/Feature/Livewire/SentinelComponentTest.php @@ -10,25 +10,13 @@ ->not->toContain('$this->syncData();'); }); -it('dispatches a server navbar refresh after toggling sentinel', function () { +it('does not expose a Sentinel disable action', function () { $componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php')); + $view = file_get_contents(resource_path('views/livewire/server/sentinel.blade.php')); - preg_match('/public function toggleSentinel\([^)]*\).*?\{(?.*?) - \}/s', $componentSource, $matches); - - expect($matches['body'] ?? '') - ->toContain("\$this->dispatch('refreshServerShow');"); -}); - -it('only marks sentinel enabled after startup succeeds', function () { - $componentSource = file_get_contents(app_path('Livewire/Server/Sentinel.php')); - - preg_match('/public function toggleSentinel\([^)]*\).*?\{(?.*?)\n \}/s', $componentSource, $matches); - $toggleBody = $matches['body'] ?? ''; - - expect(strpos($toggleBody, 'StartSentinel::run'))->toBeLessThan( - strpos($toggleBody, '$this->isSentinelEnabled = true;') - ); + expect($componentSource)->not->toContain('function toggleSentinel') + ->and($view)->not->toContain('Disable') + ->and($view)->not->toContain('Enable Sentinel'); }); it('does not repeat a disabled status badge in the sentinel empty state', function () { @@ -45,3 +33,11 @@ expect($matches['body'] ?? '') ->toContain("\$this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.');"); }); + +it('starts mandatory Sentinel after server validation succeeds', function () { + $interactiveValidation = file_get_contents(app_path('Livewire/Server/ValidateAndInstall.php')); + $queuedValidation = file_get_contents(app_path('Jobs/ValidateAndInstallServerJob.php')); + + expect($interactiveValidation)->toContain('CheckAndStartSentinelJob::dispatch($this->server);') + ->and($queuedValidation)->toContain('CheckAndStartSentinelJob::dispatch($this->server);'); +}); diff --git a/tests/Feature/Livewire/SentinelLogsTest.php b/tests/Feature/Livewire/SentinelLogsTest.php index 99d2fa105..f7c113623 100644 --- a/tests/Feature/Livewire/SentinelLogsTest.php +++ b/tests/Feature/Livewire/SentinelLogsTest.php @@ -1,13 +1,11 @@ server = Server::factory()->create(['team_id' => $team->id]); }); -it('does not show sync status or fetch logs when sentinel is disabled', function (bool $recentHeartbeat) { +it('shows Sentinel status and logs when the legacy flag and metrics are disabled', function (bool $recentHeartbeat) { $this->server->sentinelHeartbeat(isReset: ! $recentHeartbeat); $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) ->test(Logs::class) - ->assertSee('Sentinel is disabled') - ->assertSeeHtml('wire:click="enableSentinel"') - ->assertDontSee('Out of sync') - ->assertDontSee('In sync') - ->assertDontSeeLivewire(GetLogs::class); + ->assertSee($recentHeartbeat ? 'In sync' : 'Out of sync') + ->assertDontSee('Enable Sentinel') + ->assertDontSee('Sentinel is disabled') + ->assertSeeLivewire(GetLogs::class); })->with([false, true]); -it('shows sync status and logs when sentinel is enabled', function (bool $metricsOnly, bool $recentHeartbeat) { +it('shows Sentinel status independently of optional metrics', function (bool $metricsEnabled, bool $recentHeartbeat) { $this->server->sentinelHeartbeat(isReset: ! $recentHeartbeat); $this->server->settings()->update([ - 'is_sentinel_enabled' => ! $metricsOnly, - 'is_metrics_enabled' => $metricsOnly, - 'is_build_server' => false, + 'is_sentinel_enabled' => false, + 'is_metrics_enabled' => $metricsEnabled, ]); Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) ->test(Logs::class) - ->assertDontSee('Sentinel is disabled') ->assertSee($recentHeartbeat ? 'In sync' : 'Out of sync') ->assertSeeLivewire(GetLogs::class); })->with([false, true])->with([false, true]); -it('enables sentinel from the logs page', function () { - $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); - StartSentinel::shouldRun()->once()->withArgs(function (Server $server, bool $restart): bool { - expect($server->id)->toBe($this->server->id); - expect($restart)->toBeTrue(); - $server->settings->update(['is_sentinel_enabled' => true]); - - return true; - }); +it('does not offer Sentinel controls or logs on unsupported servers', function (string $setting) { + $this->server->settings()->update([$setting => true]); Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) ->test(Logs::class) - ->call('enableSentinel') - ->assertDontSee('Sentinel is disabled') ->assertDontSee('Enable Sentinel') - ->assertSeeLivewire(GetLogs::class) - ->assertDispatched('refreshServerShow') - ->assertDispatched('success'); - - expect($this->server->fresh()->isSentinelEnabled())->toBeTrue(); -}); - -it('keeps sentinel disabled when startup fails', function () { - $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); - StartSentinel::shouldRun()->once()->andThrow(new RuntimeException('Startup failed')); - - Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) - ->test(Logs::class) - ->call('enableSentinel') - ->assertSee('Sentinel is disabled') - ->assertDontSeeLivewire(GetLogs::class) - ->assertDispatched('error') - ->assertNotDispatched('success'); - - expect($this->server->fresh()->isSentinelEnabled())->toBeFalse(); -}); - -it('does not enable sentinel on unsupported servers', function (string $setting) { - $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false, $setting => true]); - StartSentinel::shouldRun()->never(); - - Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) - ->test(Logs::class) - ->call('enableSentinel') - ->assertSee('Sentinel is disabled') - ->assertDispatched('error'); + ->assertDontSeeLivewire(GetLogs::class); })->with(['is_build_server', 'is_swarm_manager', 'is_swarm_worker']); - -it('denies enabling sentinel to members and users outside the server team', function (bool $crossTeam) { - $this->server->settings()->update(['is_sentinel_enabled' => false, 'is_metrics_enabled' => false]); - $user = User::factory()->create(); - if (! $crossTeam) { - $this->server->team->members()->attach($user->id, ['role' => 'member']); - } - $this->actingAs($user); - StartSentinel::shouldRun()->never(); - $component = new Logs; - $component->server = $this->server->fresh(); - - expect(fn () => $component->enableSentinel()) - ->toThrow(AuthorizationException::class); - expect($this->server->fresh()->isSentinelEnabled())->toBeFalse(); -})->with([false, true]); - -it('does not restart sentinel when it is already enabled', function () { - $this->server->settings()->update(['is_sentinel_enabled' => true, 'is_build_server' => false]); - StartSentinel::shouldRun()->never(); - - Livewire::withQueryParams(['server_uuid' => $this->server->uuid]) - ->test(Logs::class) - ->assertSeeLivewire(GetLogs::class) - ->call('enableSentinel') - ->assertNotDispatched('success'); -}); diff --git a/tests/Feature/SentinelMandatoryMigrationTest.php b/tests/Feature/SentinelMandatoryMigrationTest.php new file mode 100644 index 000000000..085ccd827 --- /dev/null +++ b/tests/Feature/SentinelMandatoryMigrationTest.php @@ -0,0 +1,41 @@ +create(); + $teamId = $user->teams()->first()->id; + + $regularServer = Server::factory()->create(['team_id' => $teamId]); + $regularServer->settings->update([ + 'is_sentinel_enabled' => false, + 'is_reachable' => true, + 'is_usable' => true, + ]); + + $buildServer = Server::factory()->create(['team_id' => $teamId]); + $buildServer->settings->update([ + 'is_sentinel_enabled' => false, + 'is_build_server' => true, + 'is_reachable' => true, + 'is_usable' => true, + ]); + + $unvalidatedServer = Server::factory()->create(['team_id' => $teamId]); + $unvalidatedServer->settings->update([ + 'is_sentinel_enabled' => false, + 'is_reachable' => false, + 'is_usable' => false, + ]); + + $migration = require database_path('migrations/2026_09_08_202212_enable_sentinel_for_existing_regular_servers.php'); + $migration->up(); + + expect((bool) $regularServer->settings->fresh()->is_sentinel_enabled)->toBeTrue() + ->and((bool) $buildServer->settings->fresh()->is_sentinel_enabled)->toBeFalse() + ->and((bool) $unvalidatedServer->settings->fresh()->is_sentinel_enabled)->toBeFalse(); +}); diff --git a/tests/Feature/ServerManagerJobShouldRunNowTest.php b/tests/Feature/ServerManagerJobShouldRunNowTest.php index 2743a8650..2bdd0e386 100644 --- a/tests/Feature/ServerManagerJobShouldRunNowTest.php +++ b/tests/Feature/ServerManagerJobShouldRunNowTest.php @@ -7,15 +7,15 @@ Cache::flush(); }); -it('catches delayed sentinel restart when job runs past midnight', function () { - Cache::put('sentinel-restart:1', Carbon::create(2026, 2, 27, 0, 0, 0, 'UTC')->toIso8601String(), 86400); +it('catches a delayed daily job when it runs past midnight', function () { + Cache::put('daily-job:1', Carbon::create(2026, 2, 27, 0, 0, 0, 'UTC')->toIso8601String(), 86400); // Job runs 3 minutes late at 00:03 Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 3, 0, 'UTC')); // isDue() would return false at 00:03, but getPreviousRunDate() = 00:00 today // lastDispatched = yesterday 00:00 → today 00:00 > yesterday → fires - $result = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:1'); + $result = shouldRunCronNow('0 0 * * *', 'UTC', 'daily-job:1'); expect($result)->toBeTrue(); }); @@ -63,26 +63,26 @@ // Step 1: 15:00 — not due for midnight cron, but seeds cache Carbon::setTestNow(Carbon::create(2026, 2, 28, 15, 0, 0, 'UTC')); - $result1 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test'); + $result1 = shouldRunCronNow('0 0 * * *', 'UTC', 'daily-job:seed-test'); expect($result1)->toBeFalse(); // Step 2: Next day at 00:05 — delayed 5 minutes past midnight // Catch-up: previousDue = Mar 1 00:00, lastDispatched = Feb 28 00:00 → fires Carbon::setTestNow(Carbon::create(2026, 3, 1, 0, 5, 0, 'UTC')); - $result2 = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:seed-test'); + $result2 = shouldRunCronNow('0 0 * * *', 'UTC', 'daily-job:seed-test'); expect($result2)->toBeTrue(); }); it('does not double-dispatch within same cron window', function () { Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 0, 0, 'UTC')); - $first = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10'); + $first = shouldRunCronNow('0 0 * * *', 'UTC', 'daily-job:10'); expect($first)->toBeTrue(); // Next minute — should NOT dispatch again Carbon::setTestNow(Carbon::create(2026, 2, 28, 0, 1, 0, 'UTC')); - $second = shouldRunCronNow('0 0 * * *', 'UTC', 'sentinel-restart:10'); + $second = shouldRunCronNow('0 0 * * *', 'UTC', 'daily-job:10'); expect($second)->toBeFalse(); }); diff --git a/tests/Unit/ServerManagerJobSentinelCheckTest.php b/tests/Unit/ServerManagerJobSentinelCheckTest.php index dc28d18fe..4666565fe 100644 --- a/tests/Unit/ServerManagerJobSentinelCheckTest.php +++ b/tests/Unit/ServerManagerJobSentinelCheckTest.php @@ -43,6 +43,15 @@ Queue::assertNotPushed(CheckAndStartSentinelJob::class); }); +it('does not schedule periodic Sentinel restart checks', function () { + $root = dirname(__DIR__, 2); + $manager = file_get_contents($root.'/app/Jobs/ServerManagerJob.php'); + $diagnostics = file_get_contents($root.'/app/Console/Commands/ScheduledJobDiagnostics.php'); + + expect($manager)->not->toContain('sentinel-restart:') + ->and($diagnostics)->not->toContain('sentinel-restart:'); +}); + it('skips ServerConnectionCheckJob when sentinel is live', function () { $settings = Mockery::mock(InstanceSettings::class); $settings->instance_timezone = 'UTC';