scheduledBackups()->exists()) { throw new \RuntimeException('Delete this volume backup schedule and its archives before deleting the volume.'); } }); // A copy is a new Docker volume, so Docker Compose creates it with the driver options. static::replicating(function (LocalPersistentVolume $volume): void { $volume->ignores_compose_driver_options = false; }); } protected $fillable = [ 'name', 'mount_path', 'host_path', 'standalone_sqlite_id', 'container_id', 'resource_type', 'resource_id', 'is_preview_suffix_enabled', ]; protected $casts = [ 'is_preview_suffix_enabled' => 'boolean', 'ignores_compose_driver_options' => 'boolean', ]; public function resource() { return $this->morphTo('resource'); } public function application() { return $this->morphTo('resource'); } public function service() { return $this->morphTo('resource'); } public function database() { return $this->morphTo('resource'); } public function scheduledBackups(): MorphMany { return $this->morphMany(ScheduledVolumeBackup::class, 'backupable'); } /** * The SQLite database this volume was connected from, if any. */ public function standaloneSqlite(): BelongsTo { return $this->belongsTo(StandaloneSqlite::class); } public function abortIfScheduledBackupsExist(): void { if ($this->scheduledBackups()->exists()) { abort(422, 'Delete this volume backup schedule and its archives before deleting the volume.'); } } /** * Whether another resource mounts the same Docker volume, e.g. an application that uses a SQLite database volume. */ public function isSharedWithAnotherResource(): bool { if ($this->standalone_sqlite_id !== null) { return true; } if ($this->resource_type !== StandaloneSqlite::class) { return false; } $isConnected = static::query() ->where('standalone_sqlite_id', $this->resource_id) ->where('name', $this->name) ->exists(); return $isConnected || ($this->resource instanceof StandaloneSqlite && $this->resource->composeApplicationsUsingDataVolume($this->name)->isNotEmpty()); } protected function customizeName($value) { return str($value)->trim()->value; } protected function mountPath(): Attribute { return Attribute::make( set: fn (string $value) => str($value)->trim()->start('/')->value ); } protected function hostPath(): Attribute { return Attribute::make( set: function (?string $value) { if ($value) { return str($value)->trim()->start('/')->value; } else { return $value; } } ); } // 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(); } public function isDeclaredInCompose(): bool { try { $resource = $this->resource; if (! $resource) { return true; } $composeContent = $resource instanceof Application ? $resource->docker_compose_raw : data_get($resource, 'service.docker_compose_raw'); if (blank($composeContent)) { return true; } $compose = parseDockerComposeYaml($composeContent); $services = data_get($compose, 'services', []); $topLevelVolumes = collect(data_get($compose, 'volumes') ?? []); if ($this->isServiceResource()) { $services = array_intersect_key($services, [$resource->name => true]); } foreach ($services as $service) { foreach (data_get($service, 'volumes', []) as $volume) { $parsedVolume = is_array($volume) ? $volume : parseDockerVolumeString($volume); $source = data_get($parsedVolume, 'source'); $target = data_get($parsedVolume, 'target'); if ($source && isComposeExternalVolume($topLevelVolumes->get((string) $source))) { // The parsers use an external volume as written. A storage entry with the generated // name is an old entry that the user can delete (see replacedExternalComposeVolume()). continue; } $resourceUuid = $resource instanceof Application ? $resource->uuid : data_get($resource, 'service.uuid'); $generatedName = $source ? $resourceUuid.'_'.Str::slug($source, '-') : null; if ($generatedName && $target && str($target)->start('/')->value() === $this->mount_path && ($generatedName === $this->name || $this->isVolumeOfOpenPreview($resource, $generatedName))) { return true; } } } return false; } catch (\Throwable) { return true; } } /** * Whether this storage entry is the preview copy ("{volume}-pr-{id}") of the given volume for a * preview that still exists. The preview cleanup removes the entry when the preview is closed. */ private function isVolumeOfOpenPreview(mixed $resource, string $generatedName): bool { if (! $resource instanceof Application || preg_match('/^'.preg_quote($generatedName, '/').'-pr-(\d+)$/', $this->name, $matches) !== 1) { return false; } return $resource->previews()->where('pull_request_id', (int) $matches[1])->exists(); } /** * The external Compose volume that this storage entry still replaces, or null. Before Coolify * used external volumes as written, the parsers gave them a generated name, for example * "{uuid}_{volume}". While this storage entry exists, the parsers keep that name so that the * resource keeps its data (see useComposeExternalVolumeAsWritten()). A preview volume * ("{uuid}_{volume}-pr-{id}") replaces nothing: a preview never uses the external volume. */ public function replacedExternalComposeVolume(): ?string { try { $resource = $this->resource; if (! $resource) { return null; } $composeContent = $resource instanceof Application ? $resource->docker_compose_raw : data_get($resource, 'service.docker_compose_raw'); if (blank($composeContent)) { return null; } foreach (data_get(parseDockerComposeYaml($composeContent), 'volumes') ?? [] as $key => $declaration) { $key = (string) $key; if (! isComposeExternalVolume($declaration)) { continue; } $legacyName = match (true) { $resource instanceof Application && (int) $resource->compose_parsing_version < 3 => legacyApplicationComposeVolumeName($resource, $key, 0), $resource instanceof Application => $resource->uuid.'_'.Str::slug($key, '-'), default => data_get($resource, 'service.uuid').'_'.Str::slug($key, '-'), }; if ($legacyName !== $key && $this->name === $legacyName) { return $key; } } return null; } catch (\Throwable) { return null; } } /** * Whether the Compose file gives this volume `driver`, `driver_opts` or `labels` that the parsers * do not apply, because the volume was created before Coolify kept them. Docker Compose would ask * to recreate such a volume, so the parsers keep its old name-only declaration. */ public function ignoresComposeDriverOptionsOfDeclaration(): bool { if (! $this->ignores_compose_driver_options) { return false; } try { $resource = $this->resource; if (! $resource) { return false; } $composeContent = $resource instanceof Application ? $resource->docker_compose_raw : data_get($resource, 'service.docker_compose_raw'); if (blank($composeContent)) { return false; } $resourceUuid = $resource instanceof Application ? $resource->uuid : data_get($resource, 'service.uuid'); foreach (data_get(parseDockerComposeYaml($composeContent), 'volumes') ?? [] as $key => $declaration) { if (! is_array($declaration) || isComposeExternalVolume($declaration)) { continue; } $generatedName = $resourceUuid.'_'.Str::slug((string) $key, '-'); if ($this->name !== $generatedName && preg_match('/^'.preg_quote($generatedName, '/').'-pr-\d+$/', $this->name) !== 1) { continue; } return filled(data_get($declaration, 'driver')) || filled(data_get($declaration, 'driver_opts')) || filled(data_get($declaration, 'labels')); } return false; } catch (\Throwable) { return false; } } // Check if this volume is read-only by parsing the docker-compose content public function isReadOnlyVolume(): bool { try { // Get the resource (can be application, service, or database) $resource = $this->resource; if (! $resource) { return false; } // Only check for services if (! method_exists($resource, 'service')) { return false; } $actualService = $resource->service; if (! $actualService || ! $actualService->docker_compose_raw) { return false; } // Parse the docker-compose content $compose = parseDockerComposeYaml($actualService->docker_compose_raw); if (! isset($compose['services'])) { return false; } // Find the service that this volume belongs to $serviceName = $resource->name; if (! isset($compose['services'][$serviceName]['volumes'])) { return false; } $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)) { $parts = explode(':', $volume); // Check if this volume matches our mount_path if (count($parts) >= 2) { $containerPath = $parts[1]; $options = $parts[2] ?? null; // 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/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; } } } return false; } catch (\Throwable $e) { return false; } } }