mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-26 09:20:54 -04:00
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:
@@ -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);');
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user