mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Aditya Tripathi <aditya@climactic.co> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
10 KiB
PHP
252 lines
10 KiB
PHP
<?php
|
|
|
|
use App\Actions\Server\StartSentinel;
|
|
use App\Enums\ServerRole;
|
|
use App\Events\SentinelRestarted;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\PrivateKey;
|
|
use App\Models\Server;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Symfony\Component\Process\Process as SymfonyProcess;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Queue::fake();
|
|
$user = User::factory()->create();
|
|
$this->team = $user->teams()->first();
|
|
});
|
|
|
|
it('produces no traffic env when disabled', function () {
|
|
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
|
$server->settings->is_traffic_analytics_enabled = false;
|
|
$server->settings->save();
|
|
expect(StartSentinel::sentinelTrafficEnvironment($server->fresh()))->toBe([]);
|
|
});
|
|
|
|
it('produces traffic + geoip env when enabled', function () {
|
|
$server = trafficProxyServer($this);
|
|
$server->settings->is_traffic_analytics_enabled = true;
|
|
$server->settings->geoip_maxmind_license_key = 'lic';
|
|
$server->settings->save();
|
|
|
|
$env = StartSentinel::sentinelTrafficEnvironment($server->fresh());
|
|
expect($env['TRAFFIC_ENABLED'])->toBe('true');
|
|
expect($env['TRAFFIC_PROXY_TYPE'])->toBe('auto');
|
|
expect($env['TRAFFIC_TOPN'])->toBe('50');
|
|
expect($env['TRAFFIC_SAMPLE_THRESHOLD'])->toBe('0');
|
|
expect($env['TRAFFIC_RETENTION_1H_DAYS'])->toBe('30');
|
|
expect($env['TRAFFIC_RETENTION_1D_DAYS'])->toBe('395');
|
|
expect($env['GEOIP_ENABLED'])->toBe('true');
|
|
expect($env['GEOIP_REFRESH_DAYS'])->toBe('30');
|
|
expect($env['GEOIP_MAXMIND_LICENSE_KEY'])->toBe('lic');
|
|
expect($env['TRAFFIC_IP_MODE'])->toBe('full');
|
|
expect($env)->toHaveKey('TRAFFIC_ACCESS_LOG_PATH');
|
|
});
|
|
|
|
it('passes the saved client IP mode as sentinel env', function (string $mode) {
|
|
$server = trafficProxyServer($this);
|
|
$server->settings->is_traffic_analytics_enabled = true;
|
|
$server->settings->traffic_ip_mode = $mode;
|
|
$server->settings->save();
|
|
|
|
expect(StartSentinel::sentinelTrafficEnvironment($server->fresh())['TRAFFIC_IP_MODE'])->toBe($mode);
|
|
})->with(['anonymized', 'off']);
|
|
|
|
it('uses the dev proxy volume for traffic logs only on the testing-host server locally', function (string $ip, string $directory) {
|
|
config()->set('app.env', 'local');
|
|
$server = trafficProxyServer($this);
|
|
$server->update(['ip' => $ip]);
|
|
$server->settings->is_traffic_analytics_enabled = true;
|
|
$server->settings->save();
|
|
|
|
expect(StartSentinel::trafficLogDirectory($server->fresh()))
|
|
->toBe($directory);
|
|
expect(StartSentinel::sentinelTrafficEnvironment($server->fresh())['TRAFFIC_ACCESS_LOG_PATH'])
|
|
->toBe("{$directory}/access.log");
|
|
})->with([
|
|
'dev KVM server' => ['10.221.1.10', '/data/coolify/proxy'],
|
|
'dev testing-host server' => [Server::DEV_TESTING_HOST_IP, '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy'],
|
|
]);
|
|
|
|
it('passes custom traffic settings as sentinel env', function () {
|
|
$server = trafficProxyServer($this);
|
|
$server->settings->is_traffic_analytics_enabled = true;
|
|
$server->settings->traffic_topn = 100;
|
|
$server->settings->traffic_sample_threshold = 500;
|
|
$server->settings->traffic_retention_1h_days = 14;
|
|
$server->settings->traffic_retention_1d_days = 180;
|
|
$server->settings->is_geoip_enabled = false;
|
|
$server->settings->geoip_refresh_days = 7;
|
|
// A key may still be stored while GeoIP is off; it must not reach Sentinel.
|
|
$server->settings->geoip_maxmind_license_key = 'secret-maxmind-key';
|
|
$server->settings->save();
|
|
|
|
$env = StartSentinel::sentinelTrafficEnvironment($server->fresh());
|
|
expect($env['TRAFFIC_TOPN'])->toBe('100');
|
|
expect($env['TRAFFIC_SAMPLE_THRESHOLD'])->toBe('500');
|
|
expect($env['TRAFFIC_RETENTION_1H_DAYS'])->toBe('14');
|
|
expect($env['TRAFFIC_RETENTION_1D_DAYS'])->toBe('180');
|
|
expect($env['GEOIP_ENABLED'])->toBe('false');
|
|
expect($env['GEOIP_REFRESH_DAYS'])->toBe('7');
|
|
expect($env)->not->toHaveKey('GEOIP_MAXMIND_LICENSE_KEY');
|
|
});
|
|
|
|
it('injects the maxmind license key only when geoip is enabled', function () {
|
|
$server = trafficProxyServer($this);
|
|
$server->settings->is_traffic_analytics_enabled = true;
|
|
$server->settings->is_geoip_enabled = true;
|
|
$server->settings->geoip_maxmind_license_key = 'secret-maxmind-key';
|
|
$server->settings->save();
|
|
|
|
$env = StartSentinel::sentinelTrafficEnvironment($server->fresh());
|
|
expect($env['GEOIP_MAXMIND_LICENSE_KEY'])->toBe('secret-maxmind-key');
|
|
});
|
|
|
|
function trafficProxyServer(object $test): Server
|
|
{
|
|
$server = Server::factory()->create(['team_id' => $test->team->id]);
|
|
$server->proxy->set('type', 'TRAEFIK');
|
|
$server->save();
|
|
|
|
return $server;
|
|
}
|
|
|
|
function sentinelTrafficServer(object $test, ?string $proxyType, bool $analyticsEnabled): Server
|
|
{
|
|
$server = Server::factory()->create([
|
|
'team_id' => $test->team->id,
|
|
'private_key_id' => PrivateKey::factory()->create(['team_id' => $test->team->id])->id,
|
|
]);
|
|
$server->proxy->set('type', $proxyType);
|
|
$server->save();
|
|
$server->settings->is_traffic_analytics_enabled = $analyticsEnabled;
|
|
$server->settings->sentinel_custom_url = 'https://coolify.example.com';
|
|
$server->settings->save();
|
|
|
|
return $server->fresh();
|
|
}
|
|
|
|
/**
|
|
* Runs StartSentinel against a faked SSH process and returns the remote script it sent.
|
|
*/
|
|
function runStartSentinelAndCaptureScript(Server $server): string
|
|
{
|
|
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
}
|
|
config(['constants.ssh.mux_enabled' => false]);
|
|
Event::fake([SentinelRestarted::class]);
|
|
$scripts = [];
|
|
Process::fake(function ($process) use (&$scripts) {
|
|
$scripts[] = is_array(remoteProcessScript($process)) ? implode(' ', remoteProcessScript($process)) : remoteProcessScript($process);
|
|
|
|
return Process::result(output: '');
|
|
});
|
|
|
|
StartSentinel::run($server, latestVersion: '1.0.1');
|
|
|
|
return collect($scripts)->first(fn (string $script): bool => str_contains($script, 'docker run -d'));
|
|
}
|
|
|
|
it('creates the access log before starting sentinel with traffic analytics', function (string $proxyType, string $directory) {
|
|
$server = sentinelTrafficServer($this, $proxyType, analyticsEnabled: true);
|
|
|
|
expect(StartSentinel::trafficLogPreparationCommands($server))->toBe([
|
|
'mkdir -p '.escapeshellarg($directory),
|
|
'touch '.escapeshellarg($directory.'/access.log'),
|
|
]);
|
|
|
|
$script = runStartSentinelAndCaptureScript($server);
|
|
$touch = 'touch '.escapeshellarg($directory.'/access.log');
|
|
|
|
expect($script)->toContain('mkdir -p '.escapeshellarg($directory))
|
|
->toContain($touch)
|
|
->not->toContain('> '.$directory.'/access.log')
|
|
->and(strpos($script, 'mkdir -p '.escapeshellarg($directory)))->toBeLessThan(strpos($script, $touch))
|
|
->and(strpos($script, $touch))->toBeLessThan(strpos($script, 'docker run -d'));
|
|
})->with([
|
|
'traefik' => ['TRAEFIK', '/data/coolify/proxy'],
|
|
'caddy' => ['CADDY', '/data/coolify/proxy/caddy'],
|
|
]);
|
|
|
|
it('starts sentinel with a restart policy so Docker restarts it after a crash or reboot', function () {
|
|
$script = runStartSentinelAndCaptureScript(sentinelTrafficServer($this, 'TRAEFIK', analyticsEnabled: false));
|
|
|
|
expect($script)->toContain('--name coolify-sentinel --restart unless-stopped ');
|
|
});
|
|
|
|
it('starts sentinel in the host user namespace so --pid host works on a userns-remap daemon', function () {
|
|
$script = runStartSentinelAndCaptureScript(sentinelTrafficServer($this, 'TRAEFIK', analyticsEnabled: false));
|
|
|
|
expect($script)->toContain('--userns host --pid host ');
|
|
});
|
|
|
|
it('does not touch the access log when traffic analytics cannot run', function (?string $proxyType, bool $analyticsEnabled) {
|
|
$server = sentinelTrafficServer($this, $proxyType, $analyticsEnabled);
|
|
|
|
expect(StartSentinel::trafficLogPreparationCommands($server))->toBe([]);
|
|
|
|
$script = runStartSentinelAndCaptureScript($server);
|
|
|
|
expect($script)->toContain('docker run -d')
|
|
->not->toContain('touch ')
|
|
->not->toContain('/data/coolify/proxy:/data/coolify/proxy')
|
|
->not->toContain('TRAFFIC_ENABLED');
|
|
})->with([
|
|
'analytics disabled' => ['TRAEFIK', false],
|
|
'proxy none' => ['NONE', true],
|
|
]);
|
|
|
|
it('does not touch the access log on swarm or build servers', function (string $setting, mixed $value) {
|
|
$server = sentinelTrafficServer($this, 'TRAEFIK', analyticsEnabled: true);
|
|
$server->settings->{$setting} = $value;
|
|
$server->settings->save();
|
|
|
|
expect(StartSentinel::trafficLogPreparationCommands($server->fresh()))->toBe([]);
|
|
})->with([
|
|
'swarm' => ['is_swarm_manager', true],
|
|
'build' => ['server_role', ServerRole::BUILD],
|
|
]);
|
|
|
|
it('keeps the access log commands valid for non-root servers', function () {
|
|
$server = sentinelTrafficServer($this, 'TRAEFIK', analyticsEnabled: true);
|
|
$server->user = 'ubuntu';
|
|
|
|
$commands = parseCommandsByLineForSudo(collect(StartSentinel::trafficLogPreparationCommands($server)), $server);
|
|
|
|
expect($commands)->toBe([
|
|
"sudo mkdir -p '/data/coolify/proxy'",
|
|
"sudo touch '/data/coolify/proxy/access.log'",
|
|
]);
|
|
|
|
$script = tempnam(sys_get_temp_dir(), 'sentinel-traffic');
|
|
file_put_contents($script, implode("\n", $commands)."\n");
|
|
$syntax = SymfonyProcess::fromShellCommandline('bash -n '.escapeshellarg($script));
|
|
$syntax->run();
|
|
unlink($script);
|
|
|
|
expect($syntax->isSuccessful())->toBeTrue($syntax->getErrorOutput());
|
|
});
|
|
|
|
it('mounts the configured dev data volume for traffic logs and Sentinel data on the testing-host server', function () {
|
|
config()->set('app.env', 'local');
|
|
config()->set('constants.coolify.dev_data_volume', 'coolify-dev-feature_coolify_data');
|
|
$server = sentinelTrafficServer($this, 'CADDY', analyticsEnabled: true);
|
|
$server->update(['ip' => Server::DEV_TESTING_HOST_IP]);
|
|
$server = $server->fresh();
|
|
$directory = '/var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/proxy/caddy';
|
|
|
|
$script = runStartSentinelAndCaptureScript($server);
|
|
|
|
expect(StartSentinel::trafficLogDirectory($server))->toBe($directory)
|
|
->and($script)->toContain(escapeshellarg("{$directory}:{$directory}:ro"))
|
|
->toContain(escapeshellarg("TRAFFIC_ACCESS_LOG_PATH={$directory}/access.log"))
|
|
->toContain('-v /var/lib/docker/volumes/coolify-dev-feature_coolify_data/_data/sentinel:/app/db')
|
|
->not->toContain('coolify_dev_coolify_data');
|
|
});
|