feat: shared server environment variables

This commit is contained in:
ShadowArcanist 2025-12-24 11:30:16 +01:00
parent c3ff32b287
commit e8d985211e
13 changed files with 411 additions and 33 deletions

View file

@ -1234,7 +1234,7 @@ private function generate_runtime_environment_variables()
});
foreach ($runtime_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->server));
}
// Check for PORT environment variable mismatch with ports_exposes
@ -1300,7 +1300,7 @@ private function generate_runtime_environment_variables()
});
foreach ($runtime_environment_variables_preview as $env) {
$envs->push($env->key.'='.$env->real_value);
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->server));
}
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
@ -2304,14 +2304,16 @@ private function generate_nixpacks_env_variables()
$this->env_nixpacks_args = collect([]);
if ($this->pull_request_id === 0) {
foreach ($this->application->nixpacks_environment_variables as $env) {
if (! is_null($env->real_value) && $env->real_value !== '') {
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
$resolvedValue = $env->getResolvedValueWithServer($this->server);
if (! is_null($resolvedValue) && $resolvedValue !== '') {
$this->env_nixpacks_args->push("--env {$env->key}={$resolvedValue}");
}
}
} else {
foreach ($this->application->nixpacks_environment_variables_preview as $env) {
if (! is_null($env->real_value) && $env->real_value !== '') {
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
$resolvedValue = $env->getResolvedValueWithServer($this->server);
if (! is_null($resolvedValue) && $resolvedValue !== '') {
$this->env_nixpacks_args->push("--env {$env->key}={$resolvedValue}");
}
}
}
@ -2447,8 +2449,9 @@ private function generate_env_variables()
->get();
foreach ($envs as $env) {
if (! is_null($env->real_value)) {
$this->env_args->put($env->key, $env->real_value);
$resolvedValue = $env->getResolvedValueWithServer($this->server);
if (! is_null($resolvedValue)) {
$this->env_args->put($env->key, $resolvedValue);
}
}
} else {
@ -2458,8 +2461,9 @@ private function generate_env_variables()
->get();
foreach ($envs as $env) {
if (! is_null($env->real_value)) {
$this->env_args->put($env->key, $env->real_value);
$resolvedValue = $env->getResolvedValueWithServer($this->server);
if (! is_null($resolvedValue)) {
$this->env_args->put($env->key, $resolvedValue);
}
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Livewire\SharedVariables\Server;
use App\Models\Server;
use Illuminate\Support\Collection;
use Livewire\Component;
class Index extends Component
{
public Collection $servers;
public function mount()
{
$this->servers = Server::ownedByCurrentTeamCached();
}
public function render()
{
return view('livewire.shared-variables.server.index');
}
}

View file

@ -0,0 +1,169 @@
<?php
namespace App\Livewire\SharedVariables\Server;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public Server $server;
public string $view = 'normal';
public ?string $variables = null;
protected $listeners = ['refreshEnvs' => 'refreshEnvs', 'saveKey' => 'saveKey', 'environmentVariableDeleted' => 'refreshEnvs'];
public function saveKey($data)
{
try {
$this->authorize('update', $this->server);
$found = $this->server->environment_variables()->where('key', $data['key'])->first();
if ($found) {
throw new \Exception('Variable already exists.');
}
$this->server->environment_variables()->create([
'key' => $data['key'],
'value' => $data['value'],
'is_multiline' => $data['is_multiline'],
'is_literal' => $data['is_literal'],
'type' => 'server',
'team_id' => currentTeam()->id,
]);
$this->server->refresh();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function mount()
{
$serverUuid = request()->route('server_uuid');
$teamId = currentTeam()->id;
$server = Server::where('team_id', $teamId)->where('uuid', $serverUuid)->first();
if (!$server) {
return redirect()->route('dashboard');
}
$this->server = $server;
$this->getDevView();
}
public function switch()
{
$this->authorize('view', $this->server);
$this->view = $this->view === 'normal' ? 'dev' : 'normal';
$this->getDevView();
}
public function getDevView()
{
$this->variables = $this->formatEnvironmentVariables($this->server->environment_variables->sortBy('key'));
}
private function formatEnvironmentVariables($variables)
{
return $variables->map(function ($item) {
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
}
public function submit()
{
try {
$this->authorize('update', $this->server);
$this->handleBulkSubmit();
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
$this->refreshEnvs();
}
}
private function handleBulkSubmit()
{
$variables = parseEnvFormatToArray($this->variables);
$changesMade = DB::transaction(function () use ($variables) {
// Delete removed variables
$deletedCount = $this->deleteRemovedVariables($variables);
// Update or create variables
$updatedCount = $this->updateOrCreateVariables($variables);
return $deletedCount > 0 || $updatedCount > 0;
});
if ($changesMade) {
$this->dispatch('success', 'Environment variables updated.');
}
}
private function deleteRemovedVariables($variables)
{
$variablesToDelete = $this->server->environment_variables()->whereNotIn('key', array_keys($variables))->get();
if ($variablesToDelete->isEmpty()) {
return 0;
}
$this->server->environment_variables()->whereNotIn('key', array_keys($variables))->delete();
return $variablesToDelete->count();
}
private function updateOrCreateVariables($variables)
{
$count = 0;
foreach ($variables as $key => $value) {
$found = $this->server->environment_variables()->where('key', $key)->first();
if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) {
if ($found->value !== $value) {
$found->value = $value;
$found->save();
$count++;
}
}
} else {
$this->server->environment_variables()->create([
'key' => $key,
'value' => $value,
'is_multiline' => false,
'is_literal' => false,
'type' => 'server',
'team_id' => currentTeam()->id,
]);
$count++;
}
}
return $count;
}
public function refreshEnvs()
{
$this->server->refresh();
$this->getDevView();
}
public function render()
{
return view('livewire.shared-variables.server.show');
}
}

View file

@ -122,6 +122,17 @@ public function realValue(): Attribute
return null;
}
// Load relationships needed for shared variable resolution
if (! $resource->relationLoaded('environment')) {
$resource->load('environment');
}
if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) {
$resource->load('server');
}
if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) {
$resource->load('destination.server');
}
$real_value = $this->get_real_environment_variables($this->value, $resource);
if ($this->is_literal || $this->is_multiline) {
$real_value = '\''.$real_value.'\'';
@ -181,7 +192,43 @@ protected function isShared(): Attribute
);
}
private function get_real_environment_variables(?string $environment_variable = null, $resource = null)
public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null)
{
return $this->get_real_environment_variables_internal($environment_variable, $resource, $server);
}
public function getResolvedValueWithServer($server = null)
{
if (! $this->relationLoaded('resourceable')) {
$this->load('resourceable');
}
$resource = $this->resourceable;
if (! $resource) {
return null;
}
// Load relationships needed for shared variable resolution
if (! $resource->relationLoaded('environment')) {
$resource->load('environment');
}
if (! $resource->relationLoaded('server') && method_exists($resource, 'server')) {
$resource->load('server');
}
if (! $resource->relationLoaded('destination') && method_exists($resource, 'destination')) {
$resource->load('destination.server');
}
$real_value = $this->get_real_environment_variables_internal($this->value, $resource, $server);
if ($this->is_literal || $this->is_multiline) {
$real_value = '\''.$real_value.'\'';
} else {
$real_value = escapeEnvVariables($real_value);
}
return $real_value;
}
private function get_real_environment_variables_internal(?string $environment_variable = null, $resource = null, $serverOverride = null)
{
if ((is_null($environment_variable) && $environment_variable === '') || is_null($resource)) {
return null;
@ -203,6 +250,17 @@ private function get_real_environment_variables(?string $environment_variable =
$id = $resource->environment->project->id;
} elseif ($type->value() === 'team') {
$id = $resource->team()->id;
} elseif ($type->value() === 'server') {
// Use server override if provided (for deployment context), otherwise use resource's server
if ($serverOverride) {
$id = $serverOverride->id;
} elseif (isset($resource->server) && $resource->server) {
$id = $resource->server->id;
} elseif (isset($resource->destination) && $resource->destination && isset($resource->destination->server)) {
$id = $resource->destination->server->id;
} else {
$id = null;
}
}
if (is_null($id)) {
continue;
@ -216,6 +274,11 @@ private function get_real_environment_variables(?string $environment_variable =
return str($environment_variable)->value();
}
private function get_real_environment_variables(?string $environment_variable = null, $resource = null)
{
return $this->get_real_environment_variables_internal($environment_variable, $resource);
}
private function get_environment_variables(?string $environment_variable = null): ?string
{
if (! $environment_variable) {

View file

@ -1016,6 +1016,11 @@ public function team()
return $this->belongsTo(Team::class);
}
public function environment_variables()
{
return $this->hasMany(SharedEnvironmentVariable::class)->where('type', 'server');
}
public function isProxyShouldRun()
{
// TODO: Do we need "|| $this->proxy->force_stop" here?

View file

@ -27,4 +27,9 @@ public function environment()
{
return $this->belongsTo(Environment::class);
}
public function server()
{
return $this->belongsTo(Server::class);
}
}

View file

@ -38,6 +38,7 @@ public function __construct(
public array $availableVars = [],
public ?string $projectUuid = null,
public ?string $environmentUuid = null,
public ?string $serverUuid = null,
) {
// Handle authorization-based disabling
if ($this->canGate && $this->canResource && $this->autoDisable) {
@ -86,6 +87,9 @@ public function render(): View|Closure|string
'environment_uuid' => $this->environmentUuid,
])
: route('shared-variables.environment.index'),
'server' => $this->serverUuid
? route('shared-variables.server.show', ['server_uuid' => $this->serverUuid])
: route('shared-variables.server.index'),
'default' => route('shared-variables.index'),
];

View file

@ -81,4 +81,4 @@
const NEEDS_TO_DISABLE_STRIPPREFIX = [
'appwrite' => ['appwrite', 'appwrite-console', 'appwrite-realtime'],
];
const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment'];
const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment', 'server'];

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement("ALTER TABLE shared_environment_variables DROP CONSTRAINT shared_environment_variables_type_check");
DB::statement("ALTER TABLE shared_environment_variables ADD CONSTRAINT shared_environment_variables_type_check CHECK (type IN ('team', 'project', 'environment', 'server'))");
Schema::table('shared_environment_variables', function (Blueprint $table) {
$table->foreignId('server_id')->nullable()->constrained()->onDelete('cascade');
$table->unique(['key', 'server_id', 'team_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('shared_environment_variables', function (Blueprint $table) {
$table->dropUnique(['key', 'server_id', 'team_id']);
$table->dropForeign(['server_id']);
$table->dropColumn('server_id');
});
DB::statement("ALTER TABLE shared_environment_variables DROP CONSTRAINT shared_environment_variables_type_check");
DB::statement("ALTER TABLE shared_environment_variables ADD CONSTRAINT shared_environment_variables_type_check CHECK (type IN ('team', 'project', 'environment'))");
}
};

View file

@ -5,27 +5,33 @@
<div class="flex items-start gap-2">
<h1>Shared Variables</h1>
</div>
<div class="subtitle">Set Team / Project / Environment wide variables.</div>
<div class="subtitle">Set Team / Project / Environment / Server wide variables.</div>
<div class="flex flex-col gap-2 -mt-1">
<a class="coolbox group" href="{{ route('shared-variables.team.index') }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">Team wide</div>
<div class="box-description">Usable for all resources in a team.</div>
</div>
</a>
<a class="coolbox group" href="{{ route('shared-variables.project.index') }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">Project wide</div>
<div class="box-description">Usable for all resources in a project.</div>
</div>
</a>
<a class="coolbox group" href="{{ route('shared-variables.environment.index') }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">Environment wide</div>
<div class="box-description">Usable for all resources in an environment.</div>
</div>
</a>
<div class="flex flex-col gap-2 -mt-1">
<a class="coolbox group" href="{{ route('shared-variables.team.index') }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">Team wide</div>
<div class="box-description">Usable for all resources in a team.</div>
</div>
</a>
<a class="coolbox group" href="{{ route('shared-variables.project.index') }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">Project wide</div>
<div class="box-description">Usable for all resources in a project.</div>
</div>
</a>
<a class="coolbox group" href="{{ route('shared-variables.environment.index') }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">Environment wide</div>
<div class="box-description">Usable for all resources in an environment.</div>
</div>
</a>
<a class="coolbox group" href="{{ route('shared-variables.server.index') }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6">
<div class="box-title">Server wide</div>
<div class="box-description">Usable for all resources in a server.</div>
</div>
</a>
</div>
</div>
</div>

View file

@ -0,0 +1,25 @@
<div>
<x-slot:title>
Server Variables | Coolify
</x-slot>
<div class="flex gap-2">
<h1>Servers</h1>
</div>
<div class="subtitle">List of your servers.</div>
<div class="flex flex-col gap-2">
@forelse ($servers as $server)
<a class="coolbox group"
href="{{ route('shared-variables.server.show', ['server_uuid' => data_get($server, 'uuid')]) }}" {{ wireNavigate() }}>
<div class="flex flex-col justify-center mx-6 ">
<div class="box-title">{{ $server->name }}</div>
<div class="box-description ">
{{ $server->description }}</div>
</div>
</a>
@empty
<div>
<div>No server found.</div>
</div>
@endforelse
</div>
</div>

View file

@ -0,0 +1,36 @@
<div>
<x-slot:title>
Server Variable | Coolify
</x-slot>
<div class="flex gap-2 items-center">
<h1>Shared Variables for {{ data_get($server, 'name') }}</h1>
@can('update', $server)
<x-modal-input buttonTitle="+ Add" title="New Shared Variable">
<livewire:project.shared.environment-variable.add :shared="true" />
</x-modal-input>
@endcan
<x-forms.button canGate="update" :canResource="$server" wire:click='switch'>{{ $view === 'normal' ? 'Developer view' : 'Normal view' }}</x-forms.button>
</div>
<div class="flex flex-wrap gap-1 subtitle">
<div>You can use these variables anywhere with</div>
<div class="dark:text-warning text-coollabs">@{{ server.VARIABLENAME }} </div>
<x-helper
helper="More info <a class='underline dark:text-white' href='https://coolify.io/docs/knowledge-base/environment-variables#shared-variables' target='_blank'>here</a>."></x-helper>
</div>
@if ($view === 'normal')
<div class="flex flex-col gap-2">
@forelse ($server->environment_variables->sort()->sortBy('key') as $env)
<livewire:project.shared.environment-variable.show wire:key="environment-{{ $env->id }}"
:env="$env" type="server" />
@empty
<div>No environment variables found.</div>
@endforelse
</div>
@else
<form wire:submit='submit' class="flex flex-col gap-2">
<x-forms.textarea canGate="update" :canResource="$server" rows="20" class="whitespace-pre-wrap" id="variables" wire:model="variables"
label="Server Shared Variables"></x-forms.textarea>
<x-forms.button canGate="update" :canResource="$server" type="submit" class="btn btn-primary">Save All Environment Variables</x-forms.button>
</form>
@endif
</div>

View file

@ -70,6 +70,8 @@
use App\Livewire\SharedVariables\Index as SharedVariablesIndex;
use App\Livewire\SharedVariables\Project\Index as ProjectSharedVariablesIndex;
use App\Livewire\SharedVariables\Project\Show as ProjectSharedVariablesShow;
use App\Livewire\SharedVariables\Server\Index as ServerSharedVariablesIndex;
use App\Livewire\SharedVariables\Server\Show as ServerSharedVariablesShow;
use App\Livewire\SharedVariables\Team\Index as TeamSharedVariablesIndex;
use App\Livewire\Source\Github\Change as GitHubChange;
use App\Livewire\Storage\Index as StorageIndex;
@ -145,6 +147,8 @@
Route::get('/project/{project_uuid}', ProjectSharedVariablesShow::class)->name('shared-variables.project.show');
Route::get('/environments', EnvironmentSharedVariablesIndex::class)->name('shared-variables.environment.index');
Route::get('/environments/project/{project_uuid}/environment/{environment_uuid}', EnvironmentSharedVariablesShow::class)->name('shared-variables.environment.show');
Route::get('/servers', ServerSharedVariablesIndex::class)->name('shared-variables.server.index');
Route::get('/server/{server_uuid}', ServerSharedVariablesShow::class)->name('shared-variables.server.show');
});
Route::prefix('team')->group(function () {