fix: improve Compose environment variable UI

This commit is contained in:
Andras Bacsai
2026-08-15 11:00:15 +02:00
parent ec8a24d178
commit bb4373487e
15 changed files with 941 additions and 35 deletions
+40
View File
@@ -1389,6 +1389,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
});
foreach ($runtime_environment_variables as $env) {
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
continue;
}
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
}
@@ -1456,6 +1460,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
});
foreach ($runtime_environment_variables_preview as $env) {
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
continue;
}
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
}
@@ -1470,6 +1478,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $env->is_runtime && ! in_array($env->key, $previewKeys);
});
foreach ($fallback_production_vars as $env) {
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
continue;
}
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
}
}
@@ -1499,6 +1511,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|| $key->startsWith('SERVICE_NAME_');
}
private function shouldOmitBlankComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool
{
if ($this->build_pack !== 'dockercompose' || filled($environmentVariable->getResolvedValueWithServer($this->mainServer))) {
return false;
}
return dockerComposeEnvironmentVariableRequiresUnsetWhenBlank(
$this->application->docker_compose_raw ?? $this->application->docker_compose,
$environmentVariable->key
);
}
private function save_runtime_environment_variables()
{
// This method saves the .env file with ALL runtime variables
@@ -1722,6 +1746,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($sorted_environment_variables as $env) {
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
continue;
}
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
continue;
}
@@ -1777,6 +1805,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($sorted_environment_variables as $env) {
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
continue;
}
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
continue;
}
@@ -3192,6 +3224,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
foreach ($envs as $env) {
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
$this->env_args->put($env->key, $resolvedValue);
@@ -3208,6 +3244,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
foreach ($envs as $env) {
if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) {
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
$this->env_args->put($env->key, $resolvedValue);
@@ -54,6 +54,8 @@ class All extends Component
*/
public bool $readyToLoad = false;
private ?Collection $composeEnvironmentAssignments = null;
protected $listeners = [
'saveKey' => 'submit',
'refreshEnvs',
@@ -66,6 +68,20 @@ class All extends Component
$this->clearEnvironmentVariableCaches();
}
public function focusComposeEnvironmentVariable(string $key): void
{
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key) !== 1) {
return;
}
$this->search = $key;
$this->variableFilters = [];
$this->serviceFilters = [];
$this->environmentFilter = 'all';
$this->page = 1;
$this->clearEnvironmentVariableCaches();
}
private function clearEnvironmentVariableCaches(): void
{
unset($this->environmentVariables);
@@ -666,12 +682,16 @@ class All extends Component
'kind' => 'managed',
'scope' => $environmentVariable->is_preview ? 'preview' : 'production',
'environmentVariable' => $environmentVariable,
'composeInfo' => $this->composeInfoFor($environmentVariable->key),
];
}
private function hardcodedEnvironmentVariableRow(array $environmentVariable, bool $isPreview, int $index): array
{
$scope = $isPreview ? 'preview' : 'production';
$references = $this->composeReferences($environmentVariable['value'] ?? null);
$environmentVariable['compose_type'] = $references === [] ? 'literal' : 'derived';
$environmentVariable['references'] = $references;
return [
'id' => 'hardcoded-'.$scope.'-'.$environmentVariable['key'].'-'.($environmentVariable['service_name'] ?? 'default').'-'.$index,
@@ -778,24 +798,79 @@ class All extends Component
private function isSelfReferencingComposeVariable(array $variable): bool
{
$value = $variable['value'] ?? null;
if (! is_string($value)) {
return false;
}
if ($value === '$'.$variable['key']) {
if ($variable['is_passthrough'] ?? false) {
return true;
}
$reference = extractBalancedBraceContent($value);
if ($reference === null || $reference['start'] !== 1 || $reference['end'] !== strlen($value) - 1) {
return false;
$reference = $this->directComposeReference($variable['value'] ?? null);
return $reference !== null && $reference['key'] === $variable['key'];
}
/** @return array{services: list<string>, default: ?string, operator: ?string, required: bool}|null */
private function composeInfoFor(string $key): ?array
{
return dockerComposeEnvironmentVariableInfo(
$this->resource->docker_compose_raw ?? $this->resource->docker_compose,
$key
);
}
private function composeEnvironmentAssignments(): Collection
{
if ($this->composeEnvironmentAssignments !== null) {
return $this->composeEnvironmentAssignments;
}
$splitReference = splitOnOperatorOutsideNested($reference['content']);
$referencedKey = $splitReference['variable'] ?? $reference['content'];
$dockerCompose = $this->resource->docker_compose_raw ?? $this->resource->docker_compose;
return $referencedKey === $variable['key'];
return $this->composeEnvironmentAssignments = blank($dockerCompose)
? collect()
: extractHardcodedEnvironmentVariables($dockerCompose);
}
/** @return list<string> */
private function composeReferences(mixed $value): array
{
if (! is_string($value) || ! str_contains($value, '$')) {
return [];
}
return extractDockerComposeEnvironmentVariableReferences($value)->all();
}
/** @return array{key: string, default: ?string, operator: ?string, required: bool}|null */
private function directComposeReference(mixed $value): ?array
{
if (! is_string($value)) {
return null;
}
if (preg_match('/^\$([A-Za-z_][A-Za-z0-9_]*)$/', $value, $bareMatch) === 1) {
return ['key' => $bareMatch[1], 'default' => null, 'operator' => null, 'required' => false];
}
$balancedReference = extractBalancedBraceContent($value);
if ($balancedReference === null || $balancedReference['start'] !== 1 || $balancedReference['end'] !== strlen($value) - 1) {
return null;
}
$splitReference = splitOnOperatorOutsideNested($balancedReference['content']);
if ($splitReference === null) {
return [
'key' => $balancedReference['content'],
'default' => null,
'operator' => null,
'required' => false,
];
}
return [
'key' => $splitReference['variable'],
'default' => str_contains($splitReference['operator'], '-') ? $splitReference['default'] : null,
'operator' => $splitReference['operator'],
'required' => str_contains($splitReference['operator'], '?'),
];
}
public function getDevView()
@@ -21,6 +21,9 @@ class Show extends Component
{
public bool $showEnvironmentType = true;
/** @var array{services: list<string>, default: ?string, operator: ?string, required: bool}|null */
public ?array $composeInfo = null;
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
public $parameters;
@@ -221,6 +224,9 @@ class Show extends Component
private function hydrateValueFields(): void
{
$this->value = $this->env->value;
if (($this->composeInfo['default'] ?? null) === $this->value) {
$this->value = null;
}
$this->is_shared = (bool) ($this->env->is_shared ?? false);
if ($this->is_shared) {
@@ -284,6 +290,17 @@ class Show extends Component
$this->authorize('update', $this->env);
$this->loadValues();
$composeInfo = $this->serverComposeInfo();
if ($composeInfo !== null) {
$storedKey = $this->env->fresh()?->key;
if ($storedKey === null || $this->key !== $storedKey) {
$this->key = $storedKey ?? $this->key;
$this->dispatch('error', 'Compose-linked environment variable names cannot be changed.');
return;
}
}
if (! $this->isSharedVariable && $this->is_required && str($this->value)->isEmpty()) {
$oldValue = $this->env->getOriginal('value');
$this->value = $oldValue;
@@ -292,6 +309,10 @@ class Show extends Component
return;
}
if (! $this->isSharedVariable && blank($this->value) && ($composeInfo['default'] ?? null) !== null) {
$this->value = $composeInfo['default'];
}
$this->serialize();
$this->syncData(true);
$this->syncData(false);
@@ -303,6 +324,29 @@ class Show extends Component
}
}
/** @return array{services: list<string>, default: ?string, operator: ?string, required: bool}|null */
private function serverComposeInfo(): ?array
{
if ($this->isSharedVariable) {
return null;
}
$storedEnvironmentVariable = $this->env->fresh();
if ($storedEnvironmentVariable === null) {
return null;
}
$resource = $storedEnvironmentVariable->resourceable;
if (! $resource instanceof Application && ! $resource instanceof Service) {
return null;
}
return dockerComposeEnvironmentVariableInfo(
$resource->docker_compose_raw ?? $resource->docker_compose,
$storedEnvironmentVariable->key
);
}
#[Computed]
public function availableSharedVariables(): array
{
@@ -20,12 +20,19 @@ class ShowHardcoded extends Component
public bool $isPreview = false;
public string $composeType = 'literal';
/** @var list<string> */
public array $references = [];
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;
$this->composeType = $this->env['compose_type'] ?? 'literal';
$this->references = $this->env['references'] ?? [];
}
public function render()
+1
View File
@@ -110,6 +110,7 @@ class EnvironmentVariable extends BaseModel
'is_literal' => $environment_variable->is_literal ?? false,
'is_runtime' => $environment_variable->is_runtime ?? false,
'is_buildtime' => $environment_variable->is_buildtime ?? false,
'is_required' => $environment_variable->is_required ?? false,
'comment' => $environment_variable->comment,
'resourceable_type' => Application::class,
'resourceable_id' => $environment_variable->resourceable_id,
+5 -1
View File
@@ -1631,7 +1631,11 @@ class Service extends BaseModel
return 3;
});
foreach ($sorted as $env) {
$envs->push("{$env->key}={$env->real_value}");
$resolvedValue = $env->real_value;
if (blank($resolvedValue) && dockerComposeEnvironmentVariableRequiresUnsetWhenBlank($this->docker_compose_raw ?? $this->docker_compose, $env->key)) {
continue;
}
$envs->push("{$env->key}={$resolvedValue}");
}
if ($envs->count() === 0) {
$commands[] = 'touch .env';
+73 -16
View File
@@ -983,6 +983,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
$passthroughEnvironmentKeys = extractDockerComposePassthroughKeys(data_get($service, 'environment', []));
$normalEnvironments = $environment->diffKeys($allMagicEnvironments);
$normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
return ! str($value)->startsWith('SERVICE_');
@@ -992,6 +993,35 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$value = str($value);
$originalValue = $value;
$parsedValue = replaceVariables($value);
if ($value->isEmpty() && $passthroughEnvironmentKeys->contains($key->value())) {
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
]);
continue;
}
extractDockerComposeEnvironmentVariableReferences($value->value())
->reject(fn (string $reference): bool => str_starts_with($reference, 'SERVICE_'))
->each(function (string $reference) use ($resource): void {
$environmentVariable = $resource->environment_variables()->firstOrCreate([
'key' => $reference,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
]);
$isRequired = dockerComposeEnvironmentVariableIsRequired(
$resource->docker_compose_raw ?? $resource->docker_compose,
$reference
);
if ((bool) $environmentVariable->is_required !== $isRequired) {
$environmentVariable->update(['is_required' => $isRequired]);
}
});
if ($value->startsWith('$SERVICE_')) {
$resource->environment_variables()->firstOrCreate([
'key' => $key,
@@ -1044,13 +1074,14 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $defaultValue,
'value' => $isRequired ? null : $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;
if ($isRequired && $envVar->value === $defaultValue) {
$envVar->update(['value' => null, 'is_required' => true]);
}
// Recursively process nested variables in default value
if (str_contains($defaultValue, '${')) {
@@ -1076,7 +1107,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'value' => $nestedSplit['default'],
'is_preview' => false,
]);
$environment[$nestedSplit['variable']] = $nestedEnvVar->value;
} else {
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedContent,
@@ -1085,7 +1115,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
], [
'is_preview' => false,
]);
$environment[$nestedContent] = $nestedEnvVar->value;
}
}
@@ -1107,8 +1136,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'is_preview' => false,
'is_required' => $isRequired,
]);
// Add the variable to the environment using the saved DB value
$environment[$content] = $envVar->value;
}
} else {
// Fallback to old behavior for malformed input (backward compatibility)
@@ -1524,6 +1551,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
data_forget($resource, 'environment_variables_preview');
$resource->save();
syncDockerComposeEnvironmentVariableRequiredState($resource);
return $topLevel;
}
@@ -2340,6 +2369,7 @@ function serviceParser(Service $resource): Collection
}
}
$passthroughEnvironmentKeys = extractDockerComposePassthroughKeys(data_get($service, 'environment', []));
$normalEnvironments = $environment->diffKeys($allMagicEnvironments);
$normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
return ! str($value)->startsWith('SERVICE_');
@@ -2350,6 +2380,36 @@ function serviceParser(Service $resource): Collection
$value = str($value);
$originalValue = $value;
$parsedValue = replaceVariables($value);
if ($value->isEmpty() && $passthroughEnvironmentKeys->contains($key->value())) {
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
continue;
}
extractDockerComposeEnvironmentVariableReferences($value->value())
->reject(fn (string $reference): bool => str_starts_with($reference, 'SERVICE_'))
->each(function (string $reference) use ($resource): void {
$environmentVariable = $resource->environment_variables()->firstOrCreate([
'key' => $reference,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
]);
$isRequired = dockerComposeEnvironmentVariableIsRequired(
$resource->docker_compose_raw ?? $resource->docker_compose,
$reference
);
if ((bool) $environmentVariable->is_required !== $isRequired) {
$environmentVariable->update(['is_required' => $isRequired]);
}
});
if ($parsedValue->startsWith('SERVICE_')) {
$resource->environment_variables()->updateOrCreate([
'key' => $key,
@@ -2405,14 +2465,15 @@ function serviceParser(Service $resource): Collection
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $defaultValue,
'value' => $isRequired ? null : $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;
if ($isRequired && $envVar->value === $defaultValue) {
$envVar->update(['value' => null, 'is_required' => true]);
}
// Recursively process nested variables in default value
if (str_contains($defaultValue, '${')) {
@@ -2440,8 +2501,6 @@ function serviceParser(Service $resource): Collection
'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([
@@ -2451,8 +2510,6 @@ function serviceParser(Service $resource): Collection
], [
'is_preview' => false,
]);
// Add nested variable to environment
$environment[$nestedContent] = $nestedEnvVar->value;
}
}
@@ -2476,8 +2533,6 @@ function serviceParser(Service $resource): Collection
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment using the saved DB value
$environment[$content] = $envVar->value;
}
} else {
// Fallback to old behavior for malformed input (backward compatibility)
@@ -2784,5 +2839,7 @@ function serviceParser(Service $resource): Collection
data_forget($resource, 'environment_variables_preview');
$resource->save();
syncDockerComposeEnvironmentVariableRequiredState($resource);
return $topLevel;
}
+142
View File
@@ -4630,6 +4630,8 @@ function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collect
continue;
}
$passthroughKeys = extractDockerComposePassthroughKeys($environment);
// Convert environment variables to key-value format
$environment = convertToKeyValueCollection($environment);
@@ -4639,6 +4641,7 @@ function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collect
'value' => $value,
'comment' => $envComments[$key] ?? null,
'service_name' => $serviceName,
'is_passthrough' => $passthroughKeys->contains($key),
]);
}
}
@@ -4646,6 +4649,145 @@ function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collect
return $hardcodedVars;
}
/** @return Collection<int, string> */
function extractDockerComposePassthroughKeys(mixed $environment): Collection
{
return collect($environment)
->map(function (mixed $value, mixed $key): ?string {
if (is_numeric($key)) {
return is_string($value) && ! str_contains($value, '=') ? $value : null;
}
return $value === null ? (string) $key : null;
})
->filter()
->unique()
->values();
}
/** @return Collection<int, string> */
function extractDockerComposeEnvironmentVariableReferences(mixed $value): Collection
{
if (! is_string($value) || ! str_contains($value, '$')) {
return collect();
}
preg_match_all('/(?<!\$)\$\{([A-Za-z_][A-Za-z0-9_]*)/', $value, $bracedMatches);
preg_match_all('/(?<!\$)\$(?!\{)([A-Za-z_][A-Za-z0-9_]*)/', $value, $bareMatches);
return collect([...$bracedMatches[1], ...$bareMatches[1]])
->unique()
->values();
}
/** @return array{services: list<string>, default: ?string, operator: ?string, required: bool}|null */
function dockerComposeEnvironmentVariableInfo(?string $dockerCompose, string $key): ?array
{
if (blank($dockerCompose)) {
return null;
}
$services = [];
$default = null;
$operator = null;
$required = false;
foreach (extractHardcodedEnvironmentVariables($dockerCompose) as $assignment) {
$value = $assignment['value'] ?? null;
$isPassthrough = ($assignment['is_passthrough'] ?? false) && $assignment['key'] === $key;
if (! $isPassthrough && ! extractDockerComposeEnvironmentVariableReferences($value)->contains($key)) {
continue;
}
if (filled($assignment['service_name'] ?? null)) {
$services[] = $assignment['service_name'];
}
if (is_string($value)) {
$offset = 0;
while (($referenceStart = strpos($value, '${', $offset)) !== false) {
if ($referenceStart > 0 && $value[$referenceStart - 1] === '$') {
$offset = $referenceStart + 2;
continue;
}
$balancedReference = extractBalancedBraceContent(substr($value, $referenceStart));
if ($balancedReference === null) {
break;
}
$splitReference = splitOnOperatorOutsideNested($balancedReference['content']);
if ($splitReference !== null && $splitReference['variable'] === $key) {
$matchedOperator = $splitReference['operator'];
$operator ??= $matchedOperator;
if (str_contains($matchedOperator, '-')) {
$default ??= $splitReference['default'];
}
$required = $required || str_contains($matchedOperator, '?');
}
$offset = $referenceStart + 2;
}
}
}
if ($services === []) {
return null;
}
return [
'services' => array_values(array_unique($services)),
'default' => $default,
'operator' => $operator,
'required' => $required,
];
}
function dockerComposeEnvironmentVariableIsRequired(?string $dockerCompose, string $key): bool
{
if (blank($dockerCompose)) {
return false;
}
$key = preg_quote($key, '/');
return preg_match('/(?<!\$)\$\{'.$key.'(?::\?|\?)/', $dockerCompose) === 1;
}
function dockerComposeEnvironmentVariableRequiresUnsetWhenBlank(?string $dockerCompose, string $key): bool
{
if (blank($dockerCompose)) {
return false;
}
$assignments = extractHardcodedEnvironmentVariables($dockerCompose);
if ($assignments->contains(fn (array $assignment): bool => $assignment['key'] === $key && $assignment['is_passthrough'])) {
return true;
}
$key = preg_quote($key, '/');
return preg_match('/(?<!\$)\$\{'.$key.'(?:-|\?)/', $dockerCompose) === 1;
}
function syncDockerComposeEnvironmentVariableRequiredState(Application|Service $resource): void
{
$dockerCompose = $resource->docker_compose_raw ?? $resource->docker_compose;
$environmentVariables = $resource->environment_variables()->get();
if ($resource instanceof Application) {
$environmentVariables = $environmentVariables->concat($resource->environment_variables_preview()->get());
}
$environmentVariables->each(function (EnvironmentVariable $environmentVariable) use ($dockerCompose): void {
$isRequired = dockerComposeEnvironmentVariableIsRequired($dockerCompose, $environmentVariable->key);
if ((bool) $environmentVariable->is_required !== $isRequired) {
$environmentVariable->update(['is_required' => $isRequired]);
}
});
}
/**
* Downsample metrics using the Largest-Triangle-Three-Buckets (LTTB) algorithm.
* This preserves the visual shape of the data better than simple averaging.
@@ -12,7 +12,8 @@
}
$activeFilterText = $activeFilterLabels->implode(', ');
@endphp
<div class="flex flex-col gap-4" wire:init="loadEnvironmentVariables">
<div class="flex flex-col gap-4" wire:init="loadEnvironmentVariables"
x-on:focus-compose-environment-variable.window="const key = $event.detail.key; const focus = () => { const row = document.querySelector(`[data-env-key='${CSS.escape(key)}']`); if (!row) return false; row.scrollIntoView({ behavior: 'smooth', block: 'center' }); row.querySelector('[data-env-settings-trigger]')?.click(); return true; }; if (!focus()) { $wire.focusComposeEnvironmentVariable(key).then(() => requestAnimationFrame(focus)); }">
<x-application.settings-section id="environment-variables-section" title="Environment variables"
helper="Environment variables (secrets) for this resource.">
@can('manageEnvironment', $resource)
@@ -215,7 +216,8 @@
@foreach ($this->environmentVariablePageRows as $row)
@if ($row['kind'] === 'managed')
<livewire:project.shared.environment-variable.show wire:key="{{ $row['id'] }}"
:env="$row['environmentVariable']" :type="$resource->type()" :showEnvironmentType="$showEnvironmentType" />
:env="$row['environmentVariable']" :type="$resource->type()"
:composeInfo="$row['composeInfo']" :showEnvironmentType="$showEnvironmentType" />
@else
<livewire:project.shared.environment-variable.show-hardcoded
wire:key="{{ $row['id'] }}" :env="$row['environmentVariable']"
@@ -14,6 +14,7 @@
@if ($serviceName)
<span class="table-badge shrink-0">{{ $serviceName }}</span>
@endif
<span class="table-badge shrink-0">{{ $composeType === 'derived' ? 'Derived in Compose' : 'Defined in Compose' }}</span>
</div>
<span class="env-managed-desktop data-table-cell-check">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
@@ -42,8 +43,19 @@
@if (filled($comment))
<x-forms.input label="Comment" :value="$comment" readonly />
@endif
<x-callout type="info" title="Managed by Docker Compose">
Update this value in the Compose file.
<x-callout type="info" :title="$composeType === 'derived' ? 'Derived in Compose' : 'Defined in Compose'">
@if ($composeType === 'derived')
This value is derived from
@foreach ($references as $reference)
<button type="button" data-compose-reference
class="font-mono underline underline-offset-2"
@click="$dispatch('focus-compose-environment-variable', { key: @js($reference) }); modalOpen = false">
{{ $reference }}</button>{{ $loop->last ? '' : ',' }}
@endforeach.
Edit those variables or update the Compose file.
@else
This value is defined directly in Docker Compose. Update it in the Compose file.
@endif
</x-callout>
</div>
</x-modal-input>
@@ -8,7 +8,7 @@
$showBuildtime = !$is_redis_credential && !$isMagicVariable && !$isSharedVariable;
$showRuntime = !$is_redis_credential && !$isMagicVariable && !$isSharedVariable;
@endphp
<div class="env-table-item"
<div class="env-table-item" data-env-key="{{ $env->key }}"
@if ($isSharedVariable) :style="`order: ${sharedSort === 'alphabetical' ? {{ $tableAlphabeticalOrder }} : {{ $tableCreationOrder }}}`" @endif
x-show="(typeof envFilter === 'undefined' || envFilter === 'all' || envFilter === '{{ $rowScope }}')
&& (typeof sharedSearch === 'undefined' || @js(mb_strtolower($env->key . ' ' . ($comment ?? '') . ' ' . $rowScopeLabel)).includes(sharedSearch.trim().toLowerCase()))">
@@ -36,6 +36,10 @@
@if ($is_really_required)
<span class="table-badge table-badge-danger shrink-0">Required</span>
@endif
@if ($composeInfo)
<span class="table-badge shrink-0"
title="Used by Compose services: {{ implode(', ', $composeInfo['services']) }}">Used by Compose</span>
@endif
</div>
@if (! $isSharedVariable)
@if ($isMagicVariable)
@@ -98,7 +102,7 @@
x-data="{ isMultiline: $wire.entangle('is_multiline') }">
<div class="grid items-end gap-4 sm:grid-cols-2">
<x-forms.input id="key" label="Name" :required="$is_redis_credential"
:disabled="!$canEditValue || $is_redis_credential" />
:disabled="!$canEditValue || $is_redis_credential || $composeInfo !== null" />
<x-forms.input id="comment" label="Comment" placeholder="Optional note"
helper="Add a note to document what this environment variable is used for." maxlength="256"
:disabled="!$canUpdate" />
@@ -156,6 +160,7 @@
@else
<x-forms.env-var-input id="value" type="password"
:required="$is_redis_credential" :disabled="!$canEditValue"
:placeholder="$composeInfo['default'] ?? null"
:availableVars="$isSharedVariable ? [] : $this->availableSharedVariables"
:projectUuid="data_get($parameters, 'project_uuid')"
:environmentUuid="data_get($parameters, 'environment_uuid')"
@@ -178,6 +183,17 @@
@if ($is_shared)
<x-forms.input disabled type="password" id="real_value" label="Resolved value" />
@endif
@if ($composeInfo)
<x-callout type="info" title="Used by Docker Compose">
Used by: {{ implode(', ', $composeInfo['services']) }}.
@if ($composeInfo['default'] !== null)
Compose default: <span class="font-mono">{{ $composeInfo['default'] }}</span>.
@endif
@if ($composeInfo['required'])
Compose requires this variable to have a value.
@endif
</x-callout>
@endif
@if ($showValueType || $showInterpolation || $showBuildtime || $showRuntime)
<div
@@ -11,6 +11,7 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use phpseclib3\Crypt\EC;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
@@ -49,6 +50,59 @@ beforeEach(function () {
]);
});
test('applicationParser creates an editable variable for bare Compose passthrough syntax', function () {
$dockerCompose = <<<'YAML'
services:
app:
image: nginx
environment:
- API_TOKEN
YAML;
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_raw' => $dockerCompose,
]);
applicationParser($application);
expect($application->environment_variables()->where('key', 'API_TOKEN')->exists())->toBeTrue()
->and(Yaml::parse($application->fresh()->docker_compose_raw))
->toBe(Yaml::parse($dockerCompose));
});
test('applicationParser creates derived inputs and preserves required Compose expressions', function () {
$dockerCompose = <<<'YAML'
services:
app:
image: nginx
environment:
API_URL: https://${API_HOST}/v1
API_TOKEN: ${API_TOKEN:?API_TOKEN must be set}
YAML;
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_raw' => $dockerCompose,
]);
$parsedCompose = applicationParser($application);
$requiredVariable = $application->environment_variables()->where('key', 'API_TOKEN')->firstOrFail();
expect($application->environment_variables()->where('key', 'API_HOST')->exists())->toBeTrue()
->and($requiredVariable->value)->toBeNull()
->and((bool) $requiredVariable->is_required)->toBeTrue()
->and(data_get($parsedCompose, 'services.app.environment.API_URL'))->toBe('https://${API_HOST}/v1')
->and(data_get($parsedCompose, 'services.app.environment.API_TOKEN'))->toBe('${API_TOKEN:?API_TOKEN must be set}')
->and(Yaml::parse($application->fresh()->docker_compose_raw))->toBe(Yaml::parse($dockerCompose));
});
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN variables', function () {
$dockerCompose = <<<'YAML'
services:
@@ -1,16 +1,19 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Symfony\Component\Yaml\Yaml;
uses(RefreshDatabase::class);
@@ -246,6 +249,382 @@ YAML,
->and($component->instance()->environmentVariablePageRows->first()['environmentVariable']['value'])->toBe('from-compose');
});
it('keeps a self-referencing Compose environment variable editable without changing Compose', function () {
$dockerCompose = <<<'YAML'
services:
app:
image: nginx
environment:
- API_TOKEN=${API_TOKEN}
YAML;
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'docker_compose_raw' => $dockerCompose,
'docker_compose' => $dockerCompose,
]);
$environmentVariable = EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'from-environment-tab',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
$component = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables');
expect($component->instance()->environmentVariablePageRows)
->toHaveCount(1)
->and($component->instance()->environmentVariablePageRows->first()['kind'])->toBe('managed')
->and($component->instance()->environmentVariablePageRows->first()['environmentVariable']->is($environmentVariable))->toBeTrue()
->and($component->instance()->environmentVariablePageRows->first()['composeInfo'])->toBe([
'services' => ['app'],
'default' => null,
'operator' => null,
'required' => false,
])
->and($service->fresh()->docker_compose_raw)->toBe($dockerCompose)
->and($service->fresh()->docker_compose)->toBe($dockerCompose);
});
it('keeps a self-referencing Compose variable with a default editable', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
environment:
LOG_LEVEL: ${LOG_LEVEL:-info}
YAML,
]);
EnvironmentVariable::create([
'key' => 'LOG_LEVEL',
'value' => 'debug',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
$component = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables');
expect($component->instance()->environmentVariablePageRows)
->toHaveCount(1)
->and($component->instance()->environmentVariablePageRows->first()['kind'])->toBe('managed')
->and($component->instance()->environmentVariablePageRows->first()['composeInfo'])->toBe([
'services' => ['app'],
'default' => 'info',
'operator' => ':-',
'required' => false,
]);
});
it('describes derived Compose assignments without replacing them', function () {
$dockerCompose = <<<'YAML'
services:
api:
image: nginx
environment:
API_URL: https://${API_HOST}/v1
YAML;
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'docker_compose_raw' => $dockerCompose,
'docker_compose' => $dockerCompose,
]);
EnvironmentVariable::create([
'key' => 'API_HOST',
'value' => 'api.example.com',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
$rows = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables')
->instance()
->environmentVariablePageRows;
expect($rows)->toHaveCount(2)
->and($rows->firstWhere('kind', 'managed')['composeInfo'])->toBe([
'services' => ['api'],
'default' => null,
'operator' => null,
'required' => false,
])
->and($rows->firstWhere('kind', 'hardcoded')['environmentVariable']['compose_type'])->toBe('derived')
->and($rows->firstWhere('kind', 'hardcoded')['environmentVariable']['references'])->toBe(['API_HOST'])
->and($service->fresh()->docker_compose_raw)->toBe($dockerCompose)
->and($service->fresh()->docker_compose)->toBe($dockerCompose);
Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables')
->set('serviceFilters', ['api'])
->set('variableFilters', ['managed'])
->set('environmentFilter', 'production')
->call('focusComposeEnvironmentVariable', 'API_HOST')
->assertSet('search', 'API_HOST')
->assertSet('serviceFilters', [])
->assertSet('variableFilters', [])
->assertSet('environmentFilter', 'all')
->assertSet('page', 1);
});
it('creates editable inputs referenced inside derived Compose values', function () {
$dockerCompose = <<<'YAML'
services:
api:
image: nginx
environment:
API_URL: ${API_SCHEME}://${API_HOST}/v1
YAML;
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id,
'docker_compose_raw' => $dockerCompose,
]);
$service->parse();
expect($service->environment_variables()->where('key', 'API_SCHEME')->exists())->toBeTrue()
->and($service->environment_variables()->where('key', 'API_HOST')->exists())->toBeTrue()
->and(Yaml::parse($service->fresh()->docker_compose_raw))->toBe(Yaml::parse($dockerCompose));
});
it('keeps editable and literal rows when services use the same key differently', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'docker_compose_raw' => <<<'YAML'
services:
api:
image: nginx
environment:
FOO: ${FOO}
worker:
image: nginx
environment:
FOO: fixed
YAML,
]);
EnvironmentVariable::create([
'key' => 'FOO',
'value' => 'editable',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
$rows = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables')
->instance()
->environmentVariablePageRows;
expect($rows)->toHaveCount(2)
->and($rows->pluck('kind')->all())->toBe(['managed', 'hardcoded']);
});
it('treats escaped Compose dollars as a literal value', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
environment:
SHELL_EXPRESSION: $${NOT_AN_INPUT}
YAML,
]);
$row = Livewire::test(All::class, ['resource' => $service])
->call('loadEnvironmentVariables')
->instance()
->environmentVariablePageRows
->first();
expect($row['kind'])->toBe('hardcoded')
->and($row['environmentVariable']['compose_type'])->toBe('literal')
->and($row['environmentVariable']['references'])->toBe([]);
});
it('does not use a required Compose expression message as its value', function () {
$dockerCompose = <<<'YAML'
services:
app:
image: nginx
environment:
API_TOKEN: ${API_TOKEN:?API_TOKEN must be set}
YAML;
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id,
'docker_compose_raw' => $dockerCompose,
]);
$parsedCompose = $service->parse();
$environmentVariable = $service->environment_variables()->where('key', 'API_TOKEN')->firstOrFail();
expect($environmentVariable->value)->toBeNull()
->and((bool) $environmentVariable->is_required)->toBeTrue()
->and(data_get($parsedCompose, 'services.app.environment.API_TOKEN'))->toBe('${API_TOKEN:?API_TOKEN must be set}')
->and(Yaml::parse($service->fresh()->docker_compose_raw))->toBe(Yaml::parse($dockerCompose));
});
it('synchronizes required metadata when Compose expressions change', function () {
$server = Server::factory()->create(['team_id' => $this->team->id]);
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $server->id,
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
environment:
API_TOKEN: ${API_TOKEN:?API_TOKEN must be set}
YAML,
]);
$environmentVariable = EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'configured',
'is_required' => false,
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
$service->parse();
expect((bool) $environmentVariable->fresh()->is_required)->toBeTrue();
$service->update([
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
environment:
API_TOKEN: ${API_TOKEN:-optional}
YAML,
]);
$service->parse();
expect((bool) $environmentVariable->fresh()->is_required)->toBeFalse();
$service->update([
'docker_compose_raw' => <<<'YAML'
services:
app:
image: nginx
YAML,
]);
$service->parse();
expect((bool) $environmentVariable->fresh()->is_required)->toBeFalse();
});
it('rejects renaming a Compose-linked variable through Livewire state', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id,
'docker_compose_raw' => "services:\n app:\n image: nginx\n environment:\n API_TOKEN: \${API_TOKEN}\n",
]);
$environmentVariable = EnvironmentVariable::create([
'key' => 'API_TOKEN',
'value' => 'secret',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
Livewire::test(Show::class, [
'env' => $environmentVariable,
'type' => 'service',
'composeInfo' => [
'services' => ['app'],
'default' => null,
'operator' => null,
'required' => false,
],
])->call('loadValues')
->set('composeInfo', null)
->set('key', 'RENAMED_TOKEN')
->call('submit');
expect($environmentVariable->fresh()->key)->toBe('API_TOKEN');
});
it('creates an editable variable for bare Compose passthrough syntax without rewriting Compose', function () {
$dockerCompose = <<<'YAML'
services:
app:
image: nginx
environment:
- API_TOKEN
- EMPTY_VALUE=
YAML;
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id,
'docker_compose_raw' => $dockerCompose,
]);
$storedDockerCompose = $service->docker_compose_raw;
$service->parse();
$rows = Livewire::test(All::class, ['resource' => $service->fresh()])
->call('loadEnvironmentVariables')
->instance()
->environmentVariablePageRows;
expect($service->environment_variables()->where('key', 'API_TOKEN')->exists())->toBeTrue()
->and($service->environment_variables()->where('key', 'EMPTY_VALUE')->exists())->toBeFalse()
->and($rows)->toHaveCount(2)
->and($rows->first()['kind'])->toBe('managed')
->and($rows->first()['composeInfo'])->toBe([
'services' => ['app'],
'default' => null,
'operator' => null,
'required' => false,
])
->and($rows->last()['kind'])->toBe('hardcoded')
->and($rows->last()['environmentVariable']['key'])->toBe('EMPTY_VALUE')
->and($rows->last()['environmentVariable']['compose_type'])->toBe('literal')
->and(Yaml::parse($service->fresh()->docker_compose_raw))->toBe(Yaml::parse($storedDockerCompose));
});
it('presents a Compose fallback as a placeholder and restores it when cleared', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id,
'docker_compose_raw' => "services:\n app:\n image: nginx\n environment:\n LOG_LEVEL: \${LOG_LEVEL:-info}\n",
]);
$environmentVariable = EnvironmentVariable::create([
'key' => 'LOG_LEVEL',
'value' => 'info',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
$component = Livewire::test(Show::class, [
'env' => $environmentVariable,
'type' => 'service',
'composeInfo' => [
'services' => ['app'],
'default' => 'info',
'operator' => ':-',
'required' => false,
],
])->call('loadValues');
$component->assertSet('value', null)
->set('value', 'debug')
->call('submit')
->set('value', null)
->call('submit');
expect($environmentVariable->fresh()->value)->toBe('info');
});
it('searches service environment variables without requiring preview variables', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
@@ -47,6 +47,10 @@ test('resource environment variables table has a Managed column and no name-cell
// Name cell does not repeat the environment type; Type owns Production/Preview.
expect($show)
->toContain('env-managed-desktop')
->toContain('Used by Compose')
->toContain('Compose default:')
->toContain('data-env-key="{{ $env->key }}"')
->toContain(':disabled="!$canEditValue || $is_redis_credential || $composeInfo !== null"')
->toContain('env-type-desktop')
->not->toContain('env-type-mobile')
->not->toContain('env-managed-mobile')
@@ -63,6 +67,10 @@ test('resource environment variables table has a Managed column and no name-cell
expect($hardcoded)
->toContain('env-managed-desktop data-table-cell-check')
->toContain('Defined in Compose')
->toContain('Derived in Compose')
->toContain('data-compose-reference')
->toContain("\$dispatch('focus-compose-environment-variable'")
->toContain('title="Environment variable details"')
->toContain('<x-forms.input label="Value" :value="$value ?? \'\'" readonly />')
->not->toContain("{{ filled(\$value) ? \$value : '(empty)' }}")
@@ -77,6 +85,9 @@ test('resource environment variables table has a Managed column and no name-cell
->toContain('minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem');
expect($all)->not->toContain('<span>Comment</span>');
expect($all)
->toContain('x-on:focus-compose-environment-variable.window')
->toContain('data-env-settings-trigger');
expect($show)->toContain('<x-helper :helper="e($comment)" />');
});
@@ -93,6 +93,68 @@ YAML;
->and($result[1]['value'])->toBe('false');
});
test('distinguishes passthrough variables from explicit empty values', function () {
$yaml = <<<'YAML'
services:
list-app:
environment:
- PASSTHROUGH
- EMPTY=
map-app:
environment:
MAPPED_PASSTHROUGH:
MAPPED_EMPTY: ""
YAML;
$result = extractHardcodedEnvironmentVariables($yaml)->keyBy('key');
expect($result['PASSTHROUGH']['is_passthrough'])->toBeTrue()
->and($result['EMPTY']['is_passthrough'])->toBeFalse()
->and($result['MAPPED_PASSTHROUGH']['is_passthrough'])->toBeTrue()
->and($result['MAPPED_EMPTY']['is_passthrough'])->toBeFalse();
});
test('detects Compose inputs that must remain unset when blank', function () {
$yaml = <<<'YAML'
services:
app:
environment:
BARE:
DASH: ${DASH-default}
QUESTION: ${QUESTION?required}
COLON_DASH: ${COLON_DASH:-default}
ESCAPED: $${ESCAPED-nope}
YAML;
expect(dockerComposeEnvironmentVariableRequiresUnsetWhenBlank($yaml, 'BARE'))->toBeTrue()
->and(dockerComposeEnvironmentVariableRequiresUnsetWhenBlank($yaml, 'DASH'))->toBeTrue()
->and(dockerComposeEnvironmentVariableRequiresUnsetWhenBlank($yaml, 'QUESTION'))->toBeTrue()
->and(dockerComposeEnvironmentVariableRequiresUnsetWhenBlank($yaml, 'COLON_DASH'))->toBeFalse()
->and(dockerComposeEnvironmentVariableRequiresUnsetWhenBlank($yaml, 'ESCAPED'))->toBeFalse();
});
test('extracts fallback and required metadata from derived Compose values', function () {
$compose = <<<'YAML'
services:
app:
environment:
URL: https://${HOST:-${INNER_HOST:-localhost}/api}
PATH: ${TOKEN?TOKEN is required}/v1
YAML;
expect(dockerComposeEnvironmentVariableInfo($compose, 'HOST'))->toMatchArray([
'services' => ['app'],
'default' => '${INNER_HOST:-localhost}/api',
'operator' => ':-',
'required' => false,
])->and(dockerComposeEnvironmentVariableInfo($compose, 'TOKEN'))->toMatchArray([
'services' => ['app'],
'default' => null,
'operator' => '?',
'required' => true,
]);
});
test('returns empty collection for malformed YAML', function () {
$yaml = 'invalid: yaml: content::: [[[';