feat(env): support variable flag directives in developer view (#12349)

This commit is contained in:
Andras Bacsai
2026-10-27 16:27:22 +01:00
committed by GitHub
parent 1039368491
commit fcdcd80eeb
12 changed files with 441 additions and 109 deletions
@@ -0,0 +1,146 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All;
use App\Livewire\SharedVariables\Team\Index;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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.');
});
+91
View File
@@ -0,0 +1,91 @@
<?php
use App\Models\EnvironmentVariable;
use App\Models\SharedEnvironmentVariable;
use Tests\TestCase;
uses(TestCase::class);
function devViewVariable(array $attributes): EnvironmentVariable
{
return (new EnvironmentVariable)->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]]);
});