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"]], +]);