Merge branch 'next' into fix-traefik-startup

This commit is contained in:
Andras Bacsai
2025-11-28 17:54:48 +01:00
committed by GitHub
12 changed files with 290 additions and 50 deletions

View File

@@ -1652,6 +1652,10 @@ class ApplicationsController extends Controller
$service->save(); $service->save();
$service->parse(isNew: true); $service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
if ($instantDeploy) { if ($instantDeploy) {
StartService::dispatch($service); StartService::dispatch($service);
} }

View File

@@ -376,6 +376,10 @@ class ServicesController extends Controller
}); });
} }
$service->parse(isNew: true); $service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
if ($instantDeploy) { if ($instantDeploy) {
StartService::dispatch($service); StartService::dispatch($service);
} }

View File

@@ -74,6 +74,9 @@ class DockerCompose extends Component
} }
$service->parse(isNew: true); $service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
return redirect()->route('project.service.configuration', [ return redirect()->route('project.service.configuration', [
'service_uuid' => $service->uuid, 'service_uuid' => $service->uuid,
'environment_uuid' => $environment->uuid, 'environment_uuid' => $environment->uuid,

View File

@@ -102,33 +102,16 @@ class Create extends Component
} }
}); });
} }
$service->parse(isNew: true); $service->parse(isNew: true);
// For Beszel service disable gzip (fixes realtime not working issue) // Apply service-specific application prerequisites
if ($oneClickServiceName === 'beszel') { applyServiceApplicationPrerequisites($service);
$appService = $service->applications()->whereName('beszel')->first();
if ($appService) {
$appService->is_gzip_enabled = false;
$appService->save();
}
}
// For Appwrite services, disable strip prefix for services that handle domain requests
if ($oneClickServiceName === 'appwrite') {
$servicesToDisableStripPrefix = ['appwrite', 'appwrite-console', 'appwrite-realtime'];
foreach ($servicesToDisableStripPrefix as $serviceName) {
$appService = $service->applications()->whereName($serviceName)->first();
if ($appService) {
$appService->is_stripprefix_enabled = false;
$appService->save();
}
}
}
return redirect()->route('project.service.configuration', [ return redirect()->route('project.service.configuration', [
'service_uuid' => $service->uuid, 'service_uuid' => $service->uuid,
'environment_uuid' => $environment->uuid, 'environment_uuid' => $environment->uuid,
'project_uuid' => $project->uuid, 'project_uuid' => $project->uuid,
]); ]);
} }
} }
$this->type = $type->value(); $this->type = $type->value();

View File

@@ -91,7 +91,7 @@ class Proxy extends Component
public function getConfigurationFilePathProperty(): string public function getConfigurationFilePathProperty(): string
{ {
return rtrim($this->server->proxyPath(), '/') . '/docker-compose.yml'; return rtrim($this->server->proxyPath(), '/').'/docker-compose.yml';
} }
public function changeProxy() public function changeProxy()

View File

