Compare commits

...

40 commits

Author SHA1 Message Date
Diogo Carvalho
ad1e97c391
Merge 34e9f97b79 into e436eeef91 2026-03-02 22:14:51 +01:00
Andras Bacsai
e436eeef91
feat(service): disable minio community edition (#8686)
Some checks are pending
Staging Build / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Waiting to run
Staging Build / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Waiting to run
Staging Build / merge-manifest (push) Blocked by required conditions
2026-03-02 12:52:14 +01:00
Andras Bacsai
61366871ee
Fix Grist service template (#8384) 2026-03-02 12:52:03 +01:00
Andras Bacsai
e7fb4155cf
feat(service): add Pydio cells (#8323) 2026-03-02 12:51:50 +01:00
Andras Bacsai
b5d6e13b83
feat: add comment field to environment variables (#7269) 2026-03-02 12:37:14 +01:00
Andras Bacsai
1234463fca feat(models): add is_required to EnvironmentVariable fillable array
Add is_required field to the EnvironmentVariable model's fillable
array to allow mass assignment. Include comprehensive tests to verify
all fillable fields are properly configured for mass assignment.
2026-03-02 12:34:30 +01:00
Andras Bacsai
059164224c fix(bootstrap): add bounds check to extractBalancedBraceContent
Return null when startPos exceeds string length to prevent out-of-bounds
access. Add comprehensive test coverage for environment variable parsing
edge cases.
2026-03-02 12:24:40 +01:00
Andras Bacsai
9b7e2e15b0 Merge remote-tracking branch 'origin/next' into env-var-descriptions 2026-03-01 14:39:23 +01:00
Andras Bacsai
9a4b4280be refactor(jobs): split task skip checks into critical and runtime phases
Some checks failed
Staging Build / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Has been cancelled
Staging Build / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Has been cancelled
Staging Build / merge-manifest (push) Has been cancelled
Move expensive runtime checks (service/application status) after cron
validation to avoid running them for tasks that aren't due. Critical
checks (orphans, infrastructure) remain in first phase.

Also fix database heading parameters to be built from the model.
2026-02-28 18:37:51 +01:00
Andras Bacsai
31555f9e8a fix(jobs): prevent non-due jobs firing on restart and enrich skip logs with resource links
- Refactor shouldRunNow() to only fire on first run (empty cache) if actually due by cron schedule, preventing spurious executions after cache loss or service restart
- Add enrichSkipLogsWithLinks() method to fetch and populate resource names and links for tasks, backups, and docker cleanup jobs in skip logs
- Update skip logs UI to display resource column with links to related resources, improving navigation and context
- Add fallback display when linked resources are deleted
- Expand tests to cover both restart scenarios: non-due jobs (should not fire) and due jobs (should fire)
2026-02-28 18:03:29 +01:00
ShadowArcanist
8c13ddf2c7
feat(service): disable minio community edition 2026-02-28 18:38:06 +05:30
Andras Bacsai
d9e39ba211 Merge remote-tracking branch 'origin/next' into env-var-descriptions
Some checks failed
Staging Build / build-push (aarch64, linux/aarch64, ubuntu-24.04-arm) (push) Has been cancelled
Staging Build / build-push (amd64, linux/amd64, ubuntu-24.04) (push) Has been cancelled
Staging Build / merge-manifest (push) Has been cancelled
2026-02-28 00:09:54 +01:00
Diogo Carvalho
34e9f97b79
Fix logo format 2026-02-25 23:48:15 +00:00
Diogo Carvalho
1a8441b230
Rename speedtest to librespeed 2026-02-25 23:28:20 +00:00
Diogo Carvalho
e9c980d3d5
Add logo 2026-02-25 23:25:08 +00:00
Diogo Carvalho
907b3d04c1
Add speedtest service 2026-02-25 23:22:38 +00:00
John Rallis
281283b12d
Add SERVICE_URL_ 2026-02-16 15:13:50 +02:00
John Rallis
b2f9137ee9
Fix Grist service template 2026-02-16 12:24:00 +02:00
benqsz
fe0e374d61 feat: pydio-cells.yml pin to stable version
And  fix volumes naming
2026-02-13 18:47:54 +01:00
benqsz
4a502c4d13
fix: pydio-cells svg path typo 2026-02-13 15:19:06 +01:00
benqsz
bdfbb5bf1c
feat: pydio cells svg 2026-02-13 15:09:29 +01:00
benqsz
8bc3737b45
feat(service): pydio-cells.yml 2026-02-13 15:04:23 +01:00
Andras Bacsai
87f9ce0674 Add comment field support to environment variable API endpoints
API consumers can now create and update environment variables with
an optional comment field for documentation purposes. Changes include:
- Added comment validation (string, nullable, max 256 chars) to all env endpoints
- Updated ApplicationsController create_env and update_env_by_uuid
- Updated ServicesController create_env and update_env_by_uuid
- Updated openapi.json request schemas to document the comment field

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:24:53 +01:00
Andras Bacsai
208f0eac99 feat: add comprehensive environment variable parsing with nested resolution and hardcoded variable detection
This commit introduces advanced environment variable handling capabilities including:
- Nested environment variable resolution with circular dependency detection
- Extraction of hardcoded environment variables from docker-compose.yml
- New ShowHardcoded Livewire component for displaying detected variables
- Enhanced UI for better environment variable management

The changes improve the user experience by automatically detecting and displaying
environment variables that are hardcoded in docker-compose files, allowing users
to override them if needed. The nested variable resolution ensures complex variable
dependencies are properly handled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:24:53 +01:00
Andras Bacsai
d67fcd1dff feat: add magic variable detection and update UI behavior accordingly 2025-12-27 15:24:09 +01:00
Andras Bacsai
89192c9862 feat: add function to extract inline comments from docker-compose YAML environment variables 2025-12-27 15:24:09 +01:00
Andras Bacsai
e4cc5c1178 fix: update success message logic to only show when changes are made 2025-12-27 15:24:09 +01:00
Andras Bacsai
61dcf8b4ac refactor: replace inline note with callout component for consistency
- Use x-callout component in developer view for env var note
- Simplify label text from "Comment (Optional)" to "Comment"
- Minor code formatting improvements via Pint

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:24:09 +01:00
Andras Bacsai
d640911bb9 fix: preserve existing comments in bulk update and always show save notification
This commit fixes two UX issues with environment variable bulk updates:

1. Comment Preservation (High Priority Bug):
   - When bulk updating environment variables via Developer view, existing
     manually-entered comments are now preserved when no inline comment is provided
   - Only overwrites existing comments when an inline comment (#comment) is explicitly
     provided in the pasted content
   - Previously: pasting "KEY=value" would erase existing comment to null
   - Now: pasting "KEY=value" preserves existing comment, "KEY=value #new" overwrites it

2. Save Notification (UX Improvement):
   - "Save all Environment variables" button now always shows success notification
   - Previously: only showed notification when changes were detected
   - Now: provides feedback even when no changes were made
   - Consistent with other save operations in the codebase

Changes:
- Modified updateOrCreateVariables() to only update comment field when inline comment
  is provided (null check prevents overwriting existing comments)
- Modified handleBulkSubmit() to always dispatch success notification unless error occurred
- Added comprehensive test coverage for bulk update comment preservation scenarios

Tests:
- Added 4 new feature tests covering comment preservation edge cases
- All 22 existing unit tests for parseEnvFormatToArray pass
- Code formatted with Pint

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:24:09 +01:00
Andras Bacsai
dfb180601a fix: position Update button next to comment field for locked variables
Moved the Update button to appear inline with the comment field for better UX when editing comments on locked environment variables. The button now appears on the same row as the comment input on larger screens.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:24:09 +01:00
Andras Bacsai
c1be02dfb9 fix: remove duplicate delete button from locked environment variable view
Removed the duplicate delete button that was appearing at the bottom of locked environment variables. The delete button at the top (next to the lock icon) is sufficient.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:24:09 +01:00
Andras Bacsai
c8558b5f78 fix: add Update button for locked environment variable comments
Removed instantSave from comment field and added a proper Update button with Delete modal for locked environment variables. This ensures users can explicitly save their comment changes on locked variables.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:24:09 +01:00
Andras Bacsai
4623853c99 fix: allow editing comments on locked environment variables
Modified the locked environment variable view to keep the comment field editable even when the variable value is locked. Users with update permission can now edit comments on locked variables, while users without permission can still view the comment for reference.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:24:09 +01:00
Andras Bacsai
a4d5465963 feat: show comment field for locked environment variables
When an environment variable is locked (is_shown_once=true), the comment field is now displayed as disabled/read-only for future reference. This allows users to see the documentation/note about what the variable is for, even when the value is hidden for security.

The comment field appears after the key field and before the configuration checkboxes in the locked view.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:24:09 +01:00
Andras Bacsai
0eb0dbef02 fix: save comment field when creating application environment variables
The comment field was not being saved when creating environment variables from applications, even though it worked for shared environment variables. The issue was in the createEnvironmentVariable method which was missing the comment assignment.

Added: $environment->comment = $data['comment'] ?? null;

The comment is already dispatched from the Add component and now it's properly saved to the database for application environment variables.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:24:09 +01:00
Andras Bacsai
4e329053dd feat: add comment field to shared environment variables
Add comment field support to the "New Shared Variable" modal, ensuring it's saved properly for both normal and shared environment variables at all levels (Team, Project, Environment).

Changes:
- Add comment property, validation, and dispatch to Add component (Livewire & view)
- Update saveKey methods in Team, Project, and Environment to accept comment
- Replace SharedEnvironmentVariable model's $guarded with explicit $fillable array
- Include comment field in creation flow for all shared variable types

The comment field (max 256 chars, optional) is now available when creating shared variables and is consistently saved across all variable types.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:24:09 +01:00
Andras Bacsai
2bba5ddb2e refactor: add explicit fillable array to EnvironmentVariable model
Replace permissive $guarded = [] with explicit $fillable array for better security and clarity. The fillable array includes all 13 fields that are legitimately mass-assignable:

- Core: 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
- Metadata: version, order

Also adds comprehensive test suite (EnvironmentVariableMassAssignmentTest) with 12 test cases covering:
- Mass assignment of all fillable fields
- Comment field edge cases (null, empty, long text)
- Value encryption verification
- Key mutation (trim and space replacement)
- Protection of auto-managed fields (id, uuid, timestamps)
- Update method compatibility

All tests passing (12 passed, 33 assertions).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-27 15:24:09 +01:00
Andras Bacsai
ab472bf5ed feat: enhance environment variable handling to support mixed formats and add comprehensive tests 2025-12-27 15:24:09 +01:00
Andras Bacsai
201c9fada3 feat: limit comment field to 256 characters for environment variables 2025-12-27 15:24:09 +01:00
Andras Bacsai
e33558488e feat: add comment field to environment variables
- Add comment field to EnvironmentVariable model and database
- Update parseEnvFormatToArray to extract inline comments from env files
- Update Livewire components to handle comment field
- Add UI for displaying and editing comments
- Add tests for comment parsing functionality
2025-12-27 15:24:09 +01:00
45 changed files with 3900 additions and 297 deletions

View file

@ -2936,7 +2936,7 @@ class ApplicationsController extends Controller
)]
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 @@ class ApplicationsController extends Controller
'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 @@ class ApplicationsController extends Controller
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 @@ class ApplicationsController extends Controller
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 @@ class ApplicationsController extends Controller
)]
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 @@ class ApplicationsController extends Controller
'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 @@ class ApplicationsController extends Controller
'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 @@ class ApplicationsController extends Controller
'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,
]);

