diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 82b7c97e33..e8e9bb5cee 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -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) diff --git a/tests/Feature/Authorization/RenderedComposeLockedValueRedactionTest.php b/tests/Feature/Authorization/RenderedComposeLockedValueRedactionTest.php index 9406a80c16..2cda4c8224 100644 --- a/tests/Feature/Authorization/RenderedComposeLockedValueRedactionTest.php +++ b/tests/Feature/Authorization/RenderedComposeLockedValueRedactionTest.php @@ -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('') ->toContain('plain-visible-value'); } @@ -101,7 +112,8 @@ function expectStoredComposeKeepsRealValues(Application|Service $resource): void expect($resource->fresh()->docker_compose) ->not->toContain('') ->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 () { diff --git a/tests/Feature/SharedVariableComposeResolutionTest.php b/tests/Feature/SharedVariableComposeResolutionTest.php index 5ffb027f0e..d97b0cd913 100644 --- a/tests/Feature/SharedVariableComposeResolutionTest.php +++ b/tests/Feature/SharedVariableComposeResolutionTest.php @@ -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}}');