feat: add comment field to environment variables (#7269)
This commit is contained in:
commit
b5d6e13b83
33 changed files with 3596 additions and 247 deletions
|
|
@ -2936,7 +2936,7 @@ public function envs(Request $request)
|
|||
)]
|
||||
public function update_env_by_uuid(Request $request)
|
||||
{
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
|
|
@ -2966,6 +2966,7 @@ public function update_env_by_uuid(Request $request)
|
|||
'is_shown_once' => 'boolean',
|
||||
'is_runtime' => 'boolean',
|
||||
'is_buildtime' => 'boolean',
|
||||
'comment' => 'string|nullable|max:256',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
|
|
@ -3007,6 +3008,9 @@ public function update_env_by_uuid(Request $request)
|
|||
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
|
||||
$env->is_buildtime = $request->is_buildtime;
|
||||
}
|
||||
if ($request->has('comment') && $env->comment != $request->comment) {
|
||||
$env->comment = $request->comment;
|
||||
}
|
||||
$env->save();
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
|
|
@ -3037,6 +3041,9 @@ public function update_env_by_uuid(Request $request)
|
|||
if ($request->has('is_buildtime') && $env->is_buildtime != $request->is_buildtime) {
|
||||
$env->is_buildtime = $request->is_buildtime;
|
||||
}
|
||||
if ($request->has('comment') && $env->comment != $request->comment) {
|
||||
$env->comment = $request->comment;
|
||||
}
|
||||
$env->save();
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
|
|
@ -3329,7 +3336,7 @@ public function create_bulk_envs(Request $request)
|
|||
)]
|
||||
public function create_env(Request $request)
|
||||
{
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
|
||||
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment'];
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
if (is_null($teamId)) {
|
||||
|
|
@ -3354,6 +3361,7 @@ public function create_env(Request $request)
|
|||
'is_shown_once' => 'boolean',
|
||||
'is_runtime' => 'boolean',
|
||||
'is_buildtime' => 'boolean',
|
||||
'comment' => 'string|nullable|max:256',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
|
|
@ -3389,6 +3397,7 @@ public function create_env(Request $request)
|
|||
'is_shown_once' => $request->is_shown_once ?? false,
|
||||
'is_runtime' => $request->is_runtime ?? true,
|
||||
'is_buildtime' => $request->is_buildtime ?? true,
|
||||
'comment' => $request->comment ?? null,
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
|
@ -3413,6 +3422,7 @@ public function create_env(Request $request)
|
|||
'is_shown_once' => $request->is_shown_once ?? false,
|
||||
'is_runtime' => $request->is_runtime ?? true,
|
||||
'is_buildtime' => $request->is_buildtime ?? true,
|
||||
'comment' => $request->comment ?? null,
|
||||
'resourceable_type' => get_class($application),
|
||||
'resourceable_id' => $application->id,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1184,6 +1184,7 @@ public function update_env_by_uuid(Request $request)
|
|||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
'comment' => 'string|nullable|max:256',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
|
|
@ -1199,7 +1200,19 @@ public function update_env_by_uuid(Request $request)
|
|||
return response()->json(['message' => 'Environment variable not found.'], 404);
|
||||
}
|
||||
|
||||
$env->fill($request->all());
|
||||
$env->value = $request->value;
|
||||
if ($request->has('is_literal')) {
|
||||
$env->is_literal = $request->is_literal;
|
||||
}
|
||||
if ($request->has('is_multiline')) {
|
||||
$env->is_multiline = $request->is_multiline;
|
||||
}
|
||||
if ($request->has('is_shown_once')) {
|
||||
$env->is_shown_once = $request->is_shown_once;
|
||||
}
|
||||
if ($request->has('comment')) {
|
||||
$env->comment = $request->comment;
|
||||
}
|
||||
$env->save();
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
|
|
@ -1425,6 +1438,7 @@ public function create_env(Request $request)
|
|||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
'is_shown_once' => 'boolean',
|
||||
'comment' => 'string|nullable|max:256',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
|
|
@ -1442,7 +1456,14 @@ public function create_env(Request $request)
|
|||
], 409);
|
||||
}
|
||||
|
||||
$env = $service->environment_variables()->create($request->all());
|
||||
$env = $service->environment_variables()->create([
|
||||
'key' => $key,
|
||||
'value' => $request->value,
|
||||
'is_literal' => $request->is_literal ?? false,
|
||||
'is_multiline' => $request->is_multiline ?? false,
|
||||
'is_shown_once' => $request->is_shown_once ?? false,
|
||||
'comment' => $request->comment ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,10 +63,16 @@ public function submit()
|
|||
]);
|
||||
|
||||
$variables = parseEnvFormatToArray($this->envFile);
|
||||
foreach ($variables as $key => $variable) {
|
||||
foreach ($variables as $key => $data) {
|
||||
// Extract value and comment from parsed data
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $variable,
|
||||
'value' => $value,
|
||||
'comment' => $comment,
|
||||
'is_preview' => false,
|
||||
'resourceable_id' => $service->id,
|
||||
'resourceable_type' => $service->getMorphClass(),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ class Add extends Component
|
|||
|
||||
public bool $is_buildtime = true;
|
||||
|
||||
public ?string $comment = null;
|
||||
|
||||
public array $problematicVariables = [];
|
||||
|
||||
protected $listeners = ['clearAddEnv' => 'clear'];
|
||||
|
|
@ -42,6 +44,7 @@ class Add extends Component
|
|||
'is_literal' => 'required|boolean',
|
||||
'is_runtime' => 'required|boolean',
|
||||
'is_buildtime' => 'required|boolean',
|
||||
'comment' => 'nullable|string|max:256',
|
||||
];
|
||||
|
||||
protected $validationAttributes = [
|
||||
|
|
@ -51,6 +54,7 @@ class Add extends Component
|
|||
'is_literal' => 'literal',
|
||||
'is_runtime' => 'runtime',
|
||||
'is_buildtime' => 'buildtime',
|
||||
'comment' => 'comment',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
|
|
@ -136,6 +140,7 @@ public function submit()
|
|||
'is_runtime' => $this->is_runtime,
|
||||
'is_buildtime' => $this->is_buildtime,
|
||||
'is_preview' => $this->is_preview,
|
||||
'comment' => $this->comment,
|
||||
]);
|
||||
$this->clear();
|
||||
}
|
||||
|
|
@ -148,5 +153,6 @@ public function clear()
|
|||
$this->is_literal = false;
|
||||
$this->is_runtime = true;
|
||||
$this->is_buildtime = true;
|
||||
$this->comment = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,62 @@ public function getEnvironmentVariablesPreviewProperty()
|
|||
return $query->get();
|
||||
}
|
||||
|
||||
public function getHardcodedEnvironmentVariablesProperty()
|
||||
{
|
||||
return $this->getHardcodedVariables(false);
|
||||
}
|
||||
|
||||
public function getHardcodedEnvironmentVariablesPreviewProperty()
|
||||
{
|
||||
return $this->getHardcodedVariables(true);
|
||||
}
|
||||
|
||||
protected function getHardcodedVariables(bool $isPreview)
|
||||
{
|
||||
// Only for services and docker-compose applications
|
||||
if ($this->resource->type() !== 'service' &&
|
||||
($this->resourceClass !== 'App\Models\Application' ||
|
||||
($this->resourceClass === 'App\Models\Application' && $this->resource->build_pack !== 'dockercompose'))) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
$dockerComposeRaw = $this->resource->docker_compose_raw ?? $this->resource->docker_compose;
|
||||
|
||||
if (blank($dockerComposeRaw)) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
// Extract all hard-coded variables
|
||||
$hardcodedVars = extractHardcodedEnvironmentVariables($dockerComposeRaw);
|
||||
|
||||
// Filter out magic variables (SERVICE_FQDN_*, SERVICE_URL_*, SERVICE_NAME_*)
|
||||
$hardcodedVars = $hardcodedVars->filter(function ($var) {
|
||||
$key = $var['key'];
|
||||
|
||||
return ! str($key)->startsWith(['SERVICE_FQDN_', 'SERVICE_URL_', 'SERVICE_NAME_']);
|
||||
});
|
||||
|
||||
// Filter out variables that exist in database (user has overridden/managed them)
|
||||
// For preview, check against preview variables; for production, check against production variables
|
||||
if ($isPreview) {
|
||||
$managedKeys = $this->resource->environment_variables_preview()->pluck('key')->toArray();
|
||||
} else {
|
||||
$managedKeys = $this->resource->environment_variables()->where('is_preview', false)->pluck('key')->toArray();
|
||||
}
|
||||
|
||||
$hardcodedVars = $hardcodedVars->filter(function ($var) use ($managedKeys) {
|
||||
return ! in_array($var['key'], $managedKeys);
|
||||
});
|
||||
|
||||
// Apply sorting based on is_env_sorting_enabled
|
||||
if ($this->is_env_sorting_enabled) {
|
||||
$hardcodedVars = $hardcodedVars->sortBy('key')->values();
|
||||
}
|
||||
// Otherwise keep order from docker-compose file
|
||||
|
||||
return $hardcodedVars;
|
||||
}
|
||||
|
||||
public function getDevView()
|
||||
{
|
||||
$this->variables = $this->formatEnvironmentVariables($this->environmentVariables);
|
||||
|
|
@ -240,6 +296,7 @@ private function createEnvironmentVariable($data)
|
|||
$environment->is_runtime = $data['is_runtime'] ?? true;
|
||||
$environment->is_buildtime = $data['is_buildtime'] ?? true;
|
||||
$environment->is_preview = $data['is_preview'] ?? false;
|
||||
$environment->comment = $data['comment'] ?? null;
|
||||
$environment->resourceable_id = $this->resource->id;
|
||||
$environment->resourceable_type = $this->resource->getMorphClass();
|
||||
|
||||
|
|
@ -280,18 +337,37 @@ private function deleteRemovedVariables($isPreview, $variables)
|
|||
private function updateOrCreateVariables($isPreview, $variables)
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($variables as $key => $value) {
|
||||
foreach ($variables as $key => $data) {
|
||||
if (str($key)->startsWith('SERVICE_FQDN') || str($key)->startsWith('SERVICE_URL') || str($key)->startsWith('SERVICE_NAME')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract value and comment from parsed data
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
$method = $isPreview ? 'environment_variables_preview' : 'environment_variables';
|
||||
$found = $this->resource->$method()->where('key', $key)->first();
|
||||
|
||||
if ($found) {
|
||||
if (! $found->is_shown_once && ! $found->is_multiline) {
|
||||
// Only count as a change if the value actually changed
|
||||
$changed = false;
|
||||
|
||||
// Update value if it changed
|
||||
if ($found->value !== $value) {
|
||||
$found->value = $value;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
// Only update comment from inline comment if one is provided (overwrites existing)
|
||||
// If $comment is null, don't touch existing comment field to preserve it
|
||||
if ($comment !== null && $found->comment !== $comment) {
|
||||
$found->comment = $comment;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$found->save();
|
||||
$count++;
|
||||
}
|
||||
|
|
@ -300,6 +376,7 @@ private function updateOrCreateVariables($isPreview, $variables)
|
|||
$environment = new EnvironmentVariable;
|
||||
$environment->key = $key;
|
||||
$environment->value = $value;
|
||||
$environment->comment = $comment; // Set comment from inline comment
|
||||
$environment->is_multiline = false;
|
||||
$environment->is_preview = $isPreview;
|
||||
$environment->resourceable_id = $this->resource->id;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ class Show extends Component
|
|||
|
||||
public bool $isLocked = false;
|
||||
|
||||
public bool $isMagicVariable = false;
|
||||
|
||||
public bool $isSharedVariable = false;
|
||||
|
||||
public string $type;
|
||||
|
|
@ -34,6 +36,8 @@ class Show extends Component
|
|||
|
||||
public ?string $real_value = null;
|
||||
|
||||
public ?string $comment = null;
|
||||
|
||||
public bool $is_shared = false;
|
||||
|
||||
public bool $is_multiline = false;
|
||||
|
|
@ -63,6 +67,7 @@ class Show extends Component
|
|||
protected $rules = [
|
||||
'key' => 'required|string',
|
||||
'value' => 'nullable',
|
||||
'comment' => 'nullable|string|max:256',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_shown_once' => 'required|boolean',
|
||||
|
|
@ -104,6 +109,7 @@ public function syncData(bool $toModel = false)
|
|||
$this->validate([
|
||||
'key' => 'required|string',
|
||||
'value' => 'nullable',
|
||||
'comment' => 'nullable|string|max:256',
|
||||
'is_multiline' => 'required|boolean',
|
||||
'is_literal' => 'required|boolean',
|
||||
'is_shown_once' => 'required|boolean',
|
||||
|
|
@ -118,6 +124,7 @@ public function syncData(bool $toModel = false)
|
|||
}
|
||||
$this->env->key = $this->key;
|
||||
$this->env->value = $this->value;
|
||||
$this->env->comment = $this->comment;
|
||||
$this->env->is_multiline = $this->is_multiline;
|
||||
$this->env->is_literal = $this->is_literal;
|
||||
$this->env->is_shown_once = $this->is_shown_once;
|
||||
|
|
@ -125,6 +132,7 @@ public function syncData(bool $toModel = false)
|
|||
} else {
|
||||
$this->key = $this->env->key;
|
||||
$this->value = $this->env->value;
|
||||
$this->comment = $this->env->comment;
|
||||
$this->is_multiline = $this->env->is_multiline;
|
||||
$this->is_literal = $this->env->is_literal;
|
||||
$this->is_shown_once = $this->env->is_shown_once;
|
||||
|
|
@ -140,9 +148,13 @@ public function syncData(bool $toModel = false)
|
|||
public function checkEnvs()
|
||||
{
|
||||
$this->isDisabled = false;
|
||||
$this->isMagicVariable = false;
|
||||
|
||||
if (str($this->env->key)->startsWith('SERVICE_FQDN') || str($this->env->key)->startsWith('SERVICE_URL') || str($this->env->key)->startsWith('SERVICE_NAME')) {
|
||||
$this->isDisabled = true;
|
||||
$this->isMagicVariable = true;
|
||||
}
|
||||
|
||||
if ($this->env->is_shown_once) {
|
||||
$this->isLocked = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class ShowHardcoded extends Component
|
||||
{
|
||||
public array $env;
|
||||
|
||||
public string $key;
|
||||
|
||||
public ?string $value = null;
|
||||
|
||||
public ?string $comment = null;
|
||||
|
||||
public ?string $serviceName = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->key = $this->env['key'];
|
||||
$this->value = $this->env['value'] ?? null;
|
||||
$this->comment = $this->env['comment'] ?? null;
|
||||
$this->serviceName = $this->env['service_name'] ?? null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.environment-variable.show-hardcoded');
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ public function saveKey($data)
|
|||
'value' => $data['value'],
|
||||
'is_multiline' => $data['is_multiline'],
|
||||
'is_literal' => $data['is_literal'],
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'type' => 'environment',
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ public function saveKey($data)
|
|||
'value' => $data['value'],
|
||||
'is_multiline' => $data['is_multiline'],
|
||||
'is_literal' => $data['is_literal'],
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'type' => 'project',
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ public function saveKey($data)
|
|||
'value' => $data['value'],
|
||||
'is_multiline' => $data['is_multiline'],
|
||||
'is_literal' => $data['is_literal'],
|
||||
'comment' => $data['comment'] ?? null,
|
||||
'type' => 'team',
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
'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'],
|
||||
|
|
@ -31,7 +32,30 @@
|
|||
)]
|
||||
class EnvironmentVariable extends BaseModel
|
||||
{
|
||||
protected $guarded = [];
|
||||
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',
|
||||
|
|
@ -67,6 +91,7 @@ protected static function booted()
|
|||
'is_literal' => $environment_variable->is_literal ?? false,
|
||||
'is_runtime' => $environment_variable->is_runtime ?? false,
|
||||
'is_buildtime' => $environment_variable->is_buildtime ?? false,
|
||||
'comment' => $environment_variable->comment,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $environment_variable->resourceable_id,
|
||||
'is_preview' => true,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,23 @@
|
|||
|
||||
class SharedEnvironmentVariable extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
protected $fillable = [
|
||||
// Core identification
|
||||
'key',
|
||||
'value',
|
||||
'comment',
|
||||
|
||||
// Type and relationships
|
||||
'type',
|
||||
'team_id',
|
||||
'project_id',
|
||||
'environment_id',
|
||||
|
||||
// Boolean flags
|
||||
'is_multiline',
|
||||
'is_literal',
|
||||
'is_shown_once',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'key' => 'string',
|
||||
|
|
|
|||
|
|
@ -998,53 +998,139 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
|
|||
} else {
|
||||
if ($value->startsWith('$')) {
|
||||
$isRequired = false;
|
||||
if ($value->contains(':-')) {
|
||||
$value = replaceVariables($value);
|
||||
$key = $value->before(':');
|
||||
$value = $value->after(':-');
|
||||
} elseif ($value->contains('-')) {
|
||||
$value = replaceVariables($value);
|
||||
|
||||
$key = $value->before('-');
|
||||
$value = $value->after('-');
|
||||
} elseif ($value->contains(':?')) {
|
||||
$value = replaceVariables($value);
|
||||
// Extract variable content between ${...} using balanced brace matching
|
||||
$result = extractBalancedBraceContent($value->value(), 0);
|
||||
|
||||
$key = $value->before(':');
|
||||
$value = $value->after(':?');
|
||||
$isRequired = true;
|
||||
} elseif ($value->contains('?')) {
|
||||
$value = replaceVariables($value);
|
||||
if ($result !== null) {
|
||||
$content = $result['content'];
|
||||
$split = splitOnOperatorOutsideNested($content);
|
||||
|
||||
$key = $value->before('?');
|
||||
$value = $value->after('?');
|
||||
$isRequired = true;
|
||||
}
|
||||
if ($originalValue->value() === $value->value()) {
|
||||
// This means the variable does not have a default value, so it needs to be created in Coolify
|
||||
$parsedKeyValue = replaceVariables($value);
|
||||
if ($split !== null) {
|
||||
// Has default value syntax (:-, -, :?, or ?)
|
||||
$varName = $split['variable'];
|
||||
$operator = $split['operator'];
|
||||
$defaultValue = $split['default'];
|
||||
$isRequired = str_contains($operator, '?');
|
||||
|
||||
// Create the primary variable with its default (only if it doesn't exist)
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $varName,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $defaultValue,
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
|
||||
// Add the variable to the environment so it will be shown in the deployable compose file
|
||||
$environment[$varName] = $envVar->value;
|
||||
|
||||
// Recursively process nested variables in default value
|
||||
if (str_contains($defaultValue, '${')) {
|
||||
$searchPos = 0;
|
||||
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
|
||||
while ($nestedResult !== null) {
|
||||
$nestedContent = $nestedResult['content'];
|
||||
$nestedSplit = splitOnOperatorOutsideNested($nestedContent);
|
||||
|
||||
// Determine the nested variable name
|
||||
$nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent;
|
||||
|
||||
// Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system
|
||||
$isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_');
|
||||
|
||||
if (! $isMagicVariable) {
|
||||
if ($nestedSplit !== null) {
|
||||
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $nestedSplit['variable'],
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $nestedSplit['default'],
|
||||
'is_preview' => false,
|
||||
]);
|
||||
$environment[$nestedSplit['variable']] = $nestedEnvVar->value;
|
||||
} else {
|
||||
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $nestedContent,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'is_preview' => false,
|
||||
]);
|
||||
$environment[$nestedContent] = $nestedEnvVar->value;
|
||||
}
|
||||
}
|
||||
|
||||
$searchPos = $nestedResult['end'] + 1;
|
||||
if ($searchPos >= strlen($defaultValue)) {
|
||||
break;
|
||||
}
|
||||
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Simple variable reference without default
|
||||
$parsedKeyValue = replaceVariables($value);
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $content,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
// Add the variable to the environment
|
||||
$environment[$content] = $value;
|
||||
}
|
||||
} else {
|
||||
// Fallback to old behavior for malformed input (backward compatibility)
|
||||
if ($value->contains(':-')) {
|
||||
$value = replaceVariables($value);
|
||||
$key = $value->before(':');
|
||||
$value = $value->after(':-');
|
||||
} elseif ($value->contains('-')) {
|
||||
$value = replaceVariables($value);
|
||||
$key = $value->before('-');
|
||||
$value = $value->after('-');
|
||||
} elseif ($value->contains(':?')) {
|
||||
$value = replaceVariables($value);
|
||||
$key = $value->before(':');
|
||||
$value = $value->after(':?');
|
||||
$isRequired = true;
|
||||
} elseif ($value->contains('?')) {
|
||||
$value = replaceVariables($value);
|
||||
$key = $value->before('?');
|
||||
$value = $value->after('?');
|
||||
$isRequired = true;
|
||||
}
|
||||
if ($originalValue->value() === $value->value()) {
|
||||
// This means the variable does not have a default value
|
||||
$parsedKeyValue = replaceVariables($value);
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $parsedKeyValue,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
$environment[$parsedKeyValue->value()] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $parsedKeyValue,
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
// Add the variable to the environment so it will be shown in the deployable compose file
|
||||
$environment[$parsedKeyValue->value()] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1411,6 +1497,9 @@ function serviceParser(Service $resource): Collection
|
|||
return collect([]);
|
||||
}
|
||||
|
||||
// Extract inline comments from raw YAML before Symfony parser discards them
|
||||
$envComments = extractYamlEnvironmentComments($compose);
|
||||
|
||||
$server = data_get($resource, 'server');
|
||||
$allServices = get_service_templates();
|
||||
|
||||
|
|
@ -1694,51 +1783,60 @@ function serviceParser(Service $resource): Collection
|
|||
}
|
||||
|
||||
// ALWAYS create BOTH base SERVICE_URL and SERVICE_FQDN pairs (without port)
|
||||
$fqdnKey = "SERVICE_FQDN_{$serviceName}";
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => "SERVICE_FQDN_{$serviceName}",
|
||||
'key' => $fqdnKey,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdnValueForEnv,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$fqdnKey] ?? null,
|
||||
]);
|
||||
|
||||
$urlKey = "SERVICE_URL_{$serviceName}";
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => "SERVICE_URL_{$serviceName}",
|
||||
'key' => $urlKey,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $url,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$urlKey] ?? null,
|
||||
]);
|
||||
|
||||
// For port-specific variables, ALSO create port-specific pairs
|
||||
// If template variable has port, create both URL and FQDN with port suffix
|
||||
if ($parsed['has_port'] && $port) {
|
||||
$fqdnPortKey = "SERVICE_FQDN_{$serviceName}_{$port}";
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => "SERVICE_FQDN_{$serviceName}_{$port}",
|
||||
'key' => $fqdnPortKey,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdnValueForEnvWithPort,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$fqdnPortKey] ?? null,
|
||||
]);
|
||||
|
||||
$urlPortKey = "SERVICE_URL_{$serviceName}_{$port}";
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => "SERVICE_URL_{$serviceName}_{$port}",
|
||||
'key' => $urlPortKey,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $urlWithPort,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$urlPortKey] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
$allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
|
||||
if ($magicEnvironments->count() > 0) {
|
||||
foreach ($magicEnvironments as $key => $value) {
|
||||
$key = str($key);
|
||||
foreach ($magicEnvironments as $magicKey => $value) {
|
||||
$originalMagicKey = $magicKey; // Preserve original key for comment lookup
|
||||
$key = str($magicKey);
|
||||
$value = replaceVariables($value);
|
||||
$command = parseCommandFromMagicEnvVariable($key);
|
||||
if ($command->value() === 'FQDN') {
|
||||
|
|
@ -1762,18 +1860,33 @@ function serviceParser(Service $resource): Collection
|
|||
$serviceExists->fqdn = $url;
|
||||
$serviceExists->save();
|
||||
}
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
// Create FQDN variable
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $key->value(),
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdn,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$originalMagicKey] ?? null,
|
||||
]);
|
||||
|
||||
// Also create the paired SERVICE_URL_* variable
|
||||
$urlKey = 'SERVICE_URL_'.strtoupper($fqdnFor);
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $urlKey,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $url,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$urlKey] ?? null,
|
||||
]);
|
||||
|
||||
} elseif ($command->value() === 'URL') {
|
||||
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
|
||||
$url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid");
|
||||
$fqdn = generateFqdn(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version);
|
||||
|
||||
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
|
||||
// Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791)
|
||||
|
|
@ -1790,24 +1903,39 @@ function serviceParser(Service $resource): Collection
|
|||
$serviceExists->fqdn = $url;
|
||||
$serviceExists->save();
|
||||
}
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
// Create URL variable
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $key->value(),
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $url,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$originalMagicKey] ?? null,
|
||||
]);
|
||||
|
||||
// Also create the paired SERVICE_FQDN_* variable
|
||||
$fqdnKey = 'SERVICE_FQDN_'.strtoupper($urlFor);
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $fqdnKey,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $fqdn,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$fqdnKey] ?? null,
|
||||
]);
|
||||
|
||||
} else {
|
||||
$value = generateEnvValue($command, $resource);
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $key->value(),
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$originalMagicKey] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -2163,18 +2291,20 @@ function serviceParser(Service $resource): Collection
|
|||
return ! str($value)->startsWith('SERVICE_');
|
||||
});
|
||||
foreach ($normalEnvironments as $key => $value) {
|
||||
$originalKey = $key; // Preserve original key for comment lookup
|
||||
$key = str($key);
|
||||
$value = str($value);
|
||||
$originalValue = $value;
|
||||
$parsedValue = replaceVariables($value);
|
||||
if ($parsedValue->startsWith('SERVICE_')) {
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
|
||||
continue;
|
||||
|
|
@ -2184,64 +2314,161 @@ function serviceParser(Service $resource): Collection
|
|||
}
|
||||
if ($key->value() === $parsedValue->value()) {
|
||||
$value = null;
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
} else {
|
||||
if ($value->startsWith('$')) {
|
||||
$isRequired = false;
|
||||
if ($value->contains(':-')) {
|
||||
$value = replaceVariables($value);
|
||||
$key = $value->before(':');
|
||||
$value = $value->after(':-');
|
||||
} elseif ($value->contains('-')) {
|
||||
$value = replaceVariables($value);
|
||||
|
||||
$key = $value->before('-');
|
||||
$value = $value->after('-');
|
||||
} elseif ($value->contains(':?')) {
|
||||
$value = replaceVariables($value);
|
||||
// Extract variable content between ${...} using balanced brace matching
|
||||
$result = extractBalancedBraceContent($value->value(), 0);
|
||||
|
||||
$key = $value->before(':');
|
||||
$value = $value->after(':?');
|
||||
$isRequired = true;
|
||||
} elseif ($value->contains('?')) {
|
||||
$value = replaceVariables($value);
|
||||
if ($result !== null) {
|
||||
$content = $result['content'];
|
||||
$split = splitOnOperatorOutsideNested($content);
|
||||
|
||||
$key = $value->before('?');
|
||||
$value = $value->after('?');
|
||||
$isRequired = true;
|
||||
}
|
||||
if ($originalValue->value() === $value->value()) {
|
||||
// This means the variable does not have a default value, so it needs to be created in Coolify
|
||||
$parsedKeyValue = replaceVariables($value);
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $parsedKeyValue,
|
||||
if ($split !== null) {
|
||||
// Has default value syntax (:-, -, :?, or ?)
|
||||
$varName = $split['variable'];
|
||||
$operator = $split['operator'];
|
||||
$defaultValue = $split['default'];
|
||||
$isRequired = str_contains($operator, '?');
|
||||
|
||||
// Create the primary variable with its default (only if it doesn't exist)
|
||||
// Use firstOrCreate instead of updateOrCreate to avoid overwriting user edits
|
||||
$envVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $varName,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $defaultValue,
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
|
||||
// Add the variable to the environment so it will be shown in the deployable compose file
|
||||
$environment[$varName] = $envVar->value;
|
||||
|
||||
// Recursively process nested variables in default value
|
||||
if (str_contains($defaultValue, '${')) {
|
||||
// Extract and create nested variables
|
||||
$searchPos = 0;
|
||||
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
|
||||
while ($nestedResult !== null) {
|
||||
$nestedContent = $nestedResult['content'];
|
||||
$nestedSplit = splitOnOperatorOutsideNested($nestedContent);
|
||||
|
||||
// Determine the nested variable name
|
||||
$nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent;
|
||||
|
||||
// Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system
|
||||
$isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_');
|
||||
|
||||
if (! $isMagicVariable) {
|
||||
if ($nestedSplit !== null) {
|
||||
// Create nested variable with its default (only if it doesn't exist)
|
||||
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $nestedSplit['variable'],
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $nestedSplit['default'],
|
||||
'is_preview' => false,
|
||||
]);
|
||||
// Add nested variable to environment
|
||||
$environment[$nestedSplit['variable']] = $nestedEnvVar->value;
|
||||
} else {
|
||||
// Simple nested variable without default (only if it doesn't exist)
|
||||
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
|
||||
'key' => $nestedContent,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'is_preview' => false,
|
||||
]);
|
||||
// Add nested variable to environment
|
||||
$environment[$nestedContent] = $nestedEnvVar->value;
|
||||
}
|
||||
}
|
||||
|
||||
// Look for more nested variables
|
||||
$searchPos = $nestedResult['end'] + 1;
|
||||
if ($searchPos >= strlen($defaultValue)) {
|
||||
break;
|
||||
}
|
||||
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Simple variable reference without default
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $content,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Fallback to old behavior for malformed input (backward compatibility)
|
||||
if ($value->contains(':-')) {
|
||||
$value = replaceVariables($value);
|
||||
$key = $value->before(':');
|
||||
$value = $value->after(':-');
|
||||
} elseif ($value->contains('-')) {
|
||||
$value = replaceVariables($value);
|
||||
$key = $value->before('-');
|
||||
$value = $value->after('-');
|
||||
} elseif ($value->contains(':?')) {
|
||||
$value = replaceVariables($value);
|
||||
$key = $value->before(':');
|
||||
$value = $value->after(':?');
|
||||
$isRequired = true;
|
||||
} elseif ($value->contains('?')) {
|
||||
$value = replaceVariables($value);
|
||||
$key = $value->before('?');
|
||||
$value = $value->after('?');
|
||||
$isRequired = true;
|
||||
}
|
||||
|
||||
if ($originalValue->value() === $value->value()) {
|
||||
// This means the variable does not have a default value
|
||||
$parsedKeyValue = replaceVariables($value);
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $parsedKeyValue,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
// Add the variable to the environment so it will be shown in the deployable compose file
|
||||
$environment[$parsedKeyValue->value()] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
$resource->environment_variables()->updateOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
// Add the variable to the environment so it will be shown in the deployable compose file
|
||||
$environment[$parsedKeyValue->value()] = $value;
|
||||
|
||||
continue;
|
||||
}
|
||||
$resource->environment_variables()->firstOrCreate([
|
||||
'key' => $key,
|
||||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
], [
|
||||
'value' => $value,
|
||||
'is_preview' => false,
|
||||
'is_required' => $isRequired,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,118 @@ function collectRegex(string $name)
|
|||
{
|
||||
return "/{$name}\w+/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content between balanced braces, handling nested braces properly.
|
||||
*
|
||||
* @param string $str The string to search
|
||||
* @param int $startPos Position to start searching from
|
||||
* @return array|null Array with 'content', 'start', and 'end' keys, or null if no balanced braces found
|
||||
*/
|
||||
function extractBalancedBraceContent(string $str, int $startPos = 0): ?array
|
||||
{
|
||||
// Find opening brace
|
||||
if ($startPos >= strlen($str)) {
|
||||
return null;
|
||||
}
|
||||
$openPos = strpos($str, '{', $startPos);
|
||||
if ($openPos === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Track depth to find matching closing brace
|
||||
$depth = 1;
|
||||
$pos = $openPos + 1;
|
||||
$len = strlen($str);
|
||||
|
||||
while ($pos < $len && $depth > 0) {
|
||||
if ($str[$pos] === '{') {
|
||||
$depth++;
|
||||
} elseif ($str[$pos] === '}') {
|
||||
$depth--;
|
||||
}
|
||||
$pos++;
|
||||
}
|
||||
|
||||
if ($depth !== 0) {
|
||||
// Unbalanced braces
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => substr($str, $openPos + 1, $pos - $openPos - 2),
|
||||
'start' => $openPos,
|
||||
'end' => $pos - 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split variable expression on operators (:-, -, :?, ?) while respecting nested braces.
|
||||
*
|
||||
* @param string $content The content to split (without outer ${...})
|
||||
* @return array|null Array with 'variable', 'operator', and 'default' keys, or null if no operator found
|
||||
*/
|
||||
function splitOnOperatorOutsideNested(string $content): ?array
|
||||
{
|
||||
$operators = [':-', '-', ':?', '?'];
|
||||
$depth = 0;
|
||||
$len = strlen($content);
|
||||
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
if ($content[$i] === '{') {
|
||||
$depth++;
|
||||
} elseif ($content[$i] === '}') {
|
||||
$depth--;
|
||||
} elseif ($depth === 0) {
|
||||
// Check for operators only at depth 0 (outside nested braces)
|
||||
foreach ($operators as $op) {
|
||||
if (substr($content, $i, strlen($op)) === $op) {
|
||||
return [
|
||||
'variable' => substr($content, 0, $i),
|
||||
'operator' => $op,
|
||||
'default' => substr($content, $i + strlen($op)),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function replaceVariables(string $variable): Stringable
|
||||
{
|
||||
return str($variable)->before('}')->replaceFirst('$', '')->replaceFirst('{', '');
|
||||
// Handle ${VAR} syntax with proper brace matching
|
||||
$str = str($variable);
|
||||
|
||||
// Handle ${VAR} format
|
||||
if ($str->startsWith('${')) {
|
||||
$result = extractBalancedBraceContent($variable, 0);
|
||||
if ($result !== null) {
|
||||
return str($result['content']);
|
||||
}
|
||||
|
||||
// Fallback to old behavior for malformed input
|
||||
return $str->before('}')->replaceFirst('$', '')->replaceFirst('{', '');
|
||||
}
|
||||
|
||||
// Handle {VAR} format (from regex capture group without $)
|
||||
if ($str->startsWith('{') && $str->endsWith('}')) {
|
||||
return str(substr($variable, 1, -1));
|
||||
}
|
||||
|
||||
// Handle {VAR format (from regex capture group, may be truncated)
|
||||
if ($str->startsWith('{')) {
|
||||
$result = extractBalancedBraceContent('$'.$variable, 0);
|
||||
if ($result !== null) {
|
||||
return str($result['content']);
|
||||
}
|
||||
|
||||
// Fallback: remove { and get content before }
|
||||
return $str->replaceFirst('{', '')->before('}');
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false)
|
||||
|
|
|
|||
|
|
@ -448,19 +448,286 @@ function parseEnvFormatToArray($env_file_contents)
|
|||
$equals_pos = strpos($line, '=');
|
||||
if ($equals_pos !== false) {
|
||||
$key = substr($line, 0, $equals_pos);
|
||||
$value = substr($line, $equals_pos + 1);
|
||||
if (substr($value, 0, 1) === '"' && substr($value, -1) === '"') {
|
||||
$value = substr($value, 1, -1);
|
||||
} elseif (substr($value, 0, 1) === "'" && substr($value, -1) === "'") {
|
||||
$value = substr($value, 1, -1);
|
||||
$value_and_comment = substr($line, $equals_pos + 1);
|
||||
$comment = null;
|
||||
$remainder = '';
|
||||
|
||||
// Check if value starts with quotes
|
||||
$firstChar = $value_and_comment[0] ?? '';
|
||||
$isDoubleQuoted = $firstChar === '"';
|
||||
$isSingleQuoted = $firstChar === "'";
|
||||
|
||||
if ($isDoubleQuoted) {
|
||||
// Find the closing double quote
|
||||
$closingPos = strpos($value_and_comment, '"', 1);
|
||||
if ($closingPos !== false) {
|
||||
// Extract quoted value and remove quotes
|
||||
$value = substr($value_and_comment, 1, $closingPos - 1);
|
||||
// Everything after closing quote (including comments)
|
||||
$remainder = substr($value_and_comment, $closingPos + 1);
|
||||
} else {
|
||||
// No closing quote - treat as unquoted
|
||||
$value = substr($value_and_comment, 1);
|
||||
}
|
||||
} elseif ($isSingleQuoted) {
|
||||
// Find the closing single quote
|
||||
$closingPos = strpos($value_and_comment, "'", 1);
|
||||
if ($closingPos !== false) {
|
||||
// Extract quoted value and remove quotes
|
||||
$value = substr($value_and_comment, 1, $closingPos - 1);
|
||||
// Everything after closing quote (including comments)
|
||||
$remainder = substr($value_and_comment, $closingPos + 1);
|
||||
} else {
|
||||
// No closing quote - treat as unquoted
|
||||
$value = substr($value_and_comment, 1);
|
||||
}
|
||||
} else {
|
||||
// Unquoted value - strip inline comments
|
||||
// Only treat # as comment if preceded by whitespace
|
||||
if (preg_match('/\s+#/', $value_and_comment, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
// Found whitespace followed by #, extract comment
|
||||
$remainder = substr($value_and_comment, $matches[0][1]);
|
||||
$value = substr($value_and_comment, 0, $matches[0][1]);
|
||||
$value = rtrim($value);
|
||||
} else {
|
||||
$value = $value_and_comment;
|
||||
}
|
||||
}
|
||||
$env_array[$key] = $value;
|
||||
|
||||
// Extract comment from remainder (if any)
|
||||
if ($remainder !== '') {
|
||||
// Look for # in remainder
|
||||
$hashPos = strpos($remainder, '#');
|
||||
if ($hashPos !== false) {
|
||||
// Extract everything after the # and trim
|
||||
$comment = substr($remainder, $hashPos + 1);
|
||||
$comment = trim($comment);
|
||||
// Set to null if empty after trimming
|
||||
if ($comment === '') {
|
||||
$comment = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$env_array[$key] = [
|
||||
'value' => $value,
|
||||
'comment' => $comment,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $env_array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract inline comments from environment variables in raw docker-compose YAML.
|
||||
*
|
||||
* Parses raw docker-compose YAML to extract inline comments from environment sections.
|
||||
* Standard YAML parsers discard comments, so this pre-processes the raw text.
|
||||
*
|
||||
* Handles both formats:
|
||||
* - Map format: `KEY: "value" # comment` or `KEY: value # comment`
|
||||
* - Array format: `- KEY=value # comment`
|
||||
*
|
||||
* @param string $rawYaml The raw docker-compose.yml content
|
||||
* @return array Map of environment variable keys to their inline comments
|
||||
*/
|
||||
function extractYamlEnvironmentComments(string $rawYaml): array
|
||||
{
|
||||
$comments = [];
|
||||
$lines = explode("\n", $rawYaml);
|
||||
$inEnvironmentBlock = false;
|
||||
$environmentIndent = 0;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Skip empty lines
|
||||
if (trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate current line's indentation (number of leading spaces)
|
||||
$currentIndent = strlen($line) - strlen(ltrim($line));
|
||||
|
||||
// Check if this line starts an environment block
|
||||
if (preg_match('/^(\s*)environment\s*:\s*$/', $line, $matches)) {
|
||||
$inEnvironmentBlock = true;
|
||||
$environmentIndent = strlen($matches[1]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this line starts an environment block with inline content (rare but possible)
|
||||
if (preg_match('/^(\s*)environment\s*:\s*\{/', $line)) {
|
||||
// Inline object format - not supported for comment extraction
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we're in an environment block, check if we've exited it
|
||||
if ($inEnvironmentBlock) {
|
||||
// If we hit a line with same or less indentation that's not empty, we've left the block
|
||||
// Unless it's a continuation of the environment block
|
||||
$trimmedLine = ltrim($line);
|
||||
|
||||
// Check if this is a new top-level key (same indent as 'environment:' or less)
|
||||
if ($currentIndent <= $environmentIndent && ! str_starts_with($trimmedLine, '-') && ! str_starts_with($trimmedLine, '#')) {
|
||||
// Check if it looks like a YAML key (contains : not inside quotes)
|
||||
if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*\s*:/', $trimmedLine)) {
|
||||
$inEnvironmentBlock = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip comment-only lines
|
||||
if (str_starts_with($trimmedLine, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to extract environment variable and comment from this line
|
||||
$extracted = extractEnvVarCommentFromYamlLine($trimmedLine);
|
||||
if ($extracted !== null && $extracted['comment'] !== null) {
|
||||
$comments[$extracted['key']] = $extracted['comment'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $comments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract environment variable key and inline comment from a single YAML line.
|
||||
*
|
||||
* @param string $line A trimmed line from the environment section
|
||||
* @return array|null Array with 'key' and 'comment', or null if not an env var line
|
||||
*/
|
||||
function extractEnvVarCommentFromYamlLine(string $line): ?array
|
||||
{
|
||||
$key = null;
|
||||
$comment = null;
|
||||
|
||||
// Handle array format: `- KEY=value # comment` or `- KEY # comment`
|
||||
if (str_starts_with($line, '-')) {
|
||||
$content = ltrim(substr($line, 1));
|
||||
|
||||
// Check for KEY=value format
|
||||
if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)/', $content, $keyMatch)) {
|
||||
$key = $keyMatch[1];
|
||||
// Find comment - need to handle quoted values
|
||||
$comment = extractCommentAfterValue($content);
|
||||
}
|
||||
}
|
||||
// Handle map format: `KEY: "value" # comment` or `KEY: value # comment`
|
||||
elseif (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\s*:/', $line, $keyMatch)) {
|
||||
$key = $keyMatch[1];
|
||||
// Get everything after the key and colon
|
||||
$afterKey = substr($line, strlen($keyMatch[0]));
|
||||
$comment = extractCommentAfterValue($afterKey);
|
||||
}
|
||||
|
||||
if ($key === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'comment' => $comment,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract inline comment from a value portion of a YAML line.
|
||||
*
|
||||
* Handles quoted values (where # inside quotes is not a comment).
|
||||
*
|
||||
* @param string $valueAndComment The value portion (may include comment)
|
||||
* @return string|null The comment text, or null if no comment
|
||||
*/
|
||||
function extractCommentAfterValue(string $valueAndComment): ?string
|
||||
{
|
||||
$valueAndComment = ltrim($valueAndComment);
|
||||
|
||||
if ($valueAndComment === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$firstChar = $valueAndComment[0] ?? '';
|
||||
|
||||
// Handle case where value is empty and line starts directly with comment
|
||||
// e.g., `KEY: # comment` becomes `# comment` after ltrim
|
||||
if ($firstChar === '#') {
|
||||
$comment = trim(substr($valueAndComment, 1));
|
||||
|
||||
return $comment !== '' ? $comment : null;
|
||||
}
|
||||
|
||||
// Handle double-quoted value
|
||||
if ($firstChar === '"') {
|
||||
// Find closing quote (handle escaped quotes)
|
||||
$pos = 1;
|
||||
$len = strlen($valueAndComment);
|
||||
while ($pos < $len) {
|
||||
if ($valueAndComment[$pos] === '\\' && $pos + 1 < $len) {
|
||||
$pos += 2; // Skip escaped character
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($valueAndComment[$pos] === '"') {
|
||||
// Found closing quote
|
||||
$remainder = substr($valueAndComment, $pos + 1);
|
||||
|
||||
return extractCommentFromRemainder($remainder);
|
||||
}
|
||||
$pos++;
|
||||
}
|
||||
|
||||
// No closing quote found
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle single-quoted value
|
||||
if ($firstChar === "'") {
|
||||
// Find closing quote (single quotes don't have escapes in YAML)
|
||||
$closingPos = strpos($valueAndComment, "'", 1);
|
||||
if ($closingPos !== false) {
|
||||
$remainder = substr($valueAndComment, $closingPos + 1);
|
||||
|
||||
return extractCommentFromRemainder($remainder);
|
||||
}
|
||||
|
||||
// No closing quote found
|
||||
return null;
|
||||
}
|
||||
|
||||
// Unquoted value - find # that's preceded by whitespace
|
||||
// Be careful not to match # at the start of a value like color codes
|
||||
if (preg_match('/\s+#\s*(.*)$/', $valueAndComment, $matches)) {
|
||||
$comment = trim($matches[1]);
|
||||
|
||||
return $comment !== '' ? $comment : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract comment from the remainder of a line after a quoted value.
|
||||
*
|
||||
* @param string $remainder Text after the closing quote
|
||||
* @return string|null The comment text, or null if no comment
|
||||
*/
|
||||
function extractCommentFromRemainder(string $remainder): ?string
|
||||
{
|
||||
// Look for # in remainder
|
||||
$hashPos = strpos($remainder, '#');
|
||||
if ($hashPos !== false) {
|
||||
$comment = trim(substr($remainder, $hashPos + 1));
|
||||
|
||||
return $comment !== '' ? $comment : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function data_get_str($data, $key, $default = null): Stringable
|
||||
{
|
||||
$str = data_get($data, $key, $default) ?? $default;
|
||||
|
|
@ -1317,6 +1584,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
{
|
||||
if ($resource->getMorphClass() === \App\Models\Service::class) {
|
||||
if ($resource->docker_compose_raw) {
|
||||
// Extract inline comments from raw YAML before Symfony parser discards them
|
||||
$envComments = extractYamlEnvironmentComments($resource->docker_compose_raw);
|
||||
|
||||
try {
|
||||
$yaml = Yaml::parse($resource->docker_compose_raw);
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -1348,7 +1618,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
}
|
||||
$topLevelVolumes = collect($tempTopLevelVolumes);
|
||||
}
|
||||
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices) {
|
||||
$services = collect($services)->map(function ($service, $serviceName) use ($topLevelVolumes, $topLevelNetworks, $definedNetwork, $isNew, $generatedServiceFQDNS, $resource, $allServices, $envComments) {
|
||||
// Workarounds for beta users.
|
||||
if ($serviceName === 'registry') {
|
||||
$tempServiceName = 'docker-registry';
|
||||
|
|
@ -1694,6 +1964,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
$key = str($variableName);
|
||||
$value = str($variable);
|
||||
}
|
||||
// Preserve original key for comment lookup before $key might be reassigned
|
||||
$originalKey = $key->value();
|
||||
if ($key->startsWith('SERVICE_FQDN')) {
|
||||
if ($isNew || $savedService->fqdn === null) {
|
||||
$name = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower();
|
||||
|
|
@ -1747,6 +2019,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
}
|
||||
// Caddy needs exact port in some cases.
|
||||
|
|
@ -1826,6 +2099,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
}
|
||||
if (! $isDatabase) {
|
||||
|
|
@ -1864,6 +2138,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1902,6 +2177,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
|
|||
'resourceable_type' => get_class($resource),
|
||||
'resourceable_id' => $resource->id,
|
||||
'is_preview' => false,
|
||||
'comment' => $envComments[$originalKey] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -3447,6 +3723,58 @@ function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $compon
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract hard-coded environment variables from docker-compose YAML.
|
||||
*
|
||||
* @param string $dockerComposeRaw Raw YAML content
|
||||
* @return \Illuminate\Support\Collection Collection of arrays with: key, value, comment, service_name
|
||||
*/
|
||||
function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): \Illuminate\Support\Collection
|
||||
{
|
||||
if (blank($dockerComposeRaw)) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
try {
|
||||
$yaml = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
|
||||
} catch (\Exception $e) {
|
||||
// Malformed YAML - return empty collection
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
$services = data_get($yaml, 'services', []);
|
||||
if (empty($services)) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
// Extract inline comments from raw YAML
|
||||
$envComments = extractYamlEnvironmentComments($dockerComposeRaw);
|
||||
|
||||
$hardcodedVars = collect([]);
|
||||
|
||||
foreach ($services as $serviceName => $service) {
|
||||
$environment = collect(data_get($service, 'environment', []));
|
||||
|
||||
if ($environment->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert environment variables to key-value format
|
||||
$environment = convertToKeyValueCollection($environment);
|
||||
|
||||
foreach ($environment as $key => $value) {
|
||||
$hardcodedVars->push([
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'comment' => $envComments[$key] ?? null,
|
||||
'service_name' => $serviceName,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $hardcodedVars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downsample metrics using the Largest-Triangle-Three-Buckets (LTTB) algorithm.
|
||||
* This preserves the visual shape of the data better than simple averaging.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('environment_variables', function (Blueprint $table) {
|
||||
$table->string('comment', 256)->nullable();
|
||||
});
|
||||
|
||||
Schema::table('shared_environment_variables', function (Blueprint $table) {
|
||||
$table->string('comment', 256)->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('environment_variables', function (Blueprint $table) {
|
||||
$table->dropColumn('comment');
|
||||
});
|
||||
|
||||
Schema::table('shared_environment_variables', function (Blueprint $table) {
|
||||
$table->dropColumn('comment');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -11404,6 +11404,10 @@
|
|||
"real_value": {
|
||||
"type": "string"
|
||||
},
|
||||
"comment": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7241,6 +7241,9 @@ components:
|
|||
type: string
|
||||
real_value:
|
||||
type: string
|
||||
comment:
|
||||
type: string
|
||||
nullable: true
|
||||
version:
|
||||
type: string
|
||||
created_at:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@
|
|||
</div>
|
||||
@endif
|
||||
|
||||
<x-forms.input id="comment" label="Comment"
|
||||
helper="Add a note to document what this environment variable is used for." maxlength="256" />
|
||||
|
||||
@if (!$shared)
|
||||
<x-forms.checkbox id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
|
|
|
|||
|
|
@ -41,19 +41,6 @@
|
|||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if ($resource->type() === 'service' || $resource?->build_pack === 'dockercompose')
|
||||
<div class="flex items-center gap-1 pt-4 dark:text-warning text-coollabs">
|
||||
<svg class="hidden w-4 h-4 dark:text-warning lg:block" viewBox="0 0 256 256"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="currentColor"
|
||||
d="M240.26 186.1L152.81 34.23a28.74 28.74 0 0 0-49.62 0L15.74 186.1a27.45 27.45 0 0 0 0 27.71A28.31 28.31 0 0 0 40.55 228h174.9a28.31 28.31 0 0 0 24.79-14.19a27.45 27.45 0 0 0 .02-27.71m-20.8 15.7a4.46 4.46 0 0 1-4 2.2H40.55a4.46 4.46 0 0 1-4-2.2a3.56 3.56 0 0 1 0-3.73L124 46.2a4.77 4.77 0 0 1 8 0l87.44 151.87a3.56 3.56 0 0 1 .02 3.73M116 136v-32a12 12 0 0 1 24 0v32a12 12 0 0 1-24 0m28 40a16 16 0 1 1-16-16a16 16 0 0 1 16 16">
|
||||
</path>
|
||||
</svg>
|
||||
Hardcoded variables are not shown here.
|
||||
</div>
|
||||
{{-- <div class="pb-4 dark:text-warning text-coollabs">If you would like to add a variable, you must add it to
|
||||
your compose file.</div> --}}
|
||||
@endif
|
||||
</div>
|
||||
@if ($view === 'normal')
|
||||
<div>
|
||||
|
|
@ -61,31 +48,48 @@
|
|||
<div>Environment (secrets) variables for Production.</div>
|
||||
</div>
|
||||
@forelse ($this->environmentVariables as $env)
|
||||
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}"
|
||||
:env="$env" :type="$resource->type()" />
|
||||
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env"
|
||||
:type="$resource->type()" />
|
||||
@empty
|
||||
<div>No environment variables found.</div>
|
||||
@endforelse
|
||||
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariables->isNotEmpty())
|
||||
@foreach ($this->hardcodedEnvironmentVariables as $index => $env)
|
||||
<livewire:project.shared.environment-variable.show-hardcoded
|
||||
wire:key="hardcoded-prod-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}"
|
||||
:env="$env" />
|
||||
@endforeach
|
||||
@endif
|
||||
@if ($resource->type() === 'application' && $resource->environment_variables_preview->count() > 0 && $showPreview)
|
||||
<div>
|
||||
<h3>Preview Deployments Environment Variables</h3>
|
||||
<div>Environment (secrets) variables for Preview Deployments.</div>
|
||||
</div>
|
||||
@foreach ($this->environmentVariablesPreview as $env)
|
||||
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}"
|
||||
:env="$env" :type="$resource->type()" />
|
||||
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}" :env="$env"
|
||||
:type="$resource->type()" />
|
||||
@endforeach
|
||||
@if (($resource->type() === 'service' || $resource?->build_pack === 'dockercompose') && $this->hardcodedEnvironmentVariablesPreview->isNotEmpty())
|
||||
@foreach ($this->hardcodedEnvironmentVariablesPreview as $index => $env)
|
||||
<livewire:project.shared.environment-variable.show-hardcoded
|
||||
wire:key="hardcoded-preview-{{ $env['key'] }}-{{ $env['service_name'] ?? 'default' }}-{{ $index }}"
|
||||
:env="$env" />
|
||||
@endforeach
|
||||
@endif
|
||||
@endif
|
||||
@else
|
||||
<form wire:submit.prevent='submit' class="flex flex-col gap-2">
|
||||
@can('manageEnvironment', $resource)
|
||||
<x-callout type="info" title="Note" class="mb-2">
|
||||
Inline comments with space before # (e.g., <code class="font-mono">KEY=value #comment</code>) are stripped.
|
||||
</x-callout>
|
||||
|
||||
<x-forms.textarea rows="10" class="whitespace-pre-wrap" id="variables" wire:model="variables"
|
||||
label="Production Environment Variables"></x-forms.textarea>
|
||||
|
||||
@if ($showPreview)
|
||||
<x-forms.textarea rows="10" class="whitespace-pre-wrap"
|
||||
label="Preview Deployments Environment Variables" id="variablesPreview"
|
||||
wire:model="variablesPreview"></x-forms.textarea>
|
||||
<x-forms.textarea rows="10" class="whitespace-pre-wrap" label="Preview Deployments Environment Variables"
|
||||
id="variablesPreview" wire:model="variablesPreview"></x-forms.textarea>
|
||||
@endif
|
||||
|
||||
<x-forms.button type="submit" class="btn btn-primary">Save All Environment Variables</x-forms.button>
|
||||
|
|
@ -94,11 +98,10 @@
|
|||
label="Production Environment Variables" disabled></x-forms.textarea>
|
||||
|
||||
@if ($showPreview)
|
||||
<x-forms.textarea rows="10" class="whitespace-pre-wrap"
|
||||
label="Preview Deployments Environment Variables" id="variablesPreview"
|
||||
wire:model="variablesPreview" disabled></x-forms.textarea>
|
||||
<x-forms.textarea rows="10" class="whitespace-pre-wrap" label="Preview Deployments Environment Variables"
|
||||
id="variablesPreview" wire:model="variablesPreview" disabled></x-forms.textarea>
|
||||
@endif
|
||||
@endcan
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<div>
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 p-4 bg-white border lg:items-start dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-normal rounded dark:bg-coolgray-400/50 bg-neutral-200 dark:text-neutral-400 text-neutral-600">
|
||||
Hardcoded env
|
||||
</span>
|
||||
@if($serviceName)
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-normal rounded dark:bg-coolgray-400/50 bg-neutral-200 dark:text-neutral-400 text-neutral-600">
|
||||
Service: {{ $serviceName }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div class="flex flex-col w-full gap-2 lg:flex-row">
|
||||
<x-forms.input disabled id="key" />
|
||||
@if($value !== null && $value !== '')
|
||||
<x-forms.input disabled type="password" value="{{ $value }}" />
|
||||
@else
|
||||
<x-forms.input disabled value="(inherited from host)" />
|
||||
@endif
|
||||
</div>
|
||||
@if($comment)
|
||||
<x-forms.input disabled value="{{ $comment }}" label="Comment"
|
||||
helper="Documentation for this environment variable." />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -15,13 +15,21 @@
|
|||
</svg>
|
||||
@can('delete', $this->env)
|
||||
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']" confirmationText="{{ $env->key }}"
|
||||
submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
|
||||
confirmationText="{{ $env->key }}"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
|
||||
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" />
|
||||
@endcan
|
||||
</div>
|
||||
@can('update', $this->env)
|
||||
<div class="flex flex-col w-full gap-2 lg:flex-row lg:items-end">
|
||||
<div class="flex-1">
|
||||
<x-forms.input id="comment" label="Comment"
|
||||
helper="Add a note to document what this environment variable is used for." maxlength="256" />
|
||||
</div>
|
||||
<x-forms.button type="submit">Update</x-forms.button>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-3">
|
||||
<div class="flex flex-wrap w-full items-center gap-4">
|
||||
@if (!$is_redis_credential)
|
||||
|
|
@ -32,28 +40,40 @@
|
|||
<x-forms.checkbox instantSave id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($isSharedVariable)
|
||||
@if (!$isMagicVariable)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
@else
|
||||
@if ($is_shared)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if (!$env->is_nixpacks)
|
||||
<x-forms.checkbox instantSave id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
@endif
|
||||
<x-forms.checkbox instantSave id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
@if (!$env->is_nixpacks)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@if ($isSharedVariable)
|
||||
@if (!$isMagicVariable)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@endif
|
||||
@else
|
||||
@if (!$env->is_nixpacks)
|
||||
<x-forms.checkbox instantSave id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
@endif
|
||||
<x-forms.checkbox instantSave id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
@if (!$isMagicVariable)
|
||||
@if (!$env->is_nixpacks)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
|
|
@ -72,82 +92,95 @@
|
|||
<x-forms.checkbox disabled id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($isSharedVariable)
|
||||
@if (!$isMagicVariable)
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
@else
|
||||
@if ($is_shared)
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if (!$env->is_nixpacks)
|
||||
@if ($isSharedVariable)
|
||||
@if (!$isMagicVariable)
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@endif
|
||||
@else
|
||||
<x-forms.checkbox disabled id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
@endif
|
||||
<x-forms.checkbox disabled id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
<x-forms.checkbox disabled id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
@if (!$isMagicVariable)
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<x-forms.input disabled id="comment" label="Comment" helper="Documentation for this environment variable."
|
||||
maxlength="256" />
|
||||
</div>
|
||||
@endcan
|
||||
@else
|
||||
@can('update', $this->env)
|
||||
@if ($isDisabled)
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div class="flex flex-col w-full gap-2 lg:flex-row">
|
||||
<x-forms.input disabled id="key" />
|
||||
<x-forms.input disabled type="password" id="value" />
|
||||
@if ($is_shared)
|
||||
<x-forms.input disabled type="password" id="real_value" />
|
||||
@endif
|
||||
</div>
|
||||
@if (!$isMagicVariable)
|
||||
<x-forms.input disabled id="comment" label="Comment"
|
||||
helper="Add a note to document what this environment variable is used for." maxlength="256" />
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div class="flex flex-col w-full gap-2 lg:flex-row">
|
||||
@if ($is_multiline)
|
||||
<x-forms.input :required="$is_redis_credential" isMultiline="{{ $is_multiline }}" id="key" />
|
||||
<x-forms.textarea :required="$is_redis_credential" type="password" id="value" />
|
||||
@else
|
||||
<x-forms.input :disabled="$is_redis_credential" :required="$is_redis_credential" id="key" />
|
||||
<x-forms.input :required="$is_redis_credential" type="password" id="value" />
|
||||
@endif
|
||||
@if ($is_shared)
|
||||
<x-forms.input :disabled="$is_redis_credential" :required="$is_redis_credential" disabled
|
||||
type="password" id="real_value" />
|
||||
@endif
|
||||
</div>
|
||||
<x-forms.input instantSave id="comment" label="Comment"
|
||||
helper="Add a note to document what this environment variable is used for." maxlength="256" />
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div class="flex flex-col w-full gap-2 lg:flex-row">
|
||||
<x-forms.input disabled id="key" />
|
||||
<x-forms.env-var-input
|
||||
disabled
|
||||
type="password"
|
||||
id="value"
|
||||
:availableVars="$this->availableSharedVariables"
|
||||
:projectUuid="data_get($parameters, 'project_uuid')"
|
||||
:environmentUuid="data_get($parameters, 'environment_uuid')" />
|
||||
<x-forms.input disabled type="password" id="value" />
|
||||
@if ($is_shared)
|
||||
<x-forms.input disabled type="password" id="real_value" />
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col w-full gap-2 lg:flex-row">
|
||||
@if ($is_multiline)
|
||||
<x-forms.input :required="$is_redis_credential" isMultiline="{{ $is_multiline }}" id="key" />
|
||||
<x-forms.textarea :required="$is_redis_credential" type="password" id="value" />
|
||||
@else
|
||||
<x-forms.input :disabled="$is_redis_credential" :required="$is_redis_credential" id="key" />
|
||||
<x-forms.env-var-input
|
||||
:required="$is_redis_credential"
|
||||
type="password"
|
||||
id="value"
|
||||
:availableVars="$this->availableSharedVariables"
|
||||
:projectUuid="data_get($parameters, 'project_uuid')"
|
||||
:environmentUuid="data_get($parameters, 'environment_uuid')" />
|
||||
@endif
|
||||
@if ($is_shared)
|
||||
<x-forms.input :disabled="$is_redis_credential" :required="$is_redis_credential" disabled type="password" id="real_value" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="flex flex-col w-full gap-2 lg:flex-row">
|
||||
<x-forms.input disabled id="key" />
|
||||
<x-forms.env-var-input
|
||||
disabled
|
||||
type="password"
|
||||
id="value"
|
||||
:availableVars="$this->availableSharedVariables"
|
||||
:projectUuid="data_get($parameters, 'project_uuid')"
|
||||
:environmentUuid="data_get($parameters, 'environment_uuid')" />
|
||||
@if ($is_shared)
|
||||
<x-forms.input disabled type="password" id="real_value" />
|
||||
@if (!$isMagicVariable)
|
||||
<x-forms.input disabled id="comment" label="Comment"
|
||||
helper="Add a note to document what this environment variable is used for." maxlength="256" />
|
||||
@endif
|
||||
</div>
|
||||
@endcan
|
||||
|
|
@ -162,28 +195,40 @@
|
|||
<x-forms.checkbox instantSave id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($isSharedVariable)
|
||||
@if (!$isMagicVariable)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
@else
|
||||
@if ($is_shared)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if (!$env->is_nixpacks)
|
||||
<x-forms.checkbox instantSave id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
@endif
|
||||
<x-forms.checkbox instantSave id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
@if (!$env->is_nixpacks)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@if ($isSharedVariable)
|
||||
@if (!$isMagicVariable)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@endif
|
||||
@else
|
||||
@if (!$env->is_nixpacks)
|
||||
<x-forms.checkbox instantSave id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
@endif
|
||||
<x-forms.checkbox instantSave id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
@if (!$isMagicVariable)
|
||||
@if (!$env->is_nixpacks)
|
||||
<x-forms.checkbox instantSave id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox instantSave id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
|
|
@ -191,12 +236,13 @@
|
|||
@endif
|
||||
</div>
|
||||
<x-environment-variable-warning :problematic-variables="$problematicVariables" />
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
@if ($isDisabled)
|
||||
@if (!$isMagicVariable)
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
@if ($isDisabled)
|
||||
<x-forms.button disabled type="submit">Update</x-forms.button>
|
||||
<x-forms.button wire:click='lock'>Lock</x-forms.button>
|
||||
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
|
||||
buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
|
||||
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
|
||||
confirmationText="{{ $key }}" buttonFullWidth="true"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
|
||||
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
|
||||
|
|
@ -204,14 +250,15 @@
|
|||
@else
|
||||
<x-forms.button type="submit">Update</x-forms.button>
|
||||
<x-forms.button wire:click='lock'>Lock</x-forms.button>
|
||||
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton
|
||||
buttonTitle="Delete" submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
|
||||
<x-modal-confirmation title="Confirm Environment Variable Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete" :actions="['The selected environment variable will be permanently deleted.']"
|
||||
confirmationText="{{ $key }}" buttonFullWidth="true"
|
||||
confirmationLabel="Please confirm the execution of the actions by entering the Environment Variable Name below"
|
||||
shortConfirmationLabel="Environment Variable Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-col w-full gap-3">
|
||||
|
|
@ -224,27 +271,37 @@
|
|||
<x-forms.checkbox disabled id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if ($isSharedVariable)
|
||||
@if (!$isMagicVariable)
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
@else
|
||||
@if ($is_shared)
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@else
|
||||
@if (!$env->is_nixpacks)
|
||||
@if ($isSharedVariable)
|
||||
@if (!$isMagicVariable)
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@endif
|
||||
@else
|
||||
<x-forms.checkbox disabled id="is_buildtime"
|
||||
helper="Make this variable available during Docker build process. Useful for build secrets and dependencies."
|
||||
label="Available at Buildtime" />
|
||||
@endif
|
||||
<x-forms.checkbox disabled id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
<x-forms.checkbox disabled id="is_runtime"
|
||||
helper="Make this variable available in the running container at runtime."
|
||||
label="Available at Runtime" />
|
||||
@if (!$isMagicVariable)
|
||||
<x-forms.checkbox disabled id="is_multiline" label="Is Multiline?" />
|
||||
@if ($is_multiline === false)
|
||||
<x-forms.checkbox disabled id="is_literal"
|
||||
helper="This means that when you use $VARIABLES in a value, it should be interpreted as the actual characters '$VARIABLES' and not as the value of a variable named VARIABLE.<br><br>Useful if you have $ sign in your value and there are some characters after it, but you would not like to interpolate it from another value. In this case, you should set this to true."
|
||||
label="Is Literal?" />
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
|
|
@ -255,4 +312,4 @@
|
|||
@endif
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
283
tests/Feature/EnvironmentVariableCommentTest.php
Normal file
283
tests/Feature/EnvironmentVariableCommentTest.php
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
||||
$this->application = Application::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('environment variable can be created with comment', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => 'This is a test environment variable',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBe('This is a test environment variable');
|
||||
expect($env->key)->toBe('TEST_VAR');
|
||||
expect($env->value)->toBe('test_value');
|
||||
});
|
||||
|
||||
test('environment variable comment is optional', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBeNull();
|
||||
expect($env->key)->toBe('TEST_VAR');
|
||||
});
|
||||
|
||||
test('environment variable comment can be updated', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => 'Initial comment',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$env->comment = 'Updated comment';
|
||||
$env->save();
|
||||
|
||||
$env->refresh();
|
||||
expect($env->comment)->toBe('Updated comment');
|
||||
});
|
||||
|
||||
test('environment variable comment is preserved when updating value', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'initial_value',
|
||||
'comment' => 'Important variable for testing',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$env->value = 'new_value';
|
||||
$env->save();
|
||||
|
||||
$env->refresh();
|
||||
expect($env->value)->toBe('new_value');
|
||||
expect($env->comment)->toBe('Important variable for testing');
|
||||
});
|
||||
|
||||
test('environment variable comment is copied to preview environment', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => 'Test comment',
|
||||
'is_preview' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// The model's created() event listener automatically creates a preview version
|
||||
$previewEnv = EnvironmentVariable::where('key', 'TEST_VAR')
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->where('is_preview', true)
|
||||
->first();
|
||||
|
||||
expect($previewEnv)->not->toBeNull();
|
||||
expect($previewEnv->comment)->toBe('Test comment');
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray preserves values without inline comments', function () {
|
||||
$input = "KEY1=value1\nKEY2=value2";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('developer view format does not break with comment-like values', function () {
|
||||
// Values that contain # but shouldn't be treated as comments when quoted
|
||||
$env1 = EnvironmentVariable::create([
|
||||
'key' => 'HASH_VAR',
|
||||
'value' => 'value_with_#_in_it',
|
||||
'comment' => 'Contains hash symbol',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env1->value)->toBe('value_with_#_in_it');
|
||||
expect($env1->comment)->toBe('Contains hash symbol');
|
||||
});
|
||||
|
||||
test('environment variable comment can store up to 256 characters', function () {
|
||||
$comment = str_repeat('a', 256);
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => $comment,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBe($comment);
|
||||
expect(strlen($env->comment))->toBe(256);
|
||||
});
|
||||
|
||||
test('environment variable comment cannot exceed 256 characters via Livewire', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$longComment = str_repeat('a', 257);
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\Show::class, ['env' => $env, 'type' => 'application'])
|
||||
->set('comment', $longComment)
|
||||
->call('submit')
|
||||
->assertHasErrors(['comment' => 'max']);
|
||||
});
|
||||
|
||||
test('bulk update preserves existing comments when no inline comment provided', function () {
|
||||
// Create existing variable with a manually-entered comment
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'DATABASE_URL',
|
||||
'value' => 'postgres://old-host',
|
||||
'comment' => 'Production database',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// User switches to Developer view and pastes new value without inline comment
|
||||
$bulkContent = "DATABASE_URL=postgres://new-host\nOTHER_VAR=value";
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
->set('variables', $bulkContent)
|
||||
->call('submit');
|
||||
|
||||
// Refresh the environment variable
|
||||
$env->refresh();
|
||||
|
||||
// The value should be updated
|
||||
expect($env->value)->toBe('postgres://new-host');
|
||||
|
||||
// The manually-entered comment should be PRESERVED
|
||||
expect($env->comment)->toBe('Production database');
|
||||
});
|
||||
|
||||
test('bulk update overwrites existing comments when inline comment provided', function () {
|
||||
// Create existing variable with a comment
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'API_KEY',
|
||||
'value' => 'old-key',
|
||||
'comment' => 'Old comment',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// User pastes new value WITH inline comment
|
||||
$bulkContent = 'API_KEY=new-key #Updated production key';
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
->set('variables', $bulkContent)
|
||||
->call('submit');
|
||||
|
||||
// Refresh the environment variable
|
||||
$env->refresh();
|
||||
|
||||
// The value should be updated
|
||||
expect($env->value)->toBe('new-key');
|
||||
|
||||
// The comment should be OVERWRITTEN with the inline comment
|
||||
expect($env->comment)->toBe('Updated production key');
|
||||
});
|
||||
|
||||
test('bulk update handles mixed inline and stored comments correctly', function () {
|
||||
// Create two variables with comments
|
||||
$env1 = EnvironmentVariable::create([
|
||||
'key' => 'VAR_WITH_COMMENT',
|
||||
'value' => 'value1',
|
||||
'comment' => 'Existing comment 1',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$env2 = EnvironmentVariable::create([
|
||||
'key' => 'VAR_WITHOUT_COMMENT',
|
||||
'value' => 'value2',
|
||||
'comment' => 'Existing comment 2',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Bulk paste: one with inline comment, one without
|
||||
$bulkContent = "VAR_WITH_COMMENT=new_value1 #New inline comment\nVAR_WITHOUT_COMMENT=new_value2";
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
->set('variables', $bulkContent)
|
||||
->call('submit');
|
||||
|
||||
// Refresh both variables
|
||||
$env1->refresh();
|
||||
$env2->refresh();
|
||||
|
||||
// First variable: comment should be overwritten with inline comment
|
||||
expect($env1->value)->toBe('new_value1');
|
||||
expect($env1->comment)->toBe('New inline comment');
|
||||
|
||||
// Second variable: comment should be preserved
|
||||
expect($env2->value)->toBe('new_value2');
|
||||
expect($env2->comment)->toBe('Existing comment 2');
|
||||
});
|
||||
|
||||
test('bulk update creates new variables with inline comments', function () {
|
||||
// Bulk paste creates new variables, some with inline comments
|
||||
$bulkContent = "NEW_VAR1=value1 #Comment for var1\nNEW_VAR2=value2\nNEW_VAR3=value3 #Comment for var3";
|
||||
|
||||
Livewire::test(\App\Livewire\Project\Shared\EnvironmentVariable\All::class, [
|
||||
'resource' => $this->application,
|
||||
'type' => 'application',
|
||||
])
|
||||
->set('variables', $bulkContent)
|
||||
->call('submit');
|
||||
|
||||
// Check that variables were created with correct comments
|
||||
$var1 = EnvironmentVariable::where('key', 'NEW_VAR1')
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
$var2 = EnvironmentVariable::where('key', 'NEW_VAR2')
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
$var3 = EnvironmentVariable::where('key', 'NEW_VAR3')
|
||||
->where('resourceable_id', $this->application->id)
|
||||
->first();
|
||||
|
||||
expect($var1->value)->toBe('value1');
|
||||
expect($var1->comment)->toBe('Comment for var1');
|
||||
|
||||
expect($var2->value)->toBe('value2');
|
||||
expect($var2->comment)->toBeNull();
|
||||
|
||||
expect($var3->value)->toBe('value3');
|
||||
expect($var3->comment)->toBe('Comment for var3');
|
||||
});
|
||||
217
tests/Feature/EnvironmentVariableMassAssignmentTest.php
Normal file
217
tests/Feature/EnvironmentVariableMassAssignmentTest.php
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
||||
$this->application = Application::factory()->create();
|
||||
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('all fillable fields can be mass assigned', function () {
|
||||
$data = [
|
||||
'key' => 'TEST_KEY',
|
||||
'value' => 'test_value',
|
||||
'comment' => 'Test comment',
|
||||
'is_literal' => true,
|
||||
'is_multiline' => true,
|
||||
'is_preview' => false,
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => false,
|
||||
'is_shown_once' => false,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
];
|
||||
|
||||
$env = EnvironmentVariable::create($data);
|
||||
|
||||
expect($env->key)->toBe('TEST_KEY');
|
||||
expect($env->value)->toBe('test_value');
|
||||
expect($env->comment)->toBe('Test comment');
|
||||
expect($env->is_literal)->toBeTrue();
|
||||
expect($env->is_multiline)->toBeTrue();
|
||||
expect($env->is_preview)->toBeFalse();
|
||||
expect($env->is_runtime)->toBeTrue();
|
||||
expect($env->is_buildtime)->toBeFalse();
|
||||
expect($env->is_shown_once)->toBeFalse();
|
||||
expect($env->resourceable_type)->toBe(Application::class);
|
||||
expect($env->resourceable_id)->toBe($this->application->id);
|
||||
});
|
||||
|
||||
test('comment field can be mass assigned with null', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => null,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBeNull();
|
||||
});
|
||||
|
||||
test('comment field can be mass assigned with empty string', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => '',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBe('');
|
||||
});
|
||||
|
||||
test('comment field can be mass assigned with long text', function () {
|
||||
$comment = str_repeat('This is a long comment. ', 10);
|
||||
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'comment' => $comment,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->comment)->toBe($comment);
|
||||
expect(strlen($env->comment))->toBe(strlen($comment));
|
||||
});
|
||||
|
||||
test('all boolean fields default correctly when not provided', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Boolean fields can be null or false depending on database defaults
|
||||
expect($env->is_multiline)->toBeIn([false, null]);
|
||||
expect($env->is_preview)->toBeIn([false, null]);
|
||||
expect($env->is_runtime)->toBeIn([false, null]);
|
||||
expect($env->is_buildtime)->toBeIn([false, null]);
|
||||
expect($env->is_shown_once)->toBeIn([false, null]);
|
||||
});
|
||||
|
||||
test('value field is properly encrypted when mass assigned', function () {
|
||||
$plainValue = 'secret_value_123';
|
||||
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'SECRET_KEY',
|
||||
'value' => $plainValue,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Value should be decrypted when accessed via model
|
||||
expect($env->value)->toBe($plainValue);
|
||||
|
||||
// Verify it's actually encrypted in the database
|
||||
$rawValue = \DB::table('environment_variables')
|
||||
->where('id', $env->id)
|
||||
->value('value');
|
||||
|
||||
expect($rawValue)->not->toBe($plainValue);
|
||||
expect($rawValue)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('key field is trimmed and spaces replaced with underscores', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => ' TEST KEY WITH SPACES ',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->key)->toBe('TEST_KEY_WITH_SPACES');
|
||||
});
|
||||
|
||||
test('version field can be mass assigned', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'version' => '1.2.3',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// The booted() method sets version automatically, so it will be the current version
|
||||
expect($env->version)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('mass assignment works with update method', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'initial_value',
|
||||
'comment' => 'Initial comment',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
$env->update([
|
||||
'value' => 'updated_value',
|
||||
'comment' => 'Updated comment',
|
||||
'is_literal' => true,
|
||||
]);
|
||||
|
||||
$env->refresh();
|
||||
|
||||
expect($env->value)->toBe('updated_value');
|
||||
expect($env->comment)->toBe('Updated comment');
|
||||
expect($env->is_literal)->toBeTrue();
|
||||
});
|
||||
|
||||
test('protected attributes cannot be mass assigned', function () {
|
||||
$customDate = '2020-01-01 00:00:00';
|
||||
|
||||
$env = EnvironmentVariable::create([
|
||||
'id' => 999999,
|
||||
'uuid' => 'custom-uuid',
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
'created_at' => $customDate,
|
||||
'updated_at' => $customDate,
|
||||
]);
|
||||
|
||||
// id should be auto-generated, not 999999
|
||||
expect($env->id)->not->toBe(999999);
|
||||
|
||||
// uuid should be auto-generated, not 'custom-uuid'
|
||||
expect($env->uuid)->not->toBe('custom-uuid');
|
||||
|
||||
// Timestamps should be current, not 2020
|
||||
expect($env->created_at->year)->toBe(now()->year);
|
||||
});
|
||||
|
||||
test('order field can be mass assigned', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'order' => 5,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
expect($env->order)->toBe(5);
|
||||
});
|
||||
|
||||
test('is_shared field can be mass assigned', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test_value',
|
||||
'is_shared' => true,
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $this->application->id,
|
||||
]);
|
||||
|
||||
// Note: is_shared is also computed via accessor, but can be mass assigned
|
||||
expect($env->is_shared)->not->toBeNull();
|
||||
});
|
||||
72
tests/Unit/EnvironmentVariableFillableTest.php
Normal file
72
tests/Unit/EnvironmentVariableFillableTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
|
||||
test('fillable array contains all fields used in mass assignment across codebase', function () {
|
||||
$model = new EnvironmentVariable;
|
||||
$fillable = $model->getFillable();
|
||||
|
||||
// Core identification
|
||||
expect($fillable)->toContain('key')
|
||||
->toContain('value')
|
||||
->toContain('comment');
|
||||
|
||||
// Polymorphic relationship
|
||||
expect($fillable)->toContain('resourceable_type')
|
||||
->toContain('resourceable_id');
|
||||
|
||||
// Boolean flags — all used in create/firstOrCreate/updateOrCreate calls
|
||||
expect($fillable)->toContain('is_preview')
|
||||
->toContain('is_multiline')
|
||||
->toContain('is_literal')
|
||||
->toContain('is_runtime')
|
||||
->toContain('is_buildtime')
|
||||
->toContain('is_shown_once')
|
||||
->toContain('is_shared')
|
||||
->toContain('is_required');
|
||||
|
||||
// Metadata
|
||||
expect($fillable)->toContain('version')
|
||||
->toContain('order');
|
||||
});
|
||||
|
||||
test('is_required can be mass assigned', function () {
|
||||
$model = new EnvironmentVariable;
|
||||
$model->fill(['is_required' => true]);
|
||||
|
||||
expect($model->is_required)->toBeTrue();
|
||||
});
|
||||
|
||||
test('all boolean flags can be mass assigned', function () {
|
||||
$booleanFlags = [
|
||||
'is_preview',
|
||||
'is_multiline',
|
||||
'is_literal',
|
||||
'is_runtime',
|
||||
'is_buildtime',
|
||||
'is_shown_once',
|
||||
'is_required',
|
||||
];
|
||||
|
||||
$model = new EnvironmentVariable;
|
||||
$model->fill(array_fill_keys($booleanFlags, true));
|
||||
|
||||
foreach ($booleanFlags as $flag) {
|
||||
expect($model->$flag)->toBeTrue("Expected {$flag} to be mass assignable and set to true");
|
||||
}
|
||||
|
||||
// is_shared has a computed getter derived from the value field,
|
||||
// so verify it's fillable via the underlying attributes instead
|
||||
$model2 = new EnvironmentVariable;
|
||||
$model2->fill(['is_shared' => true]);
|
||||
expect($model2->getAttributes())->toHaveKey('is_shared');
|
||||
});
|
||||
|
||||
test('non-fillable fields are rejected by mass assignment', function () {
|
||||
$model = new EnvironmentVariable;
|
||||
$model->fill(['id' => 999, 'uuid' => 'injected', 'created_at' => 'injected']);
|
||||
|
||||
expect($model->id)->toBeNull()
|
||||
->and($model->uuid)->toBeNull()
|
||||
->and($model->created_at)->toBeNull();
|
||||
});
|
||||
141
tests/Unit/EnvironmentVariableMagicVariableTest.php
Normal file
141
tests/Unit/EnvironmentVariableMagicVariableTest.php
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
|
||||
use App\Models\EnvironmentVariable;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
test('SERVICE_FQDN variables are identified as magic variables', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_FQDN_DB');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('SERVICE_URL variables are identified as magic variables', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_URL_API');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('SERVICE_NAME variables are identified as magic variables', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_NAME');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('regular variables are not magic variables', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('DATABASE_URL');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeFalse();
|
||||
expect($component->isDisabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('locked variables are not magic variables unless they start with SERVICE_', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SECRET_KEY');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(true);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeFalse();
|
||||
expect($component->isLocked)->toBeTrue();
|
||||
});
|
||||
|
||||
test('SERVICE_FQDN with port suffix is identified as magic variable', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_FQDN_DB_5432');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('SERVICE_URL with port suffix is identified as magic variable', function () {
|
||||
$mock = Mockery::mock(EnvironmentVariable::class);
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('key')
|
||||
->andReturn('SERVICE_URL_API_8080');
|
||||
$mock->shouldReceive('getAttribute')
|
||||
->with('is_shown_once')
|
||||
->andReturn(false);
|
||||
$mock->shouldReceive('getMorphClass')
|
||||
->andReturn(EnvironmentVariable::class);
|
||||
|
||||
$component = new Show;
|
||||
$component->env = $mock;
|
||||
$component->checkEnvs();
|
||||
|
||||
expect($component->isMagicVariable)->toBeTrue();
|
||||
expect($component->isDisabled)->toBeTrue();
|
||||
});
|
||||
351
tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php
Normal file
351
tests/Unit/EnvironmentVariableParsingEdgeCasesTest.php
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
<?php
|
||||
|
||||
use function PHPUnit\Framework\assertNotNull;
|
||||
use function PHPUnit\Framework\assertNull;
|
||||
|
||||
// ─── Malformed Variables ───────────────────────────────────────────────────────
|
||||
|
||||
test('extractBalancedBraceContent handles empty variable name', function () {
|
||||
$result = extractBalancedBraceContent('${}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles empty variable name with default', function () {
|
||||
$split = splitOnOperatorOutsideNested(':-default');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('default');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles double opening brace', function () {
|
||||
$result = extractBalancedBraceContent('${{VAR}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('{VAR}');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for empty string', function () {
|
||||
$result = extractBalancedBraceContent('', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for just dollar sign', function () {
|
||||
$result = extractBalancedBraceContent('$', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for just opening brace', function () {
|
||||
$result = extractBalancedBraceContent('{', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for just closing brace', function () {
|
||||
$result = extractBalancedBraceContent('}', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles extra closing brace', function () {
|
||||
$result = extractBalancedBraceContent('${VAR}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for unclosed with no content', function () {
|
||||
$result = extractBalancedBraceContent('${', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for deeply unclosed nested braces', function () {
|
||||
$result = extractBalancedBraceContent('${A:-${B:-${C}', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('replaceVariables handles empty braces gracefully', function () {
|
||||
$result = replaceVariables('${}');
|
||||
|
||||
expect($result->value())->toBe('');
|
||||
});
|
||||
|
||||
test('replaceVariables handles double braces gracefully', function () {
|
||||
$result = replaceVariables('${{VAR}}');
|
||||
|
||||
expect($result->value())->toBe('{VAR}');
|
||||
});
|
||||
|
||||
// ─── Edge Cases with Braces and Special Characters ─────────────────────────────
|
||||
|
||||
test('extractBalancedBraceContent finds consecutive variables', function () {
|
||||
$str = '${A}${B}';
|
||||
|
||||
$first = extractBalancedBraceContent($str, 0);
|
||||
assertNotNull($first);
|
||||
expect($first['content'])->toBe('A');
|
||||
|
||||
$second = extractBalancedBraceContent($str, $first['end'] + 1);
|
||||
assertNotNull($second);
|
||||
expect($second['content'])->toBe('B');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles URL with port in default', function () {
|
||||
$split = splitOnOperatorOutsideNested('URL:-http://host:8080/path');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('http://host:8080/path');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles equals sign in default', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:-key=value&foo=bar');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('key=value&foo=bar');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles dashes in default value', function () {
|
||||
$split = splitOnOperatorOutsideNested('A:-value-with-dashes');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('value-with-dashes');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles question mark in default value', function () {
|
||||
$split = splitOnOperatorOutsideNested('A:-what?');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('what?');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles variable with digits', function () {
|
||||
$result = extractBalancedBraceContent('${VAR123}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR123');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles long variable name', function () {
|
||||
$longName = str_repeat('A', 200);
|
||||
$result = extractBalancedBraceContent('${'.$longName.'}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe($longName);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested returns null for empty string', function () {
|
||||
$split = splitOnOperatorOutsideNested('');
|
||||
|
||||
assertNull($split);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles variable name with underscores', function () {
|
||||
$split = splitOnOperatorOutsideNested('_MY_VAR_:-default');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('_MY_VAR_')
|
||||
->and($split['default'])->toBe('default');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent with startPos beyond string length', function () {
|
||||
$result = extractBalancedBraceContent('${VAR}', 100);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles brace in middle of text', function () {
|
||||
$result = extractBalancedBraceContent('prefix ${VAR} suffix', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR');
|
||||
});
|
||||
|
||||
// ─── Deeply Nested Defaults ────────────────────────────────────────────────────
|
||||
|
||||
test('extractBalancedBraceContent handles four levels of nesting', function () {
|
||||
$input = '${A:-${B:-${C:-${D}}}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('A:-${B:-${C:-${D}}}');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles four levels of nesting', function () {
|
||||
$content = 'A:-${B:-${C:-${D}}}';
|
||||
$split = splitOnOperatorOutsideNested($content);
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:-${C:-${D}}}');
|
||||
|
||||
// Verify second level
|
||||
$nested = extractBalancedBraceContent($split['default'], 0);
|
||||
assertNotNull($nested);
|
||||
$split2 = splitOnOperatorOutsideNested($nested['content']);
|
||||
assertNotNull($split2);
|
||||
expect($split2['variable'])->toBe('B')
|
||||
->and($split2['default'])->toBe('${C:-${D}}');
|
||||
});
|
||||
|
||||
test('multiple variables at same depth in default', function () {
|
||||
$input = '${A:-${B}/${C}/${D}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
assertNotNull($result);
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
assertNotNull($split);
|
||||
expect($split['default'])->toBe('${B}/${C}/${D}');
|
||||
|
||||
// Verify all three nested variables can be found
|
||||
$default = $split['default'];
|
||||
$vars = [];
|
||||
$pos = 0;
|
||||
while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
|
||||
$vars[] = $nested['content'];
|
||||
$pos = $nested['end'] + 1;
|
||||
}
|
||||
|
||||
expect($vars)->toBe(['B', 'C', 'D']);
|
||||
});
|
||||
|
||||
test('nested with mixed operators', function () {
|
||||
$input = '${A:-${B:?required}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:?required}');
|
||||
|
||||
// Inner variable uses :? operator
|
||||
$nested = extractBalancedBraceContent($split['default'], 0);
|
||||
$innerSplit = splitOnOperatorOutsideNested($nested['content']);
|
||||
|
||||
expect($innerSplit['variable'])->toBe('B')
|
||||
->and($innerSplit['operator'])->toBe(':?')
|
||||
->and($innerSplit['default'])->toBe('required');
|
||||
});
|
||||
|
||||
test('nested variable without default as default', function () {
|
||||
$input = '${A:-${B}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${B}');
|
||||
|
||||
$nested = extractBalancedBraceContent($split['default'], 0);
|
||||
$innerSplit = splitOnOperatorOutsideNested($nested['content']);
|
||||
|
||||
assertNull($innerSplit);
|
||||
expect($nested['content'])->toBe('B');
|
||||
});
|
||||
|
||||
// ─── Backwards Compatibility ───────────────────────────────────────────────────
|
||||
|
||||
test('replaceVariables with brace format without dollar sign', function () {
|
||||
$result = replaceVariables('{MY_VAR}');
|
||||
|
||||
expect($result->value())->toBe('MY_VAR');
|
||||
});
|
||||
|
||||
test('replaceVariables with truncated brace format', function () {
|
||||
$result = replaceVariables('{MY_VAR');
|
||||
|
||||
expect($result->value())->toBe('MY_VAR');
|
||||
});
|
||||
|
||||
test('replaceVariables with plain string returns unchanged', function () {
|
||||
$result = replaceVariables('plain_value');
|
||||
|
||||
expect($result->value())->toBe('plain_value');
|
||||
});
|
||||
|
||||
test('replaceVariables preserves full content for variable with default', function () {
|
||||
$result = replaceVariables('${DB_HOST:-localhost}');
|
||||
|
||||
expect($result->value())->toBe('DB_HOST:-localhost');
|
||||
});
|
||||
|
||||
test('replaceVariables preserves nested content for variable with nested default', function () {
|
||||
$result = replaceVariables('${DB_URL:-${SERVICE_URL_PG}/db}');
|
||||
|
||||
expect($result->value())->toBe('DB_URL:-${SERVICE_URL_PG}/db');
|
||||
});
|
||||
|
||||
test('replaceVariables with brace format containing default falls back gracefully', function () {
|
||||
$result = replaceVariables('{VAR:-default}');
|
||||
|
||||
expect($result->value())->toBe('VAR:-default');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested colon-dash takes precedence over bare dash', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:-val-ue');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['operator'])->toBe(':-')
|
||||
->and($split['variable'])->toBe('VAR')
|
||||
->and($split['default'])->toBe('val-ue');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested colon-question takes precedence over bare question', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:?error?');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['operator'])->toBe(':?')
|
||||
->and($split['variable'])->toBe('VAR')
|
||||
->and($split['default'])->toBe('error?');
|
||||
});
|
||||
|
||||
test('full round trip: extract, split, and resolve nested variables', function () {
|
||||
$input = '${APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health}';
|
||||
|
||||
// Step 1: Extract outer content
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('APP_URL:-${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
|
||||
|
||||
// Step 2: Split on outer operator
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('APP_URL')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_APP}/v${API_VERSION:-2}/health');
|
||||
|
||||
// Step 3: Find all nested variables in default
|
||||
$default = $split['default'];
|
||||
$nestedVars = [];
|
||||
$pos = 0;
|
||||
while (($nested = extractBalancedBraceContent($default, $pos)) !== null) {
|
||||
$innerSplit = splitOnOperatorOutsideNested($nested['content']);
|
||||
$nestedVars[] = [
|
||||
'name' => $innerSplit !== null ? $innerSplit['variable'] : $nested['content'],
|
||||
'default' => $innerSplit !== null ? $innerSplit['default'] : null,
|
||||
];
|
||||
$pos = $nested['end'] + 1;
|
||||
}
|
||||
|
||||
expect($nestedVars)->toHaveCount(2)
|
||||
->and($nestedVars[0]['name'])->toBe('SERVICE_URL_APP')
|
||||
->and($nestedVars[0]['default'])->toBeNull()
|
||||
->and($nestedVars[1]['name'])->toBe('API_VERSION')
|
||||
->and($nestedVars[1]['default'])->toBe('2');
|
||||
});
|
||||
147
tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
Normal file
147
tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<?php
|
||||
|
||||
test('extracts simple environment variables from docker-compose', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('NODE_ENV')
|
||||
->and($result[0]['value'])->toBe('production')
|
||||
->and($result[0]['service_name'])->toBe('app')
|
||||
->and($result[1]['key'])->toBe('PORT')
|
||||
->and($result[1]['value'])->toBe('3000')
|
||||
->and($result[1]['service_name'])->toBe('app');
|
||||
});
|
||||
|
||||
test('extracts environment variables with inline comments', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- NODE_ENV=production # Production environment
|
||||
- DEBUG=false # Disable debug mode
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['comment'])->toBe('Production environment')
|
||||
->and($result[1]['comment'])->toBe('Disable debug mode');
|
||||
});
|
||||
|
||||
test('handles multiple services', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- APP_ENV=prod
|
||||
db:
|
||||
environment:
|
||||
- POSTGRES_DB=mydb
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('APP_ENV')
|
||||
->and($result[0]['service_name'])->toBe('app')
|
||||
->and($result[1]['key'])->toBe('POSTGRES_DB')
|
||||
->and($result[1]['service_name'])->toBe('db');
|
||||
});
|
||||
|
||||
test('handles associative array format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('NODE_ENV')
|
||||
->and($result[0]['value'])->toBe('production')
|
||||
->and($result[1]['key'])->toBe('PORT')
|
||||
->and($result[1]['value'])->toBe(3000); // Integer values stay as integers from YAML
|
||||
});
|
||||
|
||||
test('handles environment variables without values', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- API_KEY
|
||||
- DEBUG=false
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result[0]['key'])->toBe('API_KEY')
|
||||
->and($result[0]['value'])->toBe('') // Variables without values get empty string, not null
|
||||
->and($result[1]['key'])->toBe('DEBUG')
|
||||
->and($result[1]['value'])->toBe('false');
|
||||
});
|
||||
|
||||
test('returns empty collection for malformed YAML', function () {
|
||||
$yaml = 'invalid: yaml: content::: [[[';
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns empty collection for empty compose file', function () {
|
||||
$result = extractHardcodedEnvironmentVariables('');
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns empty collection when no services defined', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: '3.8'
|
||||
networks:
|
||||
default:
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('returns empty collection when service has no environment section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
image: nginx
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('handles mixed associative and array format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
PORT: 3000
|
||||
YAML;
|
||||
|
||||
$result = extractHardcodedEnvironmentVariables($yaml);
|
||||
|
||||
// Mixed format is invalid YAML and returns empty collection
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
334
tests/Unit/ExtractYamlEnvironmentCommentsTest.php
Normal file
334
tests/Unit/ExtractYamlEnvironmentCommentsTest.php
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
<?php
|
||||
|
||||
test('extractYamlEnvironmentComments returns empty array for YAML without environment section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments extracts inline comments from map format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
FOO: bar # This is a comment
|
||||
BAZ: qux
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'This is a comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments extracts inline comments from array format', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
- FOO=bar # This is a comment
|
||||
- BAZ=qux
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'This is a comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles quoted values containing hash symbols', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
COLOR: "#FF0000" # hex color code
|
||||
DB_URL: "postgres://user:pass#123@localhost" # database URL
|
||||
PLAIN: value # no quotes
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'COLOR' => 'hex color code',
|
||||
'DB_URL' => 'database URL',
|
||||
'PLAIN' => 'no quotes',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles single quoted values containing hash symbols', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
PASSWORD: 'secret#123' # my password
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'PASSWORD' => 'my password',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments skips full-line comments', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
# This is a full line comment
|
||||
FOO: bar # This is an inline comment
|
||||
# Another full line comment
|
||||
BAZ: qux
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'This is an inline comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles multiple services', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
WEB_PORT: 8080 # web server port
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: admin # database admin user
|
||||
POSTGRES_PASSWORD: secret
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'WEB_PORT' => 'web server port',
|
||||
'POSTGRES_USER' => 'database admin user',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles variables without values', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
- DEBUG # enable debug mode
|
||||
- VERBOSE
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'DEBUG' => 'enable debug mode',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles array format with colons', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
- DATABASE_URL: postgres://localhost # connection string
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'DATABASE_URL' => 'connection string',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments does not treat hash inside unquoted values as comment start', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
API_KEY: abc#def
|
||||
OTHER: xyz # this is a comment
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
// abc#def has no space before #, so it's not treated as a comment
|
||||
expect($result)->toBe([
|
||||
'OTHER' => 'this is a comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles empty environment section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
ports:
|
||||
- "80:80"
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles environment inline format (not supported)', function () {
|
||||
// Inline format like environment: { FOO: bar } is not supported for comment extraction
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment: { FOO: bar }
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
// No comments extracted from inline format
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles complex real-world docker-compose', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: myapp:latest
|
||||
environment:
|
||||
NODE_ENV: production # Set to development for local
|
||||
DATABASE_URL: "postgres://user:pass@db:5432/mydb" # Main database
|
||||
REDIS_URL: "redis://cache:6379"
|
||||
API_SECRET: "${API_SECRET}" # From .env file
|
||||
LOG_LEVEL: debug # Options: debug, info, warn, error
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: user # Database admin username
|
||||
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
||||
POSTGRES_DB: mydb
|
||||
|
||||
cache:
|
||||
image: redis:7
|
||||
environment:
|
||||
- REDIS_MAXMEMORY=256mb # Memory limit for cache
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'NODE_ENV' => 'Set to development for local',
|
||||
'DATABASE_URL' => 'Main database',
|
||||
'API_SECRET' => 'From .env file',
|
||||
'LOG_LEVEL' => 'Options: debug, info, warn, error',
|
||||
'POSTGRES_USER' => 'Database admin username',
|
||||
'REDIS_MAXMEMORY' => 'Memory limit for cache',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles comment with multiple hash symbols', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
FOO: bar # comment # with # hashes
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'comment # with # hashes',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles variables with empty comments', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
FOO: bar #
|
||||
BAZ: qux #
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
// Empty comments should not be included
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments properly exits environment block on new section', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
image: nginx:latest
|
||||
environment:
|
||||
FOO: bar # env comment
|
||||
ports:
|
||||
- "80:80" # port comment should not be captured
|
||||
volumes:
|
||||
- ./data:/data # volume comment should not be captured
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
// Only environment variables should have comments extracted
|
||||
expect($result)->toBe([
|
||||
'FOO' => 'env comment',
|
||||
]);
|
||||
});
|
||||
|
||||
test('extractYamlEnvironmentComments handles SERVICE_ variables', function () {
|
||||
$yaml = <<<'YAML'
|
||||
version: "3.8"
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
SERVICE_FQDN_WEB: /api # Path for the web service
|
||||
SERVICE_URL_WEB: # URL will be generated
|
||||
NORMAL_VAR: value # Regular variable
|
||||
YAML;
|
||||
|
||||
$result = extractYamlEnvironmentComments($yaml);
|
||||
|
||||
expect($result)->toBe([
|
||||
'SERVICE_FQDN_WEB' => 'Path for the web service',
|
||||
'SERVICE_URL_WEB' => 'URL will be generated',
|
||||
'NORMAL_VAR' => 'Regular variable',
|
||||
]);
|
||||
});
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
test('DockerCompose handles array format from parseEnvFormatToArray', function () {
|
||||
// Simulate the array format that parseEnvFormatToArray returns
|
||||
$variables = [
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => 'This is a comment'],
|
||||
'KEY3' => ['value' => 'value3', 'comment' => null],
|
||||
];
|
||||
|
||||
// Test the extraction logic
|
||||
foreach ($variables as $key => $data) {
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
// Verify the extraction
|
||||
expect($value)->toBeString();
|
||||
expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
|
||||
|
||||
if ($key === 'KEY1') {
|
||||
expect($value)->toBe('value1');
|
||||
expect($comment)->toBeNull();
|
||||
} elseif ($key === 'KEY2') {
|
||||
expect($value)->toBe('value2');
|
||||
expect($comment)->toBe('This is a comment');
|
||||
} elseif ($key === 'KEY3') {
|
||||
expect($value)->toBe('value3');
|
||||
expect($comment)->toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('DockerCompose handles plain string format gracefully', function () {
|
||||
// Simulate a scenario where parseEnvFormatToArray might return plain strings
|
||||
// (for backward compatibility or edge cases)
|
||||
$variables = [
|
||||
'KEY1' => 'value1',
|
||||
'KEY2' => 'value2',
|
||||
'KEY3' => 'value3',
|
||||
];
|
||||
|
||||
// Test the extraction logic
|
||||
foreach ($variables as $key => $data) {
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
// Verify the extraction
|
||||
expect($value)->toBeString();
|
||||
expect($comment)->toBeNull();
|
||||
expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
|
||||
}
|
||||
});
|
||||
|
||||
test('DockerCompose handles mixed array and string formats', function () {
|
||||
// Simulate a mixed scenario (unlikely but possible)
|
||||
$variables = [
|
||||
'KEY1' => ['value' => 'value1', 'comment' => 'comment1'],
|
||||
'KEY2' => 'value2', // Plain string
|
||||
'KEY3' => ['value' => 'value3', 'comment' => null],
|
||||
'KEY4' => 'value4', // Plain string
|
||||
];
|
||||
|
||||
// Test the extraction logic
|
||||
foreach ($variables as $key => $data) {
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
// Verify the extraction
|
||||
expect($value)->toBeString();
|
||||
expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3', 'KEY4']);
|
||||
|
||||
if ($key === 'KEY1') {
|
||||
expect($value)->toBe('value1');
|
||||
expect($comment)->toBe('comment1');
|
||||
} elseif ($key === 'KEY2') {
|
||||
expect($value)->toBe('value2');
|
||||
expect($comment)->toBeNull();
|
||||
} elseif ($key === 'KEY3') {
|
||||
expect($value)->toBe('value3');
|
||||
expect($comment)->toBeNull();
|
||||
} elseif ($key === 'KEY4') {
|
||||
expect($value)->toBe('value4');
|
||||
expect($comment)->toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('DockerCompose handles empty array values gracefully', function () {
|
||||
// Simulate edge case with incomplete array structure
|
||||
$variables = [
|
||||
'KEY1' => ['value' => 'value1'], // Missing 'comment' key
|
||||
'KEY2' => ['comment' => 'comment2'], // Missing 'value' key (edge case)
|
||||
'KEY3' => [], // Empty array (edge case)
|
||||
];
|
||||
|
||||
// Test the extraction logic with improved fallback
|
||||
foreach ($variables as $key => $data) {
|
||||
// Handle both array format ['value' => ..., 'comment' => ...] and plain string values
|
||||
$value = is_array($data) ? ($data['value'] ?? '') : $data;
|
||||
$comment = is_array($data) ? ($data['comment'] ?? null) : null;
|
||||
|
||||
// Verify the extraction doesn't crash
|
||||
expect($key)->toBeIn(['KEY1', 'KEY2', 'KEY3']);
|
||||
|
||||
if ($key === 'KEY1') {
|
||||
expect($value)->toBe('value1');
|
||||
expect($comment)->toBeNull();
|
||||
} elseif ($key === 'KEY2') {
|
||||
// If 'value' is missing, fallback to empty string (not the whole array)
|
||||
expect($value)->toBe('');
|
||||
expect($comment)->toBe('comment2');
|
||||
} elseif ($key === 'KEY3') {
|
||||
// If both are missing, fallback to empty string (not empty array)
|
||||
expect($value)->toBe('');
|
||||
expect($comment)->toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
220
tests/Unit/NestedEnvironmentVariableParsingTest.php
Normal file
220
tests/Unit/NestedEnvironmentVariableParsingTest.php
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Tests for nested environment variable parsing in Docker Compose files.
|
||||
*
|
||||
* These tests verify that the parser correctly handles nested variable substitution syntax
|
||||
* like ${API_URL:-${SERVICE_URL_YOLO}/api} where defaults can contain other variable references.
|
||||
*/
|
||||
test('nested variable syntax is parsed correctly', function () {
|
||||
// Test the exact scenario from the bug report
|
||||
$input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
|
||||
expect($result)->not->toBeNull()
|
||||
->and($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split)->not->toBeNull()
|
||||
->and($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
|
||||
});
|
||||
|
||||
test('replaceVariables correctly extracts nested variable content', function () {
|
||||
// Before the fix, this would incorrectly extract only up to the first closing brace
|
||||
$result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
|
||||
|
||||
// Should extract the full content, not just "${API_URL:-${SERVICE_URL_YOLO"
|
||||
expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
|
||||
->and($result->value())->not->toBe('API_URL:-${SERVICE_URL_YOLO'); // Not truncated
|
||||
});
|
||||
|
||||
test('nested defaults with path concatenation work', function () {
|
||||
$input = '${REDIS_URL:-${SERVICE_URL_REDIS}/db/0}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('REDIS_URL')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_REDIS}/db/0');
|
||||
});
|
||||
|
||||
test('deeply nested variables are handled', function () {
|
||||
// Three levels of nesting
|
||||
$input = '${A:-${B:-${C}}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
|
||||
expect($result['content'])->toBe('A:-${B:-${C}}');
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['default'])->toBe('${B:-${C}}');
|
||||
});
|
||||
|
||||
test('multiple nested variables in default value', function () {
|
||||
// Default value contains multiple variable references
|
||||
$input = '${API:-${SERVICE_URL}:${SERVICE_PORT}/api}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('API')
|
||||
->and($split['default'])->toBe('${SERVICE_URL}:${SERVICE_PORT}/api');
|
||||
});
|
||||
|
||||
test('nested variables with different operators', function () {
|
||||
// Nested variable uses different operator
|
||||
$input = '${API_URL:-${SERVICE_URL?error message}/api}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL?error message}/api');
|
||||
});
|
||||
|
||||
test('backward compatibility with simple variables', function () {
|
||||
// Simple variable without nesting should still work
|
||||
$input = '${VAR}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
|
||||
expect($result->value())->toBe('VAR');
|
||||
});
|
||||
|
||||
test('backward compatibility with single-level defaults', function () {
|
||||
// Single-level default without nesting
|
||||
$input = '${VAR:-default_value}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
|
||||
expect($result->value())->toBe('VAR:-default_value');
|
||||
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['default'])->toBe('default_value');
|
||||
});
|
||||
|
||||
test('backward compatibility with dash operator', function () {
|
||||
$input = '${VAR-default}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['operator'])->toBe('-');
|
||||
});
|
||||
|
||||
test('backward compatibility with colon question operator', function () {
|
||||
$input = '${VAR:?error message}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['operator'])->toBe(':?')
|
||||
->and($split['default'])->toBe('error message');
|
||||
});
|
||||
|
||||
test('backward compatibility with question operator', function () {
|
||||
$input = '${VAR?error}';
|
||||
|
||||
$result = replaceVariables($input);
|
||||
$split = splitOnOperatorOutsideNested($result->value());
|
||||
|
||||
expect($split['operator'])->toBe('?')
|
||||
->and($split['default'])->toBe('error');
|
||||
});
|
||||
|
||||
test('SERVICE_URL magic variables in nested defaults', function () {
|
||||
// Real-world scenario: SERVICE_URL_* magic variable used in nested default
|
||||
$input = '${DATABASE_URL:-${SERVICE_URL_POSTGRES}/mydb}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['variable'])->toBe('DATABASE_URL')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_POSTGRES}/mydb');
|
||||
|
||||
// Extract the nested SERVICE_URL variable
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
|
||||
expect($nestedResult['content'])->toBe('SERVICE_URL_POSTGRES');
|
||||
});
|
||||
|
||||
test('SERVICE_FQDN magic variables in nested defaults', function () {
|
||||
$input = '${API_HOST:-${SERVICE_FQDN_API}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${SERVICE_FQDN_API}');
|
||||
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
|
||||
expect($nestedResult['content'])->toBe('SERVICE_FQDN_API');
|
||||
});
|
||||
|
||||
test('complex real-world example', function () {
|
||||
// Complex real-world scenario from the bug report
|
||||
$input = '${API_URL:-${SERVICE_URL_YOLO}/api}';
|
||||
|
||||
// Step 1: Extract outer variable content
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
|
||||
// Step 2: Split on operator
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
expect($split['variable'])->toBe('API_URL');
|
||||
expect($split['operator'])->toBe(':-');
|
||||
expect($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
|
||||
|
||||
// Step 3: Extract nested variable
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
expect($nestedResult['content'])->toBe('SERVICE_URL_YOLO');
|
||||
|
||||
// This verifies that:
|
||||
// 1. API_URL should be created with value "${SERVICE_URL_YOLO}/api"
|
||||
// 2. SERVICE_URL_YOLO should be recognized and created as magic variable
|
||||
});
|
||||
|
||||
test('empty nested default values', function () {
|
||||
$input = '${VAR:-${NESTED:-}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${NESTED:-}');
|
||||
|
||||
$nestedResult = extractBalancedBraceContent($split['default'], 0);
|
||||
$nestedSplit = splitOnOperatorOutsideNested($nestedResult['content']);
|
||||
|
||||
expect($nestedSplit['default'])->toBe('');
|
||||
});
|
||||
|
||||
test('nested variables with complex paths', function () {
|
||||
$input = '${CONFIG_URL:-${SERVICE_URL_CONFIG}/v2/config.json}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
expect($split['default'])->toBe('${SERVICE_URL_CONFIG}/v2/config.json');
|
||||
});
|
||||
|
||||
test('operator precedence with nesting', function () {
|
||||
// The first :- at depth 0 should be used, not the one inside nested braces
|
||||
$input = '${A:-${B:-default}}';
|
||||
|
||||
$result = extractBalancedBraceContent($input, 0);
|
||||
$split = splitOnOperatorOutsideNested($result['content']);
|
||||
|
||||
// Should split on first :- (at depth 0)
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:-default}'); // Not split here
|
||||
});
|
||||
207
tests/Unit/NestedEnvironmentVariableTest.php
Normal file
207
tests/Unit/NestedEnvironmentVariableTest.php
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
use function PHPUnit\Framework\assertNotNull;
|
||||
use function PHPUnit\Framework\assertNull;
|
||||
|
||||
test('extractBalancedBraceContent extracts content from simple variable', function () {
|
||||
$result = extractBalancedBraceContent('${VAR}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR')
|
||||
->and($result['start'])->toBe(1)
|
||||
->and($result['end'])->toBe(5);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles nested braces', function () {
|
||||
$result = extractBalancedBraceContent('${API_URL:-${SERVICE_URL_YOLO}/api}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('API_URL:-${SERVICE_URL_YOLO}/api')
|
||||
->and($result['start'])->toBe(1)
|
||||
->and($result['end'])->toBe(34); // Position of closing }
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles triple nesting', function () {
|
||||
$result = extractBalancedBraceContent('${A:-${B:-${C}}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('A:-${B:-${C}}');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null for unbalanced braces', function () {
|
||||
$result = extractBalancedBraceContent('${VAR', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent returns null when no braces', function () {
|
||||
$result = extractBalancedBraceContent('VAR', 0);
|
||||
|
||||
assertNull($result);
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles startPos parameter', function () {
|
||||
$result = extractBalancedBraceContent('foo ${VAR} bar', 4);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('VAR')
|
||||
->and($result['start'])->toBe(5)
|
||||
->and($result['end'])->toBe(9);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested splits on :- operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('API_URL:-default_value');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('default_value');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles nested defaults', function () {
|
||||
$split = splitOnOperatorOutsideNested('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('API_URL')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL_YOLO}/api');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles dash operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR-default');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe('-')
|
||||
->and($split['default'])->toBe('default');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles colon question operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:?error message');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe(':?')
|
||||
->and($split['default'])->toBe('error message');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles question operator', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR?error');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe('?')
|
||||
->and($split['default'])->toBe('error');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested returns null for simple variable', function () {
|
||||
$split = splitOnOperatorOutsideNested('SIMPLE_VAR');
|
||||
|
||||
assertNull($split);
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested ignores operators inside nested braces', function () {
|
||||
$split = splitOnOperatorOutsideNested('A:-${B:-default}');
|
||||
|
||||
assertNotNull($split);
|
||||
// Should split on first :- (outside nested braces), not the one inside ${B:-default}
|
||||
expect($split['variable'])->toBe('A')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${B:-default}');
|
||||
});
|
||||
|
||||
test('replaceVariables handles simple variable', function () {
|
||||
$result = replaceVariables('${VAR}');
|
||||
|
||||
expect($result->value())->toBe('VAR');
|
||||
});
|
||||
|
||||
test('replaceVariables handles nested expressions', function () {
|
||||
$result = replaceVariables('${API_URL:-${SERVICE_URL_YOLO}/api}');
|
||||
|
||||
expect($result->value())->toBe('API_URL:-${SERVICE_URL_YOLO}/api');
|
||||
});
|
||||
|
||||
test('replaceVariables handles variable with default', function () {
|
||||
$result = replaceVariables('${API_URL:-http://localhost}');
|
||||
|
||||
expect($result->value())->toBe('API_URL:-http://localhost');
|
||||
});
|
||||
|
||||
test('replaceVariables returns unchanged for non-variable string', function () {
|
||||
$result = replaceVariables('not_a_variable');
|
||||
|
||||
expect($result->value())->toBe('not_a_variable');
|
||||
});
|
||||
|
||||
test('replaceVariables handles triple nesting', function () {
|
||||
$result = replaceVariables('${A:-${B:-${C}}}');
|
||||
|
||||
expect($result->value())->toBe('A:-${B:-${C}}');
|
||||
});
|
||||
|
||||
test('replaceVariables fallback works for malformed input', function () {
|
||||
// When braces are unbalanced, it falls back to old behavior
|
||||
$result = replaceVariables('${VAR');
|
||||
|
||||
// Old behavior would extract everything before first }
|
||||
// But since there's no }, it will extract 'VAR' (removing ${)
|
||||
expect($result->value())->toContain('VAR');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent handles complex nested expression', function () {
|
||||
$result = extractBalancedBraceContent('${API:-${SERVICE_URL}/api/v${VERSION:-1}}', 0);
|
||||
|
||||
assertNotNull($result);
|
||||
expect($result['content'])->toBe('API:-${SERVICE_URL}/api/v${VERSION:-1}');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles complex nested expression', function () {
|
||||
$split = splitOnOperatorOutsideNested('API:-${SERVICE_URL}/api/v${VERSION:-1}');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('API')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('${SERVICE_URL}/api/v${VERSION:-1}');
|
||||
});
|
||||
|
||||
test('extractBalancedBraceContent finds second variable in string', function () {
|
||||
$str = '${VAR1} and ${VAR2}';
|
||||
|
||||
// First variable
|
||||
$result1 = extractBalancedBraceContent($str, 0);
|
||||
assertNotNull($result1);
|
||||
expect($result1['content'])->toBe('VAR1');
|
||||
|
||||
// Second variable
|
||||
$result2 = extractBalancedBraceContent($str, $result1['end'] + 1);
|
||||
assertNotNull($result2);
|
||||
expect($result2['content'])->toBe('VAR2');
|
||||
});
|
||||
|
||||
test('replaceVariables handles empty default value', function () {
|
||||
$result = replaceVariables('${VAR:-}');
|
||||
|
||||
expect($result->value())->toBe('VAR:-');
|
||||
});
|
||||
|
||||
test('splitOnOperatorOutsideNested handles empty default value', function () {
|
||||
$split = splitOnOperatorOutsideNested('VAR:-');
|
||||
|
||||
assertNotNull($split);
|
||||
expect($split['variable'])->toBe('VAR')
|
||||
->and($split['operator'])->toBe(':-')
|
||||
->and($split['default'])->toBe('');
|
||||
});
|
||||
|
||||
test('replaceVariables handles brace format without dollar sign', function () {
|
||||
// This format is used by the regex capture group in magic variable detection
|
||||
$result = replaceVariables('{SERVICE_URL_YOLO}');
|
||||
expect($result->value())->toBe('SERVICE_URL_YOLO');
|
||||
});
|
||||
|
||||
test('replaceVariables handles truncated brace format', function () {
|
||||
// When regex captures {VAR from a larger expression, no closing brace
|
||||
$result = replaceVariables('{API_URL');
|
||||
expect($result->value())->toBe('API_URL');
|
||||
});
|
||||
248
tests/Unit/ParseEnvFormatToArrayTest.php
Normal file
248
tests/Unit/ParseEnvFormatToArrayTest.php
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
<?php
|
||||
|
||||
test('parseEnvFormatToArray parses simple KEY=VALUE pairs', function () {
|
||||
$input = "KEY1=value1\nKEY2=value2";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray strips inline comments from unquoted values', function () {
|
||||
$input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'NIXPACKS_NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
|
||||
'NODE_VERSION' => ['value' => '22', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray strips inline comments only when preceded by whitespace', function () {
|
||||
$input = "KEY1=value1#nocomment\nKEY2=value2 #comment\nKEY3=value3 # comment with spaces";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1#nocomment', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => 'comment'],
|
||||
'KEY3' => ['value' => 'value3', 'comment' => 'comment with spaces'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray preserves # in quoted values', function () {
|
||||
$input = "KEY1=\"value with # hash\"\nKEY2='another # hash'";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value with # hash', 'comment' => null],
|
||||
'KEY2' => ['value' => 'another # hash', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles quoted values correctly', function () {
|
||||
$input = "KEY1=\"quoted value\"\nKEY2='single quoted'";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'quoted value', 'comment' => null],
|
||||
'KEY2' => ['value' => 'single quoted', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray skips comment lines', function () {
|
||||
$input = "# This is a comment\nKEY1=value1\n# Another comment\nKEY2=value2";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray skips empty lines', function () {
|
||||
$input = "KEY1=value1\n\nKEY2=value2\n\n";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles values with equals signs', function () {
|
||||
$input = 'KEY1=value=with=equals';
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value=with=equals', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles empty values', function () {
|
||||
$input = "KEY1=\nKEY2=value";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => '', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles complex real-world example', function () {
|
||||
$input = <<<'ENV'
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432 #default postgres port
|
||||
DB_NAME="my_database"
|
||||
DB_PASSWORD='p@ssw0rd#123'
|
||||
|
||||
# API Keys
|
||||
API_KEY=abc123 # Production key
|
||||
SECRET_KEY=xyz789
|
||||
ENV;
|
||||
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'DB_HOST' => ['value' => 'localhost', 'comment' => null],
|
||||
'DB_PORT' => ['value' => '5432', 'comment' => 'default postgres port'],
|
||||
'DB_NAME' => ['value' => 'my_database', 'comment' => null],
|
||||
'DB_PASSWORD' => ['value' => 'p@ssw0rd#123', 'comment' => null],
|
||||
'API_KEY' => ['value' => 'abc123', 'comment' => 'Production key'],
|
||||
'SECRET_KEY' => ['value' => 'xyz789', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles the original bug scenario', function () {
|
||||
$input = "NIXPACKS_NODE_VERSION=22 #needed for now\nNODE_VERSION=22";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
// The value should be "22", not "22 #needed for now"
|
||||
expect($result['NIXPACKS_NODE_VERSION']['value'])->toBe('22');
|
||||
expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('#');
|
||||
expect($result['NIXPACKS_NODE_VERSION']['value'])->not->toContain('needed');
|
||||
// And the comment should be extracted
|
||||
expect($result['NIXPACKS_NODE_VERSION']['comment'])->toBe('needed for now');
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles quoted strings with spaces before hash', function () {
|
||||
$input = "KEY1=\"value with spaces\" #comment\nKEY2=\"another value\"";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value with spaces', 'comment' => 'comment'],
|
||||
'KEY2' => ['value' => 'another value', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles unquoted values with multiple hash symbols', function () {
|
||||
$input = "KEY1=value1#not#comment\nKEY2=value2 # comment # with # hashes";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
// KEY1: no space before #, so entire value is kept
|
||||
// KEY2: space before first #, so everything from first space+# is stripped
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1#not#comment', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => 'comment # with # hashes'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles quoted values containing hash symbols at various positions', function () {
|
||||
$input = "KEY1=\"#starts with hash\"\nKEY2=\"hash # in middle\"\nKEY3=\"ends with hash#\"";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => '#starts with hash', 'comment' => null],
|
||||
'KEY2' => ['value' => 'hash # in middle', 'comment' => null],
|
||||
'KEY3' => ['value' => 'ends with hash#', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray trims whitespace before comments', function () {
|
||||
$input = "KEY1=value1 #comment\nKEY2=value2\t#comment with tab";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value1', 'comment' => 'comment'],
|
||||
'KEY2' => ['value' => 'value2', 'comment' => 'comment with tab'],
|
||||
]);
|
||||
// Values should not have trailing spaces
|
||||
expect($result['KEY1']['value'])->not->toEndWith(' ');
|
||||
expect($result['KEY2']['value'])->not->toEndWith("\t");
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray preserves hash in passwords without spaces', function () {
|
||||
$input = "PASSWORD=pass#word123\nAPI_KEY=abc#def#ghi";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'PASSWORD' => ['value' => 'pass#word123', 'comment' => null],
|
||||
'API_KEY' => ['value' => 'abc#def#ghi', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray strips comments with space before hash', function () {
|
||||
$input = "PASSWORD=passw0rd #this is secure\nNODE_VERSION=22 #needed for now";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'PASSWORD' => ['value' => 'passw0rd', 'comment' => 'this is secure'],
|
||||
'NODE_VERSION' => ['value' => '22', 'comment' => 'needed for now'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray extracts comments from quoted values followed by comments', function () {
|
||||
$input = "KEY1=\"value\" #comment after quote\nKEY2='value' #another comment";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value', 'comment' => 'comment after quote'],
|
||||
'KEY2' => ['value' => 'value', 'comment' => 'another comment'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles empty comments', function () {
|
||||
$input = "KEY1=value #\nKEY2=value # ";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'KEY1' => ['value' => 'value', 'comment' => null],
|
||||
'KEY2' => ['value' => 'value', 'comment' => null],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray extracts multi-word comments', function () {
|
||||
$input = 'DATABASE_URL=postgres://localhost #this is the database connection string for production';
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'DATABASE_URL' => ['value' => 'postgres://localhost', 'comment' => 'this is the database connection string for production'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles mixed quoted and unquoted with comments', function () {
|
||||
$input = "UNQUOTED=value1 #comment1\nDOUBLE=\"value2\" #comment2\nSINGLE='value3' #comment3";
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'UNQUOTED' => ['value' => 'value1', 'comment' => 'comment1'],
|
||||
'DOUBLE' => ['value' => 'value2', 'comment' => 'comment2'],
|
||||
'SINGLE' => ['value' => 'value3', 'comment' => 'comment3'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEnvFormatToArray handles the user reported case ASD=asd #asdfgg', function () {
|
||||
$input = 'ASD=asd #asdfgg';
|
||||
$result = parseEnvFormatToArray($input);
|
||||
|
||||
expect($result)->toBe([
|
||||
'ASD' => ['value' => 'asd', 'comment' => 'asdfgg'],
|
||||
]);
|
||||
|
||||
// Specifically verify the comment is extracted
|
||||
expect($result['ASD']['value'])->toBe('asd');
|
||||
expect($result['ASD']['comment'])->toBe('asdfgg');
|
||||
expect($result['ASD']['comment'])->not->toBeNull();
|
||||
});
|
||||
Loading…
Reference in a new issue