@@ -8,8 +8,6 @@ use Livewire\Component;
class Upgrade extends Component class Upgrade extends Component
{ {
public bool $showProgress = false;
public bool $updateInProgress = false; public bool $updateInProgress = false;
public bool $isUpgradeAvailable = false; public bool $isUpgradeAvailable = false;

View File

@@ -71,4 +71,10 @@ const NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK = [
'pgadmin', 'pgadmin',
'postgresus', 'postgresus',
]; ];
const NEEDS_TO_DISABLE_GZIP = [
'beszel' => ['beszel'],
];
const NEEDS_TO_DISABLE_STRIPPREFIX = [
'appwrite' => ['appwrite', 'appwrite-console', 'appwrite-realtime'],
];
const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment']; const SHARED_VARIABLE_TYPES = ['team', 'project', 'environment'];

View File

@@ -4,6 +4,7 @@ use App\Models\Application;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Stringable; use Illuminate\Support\Stringable;
use Spatie\Url\Url; use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
@@ -339,3 +340,54 @@ function parseServiceEnvironmentVariable(string $key): array
'has_port' => $hasPort, 'has_port' => $hasPort,
]; ];
} }
/**
* Apply service-specific application prerequisites after service parse.
*
* This function configures application-level settings that are required for
* specific one-click services to work correctly (e.g., disabling gzip for Beszel,
* disabling strip prefix for Appwrite services).
*
* Must be called AFTER $service->parse() since it requires applications to exist.
*
* @param Service $service The service to apply prerequisites to
*/
function applyServiceApplicationPrerequisites(Service $service): void
{
try {
// Extract service name from service name (format: "servicename-uuid")
$serviceName = str($service->name)->beforeLast('-')->value();
// Apply gzip disabling if needed
if (array_key_exists($serviceName, NEEDS_TO_DISABLE_GZIP)) {
$applicationNames = NEEDS_TO_DISABLE_GZIP[$serviceName];
foreach ($applicationNames as $applicationName) {
$application = $service->applications()->whereName($applicationName)->first();
if ($application) {
$application->is_gzip_enabled = false;
$application->save();
}
}
}
// Apply stripprefix disabling if needed
if (array_key_exists($serviceName, NEEDS_TO_DISABLE_STRIPPREFIX)) {
$applicationNames = NEEDS_TO_DISABLE_STRIPPREFIX[$serviceName];
foreach ($applicationNames as $applicationName) {
$application = $service->applications()->whereName($applicationName)->first();
if ($application) {
$application->is_stripprefix_enabled = false;
$application->save();
}
}
}
} catch (\Throwable $e) {
// Log error but don't throw - prerequisites are nice-to-have, not critical
Log::error('Failed to apply service application prerequisites', [
'service_id' => $service->id,
'service_name' => $service->name,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}

View File

@@ -93,6 +93,10 @@
modalOpen: false, modalOpen: false,
showProgress: false, showProgress: false,
currentStatus: '', currentStatus: '',
checkHealthInterval: null,
checkIfIamDeadInterval: null,
healthCheckAttempts: 0,
startTime: null,
confirmed() { confirmed() {
this.showProgress = true; this.showProgress = true;
this.$wire.$call('upgrade') this.$wire.$call('upgrade')
@@ -102,43 +106,78 @@
event.returnValue = ''; event.returnValue = '';
}); });
}, },
getReviveStatusMessage(elapsedMinutes, attempts) {
if (elapsedMinutes === 0) {
return `Waiting for Coolify to come back online... (attempt ${attempts})`;
} else if (elapsedMinutes < 2) {
return `Waiting for Coolify to come back online... (${elapsedMinutes} minute${elapsedMinutes !== 1 ? 's' : ''} elapsed)`;
} else if (elapsedMinutes < 5) {
return `Update in progress, this may take several minutes... (${elapsedMinutes} minutes elapsed)`;
} else if (elapsedMinutes < 10) {
return `Large updates can take 10+ minutes. Please be patient... (${elapsedMinutes} minutes elapsed)`;
} else {
return `Still updating. If this takes longer than 15 minutes, please check server logs... (${elapsedMinutes} minutes elapsed)`;
}
},
revive() { revive() {
if (checkHealthInterval) return true; if (this.checkHealthInterval) return true;
this.healthCheckAttempts = 0;
this.startTime = Date.now();
console.log('Checking server\'s health...') console.log('Checking server\'s health...')
checkHealthInterval = setInterval(() => { this.checkHealthInterval = setInterval(() => {
this.healthCheckAttempts++;
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
fetch('/api/health') fetch('/api/health')
.then(response => { .then(response => {
if (response.ok) { if (response.ok) {
this.currentStatus = this.currentStatus =
'Coolify is back online. Reloading this page (you can manually reload if its not done automatically)...'; 'Coolify is back online. Reloading this page in 5 seconds...';
if (checkHealthInterval) clearInterval( if (this.checkHealthInterval) {
checkHealthInterval); clearInterval(this.checkHealthInterval);
this.checkHealthInterval = null;
}
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 5000) }, 5000)
} else { } else {
this.currentStatus = this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this
"Waiting for Coolify to come back from the dead..." .healthCheckAttempts);
} }
}) })
.catch(error => {
console.error('Health check failed:', error);
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this
.healthCheckAttempts);
});
}, 2000); }, 2000);
}, },
upgrade() { upgrade() {
if (checkIfIamDeadInterval || this.$wire.showProgress) return true; if (this.checkIfIamDeadInterval || this.showProgress) return true;
this.currentStatus = 'Pulling new images and updating Coolify.'; this.currentStatus = 'Update in progress. Pulling new images and preparing to restart Coolify...';
checkIfIamDeadInterval = setInterval(() => { this.checkIfIamDeadInterval = setInterval(() => {
fetch('/api/health') fetch('/api/health')
.then(response => { .then(response => {
if (response.ok) { if (response.ok) {
this.currentStatus = "Waiting for the update process..."
} else {
this.currentStatus = this.currentStatus =
"Update done, restarting Coolify & waiting until it is revived!" "Update in progress. Pulling new images and preparing to restart Coolify..."
if (checkIfIamDeadInterval) clearInterval( } else {
checkIfIamDeadInterval); this.currentStatus = "Coolify is restarting with the new version..."
if (this.checkIfIamDeadInterval) {
clearInterval(this.checkIfIamDeadInterval);
this.checkIfIamDeadInterval = null;
}
this.revive(); this.revive();
} }
}) })
.catch(error => {
console.error('Health check failed:', error);
this.currentStatus = "Coolify is restarting with the new version..."
if (this.checkIfIamDeadInterval) {
clearInterval(this.checkIfIamDeadInterval);
this.checkIfIamDeadInterval = null;
}
this.revive();
});
}, 2000); }, 2000);
} }

