mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
Added docker awslogs driver to log drains (#11972)
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ class StartService
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! $service->destination?->server?->isLogDrainEnabled()) {
|
||||
if (! $service->destination?->server?->isFluentBitLogDrainEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'] = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
|
||||
CheckAndStartSentinelJob::dispatch($this->server);
|
||||
}
|
||||
|
||||
if ($this->server->isLogDrainEnabled()) {
|
||||
if ($this->server->isFluentBitLogDrainEnabled()) {
|
||||
$this->checkLogDrainContainer();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', []));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('server_settings', function (Blueprint $table) {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)" />
|
||||
<x-forms.input canGate="update" :canResource="$server" type="password" required
|
||||
id="logDrainNewRelicLicenseKey" label="License key"
|
||||
:disabled="$server->isLogDrainEnabled()" />
|
||||
@@ -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)" />
|
||||
<x-forms.input canGate="update" :canResource="$server" type="password" required
|
||||
id="logDrainAxiomApiKey" label="API key"
|
||||
:disabled="$server->isLogDrainEnabled()" />
|
||||
@@ -62,6 +62,29 @@
|
||||
:disabled="$server->isLogDrainEnabled()" />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
<x-application.settings-section id="server-cloudwatch-drain-section" title="Amazon CloudWatch Logs"
|
||||
helper="Send container logs directly to CloudWatch Logs using the Docker awslogs logging driver.<br><br>AWS credentials are read by the Docker daemon on this server, not by Coolify. Attach an IAM instance role, or configure <code>AWS_ACCESS_KEY_ID</code> and <code>AWS_SECRET_ACCESS_KEY</code> for the Docker service or in <code>/root/.aws/credentials</code>.<br><br>The credentials need <code>logs:CreateLogStream</code> and <code>logs:PutLogEvents</code>, plus <code>logs:CreateLogGroup</code> if the log group does not exist yet.<br><br>Delivery errors do not stop containers; they appear in the Docker daemon logs (<code>journalctl -u docker</code>). Redeploy or restart resources to apply the logging driver.">
|
||||
<div class="mb-4 max-w-sm">
|
||||
<x-forms.listbox canGate="update" :canResource="$server" id="isLogDrainCloudwatchEnabled" label="Status"
|
||||
onChange="instantSave" :options="[
|
||||
['value' => false, 'label' => 'Disabled'],
|
||||
['value' => true, 'label' => 'Enabled'],
|
||||
]"
|
||||
:disabled="$isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled || $isLogDrainCustomEnabled || !auth()->user()->can('update', $server)" />
|
||||
</div>
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<x-forms.input canGate="update" :canResource="$server" required
|
||||
id="logDrainCloudwatchGroup" label="Log group" placeholder="/coolify/production"
|
||||
:disabled="$server->isLogDrainEnabled()" />
|
||||
<x-forms.input canGate="update" :canResource="$server"
|
||||
id="logDrainCloudwatchStreamPrefix" label="Log stream prefix" placeholder="docker/"
|
||||
helper="Optional. Log streams are named after the container, prefixed with this value (e.g. docker/<container name>)."
|
||||
:disabled="$server->isLogDrainEnabled()" />
|
||||
<x-forms.input canGate="update" :canResource="$server" required
|
||||
id="logDrainCloudwatchRegion" label="Region" placeholder="us-east-1"
|
||||
:disabled="$server->isLogDrainEnabled()" />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
<x-application.settings-section id="server-custom-drain-section" title="Custom Fluent Bit"
|
||||
helper="Provide a custom Fluent Bit output and optional parser configuration.">
|
||||
<div class="mb-4 max-w-sm">
|
||||
@@ -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)" />
|
||||
</div>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.textarea canGate="update" :canResource="$server" rows="8" required
|
||||
|
||||
@@ -188,6 +188,89 @@ describe('Log drains API', function () {
|
||||
->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")
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\StartLogDrain;
|
||||
use App\Actions\Server\StopLogDrain;
|
||||
use App\Livewire\Server\LogDrains;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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'));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
|
||||
function serverWithLogDrainSettings(array $attributes): Server
|
||||
{
|
||||
$server = new Server;
|
||||
$server->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();
|
||||
});
|
||||
Reference in New Issue
Block a user