mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 17:44:36 +08:00
742 lines
28 KiB
PHP
742 lines
28 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Exceptions\RemoteSecretException;
|
|
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
|
use App\Support\RemoteSecretReferences;
|
|
use App\Support\RemoteSecretValueFormatter;
|
|
use App\Support\ValidationPatterns;
|
|
use App\Traits\Auditable;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Log;
|
|
use OpenApi\Attributes as OA;
|
|
|
|
#[OA\Schema(
|
|
description: 'Environment Variable model',
|
|
type: 'object',
|
|
properties: [
|
|
'id' => ['type' => 'integer'],
|
|
'uuid' => ['type' => 'string'],
|
|
'resourceable_type' => ['type' => 'string'],
|
|
'resourceable_id' => ['type' => 'integer'],
|
|
'is_literal' => ['type' => 'boolean'],
|
|
'is_multiline' => ['type' => 'boolean'],
|
|
'is_preview' => ['type' => 'boolean'],
|
|
'is_runtime' => ['type' => 'boolean'],
|
|
'is_buildtime' => ['type' => 'boolean'],
|
|
'is_shared' => ['type' => 'boolean'],
|
|
'is_shown_once' => ['type' => 'boolean', 'description' => 'If true, the saved value is hidden in the UI and API responses. MCP never returns environment variable values.'],
|
|
'key' => ['type' => 'string'],
|
|
'value' => ['type' => 'string'],
|
|
'real_value' => ['type' => 'string'],
|
|
'comment' => ['type' => 'string', 'nullable' => true],
|
|
'version' => ['type' => 'string'],
|
|
'created_at' => ['type' => 'string'],
|
|
'updated_at' => ['type' => 'string'],
|
|
]
|
|
)]
|
|
class EnvironmentVariable extends BaseModel
|
|
{
|
|
use Auditable;
|
|
|
|
/**
|
|
* Flags that a "# coolify:" line of the developer view can set, with their defaults.
|
|
*
|
|
* @var array<string, bool>
|
|
*/
|
|
public const DEV_VIEW_FLAG_DEFAULTS = ['literal' => false, 'buildtime' => true, 'runtime' => true];
|
|
|
|
public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_'];
|
|
|
|
/**
|
|
* Buildpack control variables that the built image reads when the container starts,
|
|
* e.g. the Nixpacks PHP prestart script renders nginx.conf from NIXPACKS_PHP_ROOT_DIR.
|
|
*/
|
|
public const RUNTIME_BUILDPACK_CONTROL_VARIABLES = [
|
|
'NIXPACKS_PHP_ROOT_DIR',
|
|
'NIXPACKS_PHP_FALLBACK_PATH',
|
|
'NIXPACKS_SPA_OUTPUT_DIR',
|
|
'RAILPACK_SKIP_MIGRATIONS',
|
|
];
|
|
|
|
/**
|
|
* Magic variable commands (SERVICE_<COMMAND>_<NAME>) whose value generateEnvValue() generates.
|
|
*/
|
|
public const GENERATED_MAGIC_VARIABLE_COMMANDS = [
|
|
'PASSWORD', 'PASSWORD_64', 'PASSWORDWITHSYMBOLS', 'PASSWORDWITHSYMBOLS_64',
|
|
'BASE64', 'BASE64_32', 'BASE64_64', 'BASE64_128',
|
|
'REALBASE64', 'REALBASE64_32', 'REALBASE64_64', 'REALBASE64_128',
|
|
'HEX_32', 'HEX_64', 'HEX_128',
|
|
'USER', 'LOWERCASEUSER', 'SUPABASEANON', 'SUPABASESERVICE',
|
|
];
|
|
|
|
protected $attributes = [
|
|
'is_runtime' => true,
|
|
'is_buildtime' => true,
|
|
];
|
|
|
|
protected $fillable = [
|
|
// Core identification
|
|
'key',
|
|
'value',
|
|
'comment',
|
|
|
|
// Polymorphic relationship
|
|
'resourceable_type',
|
|
'resourceable_id',
|
|
|
|
// Boolean flags
|
|
'is_preview',
|
|
'is_multiline',
|
|
'is_literal',
|
|
'is_runtime',
|
|
'is_buildtime',
|
|
'is_shown_once',
|
|
'is_shared',
|
|
'is_required',
|
|
|
|
// Metadata
|
|
'version',
|
|
'order',
|
|
];
|
|
|
|
protected $casts = [
|
|
'key' => 'string',
|
|
'value' => 'encrypted',
|
|
'preserve_compose_null' => 'boolean',
|
|
'is_multiline' => 'boolean',
|
|
'is_literal' => 'boolean',
|
|
'is_preview' => 'boolean',
|
|
'is_runtime' => 'boolean',
|
|
'is_buildtime' => 'boolean',
|
|
'uses_legacy_escaping' => 'boolean',
|
|
'version' => 'string',
|
|
'resourceable_type' => 'string',
|
|
'resourceable_id' => 'integer',
|
|
];
|
|
|
|
protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_buildpack_control', 'is_coolify'];
|
|
|
|
/**
|
|
* Sensitive fields hidden by default in serialized output (toArray/toJson).
|
|
* API controllers should call makeVisible([...]) for callers with the
|
|
* `read:sensitive` or `root` token ability.
|
|
*/
|
|
protected $hidden = [
|
|
'value',
|
|
'real_value',
|
|
// Internal: decides the escaping of variables saved before exact escaping.
|
|
'uses_legacy_escaping',
|
|
];
|
|
|
|
protected static function booted()
|
|
{
|
|
static::created(function (ModelsEnvironmentVariable $environment_variable) {
|
|
if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) {
|
|
$found = ModelsEnvironmentVariable::where('key', $environment_variable->key)
|
|
->where('resourceable_type', Application::class)
|
|
->where('resourceable_id', $environment_variable->resourceable_id)
|
|
->where('is_preview', true)
|
|
->first();
|
|
|
|
if (! $found) {
|
|
$application = Application::find($environment_variable->resourceable_id);
|
|
// A compose preview generates its own magic secrets on the first pull request parse.
|
|
if ($application && ! ($application->build_pack === 'dockercompose' && $environment_variable->isGeneratedMagicVariable())) {
|
|
ModelsEnvironmentVariable::create([
|
|
'key' => $environment_variable->key,
|
|
'value' => $environment_variable->value,
|
|
'is_multiline' => $environment_variable->is_multiline ?? false,
|
|
'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,
|
|
'is_shown_once' => $environment_variable->is_shown_once ?? false,
|
|
'comment' => $environment_variable->comment,
|
|
'resourceable_type' => Application::class,
|
|
'resourceable_id' => $environment_variable->resourceable_id,
|
|
'is_preview' => true,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
$environment_variable->update([
|
|
'version' => config('constants.coolify.version'),
|
|
]);
|
|
});
|
|
|
|
static::saving(function (ModelsEnvironmentVariable $environmentVariable) {
|
|
if ($environmentVariable->preserve_compose_null && $environmentVariable->isDirty('value') && $environmentVariable->value !== null) {
|
|
$environmentVariable->preserve_compose_null = false;
|
|
}
|
|
$environmentVariable->updateIsShared();
|
|
});
|
|
}
|
|
|
|
public function isGeneratedMagicVariable(): bool
|
|
{
|
|
return str_starts_with($this->key, 'SERVICE_')
|
|
&& in_array(parseCommandFromMagicEnvVariable($this->key)->value(), self::GENERATED_MAGIC_VARIABLE_COMMANDS, true);
|
|
}
|
|
|
|
public function preservesLegacyComposeNull(): bool
|
|
{
|
|
return $this->preserve_compose_null && $this->value === null;
|
|
}
|
|
|
|
public function service()
|
|
{
|
|
return $this->belongsTo(Service::class);
|
|
}
|
|
|
|
public function scopeWithoutBuildpackControlVariables(Builder $query): Builder
|
|
{
|
|
foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
|
|
$query->where('key', 'not like', "{$prefix}%");
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
public function scopeWithoutBuildOnlyBuildpackControlVariables(Builder $query): Builder
|
|
{
|
|
return $query->where(function (Builder $query) {
|
|
$query->whereIn('key', self::RUNTIME_BUILDPACK_CONTROL_VARIABLES)
|
|
->orWhere(fn (Builder $query) => $query->withoutBuildpackControlVariables());
|
|
});
|
|
}
|
|
|
|
public static function isBuildpackControlKey(?string $key): bool
|
|
{
|
|
if (blank($key)) {
|
|
return false;
|
|
}
|
|
|
|
foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
|
|
if (str($key)->startsWith($prefix)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected function value(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn (?string $value = null) => $this->get_environment_variables($value),
|
|
set: fn (?string $value = null) => $this->set_environment_variables($value),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the parent resourceable model.
|
|
*/
|
|
public function resourceable()
|
|
{
|
|
return $this->morphTo();
|
|
}
|
|
|
|
public function resource()
|
|
{
|
|
return $this->resourceable;
|
|
}
|
|
|
|
/**
|
|
* The value that the container gets: the old escaped form for variables saved before exact
|
|
* escaping, otherwise the resolved value without escaping.
|
|
*/
|
|
public function realValue(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->resolveRealValue(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolved value for display and copy. References to locked shared variables keep their
|
|
* reference text, so the UI never reveals a value that is only shown once.
|
|
*/
|
|
public function displayRealValue(): ?string
|
|
{
|
|
return $this->resolveRealValue(revealLockedSharedVariables: false);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, SharedEnvironmentVariable>|null $sharedVariables
|
|
*/
|
|
private function resolveRealValue(bool $revealLockedSharedVariables = true, bool $escapeLegacyValue = true, ?Collection $sharedVariables = null): ?string
|
|
{
|
|
if (! $this->relationLoaded('resourceable')) {
|
|
$this->load('resourceable');
|
|
}
|
|
$resource = $this->resourceable;
|
|
if (! $resource) {
|
|
return null;
|
|
}
|
|
|
|
// Load relationships needed for shared variable resolution
|
|
if (! $resource->relationLoaded('environment')) {
|
|
$resource->load('environment');
|
|
}
|
|
if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) {
|
|
$resource->load('server');
|
|
}
|
|
if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) {
|
|
$resource->load('destination.server');
|
|
}
|
|
|
|
$value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables, $sharedVariables);
|
|
|
|
return $value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping ? $value : $this->legacyEscapedValue($value);
|
|
}
|
|
|
|
/**
|
|
* The escaping used before exact escaping.
|
|
*/
|
|
private function legacyEscapedValue(string $value): string
|
|
{
|
|
// Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160)
|
|
if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
|
|
return $value;
|
|
}
|
|
|
|
return $this->is_literal || $this->is_multiline
|
|
? '\''.$value.'\''
|
|
: escapeEnvVariables($value);
|
|
}
|
|
|
|
protected function isReallyRequired(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->is_required && str($this->real_value)->isEmpty(),
|
|
);
|
|
}
|
|
|
|
protected function isBuildpackControl(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => self::isBuildpackControlKey($this->key),
|
|
);
|
|
}
|
|
|
|
protected function isCoolify(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: function () {
|
|
if (str($this->key)->startsWith('SERVICE_')) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
);
|
|
}
|
|
|
|
protected function isShared(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->isSharedReference(),
|
|
);
|
|
}
|
|
|
|
private function isSharedReference(): bool
|
|
{
|
|
if (blank($this->value)) {
|
|
return false;
|
|
}
|
|
|
|
$types = implode('|', SHARED_VARIABLE_TYPES);
|
|
|
|
return preg_match('/^{{\s*(?:'.$types.')\..*}}$/s', trim($this->value)) === 1;
|
|
}
|
|
|
|
public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null, bool $revealLockedSharedVariables = true)
|
|
{
|
|
return $this->get_real_environment_variables_internal($environment_variable, $resource, $server, $revealLockedSharedVariables);
|
|
}
|
|
|
|
public function getResolvedValueWithServer($server = null)
|
|
{
|
|
if (! $this->relationLoaded('resourceable')) {
|
|
$this->load('resourceable');
|
|
}
|
|
$resource = $this->resourceable;
|
|
if (! $resource) {
|
|
return null;
|
|
}
|
|
|
|
// Load relationships needed for shared variable resolution
|
|
if (! $resource->relationLoaded('environment')) {
|
|
$resource->load('environment');
|
|
}
|
|
if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) {
|
|
$resource->load('server');
|
|
}
|
|
if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) {
|
|
$resource->load('destination.server');
|
|
}
|
|
|
|
$real_value = $this->get_real_environment_variables_internal($this->value, $resource, $server);
|
|
|
|
// Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160)
|
|
if (json_validate($real_value) && (str_starts_with($real_value, '{') || str_starts_with($real_value, '['))) {
|
|
return $real_value;
|
|
}
|
|
|
|
if ($this->is_literal || $this->is_multiline) {
|
|
$real_value = '\''.$real_value.'\'';
|
|
} else {
|
|
$real_value = escapeEnvVariables($real_value);
|
|
}
|
|
|
|
return $real_value;
|
|
}
|
|
|
|
/** @return array<int, string> */
|
|
/**
|
|
* @param Collection<int, SharedEnvironmentVariable>|null $sharedVariables Preloaded shared variables, to avoid one query per reference.
|
|
*/
|
|
public function logRedactionValues(?Collection $sharedVariables = null): array
|
|
{
|
|
$resolvedValue = $this->resolveRealValue(escapeLegacyValue: false, sharedVariables: $sharedVariables);
|
|
if (! is_string($resolvedValue) || $resolvedValue === '') {
|
|
return [];
|
|
}
|
|
|
|
// Build-time paths still write the old escaped form, so redact both forms.
|
|
$value = $this->legacyEscapedValue($resolvedValue);
|
|
$values = [$value, $resolvedValue];
|
|
if ($this->is_multiline || $this->is_literal) {
|
|
$unquoted = str_starts_with($value, "'") && str_ends_with($value, "'")
|
|
? substr($value, 1, -1)
|
|
: $value;
|
|
$values = array_merge($values, self::logRedactionVariants($unquoted, (bool) $this->is_multiline));
|
|
}
|
|
|
|
return array_values(array_unique(array_filter($values, static fn (string $item): bool => $item !== '')));
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $secrets
|
|
* @return array<int, string>
|
|
*/
|
|
public static function remoteSecretLogRedactionValues(array $secrets): array
|
|
{
|
|
return collect($secrets)
|
|
->filter(static fn (mixed $secret): bool => is_string($secret) && $secret !== '')
|
|
->flatMap(static fn (string $secret): array => [
|
|
...self::logRedactionVariants($secret, preg_match('/\r|\n/', $secret) === 1),
|
|
RemoteSecretValueFormatter::composeFile($secret),
|
|
])
|
|
->filter(static fn (string $item): bool => $item !== '')
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Also covers the value inside a quoted argument such as 'KEY=value'.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private static function logRedactionVariants(string $value, bool $splitLines): array
|
|
{
|
|
$values = [
|
|
$value,
|
|
escapeBashEnvValue($value),
|
|
str_replace("'", "'\\''", $value),
|
|
str_replace(["\r\n", "\r", "\n"], ['\\n', '\\n', '\\n'], $value),
|
|
];
|
|
if ($splitLines) {
|
|
$values = array_merge($values, preg_split('/\r\n|\r|\n/', $value) ?: []);
|
|
}
|
|
|
|
return $values;
|
|
}
|
|
|
|
/**
|
|
* Collect the values to hide in a resource's logs.
|
|
*
|
|
* Locked values, database passwords, and resolved remote secrets are always hidden,
|
|
* whatever their length. Other values are hidden unless the resource limits log redaction
|
|
* to locked values only.
|
|
*
|
|
* Remote secrets are fetched at most once per call, and only when a variable references one.
|
|
* Callers that already hold the fetched values, such as deployments, pass $resolveRemoteSecrets = false.
|
|
*
|
|
* @return array{known: array<int, string>, locked: array<int, string>}
|
|
*/
|
|
public static function logRedactionSecretsFor(Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite|null $resource, bool $includePreview = false, bool $resolveRemoteSecrets = true): array
|
|
{
|
|
if ($resource === null) {
|
|
return ['known' => [], 'locked' => []];
|
|
}
|
|
|
|
$isDatabase = ! $resource instanceof Application && ! $resource instanceof Service;
|
|
$secrets = ['known' => [], 'locked' => []];
|
|
if ($isDatabase) {
|
|
foreach (array_keys($resource->getAttributes()) as $attribute) {
|
|
if (str_contains($attribute, 'password')) {
|
|
$value = $resource->getAttribute($attribute);
|
|
if (is_string($value) && $value !== '') {
|
|
$secrets['locked'][] = $value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$variables = collect($isDatabase ? $resource->runtime_environment_variables : $resource->environment_variables);
|
|
if ($includePreview && $resource instanceof Application) {
|
|
$variables = $variables->merge($resource->environment_variables_preview ?? []);
|
|
}
|
|
if ($variables->isEmpty()) {
|
|
return $secrets;
|
|
}
|
|
|
|
// Load the resource relations once and share them, so each variable does not load its own copy.
|
|
$resource->loadMissing('environment.project.team');
|
|
if (method_exists($resource, 'server')) {
|
|
$resource->loadMissing('server');
|
|
}
|
|
if (method_exists($resource, 'destination')) {
|
|
$resource->loadMissing('destination.server');
|
|
}
|
|
// Use copies so the resource's own loaded variables do not get a reference back to the resource.
|
|
$variables = $variables->map(fn (self $variable): self => (clone $variable)->setRelation('resourceable', $resource));
|
|
$sharedVariables = self::sharedVariablesReferencedBy($variables, $resource->team()?->id);
|
|
|
|
$redactAllValues = $resource->redactsAllEnvValuesInLogs();
|
|
$unresolvedKeys = [];
|
|
$remoteSecretFailure = null;
|
|
foreach ($variables as $variable) {
|
|
if ($resolveRemoteSecrets) {
|
|
$secrets['locked'] = array_merge(
|
|
$secrets['locked'],
|
|
self::remoteSecretLogRedactionValuesFor($resource, $variable, $sharedVariables, $remoteSecretFailure, $unresolvedKeys),
|
|
);
|
|
}
|
|
if ($variable->isLockedForLogs($sharedVariables)) {
|
|
$secrets['locked'] = array_merge($secrets['locked'], $variable->logRedactionValues($sharedVariables));
|
|
} elseif ($redactAllValues) {
|
|
$secrets['known'] = array_merge($secrets['known'], $variable->logRedactionValues($sharedVariables));
|
|
}
|
|
}
|
|
|
|
if ($remoteSecretFailure !== null) {
|
|
Log::warning('Could not resolve remote secrets for log redaction; their resolved values are not hidden.', [
|
|
'resource_type' => $resource->getMorphClass(),
|
|
'resource_uuid' => $resource->uuid,
|
|
'variables' => array_values(array_unique($unresolvedKeys)),
|
|
'exception' => $remoteSecretFailure::class,
|
|
]);
|
|
}
|
|
|
|
return $secrets;
|
|
}
|
|
|
|
/**
|
|
* Resolved remote secret values of a variable; these are always hidden.
|
|
*
|
|
* When the references cannot be resolved, export still works: the reference text and any keys
|
|
* that did resolve are hidden, and the variable is reported. After a failed or empty fetch the
|
|
* source is not asked again.
|
|
*
|
|
* @param Collection<int, SharedEnvironmentVariable>|null $sharedVariables
|
|
* @param array<int, string> $unresolvedKeys
|
|
* @return array<int, string>
|
|
*/
|
|
private static function remoteSecretLogRedactionValuesFor(Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $resource, self $variable, ?Collection $sharedVariables, ?RemoteSecretException &$failure, array &$unresolvedKeys): array
|
|
{
|
|
// Cheap check first, so variables without references never query or contact a source.
|
|
$value = $variable->resolveRealValue(escapeLegacyValue: false, sharedVariables: $sharedVariables);
|
|
if (! RemoteSecretReferences::containsReference($value) || ! $resource->environmentVariableUsesSecretManager($variable)) {
|
|
return [];
|
|
}
|
|
|
|
if ($failure === null || $resource->resolvedSecretManagerValuesForRedaction() !== []) {
|
|
try {
|
|
return self::remoteSecretLogRedactionValues([$resource->resolveSecretManagerEnvironmentVariableValue($variable)]);
|
|
} catch (RemoteSecretException $e) {
|
|
$failure = $e;
|
|
}
|
|
}
|
|
|
|
$unresolvedKeys[] = $variable->key;
|
|
$available = array_intersect_key($resource->resolvedSecretManagerValuesForRedaction(), array_flip(RemoteSecretReferences::referencedKeys($value)));
|
|
|
|
return [...self::remoteSecretLogRedactionValues(array_values($available)), ...$variable->logRedactionValues($sharedVariables)];
|
|
}
|
|
|
|
/**
|
|
* A variable is locked when it is locked itself or references a locked shared variable.
|
|
*
|
|
* @param Collection<int, SharedEnvironmentVariable>|null $sharedVariables Preloaded shared variables of the resource's team.
|
|
*/
|
|
public function isLockedForLogs(?Collection $sharedVariables = null): bool
|
|
{
|
|
if ($this->is_shown_once) {
|
|
return true;
|
|
}
|
|
|
|
$references = self::sharedVariableReferences($this->value);
|
|
if ($references === []) {
|
|
return false;
|
|
}
|
|
|
|
$sharedVariables ??= self::sharedVariablesReferencedBy(collect([$this]), $this->resourceable?->team()?->id);
|
|
if ($sharedVariables === null) {
|
|
return true;
|
|
}
|
|
|
|
return $sharedVariables->contains(fn (SharedEnvironmentVariable $shared): bool => $shared->is_shown_once
|
|
&& in_array(['type' => $shared->type, 'key' => $shared->key], $references, true));
|
|
}
|
|
|
|
/**
|
|
* Load every shared variable that the given variables reference, with one query.
|
|
*
|
|
* @param Collection<int, self> $variables
|
|
* @return Collection<int, SharedEnvironmentVariable>|null Null when the team is unknown.
|
|
*/
|
|
private static function sharedVariablesReferencedBy(Collection $variables, ?int $teamId): ?Collection
|
|
{
|
|
if ($teamId === null) {
|
|
return null;
|
|
}
|
|
|
|
$keys = $variables
|
|
->flatMap(fn (self $variable): array => self::sharedVariableReferences($variable->value))
|
|
->pluck('key')
|
|
->unique()
|
|
->values();
|
|
|
|
return $keys->isEmpty()
|
|
? collect()
|
|
: SharedEnvironmentVariable::query()->where('team_id', $teamId)->whereIn('key', $keys)->get();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{type: string, key: string}>
|
|
*/
|
|
private static function sharedVariableReferences(?string $value): array
|
|
{
|
|
preg_match_all('/{{\s*(\w+)\.([^}\s]+)\s*}}/', (string) $value, $matches, PREG_SET_ORDER);
|
|
|
|
return array_map(fn (array $match): array => ['type' => $match[1], 'key' => $match[2]], $matches);
|
|
}
|
|
|
|
public function resolveReferencedValue(): ?string
|
|
{
|
|
$value = $this->value;
|
|
|
|
if ($this->is_literal || blank($value) || ! str($value)->startsWith('$')) {
|
|
return $value;
|
|
}
|
|
|
|
$referencedKey = str($value)->after('$')->trim('{}')->value();
|
|
|
|
return static::where('resourceable_type', $this->resourceable_type)
|
|
->where('resourceable_id', $this->resourceable_id)
|
|
->where('is_preview', (bool) $this->is_preview)
|
|
->where('is_shown_once', false)
|
|
->where('key', $referencedKey)
|
|
->first()?->value ?? $value;
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, SharedEnvironmentVariable>|null $sharedVariables
|
|
*/
|
|
private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null, bool $revealLockedSharedVariables = true, ?Collection $sharedVariables = null)
|
|
{
|
|
if (is_null($environment_variable) || $environment_variable === '' || is_null($resource)) {
|
|
return $environment_variable;
|
|
}
|
|
$environment_variable = trim($environment_variable);
|
|
$sharedEnvsFound = str($environment_variable)->matchAll('/{{(.*?)}}/');
|
|
if ($sharedEnvsFound->isEmpty()) {
|
|
return $environment_variable;
|
|
}
|
|
foreach ($sharedEnvsFound as $sharedEnv) {
|
|
$type = str($sharedEnv)->trim()->match('/(.*?)\./');
|
|
if (! collect(SHARED_VARIABLE_TYPES)->contains($type)) {
|
|
continue;
|
|
}
|
|
$variable = str($sharedEnv)->trim()->match('/\.(.*)/');
|
|
$id = null;
|
|
if ($type->value() === 'environment') {
|
|
$id = $resource->environment->id;
|
|
} elseif ($type->value() === 'project') {
|
|
$id = $resource->environment->project->id;
|
|
} elseif ($type->value() === 'team') {
|
|
$id = $resource->team()->id;
|
|
} elseif ($type->value() === 'server') {
|
|
if ($serverOverride) {
|
|
$id = $serverOverride->id;
|
|
} elseif (isset($resource->server) && $resource->server) {
|
|
$id = $resource->server->id;
|
|
} elseif (isset($resource->destination) && $resource->destination && isset($resource->destination->server)) {
|
|
$id = $resource->destination->server->id;
|
|
}
|
|
}
|
|
if (is_null($id)) {
|
|
continue;
|
|
}
|
|
$found = $sharedVariables !== null
|
|
? $sharedVariables->first(fn (SharedEnvironmentVariable $shared): bool => $shared->type === $type->value()
|
|
&& $shared->key === $variable->value()
|
|
&& (int) $shared->{"{$type}_id"} === (int) $id)
|
|
: SharedEnvironmentVariable::where('type', $type)
|
|
->where('key', $variable)
|
|
->where('team_id', $resource->team()->id)
|
|
->where("{$type}_id", $id)
|
|
->first();
|
|
if ($found && ($revealLockedSharedVariables || ! $found->is_shown_once)) {
|
|
$environment_variable = str($environment_variable)->replace("{{{$sharedEnv}}}", $found->value);
|
|
}
|
|
}
|
|
|
|
return str($environment_variable)->value();
|
|
}
|
|
|
|
private function get_environment_variables(?string $environment_variable = null): ?string
|
|
{
|
|
if (! $environment_variable) {
|
|
return null;
|
|
}
|
|
|
|
return trim(decrypt($environment_variable));
|
|
}
|
|
|
|
private function set_environment_variables(?string $environment_variable = null): ?string
|
|
{
|
|
if (is_null($environment_variable)) {
|
|
return null;
|
|
}
|
|
$environment_variable = trim($environment_variable);
|
|
$type = str($environment_variable)->after('{{')->before('.')->value;
|
|
if (str($environment_variable)->startsWith('{{'.$type) && str($environment_variable)->endsWith('}}')) {
|
|
return encrypt($environment_variable);
|
|
}
|
|
|
|
return encrypt($environment_variable);
|
|
}
|
|
|
|
protected function key(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey(
|
|
ValidationPatterns::normalizeEnvironmentVariableKey($value)
|
|
),
|
|
);
|
|
}
|
|
|
|
protected function updateIsShared(): void
|
|
{
|
|
$this->is_shared = $this->isSharedReference();
|
|
}
|
|
}
|