View file

@ -1184,6 +1184,7 @@ class ServicesController extends Controller
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
@ -1199,7 +1200,19 @@ class ServicesController extends Controller
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 @@ class ServicesController extends Controller
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
@ -1442,7 +1456,14 @@ class ServicesController extends Controller
], 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);
}

View file

@ -214,10 +214,12 @@ class ScheduledJobManager implements ShouldQueue
foreach ($tasks as $task) {
try {
$server = $task->server();
$skipReason = $this->getTaskSkipReason($task, $server);
if ($skipReason !== null) {
// Phase 1: Critical checks (always — cheap, handles orphans and infra issues)
$criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
if ($criticalSkip !== null) {
$this->skippedCount++;
$this->logSkip('task', $skipReason, [
$this->logSkip('task', $criticalSkip, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server?->team_id,
@ -237,16 +239,31 @@ class ScheduledJobManager implements ShouldQueue
$frequency = VALID_CRON_STRINGS[$frequency];
}
if ($this->shouldRunNow($frequency, $serverTimezone, "scheduled-task:{$task->id}")) {
ScheduledTaskJob::dispatch($task);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Task dispatched', [
if (! $this->shouldRunNow($frequency, $serverTimezone, "scheduled-task:{$task->id}")) {
continue;
}
// Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources)
$runtimeSkip = $this->getTaskRuntimeSkipReason($task);
if ($runtimeSkip !== null) {
$this->skippedCount++;
$this->logSkip('task', $runtimeSkip, [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
'server_id' => $server->id,
]);
continue;
}
ScheduledTaskJob::dispatch($task);
$this->dispatchedCount++;
Log::channel('scheduled')->info('Task dispatched', [
'task_id' => $task->id,
'task_name' => $task->name,
'team_id' => $server->team_id,
'server_id' => $server->id,
]);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Error processing task', [
'task_id' => $task->id,
@ -281,11 +298,8 @@ class ScheduledJobManager implements ShouldQueue
return null;
}
private function getTaskSkipReason(ScheduledTask $task, ?Server $server): ?string
private function getTaskCriticalSkipReason(ScheduledTask $task, ?Server $server): ?string
{
$service = $task->service;
$application = $task->application;
if (blank($server)) {
$task->delete();
@ -300,17 +314,22 @@ class ScheduledJobManager implements ShouldQueue
return 'subscription_unpaid';
}
if (! $service && ! $application) {
if (! $task->service && ! $task->application) {
$task->delete();
return 'resource_deleted';
}
if ($application && str($application->status)->contains('running') === false) {
return null;
}
private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
{
if ($task->application && str($task->application->status)->contains('running') === false) {
return 'application_not_running';
}
if ($service && str($service->status)->contains('running') === false) {
if ($task->service && str($task->service->status)->contains('running') === false) {
return 'service_not_running';
}
@ -341,8 +360,19 @@ class ScheduledJobManager implements ShouldQueue
$lastDispatched = Cache::get($dedupKey);
// Run if: never dispatched before, OR there's been a due time since last dispatch
if ($lastDispatched === null || $previousDue->gt(Carbon::parse($lastDispatched))) {
if ($lastDispatched === null) {
// First run after restart or cache loss: only fire if actually due right now.
// Seed the cache so subsequent runs can use tolerance/catch-up logic.
$isDue = $cron->isDue($executionTime);
if ($isDue) {
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
}
return $isDue;
}
// Subsequent runs: fire if there's been a due time since last dispatch
if ($previousDue->gt(Carbon::parse($lastDispatched))) {
Cache::put($dedupKey, $executionTime->toIso8601String(), 86400);
return true;

View file

@ -69,7 +69,11 @@ class Heading extends Component
public function mount()
{
$this->parameters = get_route_parameters();
$this->parameters = [
'project_uuid' => $this->database->environment->project->uuid,
'environment_uuid' => $this->database->environment->uuid,
'database_uuid' => $this->database->uuid,
];
}
public function stop()

View file

@ -63,10 +63,16 @@ class DockerCompose extends Component
]);
$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(),

View file

@ -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 @@ class Add extends Component
'is_runtime' => $this->is_runtime,
'is_buildtime' => $this->is_buildtime,
'is_preview' => $this->is_preview,
'comment' => $this->comment,
]);
$this->clear();
}
@ -148,5 +153,6 @@ class Add extends Component
$this->is_literal = false;
$this->is_runtime = true;
$this->is_buildtime = true;
$this->comment = null;
}
}

View file

@ -89,6 +89,62 @@ class All extends Component
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 @@ class All extends Component
$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 @@ class All extends Component
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 @@ class All extends Component
$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;

View file

@ -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 @@ class Show extends Component
$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 @@ class Show extends Component
}
$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 @@ class Show extends Component
} 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 @@ class Show extends Component
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;
}

View file

@ -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');
}
}

View file

@ -3,8 +3,11 @@
namespace App\Livewire\Settings;
use App\Models\DockerCleanupExecution;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\Server;
use App\Services\SchedulerLogParser;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
@ -102,13 +105,84 @@ class ScheduledJobs extends Component
$parser = new SchedulerLogParser;
$allSkips = $parser->getRecentSkips(500, $teamId);
$this->skipTotalCount = $allSkips->count();
$this->skipLogs = $allSkips->slice($this->skipPage, $this->skipDefaultTake)->values();
$this->skipLogs = $this->enrichSkipLogsWithLinks(
$allSkips->slice($this->skipPage, $this->skipDefaultTake)->values()
);
$this->showSkipPrev = $this->skipPage > 0;
$this->showSkipNext = ($this->skipPage + $this->skipDefaultTake) < $this->skipTotalCount;
$this->skipCurrentPage = intval($this->skipPage / $this->skipDefaultTake) + 1;
$this->managerRuns = $parser->getRecentRuns(30, $teamId);
}
private function enrichSkipLogsWithLinks(Collection $skipLogs): Collection
{
$taskIds = $skipLogs->where('type', 'task')->pluck('context.task_id')->filter()->unique()->values();
$backupIds = $skipLogs->where('type', 'backup')->pluck('context.backup_id')->filter()->unique()->values();
$serverIds = $skipLogs->where('type', 'docker_cleanup')->pluck('context.server_id')->filter()->unique()->values();
$tasks = $taskIds->isNotEmpty()
? ScheduledTask::with(['application.environment.project', 'service.environment.project'])->whereIn('id', $taskIds)->get()->keyBy('id')
: collect();
$backups = $backupIds->isNotEmpty()
? ScheduledDatabaseBackup::with(['database.environment.project'])->whereIn('id', $backupIds)->get()->keyBy('id')
: collect();
$servers = $serverIds->isNotEmpty()
? Server::whereIn('id', $serverIds)->get()->keyBy('id')
: collect();
return $skipLogs->map(function (array $skip) use ($tasks, $backups, $servers): array {
$skip['link'] = null;
$skip['resource_name'] = null;
if ($skip['type'] === 'task') {
$task = $tasks->get($skip['context']['task_id'] ?? null);
if ($task) {
$skip['resource_name'] = $skip['context']['task_name'] ?? $task->name;
$resource = $task->application ?? $task->service;
$environment = $resource?->environment;
$project = $environment?->project;
if ($project && $environment && $resource) {
$routeName = $task->application_id
? 'project.application.scheduled-tasks'
: 'project.service.scheduled-tasks';
$routeKey = $task->application_id ? 'application_uuid' : 'service_uuid';
$skip['link'] = route($routeName, [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
$routeKey => $resource->uuid,
'task_uuid' => $task->uuid,
]);
}
}
} elseif ($skip['type'] === 'backup') {
$backup = $backups->get($skip['context']['backup_id'] ?? null);
if ($backup) {
$database = $backup->database;
$skip['resource_name'] = $database?->name ?? 'Database backup';
$environment = $database?->environment;
$project = $environment?->project;
if ($project && $environment && $database) {
$skip['link'] = route('project.database.backup.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
]);
}
}
} elseif ($skip['type'] === 'docker_cleanup') {
$server = $servers->get($skip['context']['server_id'] ?? null);
if ($server) {
$skip['resource_name'] = $server->name;
$skip['link'] = route('server.show', ['server_uuid' => $server->uuid]);
}
}
return $skip;
});
}
private function getExecutions(?int $teamId = null): Collection
{
$dateFrom = $this->getDateFrom();

View file

@ -40,6 +40,7 @@ class Show extends Component
'value' => $data['value'],
'is_multiline' => $data['is_multiline'],
'is_literal' => $data['is_literal'],
'comment' => $data['comment'] ?? null,
'type' => 'environment',
'team_id' => currentTeam()->id,
]);

