From 0bc1288e81e4e3a81def6f2754653d99450cbe9a Mon Sep 17 00:00:00 2001 From: Maxwell <136101+mxswd@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:20:42 +1000 Subject: [PATCH] Added docker awslogs driver to log drains (#11972) Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> --- app/Actions/Database/StartClickhouse.php | 2 +- app/Actions/Database/StartDragonfly.php | 2 +- app/Actions/Database/StartKeydb.php | 2 +- app/Actions/Database/StartMariadb.php | 2 +- app/Actions/Database/StartMongodb.php | 2 +- app/Actions/Database/StartMysql.php | 2 +- app/Actions/Database/StartPostgresql.php | 2 +- app/Actions/Database/StartRedis.php | 2 +- app/Actions/Database/StartSqlite.php | 2 +- app/Actions/Server/StartLogDrain.php | 10 +- app/Actions/Service/StartService.php | 2 +- .../Api/ServerLogDrainsController.php | 41 +++ app/Jobs/ApplicationDeploymentJob.php | 2 +- app/Jobs/PushServerUpdateJob.php | 2 +- app/Jobs/ServerCheckJob.php | 2 +- app/Livewire/Server/LogDrains.php | 78 ++++++ app/Models/Server.php | 8 + app/Models/ServerSetting.php | 9 + bootstrap/helpers/parsers.php | 4 +- bootstrap/helpers/shared.php | 29 ++- ...tch_log_drain_to_server_settings_table.php | 36 +++ openapi.json | 56 ++++ openapi.yaml | 21 ++ .../livewire/server/log-drains.blade.php | 29 ++- tests/Feature/Api/ServerSubsystemsApiTest.php | 83 ++++++ .../LogDrain/CloudwatchLogDrainTest.php | 244 ++++++++++++++++++ tests/Feature/StandaloneSqliteTest.php | 26 ++ .../GenerateLogDrainConfigurationTest.php | 77 ++++++ 28 files changed, 756 insertions(+), 21 deletions(-) create mode 100644 database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php create mode 100644 tests/Feature/LogDrain/CloudwatchLogDrainTest.php create mode 100644 tests/Unit/GenerateLogDrainConfigurationTest.php diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index d053142d2c..bcf5e051f7 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -81,7 +81,7 @@ class StartClickhouse data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { $docker_compose['services'][$container_name]['ports'] = $this->database->ports_mappings_array; diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 16ad09c95d..0bef866db7 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -130,7 +130,7 @@ class StartDragonfly } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index fa86576d5e..f7066fc1f8 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -132,7 +132,7 @@ class StartKeydb } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index bf5739a86b..6f67500801 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -123,7 +123,7 @@ class StartMariadb } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 0afb0c4a6f..5d748cd4f4 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -130,7 +130,7 @@ class StartMongodb } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index ea723aada7..01e8ff5edf 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -125,7 +125,7 @@ class StartMysql } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index c0949e51b8..ca22dcfebb 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -131,7 +131,7 @@ class StartPostgresql } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index c12381aca8..6eecbec902 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -133,7 +133,7 @@ class StartRedis } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($this->database->ports_mappings_array) > 0) { diff --git a/app/Actions/Database/StartSqlite.php b/app/Actions/Database/StartSqlite.php index 9289cdf5a0..6cc806dc5e 100644 --- a/app/Actions/Database/StartSqlite.php +++ b/app/Actions/Database/StartSqlite.php @@ -69,7 +69,7 @@ class StartSqlite data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { - $docker_compose['services'][$container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$container_name]['logging'] = generate_log_drain_configuration($this->database->destination->server); } if (count($persistent_storages) > 0) { $docker_compose['services'][$container_name]['volumes'] = $persistent_storages; diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index 8c0922642f..e72ddef02d 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -22,9 +22,17 @@ class StartLogDrain $type = 'axiom'; } elseif ($server->settings->is_logdrain_custom_enabled) { $type = 'custom'; + } elseif ($server->settings->is_logdrain_cloudwatch_enabled) { + $type = 'cloudwatch'; } else { $type = 'none'; } + if ($type === 'cloudwatch') { + // Containers ship logs directly via Docker's awslogs driver; no Fluent Bit container is needed. + StopLogDrain::run($server); + + return 'Amazon CloudWatch Logs uses the built-in Docker awslogs driver.'; + } if ($type !== 'none') { StopLogDrain::run($server); } @@ -212,7 +220,7 @@ Files: private function logDrainNetworkConnectCommands(Server $server): array { - if (! $server->isLogDrainEnabled()) { + if (! $server->isFluentBitLogDrainEnabled()) { return []; } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index a5812ced35..3823c71661 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -116,7 +116,7 @@ class StartService return []; } - if (! $service->destination?->server?->isLogDrainEnabled()) { + if (! $service->destination?->server?->isFluentBitLogDrainEnabled()) { return []; } diff --git a/app/Http/Controllers/Api/ServerLogDrainsController.php b/app/Http/Controllers/Api/ServerLogDrainsController.php index e56d6dfb2c..f07d95cc86 100644 --- a/app/Http/Controllers/Api/ServerLogDrainsController.php +++ b/app/Http/Controllers/Api/ServerLogDrainsController.php @@ -22,6 +22,10 @@ class ServerLogDrainsController extends Controller 'is_logdrain_custom_enabled', 'logdrain_custom_config', 'logdrain_custom_config_parser', + 'is_logdrain_cloudwatch_enabled', + 'logdrain_cloudwatch_region', + 'logdrain_cloudwatch_group', + 'logdrain_cloudwatch_stream_prefix', ]; private function findServerForTeam(int $teamId, string $uuid): ?Server @@ -43,6 +47,10 @@ class ServerLogDrainsController extends Controller 'is_logdrain_axiom_enabled' => (bool) $settings->is_logdrain_axiom_enabled, 'logdrain_axiom_dataset_name' => $settings->logdrain_axiom_dataset_name, 'is_logdrain_custom_enabled' => (bool) $settings->is_logdrain_custom_enabled, + 'is_logdrain_cloudwatch_enabled' => (bool) $settings->is_logdrain_cloudwatch_enabled, + 'logdrain_cloudwatch_region' => $settings->logdrain_cloudwatch_region, + 'logdrain_cloudwatch_group' => $settings->logdrain_cloudwatch_group, + 'logdrain_cloudwatch_stream_prefix' => $settings->logdrain_cloudwatch_stream_prefix, ]; if ($this->canReadSensitive()) { @@ -80,6 +88,10 @@ class ServerLogDrainsController extends Controller new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'), new OA\Property(property: 'logdrain_custom_config', type: 'string', description: 'Only present with read:sensitive.'), new OA\Property(property: 'logdrain_custom_config_parser', type: 'string', description: 'Only present with read:sensitive.'), + new OA\Property(property: 'is_logdrain_cloudwatch_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_cloudwatch_region', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_group', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_stream_prefix', type: 'string', nullable: true, description: 'Prepended to the container name to form the log stream name.'), ], type: 'object', ), @@ -129,6 +141,10 @@ class ServerLogDrainsController extends Controller new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'), new OA\Property(property: 'logdrain_custom_config', type: 'string'), new OA\Property(property: 'logdrain_custom_config_parser', type: 'string'), + new OA\Property(property: 'is_logdrain_cloudwatch_enabled', type: 'boolean'), + new OA\Property(property: 'logdrain_cloudwatch_region', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_group', type: 'string', nullable: true), + new OA\Property(property: 'logdrain_cloudwatch_stream_prefix', type: 'string', nullable: true, description: 'Prepended to the container name to form the log stream name.'), ], type: 'object', ), @@ -170,6 +186,10 @@ class ServerLogDrainsController extends Controller 'is_logdrain_custom_enabled' => 'boolean', 'logdrain_custom_config' => 'nullable|string', 'logdrain_custom_config_parser' => 'nullable|string', + 'is_logdrain_cloudwatch_enabled' => 'boolean', + 'logdrain_cloudwatch_region' => ['nullable', 'string', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logdrain_cloudwatch_group' => ['nullable', 'string', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logdrain_cloudwatch_stream_prefix' => ['nullable', 'string', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], ]); $extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS); @@ -226,6 +246,27 @@ class ServerLogDrainsController extends Controller ], 422); } + if ($settings->is_logdrain_cloudwatch_enabled) { + if ($settings->is_logdrain_newrelic_enabled || $settings->is_logdrain_axiom_enabled || $settings->is_logdrain_custom_enabled || $settings->is_logdrain_highlight_enabled) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'is_logdrain_cloudwatch_enabled' => ['Amazon CloudWatch Logs cannot be enabled together with another log drain.'], + ], + ], 422); + } + $errors = []; + if (blank($settings->logdrain_cloudwatch_region)) { + $errors['logdrain_cloudwatch_region'] = ['The CloudWatch region is required when Amazon CloudWatch Logs is enabled.']; + } + if (blank($settings->logdrain_cloudwatch_group)) { + $errors['logdrain_cloudwatch_group'] = ['The CloudWatch log group is required when Amazon CloudWatch Logs is enabled.']; + } + if ($errors !== []) { + return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422); + } + } + $settings->save(); $server->refresh(); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 9c8a42ce2d..710dcb823c 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -4185,7 +4185,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $docker_compose['services'][$this->container_name]['labels'] = $labels; } if ($this->mainServer->isLogDrainEnabled() && $this->application->isLogDrainEnabled()) { - $docker_compose['services'][$this->container_name]['logging'] = generate_fluentd_configuration(); + $docker_compose['services'][$this->container_name]['logging'] = generate_log_drain_configuration($this->mainServer); } if ($this->application->settings->is_gpu_enabled) { $docker_compose['services'][$this->container_name]['deploy']['resources']['reservations']['devices'] = [ diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 768ebddb22..ec1ac376f2 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -970,7 +970,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced private function checkLogDrainContainer() { - if ($this->server->isLogDrainEnabled() && $this->foundLogDrainContainer === false) { + if ($this->server->isFluentBitLogDrainEnabled() && $this->foundLogDrainContainer === false) { StartLogDrain::dispatch($this->server); } } diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index a4fa961345..0ec1ce606f 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -73,7 +73,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue CheckAndStartSentinelJob::dispatch($this->server); } - if ($this->server->isLogDrainEnabled()) { + if ($this->server->isFluentBitLogDrainEnabled()) { $this->checkLogDrainContainer(); } diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 965232af44..577941ec00 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -6,6 +6,7 @@ use App\Actions\Server\StartLogDrain; use App\Actions\Server\StopLogDrain; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Validate; use Livewire\Component; @@ -42,6 +43,18 @@ class LogDrains extends Component #[Validate(['string', 'nullable'])] public ?string $logDrainCustomConfigParser = null; + #[Validate(['boolean'])] + public bool $isLogDrainCloudwatchEnabled = false; + + #[Validate(['string', 'nullable', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'])] + public ?string $logDrainCloudwatchRegion = null; + + #[Validate(['string', 'nullable', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'])] + public ?string $logDrainCloudwatchGroup = null; + + #[Validate(['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'])] + public ?string $logDrainCloudwatchStreamPrefix = null; + public function mount(string $server_uuid) { try { @@ -99,6 +112,21 @@ class LogDrains extends Component } } + private function syncDataCloudwatch(bool $toModel = false): void + { + if ($toModel) { + $this->server->settings->is_logdrain_cloudwatch_enabled = $this->isLogDrainCloudwatchEnabled; + $this->server->settings->logdrain_cloudwatch_region = $this->logDrainCloudwatchRegion; + $this->server->settings->logdrain_cloudwatch_group = $this->logDrainCloudwatchGroup; + $this->server->settings->logdrain_cloudwatch_stream_prefix = $this->logDrainCloudwatchStreamPrefix; + } else { + $this->isLogDrainCloudwatchEnabled = $this->server->settings->is_logdrain_cloudwatch_enabled; + $this->logDrainCloudwatchRegion = $this->server->settings->logdrain_cloudwatch_region; + $this->logDrainCloudwatchGroup = $this->server->settings->logdrain_cloudwatch_group; + $this->logDrainCloudwatchStreamPrefix = $this->server->settings->logdrain_cloudwatch_stream_prefix; + } + } + private function syncData(bool $toModel = false, ?string $type = null): void { if ($toModel) { @@ -109,10 +137,16 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); } elseif ($type === 'custom') { $this->syncDataCustom($toModel); + } elseif ($type === 'cloudwatch') { + $this->syncDataCloudwatch($toModel); } else { $this->syncDataNewRelic($toModel); $this->syncDataAxiom($toModel); $this->syncDataCustom($toModel); + $this->syncDataCloudwatch($toModel); + } + if ($this->enabledLogDrainCount() > 0) { + $this->server->settings->is_logdrain_highlight_enabled = false; } $this->auditLogDrain('updated'); $this->server->settings->save(); @@ -123,16 +157,27 @@ class LogDrains extends Component $this->syncDataAxiom($toModel); } elseif ($type === 'custom') { $this->syncDataCustom($toModel); + } elseif ($type === 'cloudwatch') { + $this->syncDataCloudwatch($toModel); } else { $this->syncDataNewRelic($toModel); $this->syncDataAxiom($toModel); $this->syncDataCustom($toModel); + $this->syncDataCloudwatch($toModel); } } } public function customValidation() { + if ($this->enabledLogDrainCount() > 1) { + $this->syncData(); + + throw ValidationException::withMessages([ + 'logDrain' => 'Only one log drain can be enabled at a time. Disable the current log drain first.', + ]); + } + if ($this->isLogDrainNewRelicEnabled) { try { $this->validate([ @@ -164,6 +209,18 @@ class LogDrains extends Component } catch (\Throwable $e) { $this->isLogDrainCustomEnabled = false; + throw $e; + } + } elseif ($this->isLogDrainCloudwatchEnabled) { + try { + $this->validate([ + 'logDrainCloudwatchRegion' => ['required', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], + ]); + } catch (\Throwable $e) { + $this->isLogDrainCloudwatchEnabled = false; + throw $e; } } @@ -192,6 +249,8 @@ class LogDrains extends Component $previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled; $previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled; $previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled; + $previousCloudwatchEnabled = $this->server->settings->is_logdrain_cloudwatch_enabled; + $previousHighlightEnabled = $this->server->settings->is_logdrain_highlight_enabled; try { $this->authorize('update', $this->server); @@ -206,6 +265,7 @@ class LogDrains extends Component $this->isLogDrainNewRelicEnabled = $type === 'newrelic'; $this->isLogDrainAxiomEnabled = $type === 'axiom'; $this->isLogDrainCustomEnabled = $type === 'custom'; + $this->isLogDrainCloudwatchEnabled = $type === 'cloudwatch'; } $this->syncData(true); @@ -224,6 +284,8 @@ class LogDrains extends Component $this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled; $this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled; $this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled; + $this->server->settings->is_logdrain_cloudwatch_enabled = $previousCloudwatchEnabled; + $this->server->settings->is_logdrain_highlight_enabled = $previousHighlightEnabled; $this->server->settings->save(); $this->syncData(); @@ -247,12 +309,23 @@ class LogDrains extends Component return view('livewire.server.log-drains'); } + private function enabledLogDrainCount(): int + { + return count(array_filter([ + $this->isLogDrainNewRelicEnabled, + $this->isLogDrainAxiomEnabled, + $this->isLogDrainCustomEnabled, + $this->isLogDrainCloudwatchEnabled, + ])); + } + private function enabledProperty(string $type): string { return match ($type) { 'newrelic' => 'isLogDrainNewRelicEnabled', 'axiom' => 'isLogDrainAxiomEnabled', 'custom' => 'isLogDrainCustomEnabled', + 'cloudwatch' => 'isLogDrainCloudwatchEnabled', default => throw new \InvalidArgumentException('Unknown log drain type.'), }; } @@ -282,6 +355,11 @@ class LogDrains extends Component 'logDrainCustomConfig' => ['required'], 'logDrainCustomConfigParser' => ['string', 'nullable'], ]), + 'cloudwatch' => $this->validate([ + 'logDrainCloudwatchRegion' => ['required', 'max:64', 'regex:/^[a-z]{2}(-[a-z]+)+-\d+$/'], + 'logDrainCloudwatchGroup' => ['required', 'regex:/^[\.\-_\/#A-Za-z0-9]{1,512}$/'], + 'logDrainCloudwatchStreamPrefix' => ['string', 'nullable', 'max:256', 'regex:/^[A-Za-z0-9_\-\.\/#]*$/'], + ]), default => throw new \InvalidArgumentException('Unknown log drain type.'), }; } diff --git a/app/Models/Server.php b/app/Models/Server.php index e6b85e522f..fa4c454885 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2056,6 +2056,14 @@ $siteAddress { } public function isLogDrainEnabled() + { + return $this->isFluentBitLogDrainEnabled() || $this->settings->is_logdrain_cloudwatch_enabled; + } + + /** + * Whether the active log drain relies on the coolify-log-drain Fluent Bit container. + */ + public function isFluentBitLogDrainEnabled(): bool { return $this->settings->is_logdrain_newrelic_enabled || $this->settings->is_logdrain_highlight_enabled || $this->settings->is_logdrain_axiom_enabled || $this->settings->is_logdrain_custom_enabled; } diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index f2c6116c06..706d90c517 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -26,6 +26,7 @@ use OpenApi\Attributes as OA; 'is_cloudflare_tunnel' => ['type' => 'boolean'], 'is_jump_server' => ['type' => 'boolean'], 'is_logdrain_axiom_enabled' => ['type' => 'boolean'], + 'is_logdrain_cloudwatch_enabled' => ['type' => 'boolean'], 'is_logdrain_custom_enabled' => ['type' => 'boolean'], 'is_logdrain_highlight_enabled' => ['type' => 'boolean'], 'is_logdrain_newrelic_enabled' => ['type' => 'boolean'], @@ -47,6 +48,9 @@ use OpenApi\Attributes as OA; 'is_usable' => ['type' => 'boolean'], 'logdrain_axiom_api_key' => ['type' => 'string'], 'logdrain_axiom_dataset_name' => ['type' => 'string'], + 'logdrain_cloudwatch_region' => ['type' => 'string'], + 'logdrain_cloudwatch_group' => ['type' => 'string'], + 'logdrain_cloudwatch_stream_prefix' => ['type' => 'string'], 'logdrain_custom_config' => ['type' => 'string'], 'logdrain_custom_config_parser' => ['type' => 'string'], 'logdrain_highlight_project_id' => ['type' => 'string'], @@ -113,6 +117,10 @@ class ServerSetting extends Model 'is_logdrain_custom_enabled', 'logdrain_custom_config', 'logdrain_custom_config_parser', + 'is_logdrain_cloudwatch_enabled', + 'logdrain_cloudwatch_region', + 'logdrain_cloudwatch_group', + 'logdrain_cloudwatch_stream_prefix', 'concurrent_builds', 'dynamic_timeout', 'force_disabled', @@ -190,6 +198,7 @@ class ServerSetting extends Model 'compose_version_checked_at' => 'datetime', 'backup_compression_cpu_percentage' => 'integer', 'server_disk_usage_notification_interval_hours' => 'integer', + 'is_logdrain_cloudwatch_enabled' => 'boolean', ]; /** diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 01a5b04a66..104497ed92 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1404,7 +1404,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($server->isLogDrainEnabled()) { if ($resource->isLogDrainEnabled()) { - $logging = generate_fluentd_configuration(); + $logging = generate_log_drain_configuration($server); } } $volumes = collect(data_get($service, 'volumes', [])); @@ -2775,7 +2775,7 @@ function serviceParser(Service $resource): Collection if ($server->isLogDrainEnabled()) { if ($serviceAppsLogDrainEnabledMap->get($serviceName)) { - $logging = generate_fluentd_configuration(); + $logging = generate_log_drain_configuration($server); } } $volumes = collect(data_get($service, 'volumes', [])); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index e824d43ee5..482c77f40a 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -3574,7 +3574,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal } } if ($resource->server->isLogDrainEnabled() && $savedService->isLogDrainEnabled()) { - data_set($service, 'logging', generate_fluentd_configuration()); + data_set($service, 'logging', generate_log_drain_configuration($resource->server)); } if ($serviceLabels->count() > 0) { if ($resource->is_container_label_escape_enabled) { @@ -4406,7 +4406,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal if ($server->isLogDrainEnabled()) { if ($resource instanceof Application && $resource->isLogDrainEnabled()) { - data_set($service, 'logging', generate_fluentd_configuration()); + data_set($service, 'logging', generate_log_drain_configuration($server)); } } if ($serviceLabels->count() > 0) { @@ -4466,6 +4466,31 @@ function generate_fluentd_configuration(): array ]; } +function generate_log_drain_configuration(Server $server): array +{ + if ($server->isFluentBitLogDrainEnabled()) { + return generate_fluentd_configuration(); + } + + $settings = $server->settings; + $options = [ + 'awslogs-region' => $settings->logdrain_cloudwatch_region, + 'awslogs-group' => $settings->logdrain_cloudwatch_group, + 'awslogs-create-group' => 'true', + // Like fluentd-async: a CloudWatch outage or missing credentials must not block or fail the container. + 'mode' => 'non-blocking', + // The stream is named after the container; awslogs-stream is never set because it would override this tag. + 'tag' => ($settings->logdrain_cloudwatch_stream_prefix ?? '').'{{.Name}}', + ]; + + return [ + 'driver' => 'awslogs', + 'options' => collect($options) + ->reject(fn ($value) => blank($value)) + ->all(), + ]; +} + function isAssociativeArray($array) { if ($array instanceof Collection) { diff --git a/database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php b/database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php new file mode 100644 index 0000000000..97fbd6eecc --- /dev/null +++ b/database/migrations/2026_09_24_055934_add_cloudwatch_log_drain_to_server_settings_table.php @@ -0,0 +1,36 @@ +boolean('is_logdrain_cloudwatch_enabled')->default(false); + $table->string('logdrain_cloudwatch_region')->nullable(); + $table->string('logdrain_cloudwatch_group', 512)->nullable(); + $table->string('logdrain_cloudwatch_stream_prefix')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn([ + 'is_logdrain_cloudwatch_enabled', + 'logdrain_cloudwatch_region', + 'logdrain_cloudwatch_group', + 'logdrain_cloudwatch_stream_prefix', + ]); + }); + } +}; diff --git a/openapi.json b/openapi.json index 6b0bd8e12a..102181f7c0 100644 --- a/openapi.json +++ b/openapi.json @@ -16893,6 +16893,28 @@ "logdrain_custom_config_parser": { "description": "Only present with read:sensitive.", "type": "string" + }, + "is_logdrain_cloudwatch_enabled": { + "type": "boolean" + }, + "logdrain_cloudwatch_region": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_group": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_stream_prefix": { + "description": "Prepended to the container name to form the log stream name.", + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -16966,6 +16988,28 @@ }, "logdrain_custom_config_parser": { "type": "string" + }, + "is_logdrain_cloudwatch_enabled": { + "type": "boolean" + }, + "logdrain_cloudwatch_region": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_group": { + "type": [ + "string", + "null" + ] + }, + "logdrain_cloudwatch_stream_prefix": { + "description": "Prepended to the container name to form the log stream name.", + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -24817,6 +24861,9 @@ "is_logdrain_axiom_enabled": { "type": "boolean" }, + "is_logdrain_cloudwatch_enabled": { + "type": "boolean" + }, "is_logdrain_custom_enabled": { "type": "boolean" }, @@ -24887,6 +24934,15 @@ "logdrain_axiom_dataset_name": { "type": "string" }, + "logdrain_cloudwatch_region": { + "type": "string" + }, + "logdrain_cloudwatch_group": { + "type": "string" + }, + "logdrain_cloudwatch_stream_prefix": { + "type": "string" + }, "logdrain_custom_config": { "type": "string" }, diff --git a/openapi.yaml b/openapi.yaml index d5ec8bbd2d..49cc639bd7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -10755,6 +10755,10 @@ paths: is_logdrain_custom_enabled: { type: boolean } logdrain_custom_config: { description: 'Only present with read:sensitive.', type: string } logdrain_custom_config_parser: { description: 'Only present with read:sensitive.', type: string } + is_logdrain_cloudwatch_enabled: { type: boolean } + logdrain_cloudwatch_region: { type: [string, 'null'] } + logdrain_cloudwatch_group: { type: [string, 'null'] } + logdrain_cloudwatch_stream_prefix: { description: 'Prepended to the container name to form the log stream name.', type: [string, 'null'] } type: object '401': $ref: '#/components/responses/401' @@ -10803,6 +10807,15 @@ paths: type: string logdrain_custom_config_parser: type: string + is_logdrain_cloudwatch_enabled: + type: boolean + logdrain_cloudwatch_region: + type: [string, 'null'] + logdrain_cloudwatch_group: + type: [string, 'null'] + logdrain_cloudwatch_stream_prefix: + description: 'Prepended to the container name to form the log stream name.' + type: [string, 'null'] type: object responses: '200': @@ -15888,6 +15901,8 @@ components: type: boolean is_logdrain_axiom_enabled: type: boolean + is_logdrain_cloudwatch_enabled: + type: boolean is_logdrain_custom_enabled: type: boolean is_logdrain_highlight_enabled: @@ -15936,6 +15951,12 @@ components: type: string logdrain_axiom_dataset_name: type: string + logdrain_cloudwatch_region: + type: string + logdrain_cloudwatch_group: + type: string + logdrain_cloudwatch_stream_prefix: + type: string logdrain_custom_config: type: string logdrain_custom_config_parser: diff --git a/resources/views/livewire/server/log-drains.blade.php b/resources/views/livewire/server/log-drains.blade.php index ac908a7c9d..3f3d756414 100644 --- a/resources/views/livewire/server/log-drains.blade.php +++ b/resources/views/livewire/server/log-drains.blade.php @@ -34,7 +34,7 @@ ['value' => false, 'label' => 'Disabled'], ['value' => true, 'label' => 'Enabled'], ]" - :disabled="$isLogDrainAxiomEnabled || $isLogDrainCustomEnabled || !auth()->user()->can('update', $server)" /> + :disabled="$isLogDrainAxiomEnabled || $isLogDrainCustomEnabled || $isLogDrainCloudwatchEnabled || !auth()->user()->can('update', $server)" /> @@ -53,7 +53,7 @@ ['value' => false, 'label' => 'Disabled'], ['value' => true, 'label' => 'Enabled'], ]" - :disabled="$isLogDrainNewRelicEnabled || $isLogDrainCustomEnabled || !auth()->user()->can('update', $server)" /> + :disabled="$isLogDrainNewRelicEnabled || $isLogDrainCustomEnabled || $isLogDrainCloudwatchEnabled || !auth()->user()->can('update', $server)" /> @@ -62,6 +62,29 @@ :disabled="$server->isLogDrainEnabled()" /> + +
+ +
+
+ + + +
+
@@ -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(); +});