-
+
\ No newline at end of file
From e4cc5c117836ac3dc103e80b921738781c8e5ff8 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 25 Nov 2025 10:11:48 +0100
Subject: [PATCH 14/37] fix: update success message logic to only show when
changes are made
---
.../Project/Shared/EnvironmentVariable/All.php | 4 ++--
tests/Feature/EnvironmentVariableCommentTest.php | 16 ++++++++--------
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
index f867aaf3d..12a4cae79 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
@@ -193,8 +193,8 @@ private function handleBulkSubmit()
}
}
- // Always show success message unless an error occurred
- if (! $errorOccurred) {
+ // Only show success message if changes were actually made and no errors occurred
+ if ($changesMade && ! $errorOccurred) {
$this->dispatch('success', 'Environment variables updated.');
}
}
diff --git a/tests/Feature/EnvironmentVariableCommentTest.php b/tests/Feature/EnvironmentVariableCommentTest.php
index 8b02ad8bf..e7f9a07fb 100644
--- a/tests/Feature/EnvironmentVariableCommentTest.php
+++ b/tests/Feature/EnvironmentVariableCommentTest.php
@@ -166,8 +166,8 @@
'resource' => $this->application,
'type' => 'application',
])
- ->set('variablesInput', $bulkContent)
- ->call('saveVariables');
+ ->set('variables', $bulkContent)
+ ->call('submit');
// Refresh the environment variable
$env->refresh();
@@ -196,8 +196,8 @@
'resource' => $this->application,
'type' => 'application',
])
- ->set('variablesInput', $bulkContent)
- ->call('saveVariables');
+ ->set('variables', $bulkContent)
+ ->call('submit');
// Refresh the environment variable
$env->refresh();
@@ -234,8 +234,8 @@
'resource' => $this->application,
'type' => 'application',
])
- ->set('variablesInput', $bulkContent)
- ->call('saveVariables');
+ ->set('variables', $bulkContent)
+ ->call('submit');
// Refresh both variables
$env1->refresh();
@@ -258,8 +258,8 @@
'resource' => $this->application,
'type' => 'application',
])
- ->set('variablesInput', $bulkContent)
- ->call('saveVariables');
+ ->set('variables', $bulkContent)
+ ->call('submit');
// Check that variables were created with correct comments
$var1 = EnvironmentVariable::where('key', 'NEW_VAR1')
From 89192c9862e1d0a2d911fa619bd5caceb80e1bdb Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 25 Nov 2025 10:57:07 +0100
Subject: [PATCH 15/37] feat: add function to extract inline comments from
docker-compose YAML environment variables
---
bootstrap/helpers/parsers.php | 46 ++-
bootstrap/helpers/shared.php | 220 +++++++++++-
.../ExtractYamlEnvironmentCommentsTest.php | 334 ++++++++++++++++++
3 files changed, 586 insertions(+), 14 deletions(-)
create mode 100644 tests/Unit/ExtractYamlEnvironmentCommentsTest.php
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index 43ba58e59..3f942547f 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -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,
]);
}
}
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index dc7136275..f2f29bdc6 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -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,
]);
}
}
diff --git a/tests/Unit/ExtractYamlEnvironmentCommentsTest.php b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php
new file mode 100644
index 000000000..4300b3abf
--- /dev/null
+++ b/tests/Unit/ExtractYamlEnvironmentCommentsTest.php
@@ -0,0 +1,334 @@
+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',
+ ]);
+});
From d67fcd1dff7fee7bee21bf54e49f7d03f574d431 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 25 Nov 2025 11:23:18 +0100
Subject: [PATCH 16/37] feat: add magic variable detection and update UI
behavior accordingly
---
.../Shared/EnvironmentVariable/Show.php | 6 +
.../environment-variable/show.blade.php | 130 +++++++++-------
.../EnvironmentVariableMagicVariableTest.php | 141 ++++++++++++++++++
3 files changed, 227 insertions(+), 50 deletions(-)
create mode 100644 tests/Unit/EnvironmentVariableMagicVariableTest.php
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index 75149a0d4..2a18be13c 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -24,6 +24,8 @@ class Show extends Component
public bool $isLocked = false;
+ public bool $isMagicVariable = false;
+
public bool $isSharedVariable = false;
public string $type;
@@ -146,9 +148,13 @@ public function syncData(bool $toModel = false)
public function checkEnvs()
{
$this->isDisabled = false;
+ $this->isMagicVariable = false;
+
if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL') || str($this->env->key)->startsWith('SERVICE_NAME')) {
$this->isDisabled = true;
+ $this->isMagicVariable = true;
}
+
if ($this->env->is_shown_once) {
$this->isLocked = true;
}
diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php
index cc95939de..86faeeeb4 100644
--- a/resources/views/livewire/project/shared/environment-variable/show.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php
@@ -40,10 +40,12 @@
-
-
+ @if (!$isMagicVariable)
+
+
+ @endif
@else
@if ($is_shared)
@else
@if ($isSharedVariable)
-
+ @if (!$isMagicVariable)
+
+ @endif
@else
@if (!$env->is_nixpacks)
- @if (!$env->is_nixpacks)
-
- @if ($is_multiline === false)
-
+ @if (!$isMagicVariable)
+ @if (!$env->is_nixpacks)
+
+ @if ($is_multiline === false)
+
+ @endif
@endif
@endif
@endif
@@ -86,10 +92,12 @@
-
-
+ @if (!$isMagicVariable)
+
+
+ @endif
@else
@if ($is_shared)
@else
@if ($isSharedVariable)
-
+ @if (!$isMagicVariable)
+
+ @endif
@else
-
- @if ($is_multiline === false)
-
+ @if (!$isMagicVariable)
+
+ @if ($is_multiline === false)
+
+ @endif
@endif
@endif
@endif
@@ -133,8 +145,10 @@
@endif
-
+ @if (!$isMagicVariable)
+
+ @endif
@else
@@ -164,8 +178,10 @@
@endif
-
+ @if (!$isMagicVariable)
+
+ @endif
@endcan
@can('update', $this->env)
@@ -179,10 +195,12 @@
-
-
+ @if (!$isMagicVariable)
+
+
+ @endif
@else
@if ($is_shared)
@else
@if ($isSharedVariable)
-
+ @if (!$isMagicVariable)
+
+ @endif
@else
@if (!$env->is_nixpacks)
- @if (!$env->is_nixpacks)
-
- @if ($is_multiline === false)
-
+ @if (!$isMagicVariable)
+ @if (!$env->is_nixpacks)
+
+ @if ($is_multiline === false)
+
+ @endif
@endif
@endif
@endif
@@ -214,8 +236,9 @@
@endif
-
- @if ($isDisabled)
+ @if (!$isMagicVariable)
+
+ @if ($isDisabled)
Update
Lock
- @endif
-
+ @endif
+
+ @endif
@else
@@ -247,10 +271,12 @@
-
-
+ @if (!$isMagicVariable)
+
+
+ @endif
@else
@if ($is_shared)
@else
@if ($isSharedVariable)
-
+ @if (!$isMagicVariable)
+
+ @endif
@else
-
- @if ($is_multiline === false)
-
+ @if (!$isMagicVariable)
+
+ @if ($is_multiline === false)
+
+ @endif
@endif
@endif
@endif
diff --git a/tests/Unit/EnvironmentVariableMagicVariableTest.php b/tests/Unit/EnvironmentVariableMagicVariableTest.php
new file mode 100644
index 000000000..ae85ba45f
--- /dev/null
+++ b/tests/Unit/EnvironmentVariableMagicVariableTest.php
@@ -0,0 +1,141 @@
+shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_FQDN_DB');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_URL variables are identified as magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_URL_API');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_NAME variables are identified as magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_NAME');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('regular variables are not magic variables', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('DATABASE_URL');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeFalse();
+ expect($component->isDisabled)->toBeFalse();
+});
+
+test('locked variables are not magic variables unless they start with SERVICE_', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SECRET_KEY');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(true);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeFalse();
+ expect($component->isLocked)->toBeTrue();
+});
+
+test('SERVICE_FQDN with port suffix is identified as magic variable', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_FQDN_DB_5432');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
+
+test('SERVICE_URL with port suffix is identified as magic variable', function () {
+ $mock = Mockery::mock(EnvironmentVariable::class);
+ $mock->shouldReceive('getAttribute')
+ ->with('key')
+ ->andReturn('SERVICE_URL_API_8080');
+ $mock->shouldReceive('getAttribute')
+ ->with('is_shown_once')
+ ->andReturn(false);
+ $mock->shouldReceive('getMorphClass')
+ ->andReturn(EnvironmentVariable::class);
+
+ $component = new Show;
+ $component->env = $mock;
+ $component->checkEnvs();
+
+ expect($component->isMagicVariable)->toBeTrue();
+ expect($component->isDisabled)->toBeTrue();
+});
From 208f0eac997a516398d20509dc25aac226241234 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 25 Nov 2025 15:22:38 +0100
Subject: [PATCH 17/37] feat: add comprehensive environment variable parsing
with nested resolution and hardcoded variable detection
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This commit introduces advanced environment variable handling capabilities including:
- Nested environment variable resolution with circular dependency detection
- Extraction of hardcoded environment variables from docker-compose.yml
- New ShowHardcoded Livewire component for displaying detected variables
- Enhanced UI for better environment variable management
The changes improve the user experience by automatically detecting and displaying
environment variables that are hardcoded in docker-compose files, allowing users
to override them if needed. The nested variable resolution ensures complex variable
dependencies are properly handled.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
---
.../Shared/EnvironmentVariable/All.php | 56 +++
.../EnvironmentVariable/ShowHardcoded.php | 31 ++
bootstrap/helpers/parsers.php | 353 ++++++++++++++----
bootstrap/helpers/services.php | 108 +++++-
bootstrap/helpers/shared.php | 52 +++
.../shared/environment-variable/all.blade.php | 27 +-
.../show-hardcoded.blade.php | 31 ++
...tractHardcodedEnvironmentVariablesTest.php | 147 ++++++++
.../NestedEnvironmentVariableParsingTest.php | 220 +++++++++++
tests/Unit/NestedEnvironmentVariableTest.php | 207 ++++++++++
10 files changed, 1145 insertions(+), 87 deletions(-)
create mode 100644 app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php
create mode 100644 resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php
create mode 100644 tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
create mode 100644 tests/Unit/NestedEnvironmentVariableParsingTest.php
create mode 100644 tests/Unit/NestedEnvironmentVariableTest.php
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
index 12a4cae79..b360798ff 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
@@ -79,6 +79,62 @@ public function getEnvironmentVariablesPreviewProperty()
return $this->resource->environment_variables_preview;
}
+ public function getHardcodedEnvironmentVariablesProperty()
+ {
+ return $this->getHardcodedVariables(false);
+ }
+
+ public function getHardcodedEnvironmentVariablesPreviewProperty()
+ {
+ return $this->getHardcodedVariables(true);
+ }
+
+ protected function getHardcodedVariables(bool $isPreview)
+ {
+ // Only for services and docker-compose applications
+ if ($this->resource->type() !== 'service' &&
+ ($this->resourceClass !== 'App\Models\Application' ||
+ ($this->resourceClass === 'App\Models\Application' && $this->resource->build_pack !== 'dockercompose'))) {
+ return collect([]);
+ }
+
+ $dockerComposeRaw = $this->resource->docker_compose_raw ?? $this->resource->docker_compose;
+
+ if (blank($dockerComposeRaw)) {
+ return collect([]);
+ }
+
+ // Extract all hard-coded variables
+ $hardcodedVars = extractHardcodedEnvironmentVariables($dockerComposeRaw);
+
+ // Filter out magic variables (SERVICE_FQDN_*, SERVICE_URL_*, SERVICE_NAME_*)
+ $hardcodedVars = $hardcodedVars->filter(function ($var) {
+ $key = $var['key'];
+
+ return ! str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_']);
+ });
+
+ // Filter out variables that exist in database (user has overridden/managed them)
+ // For preview, check against preview variables; for production, check against production variables
+ if ($isPreview) {
+ $managedKeys = $this->resource->environment_variables_preview()->pluck('key')->toArray();
+ } else {
+ $managedKeys = $this->resource->environment_variables()->where('is_preview', false)->pluck('key')->toArray();
+ }
+
+ $hardcodedVars = $hardcodedVars->filter(function ($var) use ($managedKeys) {
+ return ! in_array($var['key'], $managedKeys);
+ });
+
+ // Apply sorting based on is_env_sorting_enabled
+ if ($this->is_env_sorting_enabled) {
+ $hardcodedVars = $hardcodedVars->sortBy('key')->values();
+ }
+ // Otherwise keep order from docker-compose file
+
+ return $hardcodedVars;
+ }
+
public function getDevView()
{
$this->variables = $this->formatEnvironmentVariables($this->environmentVariables);
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php
new file mode 100644
index 000000000..3a49ce124
--- /dev/null
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php
@@ -0,0 +1,31 @@
+key = $this->env['key'];
+ $this->value = $this->env['value'] ?? null;
+ $this->comment = $this->env['comment'] ?? null;
+ $this->serviceName = $this->env['service_name'] ?? null;
+ }
+
+ public function render()
+ {
+ return view('livewire.project.shared.environment-variable.show-hardcoded');
+ }
+}
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index 3f942547f..5112e3abd 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -998,53 +998,139 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
} else {
if ($value->startsWith('$')) {
$isRequired = false;
- if ($value->contains(':-')) {
- $value = replaceVariables($value);
- $key = $value->before(':');
- $value = $value->after(':-');
- } elseif ($value->contains('-')) {
- $value = replaceVariables($value);
- $key = $value->before('-');
- $value = $value->after('-');
- } elseif ($value->contains(':?')) {
- $value = replaceVariables($value);
+ // Extract variable content between ${...} using balanced brace matching
+ $result = extractBalancedBraceContent($value->value(), 0);
- $key = $value->before(':');
- $value = $value->after(':?');
- $isRequired = true;
- } elseif ($value->contains('?')) {
- $value = replaceVariables($value);
+ if ($result !== null) {
+ $content = $result['content'];
+ $split = splitOnOperatorOutsideNested($content);
- $key = $value->before('?');
- $value = $value->after('?');
- $isRequired = true;
- }
- 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);
+ if ($split !== null) {
+ // Has default value syntax (:-, -, :?, or ?)
+ $varName = $split['variable'];
+ $operator = $split['operator'];
+ $defaultValue = $split['default'];
+ $isRequired = str_contains($operator, '?');
+
+ // Create the primary variable with its default (only if it doesn't exist)
+ $envVar = $resource->environment_variables()->firstOrCreate([
+ 'key' => $varName,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $defaultValue,
+ 'is_preview' => false,
+ 'is_required' => $isRequired,
+ ]);
+
+ // Add the variable to the environment so it will be shown in the deployable compose file
+ $environment[$varName] = $envVar->value;
+
+ // Recursively process nested variables in default value
+ if (str_contains($defaultValue, '${')) {
+ $searchPos = 0;
+ $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
+ while ($nestedResult !== null) {
+ $nestedContent = $nestedResult['content'];
+ $nestedSplit = splitOnOperatorOutsideNested($nestedContent);
+
+ // Determine the nested variable name
+ $nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent;
+
+ // Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system
+ $isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_');
+
+ if (! $isMagicVariable) {
+ if ($nestedSplit !== null) {
+ $nestedEnvVar = $resource->environment_variables()->firstOrCreate([
+ 'key' => $nestedSplit['variable'],
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $nestedSplit['default'],
+ 'is_preview' => false,
+ ]);
+ $environment[$nestedSplit['variable']] = $nestedEnvVar->value;
+ } else {
+ $nestedEnvVar = $resource->environment_variables()->firstOrCreate([
+ 'key' => $nestedContent,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'is_preview' => false,
+ ]);
+ $environment[$nestedContent] = $nestedEnvVar->value;
+ }
+ }
+
+ $searchPos = $nestedResult['end'] + 1;
+ if ($searchPos >= strlen($defaultValue)) {
+ break;
+ }
+ $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
+ }
+ }
+ } else {
+ // Simple variable reference without default
+ $parsedKeyValue = replaceVariables($value);
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $content,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'is_preview' => false,
+ 'is_required' => $isRequired,
+ ]);
+ // Add the variable to the environment
+ $environment[$content] = $value;
+ }
+ } else {
+ // Fallback to old behavior for malformed input (backward compatibility)
+ if ($value->contains(':-')) {
+ $value = replaceVariables($value);
+ $key = $value->before(':');
+ $value = $value->after(':-');
+ } elseif ($value->contains('-')) {
+ $value = replaceVariables($value);
+ $key = $value->before('-');
+ $value = $value->after('-');
+ } elseif ($value->contains(':?')) {
+ $value = replaceVariables($value);
+ $key = $value->before(':');
+ $value = $value->after(':?');
+ $isRequired = true;
+ } elseif ($value->contains('?')) {
+ $value = replaceVariables($value);
+ $key = $value->before('?');
+ $value = $value->after('?');
+ $isRequired = true;
+ }
+ if ($originalValue->value() === $value->value()) {
+ // This means the variable does not have a default value
+ $parsedKeyValue = replaceVariables($value);
+ $resource->environment_variables()->firstOrCreate([
+ 'key' => $parsedKeyValue,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'is_preview' => false,
+ 'is_required' => $isRequired,
+ ]);
+ $environment[$parsedKeyValue->value()] = $value;
+
+ continue;
+ }
$resource->environment_variables()->firstOrCreate([
- 'key' => $parsedKeyValue,
+ 'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
+ 'value' => $value,
'is_preview' => false,
'is_required' => $isRequired,
]);
- // 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([
- 'key' => $key,
- 'resourceable_type' => get_class($resource),
- 'resourceable_id' => $resource->id,
- ], [
- 'value' => $value,
- 'is_preview' => false,
- 'is_required' => $isRequired,
- ]);
}
}
}
@@ -1774,6 +1860,7 @@ function serviceParser(Service $resource): Collection
$serviceExists->fqdn = $url;
$serviceExists->save();
}
+ // Create FQDN variable
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
@@ -1784,9 +1871,22 @@ function serviceParser(Service $resource): Collection
'comment' => $envComments[$originalMagicKey] ?? null,
]);
+ // Also create the paired SERVICE_URL_* variable
+ $urlKey = 'SERVICE_URL_'.strtoupper($fqdnFor);
+ $resource->environment_variables()->updateOrCreate([
+ 'key' => $urlKey,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $url,
+ 'is_preview' => false,
+ 'comment' => $envComments[$urlKey] ?? null,
+ ]);
+
} elseif ($command->value() === 'URL') {
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
$url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid");
+ $fqdn = generateFqdn(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version);
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
// Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791)
@@ -1803,6 +1903,7 @@ function serviceParser(Service $resource): Collection
$serviceExists->fqdn = $url;
$serviceExists->save();
}
+ // Create URL variable
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
@@ -1813,6 +1914,18 @@ function serviceParser(Service $resource): Collection
'comment' => $envComments[$originalMagicKey] ?? null,
]);
+ // Also create the paired SERVICE_FQDN_* variable
+ $fqdnKey = 'SERVICE_FQDN_'.strtoupper($urlFor);
+ $resource->environment_variables()->updateOrCreate([
+ 'key' => $fqdnKey,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $fqdn,
+ 'is_preview' => false,
+ 'comment' => $envComments[$fqdnKey] ?? null,
+ ]);
+
} else {
$value = generateEnvValue($command, $resource);
$resource->environment_variables()->updateOrCreate([
@@ -2213,55 +2326,149 @@ function serviceParser(Service $resource): Collection
} else {
if ($value->startsWith('$')) {
$isRequired = false;
- if ($value->contains(':-')) {
- $value = replaceVariables($value);
- $key = $value->before(':');
- $value = $value->after(':-');
- } elseif ($value->contains('-')) {
- $value = replaceVariables($value);
- $key = $value->before('-');
- $value = $value->after('-');
- } elseif ($value->contains(':?')) {
- $value = replaceVariables($value);
+ // Extract variable content between ${...} using balanced brace matching
+ $result = extractBalancedBraceContent($value->value(), 0);
- $key = $value->before(':');
- $value = $value->after(':?');
- $isRequired = true;
- } elseif ($value->contains('?')) {
- $value = replaceVariables($value);
+ if ($result !== null) {
+ $content = $result['content'];
+ $split = splitOnOperatorOutsideNested($content);
- $key = $value->before('?');
- $value = $value->after('?');
- $isRequired = true;
- }
- 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);
+ if ($split !== null) {
+ // Has default value syntax (:-, -, :?, or ?)
+ $varName = $split['variable'];
+ $operator = $split['operator'];
+ $defaultValue = $split['default'];
+ $isRequired = str_contains($operator, '?');
+
+ // Create the primary variable with its default (only if it doesn't exist)
+ // Use firstOrCreate instead of updateOrCreate to avoid overwriting user edits
+ $envVar = $resource->environment_variables()->firstOrCreate([
+ 'key' => $varName,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $defaultValue,
+ '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[$varName] = $envVar->value;
+
+ // Recursively process nested variables in default value
+ if (str_contains($defaultValue, '${')) {
+ // Extract and create nested variables
+ $searchPos = 0;
+ $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
+ while ($nestedResult !== null) {
+ $nestedContent = $nestedResult['content'];
+ $nestedSplit = splitOnOperatorOutsideNested($nestedContent);
+
+ // Determine the nested variable name
+ $nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent;
+
+ // Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system
+ $isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_');
+
+ if (! $isMagicVariable) {
+ if ($nestedSplit !== null) {
+ // Create nested variable with its default (only if it doesn't exist)
+ $nestedEnvVar = $resource->environment_variables()->firstOrCreate([
+ 'key' => $nestedSplit['variable'],
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'value' => $nestedSplit['default'],
+ 'is_preview' => false,
+ ]);
+ // Add nested variable to environment
+ $environment[$nestedSplit['variable']] = $nestedEnvVar->value;
+ } else {
+ // Simple nested variable without default (only if it doesn't exist)
+ $nestedEnvVar = $resource->environment_variables()->firstOrCreate([
+ 'key' => $nestedContent,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'is_preview' => false,
+ ]);
+ // Add nested variable to environment
+ $environment[$nestedContent] = $nestedEnvVar->value;
+ }
+ }
+
+ // Look for more nested variables
+ $searchPos = $nestedResult['end'] + 1;
+ if ($searchPos >= strlen($defaultValue)) {
+ break;
+ }
+ $nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
+ }
+ }
+ } else {
+ // Simple variable reference without default
+ $resource->environment_variables()->updateOrCreate([
+ 'key' => $content,
+ 'resourceable_type' => get_class($resource),
+ 'resourceable_id' => $resource->id,
+ ], [
+ 'is_preview' => false,
+ 'is_required' => $isRequired,
+ 'comment' => $envComments[$originalKey] ?? null,
+ ]);
+ }
+ } else {
+ // Fallback to old behavior for malformed input (backward compatibility)
+ if ($value->contains(':-')) {
+ $value = replaceVariables($value);
+ $key = $value->before(':');
+ $value = $value->after(':-');
+ } elseif ($value->contains('-')) {
+ $value = replaceVariables($value);
+ $key = $value->before('-');
+ $value = $value->after('-');
+ } elseif ($value->contains(':?')) {
+ $value = replaceVariables($value);
+ $key = $value->before(':');
+ $value = $value->after(':?');
+ $isRequired = true;
+ } elseif ($value->contains('?')) {
+ $value = replaceVariables($value);
+ $key = $value->before('?');
+ $value = $value->after('?');
+ $isRequired = true;
+ }
+
+ if ($originalValue->value() === $value->value()) {
+ // This means the variable does not have a default value
+ $parsedKeyValue = replaceVariables($value);
+ $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()->updateOrCreate([
- 'key' => $parsedKeyValue,
+ 'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
+ 'value' => $value,
'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()->updateOrCreate([
- 'key' => $key,
- 'resourceable_type' => get_class($resource),
- 'resourceable_id' => $resource->id,
- ], [
- 'value' => $value,
- 'is_preview' => false,
- 'is_required' => $isRequired,
- 'comment' => $envComments[$originalKey] ?? null,
- ]);
}
}
}
diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php
index 3d2b61b86..64ec282f5 100644
--- a/bootstrap/helpers/services.php
+++ b/bootstrap/helpers/services.php
@@ -17,9 +17,115 @@ function collectRegex(string $name)
{
return "/{$name}\w+/";
}
+
+/**
+ * Extract content between balanced braces, handling nested braces properly.
+ *
+ * @param string $str The string to search
+ * @param int $startPos Position to start searching from
+ * @return array|null Array with 'content', 'start', and 'end' keys, or null if no balanced braces found
+ */
+function extractBalancedBraceContent(string $str, int $startPos = 0): ?array
+{
+ // Find opening brace
+ $openPos = strpos($str, '{', $startPos);
+ if ($openPos === false) {
+ return null;
+ }
+
+ // Track depth to find matching closing brace
+ $depth = 1;
+ $pos = $openPos + 1;
+ $len = strlen($str);
+
+ while ($pos < $len && $depth > 0) {
+ if ($str[$pos] === '{') {
+ $depth++;
+ } elseif ($str[$pos] === '}') {
+ $depth--;
+ }
+ $pos++;
+ }
+
+ if ($depth !== 0) {
+ // Unbalanced braces
+ return null;
+ }
+
+ return [
+ 'content' => substr($str, $openPos + 1, $pos - $openPos - 2),
+ 'start' => $openPos,
+ 'end' => $pos - 1,
+ ];
+}
+
+/**
+ * Split variable expression on operators (:-, -, :?, ?) while respecting nested braces.
+ *
+ * @param string $content The content to split (without outer ${...})
+ * @return array|null Array with 'variable', 'operator', and 'default' keys, or null if no operator found
+ */
+function splitOnOperatorOutsideNested(string $content): ?array
+{
+ $operators = [':-', '-', ':?', '?'];
+ $depth = 0;
+ $len = strlen($content);
+
+ for ($i = 0; $i < $len; $i++) {
+ if ($content[$i] === '{') {
+ $depth++;
+ } elseif ($content[$i] === '}') {
+ $depth--;
+ } elseif ($depth === 0) {
+ // Check for operators only at depth 0 (outside nested braces)
+ foreach ($operators as $op) {
+ if (substr($content, $i, strlen($op)) === $op) {
+ return [
+ 'variable' => substr($content, 0, $i),
+ 'operator' => $op,
+ 'default' => substr($content, $i + strlen($op)),
+ ];
+ }
+ }
+ }
+ }
+
+ return null;
+}
+
function replaceVariables(string $variable): Stringable
{
- return str($variable)->before('}')->replaceFirst('$', '')->replaceFirst('{', '');
+ // Handle ${VAR} syntax with proper brace matching
+ $str = str($variable);
+
+ // Handle ${VAR} format
+ if ($str->startsWith('${')) {
+ $result = extractBalancedBraceContent($variable, 0);
+ if ($result !== null) {
+ return str($result['content']);
+ }
+
+ // Fallback to old behavior for malformed input
+ return $str->before('}')->replaceFirst('$', '')->replaceFirst('{', '');
+ }
+
+ // Handle {VAR} format (from regex capture group without $)
+ if ($str->startsWith('{') && $str->endsWith('}')) {
+ return str(substr($variable, 1, -1));
+ }
+
+ // Handle {VAR format (from regex capture group, may be truncated)
+ if ($str->startsWith('{')) {
+ $result = extractBalancedBraceContent('$'.$variable, 0);
+ if ($result !== null) {
+ return str($result['content']);
+ }
+
+ // Fallback: remove { and get content before }
+ return $str->replaceFirst('{', '')->before('}');
+ }
+
+ return $str;
}
function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false)
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index f2f29bdc6..0437aaa70 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -3692,3 +3692,55 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon
return true;
}
+
+/**
+ * Extract hard-coded environment variables from docker-compose YAML.
+ *
+ * @param string $dockerComposeRaw Raw YAML content
+ * @return \Illuminate\Support\Collection Collection of arrays with: key, value, comment, service_name
+ */
+function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection
+{
+ if (blank($dockerComposeRaw)) {
+ return collect([]);
+ }
+
+ try {
+ $yaml = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
+ } catch (\Exception $e) {
+ // Malformed YAML - return empty collection
+ return collect([]);
+ }
+
+ $services = data_get($yaml, 'services', []);
+ if (empty($services)) {
+ return collect([]);
+ }
+
+ // Extract inline comments from raw YAML
+ $envComments = extractYamlEnvironmentComments($dockerComposeRaw);
+
+ $hardcodedVars = collect([]);
+
+ foreach ($services as $serviceName => $service) {
+ $environment = collect(data_get($service, 'environment', []));
+
+ if ($environment->isEmpty()) {
+ continue;
+ }
+
+ // Convert environment variables to key-value format
+ $environment = convertToKeyValueCollection($environment);
+
+ foreach ($environment as $key => $value) {
+ $hardcodedVars->push([
+ 'key' => $key,
+ 'value' => $value,
+ 'comment' => $envComments[$key] ?? null,
+ 'service_name' => $serviceName,
+ ]);
+ }
+ }
+
+ return $hardcodedVars;
+}
diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php
index f1d108703..a962b2cec 100644
--- a/resources/views/livewire/project/shared/environment-variable/all.blade.php
+++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php
@@ -41,19 +41,6 @@
@endif
- @if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose')
-
-
- Hardcoded variables are not shown here.
-
- {{-- If you would like to add a variable, you must add it to
- your compose file.
--}}
- @endif
@if ($view === 'normal')
@@ -66,6 +53,13 @@
@empty
No environment variables found.
@endforelse
+ @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty())
+ @foreach ($this->hardcodedEnvironmentVariables as $index => $env)
+
+ @endforeach
+ @endif
@if ($resource->type() === 'application' && $resource->environment_variables_preview->count() > 0 && $showPreview)
Preview Deployments Environment Variables
@@ -75,6 +69,13 @@
@endforeach
+ @if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
+ @foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env)
+
+ @endforeach
+ @endif
@endif
@else