From 263630bcf2e2d141d21c959f7680d2209d100539 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:17:01 +0200 Subject: [PATCH] fix(proxy): clear absent letsencrypt resolvers during migration Set the resolver to null when saved Traefik configuration has no active letsencrypt resolver. Preserve existing settings for active or unknown configurations and log parsing failures. Add regression coverage for missing, active, unknown, and malformed configurations. --- ...cate_resolver_to_server_settings_table.php | 62 +++++++++++++++++-- ...raefikCertificateResolverMigrationTest.php | 60 ++++++++++++++++++ 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/TraefikCertificateResolverMigrationTest.php diff --git a/database/migrations/2026_10_11_075120_add_traefik_certificate_resolver_to_server_settings_table.php b/database/migrations/2026_10_11_075120_add_traefik_certificate_resolver_to_server_settings_table.php index bdfb2586f0..f9530b2d98 100644 --- a/database/migrations/2026_10_11_075120_add_traefik_certificate_resolver_to_server_settings_table.php +++ b/database/migrations/2026_10_11_075120_add_traefik_certificate_resolver_to_server_settings_table.php @@ -2,18 +2,42 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; +use Symfony\Component\Yaml\Yaml; return new class extends Migration { /** - * Run the migrations. + * Add the Traefik certificate resolver setting. Servers whose saved Traefik configuration has no + * letsencrypt resolver get no resolver, because Traefik ignored that label and only logged an error. */ public function up(): void { - Schema::table('server_settings', function (Blueprint $table) { - $table->string('traefik_certificate_resolver')->nullable()->default('letsencrypt'); - }); + if (! Schema::hasColumn('server_settings', 'traefik_certificate_resolver')) { + Schema::table('server_settings', function (Blueprint $table) { + $table->string('traefik_certificate_resolver')->nullable()->default('letsencrypt'); + }); + } + + DB::table('servers') + ->select(['id', 'proxy']) + ->orderBy('id') + ->chunkById(100, function ($servers): void { + foreach ($servers as $server) { + try { + if ($this->hasNoLetsEncryptResolver($server->proxy)) { + DB::table('server_settings') + ->where('server_id', $server->id) + ->where('traefik_certificate_resolver', 'letsencrypt') + ->update(['traefik_certificate_resolver' => null]); + } + } catch (Throwable $e) { + Log::warning("Could not check the Traefik certificate resolver of server {$server->id}: {$e->getMessage()}"); + } + } + }); } /** @@ -25,4 +49,34 @@ return new class extends Migration $table->dropColumn('traefik_certificate_resolver'); }); } + + /** + * True only when the saved configuration is a Traefik compose file without an active + * --certificatesresolvers.letsencrypt.* option. Unknown or missing configurations return false. + */ + private function hasNoLetsEncryptResolver(?string $proxy): bool + { + $proxy = json_decode($proxy ?? '', true); + if (strtoupper((string) data_get($proxy, 'type')) !== 'TRAEFIK') { + return false; + } + + $configuration = data_get($proxy, 'last_saved_proxy_configuration'); + if (blank($configuration)) { + return false; + } + + $traefik = collect(data_get(Yaml::parse($configuration), 'services', [])) + ->first(fn ($service) => is_array($service) && str_contains((string) data_get($service, 'image'), 'traefik')); + if (! $traefik) { + return false; + } + + $command = data_get($traefik, 'command', []); + $options = is_array($command) ? $command : preg_split('/\s+/', (string) $command); + + return collect($options)->doesntContain( + fn ($option) => is_string($option) && str_starts_with(strtolower(trim($option, " \t'\"")), '--certificatesresolvers.letsencrypt.') + ); + } }; diff --git a/tests/Feature/TraefikCertificateResolverMigrationTest.php b/tests/Feature/TraefikCertificateResolverMigrationTest.php new file mode 100644 index 0000000000..6d9e6819e9 --- /dev/null +++ b/tests/Feature/TraefikCertificateResolverMigrationTest.php @@ -0,0 +1,60 @@ + 0]); + $this->team = Team::factory()->create(); +}); + +function traefikResolverMigrationCompose(array $commands, string $image = 'traefik:v3.6'): string +{ + $lines = collect($commands)->map(fn (string $command) => " - '{$command}'")->implode("\n"); + + return "name: coolify-proxy\nservices:\n traefik:\n image: '{$image}'\n command:\n{$lines}\n"; +} + +function traefikResolverMigrationServer(Team $team, array $proxy): Server +{ + $server = Server::factory()->create(['team_id' => $team->id, 'proxy' => $proxy]); + expect($server->settings->fresh()->traefik_certificate_resolver)->toBe('letsencrypt'); + + return $server; +} + +function runTraefikCertificateResolverMigration(): void +{ + (require database_path('migrations/2026_10_11_075120_add_traefik_certificate_resolver_to_server_settings_table.php'))->up(); +} + +test('clears the resolver when the saved Traefik configuration has no letsencrypt resolver', function () { + $compose = traefikResolverMigrationCompose(['--providers.file.directory=/traefik/dynamic/', '--entrypoints.https.address=:443']) + ."# --certificatesresolvers.letsencrypt.acme.httpchallenge=true\n"; + $server = traefikResolverMigrationServer($this->team, ['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => $compose]); + + runTraefikCertificateResolverMigration(); + + expect($server->settings->fresh()->traefik_certificate_resolver)->toBeNull(); +}); + +test('keeps letsencrypt when the resolver is active or the configuration is unknown', function (array $proxy) { + $server = traefikResolverMigrationServer($this->team, $proxy); + + runTraefikCertificateResolverMigration(); + + expect($server->settings->fresh()->traefik_certificate_resolver)->toBe('letsencrypt'); +})->with([ + 'active resolver' => [['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => traefikResolverMigrationCompose([ + '--providers.file.directory=/traefik/dynamic/', + '--certificatesresolvers.letsencrypt.acme.httpchallenge=true', + ])]], + 'no saved configuration' => [['type' => 'TRAEFIK']], + 'caddy proxy' => [['type' => 'CADDY', 'last_saved_proxy_configuration' => "services:\n caddy:\n image: 'lucaslorentz/caddy-docker-proxy:2.8-alpine'\n"]], + 'invalid yaml' => [['type' => 'TRAEFIK', 'last_saved_proxy_configuration' => "services: [\n"]], +]);