feat(sentinel): make sentinel mandatory on regular servers

Remove the enable/disable toggle from the server UI, logs page, and
Sentinel API so is_sentinel_enabled is derived and read-only. Enable
existing regular servers via migration, start Sentinel after validate-
and-install, and drop the daily ServerManagerJob restart.
This commit is contained in:
Andras Bacsai 2026-09-09 06:30:32 +02:00
parent d71a72a45d
commit 424dbd36ff
23 changed files with 214 additions and 293 deletions

View file

@ -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');

View file

@ -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', [

View file

@ -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.
}

View file

@ -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', [

View file

@ -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 {

View file

@ -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');

View file

@ -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 {

View file

@ -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) {

View file

@ -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;
}

View file

@ -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;
}

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('server_settings')
->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.
}
};

View file

@ -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());

View file

@ -25,7 +25,7 @@
</a>
</x-slot:actions>
<x-callout type="info" title="Metrics are not enabled">
Enable Sentinel and metrics for this server before collecting application usage data.
Enable metrics for this server before collecting application usage data.
</x-callout>
</x-application.settings-section>
@elseif (!str($resource->status)->contains('running'))

View file

@ -276,14 +276,14 @@ class="server-settings-workspace application-settings-workspace mt-4 grid w-full
@else
<x-application.settings-section id="server-metrics-overview-section" title="Metrics"
helper="Inspect recent CPU and memory usage reported by Sentinel.">
<x-empty size="sm" title="Sentinel is required"
description="Enable Sentinel before collecting CPU and memory metrics for this server."
<x-empty size="sm" title="Metrics unavailable"
description="Sentinel metrics are unavailable on build and Swarm servers."
icon-name="dashboard">
<x-slot:contents>
<a class="button"
href="{{ route('server.sentinel', ['server_uuid' => $server->uuid]) }}"
{{ wireNavigate() }}>
Configure Sentinel
View Sentinel
<x-external-link />
</a>
</x-slot:contents>

View file

@ -1,44 +1,31 @@
<div class="application-settings-form flex w-full flex-col gap-6">
<form wire:submit.prevent="submit" class="contents">
@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. --}}
<x-unsaved-bar action="submit"
targets="sentinelCustomUrl,sentinelToken,sentinelMetricsRefreshRateSeconds,sentinelMetricsHistoryDays,sentinelPushIntervalSeconds" />
@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. --}}
<x-unsaved-bar action="submit"
targets="sentinelCustomUrl,sentinelToken,sentinelMetricsRefreshRateSeconds,sentinelMetricsHistoryDays,sentinelPushIntervalSeconds" />
<x-application.settings-section id="server-sentinel-overview-section" title="Sentinel"
helper="Monitor server and container health while collecting historical metrics.">
<x-slot:actions>
<div class="flex items-center gap-2">
@if (!$isSentinelEnabled)
<x-forms.button canGate="update" :canResource="$server" isHighlighted
wire:click="toggleSentinel">
Enable Sentinel
</x-forms.button>
@else
<x-status-badge :status="$server->isSentinelLive() ? 'In sync' : 'Out of sync'"
:type="$server->isSentinelLive() ? 'success' : 'warning'" />
<x-forms.button wire:click="restartSentinel" canGate="update"
:canResource="$server">
<x-reicon name="refresh" class="size-3.5" />
{{ $server->isSentinelLive() ? 'Restart' : 'Sync' }}
</x-forms.button>
<x-forms.button canGate="update" :canResource="$server"
wire:click="toggleSentinel">
Disable
</x-forms.button>
@endif
<x-status-badge :status="$server->isSentinelLive() ? 'In sync' : 'Out of sync'"
:type="$server->isSentinelLive() ? 'success' : 'warning'" />
<x-forms.button wire:click="restartSentinel" canGate="update"
:canResource="$server">
<x-reicon name="refresh" class="size-3.5" />
{{ $server->isSentinelLive() ? 'Restart' : 'Sync' }}
</x-forms.button>
</div>
</x-slot:actions>
@if ($isSentinelEnabled && !$server->isSentinelLive())
@if (!$server->isSentinelLive())
<x-callout type="warning" title="Sentinel is out of sync">
Sync Sentinel to apply its current configuration and restore health reporting.
</x-callout>
@elseif ($isSentinelEnabled)
@else
<div class="flex items-start gap-3">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500 dark:bg-white/[0.06] dark:text-fg-dim">
@ -51,10 +38,6 @@ class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-10
</p>
</div>
</div>
@else
<x-empty size="sm" title="Sentinel is disabled"
description="Enable Sentinel to collect metrics and monitor server and container health."
icon-name="dashboard" />
@endif
</x-application.settings-section>

View file

@ -21,14 +21,8 @@ class="logs-section-status-badge" />
displayName="Sentinel" :collapsible="false" />
</div>
@else
<x-slot:actions>
<x-forms.button canGate="manageSentinel" :canResource="$server" isHighlighted
wire:click="enableSentinel">
Enable Sentinel
</x-forms.button>
</x-slot:actions>
<x-empty size="sm" title="Sentinel is disabled"
description="Enable Sentinel to view its logs."
<x-empty size="sm" title="Sentinel is unavailable"
description="Sentinel does not run on build or Swarm servers."
icon-name="dashboard" />
@endif
</x-application.settings-section>

View file

@ -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")

View file

@ -1,6 +1,5 @@
<?php
use App\Jobs\CheckAndStartSentinelJob;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -8,7 +7,7 @@
uses(RefreshDatabase::class);
it('does not start Sentinel after it has been disabled', function () {
it('treats Sentinel as enabled for regular servers even when the legacy flag and metrics are disabled', function () {
DB::table('instance_settings')->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();
});

View file

@ -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\([^)]*\).*?\{(?<body>.*?)
\}/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\([^)]*\).*?\{(?<body>.*?)\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);');
});

View file

@ -1,13 +1,11 @@
<?php
use App\Actions\Server\StartSentinel;
use App\Livewire\Project\Shared\GetLogs;
use App\Livewire\Server\Sentinel\Logs;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -23,105 +21,36 @@
$this->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');
});

View file

@ -0,0 +1,41 @@
<?php
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('enables Sentinel only for existing active regular servers', function () {
$user = User::factory()->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();
});

View file

@ -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();
});

View file

@ -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';