Merge branch 'next' into feat/escape-key-fullscreen-exit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai 2025-12-15 15:43:39 +01:00
commit b36f59fe58
71 changed files with 2148 additions and 482 deletions

View file

@ -30,7 +30,6 @@ public function handle($manual_update = false)
if (! $this->server) {
return;
}
CleanupDocker::dispatch($this->server, false, false);
// Fetch fresh version from CDN instead of using cache
try {
@ -117,17 +116,12 @@ public function handle($manual_update = false)
private function update()
{
$helperImage = config('constants.coolify.helper_image');
$latest_version = getHelperVersion();
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
instant_remote_process(["docker pull -q $image"], $this->server, false);
$latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion",
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
], $this->server);
}
}

View file

@ -309,7 +309,9 @@ public function normal(Request $request)
if (! $id || ! $branch) {
return response('Nothing to do. No id or branch found.');
}
$applications = Application::where('repository_project_id', $id)->whereRelation('source', 'is_public', false);
$applications = Application::where('repository_project_id', $id)
->where('source_id', $github_app->id)
->whereRelation('source', 'is_public', false);
if ($x_github_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get();
if ($applications->isEmpty()) {

View file

@ -2,10 +2,8 @@
namespace App\Livewire;
use App\Models\InstanceSettings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class NavbarDeleteTeam extends Component
@ -19,12 +17,8 @@ public function mount()
public function delete($password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$currentTeam = currentTeam();

View file

@ -20,6 +20,8 @@ class Show extends Component
public bool $is_debug_enabled = false;
public bool $fullscreen = false;
private bool $deploymentFinishedDispatched = false;
public function getListeners()

View file

@ -2,12 +2,9 @@
namespace App\Livewire\Project\Database;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -154,12 +151,8 @@ public function delete($password)
{
$this->authorize('manageBackups', $this->backup->database);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
try {

View file

@ -2,11 +2,9 @@
namespace App\Livewire\Project\Database;
use App\Models\InstanceSettings;
use App\Models\ScheduledDatabaseBackup;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class BackupExecutions extends Component
@ -69,12 +67,8 @@ public function cleanupDeleted()
public function deleteBackup($executionId, $password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$execution = $this->backup->executions()->where('id', $executionId)->first();

View file

@ -4,12 +4,9 @@
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Models\InstanceSettings;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Database extends Component
@ -96,12 +93,8 @@ public function delete($password)
try {
$this->authorize('delete', $this->database);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->database->delete();

View file

@ -3,7 +3,6 @@
namespace App\Livewire\Project\Service;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@ -16,8 +15,6 @@
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -62,7 +59,7 @@ public function mount()
$this->fs_path = $this->fileStorage->fs_path;
}
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
$this->syncData();
}
@ -104,7 +101,8 @@ public function convertToDirectory()
public function loadStorageOnServer()
{
try {
$this->authorize('update', $this->resource);
// Loading content is a read operation, so we use 'view' permission
$this->authorize('view', $this->resource);
$this->fileStorage->loadStorageOnServer();
$this->syncData();
@ -140,12 +138,8 @@ public function delete($password)
{
$this->authorize('update', $this->resource);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
try {

View file

@ -2,12 +2,9 @@
namespace App\Livewire\Project\Service;
use App\Models\InstanceSettings;
use App\Models\ServiceApplication;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
@ -128,12 +125,8 @@ public function delete($password)
try {
$this->authorize('delete', $this->application);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->application->delete();

View file

@ -67,7 +67,7 @@ public function refreshStoragesFromEvent()
public function refreshStorages()
{
$this->fileStorage = $this->resource->fileStorages()->get();
$this->resource->refresh();
$this->resource->load('persistentStorages.resource');
}
public function getFilesProperty()

View file

@ -3,13 +3,10 @@
namespace App\Livewire\Project\Shared;
use App\Jobs\DeleteResourceJob;
use App\Models\InstanceSettings;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@ -93,12 +90,8 @@ public function mount()
public function delete($password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
if (! $this->resource) {

View file

@ -5,12 +5,9 @@
use App\Actions\Application\StopApplicationOneServer;
use App\Actions\Docker\GetContainersStatus;
use App\Events\ApplicationStatusChanged;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@ -140,12 +137,8 @@ public function addServer(int $network_id, int $server_id)
public function removeServer(int $network_id, int $server_id, $password)
{
try {
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) {

View file

@ -2,11 +2,8 @@
namespace App\Livewire\Project\Shared\Storages;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Show extends Component
@ -67,7 +64,7 @@ private function syncData(bool $toModel = false): void
public function mount()
{
$this->syncData(false);
$this->isReadOnly = $this->storage->isReadOnlyVolume();
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
}
public function submit()
@ -84,12 +81,8 @@ public function delete($password)
{
$this->authorize('update', $this->resource);
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
$this->storage->delete();

View file

@ -3,11 +3,8 @@
namespace App\Livewire\Server;
use App\Actions\Server\DeleteServer;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class Delete extends Component
@ -29,12 +26,8 @@ public function mount(string $server_uuid)
public function delete($password)
{
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
try {
$this->authorize('delete', $this->server);

View file

@ -2,11 +2,8 @@
namespace App\Livewire\Server\Security;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -44,13 +41,9 @@ public function toggleTerminal($password)
throw new \Exception('Only team administrators and owners can modify terminal access.');
}
// Verify password unless two-step confirmation is disabled
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
// Verify password
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
// Toggle the terminal setting

View file

@ -5,9 +5,12 @@
use App\Actions\Server\StartSentinel;
use App\Actions\Server\StopSentinel;
use App\Events\ServerReachabilityChanged;
use App\Models\CloudProviderToken;
use App\Models\Server;
use App\Services\HetznerService;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@ -73,6 +76,17 @@ class Show extends Component
public bool $isValidating = false;
// Hetzner linking properties
public Collection $availableHetznerTokens;
public ?int $selectedHetznerTokenId = null;
public ?array $matchedHetznerServer = null;
public ?string $hetznerSearchError = null;
public bool $hetznerNoMatchFound = false;
public function getListeners()
{
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
@ -150,6 +164,9 @@ public function mount(string $server_uuid)
$this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->isValidating = $this->server->is_validating ?? false;
// Load Hetzner tokens for linking
$this->loadHetznerTokens();
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -465,6 +482,98 @@ public function submit()
}
}
public function loadHetznerTokens(): void
{
$this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->get();
}
public function searchHetznerServer(): void
{
$this->hetznerSearchError = null;
$this->hetznerNoMatchFound = false;
$this->matchedHetznerServer = null;
if (! $this->selectedHetznerTokenId) {
$this->hetznerSearchError = 'Please select a Hetzner token.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
$this->hetznerSearchError = 'Invalid token selected.';
return;
}
$hetznerService = new HetznerService($token->token);
$matched = $hetznerService->findServerByIp($this->server->ip);
if ($matched) {
$this->matchedHetznerServer = $matched;
} else {
$this->hetznerNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->hetznerSearchError = 'Failed to search Hetzner servers: '.$e->getMessage();
}
}
public function linkToHetzner()
{
if (! $this->matchedHetznerServer) {
$this->dispatch('error', 'No Hetzner server selected.');
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
if (! $token) {
$this->dispatch('error', 'Invalid token selected.');
return;
}
// Verify the server exists and is accessible with the token
$hetznerService = new HetznerService($token->token);
$serverData = $hetznerService->getServer($this->matchedHetznerServer['id']);
if (empty($serverData)) {
$this->dispatch('error', 'Could not find Hetzner server with ID: '.$this->matchedHetznerServer['id']);
return;
}
// Update the server with Hetzner details
$this->server->update([
'cloud_provider_token_id' => $this->selectedHetznerTokenId,
'hetzner_server_id' => $this->matchedHetznerServer['id'],
'hetzner_server_status' => $serverData['status'] ?? null,
]);
$this->hetznerServerStatus = $serverData['status'] ?? null;
// Clear the linking state
$this->matchedHetznerServer = null;
$this->selectedHetznerTokenId = null;
$this->hetznerNoMatchFound = false;
$this->hetznerSearchError = null;
$this->dispatch('success', 'Server successfully linked to Hetzner Cloud!');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.show');

View file

@ -5,8 +5,6 @@
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Rules\ValidIpOrCidr;
use Auth;
use Hash;
use Livewire\Attributes\Validate;
use Livewire\Component;
@ -157,9 +155,7 @@ public function instantSave()
public function toggleTwoStepConfirmation($password): bool
{
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
if (! verifyPasswordConfirmation($password, $this)) {
return false;
}

View file

@ -2,10 +2,7 @@
namespace App\Livewire\Team;
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Livewire\Component;
class AdminView extends Component
@ -58,12 +55,8 @@ public function delete($id, $password)
return redirect()->route('dashboard');
}
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
if (! Hash::check($password, Auth::user()->password)) {
$this->addError('password', 'The provided password is incorrect.');
return;
}
if (! verifyPasswordConfirmation($password, $this)) {
return;
}
if (! auth()->user()->isInstanceAdmin()) {

View file

@ -4,6 +4,7 @@
use App\Actions\Server\UpdateCoolify;
use App\Models\InstanceSettings;
use App\Models\Server;
use Livewire\Component;
class Upgrade extends Component
@ -14,12 +15,20 @@ class Upgrade extends Component
public string $latestVersion = '';
public string $currentVersion = '';
protected $listeners = ['updateAvailable' => 'checkUpdate'];
public function mount()
{
$this->currentVersion = config('constants.coolify.version');
}
public function checkUpdate()
{
try {
$this->latestVersion = get_latest_version_of_coolify();
$this->currentVersion = config('constants.coolify.version');
$this->isUpgradeAvailable = data_get(InstanceSettings::get(), 'new_version_available', false);
if (isDev()) {
$this->isUpgradeAvailable = true;
@ -41,4 +50,71 @@ public function upgrade()
return handleError($e, $this);
}
}
public function getUpgradeStatus(): array
{
// Only root team members can view upgrade status
if (auth()->user()?->currentTeam()?->id !== 0) {
return ['status' => 'none'];
}
$server = Server::find(0);
if (! $server) {
return ['status' => 'none'];
}
$statusFile = '/data/coolify/source/.upgrade-status';
try {
$content = instant_remote_process(
["cat {$statusFile} 2>/dev/null || echo ''"],
$server,
false
);
$content = trim($content ?? '');
} catch (\Throwable $e) {
return ['status' => 'none'];
}
if (empty($content)) {
return ['status' => 'none'];
}
$parts = explode('|', $content);
if (count($parts) < 3) {
return ['status' => 'none'];
}
[$step, $message, $timestamp] = $parts;
// Check if status is stale (older than 10 minutes)
try {
$statusTime = new \DateTime($timestamp);
$now = new \DateTime;
$diffMinutes = ($now->getTimestamp() - $statusTime->getTimestamp()) / 60;
if ($diffMinutes > 10) {
return ['status' => 'none'];
}
} catch (\Throwable $e) {
return ['status' => 'none'];
}
if ($step === 'error') {
return [
'status' => 'error',
'step' => 0,
'message' => $message,
];
}
$stepInt = (int) $step;
$status = $stepInt >= 6 ? 'complete' : 'in_progress';
return [
'status' => $status,
'step' => $stepInt,
'message' => $message,
];
}
}

View file

@ -209,6 +209,23 @@ public function scopeWherePlainMountPath($query, $path)
return $query->get()->where('plain_mount_path', $path);
}
// Check if this volume belongs to a service resource
public function isServiceResource(): bool
{
return in_array($this->resource_type, [
'App\Models\ServiceApplication',
'App\Models\ServiceDatabase',
]);
}
// Determine if this volume should be read-only in the UI
// File/directory mounts can be edited even for services
public function shouldBeReadOnlyInUI(): bool
{
// Check for explicit :ro flag in compose (existing logic)
return $this->isReadOnlyVolume();
}
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
@ -239,22 +256,40 @@ public function isReadOnlyVolume(): bool
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
// Note: We match on mount_path (container path) only, since fs_path gets transformed
// from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
$parts = explode(':', $volume);
// Check if this volume matches our fs_path and mount_path
// Check if this volume matches our mount_path
if (count($parts) >= 2) {
$hostPath = $parts[0];
$containerPath = $parts[1];
$options = $parts[2] ?? null;
// Match based on fs_path and mount_path
if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) {
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str($this->mount_path)->ltrim('/')->toString();
$containerPathClean = str($containerPath)->ltrim('/')->toString();
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $options === 'ro';
}
}
} elseif (is_array($volume)) {
// Long-form syntax: { type: bind, source: ..., target: ..., read_only: true }
$containerPath = data_get($volume, 'target');
$readOnly = data_get($volume, 'read_only', false);
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str($this->mount_path)->ltrim('/')->toString();
$containerPathClean = str($containerPath)->ltrim('/')->toString();
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $readOnly === true;
}
}
}

View file

@ -10,6 +10,11 @@ class LocalPersistentVolume extends Model
{
protected $guarded = [];
public function resource()
{
return $this->morphTo('resource');
}
public function application()
{
return $this->morphTo('resource');
@ -50,6 +55,54 @@ protected function hostPath(): Attribute
);
}
// Check if this volume belongs to a service resource
public function isServiceResource(): bool
{
return in_array($this->resource_type, [
'App\Models\ServiceApplication',
'App\Models\ServiceDatabase',
]);
}
// Check if this volume belongs to a dockercompose application
public function isDockerComposeResource(): bool
{
if ($this->resource_type !== 'App\Models\Application') {
return false;
}
// Only access relationship if already eager loaded to avoid N+1
if (! $this->relationLoaded('resource')) {
return false;
}
$application = $this->resource;
if (! $application) {
return false;
}
return data_get($application, 'build_pack') === 'dockercompose';
}
// Determine if this volume should be read-only in the UI
// Service volumes and dockercompose application volumes are read-only
// (users should edit compose file directly)
public function shouldBeReadOnlyInUI(): bool
{
// All service volumes should be read-only in UI
if ($this->isServiceResource()) {
return true;
}
// All dockercompose application volumes should be read-only in UI
if ($this->isDockerComposeResource()) {
return true;
}
// Check for explicit :ro flag in compose (existing logic)
return $this->isReadOnlyVolume();
}
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
@ -85,6 +138,7 @@ public function isReadOnlyVolume(): bool
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
// Note: We match on mount_path (container path) only, since host paths get transformed
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
@ -104,6 +158,19 @@ public function isReadOnlyVolume(): bool
return $options === 'ro';
}
}
} elseif (is_array($volume)) {
// Long-form syntax: { type: bind/volume, source: ..., target: ..., read_only: true }
$containerPath = data_get($volume, 'target');
$readOnly = data_get($volume, 'read_only', false);
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str($this->mount_path)->ltrim('/')->toString();
$containerPathClean = str($containerPath)->ltrim('/')->toString();
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $readOnly === true;
}
}
}

View file

@ -20,6 +20,28 @@ class S3Storage extends BaseModel
'secret' => 'encrypted',
];
/**
* Boot the model and register event listeners.
*/
protected static function boot(): void
{
parent::boot();
// Trim whitespace from credentials before saving to prevent
// "Malformed Access Key Id" errors from accidental whitespace in pasted values.
// Note: We use the saving event instead of Attribute mutators because key/secret
// use Laravel's 'encrypted' cast. Attribute mutators fire before casts, which
// would cause issues with the encryption/decryption cycle.
static::saving(function (S3Storage $storage) {
if ($storage->key !== null) {
$storage->key = trim($storage->key);
}
if ($storage->secret !== null) {
$storage->secret = trim($storage->secret);
}
});
}
public static function ownedByCurrentTeam(array $select = ['*'])
{
$selectArray = collect($select)->concat(['id']);
@ -55,6 +77,36 @@ protected function path(): Attribute
);
}
/**
* Trim whitespace from endpoint to prevent malformed URLs.
*/
protected function endpoint(): Attribute
{
return Attribute::make(
set: fn (?string $value) => $value ? trim($value) : null,
);
}
/**
* Trim whitespace from bucket name to prevent connection errors.
*/
protected function bucket(): Attribute
{
return Attribute::make(
set: fn (?string $value) => $value ? trim($value) : null,
);
}
/**
* Trim whitespace from region to prevent connection errors.
*/
protected function region(): Attribute
{
return Attribute::make(
set: fn (?string $value) => $value ? trim($value) : null,
);
}
public function testConnection(bool $shouldSave = false)
{
try {

View file

@ -443,4 +443,13 @@ public function hasEmailChangeRequest(): bool
&& $this->email_change_code_expires_at
&& Carbon::now()->lessThan($this->email_change_code_expires_at);
}
/**
* Check if the user has a password set.
* OAuth users are created without passwords.
*/
public function hasPassword(): bool
{
return ! empty($this->password);
}
}

View file

@ -43,21 +43,26 @@ public function send(SendsEmail $notifiable, Notification $notification): void
throw new Exception('No email recipients found');
}
foreach ($recipients as $recipient) {
// Check if the recipient is part of the team
if (! $members->contains('email', $recipient)) {
$emailSettings = $notifiable->emailNotificationSettings;
data_set($emailSettings, 'smtp_password', '********');
data_set($emailSettings, 'resend_api_key', '********');
send_internal_notification(sprintf(
"Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s",
$recipient,
$team,
get_class($notification),
get_class($notifiable),
json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
));
throw new Exception('Recipient is not part of the team');
// Skip team membership validation for test notifications
$isTestNotification = data_get($notification, 'isTestNotification', false);
if (! $isTestNotification) {
foreach ($recipients as $recipient) {
// Check if the recipient is part of the team
if (! $members->contains('email', $recipient)) {
$emailSettings = $notifiable->emailNotificationSettings;
data_set($emailSettings, 'smtp_password', '********');
data_set($emailSettings, 'resend_api_key', '********');
send_internal_notification(sprintf(
"Recipient is not part of the team: %s\nTeam: %s\nNotification: %s\nNotifiable: %s\nEmail Settings:\n%s",
$recipient,
$team,
get_class($notification),
get_class($notifiable),
json_encode($emailSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
));
throw new Exception('Recipient is not part of the team');
}
}
}

View file

@ -23,6 +23,8 @@ class Test extends Notification implements ShouldQueue
public $tries = 5;
public bool $isTestNotification = true;
public function __construct(public ?string $emails = null, public ?string $channel = null, public ?bool $ping = false)
{
$this->onQueue('high');

View file

@ -8,6 +8,8 @@
class Test extends CustomEmailNotification
{
public bool $isTestNotification = true;
public function __construct(public string $emails, public bool $isTransactionalEmail = true)
{
$this->onQueue('high');

View file

@ -161,4 +161,30 @@ public function deleteServer(int $serverId): void
{
$this->request('delete', "/servers/{$serverId}");
}
public function getServers(): array
{
return $this->requestPaginated('get', '/servers', 'servers');
}
public function findServerByIp(string $ip): ?array
{
$servers = $this->getServers();
foreach ($servers as $server) {
// Check IPv4
$ipv4 = data_get($server, 'public_net.ipv4.ip');
if ($ipv4 === $ip) {
return $server;
}
// Check IPv6 (Hetzner returns the full /64 block)
$ipv6 = data_get($server, 'public_net.ipv6.ip');
if ($ipv6 && str_starts_with($ip, rtrim($ipv6, '/'))) {
return $server;
}
}
return null;
}
}

View file

@ -33,6 +33,7 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\RateLimiter;
@ -3308,3 +3309,57 @@ function formatContainerStatus(string $status): string
return str($status)->headline()->value();
}
}
/**
* Check if password confirmation should be skipped.
* Returns true if:
* - Two-step confirmation is globally disabled
* - User has no password (OAuth users)
*
* Used by modal-confirmation.blade.php to determine if password step should be shown.
*
* @return bool True if password confirmation should be skipped
*/
function shouldSkipPasswordConfirmation(): bool
{
// Skip if two-step confirmation is globally disabled
if (data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
return true;
}
// Skip if user has no password (OAuth users)
if (! Auth::user()?->hasPassword()) {
return true;
}
return false;
}
/**
* Verify password for two-step confirmation.
* Skips verification if:
* - Two-step confirmation is globally disabled
* - User has no password (OAuth users)
*
* @param mixed $password The password to verify (may be array if skipped by frontend)
* @param \Livewire\Component|null $component Optional Livewire component to add errors to
* @return bool True if verification passed (or skipped), false if password is incorrect
*/
function verifyPasswordConfirmation(mixed $password, ?Livewire\Component $component = null): bool
{
// Skip if password confirmation should be skipped
if (shouldSkipPasswordConfirmation()) {
return true;
}
// Verify the password
if (! Hash::check($password, Auth::user()->password)) {
if ($component) {
$component->addError('password', 'The provided password is incorrect.');
}
return false;
}
return true;
}

View file

@ -2,7 +2,7 @@
return [
'coolify' => [
'version' => '4.0.0-beta.454',
'version' => '4.0.0-beta.455',
'helper_version' => '1.0.12',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),

View file

@ -11,16 +11,18 @@
*/
public function up(): void
{
Schema::create('cloud_provider_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->onDelete('cascade');
$table->string('provider');
$table->text('token');
$table->string('name')->nullable();
$table->timestamps();
if (! Schema::hasTable('cloud_provider_tokens')) {
Schema::create('cloud_provider_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->onDelete('cascade');
$table->string('provider');
$table->text('token');
$table->string('name')->nullable();
$table->timestamps();
$table->index(['team_id', 'provider']);
});
$table->index(['team_id', 'provider']);
});
}
}
/**

View file

@ -11,9 +11,11 @@
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->bigInteger('hetzner_server_id')->nullable()->after('id');
});
if (! Schema::hasColumn('servers', 'hetzner_server_id')) {
Schema::table('servers', function (Blueprint $table) {
$table->bigInteger('hetzner_server_id')->nullable()->after('id');
});
}
}
/**
@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('hetzner_server_id');
});
if (Schema::hasColumn('servers', 'hetzner_server_id')) {
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('hetzner_server_id');
});
}
}
};

View file

@ -11,9 +11,11 @@
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null');
});
if (! Schema::hasColumn('servers', 'cloud_provider_token_id')) {
Schema::table('servers', function (Blueprint $table) {
$table->foreignId('cloud_provider_token_id')->nullable()->after('private_key_id')->constrained()->onDelete('set null');
});
}
}
/**
@ -21,9 +23,11 @@ public function up(): void
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropForeign(['cloud_provider_token_id']);
$table->dropColumn('cloud_provider_token_id');
});
if (Schema::hasColumn('servers', 'cloud_provider_token_id')) {
Schema::table('servers', function (Blueprint $table) {
$table->dropForeign(['cloud_provider_token_id']);
$table->dropColumn('cloud_provider_token_id');
});
}
}
};

View file

@ -11,9 +11,11 @@
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->string('hetzner_server_status')->nullable()->after('hetzner_server_id');
});
if (! Schema::hasColumn('servers', 'hetzner_server_status')) {
Schema::table('servers', function (Blueprint $table) {
$table->string('hetzner_server_status')->nullable()->after('hetzner_server_id');
});
}
}
/**
@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('hetzner_server_status');
});
if (Schema::hasColumn('servers', 'hetzner_server_status')) {
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('hetzner_server_status');
});
}
}
};

View file

@ -11,9 +11,11 @@
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->boolean('is_validating')->default(false)->after('hetzner_server_status');
});
if (! Schema::hasColumn('servers', 'is_validating')) {
Schema::table('servers', function (Blueprint $table) {
$table->boolean('is_validating')->default(false)->after('hetzner_server_status');
});
}
}
/**
@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('is_validating');
});
if (Schema::hasColumn('servers', 'is_validating')) {
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('is_validating');
});
}
}
};

View file

@ -11,9 +11,11 @@
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('dev_helper_version')->nullable();
});
if (! Schema::hasColumn('instance_settings', 'dev_helper_version')) {
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('dev_helper_version')->nullable();
});
}
}
/**
@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('dev_helper_version');
});
if (Schema::hasColumn('instance_settings', 'dev_helper_version')) {
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('dev_helper_version');
});
}
}
};

View file

@ -11,9 +11,11 @@
*/
public function up(): void
{
Schema::table('scheduled_tasks', function (Blueprint $table) {
$table->integer('timeout')->default(300)->after('frequency');
});
if (! Schema::hasColumn('scheduled_tasks', 'timeout')) {
Schema::table('scheduled_tasks', function (Blueprint $table) {
$table->integer('timeout')->default(300)->after('frequency');
});
}
}
/**
@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
Schema::table('scheduled_tasks', function (Blueprint $table) {
$table->dropColumn('timeout');
});
if (Schema::hasColumn('scheduled_tasks', 'timeout')) {
Schema::table('scheduled_tasks', function (Blueprint $table) {
$table->dropColumn('timeout');
});
}
}
};

View file

@ -11,12 +11,29 @@
*/
public function up(): void
{
Schema::table('scheduled_task_executions', function (Blueprint $table) {
$table->timestamp('started_at')->nullable()->after('scheduled_task_id');
$table->integer('retry_count')->default(0)->after('status');
$table->decimal('duration', 10, 2)->nullable()->after('retry_count')->comment('Duration in seconds');
$table->text('error_details')->nullable()->after('message');
});
if (! Schema::hasColumn('scheduled_task_executions', 'started_at')) {
Schema::table('scheduled_task_executions', function (Blueprint $table) {
$table->timestamp('started_at')->nullable()->after('scheduled_task_id');
});
}
if (! Schema::hasColumn('scheduled_task_executions', 'retry_count')) {
Schema::table('scheduled_task_executions', function (Blueprint $table) {
$table->integer('retry_count')->default(0)->after('status');
});
}
if (! Schema::hasColumn('scheduled_task_executions', 'duration')) {
Schema::table('scheduled_task_executions', function (Blueprint $table) {
$table->decimal('duration', 10, 2)->nullable()->after('retry_count')->comment('Duration in seconds');
});
}
if (! Schema::hasColumn('scheduled_task_executions', 'error_details')) {
Schema::table('scheduled_task_executions', function (Blueprint $table) {
$table->text('error_details')->nullable()->after('message');
});
}
}
/**
@ -24,8 +41,13 @@ public function up(): void
*/
public function down(): void
{
Schema::table('scheduled_task_executions', function (Blueprint $table) {
$table->dropColumn(['started_at', 'retry_count', 'duration', 'error_details']);
});
$columns = ['started_at', 'retry_count', 'duration', 'error_details'];
foreach ($columns as $column) {
if (Schema::hasColumn('scheduled_task_executions', $column)) {
Schema::table('scheduled_task_executions', function (Blueprint $table) use ($column) {
$table->dropColumn($column);
});
}
}
}
};

View file

@ -11,11 +11,23 @@
*/
public function up(): void
{
Schema::table('applications', function (Blueprint $table) {
$table->integer('restart_count')->default(0)->after('status');
$table->timestamp('last_restart_at')->nullable()->after('restart_count');
$table->string('last_restart_type', 10)->nullable()->after('last_restart_at');
});
if (! Schema::hasColumn('applications', 'restart_count')) {
Schema::table('applications', function (Blueprint $table) {
$table->integer('restart_count')->default(0)->after('status');
});
}
if (! Schema::hasColumn('applications', 'last_restart_at')) {
Schema::table('applications', function (Blueprint $table) {
$table->timestamp('last_restart_at')->nullable()->after('restart_count');
});
}
if (! Schema::hasColumn('applications', 'last_restart_type')) {
Schema::table('applications', function (Blueprint $table) {
$table->string('last_restart_type', 10)->nullable()->after('last_restart_at');
});
}
}
/**
@ -23,8 +35,13 @@ public function up(): void
*/
public function down(): void
{
Schema::table('applications', function (Blueprint $table) {
$table->dropColumn(['restart_count', 'last_restart_at', 'last_restart_type']);
});
$columns = ['restart_count', 'last_restart_at', 'last_restart_type'];
foreach ($columns as $column) {
if (Schema::hasColumn('applications', $column)) {
Schema::table('applications', function (Blueprint $table) use ($column) {
$table->dropColumn($column);
});
}
}
}
};

View file

@ -11,9 +11,11 @@
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->string('detected_traefik_version')->nullable();
});
if (! Schema::hasColumn('servers', 'detected_traefik_version')) {
Schema::table('servers', function (Blueprint $table) {
$table->string('detected_traefik_version')->nullable();
});
}
}
/**
@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('detected_traefik_version');
});
if (Schema::hasColumn('servers', 'detected_traefik_version')) {
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('detected_traefik_version');
});
}
}
};

View file

@ -11,9 +11,11 @@
*/
public function up(): void
{
Schema::table('email_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_email_notifications')->default(true);
});
if (! Schema::hasColumn('email_notification_settings', 'traefik_outdated_email_notifications')) {
Schema::table('email_notification_settings', function (Blueprint $table) {
$table->boolean('traefik_outdated_email_notifications')->default(true);
});
}
}
/**
@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
Schema::table('email_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_email_notifications');
});
if (Schema::hasColumn('email_notification_settings', 'traefik_outdated_email_notifications')) {
Schema::table('email_notification_settings', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_email_notifications');
});
}
}
};

View file

@ -11,9 +11,11 @@
*/
public function up(): void
{
Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->text('telegram_notifications_traefik_outdated_thread_id')->nullable();
});
if (! Schema::hasColumn('telegram_notification_settings', 'telegram_notifications_traefik_outdated_thread_id')) {
Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->text('telegram_notifications_traefik_outdated_thread_id')->nullable();
});
}
}
/**
@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->dropColumn('telegram_notifications_traefik_outdated_thread_id');
});
if (Schema::hasColumn('telegram_notification_settings', 'telegram_notifications_traefik_outdated_thread_id')) {
Schema::table('telegram_notification_settings', function (Blueprint $table) {
$table->dropColumn('telegram_notifications_traefik_outdated_thread_id');
});
}
}
};

View file

@ -11,9 +11,11 @@
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->json('traefik_outdated_info')->nullable();
});
if (! Schema::hasColumn('servers', 'traefik_outdated_info')) {
Schema::table('servers', function (Blueprint $table) {
$table->json('traefik_outdated_info')->nullable();
});
}
}
/**
@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_info');
});
if (Schema::hasColumn('servers', 'traefik_outdated_info')) {
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('traefik_outdated_info');
});
}
}
};

View file

@ -11,10 +11,17 @@
*/
public function up(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->boolean('inject_build_args_to_dockerfile')->default(true)->after('use_build_secrets');
$table->boolean('include_source_commit_in_build')->default(false)->after('inject_build_args_to_dockerfile');
});
if (! Schema::hasColumn('application_settings', 'inject_build_args_to_dockerfile')) {
Schema::table('application_settings', function (Blueprint $table) {
$table->boolean('inject_build_args_to_dockerfile')->default(true)->after('use_build_secrets');
});
}
if (! Schema::hasColumn('application_settings', 'include_source_commit_in_build')) {
Schema::table('application_settings', function (Blueprint $table) {
$table->boolean('include_source_commit_in_build')->default(false)->after('inject_build_args_to_dockerfile');
});
}
}
/**
@ -22,9 +29,16 @@ public function up(): void
*/
public function down(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->dropColumn('inject_build_args_to_dockerfile');
$table->dropColumn('include_source_commit_in_build');
});
if (Schema::hasColumn('application_settings', 'inject_build_args_to_dockerfile')) {
Schema::table('application_settings', function (Blueprint $table) {
$table->dropColumn('inject_build_args_to_dockerfile');
});
}
if (Schema::hasColumn('application_settings', 'include_source_commit_in_build')) {
Schema::table('application_settings', function (Blueprint $table) {
$table->dropColumn('include_source_commit_in_build');
});
}
}
};