View File

@@ -48,6 +48,7 @@ it('uses max of CDN and cache versions', function () {
->once() ->once()
->with(base_path('versions.json'), Mockery::on(function ($json) { ->with(base_path('versions.json'), Mockery::on(function ($json) {
$data = json_decode($json, true); $data = json_decode($json, true);
// Should use cached version (4.0.10), not CDN version (4.0.0) // Should use cached version (4.0.10), not CDN version (4.0.0)
return $data['coolify']['v4']['version'] === '4.0.10'; return $data['coolify']['v4']['version'] === '4.0.10';
})); }));
@@ -61,7 +62,7 @@ it('uses max of CDN and cache versions', function () {
return $this->settings; return $this->settings;
}); });
$job = new CheckForUpdatesJob(); $job = new CheckForUpdatesJob;
$job->handle(); $job->handle();
}); });
@@ -87,6 +88,7 @@ it('never downgrades from current running version', function () {
->once() ->once()
->with(base_path('versions.json'), Mockery::on(function ($json) { ->with(base_path('versions.json'), Mockery::on(function ($json) {
$data = json_decode($json, true); $data = json_decode($json, true);
// Should use running version (4.0.10), not CDN (4.0.0) or cache (4.0.5) // Should use running version (4.0.10), not CDN (4.0.0) or cache (4.0.5)
return $data['coolify']['v4']['version'] === '4.0.10'; return $data['coolify']['v4']['version'] === '4.0.10';
})); }));
@@ -104,7 +106,7 @@ it('never downgrades from current running version', function () {
return $this->settings; return $this->settings;
}); });
$job = new CheckForUpdatesJob(); $job = new CheckForUpdatesJob;
$job->handle(); $job->handle();
}); });
@@ -125,7 +127,7 @@ it('uses data_set for safe version mutation', function () {
return $this->settings; return $this->settings;
}); });
$job = new CheckForUpdatesJob(); $job = new CheckForUpdatesJob;
// Should not throw even if structure is unexpected // Should not throw even if structure is unexpected
// data_set() handles nested path creation // data_set() handles nested path creation
@@ -159,6 +161,7 @@ it('preserves other component versions when preventing Coolify downgrade', funct
expect($data['traefik']['v3.6'])->toBe('3.6.2'); expect($data['traefik']['v3.6'])->toBe('3.6.2');
// Sentinel should use CDN version // Sentinel should use CDN version
expect($data['sentinel']['version'])->toBe('1.0.5'); expect($data['sentinel']['version'])->toBe('1.0.5');
return true; return true;
})); }));
@@ -178,6 +181,6 @@ it('preserves other component versions when preventing Coolify downgrade', funct
return $this->settings; return $this->settings;
}); });
$job = new CheckForUpdatesJob(); $job = new CheckForUpdatesJob;
$job->handle(); $job->handle();
}); });

View File

