mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 16:52:45 -04:00
Containers without the Coolify default variables sent no project, environment, or service to the drain. The fluentd driver now also sends the Coolify container labels; the built-in configs use them as a fallback and add coolify.service_name. Closes #8415 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
266 lines
10 KiB
PHP
266 lines
10 KiB
PHP
<?php
|
|
|
|
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
|
|
{
|
|
use AsAction;
|
|
|
|
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 = '2';
|
|
|
|
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_axiom_enabled) {
|
|
$type = 'axiom';
|
|
} elseif ($server->settings->is_logdrain_custom_enabled) {
|
|
$type = 'custom';
|
|
} elseif ($server->settings->is_logdrain_cloudwatch_enabled) {
|
|
$type = 'cloudwatch';
|
|
} else {
|
|
$type = 'none';
|
|
}
|
|
if ($type === 'cloudwatch') {
|
|
// Containers ship logs directly via Docker's awslogs driver; no Fluent Bit container is needed.
|
|
StopLogDrain::run($server);
|
|
|
|
return 'Amazon CloudWatch Logs uses the built-in Docker awslogs driver.';
|
|
}
|
|
$serverName = $this->fluentBitValue((string) $server->name);
|
|
// Rename keeps a key that exists, so the container labels fill a field only when the
|
|
// Coolify default variables are not on the container. Then the labels are removed.
|
|
$coolifyFieldsFilter = "[FILTER]
|
|
Name modify
|
|
Match *
|
|
Set coolify.server_name {$serverName}
|
|
Rename COOLIFY_APP_NAME coolify.app_name
|
|
Rename COOLIFY_PROJECT_NAME coolify.project_name
|
|
Rename COOLIFY_SERVER_IP coolify.server_ip
|
|
Rename COOLIFY_ENVIRONMENT_NAME coolify.environment_name
|
|
Rename coolify.resourceName coolify.app_name
|
|
Rename coolify.projectName coolify.project_name
|
|
Rename coolify.environmentName coolify.environment_name
|
|
Rename coolify.serviceName coolify.service_name
|
|
Remove coolify.resourceName
|
|
Remove coolify.projectName
|
|
Remove coolify.environmentName
|
|
";
|
|
try {
|
|
if ($type === 'none') {
|
|
return 'No log drain is enabled.';
|
|
} elseif ($type === 'newrelic') {
|
|
if (! $server->settings->is_logdrain_newrelic_enabled) {
|
|
throw new \Exception('New Relic log drain is not enabled.');
|
|
}
|
|
$config = base64_encode("
|
|
[SERVICE]
|
|
Flush 5
|
|
Daemon off
|
|
Log_Level warn
|
|
Parsers_File parsers.conf
|
|
[INPUT]
|
|
Name forward
|
|
Buffer_Chunk_Size 1M
|
|
Buffer_Max_Size 6M
|
|
[FILTER]
|
|
Name grep
|
|
Match *
|
|
Exclude log 127.0.0.1
|
|
{$coolifyFieldsFilter}[OUTPUT]
|
|
Name nrlogs
|
|
Match *
|
|
license_key \${LICENSE_KEY}
|
|
# https://log-api.eu.newrelic.com/log/v1 - EU
|
|
# https://log-api.newrelic.com/log/v1 - US
|
|
base_uri \${BASE_URI}
|
|
");
|
|
} elseif ($type === 'axiom') {
|
|
if (! $server->settings->is_logdrain_axiom_enabled) {
|
|
throw new \Exception('Axiom log drain is not enabled.');
|
|
}
|
|
$config = base64_encode("
|
|
[SERVICE]
|
|
Flush 5
|
|
Daemon off
|
|
Log_Level warn
|
|
Parsers_File parsers.conf
|
|
[INPUT]
|
|
Name forward
|
|
Buffer_Chunk_Size 1M
|
|
Buffer_Max_Size 6M
|
|
[FILTER]
|
|
Name grep
|
|
Match *
|
|
Exclude log 127.0.0.1
|
|
{$coolifyFieldsFilter}[OUTPUT]
|
|
Name http
|
|
Match *
|
|
Host \${AXIOM_DOMAIN}
|
|
Port 443
|
|
URI /v1/ingest/\${AXIOM_DATASET_NAME}
|
|
# Authorization Bearer should be an API token
|
|
Header Authorization Bearer \${AXIOM_API_KEY}
|
|
compress gzip
|
|
format json
|
|
json_date_key _time
|
|
json_date_format iso8601
|
|
tls On
|
|
");
|
|
} elseif ($type === 'custom') {
|
|
if (! $server->settings->is_logdrain_custom_enabled) {
|
|
throw new \Exception('Custom log drain is not enabled.');
|
|
}
|
|
$configError = (new ValidFluentBitConfig)->check($server->settings->logdrain_custom_config);
|
|
if ($configError !== null) {
|
|
throw new \Exception("Custom log drain config is not allowed: {$configError}");
|
|
}
|
|
$parsersError = (new ValidFluentBitConfig(parsersOnly: true))->check($server->settings->logdrain_custom_config_parser);
|
|
if ($parsersError !== null) {
|
|
throw new \Exception("Custom log drain parser config is not allowed: {$parsersError}");
|
|
}
|
|
$config = base64_encode($server->settings->logdrain_custom_config);
|
|
$parsers = base64_encode($server->settings->logdrain_custom_config_parser);
|
|
} else {
|
|
throw new \Exception('Unknown log drain type.');
|
|
}
|
|
if ($type !== 'custom') {
|
|
$parsers = base64_encode("
|
|
[PARSER]
|
|
Name empty_line_skipper
|
|
Format regex
|
|
Regex /^(?!\s*$).+/
|
|
");
|
|
}
|
|
$compose = base64_encode('
|
|
services:
|
|
coolify-log-drain:
|
|
image: cr.fluentbit.io/fluent/fluent-bit:5.1
|
|
container_name: coolify-log-drain
|
|
command: -c /fluent-bit.conf
|
|
env_file:
|
|
- .env
|
|
volumes:
|
|
- ./fluent-bit.conf:/fluent-bit.conf:ro
|
|
- ./parsers.conf:/parsers.conf:ro
|
|
ports:
|
|
- 127.0.0.1:24224:24224
|
|
labels:
|
|
- coolify.managed=true
|
|
- '.self::CONFIG_VERSION_LABEL.'='.self::CONFIG_VERSION.'
|
|
restart: unless-stopped
|
|
');
|
|
$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');
|
|
|
|
$config_path = $base_path.'/log-drains';
|
|
$fluent_bit_config = $config_path.'/fluent-bit.conf';
|
|
$parsers_config = $config_path.'/parsers.conf';
|
|
$compose_path = $config_path.'/docker-compose.yml';
|
|
$readme_path = $config_path.'/README.md';
|
|
if ($type === 'newrelic') {
|
|
$envContent = "LICENSE_KEY={$license_key}\nBASE_URI={$base_uri}\n";
|
|
} elseif ($type === 'axiom') {
|
|
$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 {
|
|
throw new \Exception('Unknown log drain type.');
|
|
}
|
|
$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",
|
|
"echo '{$parsers}' | base64 -d | tee $parsers_config > /dev/null",
|
|
"echo '{$config}' | base64 -d | tee $fluent_bit_config > /dev/null",
|
|
"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));
|
|
|
|
return instant_remote_process($command, $server);
|
|
} catch (\Throwable $e) {
|
|
return handleError($e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fluent Bit reads its config line by line, so a value with a line break could add sections,
|
|
* and the modify filter splits `Set` on spaces, so the value must be a single token.
|
|
*/
|
|
private function fluentBitValue(string $value): string
|
|
{
|
|
return preg_replace('/[\s\x00-\x1F\x7F]+/', '-', trim($value));
|
|
}
|
|
|
|
private function logDrainNetworkConnectCommands(Server $server): array
|
|
{
|
|
if (! $server->isFluentBitLogDrainEnabled()) {
|
|
return [];
|
|
}
|
|
|
|
return $server->services()
|
|
->with('destination')
|
|
->where('connect_to_docker_network', true)
|
|
->get()
|
|
->map(fn (Service $service) => data_get($service, 'destination.network'))
|
|
->filter()
|
|
->unique()
|
|
->map(fn (string $network) => 'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true')
|
|
->values()
|
|
->all();
|
|
}
|
|
}
|