feat(proxy): add database-backed config storage with disk backups (#8905)
This commit is contained in:
commit
6815fbda29
6 changed files with 210 additions and 50 deletions
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Services\ProxyDashboardCacheService;
|
use App\Services\ProxyDashboardCacheService;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
|
||||||
class GetProxyConfiguration
|
class GetProxyConfiguration
|
||||||
|
|
@ -17,28 +18,31 @@ public function handle(Server $server, bool $forceRegenerate = false): string
|
||||||
return 'OK';
|
return 'OK';
|
||||||
}
|
}
|
||||||
|
|
||||||
$proxy_path = $server->proxyPath();
|
|
||||||
$proxy_configuration = null;
|
$proxy_configuration = null;
|
||||||
|
|
||||||
// If not forcing regeneration, try to read existing configuration
|
|
||||||
if (! $forceRegenerate) {
|
if (! $forceRegenerate) {
|
||||||
$payload = [
|
// Primary source: database
|
||||||
"mkdir -p $proxy_path",
|
$proxy_configuration = $server->proxy->get('last_saved_proxy_configuration');
|
||||||
"cat $proxy_path/docker-compose.yml 2>/dev/null",
|
|
||||||
];
|
// Backfill: existing servers may not have DB config yet — read from disk once
|
||||||
$proxy_configuration = instant_remote_process($payload, $server, false);
|
if (empty(trim($proxy_configuration ?? ''))) {
|
||||||
|
$proxy_configuration = $this->backfillFromDisk($server);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate default configuration if:
|
// Generate default configuration as last resort
|
||||||
// 1. Force regenerate is requested
|
|
||||||
// 2. Configuration file doesn't exist or is empty
|
|
||||||
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
|
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
|
||||||
// Extract custom commands from existing config before regenerating
|
|
||||||
$custom_commands = [];
|
$custom_commands = [];
|
||||||
if (! empty(trim($proxy_configuration ?? ''))) {
|
if (! empty(trim($proxy_configuration ?? ''))) {
|
||||||
$custom_commands = extractCustomProxyCommands($server, $proxy_configuration);
|
$custom_commands = extractCustomProxyCommands($server, $proxy_configuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log::warning('Proxy configuration regenerated to defaults', [
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'server_name' => $server->name,
|
||||||
|
'reason' => $forceRegenerate ? 'force_regenerate' : 'config_not_found',
|
||||||
|
]);
|
||||||
|
|
||||||
$proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value();
|
$proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,4 +54,30 @@ public function handle(Server $server, bool $forceRegenerate = false): string
|
||||||
|
|
||||||
return $proxy_configuration;
|
return $proxy_configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill: read config from disk for servers that predate DB storage.
|
||||||
|
* Stores the result in the database so future reads skip SSH entirely.
|
||||||
|
*/
|
||||||
|
private function backfillFromDisk(Server $server): ?string
|
||||||
|
{
|
||||||
|
$proxy_path = $server->proxyPath();
|
||||||
|
$result = instant_remote_process([
|
||||||
|
"mkdir -p $proxy_path",
|
||||||
|
"cat $proxy_path/docker-compose.yml 2>/dev/null",
|
||||||
|
], $server, false);
|
||||||
|
|
||||||
|
if (! empty(trim($result ?? ''))) {
|
||||||
|
$server->proxy->last_saved_proxy_configuration = $result;
|
||||||
|
$server->save();
|
||||||
|
|
||||||
|
Log::info('Proxy config backfilled to database from disk', [
|
||||||
|
'server_id' => $server->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,41 @@ class SaveProxyConfiguration
|
||||||
{
|
{
|
||||||
use AsAction;
|
use AsAction;
|
||||||
|
|
||||||
|
private const MAX_BACKUPS = 10;
|
||||||
|
|
||||||
public function handle(Server $server, string $configuration): void
|
public function handle(Server $server, string $configuration): void
|
||||||
{
|
{
|
||||||
$proxy_path = $server->proxyPath();
|
$proxy_path = $server->proxyPath();
|
||||||
$docker_compose_yml_base64 = base64_encode($configuration);
|
$docker_compose_yml_base64 = base64_encode($configuration);
|
||||||
|
$new_hash = str($docker_compose_yml_base64)->pipe('md5')->value;
|
||||||
|
|
||||||
// Update the saved settings hash
|
// Only create a backup if the configuration actually changed
|
||||||
$server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value;
|
$old_hash = $server->proxy->get('last_saved_settings');
|
||||||
|
$config_changed = $old_hash && $old_hash !== $new_hash;
|
||||||
|
|
||||||
|
// Update the saved settings hash and store full config as database backup
|
||||||
|
$server->proxy->last_saved_settings = $new_hash;
|
||||||
|
$server->proxy->last_saved_proxy_configuration = $configuration;
|
||||||
$server->save();
|
$server->save();
|
||||||
|
|
||||||
// Transfer the configuration file to the server
|
$backup_path = "$proxy_path/backups";
|
||||||
instant_remote_process([
|
|
||||||
"mkdir -p $proxy_path",
|
// Transfer the configuration file to the server, with backup if changed
|
||||||
"echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
|
$commands = ["mkdir -p $proxy_path"];
|
||||||
], $server);
|
|
||||||
|
if ($config_changed) {
|
||||||
|
$short_hash = substr($old_hash, 0, 8);
|
||||||
|
$timestamp = now()->format('Y-m-d_H-i-s');
|
||||||
|
$backup_file = "docker-compose.{$timestamp}.{$short_hash}.yml";
|
||||||
|
$commands[] = "mkdir -p $backup_path";
|
||||||
|
// Skip backup if a file with the same hash already exists (identical content)
|
||||||
|
$commands[] = "ls $backup_path/docker-compose.*.$short_hash.yml 1>/dev/null 2>&1 || cp -f $proxy_path/docker-compose.yml $backup_path/$backup_file 2>/dev/null || true";
|
||||||
|
// Prune old backups, keep only the most recent ones
|
||||||
|
$commands[] = 'cd '.$backup_path.' && ls -1t docker-compose.*.yml 2>/dev/null | tail -n +'.((int) self::MAX_BACKUPS + 1).' | xargs rm -f 2>/dev/null || true';
|
||||||
|
}
|
||||||
|
|
||||||
|
$commands[] = "echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null";
|
||||||
|
|
||||||
|
instant_remote_process($commands, $server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ public function mount()
|
||||||
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
|
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
|
||||||
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
|
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
|
||||||
$this->syncData(false);
|
$this->syncData(false);
|
||||||
|
$this->loadProxyConfiguration();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncData(bool $toModel = false): void
|
private function syncData(bool $toModel = false): void
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
use App\Enums\ProxyTypes;
|
use App\Enums\ProxyTypes;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -215,6 +216,13 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar
|
||||||
}
|
}
|
||||||
function generateDefaultProxyConfiguration(Server $server, array $custom_commands = [])
|
function generateDefaultProxyConfiguration(Server $server, array $custom_commands = [])
|
||||||
{
|
{
|
||||||
|
Log::info('Generating default proxy configuration', [
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'server_name' => $server->name,
|
||||||
|
'custom_commands_count' => count($custom_commands),
|
||||||
|
'caller' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[1]['class'] ?? 'unknown',
|
||||||
|
]);
|
||||||
|
|
||||||
$proxy_path = $server->proxyPath();
|
$proxy_path = $server->proxyPath();
|
||||||
$proxy_type = $server->proxyType();
|
$proxy_type = $server->proxyType();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
@php use App\Enums\ProxyTypes; @endphp
|
@php use App\Enums\ProxyTypes; @endphp
|
||||||
<div>
|
<div>
|
||||||
@if ($server->proxyType())
|
@if ($server->proxyType())
|
||||||
<div x-init="$wire.loadProxyConfiguration">
|
<div>
|
||||||
@if ($selectedProxy !== 'NONE')
|
@if ($selectedProxy !== 'NONE')
|
||||||
<form wire:submit='submit'>
|
<form wire:submit='submit'>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -55,24 +55,19 @@
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h3>{{ $proxyTitle }}</h3>
|
<h3>{{ $proxyTitle }}</h3>
|
||||||
@can('update', $server)
|
@can('update', $server)
|
||||||
<div wire:loading wire:target="loadProxyConfiguration">
|
@if ($proxySettings)
|
||||||
<x-forms.button disabled>Reset Configuration</x-forms.button>
|
<x-modal-confirmation title="Reset Proxy Configuration?"
|
||||||
</div>
|
buttonTitle="Reset Configuration" submitAction="resetProxyConfiguration"
|
||||||
<div wire:loading.remove wire:target="loadProxyConfiguration">
|
:actions="[
|
||||||
@if ($proxySettings)
|
'Reset proxy configuration to default settings',
|
||||||
<x-modal-confirmation title="Reset Proxy Configuration?"
|
'All custom configurations will be lost',
|
||||||
buttonTitle="Reset Configuration" submitAction="resetProxyConfiguration"
|
'Custom ports and entrypoints will be removed',
|
||||||
:actions="[
|
]" confirmationText="{{ $server->name }}"
|
||||||
'Reset proxy configuration to default settings',
|
confirmationLabel="Please confirm by entering the server name below"
|
||||||
'All custom configurations will be lost',
|
shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration"
|
||||||
'Custom ports and entrypoints will be removed',
|
:confirmWithPassword="false" :confirmWithText="true">
|
||||||
]" confirmationText="{{ $server->name }}"
|
</x-modal-confirmation>
|
||||||
confirmationLabel="Please confirm by entering the server name below"
|
@endif
|
||||||
shortConfirmationLabel="Server Name" step2ButtonText="Reset Configuration"
|
|
||||||
:confirmWithPassword="false" :confirmWithText="true">
|
|
||||||
</x-modal-confirmation>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endcan
|
@endcan
|
||||||
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
|
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
|
||||||
<button type="button" x-show="traefikWarningsDismissed"
|
<button type="button" x-show="traefikWarningsDismissed"
|
||||||
|
|
@ -134,19 +129,14 @@ class="underline text-white">Traefik changelog</a> to understand breaking change
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<div wire:loading wire:target="loadProxyConfiguration" class="pt-4">
|
@if ($proxySettings)
|
||||||
<x-loading text="Loading proxy configuration..." />
|
<div class="flex flex-col gap-2 pt-2">
|
||||||
</div>
|
<x-forms.textarea canGate="update" :canResource="$server" useMonacoEditor
|
||||||
<div wire:loading.remove wire:target="loadProxyConfiguration">
|
monacoEditorLanguage="yaml"
|
||||||
@if ($proxySettings)
|
label="Configuration file ( {{ $this->configurationFilePath }} )"
|
||||||
<div class="flex flex-col gap-2 pt-2">
|
name="proxySettings" id="proxySettings" rows="30" />
|
||||||
<x-forms.textarea canGate="update" :canResource="$server" useMonacoEditor
|
</div>
|
||||||
monacoEditorLanguage="yaml"
|
@endif
|
||||||
label="Configuration file ( {{ $this->configurationFilePath }} )"
|
|
||||||
name="proxySettings" id="proxySettings" rows="30" />
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
@elseif($selectedProxy === 'NONE')
|
@elseif($selectedProxy === 'NONE')
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
|
||||||
109
tests/Unit/ProxyConfigRecoveryTest.php
Normal file
109
tests/Unit/ProxyConfigRecoveryTest.php
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Actions\Proxy\GetProxyConfiguration;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Spatie\SchemalessAttributes\SchemalessAttributes;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Log::spy();
|
||||||
|
Cache::spy();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockServerWithDbConfig(?string $savedConfig): object
|
||||||
|
{
|
||||||
|
$proxyAttributes = Mockery::mock(SchemalessAttributes::class);
|
||||||
|
$proxyAttributes->shouldReceive('get')
|
||||||
|
->with('last_saved_proxy_configuration')
|
||||||
|
->andReturn($savedConfig);
|
||||||
|
|
||||||
|
$server = Mockery::mock('App\Models\Server');
|
||||||
|
$server->shouldIgnoreMissing();
|
||||||
|
$server->shouldReceive('getAttribute')->with('proxy')->andReturn($proxyAttributes);
|
||||||
|
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||||
|
$server->shouldReceive('getAttribute')->with('name')->andReturn('Test Server');
|
||||||
|
$server->shouldReceive('proxyType')->andReturn('TRAEFIK');
|
||||||
|
$server->shouldReceive('proxyPath')->andReturn('/data/coolify/proxy');
|
||||||
|
|
||||||
|
return $server;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns OK for NONE proxy type without reading config', function () {
|
||||||
|
$server = Mockery::mock('App\Models\Server');
|
||||||
|
$server->shouldIgnoreMissing();
|
||||||
|
$server->shouldReceive('proxyType')->andReturn('NONE');
|
||||||
|
|
||||||
|
$result = GetProxyConfiguration::run($server);
|
||||||
|
|
||||||
|
expect($result)->toBe('OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads proxy configuration from database', function () {
|
||||||
|
$savedConfig = "services:\n traefik:\n image: traefik:v3.5\n";
|
||||||
|
$server = mockServerWithDbConfig($savedConfig);
|
||||||
|
|
||||||
|
// ProxyDashboardCacheService is called at the end — mock it
|
||||||
|
$server->shouldReceive('proxyType')->andReturn('TRAEFIK');
|
||||||
|
|
||||||
|
$result = GetProxyConfiguration::run($server);
|
||||||
|
|
||||||
|
expect($result)->toBe($savedConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves full custom config including labels, env vars, and custom commands', function () {
|
||||||
|
$customConfig = <<<'YAML'
|
||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.5
|
||||||
|
command:
|
||||||
|
- '--entrypoints.http.address=:80'
|
||||||
|
- '--metrics.prometheus=true'
|
||||||
|
labels:
|
||||||
|
- 'traefik.enable=true'
|
||||||
|
- 'waf.custom.middleware=true'
|
||||||
|
environment:
|
||||||
|
CF_API_EMAIL: user@example.com
|
||||||
|
CF_API_KEY: secret-key
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$server = mockServerWithDbConfig($customConfig);
|
||||||
|
|
||||||
|
$result = GetProxyConfiguration::run($server);
|
||||||
|
|
||||||
|
expect($result)->toBe($customConfig)
|
||||||
|
->and($result)->toContain('waf.custom.middleware=true')
|
||||||
|
->and($result)->toContain('CF_API_EMAIL')
|
||||||
|
->and($result)->toContain('metrics.prometheus=true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs warning when regenerating defaults', function () {
|
||||||
|
Log::swap(new \Illuminate\Log\LogManager(app()));
|
||||||
|
Log::spy();
|
||||||
|
|
||||||
|
// No DB config, no disk config — will try to regenerate
|
||||||
|
$server = mockServerWithDbConfig(null);
|
||||||
|
|
||||||
|
// backfillFromDisk will be called — we need instant_remote_process to return empty
|
||||||
|
// Since it's a global function we can't easily mock it, so test the logging via
|
||||||
|
// the force regenerate path instead
|
||||||
|
try {
|
||||||
|
GetProxyConfiguration::run($server, forceRegenerate: true);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// generateDefaultProxyConfiguration may fail without full server setup
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('warning')
|
||||||
|
->withArgs(fn ($message) => str_contains($message, 'regenerated to defaults'))
|
||||||
|
->once();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not read from disk when DB config exists', function () {
|
||||||
|
$savedConfig = "services:\n traefik:\n image: traefik:v3.5\n";
|
||||||
|
$server = mockServerWithDbConfig($savedConfig);
|
||||||
|
|
||||||
|
// If disk were read, instant_remote_process would be called.
|
||||||
|
// Since we're not mocking it and the test passes, it proves DB is used.
|
||||||
|
$result = GetProxyConfiguration::run($server);
|
||||||
|
|
||||||
|
expect($result)->toBe($savedConfig);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue