Compare commits

..

7 commits

Author SHA1 Message Date
rosslh
550d234eaa chore(ci): use Forgejo build and CDN publishing
All checks were successful
Build MapleDeploy Coolify Image / build (push) Successful in 1m10s
2026-07-25 00:00:46 -04:00
rosslh
b1d00b6637 feat(auth): add dashboard-managed Coolify access 2026-07-25 00:00:46 -04:00
rosslh
600148e72c fix(dns): use Canadian Shield DNS defaults 2026-07-24 23:58:20 -04:00
rosslh
34bb7d1039 fix(telemetry): disable upstream telemetry 2026-07-24 23:58:20 -04:00
rosslh
98dc9a3375 fix(update): use MapleDeploy CDN and registry artifacts 2026-07-24 23:58:20 -04:00
rosslh
07ac994f25 style(theme): apply MapleDeploy palette and fonts 2026-07-24 23:57:17 -04:00
rosslh
f81bbed7ec feat(branding): apply MapleDeploy UI branding 2026-07-24 23:57:17 -04:00
9 changed files with 28 additions and 401 deletions

View file

@ -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));
}
}

View file

@ -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());
}
}

View file

@ -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());
}

View file

@ -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) {

View file

@ -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' => [

View file

@ -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();
});

View file

@ -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();
});

View file

@ -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();

View file

@ -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();
});