View file

@ -11,9 +11,11 @@
*/
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->integer('deployment_queue_limit')->default(25)->after('concurrent_builds');
});
if (! Schema::hasColumn('server_settings', 'deployment_queue_limit')) {
Schema::table('server_settings', function (Blueprint $table) {
$table->integer('deployment_queue_limit')->default(25)->after('concurrent_builds');
});
}
}
/**
@ -21,8 +23,10 @@ public function up(): void
*/
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('deployment_queue_limit');
});
if (Schema::hasColumn('server_settings', 'deployment_queue_limit')) {
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('deployment_queue_limit');
});
}
}
};

View file

@ -8,15 +8,19 @@
{
public function up(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->integer('docker_images_to_keep')->default(2);
});
if (! Schema::hasColumn('application_settings', 'docker_images_to_keep')) {
Schema::table('application_settings', function (Blueprint $table) {
$table->integer('docker_images_to_keep')->default(2);
});
}
}
public function down(): void
{
Schema::table('application_settings', function (Blueprint $table) {
$table->dropColumn('docker_images_to_keep');
});
if (Schema::hasColumn('application_settings', 'docker_images_to_keep')) {
Schema::table('application_settings', function (Blueprint $table) {
$table->dropColumn('docker_images_to_keep');
});
}
}
};

