Files
coolify/app/Livewire/Project/Service/Storage.php
T
Andras BacsaiandClaude Opus 5.5 7bd8baa7f0 fix(multi-server): make applications on several servers consistent
- Stop the application on every server, also when one server is not functional.
- Write file mounts on every server of the application, not only on the main server.
- Deploy the exact commit, rollback flag and image tag of the main deployment to the
  additional servers, so a rollback reverts all servers.
- Pull the pushed image on additional servers for inline Dockerfile applications
  instead of building it again on each server.
- Fail multi-server deployments without a Docker image name, also from webhooks and the API.
- Send one deployment notification after all servers finish (parent_deployment_uuid).
- Block persistent volumes on multi-server applications (API, Livewire, SQLite connect)
  and warn in the deployment log for existing applications that have both.
- Remove dead code in push_to_docker_registry().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:38:57 +02:00

468 lines
16 KiB
PHP

<?php
namespace App\Livewire\Project\Service;
use App\Livewire\Project\Shared\Storages\All as StorageList;
use App\Models\Application;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
use Livewire\Component;
class Storage extends Component
{
use AuthorizesRequests;
public $resource;
public $fileStorage;
public $isSwarm = false;
public string $name = '';
public string $mount_path = '';
public string $file_storage_path = '';
public ?string $file_storage_content = null;
public string $host_file_storage_source = '';
public string $host_file_storage_destination = '';
public string $file_storage_directory_source = '';
public string $file_storage_directory_destination = '';
public string $activeTab = 'volumes';
public int $cachedVolumeCount = 0;
public int $cachedFileCount = 0;
public int $cachedDirectoryCount = 0;
/**
* External Compose volumes that this resource mounts as written. They have no storage entry,
* so the page shows them read-only (see loadExternalVolumes()).
*
* @var list<array{key: string, dockerName: string, service: string, mountPaths: list<string>}>
*/
#[Locked]
public array $externalVolumes = [];
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},FileStorageChanged" => 'refreshStoragesFromEvent',
'storageCountsChanged' => 'refreshStorages',
'addNewVolume',
];
}
public function mount()
{
if (str($this->resource->getMorphClass())->contains('Standalone')) {
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
} else {
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
}
if ($this->resource->getMorphClass() === Application::class) {
$this->resource->loadMissing('destination.server', 'environment.project');
if ($this->resource->destination?->server?->isSwarm()) {
$this->isSwarm = true;
}
}
// Counts only on mount — child All (volumes) / file list load their own payloads.
$this->loadVolumeCount();
$this->loadFileStorageMetaCounts();
$this->loadExternalVolumes();
$this->activeTab = $this->resolveDefaultTab();
$this->fileStorage = collect();
$this->loadFileStorageForActiveTab();
$this->name = $this->generateDefaultVolumeName();
}
public function refreshStoragesFromEvent()
{
$this->refreshStorages();
$this->dispatch('warning', 'File storage changed. Usually it means that the file / directory is already defined on the server, so Coolify set it up for you properly on the UI.');
}
public function refreshStorages()
{
$hadVolumes = $this->cachedVolumeCount > 0;
// Avoid loading full volume models onto this parent (child All owns that snapshot).
$this->resource->unsetRelation('persistentStorages');
$this->loadVolumeCount();
$this->loadFileStorageMetaCounts();
$this->loadExternalVolumes();
$this->loadFileStorageForActiveTab();
if ($this->activeTab === 'volumes' && $hadVolumes && $this->cachedVolumeCount > 0) {
$this->dispatch('refreshVolumeList')->to(StorageList::class);
}
}
public function setActiveTab(string $tab): void
{
if (! in_array($tab, ['volumes', 'files', 'directories'], true)) {
return;
}
$this->activeTab = $tab;
$this->loadFileStorageForActiveTab();
}
private function resolveDefaultTab(): string
{
if ($this->volumeCount > 0) {
return 'volumes';
}
if ($this->fileCount > 0) {
return 'files';
}
if ($this->directoryCount > 0) {
return 'directories';
}
return 'volumes';
}
private function loadVolumeCount(): void
{
$this->cachedVolumeCount = $this->resource->persistentStorages()->count();
}
/**
* Loads the external volumes that the current Compose file of the resource declares and that the
* resource mounts as written. Coolify does not manage these volumes, so they have no storage entry.
*
* Not included:
* - an external volume for which the resource still has the old storage entry with the generated
* name. The parsers keep that old volume, and the storage list shows the entry with a note.
* - the external volumes of a legacy Compose application (parser version 1 or 2). These parsers
* keep the old volume names, and the deployment log shows a warning.
*/
private function loadExternalVolumes(): void
{
$this->externalVolumes = [];
if (! Gate::allows('view', $this->resource)) {
return;
}
if ($this->resource instanceof Application) {
if ($this->resource->build_pack !== 'dockercompose' || (int) $this->resource->compose_parsing_version < 3) {
return;
}
$compose = $this->resource->docker_compose_raw;
$uuid = $this->resource->uuid;
$serviceName = null;
} elseif ($this->resource instanceof ServiceApplication || $this->resource instanceof ServiceDatabase) {
$compose = $this->resource->service?->docker_compose_raw;
$uuid = $this->resource->service?->uuid;
$serviceName = $this->resource->name;
} else {
return;
}
$storageNames = $this->resource->persistentStorages()->pluck('name')->all();
$this->externalVolumes = collect(composeExternalVolumeMounts($compose))
->when($serviceName !== null, fn ($mounts) => $mounts->where('service', $serviceName))
->reject(fn (array $mount): bool => in_array("{$uuid}_".Str::slug($mount['key'], '-'), $storageNames, true))
->values()
->all();
}
/**
* Counts only — avoids loading file contents into the Livewire snapshot on the volumes tab.
*/
private function loadFileStorageMetaCounts(): void
{
$this->cachedFileCount = $this->resource->fileStorages()->where('is_directory', false)->count();
$this->cachedDirectoryCount = $this->resource->fileStorages()->where('is_directory', true)->count();
}
/**
* Load full file/directory mounts only for the active tab (content only on files).
*/
private function loadFileStorageForActiveTab(): void
{
if ($this->activeTab === 'volumes') {
// Keep snapshot small while the volumes tab is shown.
$this->fileStorage = collect();
return;
}
$query = $this->resource->fileStorages();
if ($this->activeTab === 'files') {
$query->where('is_directory', false);
} else {
$query->where('is_directory', true);
}
$this->fileStorage = $query->get()->each(function (LocalFileVolume $fs): void {
if ($this->activeTab !== 'files') {
$fs->content = null;
return;
}
if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
$fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
}
});
}
public function getFilesProperty()
{
return collect($this->fileStorage)->where('is_directory', false);
}
public function getDirectoriesProperty()
{
return collect($this->fileStorage)->where('is_directory', true);
}
/**
* The reason why the resource cannot get a volume mount, or null if it can.
*/
public function getVolumeUnavailableReasonProperty(): ?string
{
return $this->resource instanceof Application ? $this->resource->persistentStorageUnavailableReason() : null;
}
public function getVolumeCountProperty()
{
return $this->cachedVolumeCount;
}
public function getFileCountProperty()
{
return $this->cachedFileCount;
}
public function getDirectoryCountProperty()
{
return $this->cachedDirectoryCount;
}
public function submitPersistentVolume()
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
], ValidationPatterns::volumeNameMessages());
if ($reason = $this->volumeUnavailableReason) {
$this->dispatch('error', 'Failed to add volume.', $reason);
return;
}
$name = $this->resource->uuid.'-'.$this->name;
LocalPersistentVolume::create([
'name' => $name,
'mount_path' => $this->mount_path,
'host_path' => null,
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->getMorphClass(),
]);
$this->clearForm();
$this->activeTab = 'volumes';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Volume added successfully');
$this->dispatch('closeStorageModal', 'volume');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submitFileStorage()
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'file_storage_path' => 'required|string',
'file_storage_content' => 'nullable|string',
]);
$this->file_storage_path = validateFileMountPath($this->file_storage_path, 'file storage path');
$fs_path = confineFileMountPath($this->fileStorageHostPath(), $this->file_storage_path, 'file storage path');
LocalFileVolume::create([
'fs_path' => $fs_path,
'mount_path' => $this->file_storage_path,
'content' => $this->file_storage_content,
'is_directory' => false,
'resource_id' => $this->resource->id,
'resource_type' => get_class($this->resource),
]);
$this->clearForm();
$this->activeTab = 'files';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'File mount added successfully');
$this->dispatch('closeStorageModal', 'file');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submitHostFileStorage()
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'host_file_storage_source' => 'required|string',
'host_file_storage_destination' => 'required|string',
]);
$this->host_file_storage_source = validateHostFileMountPath($this->host_file_storage_source, 'host file source path');
$this->host_file_storage_destination = validateFileMountPath($this->host_file_storage_destination, 'host file destination path');
LocalFileVolume::create([
'fs_path' => $this->host_file_storage_source,
'mount_path' => $this->host_file_storage_destination,
'content' => null,
'is_directory' => false,
'is_host_file' => true,
'resource_id' => $this->resource->id,
'resource_type' => get_class($this->resource),
]);
$this->clearForm();
$this->activeTab = 'files';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Host file mount added successfully');
$this->dispatch('closeStorageModal', 'host-file');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submitFileStorageDirectory()
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'file_storage_directory_source' => 'required|string',
'file_storage_directory_destination' => 'required|string',
]);
$this->file_storage_directory_source = confinePathToBase(
$this->fileStorageHostPath(),
$this->file_storage_directory_source,
'storage source path'
);
$this->file_storage_directory_destination = validateFileMountPath(
$this->file_storage_directory_destination,
'storage destination path'
);
$server = $this->resource->service?->server ?? $this->resource->destination->server;
LocalFileVolume::assertRemotePathIsConfined($this->fileStorageHostPath(), $this->file_storage_directory_source, $server);
LocalFileVolume::create([
'fs_path' => $this->file_storage_directory_source,
'mount_path' => $this->file_storage_directory_destination,
'is_directory' => true,
'resource_id' => $this->resource->id,
'resource_type' => get_class($this->resource),
]);
$this->clearForm();
$this->activeTab = 'directories';
$this->refreshStorages();
$this->dispatch('configurationChanged');
$this->dispatch('success', 'Directory mount added successfully');
$this->dispatch('closeStorageModal', 'directory');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function clearForm()
{
$this->name = $this->generateDefaultVolumeName();
$this->mount_path = '';
$this->file_storage_path = '';
$this->file_storage_content = null;
$this->file_storage_directory_destination = '';
$this->host_file_storage_source = '';
$this->host_file_storage_destination = '';
if (str($this->resource->getMorphClass())->contains('Standalone')) {
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
} else {
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
}
}
public function fileStorageHostPath(): string
{
if (method_exists($this->resource, 'workdir')) {
return $this->resource->workdir();
}
if ($this->resource->getMorphClass() === Application::class) {
return application_configuration_dir().'/'.$this->resource->uuid;
}
if (str($this->resource->getMorphClass())->contains('Standalone')) {
return database_configuration_dir().'/'.$this->resource->uuid;
}
throw new \Exception('No valid resource type for file mount storage type!');
}
private function generateDefaultVolumeName(): string
{
$name = str($this->resource->name)->slug()->value();
return ($name ?: 'volume').'-data';
}
public function fileStoragePreviewPath(): string
{
$path = str($this->file_storage_path)->trim();
if ($path->isEmpty()) {
return $this->fileStorageHostPath().'/';
}
return $this->fileStorageHostPath().$path->start('/')->value();
}
public function render()
{
return view('livewire.project.service.storage');
}
}