Files
coolify/tests/Feature/TrafficAnalytics/TraefikLogrotateSidecarTest.php
Andras Bacsai 165d2f3dc8 fix: harden traffic analytics, terminal errors and Postgres restores
Traffic analytics:
- Reject enabling unless the server runs a Coolify-managed Traefik or
  Caddy proxy, and show the reason in the settings UI
- Save the proxy configuration before the setting so a failure leaves
  both unchanged; allow disabling after the proxy was removed
- Skip the proxy restart when the proxy is stopped and report that the
  config applies on next start
- Preserve the user's own Traefik --accesslog* flags and restore them
  when analytics is disabled
- Rotate the Traefik access log in the sidecar with BusyBox tools
  (copytruncate, 5 gzip rotations)
- Create the access log before starting Sentinel, which opens it once

Terminal:
- Detect and surface WebSocket connection rejections in the browser
  terminal, with shared helpers in terminal-connection.js and
  terminal-utils.js

Database import:
- Restore PostgreSQL backups in a single transaction; SQL replace
  restores go into a temporary database and swap in only on success,
  leaving the current database untouched on failure
2026-09-25 22:58:07 +02:00

157 lines
6.2 KiB
PHP

<?php
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
beforeEach(function () {
// generateDefaultProxyConfiguration() synchronously persists the config to the
// server over SSH (SaveProxyConfiguration); fake the process layer so tests
// don't attempt a real SSH connection.
Process::fake();
$user = User::factory()->create();
$this->team = $user->teams()->first();
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
});
it('uses the latest stable traefik branch for new proxy configurations', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$config = Yaml::parse(generateDefaultProxyConfiguration($server->fresh()));
expect($config['services']['traefik']['image'])->toBe('traefik:v3.7');
});
it('does not expose the proxy container through its own docker provider', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$config = Yaml::parse(generateDefaultProxyConfiguration($server->fresh()));
$labels = $config['services']['traefik']['labels'];
expect($labels)->toContain('traefik.enable=false')
->not->toContain('traefik.enable=true')
->and(collect($labels)->contains(fn (string $label): bool => str_starts_with($label, 'traefik.http.routers.traefik.')))->toBeFalse();
});
it('preserves intentional self-router labels when updating an existing proxy configuration', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$configuration = applyTrafficAnalyticsToProxyConfiguration($server->fresh(), <<<'YAML'
services:
traefik:
command: []
labels:
- traefik.enable=true
- traefik.http.routers.traefik.entrypoints=http
- traefik.http.routers.traefik.service=api@internal
- traefik.http.services.traefik.loadbalancer.server.port=8080
- coolify.managed=true
deploy:
labels:
- traefik.enable=true
- traefik.http.routers.traefik.entrypoints=http
- traefik.http.routers.traefik.service=api@internal
- traefik.http.services.traefik.loadbalancer.server.port=8080
- coolify.proxy=true
YAML);
$traefik = Yaml::parse($configuration)['services']['traefik'];
expect($traefik['labels'])->toContain(
'traefik.enable=true',
'traefik.http.routers.traefik.service=api@internal',
'coolify.managed=true',
)->and($traefik['deploy']['labels'])->toContain(
'traefik.enable=true',
'traefik.http.routers.traefik.service=api@internal',
'coolify.proxy=true',
);
});
it('does not add a traefik-logrotate sidecar when traffic analytics is disabled', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$server->settings->is_traffic_analytics_enabled = false;
$server->settings->save();
$yaml = generateDefaultProxyConfiguration($server->fresh());
expect($yaml)->not->toContain('traefik-logrotate');
$config = Yaml::parse($yaml);
expect($config['services'])->not->toHaveKey('traefik-logrotate');
});
it('adds a traefik-logrotate sidecar with the proxy mount when enabled', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
$server = $server->fresh();
$yaml = generateDefaultProxyConfiguration($server);
expect($yaml)->toContain('traefik-logrotate');
$config = Yaml::parse($yaml);
$sidecar = $config['services']['traefik-logrotate'];
expect($sidecar['container_name'])->toBe('coolify-proxy-logrotate');
expect($sidecar['image'])->toBe('alpine:3.24');
expect($sidecar['network_mode'])->toBe('none');
expect($sidecar['volumes'])->toContain($server->proxyPath().':/traefik');
expect($sidecar['labels'])->toContain('coolify.managed=true');
expect($sidecar['entrypoint'])->toHaveCount(3)
->and($sidecar['entrypoint'][0])->toBe('/bin/sh')
->and($sidecar['entrypoint'][1])->toBe('-c');
});
it('rotates the access log with busybox tools only, without installing packages', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
$yaml = generateDefaultProxyConfiguration($server->fresh());
$script = Yaml::parse($yaml)['services']['traefik-logrotate']['entrypoint'][2];
expect($yaml)->not->toContain('apk add')
->not->toContain('apk ')
->not->toContain('logrotate -s');
expect($script)->toContain('while true; do')
->toContain('stat -c %s')
->toContain('20971520')
->toContain('keep=5')
->toContain('gzip -f')
->toContain(': > "$$log"');
});
it('escapes every dollar sign in the rotation script from docker compose interpolation', function () {
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
$server->proxy->set('type', 'TRAEFIK');
$server->save();
$server->settings->is_traffic_analytics_enabled = true;
$server->settings->save();
$script = Yaml::parse(generateDefaultProxyConfiguration($server->fresh()))['services']['traefik-logrotate']['entrypoint'][2];
expect(str_replace('$$', '', $script))->not->toContain('$')
->and(str_replace('$$', '$', $script))->toBe(traefikAccessLogRotationScript());
});