View file

@ -8,15 +8,19 @@
{
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->boolean('disable_application_image_retention')->default(false);
});
if (! Schema::hasColumn('server_settings', 'disable_application_image_retention')) {
Schema::table('server_settings', function (Blueprint $table) {
$table->boolean('disable_application_image_retention')->default(false);
});
}
}
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('disable_application_image_retention');
});
if (Schema::hasColumn('server_settings', 'disable_application_image_retention')) {
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('disable_application_image_retention');
});
}
}
};

View file

@ -13,25 +13,27 @@
*/
public function up(): void
{
Schema::table('cloud_provider_tokens', function (Blueprint $table) {
$table->string('uuid')->nullable()->unique()->after('id');
});
// Generate UUIDs for existing records using chunked processing
DB::table('cloud_provider_tokens')
->whereNull('uuid')
->chunkById(500, function ($tokens) {
foreach ($tokens as $token) {
DB::table('cloud_provider_tokens')
->where('id', $token->id)
->update(['uuid' => (string) new Cuid2]);
}
if (! Schema::hasColumn('cloud_provider_tokens', 'uuid')) {
Schema::table('cloud_provider_tokens', function (Blueprint $table) {
$table->string('uuid')->nullable()->unique()->after('id');
});
// Make uuid non-nullable after filling in values
Schema::table('cloud_provider_tokens', function (Blueprint $table) {
$table->string('uuid')->nullable(false)->change();
});
// Generate UUIDs for existing records using chunked processing
DB::table('cloud_provider_tokens')
->whereNull('uuid')
->chunkById(500, function ($tokens) {
foreach ($tokens as $token) {
DB::table('cloud_provider_tokens')
->where('id', $token->id)
->update(['uuid' => (string) new Cuid2]);
}
});
// Make uuid non-nullable after filling in values
Schema::table('cloud_provider_tokens', function (Blueprint $table) {
$table->string('uuid')->nullable(false)->change();
});
}
}
/**
@ -39,8 +41,10 @@ public function up(): void
*/
public function down(): void
{
Schema::table('cloud_provider_tokens', function (Blueprint $table) {
$table->dropColumn('uuid');
});
if (Schema::hasColumn('cloud_provider_tokens', 'uuid')) {
Schema::table('cloud_provider_tokens', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
}
};

View file

@ -0,0 +1,112 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
return new class extends Migration
{
/**
* Run the migrations.
*
* Trims whitespace from S3 storage fields (key, secret, endpoint, bucket, region)
* to fix "Malformed Access Key Id" errors that can occur when users
* accidentally paste values with leading/trailing whitespace.
*/
public function up(): void
{
DB::table('s3_storages')
->select(['id', 'key', 'secret', 'endpoint', 'bucket', 'region'])
->orderBy('id')
->chunk(100, function ($storages) {
foreach ($storages as $storage) {
try {
DB::transaction(function () use ($storage) {
$updates = [];
// Trim endpoint (not encrypted)
if ($storage->endpoint !== null) {
$trimmedEndpoint = trim($storage->endpoint);
if ($trimmedEndpoint !== $storage->endpoint) {
$updates['endpoint'] = $trimmedEndpoint;
}
}
// Trim bucket (not encrypted)
if ($storage->bucket !== null) {
$trimmedBucket = trim($storage->bucket);
if ($trimmedBucket !== $storage->bucket) {
$updates['bucket'] = $trimmedBucket;
}
}
// Trim region (not encrypted)
if ($storage->region !== null) {
$trimmedRegion = trim($storage->region);
if ($trimmedRegion !== $storage->region) {
$updates['region'] = $trimmedRegion;
}
}
// Trim key (encrypted) - verify re-encryption works before saving
if ($storage->key !== null) {
try {
$decryptedKey = Crypt::decryptString($storage->key);
$trimmedKey = trim($decryptedKey);
if ($trimmedKey !== $decryptedKey) {
$encryptedKey = Crypt::encryptString($trimmedKey);
// Verify the new encryption is valid
if (Crypt::decryptString($encryptedKey) === $trimmedKey) {
$updates['key'] = $encryptedKey;
} else {
Log::warning("S3 storage ID {$storage->id}: Re-encryption verification failed for key, skipping");
}
}
} catch (\Throwable $e) {
Log::warning("Could not decrypt S3 storage key for ID {$storage->id}: ".$e->getMessage());
}
}
// Trim secret (encrypted) - verify re-encryption works before saving
if ($storage->secret !== null) {
try {
$decryptedSecret = Crypt::decryptString($storage->secret);
$trimmedSecret = trim($decryptedSecret);
if ($trimmedSecret !== $decryptedSecret) {
$encryptedSecret = Crypt::encryptString($trimmedSecret);
// Verify the new encryption is valid
if (Crypt::decryptString($encryptedSecret) === $trimmedSecret) {
$updates['secret'] = $encryptedSecret;
} else {
Log::warning("S3 storage ID {$storage->id}: Re-encryption verification failed for secret, skipping");
}
}
} catch (\Throwable $e) {
Log::warning("Could not decrypt S3 storage secret for ID {$storage->id}: ".$e->getMessage());
}
}
if (! empty($updates)) {
DB::table('s3_storages')->where('id', $storage->id)->update($updates);
Log::info("Trimmed whitespace from S3 storage credentials for ID {$storage->id}", [
'fields_updated' => array_keys($updates),
]);
}
});
} catch (\Throwable $e) {
Log::error("Failed to process S3 storage ID {$storage->id}: ".$e->getMessage());
// Continue with next record instead of failing entire migration
}
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Cannot reverse trimming operation
}
};

View file

@ -64,9 +64,45 @@ if [ -f /root/.docker/config.json ]; then
DOCKER_CONFIG_MOUNT="-v /root/.docker/config.json:/root/.docker/config.json"
fi
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
echo "docker-compose.custom.yml detected." >>"$LOGFILE"
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
else
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
fi
# Pull all required images before stopping containers
# This ensures we don't take down the system if image pull fails (rate limits, network issues, etc.)
echo "Pulling required Docker images..." >>"$LOGFILE"
docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE}" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify image. Aborting upgrade." >>"$LOGFILE"; exit 1; }
docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify helper image. Aborting upgrade." >>"$LOGFILE"; exit 1; }
docker pull postgres:15-alpine >>"$LOGFILE" 2>&1 || { echo "Failed to pull PostgreSQL image. Aborting upgrade." >>"$LOGFILE"; exit 1; }
docker pull redis:7-alpine >>"$LOGFILE" 2>&1 || { echo "Failed to pull Redis image. Aborting upgrade." >>"$LOGFILE"; exit 1; }
# Pull realtime image - version is hardcoded in docker-compose.prod.yml, extract it or use a known version
docker pull "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.10" >>"$LOGFILE" 2>&1 || { echo "Failed to pull Coolify realtime image. Aborting upgrade." >>"$LOGFILE"; exit 1; }
echo "All images pulled successfully." >>"$LOGFILE"
# Stop and remove existing Coolify containers to prevent conflicts
# This handles both old installations (project "source") and new ones (project "coolify")
# Use nohup to ensure the script continues even if SSH connection is lost
echo "Starting container restart sequence (detached)..." >>"$LOGFILE"
nohup bash -c "
LOGFILE='$LOGFILE'
DOCKER_CONFIG_MOUNT='$DOCKER_CONFIG_MOUNT'
REGISTRY_URL='$REGISTRY_URL'
LATEST_HELPER_VERSION='$LATEST_HELPER_VERSION'
LATEST_IMAGE='$LATEST_IMAGE'
# Stop and remove containers
echo 'Stopping existing Coolify containers...' >>\"\$LOGFILE\"
for container in coolify coolify-db coolify-redis coolify-realtime; do
if docker ps -a --format '{{.Names}}' | grep -q \"^\${container}\$\"; then
docker stop \"\$container\" >>\"\$LOGFILE\" 2>&1 || true
docker rm \"\$container\" >>\"\$LOGFILE\" 2>&1 || true
echo \" - Removed container: \$container\" >>\"\$LOGFILE\"
fi
done
# Start new containers
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
echo 'docker-compose.custom.yml detected.' >>\"\$LOGFILE\"
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
else
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
fi
echo 'Upgrade completed.' >>\"\$LOGFILE\"
" >>"$LOGFILE" 2>&1 &

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.454"
"version": "4.0.0-beta.455"
},
"nightly": {
"version": "4.0.0-beta.455"
"version": "4.0.0-beta.456"
},
"helper": {
"version": "1.0.12"

View file

@ -29,17 +29,23 @@
@php
use App\Models\InstanceSettings;
// Global setting to disable ALL two-step confirmation (text + password)
$disableTwoStepConfirmation = data_get(InstanceSettings::get(), 'disable_two_step_confirmation');
// Skip ONLY password confirmation for OAuth users (they have no password)
$skipPasswordConfirmation = shouldSkipPasswordConfirmation();
if ($temporaryDisableTwoStepConfirmation) {
$disableTwoStepConfirmation = false;
$skipPasswordConfirmation = false;
}
// When password step is skipped, Step 2 becomes final - change button text from "Continue" to "Confirm"
$effectiveStep2ButtonText = ($skipPasswordConfirmation && $step2ButtonText === 'Continue') ? 'Confirm' : $step2ButtonText;
@endphp
<div {{ $ignoreWire ? 'wire:ignore' : '' }} x-data="{
modalOpen: false,
step: {{ empty($checkboxes) ? 2 : 1 }},
initialStep: {{ empty($checkboxes) ? 2 : 1 }},
finalStep: {{ $confirmWithPassword && !$disableTwoStepConfirmation ? 3 : 2 }},
finalStep: {{ $confirmWithPassword && !$skipPasswordConfirmation ? 3 : 2 }},
deleteText: '',
password: '',
actions: @js($actions),
@ -50,7 +56,7 @@
})(),
userConfirmationText: '',
confirmWithText: @js($confirmWithText && !$disableTwoStepConfirmation),
confirmWithPassword: @js($confirmWithPassword && !$disableTwoStepConfirmation),
confirmWithPassword: @js($confirmWithPassword && !$skipPasswordConfirmation),
submitAction: @js($submitAction),
dispatchAction: @js($dispatchAction),
passwordError: '',
@ -59,6 +65,7 @@
dispatchEventType: @js($dispatchEventType),
dispatchEventMessage: @js($dispatchEventMessage),
disableTwoStepConfirmation: @js($disableTwoStepConfirmation),
skipPasswordConfirmation: @js($skipPasswordConfirmation),
resetModal() {
this.step = this.initialStep;
this.deleteText = '';
@ -68,7 +75,7 @@
$wire.$refresh();
},
step1ButtonText: @js($step1ButtonText),
step2ButtonText: @js($step2ButtonText),
step2ButtonText: @js($effectiveStep2ButtonText),
step3ButtonText: @js($step3ButtonText),
validatePassword() {
if (this.confirmWithPassword && !this.password) {
@ -92,10 +99,14 @@
const paramsMatch = this.submitAction.match(/\((.*?)\)/);
const params = paramsMatch ? paramsMatch[1].split(',').map(param => param.trim()) : [];
if (this.confirmWithPassword) {
params.push(this.password);
// Always pass password parameter (empty string if password confirmation is skipped)
// This ensures consistent method signature for backend Livewire methods
params.push(this.confirmWithPassword ? this.password : '');
// Only pass selectedActions if there are checkboxes with selections
if (this.selectedActions.length > 0) {
params.push(this.selectedActions);
}
params.push(this.selectedActions);
return $wire[methodName](...params)
.then(result => {
if (result === true) {
@ -316,7 +327,7 @@ class="w-auto" isError
if (dispatchEvent) {
$wire.dispatch(dispatchEventType, dispatchEventMessage);
}
if (confirmWithPassword && !disableTwoStepConfirmation) {
if (confirmWithPassword && !skipPasswordConfirmation) {
step++;
} else {
modalOpen = false;
@ -330,7 +341,7 @@ class="w-auto" isError
</div>
<!-- Step 3: Password confirmation -->
@if (!$disableTwoStepConfirmation)
@if (!$skipPasswordConfirmation)
<div x-show="step === 3 && confirmWithPassword">
<x-callout type="danger" title="Final Confirmation" class="mb-4">
Please enter your password to confirm this destructive action.

View file

@ -0,0 +1,152 @@
@props(['step' => 0])
{{--
Step Mapping (Backend UI):
Backend steps 1-2 (config download, env update) UI Step 1: Preparing
Backend step 3 (pulling images) UI Step 2: Helper + UI Step 3: Image
Backend steps 4-5 (stop/start containers) UI Step 4: Restart
Backend step 6 (complete) mapped in JS mapStepToUI() in upgrade.blade.php
The currentStep variable is inherited from parent Alpine component (upgradeModal).
--}}
<div class="w-full max-w-md mx-auto" x-data="{ activeStep: {{ $step }} }" x-effect="activeStep = $el.closest('[x-data]')?.__x?.$data?.currentStep ?? {{ $step }}">
<div class="flex items-center justify-between">
{{-- Step 1: Preparing --}}
<div class="flex items-center flex-1">
<div class="flex flex-col items-center">
<div class="flex items-center justify-center size-8 rounded-full border-2 transition-all duration-300"
:class="{
'bg-success border-success': currentStep > 1,
'bg-warning/20 border-warning': currentStep === 1,
'border-neutral-400 dark:border-coolgray-300': currentStep < 1
}">
<template x-if="currentStep > 1">
<svg class="size-4 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</template>
<template x-if="currentStep === 1">
<svg class="size-4 text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</template>
<template x-if="currentStep < 1">
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400">1</span>
</template>
</div>
<span class="mt-1.5 text-xs font-medium transition-colors duration-300"
:class="{
'text-success': currentStep > 1,
'text-warning': currentStep === 1,
'text-neutral-500 dark:text-neutral-400': currentStep < 1
}">Preparing</span>
</div>
<div class="flex-1 h-0.5 mx-2 transition-all duration-300"
:class="currentStep > 1 ? 'bg-success' : 'bg-neutral-300 dark:bg-coolgray-300'"></div>
</div>
{{-- Step 2: Helper --}}
<div class="flex items-center flex-1">
<div class="flex flex-col items-center">
<div class="flex items-center justify-center size-8 rounded-full border-2 transition-all duration-300"
:class="{
'bg-success border-success': currentStep > 2,
'bg-warning/20 border-warning': currentStep === 2,
'border-neutral-400 dark:border-coolgray-300': currentStep < 2
}">
<template x-if="currentStep > 2">
<svg class="size-4 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</template>
<template x-if="currentStep === 2">
<svg class="size-4 text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</template>
<template x-if="currentStep < 2">
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400">2</span>
</template>
</div>
<span class="mt-1.5 text-xs font-medium transition-colors duration-300"
:class="{
'text-success': currentStep > 2,
'text-warning': currentStep === 2,
'text-neutral-500 dark:text-neutral-400': currentStep < 2
}">Helper</span>
</div>
<div class="flex-1 h-0.5 mx-2 transition-all duration-300"
:class="currentStep > 2 ? 'bg-success' : 'bg-neutral-300 dark:bg-coolgray-300'"></div>
</div>
{{-- Step 3: Image --}}
<div class="flex items-center flex-1">
<div class="flex flex-col items-center">
<div class="flex items-center justify-center size-8 rounded-full border-2 transition-all duration-300"
:class="{
'bg-success border-success': currentStep > 3,
'bg-warning/20 border-warning': currentStep === 3,
'border-neutral-400 dark:border-coolgray-300': currentStep < 3
}">
<template x-if="currentStep > 3">
<svg class="size-4 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</template>
<template x-if="currentStep === 3">
<svg class="size-4 text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</template>
<template x-if="currentStep < 3">
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400">3</span>
</template>
</div>
<span class="mt-1.5 text-xs font-medium transition-colors duration-300"
:class="{
'text-success': currentStep > 3,
'text-warning': currentStep === 3,
'text-neutral-500 dark:text-neutral-400': currentStep < 3
}">Image</span>
</div>
<div class="flex-1 h-0.5 mx-2 transition-all duration-300"
:class="currentStep > 3 ? 'bg-success' : 'bg-neutral-300 dark:bg-coolgray-300'"></div>
</div>
{{-- Step 4: Restart --}}
<div class="flex items-center">
<div class="flex flex-col items-center">
<div class="flex items-center justify-center size-8 rounded-full border-2 transition-all duration-300"
:class="{
'bg-success border-success': currentStep > 4,
'bg-warning/20 border-warning': currentStep === 4,
'border-neutral-400 dark:border-coolgray-300': currentStep < 4
}">
<template x-if="currentStep > 4">
<svg class="size-4 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</template>
<template x-if="currentStep === 4">
<svg class="size-4 text-warning animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</template>
<template x-if="currentStep < 4">
<span class="text-xs font-medium text-neutral-500 dark:text-neutral-400">4</span>
</template>
</div>
<span class="mt-1.5 text-xs font-medium transition-colors duration-300"
:class="{
'text-success': currentStep > 4,
'text-warning': currentStep === 4,
'text-neutral-500 dark:text-neutral-400': currentStep < 4
}">Restart</span>
</div>
</div>
</div>
</div>

View file

@ -6,7 +6,7 @@
<livewire:project.shared.configuration-checker :resource="$application" />
<livewire:project.application.heading :application="$application" />
<div x-data="{
fullscreen: false,
fullscreen: @entangle('fullscreen'),
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
intervalId: null,
showTimestamps: true,
@ -34,6 +34,18 @@
if (!this.searchQuery.trim()) return true;
return text.toLowerCase().includes(this.searchQuery.toLowerCase());
},
hasActiveLogSelection() {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return false;
}
const logsContainer = document.getElementById('logs');
if (!logsContainer) return false;
// Check if selection is within the logs container
const range = selection.getRangeAt(0);
return logsContainer.contains(range.commonAncestorContainer);
},
decodeHtml(text) {
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS
let decoded = text;
@ -50,6 +62,12 @@
return decoded;
},
renderHighlightedLog(el, text) {
// Skip re-render if user has text selected in logs (preserves copy ability)
// But always render if the element is empty (initial render)
if (el.textContent && this.hasActiveLogSelection()) {
return;
}
const decoded = this.decodeHtml(text);
el.textContent = '';
@ -129,6 +147,12 @@
}
},
init() {
// Prevent Livewire from morphing logs container when text is selected
Livewire.hook('morph.updating', ({ el, component, toEl, skip }) => {
if (el.id === 'logs' && this.hasActiveLogSelection()) {
skip();
}
});
// Re-render logs after Livewire updates
document.addEventListener('livewire:navigated', () => {
this.$nextTick(() => { this.renderTrigger++; });
@ -158,28 +182,28 @@
}">
<livewire:project.application.deployment-navbar
:application_deployment_queue="$application_deployment_queue" />
@if (data_get($application_deployment_queue, 'status') === 'in_progress')
<div class="flex items-center gap-1 pt-2 ">Deployment is
<div class="dark:text-warning">
{{ Str::headline(data_get($this->application_deployment_queue, 'status')) }}.
</div>
<x-loading class="loading-ring" />
</div>
{{-- <div class="">Logs will be updated automatically.</div> --}}
@else
<div class="pt-2 ">Deployment is <span
class="dark:text-warning">{{ Str::headline(data_get($application_deployment_queue, 'status')) }}</span>.
</div>
@endif
<div id="screen" :class="fullscreen ? 'fullscreen flex flex-col' : 'relative'">
<div id="screen" :class="fullscreen ? 'fullscreen flex flex-col' : 'mt-4 relative'">
<div @if ($isKeepAliveOn) wire:poll.2000ms="polling" @endif
class="flex flex-col w-full bg-white dark:text-white dark:bg-coolgray-100 dark:border-coolgray-300"
:class="fullscreen ? 'h-full' : 'mt-4 border border-dotted rounded-sm'">
:class="fullscreen ? 'h-full' : 'border border-dotted rounded-sm'">
<div
class="flex items-center justify-between gap-2 px-4 py-2 border-b dark:border-coolgray-300 border-neutral-200 shrink-0">
<span x-show="searchQuery.trim()" x-text="getMatchCount() + ' matches'"
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
<span x-show="!searchQuery.trim()"></span>
<div class="flex items-center gap-3">
@if (data_get($application_deployment_queue, 'status') === 'in_progress')
<div class="flex items-center gap-1">
<span>Deployment is</span>
<span class="dark:text-warning">In Progress</span>
<x-loading class="loading-ring loading-xs" />
</div>
@else
<div class="flex items-center gap-1">
<span>Deployment is</span>
<span class="dark:text-warning">{{ Str::headline(data_get($application_deployment_queue, 'status')) }}</span>
</div>
@endif
<span x-show="searchQuery.trim()" x-text="getMatchCount() + ' matches'"
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"></span>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
@ -267,7 +291,7 @@ class="text-gray-500 dark:text-gray-400 py-2">
$searchableContent = $line['timestamp'] . ' ' . $lineContent;
@endphp
<div data-log-line data-log-content="{{ htmlspecialchars($searchableContent) }}"
x-bind:class="{ 'hidden': !matchesSearch($el.dataset.logContent) }" @class([
x-effect="renderTrigger; searchQuery; $el.classList.toggle('hidden', !matchesSearch($el.dataset.logContent))" @class([
'mt-2' => isset($line['command']) && $line['command'],
'flex gap-2',
])>

View file

@ -37,7 +37,7 @@
<livewire:project.service.stack-form :service="$service" />
<h3>Services</h3>
<div class="grid grid-cols-1 gap-2 pt-4 xl:grid-cols-1">
@if($applications->isEmpty() && $databases->isEmpty())
@if ($applications->isEmpty() && $databases->isEmpty())
<div class="p-4 text-sm text-neutral-500">
No services defined in this Docker Compose file.
</div>
@ -76,7 +76,8 @@
@if ($application->fqdn)
<span class="flex gap-1 text-xs">{{ Str::limit($application->fqdn, 60) }}
@can('update', $service)
<x-modal-input title="Edit Domains" :closeOutside="false" minWidth="32rem" maxWidth="40rem">
<x-modal-input title="Edit Domains" :closeOutside="false" minWidth="32rem"
maxWidth="40rem">
<x-slot:content>
<span class="cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg"
@ -100,7 +101,7 @@ class="w-4 h-4 dark:text-warning text-coollabs"
@endcan
</span>
@endif
<div class="pt-2 text-xs">{{ formatContainerStatus($application->status) }}</div>
<div class="pt-2 text-xs">{{ formatContainerStatus($application->status) }}</div>
</div>
<div class="flex items-center px-4">
<a class="mx-4 text-xs font-bold hover:underline"
@ -149,7 +150,7 @@ class="w-4 h-4 dark:text-warning text-coollabs"
@if ($database->description)
<span class="text-xs">{{ Str::limit($database->description, 60) }}</span>
@endif
<div class="text-xs">{{ formatContainerStatus($database->status) }}</div>
<div class="text-xs">{{ formatContainerStatus($database->status) }}</div>
</div>
<div class="flex items-center px-4">
@if ($database->isBackupSolutionAvailable() || $database->is_migrated)
@ -185,10 +186,6 @@ class="w-4 h-4 dark:text-warning text-coollabs"
<h2>Storages</h2>
</div>
<div class="pb-4">Persistent storage to preserve data between deployments.</div>
<div class="pb-4 dark:text-warning text-coollabs">If you would like to add a volume, you must add it to
your compose file (<a class="underline"
href="{{ route('project.service.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'service_uuid' => $service->uuid]) }}">General
tab</a>).</div>
@foreach ($applications as $application)
<livewire:project.service.storage wire:key="application-{{ $application->id }}"
:resource="$application" />

View file

@ -65,6 +65,7 @@
@endif
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
rows="20" id="content"
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
@ -79,12 +80,19 @@
@endif
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
rows="20" id="content" disabled></x-forms.textarea>
@endcan
@endif
@else
{{-- Read-only view --}}
@if (!$fileStorage->is_directory)
@can('view', $resource)
<div class="flex gap-2">
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from
server</x-forms.button>
</div>
@endcan
@if (data_get($resource, 'settings.is_preserve_repository_enabled'))
<div class="w-96">
<x-forms.checkbox disabled label="Is this based on the Git repository?"
@ -93,6 +101,7 @@
@endif
<x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
rows="20" id="content" disabled></x-forms.textarea>
@endif
@endif

View file

@ -275,15 +275,9 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
</div>
<div>Persistent storage to preserve data between deployments.</div>
</div>
@if ($resource?->build_pack === 'dockercompose')
<div class="dark:text-warning text-coollabs">Please modify storage layout in your Docker Compose
file or reload the compose file to reread the storage layout.</div>
@else
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
<div>No storage found.</div>
@endif
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
<div>No storage found.</div>
@endif
@php
$hasVolumes = $this->volumeCount > 0;
$hasFiles = $this->fileCount > 0;
@ -370,7 +364,6 @@ class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark
<h2>{{ Str::headline($resource->name) }}</h2>
</div>
</div>
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
<div>No storage found.</div>
@endif

View file

@ -1,28 +1,28 @@
<div class="{{ $collapsible ? 'my-4 border dark:border-coolgray-200 border-neutral-200' : '' }}">
<div id="screen" x-data="{
collapsible: {{ $collapsible ? 'true' : 'false' }},
expanded: {{ ($expandByDefault || !$collapsible) ? 'true' : 'false' }},
logsLoaded: false,
fullscreen: false,
alwaysScroll: false,
intervalId: null,
scrollDebounce: null,
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
searchQuery: '',
containerName: '{{ $container ?? "logs" }}',
makeFullscreen() {
this.fullscreen = !this.fullscreen;
if (this.fullscreen === false) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
}
},
handleKeyDown(event) {
if (event.key === 'Escape' && this.fullscreen) {
this.makeFullscreen();
}
},
} @keydown.window="handleKeyDown($event)">
<div id="screen" x-data="{
collapsible: {{ $collapsible ? 'true' : 'false' }},
expanded: {{ ($expandByDefault || !$collapsible) ? 'true' : 'false' }},
logsLoaded: false,
fullscreen: false,
alwaysScroll: false,
intervalId: null,
scrollDebounce: null,
colorLogs: localStorage.getItem('coolify-color-logs') === 'true',
searchQuery: '',
renderTrigger: 0,
containerName: '{{ $container ?? "logs" }}',
makeFullscreen() {
this.fullscreen = !this.fullscreen;
if (this.fullscreen === false) {
this.alwaysScroll = false;
clearInterval(this.intervalId);
}
},
handleKeyDown(event) {
if (event.key === 'Escape' && this.fullscreen) {
this.makeFullscreen();
}
},
isScrolling: false,
toggleScroll() {
this.alwaysScroll = !this.alwaysScroll;
@ -86,6 +86,18 @@
if (!this.searchQuery.trim()) return true;
return line.toLowerCase().includes(this.searchQuery.toLowerCase());
},
hasActiveLogSelection() {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return false;
}
const logsContainer = document.getElementById('logs');
if (!logsContainer) return false;
// Check if selection is within the logs container
const range = selection.getRangeAt(0);
return logsContainer.contains(range.commonAncestorContainer);
},
decodeHtml(text) {
// Decode HTML entities, handling double-encoding with max iteration limit to prevent DoS
let decoded = text;
@ -102,6 +114,12 @@
return decoded;
},
renderHighlightedLog(el, text) {
// Skip re-render if user has text selected in logs (preserves copy ability)
// But always render if the element is empty (initial render)
if (el.textContent && this.hasActiveLogSelection()) {
return;
}
const decoded = this.decodeHtml(text);
el.textContent = '';
@ -173,8 +191,20 @@
this.$wire.getLogs(true);
this.logsLoaded = true;
}
// Prevent Livewire from morphing logs container when text is selected
Livewire.hook('morph.updating', ({ el, component, toEl, skip }) => {
if (el.id === 'logs' && this.hasActiveLogSelection()) {
skip();
}
});
// Re-render logs after Livewire updates
Livewire.hook('commit', ({ succeed }) => {
succeed(() => {
this.$nextTick(() => { this.renderTrigger++; });
});
});
}
}">
}" @keydown.window="handleKeyDown($event)">
@if ($collapsible)
<div class="flex gap-2 items-center p-4 cursor-pointer select-none hover:bg-gray-50 dark:hover:bg-coolgray-200"
x-on:click="expanded = !expanded; if (expanded && !logsLoaded) { $wire.getLogs(true); logsLoaded = true; }">
@ -241,6 +271,23 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
<button wire:click="toggleStreamLogs"
title="{{ $streamLogs ? 'Stop Streaming' : 'Stream Logs' }}"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 {{ $streamLogs ? '!text-warning' : '' }}">
@if ($streamLogs)
{{-- Pause icon --}}
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="currentColor">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
@else
{{-- Play icon --}}
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="currentColor">
<path d="M8 5v14l11-7L8 5z" />
</svg>
@endif
</button>
<button x-on:click="downloadLogs()" title="Download Logs"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
@ -266,23 +313,6 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
d="M9.53 16.122a3 3 0 0 0-5.78 1.128 2.25 2.25 0 0 1-2.4 2.245 4.5 4.5 0 0 0 8.4-2.245c0-.399-.078-.78-.22-1.128Zm0 0a15.998 15.998 0 0 0 3.388-1.62m-5.043-.025a15.994 15.994 0 0 1 1.622-3.395m3.42 3.42a15.995 15.995 0 0 0 4.764-4.648l3.876-5.814a1.151 1.151 0 0 0-1.597-1.597L14.146 6.32a15.996 15.996 0 0 0-4.649 4.763m3.42 3.42a6.776 6.776 0 0 0-3.42-3.42" />
</svg>
</button>
<button wire:click="toggleStreamLogs"
title="{{ $streamLogs ? 'Stop Streaming' : 'Stream Logs' }}"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 {{ $streamLogs ? '!text-warning' : '' }}">
@if ($streamLogs)
{{-- Pause icon --}}
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="currentColor">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
@else
{{-- Play icon --}}
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
fill="currentColor">
<path d="M8 5v14l11-7L8 5z" />
</svg>
@endif
</button>
<button title="Follow Logs" :class="alwaysScroll ? '!text-warning' : ''"
x-on:click="toggleScroll"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
@ -356,8 +386,8 @@ class="text-gray-500 dark:text-gray-400 py-2">
@endphp
<div data-log-line data-log-content="{{ $line }}"
x-effect="renderTrigger; searchQuery; $el.classList.toggle('hidden', !matchesSearch($el.dataset.logContent))"
x-bind:class="{
'hidden': !matchesSearch($el.dataset.logContent),
'bg-red-500/10 dark:bg-red-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'error',
'bg-yellow-500/10 dark:bg-yellow-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'warning',
'bg-purple-500/10 dark:bg-purple-500/15': colorLogs && getLogLevel($el.dataset.logContent) === 'debug',
@ -368,7 +398,7 @@ class="flex gap-2">
<span class="shrink-0 text-gray-500">{{ $timestamp }}</span>
@endif
<span data-line-text="{{ $logContent }}"
x-effect="searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
x-effect="renderTrigger; searchQuery; renderHighlightedLog($el, $el.dataset.lineText)"
class="whitespace-pre-wrap break-all"></span>
</div>
@endforeach

