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\Services\ProxyDashboardCacheService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class GetProxyConfiguration
|
||||
|
|
@ -17,28 +18,31 @@ public function handle(Server $server, bool $forceRegenerate = false): string
|
|||
return 'OK';
|
||||
}
|
||||
|
||||
$proxy_path = $server->proxyPath();
|
||||
$proxy_configuration = null;
|
||||
|
||||
// If not forcing regeneration, try to read existing configuration
|
||||
if (! $forceRegenerate) {
|
||||
$payload = [
|
||||
"mkdir -p $proxy_path",
|
||||
"cat $proxy_path/docker-compose.yml 2>/dev/null",
|
||||
];
|
||||
$proxy_configuration = instant_remote_process($payload, $server, false);
|
||||
// Primary source: database
|
||||
$proxy_configuration = $server->proxy->get('last_saved_proxy_configuration');
|
||||
|
||||
// Backfill: existing servers may not have DB config yet — read from disk once
|
||||
if (empty(trim($proxy_configuration ?? ''))) {
|
||||
$proxy_configuration = $this->backfillFromDisk($server);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate default configuration if:
|
||||
// 1. Force regenerate is requested
|
||||
// 2. Configuration file doesn't exist or is empty
|
||||
// Generate default configuration as last resort
|
||||
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
|
||||
// Extract custom commands from existing config before regenerating
|
||||
$custom_commands = [];
|
||||
if (! empty(trim($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();
|
||||
}
|
||||
|
||||
|
|
@ -50,4 +54,30 @@ public function handle(Server $server, bool $forceRegenerate = false): string
|
|||
|
||||
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;
|
||||
|
||||
private const MAX_BACKUPS = 10;
|
||||
|
||||
public function handle(Server $server, string $configuration): void
|
||||
{
|
||||
$proxy_path = $server->proxyPath();
|
||||
$docker_compose_yml_base64 = base64_encode($configuration);
|
||||
$new_hash = str($docker_compose_yml_base64)->pipe('md5')->value;
|
||||
|
||||
// Update the saved settings hash
|
||||
$server->proxy->last_saved_settings = str($docker_compose_yml_base64)->pipe('md5')->value;
|
||||
// Only create a backup if the configuration actually changed
|
||||
$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();
|
||||
|
||||
// Transfer the configuration file to the server
|
||||
instant_remote_process([
|
||||
"mkdir -p $proxy_path",
|
||||
"echo '$docker_compose_yml_base64' | base64 -d | tee $proxy_path/docker-compose.yml > /dev/null",
|
||||
], $server);
|
||||
$backup_path = "$proxy_path/backups";
|
||||
|
||||
// Transfer the configuration file to the server, with backup if changed
|
||||
$commands = ["mkdir -p $proxy_path"];
|
||||
|
||||
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->redirectUrl = data_get($this->server, 'proxy.redirect_url');
|
||||
$this->syncData(false);
|
||||
$this->loadProxyConfiguration();
|
||||
}
|
||||
|
||||
private function syncData(bool $toModel = false): void
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
|
|
@ -215,6 +216,13 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar
|
|||
}
|
||||
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_type = $server->proxyType();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
@php use App\Enums\ProxyTypes; @endphp
|
||||
<div>
|
||||
@if ($server->proxyType())
|
||||
<div x-init="$wire.loadProxyConfiguration">
|
||||
<div>
|
||||
@if ($selectedProxy !== 'NONE')
|
||||
<form wire:submit='submit'>
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -55,10 +55,6 @@
|
|||
<div class="flex items-center gap-2">
|
||||
<h3>{{ $proxyTitle }}</h3>
|
||||
@can('update', $server)
|
||||
<div wire:loading wire:target="loadProxyConfiguration">
|
||||
<x-forms.button disabled>Reset Configuration</x-forms.button>
|
||||
</div>
|
||||
<div wire:loading.remove wire:target="loadProxyConfiguration">
|
||||
@if ($proxySettings)
|
||||
<x-modal-confirmation title="Reset Proxy Configuration?"
|
||||
buttonTitle="Reset Configuration" submitAction="resetProxyConfiguration"
|
||||
|
|
@ -72,7 +68,6 @@
|
|||
:confirmWithPassword="false" :confirmWithText="true">
|
||||
</x-modal-confirmation>
|
||||
@endif
|
||||
</div>
|
||||
@endcan
|
||||
@if ($server->proxyType() === ProxyTypes::TRAEFIK->value)
|
||||
<button type="button" x-show="traefikWarningsDismissed"
|
||||
|
|
@ -134,10 +129,6 @@ class="underline text-white">Traefik changelog</a> to understand breaking change
|
|||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<div wire:loading wire:target="loadProxyConfiguration" class="pt-4">
|
||||
<x-loading text="Loading proxy configuration..." />
|
||||
</div>
|
||||
<div wire:loading.remove wire:target="loadProxyConfiguration">
|
||||
@if ($proxySettings)
|
||||
<div class="flex flex-col gap-2 pt-2">
|
||||
<x-forms.textarea canGate="update" :canResource="$server" useMonacoEditor
|
||||
|
|
@ -146,7 +137,6 @@ class="underline text-white">Traefik changelog</a> to understand breaking change
|
|||
name="proxySettings" id="proxySettings" rows="30" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
@elseif($selectedProxy === 'NONE')
|
||||
<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