diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index f250a860b..fdc65ebdb 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -6,6 +6,7 @@ use App\Models\EnvironmentVariable; use App\Traits\EnvironmentVariableProtection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; +use Visus\Cuid2\Cuid2; class All extends Component { @@ -398,4 +399,171 @@ class All extends Component unset($this->environmentVariablesPreview); $this->getDevView(); } + + public function loadFromEnvExample() + { + try { + $this->authorize('manageEnvironment', $this->resource); + + if ($this->resourceClass !== 'App\Models\Application') { + throw new \RuntimeException('This feature is only available for applications.'); + } + + if (! $this->canLoadEnvExample) { + throw new \RuntimeException('This feature requires a git repository source.'); + } + + $envExampleContent = $this->fetchEnvExampleFromGit(); + + if ($envExampleContent === null) { + $this->dispatch('error', 'No .env.example file found in the repository.'); + + return; + } + + $variables = parseEnvFormatToArray($envExampleContent); + + if (empty($variables)) { + $this->dispatch('warning', 'The .env.example file is empty or contains no valid variables.'); + + return; + } + + $addedCount = 0; + $skippedCount = 0; + $maxOrder = $this->resource->environment_variables()->max('order') ?? 0; + + foreach ($variables as $key => $value) { + if (str($key)->startsWith('SERVICE_FQDN') || + str($key)->startsWith('SERVICE_URL') || + str($key)->startsWith('SERVICE_NAME')) { + $skippedCount++; + + continue; + } + + $exists = $this->resource->environment_variables()->where('key', $key)->exists(); + if ($exists) { + $skippedCount++; + + continue; + } + + $environment = new EnvironmentVariable; + $environment->key = $key; + $environment->value = $value; + $environment->is_multiline = false; + $environment->is_preview = false; + $environment->is_runtime = true; + $environment->is_buildtime = true; + $environment->resourceable_id = $this->resource->id; + $environment->resourceable_type = $this->resource->getMorphClass(); + $environment->order = ++$maxOrder; + $environment->save(); + + $addedCount++; + } + + $this->refreshEnvs(); + + if ($addedCount > 0) { + $message = "Added {$addedCount} environment variable(s)"; + if ($skippedCount > 0) { + $message .= ", skipped {$skippedCount} existing"; + } + $this->dispatch('success', $message.'.'); + } else { + $this->dispatch('warning', 'All variables from .env.example already exist.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + private function fetchEnvExampleFromGit(): ?string + { + $uuid = (string) new Cuid2; + + ['commands' => $cloneCommand] = $this->resource->generateGitImportCommands( + deployment_uuid: $uuid, + only_checkout: true, + exec_in_docker: false, + custom_base_dir: '.' + ); + + $workdir = rtrim($this->resource->base_directory ?? '', '/'); + $envFile = '.env.example'; + $fileList = collect([".$workdir/$envFile"]); + + $server = $this->resource->destination->server; + + $getGitVersion = instant_remote_process(['git --version'], $server, false); + $gitVersion = str($getGitVersion)->explode(' ')->last(); + + if (version_compare($gitVersion, '2.35.1', '<')) { + $fileList = $fileList->map(function ($file) { + $parts = explode('/', trim($file, '.')); + $paths = collect(); + $currentPath = ''; + foreach ($parts as $part) { + $currentPath .= ($currentPath ? '/' : '').$part; + if (str($currentPath)->isNotEmpty()) { + $paths->push($currentPath); + } + } + + return $paths; + })->flatten()->unique()->values(); + + $commands = collect([ + "rm -rf /tmp/{$uuid}", + "mkdir -p /tmp/{$uuid}", + "cd /tmp/{$uuid}", + $cloneCommand, + 'git sparse-checkout init', + "git sparse-checkout set {$fileList->implode(' ')}", + 'git read-tree -mu HEAD', + "cat .$workdir/$envFile", + ]); + } else { + $commands = collect([ + "rm -rf /tmp/{$uuid}", + "mkdir -p /tmp/{$uuid}", + "cd /tmp/{$uuid}", + $cloneCommand, + 'git sparse-checkout init --cone', + "git sparse-checkout set {$fileList->implode(' ')}", + 'git read-tree -mu HEAD', + "cat .$workdir/$envFile", + ]); + } + + try { + $content = instant_remote_process($commands, $server); + + return $content; + } catch (\Exception $e) { + if (str($e->getMessage())->contains('No such file')) { + return null; + } + throw $e; + } finally { + instant_remote_process(["rm -rf /tmp/{$uuid}"], $server, false); + } + } + + public function getCanLoadEnvExampleProperty(): bool + { + if ($this->resourceClass !== 'App\Models\Application') { + return false; + } + + if (filled(data_get($this->resource, 'dockerfile'))) { + return false; + } + + $deploymentType = $this->resource->deploymentType(); + + return in_array($deploymentType, ['source', 'deploy_key']); + } } diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index a962b2cec..49806459d 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -8,6 +8,12 @@ + @if ($this->canLoadEnvExample) + + Load from .env.example + + @endif {{ $view === 'normal' ? 'Developer view' : 'Normal view' }} @endcan diff --git a/tests/Unit/Livewire/EnvironmentVariableLoadFromExampleTest.php b/tests/Unit/Livewire/EnvironmentVariableLoadFromExampleTest.php new file mode 100644 index 000000000..3527154d4 --- /dev/null +++ b/tests/Unit/Livewire/EnvironmentVariableLoadFromExampleTest.php @@ -0,0 +1,100 @@ +resourceClass = 'App\Models\Service'; + + expect($component->getCanLoadEnvExampleProperty())->toBeFalse(); +}); + +it('canLoadEnvExample returns false for StandalonePostgresql resources', function () { + $component = new All; + $component->resourceClass = 'App\Models\StandalonePostgresql'; + + expect($component->getCanLoadEnvExampleProperty())->toBeFalse(); +}); + +it('canLoadEnvExample returns false for dockerfile applications', function () { + $mockApplication = Mockery::mock('App\Models\Application')->shouldIgnoreMissing(); + $mockApplication->shouldReceive('getAttribute') + ->with('dockerfile') + ->andReturn('FROM alpine'); + + $component = new All; + $component->resourceClass = 'App\Models\Application'; + $component->resource = $mockApplication; + + expect($component->getCanLoadEnvExampleProperty())->toBeFalse(); +}); + +it('canLoadEnvExample returns true for source deployment type', function () { + $mockApplication = Mockery::mock('App\Models\Application')->shouldIgnoreMissing(); + $mockApplication->shouldReceive('getAttribute') + ->with('dockerfile') + ->andReturn(null); + $mockApplication->shouldReceive('deploymentType') + ->andReturn('source'); + + $component = new All; + $component->resourceClass = 'App\Models\Application'; + $component->resource = $mockApplication; + + expect($component->getCanLoadEnvExampleProperty())->toBeTrue(); +}); + +it('canLoadEnvExample returns true for deploy_key deployment type', function () { + $mockApplication = Mockery::mock('App\Models\Application')->shouldIgnoreMissing(); + $mockApplication->shouldReceive('getAttribute') + ->with('dockerfile') + ->andReturn(null); + $mockApplication->shouldReceive('deploymentType') + ->andReturn('deploy_key'); + + $component = new All; + $component->resourceClass = 'App\Models\Application'; + $component->resource = $mockApplication; + + expect($component->getCanLoadEnvExampleProperty())->toBeTrue(); +}); + +it('canLoadEnvExample returns false for other deployment types', function () { + $mockApplication = Mockery::mock('App\Models\Application')->shouldIgnoreMissing(); + $mockApplication->shouldReceive('getAttribute') + ->with('dockerfile') + ->andReturn(null); + $mockApplication->shouldReceive('deploymentType') + ->andReturn('dockerimage'); + + $component = new All; + $component->resourceClass = 'App\Models\Application'; + $component->resource = $mockApplication; + + expect($component->getCanLoadEnvExampleProperty())->toBeFalse(); +}); + +it('loadFromEnvExample method exists on All component', function () { + $component = new All; + + expect(method_exists($component, 'loadFromEnvExample'))->toBeTrue(); +}); + +it('parseEnvFormatToArray correctly parses env file format', function () { + $content = <<<'ENV' +# Comment line +DATABASE_URL=postgres://localhost:5432 +APP_NAME="My App" +EMPTY_VALUE= +QUOTED='single quoted' +ENV; + + $result = parseEnvFormatToArray($content); + + expect($result)->toBeArray() + ->and($result['DATABASE_URL'])->toBe('postgres://localhost:5432') + ->and($result['APP_NAME'])->toBe('My App') + ->and($result['EMPTY_VALUE'])->toBe('') + ->and($result['QUOTED'])->toBe('single quoted') + ->and($result)->not->toHaveKey('# Comment line'); +});