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 213 additions and 292 deletions
@@ -12,6 +12,11 @@ use Illuminate\Support\Facades\Queue;
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 @@ describe('Sentinel API', function () {
->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")
@@ -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 @@ use Illuminate\Support\Facades\DB;
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 @@ it('does not start Sentinel after it has been disabled', function () {
'is_sentinel_enabled' => false,
]);
(new CheckAndStartSentinelJob($server))->handle();
expect($server->fresh()->isSentinelEnabled())->toBeTrue();
});
expect((bool) $server->settings->fresh()->is_sentinel_enabled)->toBeFalse();
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();
});
@@ -10,25 +10,13 @@ it('keeps sentinel restarted events from re-syncing editable form fields', funct
->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 @@ it('tells the user that saving sentinel settings initiates a restart', function
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);');
});
+11 -82
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 @@ beforeEach(function () {
$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');
});
@@ -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();
});
@@ -7,15 +7,15 @@ beforeEach(function () {
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 @@ it('daily cron fires after cache seed even when delayed past the minute', functi
// 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();
});
@@ -43,6 +43,15 @@ it('does not dispatch CheckAndStartSentinelJob hourly anymore', function () {
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';