diff --git a/.env.development.example b/.env.development.example index e892f92ba3..f53fcce5f7 100644 --- a/.env.development.example +++ b/.env.development.example @@ -45,3 +45,6 @@ VITE_PORT=5173 DUSK_DRIVER_URL=http://selenium:4444 # Special Keys for Andras +# For Cloudflare cache purging (token needs Zone > Cache Purge) +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ZONE_ID= diff --git a/app/Console/Commands/SyncCdn.php b/app/Console/Commands/SyncCdn.php index 4f955f0d75..3e7be78b4a 100644 --- a/app/Console/Commands/SyncCdn.php +++ b/app/Console/Commands/SyncCdn.php @@ -16,7 +16,7 @@ class SyncCdn extends Command * * @var string */ - protected $signature = 'sync:cdn'; + protected $signature = 'sync:cdn {--purge : Purge the Cloudflare cache for the CDN files}'; /** * The console command description. @@ -42,6 +42,49 @@ class SyncCdn extends Command File::deleteDirectory($temporaryDirectory); } + /** + * Purge the given CDN URLs from the Cloudflare cache. + * + * @param array $urls + */ + private function purgeCloudflareCache(array $urls): bool + { + $apiToken = config('constants.cloudflare.api_token'); + $zoneId = config('constants.cloudflare.zone_id'); + + if (blank($apiToken) || blank($zoneId)) { + $this->error('CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID must be set to purge the Cloudflare cache.'); + + return false; + } + + foreach ($urls as $url) { + $this->info('Purging: '.$url); + } + + try { + $response = Http::withToken($apiToken) + ->timeout(30) + ->post("https://api.cloudflare.com/client/v4/zones/{$zoneId}/purge_cache", [ + 'files' => array_values($urls), + ]); + } catch (\Throwable $e) { + $this->error('Failed to purge Cloudflare cache: '.$e->getMessage()); + + return false; + } + + if (! $response->successful() || $response->json('success') !== true) { + $this->error('Failed to purge Cloudflare cache: '.$response->body()); + + return false; + } + + $this->info('Cloudflare cache purged.'); + + return true; + } + /** * Fetch GitHub releases and sync to GitHub repository */ @@ -251,6 +294,7 @@ class SyncCdn extends Command ], default: 'production', ) === 'nightly'; + $cdn = 'https://cdn.coollabs.io'; $parent_dir = realpath(dirname(__FILE__).'/../../..'); @@ -282,9 +326,6 @@ class SyncCdn extends Command $install_script_location = "$parent_dir/other/nightly/$install_script"; $versions_location = "$parent_dir/other/nightly/$versions"; } - $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; - $this->info("About to sync $envLabel releases, versions, compose, and environment files to GitHub repository."); - if ($nightly) { $files = [ $versions_location => 'json/coolify/nightly/versions.json', @@ -310,6 +351,23 @@ class SyncCdn extends Command } $releasesTarget = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json'; + + if ($this->option('purge')) { + $urls = array_map( + fn (string $targetPath) => $cdn.'/'.substr($targetPath, strlen('json/')), + [$releasesTarget, ...array_values($files)], + ); + if ($nightly) { + // Nightly scripts still download from the legacy /coolify-nightly/ path, which Cloudflare caches separately. + $urls = [...$urls, ...str_replace('/coolify/nightly/', '/coolify-nightly/', $urls)]; + } + + return $this->purgeCloudflareCache($urls) ? self::SUCCESS : self::FAILURE; + } + + $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; + $this->info("About to sync $envLabel releases, versions, compose, and environment files to GitHub repository."); + $options = [$releasesTarget, ...array_values($files)]; $selectedFiles = multiselect( label: 'Which files would you like to sync?', @@ -325,10 +383,12 @@ class SyncCdn extends Command fn (string $targetPath) => in_array($targetPath, $selectedFiles, true), ); - if ($includeReleases) { - $this->syncReleasesToGitHubRepo($files, $nightly); - } else { - $this->syncFilesToGitHubRepo($files, $nightly); + $synced = $includeReleases + ? $this->syncReleasesToGitHubRepo($files, $nightly) + : $this->syncFilesToGitHubRepo($files, $nightly); + + if ($synced) { + $this->info('After the CDN pull request is merged and deployed, run "php artisan sync:cdn --purge" to purge the Cloudflare cache.'); } } catch (\Throwable $e) { $this->error('Error: '.$e->getMessage()); diff --git a/config/constants.php b/config/constants.php index 5d5d84c2d9..b2c1221c05 100644 --- a/config/constants.php +++ b/config/constants.php @@ -136,6 +136,11 @@ return [ 'dev_webhook' => env('SERVEO_URL'), ], + 'cloudflare' => [ + 'api_token' => env('CLOUDFLARE_API_TOKEN'), + 'zone_id' => env('CLOUDFLARE_ZONE_ID'), + ], + 'server_checks' => [ // Notification delay configuration for parallel server checks // Used for Traefik version checks and other future server check jobs diff --git a/tests/Feature/SyncCdnTest.php b/tests/Feature/SyncCdnTest.php index 395f1f08eb..00bed238c7 100644 --- a/tests/Feature/SyncCdnTest.php +++ b/tests/Feature/SyncCdnTest.php @@ -150,3 +150,61 @@ SH); 'service-templates-latest.json', ]], ]); + +it('purges the CDN files from the Cloudflare cache', function (string $environment, array $cdnDirectories) { + config()->set('constants.cloudflare.api_token', 'test-token'); + config()->set('constants.cloudflare.zone_id', 'test-zone'); + + Http::fake([ + 'https://api.cloudflare.com/*' => Http::response(['success' => true], 200), + ]); + + $this->artisan('sync:cdn --purge') + ->expectsChoice('Which environment would you like to sync?', $environment, [ + 'production' => 'Production', + 'nightly' => 'Nightly', + ]) + ->assertExitCode(0); + + Http::assertSentCount(1); + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache' + && $request->hasHeader('Authorization', 'Bearer test-token') + && $request['files'] === collect($cdnDirectories)->flatMap(fn (string $cdnDirectory) => array_map(fn (string $file) => "https://cdn.coollabs.io/$cdnDirectory/$file", [ + 'releases.json', + 'versions.json', + 'docker-compose.yml', + 'docker-compose.prod.yml', + '.env.production', + 'install.sh', + 'upgrade.sh', + 'upgrade-postgres.sh', + 'service-templates-latest.json', + ]))->all()); +})->with([ + 'production' => ['production', ['coolify']], + 'nightly with legacy path' => ['nightly', ['coolify/nightly', 'coolify-nightly']], +]); + +it('fails the Cloudflare purge without credentials or on an API error', function (?string $apiToken, array $response) { + config()->set('constants.cloudflare.api_token', $apiToken); + config()->set('constants.cloudflare.zone_id', 'test-zone'); + + Http::fake([ + 'https://api.cloudflare.com/*' => Http::response($response, 400), + ]); + + $this->artisan('sync:cdn --purge') + ->expectsChoice('Which environment would you like to sync?', 'production', [ + 'production' => 'Production', + 'nightly' => 'Nightly', + ]) + ->assertExitCode(1); + + if ($apiToken === null) { + Http::assertNothingSent(); + } +})->with([ + 'missing token' => [null, []], + 'api error' => ['test-token', ['success' => false, 'errors' => [['code' => 10000, 'message' => 'Authentication error']]]], +]);