onQueue('high'); } /** * Explicit tags, so Horizon does not compare the models of the job. In a chain, the job is restored * with two copies of one server for a clone on the same server, and comparing them fails on PHP 8.4 * ("Nesting level too deep"), which stops the chain before the copy. * * @return array */ public function tags(): array { return array_values(array_unique([ Server::class.':'.$this->sourceServer->id, ...($this->targetServer ? [Server::class.':'.$this->targetServer->id] : []), LocalPersistentVolume::class.':'.$this->persistentVolume->id, ])); } public function handle(): void { try { if (! $this->targetServer || $this->targetServer->id === $this->sourceServer->id) { $this->cloneLocalVolume(); } else { $this->cloneRemoteVolume(); } } catch (\Exception $e) { \Log::error("Failed to copy volume data for {$this->sourceVolume}: ".$e->getMessage()); throw $e; } } protected function cloneLocalVolume(): void { $srcVol = escapeshellarg($this->sourceVolume); $tgtVol = escapeshellarg($this->targetVolume); // Same-name local copy is a no-op (used when only the network destination changes). if ($this->sourceVolume === $this->targetVolume) { return; } $sourceConfiguration = $this->sourceVolumeConfiguration(); $isBindVolume = $this->isBindVolume($sourceConfiguration); $targetConfiguration = $isBindVolume ? ['driver' => 'local', 'options' => []] : $sourceConfiguration; $createTargetVolume = $this->createTargetVolumeCommand($targetConfiguration); if ($isBindVolume) { instant_remote_process([$createTargetVolume], $this->sourceServer); $this->rememberTargetVolumeWithoutDriverOptions($targetConfiguration); $this->logSkippedBindVolumeCopy($sourceConfiguration); return; } instant_remote_process([ $createTargetVolume, "docker run --rm -v {$srcVol}:/source -v {$tgtVol}:/target alpine sh -c 'cp -a /source/. /target/'", ], $this->sourceServer); $this->rememberTargetVolumeWithoutDriverOptions($sourceConfiguration); } protected function cloneRemoteVolume(): void { $srcVol = escapeshellarg($this->sourceVolume); $tgtVol = escapeshellarg($this->targetVolume); $sourceConfiguration = $this->sourceVolumeConfiguration(); $isBindVolume = $this->isBindVolume($sourceConfiguration); $targetConfiguration = $isBindVolume ? ['driver' => 'local', 'options' => []] : $sourceConfiguration; $createTargetVolume = $this->createTargetVolumeCommand($targetConfiguration); if ($isBindVolume) { instant_remote_process([$createTargetVolume], $this->targetServer); $this->rememberTargetVolumeWithoutDriverOptions($targetConfiguration); $this->logSkippedBindVolumeCopy($sourceConfiguration); return; } $sourceCloneDir = null; $targetCloneDir = null; $localTempDir = storage_path('app/tmp/volume-clones/'.Str::uuid()->toString()); $localArchive = $localTempDir.'/volume-data.tar.gz'; try { File::ensureDirectoryExists($localTempDir, 0755); $sourceCloneDir = $this->createCloneArchiveDirectory($this->sourceServer, $this->sourceVolume); $srcDir = escapeshellarg($sourceCloneDir); instant_remote_process([ "docker run --rm -v {$srcVol}:/source -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'", ], $this->sourceServer); $targetCloneDir = $this->createCloneArchiveDirectory($this->targetServer, $this->targetVolume); $tgtDir = escapeshellarg($targetCloneDir); // Coolify host is the intermediary: download from source, upload to target. instant_scp_from_server( "{$sourceCloneDir}/volume-data.tar.gz", $localArchive, $this->sourceServer ); instant_scp( $localArchive, "{$targetCloneDir}/volume-data.tar.gz", $this->targetServer ); instant_remote_process([ $createTargetVolume, "docker run --rm -v {$tgtVol}:/target -v {$tgtDir}:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz'", ], $this->targetServer); $this->rememberTargetVolumeWithoutDriverOptions($sourceConfiguration); } catch (\Exception $e) { \Log::error("Failed to clone volume {$this->sourceVolume} to {$this->targetVolume}: ".$e->getMessage()); throw $e; } finally { try { File::deleteDirectory($localTempDir); } catch (\Exception $e) { \Log::warning('Failed to clean up local volume clone directory: '.$e->getMessage()); } $this->removeCloneArchiveDirectory($this->sourceServer, $sourceCloneDir); $this->removeCloneArchiveDirectory($this->targetServer, $targetCloneDir); } } /** * The driver and driver options of the source volume. A missing source volume gives a plain * `local` volume, as Docker creates an empty source volume for the copy. * * @return array{driver: string, options: array} */ protected function sourceVolumeConfiguration(): array { $output = instant_remote_process([ 'docker volume inspect '.escapeshellarg($this->sourceVolume)." 2>/dev/null || echo '[]'", ], $this->sourceServer); $inspected = json_decode((string) $output, true); $volume = is_array($inspected) && is_array($inspected[0] ?? null) ? $inspected[0] : []; $driver = data_get($volume, 'Driver'); $options = []; foreach ((array) data_get($volume, 'Options', []) as $key => $value) { if (is_string($key) && $key !== '' && is_scalar($value)) { $options[$key] = (string) $value; } } return [ 'driver' => is_string($driver) && $driver !== '' ? $driver : 'local', 'options' => $options, ]; } /** * Creates the target volume with its selected driver and options. A bind clone uses a plain local * volume and a name-only Compose declaration, so it never mounts the source host directory. * One `sh -c` line, so the non-root sudo parser only puts sudo in front of it and never changes * an option value. * * @param array{driver: string, options: array} $configuration */ protected function createTargetVolumeCommand(array $configuration): string { $command = 'docker volume create'; if ($configuration['driver'] !== 'local') { $command .= ' --driver '.escapeshellarg($configuration['driver']); } foreach ($configuration['options'] as $key => $value) { $command .= ' --opt '.escapeshellarg("{$key}={$value}"); } foreach ($this->composeVolumeLabels() as $key => $value) { $command .= ' --label '.escapeshellarg("{$key}={$value}"); } $command .= ' '.escapeshellarg($this->targetVolume); return 'sh -c '.escapeshellarg($command); } /** * The labels that Docker Compose puts on a volume it creates. Without the project label, every * `docker compose up` of the resource warns that the volume "was not created by Docker Compose". * The project is the `--project-name` of the resource: the service UUID for a service, else the * resource UUID (a standalone database uses its configuration directory, which is its UUID). * The volume key in the generated Compose file is the volume name. * * @return array */ protected function composeVolumeLabels(): array { $resource = $this->persistentVolume->resource; $project = $resource instanceof ServiceApplication || $resource instanceof ServiceDatabase ? $resource->service?->uuid : $resource?->uuid; if (blank($project)) { return []; } return [ 'com.docker.compose.project' => (string) $project, 'com.docker.compose.volume' => $this->targetVolume, ]; } /** * A `local` volume with `type: none` and `o: bind` is a host folder. Copying the data would write * into that folder (on the same server, the folder of the source volume itself), so the clone gets * an empty Docker-managed volume without copying the host folder. * * @param array{driver: string, options: array} $configuration */ protected function isBindVolume(array $configuration): bool { return $configuration['driver'] === 'local' && ($configuration['options']['type'] ?? null) === 'none' && in_array('bind', array_map('trim', explode(',', $configuration['options']['o'] ?? '')), true); } /** * @param array{driver: string, options: array} $configuration */ protected function logSkippedBindVolumeCopy(array $configuration): void { $device = $configuration['options']['device'] ?? 'unknown'; \Log::info("Volume {$this->sourceVolume} is a bind mount of the host folder {$device}. Created independent empty volume {$this->targetVolume} and did not copy the data."); } /** * A target volume without driver options gets the name-only Compose declaration, like any volume * that Docker created without the options (see composeRenamedVolumeDeclarationFor()). * * @param array{driver: string, options: array} $configuration */ protected function rememberTargetVolumeWithoutDriverOptions(array $configuration): void { if ($configuration['driver'] !== 'local' || $configuration['options'] !== []) { return; } if ($this->persistentVolume->name !== $this->targetVolume || $this->persistentVolume->ignores_compose_driver_options) { return; } $this->persistentVolume->forceFill(['ignores_compose_driver_options' => true])->saveQuietly(); } }