fix(env): resolve and redact server-scoped shared variables in compose

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-06 12:16:39 +01:00
co-authored by Claude Opus 5.5
parent 00d0b4be1d
commit 106a6d7ac0
3 changed files with 42 additions and 6 deletions
+6 -3
View File
@@ -5978,7 +5978,7 @@ function convertContainerMemoryBytesToMegabytes(array $metrics): array
}
/**
* Resolve shared environment variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}.
* Resolve shared environment variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}, {{server.VAR}}.
*
* This is the canonical implementation used by both EnvironmentVariable::realValue and the compose parsers
* to ensure shared variable references are replaced with their actual values.
@@ -6006,6 +6006,8 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string
$id = $resource->environment->project->id;
} elseif ($type->value() === 'team') {
$id = $resource->team()->id;
} elseif ($type->value() === 'server') {
$id = data_get($resource, 'destination.server_id');
}
if (is_null($id)) {
continue;
@@ -6029,7 +6031,7 @@ function resolveSharedEnvironmentVariables(?string $value, $resource): ?string
* The compose parsers write resolved values into `docker_compose`: environment variables, database
* overrides, and shared variable references. Deployments need those values, so only the displayed
* copy is redacted. Covered: the resource's locked variables (also variables that reference a locked
* shared variable) and locked team, project, and environment variables that the raw compose references.
* shared variable) and locked team, project, environment, and server variables that the raw compose references.
* Values shorter than 4 characters are not replaced, because they would match unrelated text.
* A parsed compose (array or collection) gets the same replacement in every string value.
*
@@ -6047,13 +6049,14 @@ function redactLockedValuesInGeneratedCompose(string|array|Collection|null $comp
$values = EnvironmentVariable::logRedactionSecretsFor($resource, includePreview: true, resolveRemoteSecrets: false)['locked'];
preg_match_all('/{{\s*(\w+)\.([^}\s]+)\s*}}/', (string) $resource->docker_compose_raw, $matches, PREG_SET_ORDER);
$references = collect($matches)->filter(fn (array $match): bool => in_array($match[1], ['team', 'project', 'environment'], true));
$references = collect($matches)->filter(fn (array $match): bool => in_array($match[1], SHARED_VARIABLE_TYPES, true));
$teamId = $resource->team()?->id;
if ($references->isNotEmpty() && $teamId !== null) {
$scopeIds = [
'team' => $teamId,
'project' => $resource->environment?->project_id,
'environment' => $resource->environment_id,
'server' => data_get($resource, 'destination.server_id'),
];
$values = SharedEnvironmentVariable::query()
->where('team_id', $teamId)
@@ -28,6 +28,7 @@ services:
LOCKED_TOKEN: ""
PLAIN_TOKEN: ""
TEAM_SECRET: '{{team.TEAM_SECRET}}'
SERVER_SECRET: '{{server.SERVER_SECRET}}'
YAML;
beforeEach(function () {
@@ -52,6 +53,15 @@ beforeEach(function () {
'is_shown_once' => true,
]);
SharedEnvironmentVariable::create([
'type' => 'server',
'key' => 'SERVER_SECRET',
'value' => 'server-shared-locked-secret',
'team_id' => $this->team->id,
'server_id' => $server->id,
'is_shown_once' => true,
]);
$this->service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $server->id,
@@ -92,6 +102,7 @@ function expectLockedValuesRedacted(?string $text): void
{
expect($text)->not->toContain('env-locked-secret-value')
->not->toContain('team-shared-locked-secret')
->not->toContain('server-shared-locked-secret')
->toContain('<locked>')
->toContain('plain-visible-value');
}
@@ -101,7 +112,8 @@ function expectStoredComposeKeepsRealValues(Application|Service $resource): void
expect($resource->fresh()->docker_compose)
->not->toContain('<locked>')
->toContain('env-locked-secret-value')
->toContain('team-shared-locked-secret');
->toContain('team-shared-locked-secret')
->toContain('server-shared-locked-secret');
}
it('writes the locked values into the generated compose that deployments use', function (string $resource) {
@@ -117,7 +129,8 @@ it('hides locked values in the service compose that an owner sees', function (st
expectLockedValuesRedacted($livewire->get('dockerCompose'));
expect(json_encode($livewire->snapshot).$livewire->html())
->not->toContain('env-locked-secret-value')
->not->toContain('team-shared-locked-secret');
->not->toContain('team-shared-locked-secret')
->not->toContain('server-shared-locked-secret');
})->with(['stack form' => [StackForm::class], 'compose editor' => [EditCompose::class]]);
it('hides locked values in the application compose that an owner sees', function () {
@@ -129,7 +142,8 @@ it('hides locked values in the application compose that an owner sees', function
expectLockedValuesRedacted($livewire->get('dockerCompose'));
expect(json_encode($livewire->snapshot).$livewire->html())
->not->toContain('env-locked-secret-value')
->not->toContain('team-shared-locked-secret');
->not->toContain('team-shared-locked-secret')
->not->toContain('server-shared-locked-secret');
});
it('keeps the real generated compose when an owner saves the service stack form', function () {
@@ -4,6 +4,7 @@ use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use App\Models\User;
@@ -21,8 +22,13 @@ beforeEach(function () {
'project_id' => $this->project->id,
]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = $this->server->standaloneDockers()->firstOrFail();
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
});
@@ -64,6 +70,19 @@ test('resolveSharedEnvironmentVariables resolves team-scoped variable', function
expect($resolved)->toBe('sk-123456');
});
test('resolveSharedEnvironmentVariables resolves server-scoped variable', function () {
SharedEnvironmentVariable::create([
'key' => 'COMMON_PG_HOST',
'value' => 'postgres.coolify',
'type' => 'server',
'server_id' => $this->server->id,
'team_id' => $this->team->id,
]);
$resolved = resolveSharedEnvironmentVariables('{{server.COMMON_PG_HOST}}', $this->application);
expect($resolved)->toBe('postgres.coolify');
});
test('resolveSharedEnvironmentVariables returns original when no match found', function () {
$resolved = resolveSharedEnvironmentVariables('{{environment.NONEXISTENT}}', $this->application);
expect($resolved)->toBe('{{environment.NONEXISTENT}}');