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.
This commit is contained in:
Andras Bacsai
2026-10-11 11:17:01 +02:00
parent af589f6da4
commit 263630bcf2
2 changed files with 118 additions and 4 deletions
@@ -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.')
);
}
};
@@ -0,0 +1,60 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 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"]],
]);