feat(services): shared Redis cache for service templates (#11094)

This commit is contained in:
Andras Bacsai
2026-07-30 22:27:25 +02:00
committed by GitHub
7 changed files with 259 additions and 27 deletions
+5 -4
View File
@@ -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());
}
}
+15 -5
View File
@@ -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());
}
}
+7
View File
@@ -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());
}
+73 -4
View File
@@ -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) {
+2
View File
@@ -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' => [
@@ -0,0 +1,136 @@
<?php
use App\Jobs\PullTemplatesFromCDN;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
beforeEach(function () {
Cache::flush();
});
it('stores the CDN service templates bundle in shared cache and local file', function () {
$payload = [
'buzz' => [
'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();
});
@@ -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();