feat: add comprehensive environment variable parsing with nested resolution and hardcoded variable detection
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 <noreply@anthropic.com>
This commit is contained in:
parent
d67fcd1dff
commit
208f0eac99
10 changed files with 1145 additions and 87 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ShowHardcoded extends Component
|
||||
{
|
||||
public array $env;
|
||||
|
||||
public string $key;
|
||||
|
||||
public ?string $value = null;
|
||||
|
||||
public ?string $comment = null;
|
||||
|
||||
public ?string $serviceName = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,19 +41,6 @@
|
|||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose')
|
||||
<div class="flex items-center gap-1 pt-4 dark:text-warning text-coollabs">
|
||||
<svg class="hidden w-4 h-4 dark:text-warning lg:block" viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor"
|
||||
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16">
|
||||
</path>
|
||||
</svg>
|
||||
Hardcoded variables are not shown here.
|
||||
</div>
|
||||
{{-- <div class="pb-4 dark:text-warning text-coollabs">If you would like to add a variable, you must add it to
|
||||
your compose file.</div> --}}
|
||||
@endif
|
||||
</div>
|
||||
@if ($view === 'normal')
|
||||
<div>
|
||||
|
|
@ -66,6 +53,13 @@
|
|||
@empty
|
||||
<div>No environment variables found.</div>
|
||||
@endforelse
|
||||
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty())
|
||||
@foreach ($this->hardcodedEnvironmentVariables as $index => $env)
|
||||
<livewire:project.shared.environment-variable.show-hardcoded
|
||||
wire:key="hardcoded-prod-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}"
|
||||
:env="$env" />
|
||||
@endforeach
|
||||
@endif
|
||||
@if ($resource->type() === 'application' && $resource->environment_variables_preview->count() > 0 && $showPreview)
|
||||
<div>
|
||||
<h3>Preview Deployments Environment Variables</h3>
|
||||
|
|
@ -75,6 +69,13 @@
|
|||
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env"
|
||||
:type="$resource->type()" />
|
||||
@endforeach
|
||||
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
|
||||
@foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env)
|
||||
<livewire:project.shared.environment-variable.show-hardcoded
|
||||
wire:key="hardcoded-preview-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}"
|
||||
:env="$env" />
|
||||
@endforeach
|
||||
@endif
|
||||
@endif
|
||||
@else
|
||||
<form wire:submit.prevent='submit' class="flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<div>
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 p-4 bg-white border lg:items-start dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-normal rounded dark:bg-coolgray-400/50 bg-neutral-200 dark:text-neutral-400 text-neutral-600">
|
||||
Hardcoded env
|
||||
</span>
|
||||
@if($serviceName)
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-normal rounded dark:bg-coolgray-400/50 bg-neutral-200 dark:text-neutral-400 text-neutral-600">
|
||||
Service: {{ $serviceName }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div class="flex flex-col w-full gap-2 lg:flex-row">
|
||||
<x-forms.input disabled id="key" />
|
||||
@if($value !== null && $value !== '')
|
||||
<x-forms.input disabled type="password" value="{{ $value }}" />
|
||||
@else
|
||||
<x-forms.input disabled value="(inherited from host)" />
|
||||
@endif
|
||||
</div>
|
||||
@if($comment)
|
||||
<x-forms.input disabled value="{{ $comment }}" label="Comment"
|
||||
helper="Documentation for this environment variable." />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
147
tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
Normal file
147
tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<?php
|
||||
|
||||
test('extracts simple environment variables from docker-compose', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('NODE_ENV')
|
||||
->and($result[0]['value'])->toBe('production')
|
||||
->and($result[0]['service_name'])->toBe('app')
|
||||
->and($result[1]['key'])->toBe('PORT')
|
||||
->and($result[1]['value'])->toBe('3000')
|
||||
->and($result[1]['service_name'])->toBe('app');
|
||||
});
|
||||
|
||||
test('extracts environment variables with inline comments', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- NODE_ENV=production # Production environment
|
||||
- DEBUG=false # Disable debug mode
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['comment'])->toBe('Production environment')
|
||||
->and($result[1]['comment'])->toBe('Disable debug mode');
|
||||
});
|
||||
|
||||
test('handles multiple services', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- APP_ENV=prod
|
||||
db:
|
||||
environment:
|
||||
- POSTGRES_DB=mydb
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('APP_ENV')
|
||||
->and($result[0]['service_name'])->toBe('app')
|
||||
->and($result[1]['key'])->toBe('POSTGRES_DB')
|
||||
->and($result[1]['service_name'])->toBe('db');
|
||||
});
|
||||
|
||||
test('handles associative array format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('NODE_ENV')
|
||||
->and($result[0]['value'])->toBe('production')
|
||||
->and($result[1]['key'])->toBe('PORT')
|
||||
->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML
|
||||
});
|
||||
|
||||
test('handles environment variables without values', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- API_KEY
|
||||
- DEBUG=false
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('API_KEY')
|
||||
->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null
|
||||
->and($result[1]['key'])->toBe('DEBUG')
|
||||
->and($result[1]['value'])->toBe('false');
|
||||
});
|
||||
|
||||
test('returns empty collection for malformed YAML', function () {
|
||||
$yaml = 'invalid: yaml: content::: [[[';
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns empty collection for empty compose file', function () {
|
||||
$result = extractHardcodedEnvironmentVariables('');
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns empty collection when no services defined', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: '3.8'
|
||||
networks:
|
||||
default:
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns empty collection when service has no environment section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('handles mixed associative and array format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
PORT: 3000
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
// Mixed format is invalid YAML and returns empty collection
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
220
tests/Unit/NestedEnvironmentVariableParsingTest.php
Normal file
220
tests/Unit/NestedEnvironmentVariableParsingTest.php
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Tests for nested environment variable parsing in Docker Compose files.
|
||||
*
|
||||
* These tests verify that the parser correctly handles nested variable substitution syntax
|
||||
* like ${API_URL:-${SERVICE_URL_YOLO}/api} where defaults can contain other variable references.
|
||||
*/
|
||||
test('nested variable syntax is parsed correctly', function () {
|
||||
// Test the exact scenario from the bug report
|
||||
$input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
|
||||
expect($result)->not->toBeNull()
|
||||
->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split)->not->toBeNull()
|
||||
->and($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
|
||||
});
|
||||
|
||||
test('replaceVariables correctly extracts nested variable content', function () {
|
||||
// Before the fix, this would incorrectly extract only up to the first closing brace
|
||||
$result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
|
||||
|
||||
// Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO"
|
||||
expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
|
||||
->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated
|
||||
});
|
||||
|
||||
test('nested defaults with path concatenation work', function () {
|
||||
$input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('REDIS_URL')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0');
|
||||
});
|
||||
|
||||
test('deeply nested variables are handled', function () {
|
||||
// Three levels of nesting
|
||||
$input = '${A:-${B:-${C}}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
|
||||
expect($result['content'])->toBe('A:-${B:-${C}}');
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['default'])->toBe('${B:-${C}}');
|
||||
});
|
||||
|
||||
test('multiple nested variables in default value', function () {
|
||||
// Default value contains multiple variable references
|
||||
$input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('API')
|
||||
->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api');
|
||||
});
|
||||
|
||||
test('nested variables with different operators', function () {
|
||||
// Nested variable uses different operator
|
||||
$input = '${API_URL:-${SERVICE_URL?error message}/api}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL?error message}/api');
|
||||
});
|
||||
|
||||
test('backward compatibility with simple variables', function () {
|
||||
// Simple variable without nesting should still work
|
||||
$input = '${VAR}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
|
||||
expect($result->value())->toBe('VAR');
|
||||
});
|
||||
|
||||
test('backward compatibility with single-level defaults', function () {
|
||||
// Single-level default without nesting
|
||||
$input = '${VAR:-default_value}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
|
||||
expect($result->value())->toBe('VAR:-default_value');
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['default'])->toBe('default_value');
|
||||
});
|
||||
|
||||
test('backward compatibility with dash operator', function () {
|
||||
$input = '${VAR-default}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['operator'])->toBe('-');
|
||||
});
|
||||
|
||||
test('backward compatibility with colon question operator', function () {
|
||||
$input = '${VAR:?error message}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['operator'])->toBe(':?')
|
||||
->and($split['default'])->toBe('error message');
|
||||
});
|
||||
|
||||
test('backward compatibility with question operator', function () {
|
||||
$input = '${VAR?error}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['operator'])->toBe('?')
|
||||
->and($split['default'])->toBe('error');
|
||||
});
|
||||
|
||||
test('SERVICE_URL magic variables in nested defaults', function () {
|
||||
// Real-world scenario: SERVICE_URL_* magic variable used in nested default
|
||||
$input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('DATABASE_URL')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb');
|
||||
|
||||
// Extract the nested SERVICE_URL variable
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
|
||||
expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES');
|
||||
});
|
||||
|
||||
test('SERVICE_FQDN magic variables in nested defaults', function () {
|
||||
$input = '${API_HOST:-${SERVICE_FQDN_API}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${SERVICE_FQDN_API}');
|
||||
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
|
||||
expect($nestedResult['content'])->toBe('SERVICE_FQDN_API');
|
||||
});
|
||||
|
||||
test('complex real-world example', function () {
|
||||
// Complex real-world scenario from the bug report
|
||||
$input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
|
||||
|
||||
// Step 1: Extract outer variable content
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
|
||||
// Step 2: Split on operator
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
expect($split['variable'])->toBe('API_URL');
|
||||
expect($split['operator'])->toBe(':-');
|
||||
expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
|
||||
|
||||
// Step 3: Extract nested variable
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO');
|
||||
|
||||
// This verifies that:
|
||||
// 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api"
|
||||
// 2. SERVICE_URL_YOLO should be recognized and created as magic variable
|
||||
});
|
||||
|
||||
test('empty nested default values', function () {
|
||||
$input = '${VAR:-${NESTED:-}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${NESTED:-}');
|
||||
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
$nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']);
|
||||
|
||||
expect($nestedSplit['default'])->toBe('');
|
||||
});
|
||||
|
||||
test('nested variables with complex paths', function () {
|
||||
$input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json');
|
||||
});
|
||||
|
||||
test('operator precedence with nesting', function () {
|
||||
// The first :- at depth 0 should be used, not the one inside nested braces
|
||||
$input = '${A:-${B:-default}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
// Should split on first :- (at depth 0)
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:-default}'); // Not split here
|
||||
});
|
||||
207
tests/Unit/NestedEnvironmentVariableTest.php
Normal file
207
tests/Unit/NestedEnvironmentVariableTest.php
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
use function PHPUnit\Framework\assertNotNull;
|
||||
use function PHPUnit\Framework\assertNull;
|
||||
|
||||
test('extractBalancedBraceContent extracts content from simple variable', function () {
|
||||
$result = extractBalancedBraceContent('${VAR}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR')
|
||||
->and($result['start'])->toBe(1)
|
||||
->and($result['end'])->toBe(5);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles nested braces', function () {
|
||||
$result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
|
||||
->and($result['start'])->toBe(1)
|
||||
->and($result['end'])->toBe(34); // Position of closing }
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles triple nesting', function () {
|
||||
$result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('A:-${B:-${C}}');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for unbalanced braces', function () {
|
||||
$result = extractBalancedBraceContent('${VAR', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null when no braces', function () {
|
||||
$result = extractBalancedBraceContent('VAR', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles startPos parameter', function () {
|
||||
$result = extractBalancedBraceContent('foo ${VAR} bar', 4);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR')
|
||||
->and($result['start'])->toBe(5)
|
||||
->and($result['end'])->toBe(9);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested splits on :- operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('API_URL:-default_value');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('default_value');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles nested defaults', function () {
|
||||
$split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles dash operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR-default');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe('-')
|
||||
->and($split['default'])->toBe('default');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles colon question operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:?error message');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe(':?')
|
||||
->and($split['default'])->toBe('error message');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles question operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR?error');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe('?')
|
||||
->and($split['default'])->toBe('error');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested returns null for simple variable', function () {
|
||||
$split = splitOnOperatorOutsideNested('SIMPLE_VAR');
|
||||
|
||||
assertNull($split);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () {
|
||||
$split = splitOnOperatorOutsideNested('A:-${B:-default}');
|
||||
|
||||
assertNotNull($split);
|
||||
// Should split on first :- (outside nested braces), not the one inside ${B:-default}
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:-default}');
|
||||
});
|
||||
|
||||
test('replaceVariables handles simple variable', function () {
|
||||
$result = replaceVariables('${VAR}');
|
||||
|
||||
expect($result->value())->toBe('VAR');
|
||||
});
|
||||
|
||||
test('replaceVariables handles nested expressions', function () {
|
||||
$result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
|
||||
|
||||
expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
});
|
||||
|
||||
test('replaceVariables handles variable with default', function () {
|
||||
$result = replaceVariables('${API_URL:-http://localhost}');
|
||||
|
||||
expect($result->value())->toBe('API_URL:-http://localhost');
|
||||
});
|
||||
|
||||
test('replaceVariables returns unchanged for non-variable string', function () {
|
||||
$result = replaceVariables('not_a_variable');
|
||||
|
||||
expect($result->value())->toBe('not_a_variable');
|
||||
});
|
||||
|
||||
test('replaceVariables handles triple nesting', function () {
|
||||
$result = replaceVariables('${A:-${B:-${C}}}');
|
||||
|
||||
expect($result->value())->toBe('A:-${B:-${C}}');
|
||||
});
|
||||
|
||||
test('replaceVariables fallback works for malformed input', function () {
|
||||
// When braces are unbalanced, it falls back to old behavior
|
||||
$result = replaceVariables('${VAR');
|
||||
|
||||
// Old behavior would extract everything before first }
|
||||
// But since there's no }, it will extract 'VAR' (removing ${)
|
||||
expect($result->value())->toContain('VAR');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles complex nested expression', function () {
|
||||
$result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles complex nested expression', function () {
|
||||
$split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('API')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent finds second variable in string', function () {
|
||||
$str = '${VAR1} and ${VAR2}';
|
||||
|
||||
// First variable
|
||||
$result1 = extractBalancedBraceContent($str, 0);
|
||||
assertNotNull($result1);
|
||||
expect($result1['content'])->toBe('VAR1');
|
||||
|
||||
// Second variable
|
||||
$result2 = extractBalancedBraceContent($str, $result1['end'] + 1);
|
||||
assertNotNull($result2);
|
||||
expect($result2['content'])->toBe('VAR2');
|
||||
});
|
||||
|
||||
test('replaceVariables handles empty default value', function () {
|
||||
$result = replaceVariables('${VAR:-}');
|
||||
|
||||
expect($result->value())->toBe('VAR:-');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles empty default value', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:-');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('');
|
||||
});
|
||||
|
||||
test('replaceVariables handles brace format without dollar sign', function () {
|
||||
// This format is used by the regex capture group in magic variable detection
|
||||
$result = replaceVariables('{SERVICE_URL_YOLO}');
|
||||
expect($result->value())->toBe('SERVICE_URL_YOLO');
|
||||
});
|
||||
|
||||
test('replaceVariables handles truncated brace format', function () {
|
||||
// When regex captures {VAR from a larger expression, no closing brace
|
||||
$result = replaceVariables('{API_URL');
|
||||
expect($result->value())->toBe('API_URL');
|
||||
});
|
||||
Loading…
Reference in a new issue