diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 4f4e67a388..8ffae393a7 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -987,27 +987,12 @@ class All extends Component private function formatEnvironmentVariables($variables) { - $hideValues = ! $this->canViewEnvironmentValues(); + $canViewValues = $this->canViewEnvironmentValues(); return $variables ->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key)) - ->map(function ($item) use ($hideValues) { - if ($hideValues) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } - $value = formatEnvValueForDevView($item->value); - if ($value === null) { - return "$item->key=(Value cannot be shown in this view, edit in normal view)"; - } - - return "$item->key=$value"; - })->join("\n"); + ->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues)) + ->join("\n"); } public function switch() @@ -1022,11 +1007,28 @@ class All extends Component public function submit($data = null) { + // Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text. try { $this->authorize('manageEnvironment', $this->resource); $this->ensureEnvironmentVariablesLoaded(); if ($data === null) { - $this->handleBulkSubmit(); + $variables = $this->normalizeEnvironmentVariables(parseEnvDevView($this->variables, EnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS)); + $previewVariables = []; + if ($this->showPreview) { + try { + $previewVariables = $this->normalizeEnvironmentVariables(parseEnvDevView($this->variablesPreview, EnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS)); + } catch (\InvalidArgumentException $e) { + throw new \InvalidArgumentException('Preview deployments: '.$e->getMessage(), previous: $e); + } + } + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + + try { + if ($data === null) { + $this->handleBulkSubmit($variables, $previewVariables); } else { $this->handleSingleSubmit($data); } @@ -1076,9 +1078,8 @@ class All extends Component } } - private function handleBulkSubmit() + private function handleBulkSubmit(array $variables, array $previewVariables) { - $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables)); $changesMade = false; $errorOccurred = false; @@ -1097,8 +1098,6 @@ class All extends Component } if ($this->showPreview) { - $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview)); - // Try to delete removed preview variables $deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables); if ($deletedPreviewCount > 0) { @@ -1244,7 +1243,10 @@ class All extends Component $changed = true; } - if ($changed) { + // Flags change only when a "# coolify:" line sets them + $found->fill($data['settings']); + + if ($changed || $found->isDirty()) { $found->save(); $count++; } @@ -1255,6 +1257,7 @@ class All extends Component $environment->value = $value; $environment->comment = $comment; // Set comment from inline comment $environment->is_multiline = false; + $environment->fill($data['settings']); $environment->is_preview = $isPreview; $environment->resourceable_id = $this->resource->id; $environment->resourceable_type = $this->resource->getMorphClass(); diff --git a/app/Livewire/SharedVariables/Environment/Show.php b/app/Livewire/SharedVariables/Environment/Show.php index f42820ba66..5e22376584 100644 --- a/app/Livewire/SharedVariables/Environment/Show.php +++ b/app/Livewire/SharedVariables/Environment/Show.php @@ -5,6 +5,7 @@ namespace App\Livewire\SharedVariables\Environment; use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Application; use App\Models\Project; +use App\Models\SharedEnvironmentVariable; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; use Livewire\Component; @@ -83,30 +84,21 @@ class Show extends Component { $canViewValues = auth()->user()?->isAdminOfTeam($this->project->team_id) ?? false; - return $variables->map(function ($item) use ($canViewValues) { - if (! $canViewValues) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } - $value = formatEnvValueForDevView($item->value); - if ($value === null) { - return "$item->key=(Value cannot be shown in this view, edit in normal view)"; - } - - return "$item->key=$value"; - })->join("\n"); + return $variables->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))->join("\n"); } public function submit() { + // Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text. try { $this->authorize('update', $this->environment); - $this->handleBulkSubmit(); + $variables = parseEnvDevView($this->variables, SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS); + } catch (\Throwable $e) { + return handleError($e, $this); + } + + try { + $this->handleBulkSubmit($variables); $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); @@ -115,9 +107,8 @@ class Show extends Component } } - private function handleBulkSubmit() + private function handleBulkSubmit(array $variables) { - $variables = parseEnvFormatToArray($this->variables); $changesMade = false; DB::transaction(function () use ($variables, &$changesMade) { @@ -165,6 +156,9 @@ class Show extends Component if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) { if ($found->value !== $value) { $found->value = $value; + } + $found->fill($data['settings']); + if ($found->isDirty()) { $found->save(); $count++; } @@ -175,6 +169,7 @@ class Show extends Component 'value' => $value, 'is_multiline' => false, 'is_literal' => false, + ...$data['settings'], 'type' => 'environment', 'team_id' => $this->project->team_id, ]); diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index a4c93cb557..b2973291ca 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -4,6 +4,7 @@ namespace App\Livewire\SharedVariables\Project; use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Project; +use App\Models\SharedEnvironmentVariable; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; use Livewire\Component; @@ -81,30 +82,21 @@ class Show extends Component { $canViewValues = auth()->user()?->isAdminOfTeam($this->project->team_id) ?? false; - return $variables->map(function ($item) use ($canViewValues) { - if (! $canViewValues) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } - $value = formatEnvValueForDevView($item->value); - if ($value === null) { - return "$item->key=(Value cannot be shown in this view, edit in normal view)"; - } - - return "$item->key=$value"; - })->join("\n"); + return $variables->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))->join("\n"); } public function submit() { + // Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text. try { $this->authorize('update', $this->project); - $this->handleBulkSubmit(); + $variables = parseEnvDevView($this->variables, SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS); + } catch (\Throwable $e) { + return handleError($e, $this); + } + + try { + $this->handleBulkSubmit($variables); $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); @@ -113,10 +105,8 @@ class Show extends Component } } - private function handleBulkSubmit() + private function handleBulkSubmit(array $variables) { - $variables = parseEnvFormatToArray($this->variables); - $changesMade = DB::transaction(function () use ($variables) { // Delete removed variables $deletedCount = $this->deleteRemovedVariables($variables); @@ -157,6 +147,9 @@ class Show extends Component if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) { if ($found->value !== $value) { $found->value = $value; + } + $found->fill($data['settings']); + if ($found->isDirty()) { $found->save(); $count++; } @@ -167,6 +160,7 @@ class Show extends Component 'value' => $value, 'is_multiline' => false, 'is_literal' => false, + ...$data['settings'], 'type' => 'project', 'team_id' => $this->project->team_id, ]); diff --git a/app/Livewire/SharedVariables/Server/Show.php b/app/Livewire/SharedVariables/Server/Show.php index 062ab60123..3c4d747e29 100644 --- a/app/Livewire/SharedVariables/Server/Show.php +++ b/app/Livewire/SharedVariables/Server/Show.php @@ -4,6 +4,7 @@ namespace App\Livewire\SharedVariables\Server; use App\Livewire\Concerns\RemembersCanvasView; use App\Models\Server; +use App\Models\SharedEnvironmentVariable; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; use Livewire\Component; @@ -82,30 +83,21 @@ class Show extends Component { $canViewValues = auth()->user()?->isAdminOfTeam($this->server->team_id) ?? false; - return $variables->map(function ($item) use ($canViewValues) { - if (! $canViewValues) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } - $value = formatEnvValueForDevView($item->value); - if ($value === null) { - return "$item->key=(Value cannot be shown in this view, edit in normal view)"; - } - - return "$item->key=$value"; - })->join("\n"); + return $variables->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))->join("\n"); } public function submit() { + // Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text. try { $this->authorize('update', $this->server); - $this->handleBulkSubmit(); + $variables = parseEnvDevView($this->variables, SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS); + } catch (\Throwable $e) { + return handleError($e, $this); + } + + try { + $this->handleBulkSubmit($variables); $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); @@ -114,10 +106,8 @@ class Show extends Component } } - private function handleBulkSubmit() + private function handleBulkSubmit(array $variables) { - $variables = parseEnvFormatToArray($this->variables); - $changesMade = DB::transaction(function () use ($variables) { // Delete removed variables $deletedCount = $this->deleteRemovedVariables($variables); @@ -167,9 +157,12 @@ class Show extends Component if ($found) { if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) { - if ($found->value !== $value || $found->comment !== $comment) { + if ($found->value !== $value) { $found->value = $value; - $found->comment = $comment; + } + $found->comment = $comment; + $found->fill($data['settings']); + if ($found->isDirty()) { $found->save(); $count++; } @@ -181,6 +174,7 @@ class Show extends Component 'comment' => $comment, 'is_multiline' => false, 'is_literal' => false, + ...$data['settings'], 'type' => 'server', 'team_id' => $this->server->team_id, ]); diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index adabb569a3..682cbeee18 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -3,6 +3,7 @@ namespace App\Livewire\SharedVariables\Team; use App\Livewire\Concerns\RemembersCanvasView; +use App\Models\SharedEnvironmentVariable; use App\Models\Team; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\DB; @@ -75,30 +76,21 @@ class Index extends Component { $canViewValues = auth()->user()?->isAdminOfTeam($this->team->id) ?? false; - return $variables->map(function ($item) use ($canViewValues) { - if (! $canViewValues) { - return "$item->key=(Hidden, only admins can view)"; - } - if ($item->is_shown_once) { - return "$item->key=(Locked Secret, delete and add again to change)"; - } - if ($item->is_multiline) { - return "$item->key=(Multiline environment variable, edit in normal view)"; - } - $value = formatEnvValueForDevView($item->value); - if ($value === null) { - return "$item->key=(Value cannot be shown in this view, edit in normal view)"; - } - - return "$item->key=$value"; - })->join("\n"); + return $variables->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))->join("\n"); } public function submit() { + // Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text. try { $this->authorize('update', $this->team); - $this->handleBulkSubmit(); + $variables = parseEnvDevView($this->variables, SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS); + } catch (\Throwable $e) { + return handleError($e, $this); + } + + try { + $this->handleBulkSubmit($variables); $this->getDevView(); } catch (\Throwable $e) { return handleError($e, $this); @@ -107,9 +99,8 @@ class Index extends Component } } - private function handleBulkSubmit() + private function handleBulkSubmit(array $variables) { - $variables = parseEnvFormatToArray($this->variables); $changesMade = false; DB::transaction(function () use ($variables, &$changesMade) { @@ -156,6 +147,9 @@ class Index extends Component if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) { if ($found->value !== $value) { $found->value = $value; + } + $found->fill($data['settings']); + if ($found->isDirty()) { $found->save(); $count++; } @@ -166,6 +160,7 @@ class Index extends Component 'value' => $value, 'is_multiline' => false, 'is_literal' => false, + ...$data['settings'], 'type' => 'team', 'team_id' => $this->team->id, ]); diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 4ef77a4f16..b40ca8ebb3 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -42,6 +42,13 @@ class EnvironmentVariable extends BaseModel { use Auditable; + /** + * Flags that a "# coolify:" line of the developer view can set, with their defaults. + * + * @var array + */ + public const DEV_VIEW_FLAG_DEFAULTS = ['literal' => false, 'buildtime' => true, 'runtime' => true]; + public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_']; /** @@ -101,6 +108,7 @@ class EnvironmentVariable extends BaseModel 'value' => 'encrypted', 'preserve_compose_null' => 'boolean', 'is_multiline' => 'boolean', + 'is_literal' => 'boolean', 'is_preview' => 'boolean', 'is_runtime' => 'boolean', 'is_buildtime' => 'boolean', diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index 086cc33e50..e0f40a4542 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -11,6 +11,14 @@ class SharedEnvironmentVariable extends Model { use Auditable; + /** + * Flags that a "# coolify:" line of the developer view can set, with their defaults. + * Shared variables have no buildtime or runtime flags. + * + * @var array + */ + public const DEV_VIEW_FLAG_DEFAULTS = ['literal' => false]; + protected $fillable = [ // Core identification 'key', @@ -40,6 +48,7 @@ class SharedEnvironmentVariable extends Model protected $casts = [ 'key' => 'string', 'value' => 'encrypted', + 'is_literal' => 'boolean', ]; /** diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index b9d482a1f7..e5c460cee8 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1284,6 +1284,97 @@ function formatEnvValueForDevView(?string $value): ?string return null; } +/** + * Format a variable for the developer view: its KEY=value line, with a "# coolify:" directive + * line above it when a flag is not its default. A variable that the view cannot edit gets a + * placeholder line and no directive. + */ +function formatEnvVariableForDevView(EnvironmentVariable|SharedEnvironmentVariable $variable, bool $canViewValue): string +{ + if (! $canViewValue) { + return "{$variable->key}=(Hidden, only admins can view)"; + } + if ($variable->is_shown_once) { + return "{$variable->key}=(Locked Secret, delete and add again to change)"; + } + if ($variable->is_multiline) { + return "{$variable->key}=(Multiline environment variable, edit in normal view)"; + } + $value = formatEnvValueForDevView($variable->value); + if ($value === null) { + return "{$variable->key}=(Value cannot be shown in this view, edit in normal view)"; + } + + $flags = []; + foreach ($variable::DEV_VIEW_FLAG_DEFAULTS as $flag => $default) { + if ((bool) $variable->{"is_{$flag}"} !== $default) { + $flags[] = $default ? "{$flag}=false" : $flag; + } + } + $line = "{$variable->key}={$value}"; + + return $flags === [] ? $line : '# coolify: '.implode(' ', $flags)."\n".$line; +} + +/** + * Parse developer view text. Returns the result of parseEnvFormatToArray() with a 'settings' + * entry per key: the flags of the "# coolify:" line directly above the key line, as model + * attributes (for example ['is_literal' => true]). A key without a directive gets empty + * settings, so its current flags stay. + * + * @param array $flagDefaults the allowed flags and their defaults + * @return array}> + * + * @throws InvalidArgumentException when a directive is not directly above a KEY=value line, or has an unknown flag or value + */ +function parseEnvDevView(?string $contents, array $flagDefaults): array +{ + $contents ??= ''; + $settingsByKey = []; + $directive = null; + $directiveLine = 0; + + foreach (explode("\n", $contents) as $index => $line) { + $isKeyLine = $line !== '' && ! str_starts_with($line, '#') && str_contains($line, '='); + if ($directive !== null && ! $isKeyLine) { + throw new InvalidArgumentException("Line {$directiveLine}: a \"# coolify:\" line must be directly above a KEY=value line."); + } + + if ($isKeyLine) { + $settingsByKey[substr($line, 0, strpos($line, '='))] = $directive ?? []; + $directive = null; + } elseif (str_starts_with($line, '# coolify:')) { + $directiveLine = $index + 1; + $flags = preg_split('/\s+/', trim(substr($line, strlen('# coolify:'))), -1, PREG_SPLIT_NO_EMPTY); + if ($flags === []) { + throw new InvalidArgumentException("Line {$directiveLine}: the \"# coolify:\" line has no flags."); + } + + $directive = []; + foreach ($flags as $flag) { + [$name, $value] = array_pad(explode('=', $flag, 2), 2, 'true'); + if (! array_key_exists($name, $flagDefaults)) { + throw new InvalidArgumentException("Line {$directiveLine}: unknown flag \"{$name}\". Allowed flags: ".implode(', ', array_keys($flagDefaults)).'.'); + } + if ($value !== 'true' && $value !== 'false') { + throw new InvalidArgumentException("Line {$directiveLine}: the value of \"{$name}\" must be true or false."); + } + $directive["is_{$name}"] = $value === 'true'; + } + } + } + if ($directive !== null) { + throw new InvalidArgumentException("Line {$directiveLine}: a \"# coolify:\" line must be directly above a KEY=value line."); + } + + $variables = parseEnvFormatToArray($contents); + foreach ($variables as $key => $data) { + $variables[$key]['settings'] = $settingsByKey[$key] ?? []; + } + + return $variables; +} + /** * Extract inline comments from environment variables in raw docker-compose YAML. * diff --git a/resources/views/components/shared-variables/editor.blade.php b/resources/views/components/shared-variables/editor.blade.php index 2b2c13cb30..18ed216172 100644 --- a/resources/views/components/shared-variables/editor.blade.php +++ b/resources/views/components/shared-variables/editor.blade.php @@ -156,8 +156,12 @@ @endforeach @endif -
+ + + To make a variable literal, put # coolify: literal directly above + its line. Without this line, the flag does not change. + diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 4be445caba..8286bb3d79 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -69,6 +69,8 @@ @can('manageEnvironment', $resource) Inline comments with space before # (e.g., KEY=value #comment) are stripped. + To set flags, put # coolify: literal buildtime=false runtime=false + directly above a variable line. Without this line, the flags of a variable do not change. diff --git a/tests/Feature/EnvironmentVariableDevViewDirectiveTest.php b/tests/Feature/EnvironmentVariableDevViewDirectiveTest.php new file mode 100644 index 0000000000..2a963edbef --- /dev/null +++ b/tests/Feature/EnvironmentVariableDevViewDirectiveTest.php @@ -0,0 +1,146 @@ + 0]); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->team->members()->attach($this->user, ['role' => 'owner']); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = Application::factory()->create(['environment_id' => $this->environment->id]); + EnvironmentVariable::query()->delete(); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->createVariable = fn (string $key, array $attributes = []) => EnvironmentVariable::create([ + 'key' => $key, + 'value' => 'value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + ...$attributes, + ]); + $this->flags = fn (string $key) => $this->application->environment_variables()->where('key', $key)->firstOrFail() + ->only(['value', 'is_literal', 'is_buildtime', 'is_runtime']); + $this->devView = fn () => Livewire::test(All::class, ['resource' => $this->application]) + ->call('loadEnvironmentVariables') + ->call('switch'); +}); + +it('shows directives for non-default flags and keeps flags when saved without edits', function () { + ($this->createVariable)('CUSTOM', ['is_literal' => true, 'is_buildtime' => false]); + ($this->createVariable)('PLAIN'); + + $component = ($this->devView)(); + + expect($component->get('variables'))->toBe("# coolify: literal buildtime=false\nCUSTOM=value\nPLAIN=value"); + + $component->call('submit')->assertNotDispatched('error'); + + expect(($this->flags)('CUSTOM'))->toBe(['value' => 'value', 'is_literal' => true, 'is_buildtime' => false, 'is_runtime' => true]) + ->and(($this->flags)('PLAIN'))->toBe(['value' => 'value', 'is_literal' => false, 'is_buildtime' => true, 'is_runtime' => true]); +}); + +it('sets flags from an added directive and resets them with explicit values', function () { + ($this->createVariable)('A'); + ($this->createVariable)('B', ['is_literal' => true, 'is_runtime' => false]); + + ($this->devView)() + ->set('variables', "# coolify: literal\nA=value\n# coolify: literal=false runtime=true\nB=value") + ->call('submit') + ->assertNotDispatched('error'); + + expect(($this->flags)('A'))->toBe(['value' => 'value', 'is_literal' => true, 'is_buildtime' => true, 'is_runtime' => true]) + ->and(($this->flags)('B'))->toBe(['value' => 'value', 'is_literal' => false, 'is_buildtime' => true, 'is_runtime' => true]); +}); + +it('keeps flags of existing variables when a plain env file without directives is saved', function () { + ($this->createVariable)('A', ['is_literal' => true, 'is_buildtime' => false, 'is_runtime' => false]); + + ($this->devView)() + ->set('variables', "A=changed\nNEW=new") + ->call('submit') + ->assertNotDispatched('error'); + + expect(($this->flags)('A'))->toBe(['value' => 'changed', 'is_literal' => true, 'is_buildtime' => false, 'is_runtime' => false]) + ->and(($this->flags)('NEW'))->toBe(['value' => 'new', 'is_literal' => false, 'is_buildtime' => true, 'is_runtime' => true]); +}); + +it('creates a new variable with the flags of its directive', function () { + ($this->devView)() + ->set('variables', "# coolify: literal buildtime=false\nNEW=new") + ->call('submit') + ->assertNotDispatched('error'); + + expect(($this->flags)('NEW'))->toBe(['value' => 'new', 'is_literal' => true, 'is_buildtime' => false, 'is_runtime' => true]); +}); + +it('rejects an invalid directive, changes nothing, and keeps the text', function () { + ($this->createVariable)('A'); + $text = "A=changed\nNEW=new\n# coolify: literal"; + + $component = ($this->devView)() + ->set('variables', $text) + ->call('submit') + ->assertDispatched('error', 'Line 3: a "# coolify:" line must be directly above a KEY=value line.'); + + expect($component->get('variables'))->toBe($text) + ->and($this->application->environment_variables()->pluck('value', 'key')->all())->toBe(['A' => 'value']); +}); + +it('ignores a directive above a locked variable', function () { + ($this->createVariable)('LOCKED', ['is_shown_once' => true]); + + $component = ($this->devView)(); + expect($component->get('variables'))->toBe('LOCKED=(Locked Secret, delete and add again to change)'); + + $component->set('variables', "# coolify: literal\nLOCKED=(Locked Secret, delete and add again to change)") + ->call('submit') + ->assertNotDispatched('error'); + + expect(($this->flags)('LOCKED'))->toBe(['value' => 'value', 'is_literal' => false, 'is_buildtime' => true, 'is_runtime' => true]); +}); + +it('applies the literal directive to team shared variables and rejects buildtime', function () { + SharedEnvironmentVariable::create(['key' => 'A', 'value' => 'value', 'type' => 'team', 'team_id' => $this->team->id]); + + $component = Livewire::test(Index::class) + ->call('switch') + ->set('variables', "# coolify: literal\nA=value\n# coolify: literal\nNEW=new") + ->call('submit') + ->assertNotDispatched('error'); + + expect($this->team->environment_variables()->pluck('is_literal', 'key')->all())->toBe(['A' => true, 'NEW' => true]) + ->and($component->get('variables'))->toBe("# coolify: literal\nA=value\n# coolify: literal\nNEW=new"); + + $component->set('variables', "# coolify: buildtime=false\nA=value") + ->call('submit') + ->assertDispatched('error', 'Line 1: unknown flag "buildtime". Allowed flags: literal.'); + + expect($this->team->environment_variables()->count())->toBe(2); +}); + +it('names the preview text area in a directive error', function () { + expect($this->application->git_repository)->not->toBeEmpty(); + + ($this->devView)() + ->set('variablesPreview', "A=1\n# coolify: literal") + ->call('submit') + ->assertDispatched('error', 'Preview deployments: Line 2: a "# coolify:" line must be directly above a KEY=value line.'); +}); diff --git a/tests/Unit/EnvDevViewDirectiveTest.php b/tests/Unit/EnvDevViewDirectiveTest.php new file mode 100644 index 0000000000..7c64ffa37b --- /dev/null +++ b/tests/Unit/EnvDevViewDirectiveTest.php @@ -0,0 +1,91 @@ +forceFill(['key' => 'KEY', 'value' => 'value', ...$attributes]); +} + +it('formats a directive line only for flags that are not their default', function (array $flags, string $expected) { + expect(formatEnvVariableForDevView(devViewVariable($flags), true))->toBe($expected); +})->with([ + 'defaults' => [['is_literal' => false, 'is_buildtime' => true, 'is_runtime' => true], 'KEY=value'], + 'literal' => [['is_literal' => true, 'is_buildtime' => true, 'is_runtime' => true], "# coolify: literal\nKEY=value"], + 'all non-default' => [['is_literal' => true, 'is_buildtime' => false, 'is_runtime' => false], "# coolify: literal buildtime=false runtime=false\nKEY=value"], +]); + +it('formats only the literal flag for shared variables', function () { + $variable = (new SharedEnvironmentVariable)->forceFill(['key' => 'KEY', 'value' => 'value', 'is_literal' => 1]); + + expect(formatEnvVariableForDevView($variable, true))->toBe("# coolify: literal\nKEY=value"); +}); + +it('formats no directive above placeholder lines', function (array $attributes, string $expected, bool $canViewValue = true) { + expect(formatEnvVariableForDevView(devViewVariable(['is_literal' => true, ...$attributes]), $canViewValue))->toBe($expected); +})->with([ + 'hidden' => [[], 'KEY=(Hidden, only admins can view)', false], + 'locked' => [['is_shown_once' => true], 'KEY=(Locked Secret, delete and add again to change)'], + 'multiline' => [['is_multiline' => true], 'KEY=(Multiline environment variable, edit in normal view)'], + 'both quote types' => [['value' => '\'"a" b\''], 'KEY=(Value cannot be shown in this view, edit in normal view)'], +]); + +it('reads back the flags and values that it formats', function (string $value, array $flags) { + $text = formatEnvVariableForDevView(devViewVariable(['value' => $value, ...$flags]), true) + ."\n".formatEnvVariableForDevView(devViewVariable(['key' => 'OTHER', 'value' => 'x']), true); + + $parsed = parseEnvDevView($text, EnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS); + + expect($parsed['KEY']['value'])->toBe($value) + ->and([...$flags, ...$parsed['KEY']['settings']])->toBe($flags) + ->and($parsed['OTHER']['settings'])->toBe([]); +})->with([ + 'plain' => 'plain', + 'double quoted' => '"abc"', + 'single quoted' => "'\$FOO'", + 'hash' => 'value #not-a-comment', +])->with([ + 'defaults' => [['is_literal' => false, 'is_buildtime' => true, 'is_runtime' => true]], + 'literal' => [['is_literal' => true, 'is_buildtime' => true, 'is_runtime' => true]], + 'all non-default' => [['is_literal' => true, 'is_buildtime' => false, 'is_runtime' => false]], +]); + +it('parses explicit and bare directive values', function () { + $parsed = parseEnvDevView( + "# coolify: literal=false buildtime runtime=true\nA=1 # a comment\n# other comment\nB=2", + EnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS, + ); + + expect($parsed)->toBe([ + 'A' => ['value' => '1', 'comment' => 'a comment', 'settings' => ['is_literal' => false, 'is_buildtime' => true, 'is_runtime' => true]], + 'B' => ['value' => '2', 'comment' => null, 'settings' => []], + ]); +}); + +it('rejects invalid directives', function (string $text, string $message) { + expect(fn () => parseEnvDevView($text, EnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS)) + ->toThrow(InvalidArgumentException::class, $message); +})->with([ + 'last line' => ["A=1\n# coolify: literal", 'Line 2: a "# coolify:" line must be directly above a KEY=value line.'], + 'blank line before key' => ["# coolify: literal\n\nA=1", 'Line 1: a "# coolify:" line must be directly above a KEY=value line.'], + 'comment before key' => ["# coolify: literal\n# note\nA=1", 'Line 1: a "# coolify:" line must be directly above a KEY=value line.'], + 'two directives' => ["# coolify: literal\n# coolify: runtime=false\nA=1", 'Line 1: a "# coolify:" line must be directly above a KEY=value line.'], + 'no flags' => ["# coolify:\nA=1", 'Line 1: the "# coolify:" line has no flags.'], + 'unknown flag' => ["# coolify: multiline\nA=1", 'Line 1: unknown flag "multiline". Allowed flags: literal, buildtime, runtime.'], + 'bad value' => ["# coolify: literal=yes\nA=1", 'Line 1: the value of "literal" must be true or false.'], +]); + +it('accepts only the literal flag for shared variables', function () { + expect(parseEnvDevView("# coolify: literal\nA=1", SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS)['A']['settings'])->toBe(['is_literal' => true]) + ->and(fn () => parseEnvDevView("# coolify: buildtime=false\nA=1", SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS)) + ->toThrow(InvalidArgumentException::class, 'Line 1: unknown flag "buildtime". Allowed flags: literal.'); +}); + +it('keeps ignoring directive lines in parseEnvFormatToArray', function () { + expect(parseEnvFormatToArray("# coolify: literal\nA=1\n# coolify: bogus")) + ->toBe(['A' => ['value' => '1', 'comment' => null]]); +});