View file

@ -5,7 +5,7 @@
helper="You can use every_minute, hourly, daily, weekly, monthly, yearly or a cron expression." id="frequency"
label="Frequency" />
<x-forms.input type="number" placeholder="300" id="timeout"
helper="Maximum execution time in seconds (60-3600). Default is 300 seconds (5 minutes)."
helper="Maximum execution time in seconds (60-36000). Default is 300 seconds (5 minutes)."
label="Timeout (seconds)" />
@if ($type === 'application')
@if ($containerNames->count() > 1)

View file

@ -36,7 +36,7 @@
<x-forms.input placeholder="php artisan schedule:run" id="command" label="Command" required />
<x-forms.input placeholder="0 0 * * * or daily" id="frequency" label="Frequency" required />
<x-forms.input type="number" placeholder="300" id="timeout"
helper="Maximum execution time in seconds (60-3600)." label="Timeout (seconds)" required />
helper="Maximum execution time in seconds (60-36000)." label="Timeout (seconds)" required />
@if ($type === 'application')
<x-forms.input placeholder="php"
helper="You can leave this empty if your resource only has one container." id="container"

View file

@ -1,5 +1,11 @@
<div>
<div class="flex flex-col gap-4">
@if ($resource->type() === 'service' || data_get($resource, 'build_pack') === 'dockercompose')
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
Volume mounts are read-only. If you would like to add or modify a volume, you must edit your Docker
Compose file and reload the compose file.
</div>
@endif
@foreach ($resource->persistentStorages as $storage)
@if ($resource->type() === 'service')
<livewire:project.shared.storages.show wire:key="storage-{{ $storage->id }}" :storage="$storage"

