mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 06:23:23 +00:00
feat(services): shared Redis cache for service templates (#11094)
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user