Compare commits
7 commits
06f5ce1703
...
550d234eaa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
550d234eaa | ||
|
|
b1d00b6637 | ||
|
|
600148e72c | ||
|
|
34bb7d1039 | ||
|
|
98dc9a3375 | ||
|
|
07ac994f25 | ||
|
|
f81bbed7ec |
9 changed files with 28 additions and 401 deletions
|
|
@ -18,6 +18,7 @@
|
|||
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
|
||||
|
|
@ -160,12 +161,10 @@ private function pullHelperImage()
|
|||
|
||||
private function pullTemplatesFromCDN()
|
||||
{
|
||||
$response = Http::retry(3, 1000, throw: false)
|
||||
->timeout(60)
|
||||
->connectTimeout(10)
|
||||
->get(config('constants.services.official'));
|
||||
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
||||
if ($response->successful()) {
|
||||
store_service_templates_bundle($response->body());
|
||||
$services = $response->json();
|
||||
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@
|
|||
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 = 60;
|
||||
public $timeout = 10;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
|
|
@ -28,24 +28,14 @@ public function handle(): void
|
|||
if (isDev()) {
|
||||
return;
|
||||
}
|
||||
$response = Http::retry(3, 1000, throw: false)
|
||||
->timeout(60)
|
||||
->connectTimeout(10)
|
||||
->get(config('constants.services.official'));
|
||||
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
||||
if ($response->successful()) {
|
||||
// Shared cache so Cloud HTTP nodes see the same bundle Horizon pulled.
|
||||
store_service_templates_bundle($response->body());
|
||||
$services = $response->json();
|
||||
File::put(base_path('templates/'.config('constants.services.file_name')), json_encode($services));
|
||||
} 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,13 +280,6 @@ public function instantSave()
|
|||
|
||||
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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1264,92 +1264,23 @@ 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, throw: false)
|
||||
->timeout(60)
|
||||
->connectTimeout(10)
|
||||
->get(config('constants.services.official'));
|
||||
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
|
||||
if ($response->failed()) {
|
||||
return collect([]);
|
||||
}
|
||||
store_service_templates_bundle($response->body());
|
||||
$services = $response->json();
|
||||
|
||||
return collect(json_decode($response->body()))->sortKeys();
|
||||
return collect($services);
|
||||
} catch (Throwable) {
|
||||
return get_service_templates();
|
||||
}
|
||||
}
|
||||
|
||||
$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([]);
|
||||
}
|
||||
|
||||
$path = base_path('templates/'.config('constants.services.file_name'));
|
||||
$mtime = filemtime($path) ?: 0;
|
||||
|
||||
return Cache::remember("service-templates:{$mtime}", now()->addDay(), function () use ($path) {
|
||||
|
|
@ -3873,17 +3804,8 @@ function loggy($message = null, array $context = [])
|
|||
|
||||
return app('log')->debug($message, $context);
|
||||
}
|
||||
/**
|
||||
* Warn when any domain uses HTTPS with an sslip hostname.
|
||||
*
|
||||
* Empty/null domain lists are valid (domains removed) and produce no warning.
|
||||
*/
|
||||
function sslipDomainWarning(?string $domains): bool
|
||||
function sslipDomainWarning(string $domains)
|
||||
{
|
||||
if (blank($domains)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$domains = str($domains)->trim()->explode(',');
|
||||
$showSslipHttpsWarning = false;
|
||||
$domains->each(function ($domain) use (&$showSslipHttpsWarning) {
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@
|
|||
'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' => [
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\General;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0], []);
|
||||
});
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first()
|
||||
?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression for #11079: removing all domains must save without TypeError from
|
||||
* sslipDomainWarning(null) after normalizeApplicationDomains returns null.
|
||||
*/
|
||||
test('can clear application domains and save successfully', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'nixpacks',
|
||||
'fqdn' => 'https://example.com',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'ports_exposes' => '3000',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->set('fqdn', null)
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertNotDispatched('error')
|
||||
->assertDispatched('success');
|
||||
|
||||
$application->refresh();
|
||||
expect($application->fqdn)->toBeNull();
|
||||
});
|
||||
|
||||
test('can clear application domains via empty string and save successfully', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'nixpacks',
|
||||
'fqdn' => 'https://app.example.com,https://www.example.com',
|
||||
'static_image' => 'nginx:alpine',
|
||||
'base_directory' => '/',
|
||||
'ports_exposes' => '3000',
|
||||
'is_http_basic_auth_enabled' => false,
|
||||
'redirect' => 'no',
|
||||
]);
|
||||
|
||||
Livewire::test(General::class, ['application' => $application])
|
||||
->assertSuccessful()
|
||||
->set('fqdn', '')
|
||||
->call('submit')
|
||||
->assertHasNoErrors()
|
||||
->assertNotDispatched('error')
|
||||
->assertDispatched('success');
|
||||
|
||||
$application->refresh();
|
||||
expect($application->fqdn)->toBeNull();
|
||||
});
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
<?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,27 +37,20 @@
|
|||
});
|
||||
|
||||
it('prefers embedded service template git timestamps from the templates bundle', function () {
|
||||
$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);
|
||||
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',
|
||||
],
|
||||
]));
|
||||
|
||||
$resources = (new Select)->loadServices();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Regression for #11079 / #11030: clearing domains normalizes to null, then
|
||||
* sslipDomainWarning must accept null without a TypeError.
|
||||
*/
|
||||
it('returns false when domains are null', function () {
|
||||
expect(sslipDomainWarning(null))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false when domains are empty or whitespace', function () {
|
||||
expect(sslipDomainWarning(''))->toBeFalse();
|
||||
expect(sslipDomainWarning(' '))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false for non-sslip https domains', function () {
|
||||
expect(sslipDomainWarning('https://example.com'))->toBeFalse();
|
||||
expect(sslipDomainWarning('http://app.example.com,https://www.example.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false for http sslip domains without https', function () {
|
||||
expect(sslipDomainWarning('http://app.127.0.0.1.sslip.io'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns true when any domain uses https with sslip', function () {
|
||||
expect(sslipDomainWarning('https://app.127.0.0.1.sslip.io'))->toBeTrue();
|
||||
expect(sslipDomainWarning('https://example.com,https://app.127.0.0.1.sslip.io'))->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in a new issue