View file

@ -33,6 +33,7 @@ class Show extends Component
'value' => $data['value'],
'is_multiline' => $data['is_multiline'],
'is_literal' => $data['is_literal'],
'comment' => $data['comment'] ?? null,
'type' => 'project',
'team_id' => currentTeam()->id,
]);

View file

@ -33,6 +33,7 @@ class Index extends Component
'value' => $data['value'],
'is_multiline' => $data['is_multiline'],
'is_literal' => $data['is_literal'],
'comment' => $data['comment'] ?? null,
'type' => 'team',
'team_id' => currentTeam()->id,
]);

View file

@ -24,6 +24,7 @@ use OpenApi\Attributes as OA;
'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 @@ use OpenApi\Attributes as OA;
)]
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 @@ class EnvironmentVariable extends BaseModel
'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,

View file

@ -6,7 +6,23 @@ use Illuminate\Database\Eloquent\Model;
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',

View file

@ -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,
]);
}
}
}

View file

@ -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)

View file

@ -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.

View file

@ -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');
});
}
};

View file

@ -11404,6 +11404,10 @@
"real_value": {
"type": "string"
},
"comment": {
"type": "string",
"nullable": true
},
"version": {
"type": "string"
},

View file

@ -7241,6 +7241,9 @@ components:
type: string
real_value:
type: string
comment:
type: string
nullable: true
version:
type: string
created_at:

BIN
public/svgs/librespeed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View file

@ -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."

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -213,8 +213,8 @@
<tr>
<th class="px-4 py-3">Time</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Resource</th>
<th class="px-4 py-3">Reason</th>
<th class="px-4 py-3">Details</th>
</tr>
</thead>
<tbody>
@ -235,6 +235,17 @@
{{ ucfirst(str_replace('_', ' ', $skip['type'])) }}
</span>
</td>
<td class="px-4 py-2">
@if($skip['link'] ?? null)
<a href="{{ $skip['link'] }}" class="text-white underline hover:no-underline">
{{ $skip['resource_name'] }}
</a>
@elseif($skip['resource_name'] ?? null)
{{ $skip['resource_name'] }}
@else
<span class="text-gray-500">{{ $skip['context']['task_name'] ?? $skip['context']['server_name'] ?? 'Deleted' }}</span>
@endif
</td>
<td class="px-4 py-2">
@php
$reasonLabel = match($skip['reason']) {
@ -256,15 +267,6 @@
@endphp
<span class="{{ $reasonBg }}">{{ $reasonLabel }}</span>
</td>
<td class="px-4 py-2 text-xs text-gray-500">
@php
$details = collect($skip['context'])
->except(['type', 'skip_reason', 'execution_time'])
->map(fn($v, $k) => str_replace('_', ' ', $k) . ': ' . $v)
->implode(', ');
@endphp
{{ $details }}
</td>
</tr>
@empty
<tr>

38
svgs/cells.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -3,16 +3,16 @@
# category: productivity
# tags: lowcode, nocode, spreadsheet, database, relational
# logo: svgs/grist.svg
# port: 443
# port: 8484
services:
grist:
image: gristlabs/grist:latest
environment:
- SERVICE_URL_GRIST_443
- SERVICE_URL_GRIST_8484
- APP_HOME_URL=${SERVICE_URL_GRIST}
- APP_DOC_URL=${SERVICE_URL_GRIST}
- GRIST_DOMAIN=${SERVICE_URL_GRIST}
- GRIST_DOMAIN=${SERVICE_FQDN_GRIST}
- TZ=${TZ:-UTC}
- GRIST_SUPPORT_ANON=${SUPPORT_ANON:-false}
- GRIST_FORCE_LOGIN=${FORCE_LOGIN:-true}
@ -20,7 +20,7 @@ services:
- GRIST_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX:- - Suffix}
- GRIST_HIDE_UI_ELEMENTS=${HIDE_UI_ELEMENTS:-billing,sendToDrive,supportGrist,multiAccounts,tutorials}
- GRIST_UI_FEATURES=${UI_FEATURES:-helpCenter,billing,templates,createSite,multiSite,sendToDrive,tutorials,supportGrist}
- GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-test@example.com}
- GRIST_DEFAULT_EMAIL=${DEFAULT_EMAIL:-?}
- GRIST_ORG_IN_PATH=${ORG_IN_PATH:-true}
- GRIST_OIDC_SP_HOST=${SERVICE_URL_GRIST}
- GRIST_OIDC_IDP_SCOPES=${OIDC_IDP_SCOPES:-openid profile email}
@ -37,7 +37,7 @@ services:
- TYPEORM_DATABASE=${POSTGRES_DATABASE:-grist-db}
- TYPEORM_USERNAME=${SERVICE_USER_POSTGRES}
- TYPEORM_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- TYPEORM_HOST=${TYPEORM_HOST}
- TYPEORM_HOST=${TYPEORM_HOST:-postgres}
- TYPEORM_PORT=${TYPEORM_PORT:-5432}
- TYPEORM_LOGGING=${TYPEORM_LOGGING:-false}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}