View file

@ -1,9 +1,11 @@
<div>
<form wire:submit='submit' class="flex flex-col items-center gap-4 p-4 bg-white border lg:items-start dark:bg-base dark:border-coolgray-300 border-neutral-200">
@if ($isReadOnly)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
This volume is mounted as read-only and cannot be modified from the UI.
</div>
@if (!$storage->isServiceResource() && !$storage->isDockerComposeResource())
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
This volume is mounted as read-only and cannot be modified from the UI.
</div>
@endif
@if ($isFirst)
<div class="flex gap-2 items-end w-full md:flex-row flex-col">
@if (

View file

@ -2,11 +2,11 @@
<div class="flex items-center gap-2">
<h2>Webhooks</h2>
<x-helper
helper="For more details goto our <a class='underline dark:text-white' href='https://coolify.io/docs/api/operations/deploy-by-tag-or-uuid' target='_blank'>docs</a>." />
helper="For more details goto our <a class='underline dark:text-white' href='https://coolify.io/docs/api-reference/api/operations/deploy-by-tag-or-uuid' target='_blank'>docs</a>." />
</div>
<div>
<x-forms.input readonly
helper="See details in our <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/api/operations/deploy-by-tag-or-uuid'>documentation</a>."
helper="See details in our <a target='_blank' class='underline dark:text-white' href='https://coolify.io/docs/api-reference/api/operations/deploy-by-tag-or-uuid'>documentation</a>."
label="Deploy Webhook (auth required)" id="deploywebhook"></x-forms.input>
</div>
@if ($resource->type() === 'application')

View file

@ -320,6 +320,64 @@ class="w-full input opacity-50 cursor-not-allowed"
</div>
</div>
</form>
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
<div class="pt-6">
<h3>Link to Hetzner Cloud</h3>
<p class="pb-4 text-sm dark:text-neutral-400">
Link this server to a Hetzner Cloud instance to enable power controls and status monitoring.
</p>
<div class="flex flex-wrap gap-4 items-end">
<div class="w-72">
<x-forms.select wire:model="selectedHetznerTokenId" label="Hetzner Token"
canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableHetznerTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<x-forms.button wire:click="searchHetznerServer"
wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchHetznerServer">Search by IP</span>
<span wire:loading wire:target="searchHetznerServer">Searching...</span>
</x-forms.button>
</div>
@if ($hetznerSearchError)
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
<p class="text-red-600 dark:text-red-400">{{ $hetznerSearchError }}</p>
</div>
@endif
@if ($hetznerNoMatchFound)
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
<p class="text-yellow-600 dark:text-yellow-400">
No Hetzner server found matching IP: {{ $server->ip }}
</p>
<p class="text-sm dark:text-neutral-400 mt-1">
Try a different token or verify the server IP is correct.
</p>
</div>
@endif
@if ($matchedHetznerServer)
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
<div><span class="font-medium">Name:</span> {{ $matchedHetznerServer['name'] }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedHetznerServer['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedHetznerServer['status']) }}</div>
<div><span class="font-medium">Type:</span> {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}</div>
</div>
<x-forms.button wire:click="linkToHetzner" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
@endif
@if ($server->isFunctional() && !$server->isSwarm() && !$server->isBuildServer())
<form wire:submit.prevent='submit'>
<div class="flex gap-2 items-center pt-4 pb-2">

View file

@ -1,21 +1,22 @@
<div @if ($isUpgradeAvailable) title="New version available" @else title="No upgrade available" @endif
x-init="$wire.checkUpdate" x-data="upgradeModal">
x-init="$wire.checkUpdate" x-data="upgradeModal({
currentVersion: @js($currentVersion),
latestVersion: @js($latestVersion)
})">
@if ($isUpgradeAvailable)
<div :class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
<button class="menu-item" @click="modalOpen=true" x-show="showProgress">
<svg xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-pink-500 transition-colors hover:text-pink-300 lds-heart" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M19.5 13.572l-7.5 7.428l-7.5 -7.428m0 0a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" />
</svg>
In progress
</button>
<button class="menu-item cursor-pointer" @click="modalOpen=true" x-show="!showProgress">
<svg xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6 text-pink-500 transition-colors hover:text-pink-300" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-pink-500 transition-colors hover:text-pink-300"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round"
stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
@ -28,10 +29,9 @@ class="w-6 h-6 text-pink-500 transition-colors hover:text-pink-300" viewBox="0 0
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed top-0 lg:pt-10 left-0 z-99 flex items-start justify-center w-screen h-screen" x-cloak>
<div x-show="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-100" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
<div x-show="modalOpen" x-transition:enter="ease-out duration-100" x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100" x-transition:leave="ease-in duration-100"
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
<div x-show="modalOpen" x-trap.inert.noscroll="modalOpen" x-transition:enter="ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-2 sm:scale-95"
@ -40,44 +40,131 @@ class="absolute inset-0 w-full h-full bg-black/20 backdrop-blur-xs"></div>
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 -translate-y-2 sm:scale-95"
class="relative w-full py-6 border rounded-sm min-w-full lg:min-w-[36rem] max-w-fit bg-neutral-100 border-neutral-400 dark:bg-base px-7 dark:border-coolgray-300">
{{-- Header --}}
<div class="flex items-center justify-between pb-3">
<h3 class="text-lg font-semibold">Upgrade confirmation</h3>
<button x-show="!showProgress" @click="modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 text-gray-600 rounded-full hover:text-gray-800 hover:bg-gray-50">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<div>
<h3 class="text-lg font-semibold"
x-text="upgradeComplete ? 'Upgrade Complete!' : (showProgress ? 'Upgrading...' : 'Upgrade Available')">
</h3>
<div class="text-sm text-neutral-500 dark:text-neutral-400">
{{ $currentVersion }} <span class="mx-1">&rarr;</span> {{ $latestVersion }}
</div>
</div>
<button x-show="!showProgress || upgradeError" @click="upgradeError ? closeErrorModal() : modalOpen=false"
class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5 text-gray-600 rounded-full hover:text-gray-800 hover:bg-gray-50 dark:text-neutral-400 dark:hover:text-white dark:hover:bg-coolgray-300">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="relative w-auto pb-8">
<p>Are you sure you would like to upgrade your instance to {{ $latestVersion }}?</p>
<br />
<x-callout type="warning" title="Caution">
<p>Any deployments running during the update process will
fail. Please ensure no deployments are in progress on any server before continuing.
</p>
</x-callout>
<br />
<p>You can review the changelogs <a class="font-bold underline dark:text-white"
href="https://github.com/coollabsio/coolify/releases" target="_blank">here</a>.</p>
<br />
<p>If something goes wrong and you cannot upgrade your instance, You can check the following
<a class="font-bold underline dark:text-white" href="https://coolify.io/docs/upgrade"
target="_blank">guide</a> on what to do.
</p>
<div class="flex flex-col pt-4" x-show="showProgress">
<h2>Progress <x-loading /></h2>
<div x-html="currentStatus"></div>
</div>
{{-- Content --}}
<div class="relative w-auto pb-6">
{{-- Progress View --}}
<template x-if="showProgress">
<div class="space-y-6">
{{-- Step Progress Indicator --}}
<div class="pt-2">
<x-upgrade-progress />
</div>
{{-- Elapsed Time --}}
<div class="text-center">
<span class="text-sm text-neutral-500 dark:text-neutral-400">Elapsed time:</span>
<span class="ml-2 font-mono text-sm" x-text="formatElapsedTime()"></span>
</div>
{{-- Current Status Message --}}
<div class="p-4 rounded-lg bg-neutral-200 dark:bg-coolgray-200">
<div class="flex items-center gap-3">
<template x-if="!upgradeComplete && !upgradeError">
<svg class="w-5 h-5 text-warning animate-spin"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</template>
<template x-if="upgradeComplete">
<svg class="w-5 h-5 text-success" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
</template>
<template x-if="upgradeError">
<svg class="w-5 h-5 text-error" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd" />
</svg>
</template>
<span x-text="currentStatus" class="text-sm"></span>
</div>
</div>
{{-- Success State with Countdown --}}
<template x-if="upgradeComplete">
<div class="flex flex-col items-center gap-4">
<p class="text-sm text-neutral-500 dark:text-neutral-400">
Reloading in <span x-text="successCountdown"
class="font-bold text-warning"></span> seconds...
</p>
<x-forms.button @click="reloadNow()" type="button">
Reload Now
</x-forms.button>
</div>
</template>
{{-- Error State with Close Button --}}
<template x-if="upgradeError">
<div class="flex flex-col items-center gap-4">
<p class="text-sm text-neutral-600 dark:text-neutral-400">
Check the logs on the server at /data/coolify/source/upgrade*.
</p>
<x-forms.button @click="closeErrorModal()" type="button">
Close
</x-forms.button>
</div>
</template>
</div>
</template>
{{-- Confirmation View --}}
<template x-if="!showProgress">
<div class="space-y-4">
{{-- Warning --}}
<x-callout type="warning" title="Caution">
<p>Any deployments running during the update process will
fail.
</p>
</x-callout>
{{-- Help Links --}}
<p class="text-sm text-neutral-600 dark:text-neutral-400">
If something goes wrong, check the
<a class="font-medium underline dark:text-white hover:text-neutral-800 dark:hover:text-neutral-300"
href="https://coolify.io/docs/upgrade" target="_blank">upgrade guide</a> or the
logs on the server at /data/coolify/source/upgrade*.
</p>
</div>
</template>
</div>
{{-- Footer Actions --}}
<div class="flex gap-4" x-show="!showProgress">
<x-forms.button @click="modalOpen=false"
class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">Cancel
</x-forms.button>
<div class="flex-1"></div>
<x-forms.button @click="confirmed" class="w-24" isHighlighted type="button">Continue
<x-forms.button @click="confirmed" class="w-32" isHighlighted type="button">
Upgrade Now
</x-forms.button>
</div>
</div>
@ -89,23 +176,64 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">Cancel
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('upgradeModal', () => ({
Alpine.data('upgradeModal', (config) => ({
modalOpen: false,
showProgress: false,
currentStatus: '',
checkHealthInterval: null,
checkIfIamDeadInterval: null,
checkUpgradeStatusInterval: null,
elapsedInterval: null,
healthCheckAttempts: 0,
startTime: null,
elapsedTime: 0,
currentStep: 0,
upgradeComplete: false,
upgradeError: false,
successCountdown: 3,
currentVersion: config.currentVersion || '',
latestVersion: config.latestVersion || '',
serviceDown: false,
confirmed() {
this.showProgress = true;
this.$wire.$call('upgrade')
this.currentStep = 1;
this.currentStatus = 'Starting upgrade...';
this.startTimer();
// Trigger server-side upgrade script via Livewire
this.$wire.$call('upgrade');
// Start client-side status polling
this.upgrade();
window.addEventListener('beforeunload', (event) => {
// Prevent accidental navigation during upgrade
this.beforeUnloadHandler = (event) => {
event.preventDefault();
event.returnValue = '';
});
};
window.addEventListener('beforeunload', this.beforeUnloadHandler);
},
startTimer() {
this.startTime = Date.now();
this.elapsedInterval = setInterval(() => {
this.elapsedTime = Math.floor((Date.now() - this.startTime) / 1000);
}, 1000);
},
formatElapsedTime() {
const minutes = Math.floor(this.elapsedTime / 60);
const seconds = this.elapsedTime % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
},
mapStepToUI(apiStep) {
// Map backend steps (1-6) to UI steps (1-4)
// Backend: 1=config, 2=env, 3=pull, 4=stop, 5=start, 6=complete
// UI: 1=prepare, 2=pull images, 3=pull coolify, 4=restart
if (apiStep <= 2) return 1;
if (apiStep === 3) return 2;
if (apiStep <= 5) return 3;
return 4;
},
getReviveStatusMessage(elapsedMinutes, attempts) {
if (elapsedMinutes === 0) {
return `Waiting for Coolify to come back online... (attempt ${attempts})`;
@ -119,68 +247,133 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300">Cancel
return `Still updating. If this takes longer than 15 minutes, please check server logs... (${elapsedMinutes} minutes elapsed)`;
}
},
revive() {
if (this.checkHealthInterval) return true;
this.healthCheckAttempts = 0;
this.startTime = Date.now();
console.log('Checking server\'s health...')
this.currentStep = 4;
console.log('Checking server\'s health...');
this.checkHealthInterval = setInterval(() => {
this.healthCheckAttempts++;
const elapsedMinutes = Math.floor((Date.now() - this.startTime) / 60000);
fetch('/api/health')
.then(response => {
if (response.ok) {
this.currentStatus =
'Coolify is back online. Reloading this page in 5 seconds...';
if (this.checkHealthInterval) {
clearInterval(this.checkHealthInterval);
this.checkHealthInterval = null;
}
setTimeout(() => {
window.location.reload();
}, 5000)
this.showSuccess();
} else {
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this
.healthCheckAttempts);
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
}
})
.catch(error => {
console.error('Health check failed:', error);
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this
.healthCheckAttempts);
this.currentStatus = this.getReviveStatusMessage(elapsedMinutes, this.healthCheckAttempts);
});
}, 2000);
},
showSuccess() {
if (this.checkHealthInterval) {
clearInterval(this.checkHealthInterval);
this.checkHealthInterval = null;
}
if (this.checkUpgradeStatusInterval) {
clearInterval(this.checkUpgradeStatusInterval);
this.checkUpgradeStatusInterval = null;
}
if (this.elapsedInterval) {
clearInterval(this.elapsedInterval);
this.elapsedInterval = null;
}
// Remove beforeunload handler now that upgrade is complete
if (this.beforeUnloadHandler) {
window.removeEventListener('beforeunload', this.beforeUnloadHandler);
this.beforeUnloadHandler = null;
}
this.upgradeComplete = true;
this.currentStep = 5;
this.currentStatus = `Successfully upgraded to ${this.latestVersion}`;
this.successCountdown = 3;
const countdownInterval = setInterval(() => {
this.successCountdown--;
if (this.successCountdown <= 0) {
clearInterval(countdownInterval);
window.location.reload();
}
}, 1000);
},
reloadNow() {
window.location.reload();
},
showError(message) {
// Stop all intervals
if (this.checkHealthInterval) {
clearInterval(this.checkHealthInterval);
this.checkHealthInterval = null;
}
if (this.checkUpgradeStatusInterval) {
clearInterval(this.checkUpgradeStatusInterval);
this.checkUpgradeStatusInterval = null;
}
if (this.elapsedInterval) {
clearInterval(this.elapsedInterval);
this.elapsedInterval = null;
}
// Remove beforeunload handler so user can close modal
if (this.beforeUnloadHandler) {
window.removeEventListener('beforeunload', this.beforeUnloadHandler);
this.beforeUnloadHandler = null;
}
this.upgradeError = true;
this.currentStatus = `Error: ${message}`;
},
closeErrorModal() {
this.modalOpen = false;
this.showProgress = false;
this.upgradeError = false;
this.currentStatus = '';
this.currentStep = 0;
},
upgrade() {
if (this.checkIfIamDeadInterval || this.showProgress) return true;
this.currentStatus = 'Update in progress. Pulling new images and preparing to restart Coolify...';
this.checkIfIamDeadInterval = setInterval(() => {
fetch('/api/health')
.then(response => {
if (response.ok) {
this.currentStatus =
"Update in progress. Pulling new images and preparing to restart Coolify..."
} else {
this.currentStatus = "Coolify is restarting with the new version..."
if (this.checkIfIamDeadInterval) {
clearInterval(this.checkIfIamDeadInterval);
this.checkIfIamDeadInterval = null;
}
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;
if (this.checkUpgradeStatusInterval) return true;
this.currentStep = 1;
this.currentStatus = 'Starting upgrade...';
this.serviceDown = false;
// Poll upgrade status via Livewire
this.checkUpgradeStatusInterval = setInterval(async () => {
try {
const data = await this.$wire.getUpgradeStatus();
if (data.status === 'in_progress') {
this.currentStep = this.mapStepToUI(data.step);
this.currentStatus = data.message;
} else if (data.status === 'complete') {
this.showSuccess();
} else if (data.status === 'error') {
this.showError(data.message);
}
} catch (error) {
// Service is down - switch to health check mode
console.log('Livewire unavailable, switching to health check mode');
if (!this.serviceDown) {
this.serviceDown = true;
this.currentStep = 4;
this.currentStatus = 'Coolify is restarting with the new version...';
if (this.checkUpgradeStatusInterval) {
clearInterval(this.checkUpgradeStatusInterval);
this.checkUpgradeStatusInterval = null;
}
this.revive();
});
}
}
}, 2000);
}
}))
})
</script>
</script>

View file

@ -7,27 +7,101 @@ LATEST_HELPER_VERSION=${2:-latest}
REGISTRY_URL=${3:-ghcr.io}
SKIP_BACKUP=${4:-false}
ENV_FILE="/data/coolify/source/.env"
STATUS_FILE="/data/coolify/source/.upgrade-status"
DATE=$(date +%Y-%m-%d-%H-%M-%S)
LOGFILE="/data/coolify/source/upgrade-${DATE}.log"
# Helper function to log with timestamp
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >>"$LOGFILE"
}
# Helper function to log section headers
log_section() {
echo "" >>"$LOGFILE"
echo "============================================================" >>"$LOGFILE"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >>"$LOGFILE"
echo "============================================================" >>"$LOGFILE"
}
# Helper function to write upgrade status for API polling
write_status() {
local step="$1"
local message="$2"
echo "${step}|${message}|$(date -Iseconds)" > "$STATUS_FILE"
}
echo ""
echo "=========================================="
echo " Coolify Upgrade - ${DATE}"
echo "=========================================="
echo ""
# Initialize log file with header
echo "============================================================" >>"$LOGFILE"
echo "Coolify Upgrade Log" >>"$LOGFILE"
echo "Started: $(date '+%Y-%m-%d %H:%M:%S')" >>"$LOGFILE"
echo "Target Version: ${LATEST_IMAGE}" >>"$LOGFILE"
echo "Helper Version: ${LATEST_HELPER_VERSION}" >>"$LOGFILE"
echo "Registry URL: ${REGISTRY_URL}" >>"$LOGFILE"
echo "============================================================" >>"$LOGFILE"
log_section "Step 1/6: Downloading configuration files"
write_status "1" "Downloading configuration files"
echo "1/6 Downloading latest configuration files..."
log "Downloading docker-compose.yml from ${CDN}/docker-compose.yml"
curl -fsSL -L $CDN/docker-compose.yml -o /data/coolify/source/docker-compose.yml
log "Downloading docker-compose.prod.yml from ${CDN}/docker-compose.prod.yml"
curl -fsSL -L $CDN/docker-compose.prod.yml -o /data/coolify/source/docker-compose.prod.yml
log "Downloading .env.production from ${CDN}/.env.production"
curl -fsSL -L $CDN/.env.production -o /data/coolify/source/.env.production
log "Configuration files downloaded successfully"
echo " Done."
# Extract all images from docker-compose configuration
log "Extracting all images from docker-compose configuration..."
COMPOSE_FILES="-f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml"
# Check if custom compose file exists
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
COMPOSE_FILES="$COMPOSE_FILES -f /data/coolify/source/docker-compose.custom.yml"
log "Including custom docker-compose.yml in image extraction"
fi
# Get all unique images from docker compose config
# LATEST_IMAGE env var is needed for image substitution in compose files
IMAGES=$(LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u)
if [ -z "$IMAGES" ]; then
log "ERROR: Failed to extract images from docker-compose files"
write_status "error" "Failed to parse docker-compose configuration"
echo " ERROR: Failed to parse docker-compose configuration. Aborting upgrade."
exit 1
fi
log "Images to pull:"
echo "$IMAGES" | while read img; do log " - $img"; done
# Backup existing .env file before making any changes
if [ "$SKIP_BACKUP" != "true" ]; then
if [ -f "$ENV_FILE" ]; then
echo "Creating backup of existing .env file to .env-$DATE" >>"$LOGFILE"
echo " Creating backup of .env file..."
log "Creating backup of .env file to .env-$DATE"
cp "$ENV_FILE" "$ENV_FILE-$DATE"
log "Backup created: ${ENV_FILE}-${DATE}"
else
echo "No existing .env file found to backup" >>"$LOGFILE"
log "WARNING: No existing .env file found to backup"
fi
fi
echo "Merging .env.production values into .env" >>"$LOGFILE"
log_section "Step 2/6: Updating environment configuration"
write_status "2" "Updating environment configuration"
echo ""
echo "2/6 Updating environment configuration..."
log "Merging .env.production values into .env"
awk -F '=' '!seen[$1]++' "$ENV_FILE" /data/coolify/source/.env.production > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
echo ".env file merged successfully" >>"$LOGFILE"
log "Environment file merged successfully"
update_env_var() {
local key="$1"
@ -36,37 +110,173 @@ update_env_var() {
# If variable "key=" exists but has no value, update the value of the existing line
if grep -q "^${key}=$" "$ENV_FILE"; then
sed -i "s|^${key}=$|${key}=${value}|" "$ENV_FILE"
echo " - Updated value of ${key} as the current value was empty" >>"$LOGFILE"
log "Updated ${key} (was empty)"
# If variable "key=" doesn't exist, append it to the file with value
elif ! grep -q "^${key}=" "$ENV_FILE"; then
printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
echo " - Added ${key} with default value as the variable was missing" >>"$LOGFILE"
log "Added ${key} (was missing)"
fi
}
echo "Checking and updating environment variables if necessary..." >>"$LOGFILE"
log "Checking environment variables..."
update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)"
log "Environment variables check complete"
echo " Done."
# Make sure coolify network exists
# It is created when starting Coolify with docker compose
log "Checking Docker network 'coolify'..."
if ! docker network inspect coolify >/dev/null 2>&1; then
log "Network 'coolify' does not exist, creating..."
if ! docker network create --attachable --ipv6 coolify 2>/dev/null; then
echo "Failed to create coolify network with ipv6. Trying without ipv6..."
log "Failed to create network with IPv6, trying without IPv6..."
docker network create --attachable coolify 2>/dev/null
log "Network 'coolify' created without IPv6"
else
log "Network 'coolify' created with IPv6 support"
fi
else
log "Network 'coolify' already exists"
fi
# Check if Docker config file exists
DOCKER_CONFIG_MOUNT=""
if [ -f /root/.docker/config.json ]; then
DOCKER_CONFIG_MOUNT="-v /root/.docker/config.json:/root/.docker/config.json"
log "Docker config mount enabled: /root/.docker/config.json"
fi
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
echo "docker-compose.custom.yml detected." >>"$LOGFILE"
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
log_section "Step 3/6: Pulling Docker images"
write_status "3" "Pulling Docker images"
echo ""
echo "3/6 Pulling Docker images..."
echo " This may take a few minutes depending on your connection."
# Also pull the helper image (not in compose files but needed for upgrade)
HELPER_IMAGE="${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}"
echo " - Pulling $HELPER_IMAGE..."
log "Pulling image: $HELPER_IMAGE"
if docker pull "$HELPER_IMAGE" >>"$LOGFILE" 2>&1; then
log "Successfully pulled $HELPER_IMAGE"
else
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock ${DOCKER_CONFIG_MOUNT} --rm ${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION} bash -c "LATEST_IMAGE=${LATEST_IMAGE} docker compose --project-name coolify --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60" >>"$LOGFILE" 2>&1
log "ERROR: Failed to pull $HELPER_IMAGE"
write_status "error" "Failed to pull $HELPER_IMAGE"
echo " ERROR: Failed to pull $HELPER_IMAGE. Aborting upgrade."
exit 1
fi
# Pull all images from compose config
# Using a for loop to avoid subshell issues with exit
for IMAGE in $IMAGES; do
if [ -n "$IMAGE" ]; then
echo " - Pulling $IMAGE..."
log "Pulling image: $IMAGE"
if docker pull "$IMAGE" >>"$LOGFILE" 2>&1; then
log "Successfully pulled $IMAGE"
else
log "ERROR: Failed to pull $IMAGE"
write_status "error" "Failed to pull $IMAGE"
echo " ERROR: Failed to pull $IMAGE. Aborting upgrade."
exit 1
fi
fi
done
log "All images pulled successfully"
echo " All images pulled successfully."
log_section "Step 4/6: Stopping and restarting containers"
write_status "4" "Stopping containers"
echo ""
echo "4/6 Stopping containers and starting new ones..."
echo " This step will restart all Coolify containers."
echo " Check the log file for details: ${LOGFILE}"
# From this point forward, we need to ensure the script continues even if
# the SSH connection is lost (which happens when coolify container stops)
# We use a subshell with nohup to ensure completion
log "Starting container restart sequence (detached)..."
nohup bash -c "
LOGFILE='$LOGFILE'
STATUS_FILE='$STATUS_FILE'
DOCKER_CONFIG_MOUNT='$DOCKER_CONFIG_MOUNT'
REGISTRY_URL='$REGISTRY_URL'
LATEST_HELPER_VERSION='$LATEST_HELPER_VERSION'
LATEST_IMAGE='$LATEST_IMAGE'
log() {
echo \"[\$(date '+%Y-%m-%d %H:%M:%S')] \$1\" >>\"\$LOGFILE\"
}
write_status() {
echo \"\$1|\$2|\$(date -Iseconds)\" > \"\$STATUS_FILE\"
}
# Stop and remove containers
for container in coolify coolify-db coolify-redis coolify-realtime; do
if docker ps -a --format '{{.Names}}' | grep -q \"^\${container}\$\"; then
log \"Stopping container: \${container}\"
docker stop \"\$container\" >>\"\$LOGFILE\" 2>&1 || true
log \"Removing container: \${container}\"
docker rm \"\$container\" >>\"\$LOGFILE\" 2>&1 || true
log \"Container \${container} stopped and removed\"
else
log \"Container \${container} not found (skipping)\"
fi
done
log \"Container cleanup complete\"
# Start new containers
echo '' >>\"\$LOGFILE\"
echo '============================================================' >>\"\$LOGFILE\"
log 'Step 5/6: Starting new containers'
echo '============================================================' >>\"\$LOGFILE\"
write_status '5' 'Starting new containers'
if [ -f /data/coolify/source/docker-compose.custom.yml ]; then
log 'Using custom docker-compose.yml'
log 'Running docker compose up with custom configuration...'
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml -f /data/coolify/source/docker-compose.custom.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
else
log 'Using standard docker-compose configuration'
log 'Running docker compose up...'
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env -f /data/coolify/source/docker-compose.yml -f /data/coolify/source/docker-compose.prod.yml up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
fi
log 'Docker compose up completed'
# Final log entry
echo '' >>\"\$LOGFILE\"
echo '============================================================' >>\"\$LOGFILE\"
log 'Step 6/6: Upgrade complete'
echo '============================================================' >>\"\$LOGFILE\"
write_status '6' 'Upgrade complete'
log 'Coolify upgrade completed successfully'
log \"Version: \${LATEST_IMAGE}\"
echo '' >>\"\$LOGFILE\"
echo '============================================================' >>\"\$LOGFILE\"
echo \"Upgrade completed: \$(date '+%Y-%m-%d %H:%M:%S')\" >>\"\$LOGFILE\"
echo '============================================================' >>\"\$LOGFILE\"
# Clean up status file after a short delay to allow frontend to read completion
sleep 10
rm -f \"\$STATUS_FILE\"
log 'Status file cleaned up'
" >>"$LOGFILE" 2>&1 &
# Give the background process a moment to start
sleep 2
log "Container restart sequence started in background (PID: $!)"
echo ""
echo "5/6 Containers are being restarted in the background..."
echo "6/6 Upgrade process initiated!"
echo ""
echo "=========================================="
echo " Coolify upgrade to ${LATEST_IMAGE} in progress"
echo "=========================================="
echo ""
echo " The upgrade will continue in the background."
echo " Coolify will be available again shortly."
echo " Log file: ${LOGFILE}"

View file

@ -7,7 +7,7 @@
services:
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
image: ghcr.io/umami-software/umami:3.0.2
environment:
- SERVICE_URL_UMAMI_3000
- DATABASE_URL=postgres://$SERVICE_USER_POSTGRES:$SERVICE_PASSWORD_POSTGRES@postgresql:5432/$POSTGRES_DB

View file

@ -4272,7 +4272,7 @@
"umami": {
"documentation": "https://umami.is?utm_source=coolify.io",
"slogan": "Umami is web analytics platform which provides insights into visitor behavior without compromising user privacy.",
"compose": "c2VydmljZXM6CiAgdW1hbWk6CiAgICBpbWFnZTogJ2doY3IuaW8vdW1hbWktc29mdHdhcmUvdW1hbWk6cG9zdGdyZXNxbC1sYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9VTUFNSV8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcG9zdGdyZXNxbDo1NDMyLyRQT1NUR1JFU19EQicKICAgICAgLSBEQVRBQkFTRV9UWVBFPXBvc3RncmVzCiAgICAgIC0gQVBQX1NFQ1JFVD0kU0VSVklDRV9QQVNTV09SRF82NF9VTUFNSQogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL2hlYXJ0YmVhdCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi11bWFtaX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
"compose": "c2VydmljZXM6CiAgdW1hbWk6CiAgICBpbWFnZTogJ2doY3IuaW8vdW1hbWktc29mdHdhcmUvdW1hbWk6My4wLjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9VTUFNSV8zMDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyRTRVJWSUNFX1VTRVJfUE9TVEdSRVM6JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNAcG9zdGdyZXNxbDo1NDMyLyRQT1NUR1JFU19EQicKICAgICAgLSBEQVRBQkFTRV9UWVBFPXBvc3RncmVzCiAgICAgIC0gQVBQX1NFQ1JFVD0kU0VSVklDRV9QQVNTV09SRF82NF9VTUFNSQogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAvYXBpL2hlYXJ0YmVhdCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi11bWFtaX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"analytics",
"insights",

View file

@ -4272,7 +4272,7 @@
"umami": {
"documentation": "https://umami.is?utm_source=coolify.io",
"slogan": "Umami is web analytics platform which provides insights into visitor behavior without compromising user privacy.",
"compose": "c2VydmljZXM6CiAgdW1hbWk6CiAgICBpbWFnZTogJ2doY3IuaW8vdW1hbWktc29mdHdhcmUvdW1hbWk6cG9zdGdyZXNxbC1sYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fVU1BTUlfMzAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBvc3RncmVzcWw6NTQzMi8kUE9TVEdSRVNfREInCiAgICAgIC0gREFUQUJBU0VfVFlQRT1wb3N0Z3JlcwogICAgICAtIEFQUF9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfVU1BTUkKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwL2FwaS9oZWFydGJlYXQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotdW1hbWl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
"compose": "c2VydmljZXM6CiAgdW1hbWk6CiAgICBpbWFnZTogJ2doY3IuaW8vdW1hbWktc29mdHdhcmUvdW1hbWk6My4wLjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fVU1BTUlfMzAwMAogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8kU0VSVklDRV9VU0VSX1BPU1RHUkVTOiRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTQHBvc3RncmVzcWw6NTQzMi8kUE9TVEdSRVNfREInCiAgICAgIC0gREFUQUJBU0VfVFlQRT1wb3N0Z3JlcwogICAgICAtIEFQUF9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfVU1BTUkKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwL2FwaS9oZWFydGJlYXQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotdW1hbWl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=",
"tags": [
"analytics",
"insights",

View file

@ -0,0 +1,152 @@
<?php
use App\Services\HetznerService;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
Http::preventStrayRequests();
});
it('getServers returns list of servers from Hetzner API', function () {
Http::fake([
'api.hetzner.cloud/v1/servers*' => Http::response([
'servers' => [
[
'id' => 12345,
'name' => 'test-server-1',
'status' => 'running',
'public_net' => [
'ipv4' => ['ip' => '123.45.67.89'],
'ipv6' => ['ip' => '2a01:4f8::/64'],
],
],
[
'id' => 67890,
'name' => 'test-server-2',
'status' => 'off',
'public_net' => [
'ipv4' => ['ip' => '98.76.54.32'],
'ipv6' => ['ip' => '2a01:4f9::/64'],
],
],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$service = new HetznerService('fake-token');
$servers = $service->getServers();
expect($servers)->toBeArray()
->and(count($servers))->toBe(2)
->and($servers[0]['id'])->toBe(12345)
->and($servers[1]['id'])->toBe(67890);
});
it('findServerByIp returns matching server by IPv4', function () {
Http::fake([
'api.hetzner.cloud/v1/servers*' => Http::response([
'servers' => [
[
'id' => 12345,
'name' => 'test-server',
'status' => 'running',
'public_net' => [
'ipv4' => ['ip' => '123.45.67.89'],
'ipv6' => ['ip' => '2a01:4f8::/64'],
],
],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$service = new HetznerService('fake-token');
$result = $service->findServerByIp('123.45.67.89');
expect($result)->not->toBeNull()
->and($result['id'])->toBe(12345)
->and($result['name'])->toBe('test-server');
});
it('findServerByIp returns null when no match', function () {
Http::fake([
'api.hetzner.cloud/v1/servers*' => Http::response([
'servers' => [
[
'id' => 12345,
'name' => 'test-server',
'status' => 'running',
'public_net' => [
'ipv4' => ['ip' => '123.45.67.89'],
'ipv6' => ['ip' => '2a01:4f8::/64'],
],
],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$service = new HetznerService('fake-token');
$result = $service->findServerByIp('1.2.3.4');
expect($result)->toBeNull();
});
it('findServerByIp returns null when server list is empty', function () {
Http::fake([
'api.hetzner.cloud/v1/servers*' => Http::response([
'servers' => [],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$service = new HetznerService('fake-token');
$result = $service->findServerByIp('123.45.67.89');
expect($result)->toBeNull();
});
it('findServerByIp matches correct server among multiple', function () {
Http::fake([
'api.hetzner.cloud/v1/servers*' => Http::response([
'servers' => [
[
'id' => 11111,
'name' => 'server-a',
'status' => 'running',
'public_net' => [
'ipv4' => ['ip' => '10.0.0.1'],
'ipv6' => ['ip' => '2a01:4f8::/64'],
],
],
[
'id' => 22222,
'name' => 'server-b',
'status' => 'running',
'public_net' => [
'ipv4' => ['ip' => '10.0.0.2'],
'ipv6' => ['ip' => '2a01:4f9::/64'],
],
],
[
'id' => 33333,
'name' => 'server-c',
'status' => 'off',
'public_net' => [
'ipv4' => ['ip' => '10.0.0.3'],
'ipv6' => ['ip' => '2a01:4fa::/64'],
],
],
],
'meta' => ['pagination' => ['next_page' => null]],
], 200),
]);
$service = new HetznerService('fake-token');
$result = $service->findServerByIp('10.0.0.2');
expect($result)->not->toBeNull()
->and($result['id'])->toBe(22222)
->and($result['name'])->toBe('server-b');
});

View file

@ -0,0 +1,219 @@
<?php
/**
* Unit tests to verify LocalFileVolume::isReadOnlyVolume() correctly detects
* read-only volumes in both short-form and long-form Docker Compose syntax.
*
* Related Issue: Volumes with read_only: true in long-form syntax were not
* being detected as read-only, allowing UI edits on files that should be protected.
*
* Related Files:
* - app/Models/LocalFileVolume.php
* - app/Livewire/Project/Service/FileStorage.php
*/
use Symfony\Component\Yaml\Yaml;
/**
* Helper function to parse volumes and detect read-only status.
* This mirrors the logic in LocalFileVolume::isReadOnlyVolume()
*
* Note: We match on mount_path (container path) only, since fs_path gets transformed
* from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing
*/
function isVolumeReadOnly(string $dockerComposeRaw, string $serviceName, string $mountPath): bool
{
$compose = Yaml::parse($dockerComposeRaw);
if (! isset($compose['services'][$serviceName]['volumes'])) {
return false;
}
$volumes = $compose['services'][$serviceName]['volumes'];
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
$parts = explode(':', $volume);
if (count($parts) >= 2) {
$containerPath = $parts[1];
$options = $parts[2] ?? null;
if ($containerPath === $mountPath) {
return $options === 'ro';
}
}
} elseif (is_array($volume)) {
// Long-form syntax: { type: bind, source: ..., target: ..., read_only: true }
$containerPath = data_get($volume, 'target');
$readOnly = data_get($volume, 'read_only', false);
if ($containerPath === $mountPath) {
return $readOnly === true;
}
}
}
return false;
}
test('detects read-only with short-form syntax using :ro', function () {
$compose = <<<'YAML'
services:
garage:
image: example/image
volumes:
- ./config.toml:/etc/config.toml:ro
YAML;
expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeTrue();
});
test('detects writable with short-form syntax without :ro', function () {
$compose = <<<'YAML'
services:
garage:
image: example/image
volumes:
- ./config.toml:/etc/config.toml
YAML;
expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeFalse();
});
test('detects read-only with long-form syntax and read_only: true', function () {
$compose = <<<'YAML'
services:
garage:
image: example/image
volumes:
- type: bind
source: ./garage.toml
target: /etc/garage.toml
read_only: true
YAML;
expect(isVolumeReadOnly($compose, 'garage', '/etc/garage.toml'))->toBeTrue();
});
test('detects writable with long-form syntax and read_only: false', function () {
$compose = <<<'YAML'
services:
garage:
image: example/image
volumes:
- type: bind
source: ./garage.toml
target: /etc/garage.toml
read_only: false
YAML;
expect(isVolumeReadOnly($compose, 'garage', '/etc/garage.toml'))->toBeFalse();
});
test('detects writable with long-form syntax without read_only key', function () {
$compose = <<<'YAML'
services:
garage:
image: example/image
volumes:
- type: bind
source: ./garage.toml
target: /etc/garage.toml
YAML;
expect(isVolumeReadOnly($compose, 'garage', '/etc/garage.toml'))->toBeFalse();
});
test('handles mixed short-form and long-form volumes in same service', function () {
$compose = <<<'YAML'
services:
garage:
image: example/image
volumes:
- ./data:/var/data
- type: bind
source: ./config.toml
target: /etc/config.toml
read_only: true
YAML;
expect(isVolumeReadOnly($compose, 'garage', '/var/data'))->toBeFalse();
expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeTrue();
});
test('handles same file mounted in multiple services with different read_only settings', function () {
$compose = <<<'YAML'
services:
garage:
image: example/garage
volumes:
- type: bind
source: ./garage.toml
target: /etc/garage.toml
garage-webui:
image: example/webui
volumes:
- type: bind
source: ./garage.toml
target: /etc/garage.toml
read_only: true
YAML;
// Same file, different services, different read_only status
expect(isVolumeReadOnly($compose, 'garage', '/etc/garage.toml'))->toBeFalse();
expect(isVolumeReadOnly($compose, 'garage-webui', '/etc/garage.toml'))->toBeTrue();
});
test('handles volume mount type', function () {
$compose = <<<'YAML'
services:
app:
image: example/app
volumes:
- type: volume
source: mydata
target: /data
read_only: true
YAML;
expect(isVolumeReadOnly($compose, 'app', '/data'))->toBeTrue();
});
test('returns false when service has no volumes', function () {
$compose = <<<'YAML'
services:
garage:
image: example/image
YAML;
expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeFalse();
});
test('returns false when service does not exist', function () {
$compose = <<<'YAML'
services:
garage:
image: example/image
volumes:
- ./config.toml:/etc/config.toml:ro
YAML;
expect(isVolumeReadOnly($compose, 'nonexistent', '/etc/config.toml'))->toBeFalse();
});
test('returns false when mount path does not match', function () {
$compose = <<<'YAML'
services:
garage:
image: example/image
volumes:
- type: bind
source: ./other.toml
target: /etc/other.toml
read_only: true
YAML;
expect(isVolumeReadOnly($compose, 'garage', '/etc/config.toml'))->toBeFalse();
});

View file

@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.454"
"version": "4.0.0-beta.455"
},
"nightly": {
"version": "4.0.0-beta.455"
"version": "4.0.0-beta.456"
},
"helper": {
"version": "1.0.12"