@@ -0,0 +1,149 @@
<?php
use App\Models\Service;
use Illuminate\Support\Facades\Log;
beforeEach(function () {
Log::shouldReceive('error')->andReturn(null);
});
it('applies beszel gzip prerequisite correctly', function () {
// Create a simple object to track the property change
$application = new class
{
public $is_gzip_enabled = true;
public function save() {}
};
$query = Mockery::mock();
$query->shouldReceive('whereName')
->with('beszel')
->once()
->andReturnSelf();
$query->shouldReceive('first')
->once()
->andReturn($application);
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'beszel-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldReceive('applications')
->once()
->andReturn($query);
applyServiceApplicationPrerequisites($service);
expect($application->is_gzip_enabled)->toBeFalse();
});
it('applies appwrite stripprefix prerequisite correctly', function () {
$applications = [];
foreach (['appwrite', 'appwrite-console', 'appwrite-realtime'] as $name) {
$app = new class
{
public $is_stripprefix_enabled = true;
public function save() {}
};
$applications[$name] = $app;
}
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'appwrite-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldReceive('applications')->times(3)->andReturnUsing(function () use (&$applications) {
static $callCount = 0;
$names = ['appwrite', 'appwrite-console', 'appwrite-realtime'];
$currentName = $names[$callCount++];
$query = Mockery::mock();
$query->shouldReceive('whereName')
->with($currentName)
->once()
->andReturnSelf();
$query->shouldReceive('first')
->once()
->andReturn($applications[$currentName]);
return $query;
});
applyServiceApplicationPrerequisites($service);
foreach ($applications as $app) {
expect($app->is_stripprefix_enabled)->toBeFalse();
}
});
it('handles missing applications gracefully', function () {
$query = Mockery::mock();
$query->shouldReceive('whereName')
->with('beszel')
->once()
->andReturnSelf();
$query->shouldReceive('first')
->once()
->andReturn(null);
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'beszel-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldReceive('applications')
->once()
->andReturn($query);
// Should not throw exception
applyServiceApplicationPrerequisites($service);
expect(true)->toBeTrue();
});
it('skips services without prerequisites', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'unknown-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldNotReceive('applications');
applyServiceApplicationPrerequisites($service);
expect(true)->toBeTrue();
});
it('correctly parses service name with single hyphen', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'docker-registry-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldNotReceive('applications');
// Should not throw exception - validates that 'docker-registry' is correctly parsed
applyServiceApplicationPrerequisites($service);
expect(true)->toBeTrue();
});
it('correctly parses service name with multiple hyphens', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'elasticsearch-with-kibana-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldNotReceive('applications');
// Should not throw exception - validates that 'elasticsearch-with-kibana' is correctly parsed
applyServiceApplicationPrerequisites($service);
expect(true)->toBeTrue();
});
it('correctly parses service name with hyphens in template name', function () {
$service = Mockery::mock(Service::class)->makePartial();
$service->name = 'apprise-api-clx1ab2cd3ef4g5hi6jk7l8m9n0o1p2q3'; // CUID2 format
$service->id = 1;
$service->shouldNotReceive('applications');
// Should not throw exception - validates that 'apprise-api' is correctly parsed
applyServiceApplicationPrerequisites($service);
expect(true)->toBeTrue();
});

View File

@@ -4,7 +4,6 @@ use App\Actions\Server\UpdateCoolify;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Server; use App\Models\Server;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
beforeEach(function () { beforeEach(function () {
@@ -46,7 +45,7 @@ it('validates cache against running version before fallback', function () {
config(['constants.coolify.version' => '4.0.10']); config(['constants.coolify.version' => '4.0.10']);
$action = new UpdateCoolify(); $action = new UpdateCoolify;
// Should throw exception - cache is older than running // Should throw exception - cache is older than running
try { try {
@@ -115,7 +114,7 @@ it('prevents downgrade even with manual update', function () {
// Current version is newer // Current version is newer
config(['constants.coolify.version' => '4.0.10']); config(['constants.coolify.version' => '4.0.10']);
$action = new UpdateCoolify(); $action = new UpdateCoolify;
\Illuminate\Support\Facades\Log::shouldReceive('error') \Illuminate\Support\Facades\Log::shouldReceive('error')
->once() ->once()