@@ -70,7 +93,7 @@
['value' => false, 'label' => 'Disabled'],
['value' => true, 'label' => 'Enabled'],
]"
- :disabled="$isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled || !auth()->user()->can('update', $server)" />
+ :disabled="$isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled || $isLogDrainCloudwatchEnabled || !auth()->user()->can('update', $server)" />
and($settings->logdrain_axiom_api_key)->toBe('axiom-key-123');
});
+ test('PATCH enables the Amazon CloudWatch Logs drain', function () {
+ Queue::fake();
+
+ $this->withHeaders(serverSubsystemsHeaders())
+ ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
+ 'is_logdrain_cloudwatch_enabled' => true,
+ 'logdrain_cloudwatch_group' => '/coolify/prod',
+ 'logdrain_cloudwatch_region' => 'us-east-1',
+ 'logdrain_cloudwatch_stream_prefix' => 'docker/',
+ ])
+ ->assertOk()
+ ->assertJsonPath('is_logdrain_cloudwatch_enabled', true)
+ ->assertJsonPath('logdrain_cloudwatch_group', '/coolify/prod')
+ ->assertJsonPath('logdrain_cloudwatch_stream_prefix', 'docker/');
+
+ $settings = $this->server->settings->fresh();
+ expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue()
+ ->and($settings->logdrain_cloudwatch_region)->toBe('us-east-1');
+ });
+
+ test('PATCH rejects enabling CloudWatch without a log group', function () {
+ Queue::fake();
+
+ $this->withHeaders(serverSubsystemsHeaders())
+ ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
+ 'is_logdrain_cloudwatch_enabled' => true,
+ 'logdrain_cloudwatch_region' => 'us-east-1',
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors('logdrain_cloudwatch_group');
+
+ expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse();
+ });
+
+ test('PATCH rejects enabling CloudWatch without a region', function () {
+ Queue::fake();
+
+ $this->withHeaders(serverSubsystemsHeaders())
+ ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
+ 'is_logdrain_cloudwatch_enabled' => true,
+ 'logdrain_cloudwatch_group' => 'coolify',
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors('logdrain_cloudwatch_region');
+
+ expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse();
+ });
+
+ test('PATCH rejects CloudWatch values that could break the compose file', function (string $field, mixed $value) {
+ Queue::fake();
+
+ $this->withHeaders(serverSubsystemsHeaders())
+ ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
+ $field => $value,
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors($field);
+ })->with([
+ 'prefix with interpolation' => ['logdrain_cloudwatch_stream_prefix', 'docker/${HOME}'],
+ 'prefix with colon' => ['logdrain_cloudwatch_stream_prefix', 'docker:'],
+ 'prefix with template braces' => ['logdrain_cloudwatch_stream_prefix', '{{.ID}}'],
+ 'invalid region' => ['logdrain_cloudwatch_region', 'not a region'],
+ 'group with spaces' => ['logdrain_cloudwatch_group', 'my group'],
+ ]);
+
+ test('PATCH rejects enabling CloudWatch together with another log drain', function () {
+ Queue::fake();
+ $this->server->settings->update([
+ 'is_logdrain_axiom_enabled' => true,
+ 'logdrain_axiom_dataset_name' => 'ds',
+ 'logdrain_axiom_api_key' => 'key',
+ ]);
+
+ $this->withHeaders(serverSubsystemsHeaders())
+ ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
+ 'is_logdrain_cloudwatch_enabled' => true,
+ 'logdrain_cloudwatch_group' => 'coolify',
+ 'logdrain_cloudwatch_region' => 'us-east-1',
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors('is_logdrain_cloudwatch_enabled');
+ });
+
test('other-team log drains endpoints return 404', function () {
$this->withHeaders(serverSubsystemsHeaders())
->getJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains")
diff --git a/tests/Feature/LogDrain/CloudwatchLogDrainTest.php b/tests/Feature/LogDrain/CloudwatchLogDrainTest.php
new file mode 100644
index 0000000000..26034973b9
--- /dev/null
+++ b/tests/Feature/LogDrain/CloudwatchLogDrainTest.php
@@ -0,0 +1,244 @@
+user = User::factory()->create();
+ $this->team = $this->user->teams()->first();
+ $this->server = Server::factory()->create(['team_id' => $this->team->id]);
+
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+});
+
+it('enables the CloudWatch drain and persists its options', function () {
+ StartLogDrain::mock()->shouldReceive('handle')->once()->andReturn('ok');
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainCloudwatchGroup', '/coolify/prod')
+ ->set('logDrainCloudwatchRegion', 'us-east-1')
+ ->set('logDrainCloudwatchStreamPrefix', 'docker/')
+ ->call('toggleLogDrain', 'cloudwatch')
+ ->assertHasNoErrors()
+ ->assertSet('isLogDrainCloudwatchEnabled', true);
+
+ $settings = $this->server->settings->fresh();
+ expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue()
+ ->and($settings->logdrain_cloudwatch_group)->toBe('/coolify/prod')
+ ->and($settings->logdrain_cloudwatch_region)->toBe('us-east-1')
+ ->and($settings->logdrain_cloudwatch_stream_prefix)->toBe('docker/');
+});
+
+it('requires a log group before enabling CloudWatch', function () {
+ StartLogDrain::mock()->shouldNotReceive('handle');
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('isLogDrainCloudwatchEnabled', true)
+ ->call('instantSave')
+ ->assertDispatched('error')
+ ->assertSet('isLogDrainCloudwatchEnabled', false);
+
+ expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse();
+});
+
+it('requires a region before enabling CloudWatch', function () {
+ StartLogDrain::mock()->shouldNotReceive('handle');
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainCloudwatchGroup', 'coolify')
+ ->call('toggleLogDrain', 'cloudwatch')
+ ->assertDispatched('error')
+ ->assertSet('isLogDrainCloudwatchEnabled', false);
+
+ expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse();
+});
+
+it('rejects stream prefixes that could break the compose file', function (string $prefix) {
+ StartLogDrain::mock()->shouldNotReceive('handle');
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainCloudwatchGroup', 'coolify')
+ ->set('logDrainCloudwatchRegion', 'us-east-1')
+ ->set('logDrainCloudwatchStreamPrefix', $prefix)
+ ->set('isLogDrainCloudwatchEnabled', true)
+ ->call('instantSave')
+ ->assertDispatched('error')
+ ->assertSet('isLogDrainCloudwatchEnabled', false);
+
+ expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse();
+})->with(['docker/${HOME}', 'docker:', '{{.ID}}', 'with space']);
+
+it('enables the CloudWatch drain from the status dropdown', function () {
+ StartLogDrain::mock()->shouldReceive('handle')->once()->andReturn('ok');
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainCloudwatchGroup', 'coolify')
+ ->set('logDrainCloudwatchRegion', 'us-east-1')
+ ->set('logDrainCloudwatchStreamPrefix', 'docker/')
+ ->set('isLogDrainCloudwatchEnabled', true)
+ ->call('instantSave')
+ ->assertDispatched('success');
+
+ $settings = $this->server->settings->fresh();
+ expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue()
+ ->and($settings->logdrain_cloudwatch_stream_prefix)->toBe('docker/');
+});
+
+it('disables other drains when CloudWatch is enabled', function () {
+ StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok');
+ $this->server->settings->update([
+ 'is_logdrain_newrelic_enabled' => true,
+ 'logdrain_newrelic_license_key' => 'abc123',
+ 'logdrain_newrelic_base_uri' => 'https://log-api.newrelic.com',
+ ]);
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainCloudwatchGroup', 'coolify')
+ ->set('logDrainCloudwatchRegion', 'us-east-1')
+ ->call('toggleLogDrain', 'cloudwatch')
+ ->assertSet('isLogDrainNewRelicEnabled', false)
+ ->assertSet('isLogDrainCloudwatchEnabled', true);
+
+ $settings = $this->server->settings->fresh();
+ expect($settings->is_logdrain_newrelic_enabled)->toBeFalsy()
+ ->and($settings->is_logdrain_cloudwatch_enabled)->toBeTrue();
+});
+
+it('reverts the CloudWatch flag when starting the drain fails', function () {
+ StartLogDrain::mock()->shouldReceive('handle')->andThrow(new RuntimeException('runtime boom'));
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainCloudwatchGroup', 'coolify')
+ ->set('logDrainCloudwatchRegion', 'us-east-1')
+ ->call('toggleLogDrain', 'cloudwatch')
+ ->assertSet('isLogDrainCloudwatchEnabled', false);
+
+ expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse();
+});
+
+it('forbids team members from enabling CloudWatch', function () {
+ StartLogDrain::mock()->shouldNotReceive('handle');
+ $member = User::factory()->create();
+ $member->teams()->attach($this->team, ['role' => 'member']);
+ $this->actingAs($member);
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainCloudwatchGroup', 'coolify')
+ ->set('logDrainCloudwatchRegion', 'us-east-1')
+ ->call('toggleLogDrain', 'cloudwatch');
+
+ expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse();
+});
+
+it('does not deploy the Fluent Bit container for CloudWatch', function () {
+ StopLogDrain::mock()->shouldReceive('handle')->once()->andReturn('removed');
+ $this->server->settings->update([
+ 'is_logdrain_cloudwatch_enabled' => true,
+ 'logdrain_cloudwatch_group' => 'coolify',
+ 'logdrain_cloudwatch_region' => 'us-east-1',
+ ]);
+
+ $result = StartLogDrain::run($this->server->fresh());
+
+ expect($result)->toContain('awslogs');
+});
+
+it('turns off the legacy Highlight drain when CloudWatch is enabled', function () {
+ StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok');
+ $this->server->settings->update([
+ 'is_logdrain_highlight_enabled' => true,
+ 'logdrain_highlight_project_id' => 'legacy-project',
+ ]);
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainCloudwatchGroup', 'coolify')
+ ->set('logDrainCloudwatchRegion', 'us-east-1')
+ ->set('isLogDrainCloudwatchEnabled', true)
+ ->call('instantSave')
+ ->assertDispatched('success');
+
+ $settings = $this->server->settings->fresh();
+ expect($settings->is_logdrain_highlight_enabled)->toBeFalsy()
+ ->and($settings->is_logdrain_cloudwatch_enabled)->toBeTrue();
+});
+
+it('turns off the legacy Highlight drain when switching drains with toggleLogDrain', function () {
+ StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok');
+ $this->server->settings->update(['is_logdrain_highlight_enabled' => true]);
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainCloudwatchGroup', 'coolify')
+ ->set('logDrainCloudwatchRegion', 'us-east-1')
+ ->call('toggleLogDrain', 'cloudwatch');
+
+ $settings = $this->server->settings->fresh();
+ expect($settings->is_logdrain_highlight_enabled)->toBeFalsy()
+ ->and($settings->is_logdrain_cloudwatch_enabled)->toBeTrue();
+});
+
+it('keeps the legacy Highlight drain when saving without enabling another drain', function () {
+ StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok');
+ $this->server->settings->update(['is_logdrain_highlight_enabled' => true]);
+
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainCloudwatchGroup', 'coolify')
+ ->set('logDrainCloudwatchRegion', 'us-east-1')
+ ->call('submit')
+ ->assertDispatched('success');
+
+ expect($this->server->settings->fresh()->is_logdrain_highlight_enabled)->toBeTruthy();
+});
+
+it('rejects a save that would enable two log drains at once', function () {
+ StartLogDrain::mock()->shouldNotReceive('handle');
+ $this->server->settings->update([
+ 'is_logdrain_newrelic_enabled' => true,
+ 'logdrain_newrelic_license_key' => 'abc123',
+ 'logdrain_newrelic_base_uri' => 'https://log-api.newrelic.com',
+ ]);
+
+ // The UI disables the CloudWatch dropdown here; this simulates a crafted request.
+ Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
+ ->set('logDrainCloudwatchGroup', 'coolify')
+ ->set('logDrainCloudwatchRegion', 'us-east-1')
+ ->set('isLogDrainCloudwatchEnabled', true)
+ ->call('instantSave')
+ ->assertDispatched('error')
+ ->assertSet('isLogDrainCloudwatchEnabled', false)
+ ->assertSet('isLogDrainNewRelicEnabled', true);
+
+ $settings = $this->server->settings->fresh();
+ expect($settings->is_logdrain_cloudwatch_enabled)->toBeFalse()
+ ->and($settings->is_logdrain_newrelic_enabled)->toBeTruthy();
+});
+
+it('keeps the Fluent Bit drain running if CloudWatch is also enabled, matching the containers logging config', function () {
+ Process::fake();
+ StopLogDrain::mock()->shouldReceive('handle')->andReturn('removed');
+ $this->server->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id]);
+ $this->server->settings->update([
+ 'is_logdrain_axiom_enabled' => true,
+ 'logdrain_axiom_dataset_name' => 'ds',
+ 'logdrain_axiom_api_key' => 'key',
+ 'is_logdrain_cloudwatch_enabled' => true,
+ 'logdrain_cloudwatch_group' => 'coolify',
+ 'logdrain_cloudwatch_region' => 'us-east-1',
+ ]);
+ $server = $this->server->fresh();
+
+ expect(generate_log_drain_configuration($server)['driver'])->toBe('fluentd');
+
+ StartLogDrain::run($server);
+
+ Process::assertRan(fn ($process) => str_contains($process->command, 'log-drains/docker-compose.yml up -d'));
+});
diff --git a/tests/Feature/StandaloneSqliteTest.php b/tests/Feature/StandaloneSqliteTest.php
index b63b9917e5..845d88e5da 100644
--- a/tests/Feature/StandaloneSqliteTest.php
+++ b/tests/Feature/StandaloneSqliteTest.php
@@ -77,6 +77,32 @@ it('generates a compose file that passes the database files to the image', funct
->and($service['volumes'])->toBe(['sqlite-data-'.$this->database->uuid.':/var/lib/sqlite']);
});
+it('uses the server log drain driver when log draining is enabled', function () {
+ $commands = [];
+ $executor = Mockery::mock(DatabaseStartCommandExecutor::class);
+ $executor->shouldReceive('execute')->once()->withArgs(function (array $captured) use (&$commands) {
+ $commands = $captured;
+
+ return true;
+ })->andReturn(new Activity);
+ app()->instance(DatabaseStartCommandExecutor::class, $executor);
+ $this->server->settings->update([
+ 'is_logdrain_cloudwatch_enabled' => true,
+ 'logdrain_cloudwatch_region' => 'us-east-1',
+ 'logdrain_cloudwatch_group' => 'coolify',
+ ]);
+ $this->database->update(['is_log_drain_enabled' => true]);
+ Server::flushIdentityMap();
+
+ (new StartSqlite)->handle($this->database->fresh(), new Activity);
+
+ preg_match("/^echo '([^']+)' \| base64 -d/", collect($commands)->first(fn (string $command) => str_contains($command, 'docker-compose.yml')), $matches);
+ $service = Yaml::parse(base64_decode($matches[1]))['services'][$this->database->uuid];
+
+ expect($service['logging']['driver'])->toBe('awslogs')
+ ->and($service['logging']['options']['awslogs-group'])->toBe('coolify');
+});
+
it('restores a gzipped backup with .restore into the first database file', function () {
$command = app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->database, '/tmp/restore_1', false);
diff --git a/tests/Unit/GenerateLogDrainConfigurationTest.php b/tests/Unit/GenerateLogDrainConfigurationTest.php
new file mode 100644
index 0000000000..0ac5eb05b2
--- /dev/null
+++ b/tests/Unit/GenerateLogDrainConfigurationTest.php
@@ -0,0 +1,77 @@
+setRelation('settings', new ServerSetting($attributes));
+
+ return $server;
+}
+
+it('uses the fluentd driver when CloudWatch is not the active drain', function () {
+ $server = serverWithLogDrainSettings(['is_logdrain_axiom_enabled' => true]);
+
+ expect(generate_log_drain_configuration($server))->toBe(generate_fluentd_configuration());
+});
+
+it('uses the awslogs driver with every configured option', function () {
+ $server = serverWithLogDrainSettings([
+ 'is_logdrain_cloudwatch_enabled' => true,
+ 'logdrain_cloudwatch_region' => 'eu-west-1',
+ 'logdrain_cloudwatch_group' => '/coolify/prod',
+ 'logdrain_cloudwatch_stream_prefix' => 'docker/',
+ ]);
+
+ expect(generate_log_drain_configuration($server))->toBe([
+ 'driver' => 'awslogs',
+ 'options' => [
+ 'awslogs-region' => 'eu-west-1',
+ 'awslogs-group' => '/coolify/prod',
+ 'awslogs-create-group' => 'true',
+ 'mode' => 'non-blocking',
+ 'tag' => 'docker/{{.Name}}',
+ ],
+ ]);
+});
+
+it('names streams after the container when no prefix is set', function () {
+ $server = serverWithLogDrainSettings([
+ 'is_logdrain_cloudwatch_enabled' => true,
+ 'logdrain_cloudwatch_region' => 'us-east-1',
+ 'logdrain_cloudwatch_group' => 'coolify',
+ ]);
+
+ expect(generate_log_drain_configuration($server)['options']['tag'])->toBe('{{.Name}}');
+});
+
+it('does not fall back to a default region', function () {
+ $server = serverWithLogDrainSettings([
+ 'is_logdrain_cloudwatch_enabled' => true,
+ 'logdrain_cloudwatch_group' => 'coolify',
+ ]);
+
+ expect(generate_log_drain_configuration($server)['options'])->not->toHaveKey('awslogs-region');
+});
+
+it('never sets awslogs-stream because it would override the container-name tag', function () {
+ $server = serverWithLogDrainSettings([
+ 'is_logdrain_cloudwatch_enabled' => true,
+ 'logdrain_cloudwatch_group' => 'coolify',
+ 'logdrain_cloudwatch_stream_prefix' => 'apps/',
+ ]);
+
+ expect(generate_log_drain_configuration($server)['options'])->not->toHaveKey('awslogs-stream');
+});
+
+it('reports CloudWatch as an enabled drain that does not use Fluent Bit', function () {
+ $cloudwatch = serverWithLogDrainSettings(['is_logdrain_cloudwatch_enabled' => true]);
+ $axiom = serverWithLogDrainSettings(['is_logdrain_axiom_enabled' => true]);
+
+ expect($cloudwatch->isLogDrainEnabled())->toBeTrue()
+ ->and($cloudwatch->isFluentBitLogDrainEnabled())->toBeFalse()
+ ->and($axiom->isLogDrainEnabled())->toBeTrue()
+ ->and($axiom->isFluentBitLogDrainEnabled())->toBeTrue();
+});