mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
fix: improve Compose environment variable UI
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
-2
@@ -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::: [[[';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user