View file

@ -0,0 +1,22 @@
# documentation: https://github.com/librespeed/speedtest
# slogan: Self-hosted lightweight Speed Test.
# category: devtools
# tags: speedtest, internet-speed
# logo: svgs/librespeed.png
# port: 82
services:
librespeed:
container_name: librespeed
image: 'ghcr.io/librespeed/speedtest:latest'
environment:
- SERVICE_URL_LIBRESPEED_82
- MODE=standalone
- TELEMETRY=false
- DISTANCE=km
- WEBPORT=82
healthcheck:
test: 'curl 127.0.0.1:82 || exit 1'
timeout: 1s
interval: 1m0s
retries: 1

View file

@ -1,3 +1,4 @@
# ignore: true
# documentation: https://github.com/coollabsio/minio?tab=readme-ov-file#minio-docker-images
# slogan: MinIO is a high performance object storage server compatible with Amazon S3 APIs.
# category: storage

View file

@ -0,0 +1,33 @@
# documentation: https://docs.pydio.com/
# slogan: High-performance large file sharing, native no-code automation, and a collaboration-centric architecture that simplifies access control without compromising security or compliance.
# tags: storage
# logo: svgs/cells.svg
# port: 8080
services:
cells:
image: pydio/cells:4.4
environment:
- SERVICE_URL_CELLS_8080
- CELLS_SITE_EXTERNAL=${SERVICE_URL_CELLS}
- CELLS_SITE_NO_TLS=1
volumes:
- cells_data:/var/cells
mariadb:
image: 'mariadb:11'
volumes:
- mysql_data:/var/lib/mysql
environment:
- MYSQL_ROOT_PASSWORD=${SERVICE_PASSWORD_MYSQLROOT}
- MYSQL_DATABASE=${MYSQL_DATABASE:-cells}
- MYSQL_USER=${SERVICE_USER_MYSQL}
- MYSQL_PASSWORD=${SERVICE_PASSWORD_MYSQL}
healthcheck:
test:
- CMD
- healthcheck.sh
- '--connect'
- '--innodb_initialized'
interval: 10s
timeout: 20s
retries: 5

