From a460a399d7b05e9e28bbe47451ec721730ab05a7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:15:39 +0200 Subject: [PATCH] chore: prepare for PR --- app/Console/Commands/Init.php | 9 +- app/Jobs/PullTemplatesFromCDN.php | 20 ++- app/Livewire/Project/New/Select.php | 7 + bootstrap/helpers/shared.php | 77 +++++++++- config/constants.php | 2 + .../PullServiceTemplatesFromCdnTest.php | 136 ++++++++++++++++++ .../ServiceTemplatesLastUpdatedHintTest.php | 35 +++-- 7 files changed, 259 insertions(+), 27 deletions(-) create mode 100644 tests/Feature/PullServiceTemplatesFromCdnTest.php diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index 4783df072..da379be23 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -18,7 +18,6 @@ use App\Models\User; use Carbon\Carbon; use Illuminate\Console\Command; use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; class Init extends Command @@ -161,10 +160,12 @@ class Init extends Command private function pullTemplatesFromCDN() { - $response = Http::retry(3, 1000)->get(config('constants.services.official')); + $response = Http::retry(3, 1000, throw: false) + ->timeout(60) + ->connectTimeout(10) + ->get(config('constants.services.official')); if ($response->successful()) { - $services = $response->json(); - File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services)); + store_service_templates_bundle($response->body()); } } diff --git a/app/Jobs/PullTemplatesFromCDN.php b/app/Jobs/PullTemplatesFromCDN.php index 7e6b2e21a..e8c655449 100644 --- a/app/Jobs/PullTemplatesFromCDN.php +++ b/app/Jobs/PullTemplatesFromCDN.php @@ -8,14 +8,14 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - public $timeout = 10; + public $timeout = 60; public function __construct() { @@ -28,14 +28,24 @@ class PullTemplatesFromCDN implements ShouldBeEncrypted, ShouldQueue if (isDev()) { return; } - $response = Http::retry(3, 1000)->get(config('constants.services.official')); + $response = Http::retry(3, 1000, throw: false) + ->timeout(60) + ->connectTimeout(10) + ->get(config('constants.services.official')); if ($response->successful()) { - $services = $response->json(); - File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services)); + // Shared cache so Cloud HTTP nodes see the same bundle Horizon pulled. + store_service_templates_bundle($response->body()); } else { + Log::error('PullTemplatesFromCDN failed', [ + 'status' => $response->status(), + 'body' => str($response->body())->limit(500)->toString(), + ]); send_internal_notification('PullTemplatesAndVersions failed with: '.$response->status().' '.$response->body()); } } catch (\Throwable $e) { + Log::error('PullTemplatesFromCDN exception', [ + 'message' => $e->getMessage(), + ]); send_internal_notification('PullTemplatesAndVersions failed with: '.$e->getMessage()); } } diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 08047fc79..3a97bcc40 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -280,6 +280,13 @@ class Select extends Component private function serviceTemplatesLastUpdated(): ?string { + $fetchedAt = get_service_templates_fetched_at(); + if ($fetchedAt instanceof CarbonImmutable) { + return $fetchedAt + ->timezone(config('app.timezone')) + ->format('M j, Y H:i'); + } + return $this->formatLastModified($this->serviceTemplatesPath()); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 92f995728..479b2b051 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1263,23 +1263,92 @@ function sslip(Server $server) return "http://{$server->ip}.sslip.io"; } +function service_templates_cache_key(): string +{ + return (string) config('constants.services.cache_key', 'coolify:service-templates-bundle'); +} + +function service_templates_path(): string +{ + return base_path('templates/'.config('constants.services.file_name')); +} + +/** + * Persist the CDN service-templates bundle to local disk and shared cache. + * + * The shared cache entry is what multi-node Cloud relies on: Horizon (or any + * single node) pulls once; every HTTP node reads the same Redis payload. + */ +function store_service_templates_bundle(string $json, ?string $fetchedAt = null): bool +{ + $fetchedAt ??= now()->toIso8601String(); + $path = service_templates_path(); + + $written = File::put($path, $json) !== false; + + Cache::forever(service_templates_cache_key(), [ + 'fetched_at' => $fetchedAt, + 'json' => $json, + ]); + + return $written; +} + +function get_service_templates_fetched_at(): ?CarbonImmutable +{ + $bundle = Cache::get(service_templates_cache_key()); + if (is_array($bundle) && filled(data_get($bundle, 'fetched_at'))) { + try { + return CarbonImmutable::parse((string) data_get($bundle, 'fetched_at')); + } catch (Throwable) { + // fall through to local file mtime + } + } + + $path = service_templates_path(); + if (File::exists($path)) { + $mtime = filemtime($path); + if ($mtime !== false) { + return CarbonImmutable::createFromTimestamp($mtime); + } + } + + return null; +} + function get_service_templates(bool $force = false): Collection { if ($force) { try { - $response = Http::retry(3, 1000)->get(config('constants.services.official')); + $response = Http::retry(3, 1000, throw: false) + ->timeout(60) + ->connectTimeout(10) + ->get(config('constants.services.official')); if ($response->failed()) { return collect([]); } - $services = $response->json(); + store_service_templates_bundle($response->body()); - return collect($services); + return collect(json_decode($response->body()))->sortKeys(); } catch (Throwable) { return get_service_templates(); } } - $path = base_path('templates/'.config('constants.services.file_name')); + $bundle = Cache::get(service_templates_cache_key()); + if (is_array($bundle) && is_string(data_get($bundle, 'json')) && data_get($bundle, 'json') !== '') { + $fetchedAt = (string) data_get($bundle, 'fetched_at', '0'); + + return Cache::remember("service-templates:shared:{$fetchedAt}", now()->addDay(), function () use ($bundle) { + return collect(json_decode((string) data_get($bundle, 'json')))->sortKeys(); + }); + } + + $path = service_templates_path(); + if (! File::exists($path)) { + return collect([]); + } + $mtime = filemtime($path) ?: 0; return Cache::remember("service-templates:{$mtime}", now()->addDay(), function () use ($path) { diff --git a/config/constants.php b/config/constants.php index 290ce3f95..c572a653c 100644 --- a/config/constants.php +++ b/config/constants.php @@ -27,6 +27,8 @@ return [ 'services' => [ 'official' => 'https://cdn.coollabs.io/coolify/service-templates-latest.json', 'file_name' => 'service-templates-latest.json', + // Shared across HTTP/Horizon nodes when CACHE_DRIVER is redis (default). + 'cache_key' => 'coolify:service-templates-bundle', ], 'terminal' => [ diff --git a/tests/Feature/PullServiceTemplatesFromCdnTest.php b/tests/Feature/PullServiceTemplatesFromCdnTest.php new file mode 100644 index 000000000..848ad44b6 --- /dev/null +++ b/tests/Feature/PullServiceTemplatesFromCdnTest.php @@ -0,0 +1,136 @@ + [ + 'category' => 'messaging', + 'documentation' => 'https://github.com/block/buzz', + 'compose' => '', + 'slogan' => 'Buzz', + 'tags' => null, + 'logo' => 'svgs/buzz.svg', + 'minversion' => '0.0.0', + 'template_last_updated_at' => '2026-07-23T18:52:29+02:00', + ], + 'activepieces' => [ + 'category' => 'automation', + 'documentation' => 'https://coolify.io/docs', + 'compose' => '', + 'slogan' => 'Activepieces', + 'tags' => null, + 'logo' => 'images/default.webp', + 'minversion' => '0.0.0', + ], + ]; + $json = json_encode($payload, JSON_THROW_ON_ERROR); + + Http::fake([ + config('constants.services.official') => Http::response($json, 200, ['Content-Type' => 'application/json']), + ]); + + $path = service_templates_path(); + $original = File::exists($path) ? File::get($path) : null; + + try { + config(['app.env' => 'production']); + + (new PullTemplatesFromCDN)->handle(); + + $bundle = Cache::get(service_templates_cache_key()); + + expect($bundle) + ->toBeArray() + ->and($bundle)->toHaveKeys(['fetched_at', 'json']) + ->and($bundle['json'])->toBe($json) + ->and(File::get($path))->toBe($json) + ->and(get_service_templates()->has('buzz'))->toBeTrue(); + } finally { + if ($original === null) { + if (File::exists($path)) { + File::delete($path); + } + } else { + File::put($path, $original); + } + Cache::forget(service_templates_cache_key()); + } +}); + +it('serves templates from shared cache when the local file is stale', function () { + $stalePath = service_templates_path(); + $original = File::exists($stalePath) ? File::get($stalePath) : null; + + $stale = json_encode(['oldservice' => ['category' => 'other', 'compose' => '']], JSON_THROW_ON_ERROR); + $fresh = json_encode([ + 'buzz' => ['category' => 'messaging', 'compose' => '', 'slogan' => 'Buzz'], + 'newservice' => ['category' => 'other', 'compose' => ''], + ], JSON_THROW_ON_ERROR); + + try { + File::put($stalePath, $stale); + + Cache::forever(service_templates_cache_key(), [ + 'fetched_at' => now()->toIso8601String(), + 'json' => $fresh, + ]); + + $templates = get_service_templates(); + + expect($templates->has('buzz'))->toBeTrue() + ->and($templates->has('newservice'))->toBeTrue() + ->and($templates->has('oldservice'))->toBeFalse(); + } finally { + if ($original === null) { + if (File::exists($stalePath)) { + File::delete($stalePath); + } + } else { + File::put($stalePath, $original); + } + Cache::forget(service_templates_cache_key()); + } +}); + +it('falls back to the local file when shared cache is empty', function () { + Cache::forget(service_templates_cache_key()); + + $templates = get_service_templates(); + + expect($templates)->not->toBeEmpty(); +}); + +it('skips pulling templates in local development', function () { + Http::fake(); + config(['app.env' => 'local']); + + (new PullTemplatesFromCDN)->handle(); + + Http::assertNothingSent(); + expect(Cache::get(service_templates_cache_key()))->toBeNull(); +}); + +it('logs when the CDN responds with a non-success status', function () { + Http::fake([ + 'cdn.coollabs.io/*' => Http::response('nope', 503), + ]); + config(['app.env' => 'production']); + + Log::shouldReceive('error') + ->once() + ->withArgs(fn (string $message, array $context = []) => $message === 'PullTemplatesFromCDN failed' + && data_get($context, 'status') === 503); + + (new PullTemplatesFromCDN)->handle(); + + expect(Cache::get(service_templates_cache_key()))->toBeNull(); +}); diff --git a/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php b/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php index f839c023c..0f01d1272 100644 --- a/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php +++ b/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php @@ -37,20 +37,27 @@ it('returns each service template last updated timestamp from the generated bund }); it('prefers embedded service template git timestamps from the templates bundle', function () { - File::shouldReceive('get') - ->with(base_path('templates/'.config('constants.services.file_name'))) - ->andReturn(json_encode([ - 'activepieces' => [ - 'documentation' => 'https://coolify.io/docs', - 'slogan' => 'Open source no-code business automation.', - 'compose' => '', - 'tags' => null, - 'category' => 'automation', - 'logo' => 'images/default.webp', - 'minversion' => '0.0.0', - 'template_last_updated_at' => '2026-05-31T12:34:56+00:00', - ], - ])); + $path = base_path('templates/'.config('constants.services.file_name')); + $payload = json_encode([ + 'activepieces' => [ + 'documentation' => 'https://coolify.io/docs', + 'slogan' => 'Open source no-code business automation.', + 'compose' => '', + 'tags' => null, + 'category' => 'automation', + 'logo' => 'images/default.webp', + 'minversion' => '0.0.0', + 'template_last_updated_at' => '2026-05-31T12:34:56+00:00', + ], + ]); + + File::partialMock() + ->shouldReceive('exists') + ->with($path) + ->andReturn(true) + ->shouldReceive('get') + ->with($path) + ->andReturn($payload); $resources = (new Select)->loadServices();