feat: add function to extract inline comments from docker-compose YAML environment variables

This commit is contained in:
Andras Bacsai 2025-11-25 10:57:07 +01:00
parent e4cc5c1178
commit 89192c9862
3 changed files with 586 additions and 14 deletions

View file

@ -1411,6 +1411,9 @@ function serviceParser(Service $resource): Collection
return collect([]);
}
// Extract inline comments from raw YAML before Symfony parser discards them
$envComments = extractYamlEnvironmentComments($compose);
$server = data_get($resource, 'server');
$allServices = get_service_templates();
@ -1694,51 +1697,60 @@ function serviceParser(Service $resource): Collection
}
// ALWAYS create BOTH base SERVICE_URL and SERVICE_FQDN pairs (without port)
$fqdnKey = "SERVICE_FQDN_{$serviceName}";
$resource->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceName}",
'key' => $fqdnKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdnValueForEnv,
'is_preview' => false,
'comment' => $envComments[$fqdnKey] ?? null,
]);
$urlKey = "SERVICE_URL_{$serviceName}";
$resource->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceName}",
'key' => $urlKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_preview' => false,
'comment' => $envComments[$urlKey] ?? null,
]);
// For port-specific variables, ALSO create port-specific pairs
// If template variable has port, create both URL and FQDN with port suffix
if ($parsed['has_port'] && $port) {
$fqdnPortKey = "SERVICE_FQDN_{$serviceName}_{$port}";
$resource->environment_variables()->updateOrCreate([
'key' => "SERVICE_FQDN_{$serviceName}_{$port}",
'key' => $fqdnPortKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdnValueForEnvWithPort,
'is_preview' => false,
'comment' => $envComments[$fqdnPortKey] ?? null,
]);
$urlPortKey = "SERVICE_URL_{$serviceName}_{$port}";
$resource->environment_variables()->updateOrCreate([
'key' => "SERVICE_URL_{$serviceName}_{$port}",
'key' => $urlPortKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $urlWithPort,
'is_preview' => false,
'comment' => $envComments[$urlPortKey] ?? null,
]);
}
}
}
$allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
if ($magicEnvironments->count() > 0) {
foreach ($magicEnvironments as $key => $value) {
$key = str($key);
foreach ($magicEnvironments as $magicKey => $value) {
$originalMagicKey = $magicKey; // Preserve original key for comment lookup
$key = str($magicKey);
$value = replaceVariables($value);
$command = parseCommandFromMagicEnvVariable($key);
if ($command->value() === 'FQDN') {
@ -1762,13 +1774,14 @@ function serviceParser(Service $resource): Collection
$serviceExists->fqdn = $url;
$serviceExists->save();
}
$resource->environment_variables()->firstOrCreate([
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_preview' => false,
'comment' => $envComments[$originalMagicKey] ?? null,
]);
} elseif ($command->value() === 'URL') {
@ -1790,24 +1803,26 @@ function serviceParser(Service $resource): Collection
$serviceExists->fqdn = $url;
$serviceExists->save();
}
$resource->environment_variables()->firstOrCreate([
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_preview' => false,
'comment' => $envComments[$originalMagicKey] ?? null,
]);
} else {
$value = generateEnvValue($command, $resource);
$resource->environment_variables()->firstOrCreate([
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'comment' => $envComments[$originalMagicKey] ?? null,
]);
}
}
@ -2163,18 +2178,20 @@ function serviceParser(Service $resource): Collection
return ! str($value)->startsWith('SERVICE_');
});
foreach ($normalEnvironments as $key => $value) {
$originalKey = $key; // Preserve original key for comment lookup
$key = str($key);
$value = str($value);
$originalValue = $value;
$parsedValue = replaceVariables($value);
if ($parsedValue->startsWith('SERVICE_')) {
$resource->environment_variables()->firstOrCreate([
$resource->environment_variables()->updateOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
continue;
@ -2184,13 +2201,14 @@ function serviceParser(Service $resource): Collection
}
if ($key->value() === $parsedValue->value()) {
$value = null;
$resource->environment_variables()->firstOrCreate([
$resource->environment_variables()->updateOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
} else {
if ($value->startsWith('$')) {
@ -2220,20 +2238,21 @@ function serviceParser(Service $resource): Collection
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value, so it needs to be created in Coolify
$parsedKeyValue = replaceVariables($value);
$resource->environment_variables()->firstOrCreate([
$resource->environment_variables()->updateOrCreate([
'key' => $parsedKeyValue,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$parsedKeyValue->value()] = $value;
continue;
}
$resource->environment_variables()->firstOrCreate([
$resource->environment_variables()->updateOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
@ -2241,6 +2260,7 @@ function serviceParser(Service $resource): Collection
'value' => $value,
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
}
}

View file

@ -512,6 +512,215 @@ function parseEnvFormatToArray($env_file_contents)
return $env_array;
}
/**
* Extract inline comments from environment variables in raw docker-compose YAML.
*
* Parses raw docker-compose YAML to extract inline comments from environment sections.
* Standard YAML parsers discard comments, so this pre-processes the raw text.
*
* Handles both formats:
* - Map format: `KEY: "value" # comment` or `KEY: value # comment`
* - Array format: `- KEY=value # comment`
*
* @param string $rawYaml The raw docker-compose.yml content
* @return array Map of environment variable keys to their inline comments
*/
function extractYamlEnvironmentComments(string $rawYaml): array
{
$comments = [];
$lines = explode("\n", $rawYaml);
$inEnvironmentBlock = false;
$environmentIndent = 0;
foreach ($lines as $line) {
// Skip empty lines
if (trim($line) === '') {
continue;
}
// Calculate current line's indentation (number of leading spaces)
$currentIndent = strlen($line) - strlen(ltrim($line));
// Check if this line starts an environment block
if (preg_match('/^(\s*)environment\s*:\s*$/', $line, $matches)) {
$inEnvironmentBlock = true;
$environmentIndent = strlen($matches[1]);
continue;
}
// Check if this line starts an environment block with inline content (rare but possible)
if (preg_match('/^(\s*)environment\s*:\s*\{/', $line)) {
// Inline object format - not supported for comment extraction
continue;
}
// If we're in an environment block, check if we've exited it
if ($inEnvironmentBlock) {
// If we hit a line with same or less indentation that's not empty, we've left the block
// Unless it's a continuation of the environment block
$trimmedLine = ltrim($line);
// Check if this is a new top-level key (same indent as 'environment:' or less)
if ($currentIndent <= $environmentIndent && ! str_starts_with($trimmedLine, '-') && ! str_starts_with($trimmedLine, '#')) {
// Check if it looks like a YAML key (contains : not inside quotes)
if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*\s*:/', $trimmedLine)) {
$inEnvironmentBlock = false;
continue;
}
}
// Skip comment-only lines
if (str_starts_with($trimmedLine, '#')) {
continue;
}
// Try to extract environment variable and comment from this line
$extracted = extractEnvVarCommentFromYamlLine($trimmedLine);
if ($extracted !== null && $extracted['comment'] !== null) {
$comments[$extracted['key']] = $extracted['comment'];
}
}
}
return $comments;
}
/**
* Extract environment variable key and inline comment from a single YAML line.
*
* @param string $line A trimmed line from the environment section
* @return array|null Array with 'key' and 'comment', or null if not an env var line
*/
function extractEnvVarCommentFromYamlLine(string $line): ?array
{
$key = null;
$comment = null;
// Handle array format: `- KEY=value # comment` or `- KEY # comment`
if (str_starts_with($line, '-')) {
$content = ltrim(substr($line, 1));
// Check for KEY=value format
if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)/', $content, $keyMatch)) {
$key = $keyMatch[1];
// Find comment - need to handle quoted values
$comment = extractCommentAfterValue($content);
}
}
// Handle map format: `KEY: "value" # comment` or `KEY: value # comment`
elseif (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\s*:/', $line, $keyMatch)) {
$key = $keyMatch[1];
// Get everything after the key and colon
$afterKey = substr($line, strlen($keyMatch[0]));
$comment = extractCommentAfterValue($afterKey);
}
if ($key === null) {
return null;
}
return [
'key' => $key,
'comment' => $comment,
];
}
/**
* Extract inline comment from a value portion of a YAML line.
*
* Handles quoted values (where # inside quotes is not a comment).
*
* @param string $valueAndComment The value portion (may include comment)
* @return string|null The comment text, or null if no comment
*/
function extractCommentAfterValue(string $valueAndComment): ?string
{
$valueAndComment = ltrim($valueAndComment);
if ($valueAndComment === '') {
return null;
}
$firstChar = $valueAndComment[0] ?? '';
// Handle case where value is empty and line starts directly with comment
// e.g., `KEY: # comment` becomes `# comment` after ltrim
if ($firstChar === '#') {
$comment = trim(substr($valueAndComment, 1));
return $comment !== '' ? $comment : null;
}
// Handle double-quoted value
if ($firstChar === '"') {
// Find closing quote (handle escaped quotes)
$pos = 1;
$len = strlen($valueAndComment);
while ($pos < $len) {
if ($valueAndComment[$pos] === '\\' && $pos + 1 < $len) {
$pos += 2; // Skip escaped character
continue;
}
if ($valueAndComment[$pos] === '"') {
// Found closing quote
$remainder = substr($valueAndComment, $pos + 1);
return extractCommentFromRemainder($remainder);
}
$pos++;
}
// No closing quote found
return null;
}
// Handle single-quoted value
if ($firstChar === "'") {
// Find closing quote (single quotes don't have escapes in YAML)
$closingPos = strpos($valueAndComment, "'", 1);
if ($closingPos !== false) {
$remainder = substr($valueAndComment, $closingPos + 1);
return extractCommentFromRemainder($remainder);
}
// No closing quote found
return null;
}
// Unquoted value - find # that's preceded by whitespace
// Be careful not to match # at the start of a value like color codes
if (preg_match('/\s+#\s*(.*)$/', $valueAndComment, $matches)) {
$comment = trim($matches[1]);
return $comment !== '' ? $comment : null;
}
return null;
}
/**
* Extract comment from the remainder of a line after a quoted value.
*
* @param string $remainder Text after the closing quote
* @return string|null The comment text, or null if no comment
*/
function extractCommentFromRemainder(string $remainder): ?string
{
// Look for # in remainder
$hashPos = strpos($remainder, '#');
if ($hashPos !== false) {
$comment = trim(substr($remainder, $hashPos + 1));
return $comment !== '' ? $comment : null;
}
return null;
}
function data_get_str($data, $key, $default = null): Stringable
{
$str = data_get($data, $key, $default) ?? $default;
@ -1345,6 +1554,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
{
if ($resource->getMorphClass() === \App\Models\Service::class) {
if ($resource->docker_compose_raw) {
// Extract inline comments from raw YAML before Symfony parser discards them
$envComments = extractYamlEnvironmentComments($resource->docker_compose_raw);
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
} catch (\Exception $e) {
@ -1376,7 +1588,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
$topLevelVolumes = collect($tempTopLevelVolumes);
}
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices) {
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices, $envComments) {
// Workarounds for beta users.
if ($serviceName === 'registry') {
$tempServiceName = 'docker-registry';
@ -1722,6 +1934,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$key = str($variableName);
$value = str($variable);
}
// Preserve original key for comment lookup before $key might be reassigned
$originalKey = $key->value();
if ($key->startsWith('SERVICE_FQDN')) {
if ($isNew || $savedService->fqdn === null) {
$name = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower();
@ -1775,6 +1989,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
}
// Caddy needs exact port in some cases.
@ -1854,6 +2069,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
}
if (! $isDatabase) {
@ -1892,6 +2108,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
}
}
@ -1930,6 +2147,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
}
}

View file

@ -0,0 +1,334 @@
<?php
test('extractYamlEnvironmentComments returns empty array for YAML without environment section', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
ports:
- "80:80"
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([]);
});
test('extractYamlEnvironmentComments extracts inline comments from map format', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
environment:
FOO: bar # This is a comment
BAZ: qux
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([
'FOO' => 'This is a comment',
]);
});
test('extractYamlEnvironmentComments extracts inline comments from array format', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
environment:
- FOO=bar # This is a comment
- BAZ=qux
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([
'FOO' => 'This is a comment',
]);
});
test('extractYamlEnvironmentComments handles quoted values containing hash symbols', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
environment:
COLOR: "#FF0000" # hex color code
DB_URL: "postgres://user:pass#123@localhost" # database URL
PLAIN: value # no quotes
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([
'COLOR' => 'hex color code',
'DB_URL' => 'database URL',
'PLAIN' => 'no quotes',
]);
});
test('extractYamlEnvironmentComments handles single quoted values containing hash symbols', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
environment:
PASSWORD: 'secret#123' # my password
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([
'PASSWORD' => 'my password',
]);
});
test('extractYamlEnvironmentComments skips full-line comments', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
environment:
# This is a full line comment
FOO: bar # This is an inline comment
# Another full line comment
BAZ: qux
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([
'FOO' => 'This is an inline comment',
]);
});
test('extractYamlEnvironmentComments handles multiple services', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
environment:
WEB_PORT: 8080 # web server port
db:
image: postgres:15
environment:
POSTGRES_USER: admin # database admin user
POSTGRES_PASSWORD: secret
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([
'WEB_PORT' => 'web server port',
'POSTGRES_USER' => 'database admin user',
]);
});
test('extractYamlEnvironmentComments handles variables without values', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
environment:
- DEBUG # enable debug mode
- VERBOSE
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([
'DEBUG' => 'enable debug mode',
]);
});
test('extractYamlEnvironmentComments handles array format with colons', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
environment:
- DATABASE_URL: postgres://localhost # connection string
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([
'DATABASE_URL' => 'connection string',
]);
});
test('extractYamlEnvironmentComments does not treat hash inside unquoted values as comment start', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
environment:
API_KEY: abc#def
OTHER: xyz # this is a comment
YAML;
$result = extractYamlEnvironmentComments($yaml);
// abc#def has no space before #, so it's not treated as a comment
expect($result)->toBe([
'OTHER' => 'this is a comment',
]);
});
test('extractYamlEnvironmentComments handles empty environment section', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
environment:
ports:
- "80:80"
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([]);
});
test('extractYamlEnvironmentComments handles environment inline format (not supported)', function () {
// Inline format like environment: { FOO: bar } is not supported for comment extraction
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
environment: { FOO: bar }
YAML;
$result = extractYamlEnvironmentComments($yaml);
// No comments extracted from inline format
expect($result)->toBe([]);
});
test('extractYamlEnvironmentComments handles complex real-world docker-compose', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
app:
image: myapp:latest
environment:
NODE_ENV: production # Set to development for local
DATABASE_URL: "postgres://user:pass@db:5432/mydb" # Main database
REDIS_URL: "redis://cache:6379"
API_SECRET: "${API_SECRET}" # From .env file
LOG_LEVEL: debug # Options: debug, info, warn, error
ports:
- "3000:3000"
db:
image: postgres:15
environment:
POSTGRES_USER: user # Database admin username
POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: mydb
cache:
image: redis:7
environment:
- REDIS_MAXMEMORY=256mb # Memory limit for cache
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([
'NODE_ENV' => 'Set to development for local',
'DATABASE_URL' => 'Main database',
'API_SECRET' => 'From .env file',
'LOG_LEVEL' => 'Options: debug, info, warn, error',
'POSTGRES_USER' => 'Database admin username',
'REDIS_MAXMEMORY' => 'Memory limit for cache',
]);
});
test('extractYamlEnvironmentComments handles comment with multiple hash symbols', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
environment:
FOO: bar # comment # with # hashes
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([
'FOO' => 'comment # with # hashes',
]);
});
test('extractYamlEnvironmentComments handles variables with empty comments', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
environment:
FOO: bar #
BAZ: qux #
YAML;
$result = extractYamlEnvironmentComments($yaml);
// Empty comments should not be included
expect($result)->toBe([]);
});
test('extractYamlEnvironmentComments properly exits environment block on new section', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
image: nginx:latest
environment:
FOO: bar # env comment
ports:
- "80:80" # port comment should not be captured
volumes:
- ./data:/data # volume comment should not be captured
YAML;
$result = extractYamlEnvironmentComments($yaml);
// Only environment variables should have comments extracted
expect($result)->toBe([
'FOO' => 'env comment',
]);
});
test('extractYamlEnvironmentComments handles SERVICE_ variables', function () {
$yaml = <<<'YAML'
version: "3.8"
services:
web:
environment:
SERVICE_FQDN_WEB: /api # Path for the web service
SERVICE_URL_WEB: # URL will be generated
NORMAL_VAR: value # Regular variable
YAML;
$result = extractYamlEnvironmentComments($yaml);
expect($result)->toBe([
'SERVICE_FQDN_WEB' => 'Path for the web service',
'SERVICE_URL_WEB' => 'URL will be generated',
'NORMAL_VAR' => 'Regular variable',
]);
});