View 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');
});

View 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();
});

View file

@ -30,8 +30,11 @@ it('dispatches backup when job runs on time at the cron minute', function () {
expect($result)->toBeTrue();
});
it('dispatches backup when job is delayed past the cron minute', function () {
// Freeze time at 02:07 — job was delayed 7 minutes past the 02:00 cron
it('catches delayed job when cache has a baseline from previous run', function () {
// Simulate a previous dispatch yesterday at 02:00
Cache::put('test-backup:1', Carbon::create(2026, 2, 27, 2, 0, 0, 'UTC')->toIso8601String(), 86400);
// Freeze time at 02:07 — job was delayed 7 minutes past today's 02:00 cron
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 7, 0, 'UTC'));
$job = new ScheduledJobManager;
@ -45,8 +48,8 @@ it('dispatches backup when job is delayed past the cron minute', function () {
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// isDue() would return false at 02:07, but getPreviousRunDate() = 02:00
// No lastDispatched in cache → should run
// isDue() would return false at 02:07, but getPreviousRunDate() = 02:00 today
// lastDispatched = 02:00 yesterday → 02:00 today > yesterday → fires
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:1');
expect($result)->toBeTrue();
@ -106,8 +109,27 @@ it('fires every_minute cron correctly on consecutive minutes', function () {
expect($result3)->toBeTrue();
});
it('re-dispatches after cache loss instead of missing', function () {
// First run at 02:00 — dispatches
it('does not fire non-due jobs on restart when cache is empty', function () {
// Time is 10:00, cron is daily at 02:00 — NOT due right now
Carbon::setTestNow(Carbon::create(2026, 2, 28, 10, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
$reflection = new ReflectionClass($job);
$executionTimeProp = $reflection->getProperty('executionTime');
$executionTimeProp->setAccessible(true);
$executionTimeProp->setValue($job, Carbon::now());
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
// Cache is empty (fresh restart) — should NOT fire daily backup at 10:00
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
expect($result)->toBeFalse();
});
it('fires due jobs on restart when cache is empty', function () {
// Time is exactly 02:00, cron is daily at 02:00 — IS due right now
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 0, 0, 'UTC'));
$job = new ScheduledJobManager;
@ -120,16 +142,8 @@ it('re-dispatches after cache loss instead of missing', function () {
$method = $reflection->getMethod('shouldRunNow');
$method->setAccessible(true);
$method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
// Simulate Redis restart — cache lost
Cache::forget('test-backup:4');
// Run again at 02:01 — should re-dispatch (lastDispatched = null after cache loss)
Carbon::setTestNow(Carbon::create(2026, 2, 28, 2, 1, 0, 'UTC'));
$executionTimeProp->setValue($job, Carbon::now());
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4');
// Cache is empty (fresh restart) — but cron IS due → should fire
$result = $method->invoke($job, '0 2 * * *', 'UTC', 'test-backup:4b');
expect($result)->toBeTrue();
});

View file

@ -234,3 +234,39 @@ test('scheduler log parser filters by team id', function () {
// Cleanup
@unlink($logPath);
});
test('skipped jobs show fallback when resource is deleted', function () {
$this->actingAs($this->rootUser);
session(['currentTeam' => $this->rootTeam]);
$logPath = storage_path('logs/scheduled-'.now()->format('Y-m-d').'.log');
$logDir = dirname($logPath);
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
// Temporarily rename existing logs so they don't interfere
$existingLogs = glob(storage_path('logs/scheduled-*.log'));
$renamed = [];
foreach ($existingLogs as $log) {
$tmp = $log.'.bak';
rename($log, $tmp);
$renamed[$tmp] = $log;
}
$lines = [
'['.now()->format('Y-m-d H:i:s').'] production.INFO: Task skipped {"type":"task","skip_reason":"application_not_running","task_id":99999,"task_name":"my-cron-job","team_id":0}',
];
file_put_contents($logPath, implode("\n", $lines)."\n");
Livewire::test(ScheduledJobs::class)
->assertStatus(200)
->assertSee('my-cron-job')
->assertSee('Application not running');
// Cleanup
@unlink($logPath);
foreach ($renamed as $tmp => $original) {
rename($tmp, $original);
}
});

View 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();
});

View 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();
});

View 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');
});

View 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();
});

View 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',
]);
});

View file

@ -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();
}
}
});

View 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
});

View 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');
});

View 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();
});