diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php index 953afc4eb1..4887619509 100644 --- a/app/Jobs/VolumeCloneJob.php +++ b/app/Jobs/VolumeCloneJob.php @@ -4,6 +4,8 @@ namespace App\Jobs; use App\Models\LocalPersistentVolume; use App\Models\Server; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; use App\Traits\StagesCloneArchives; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; @@ -202,11 +204,40 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue 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 diff --git a/tests/Feature/VolumeCloneJobTest.php b/tests/Feature/VolumeCloneJobTest.php index b0f6e627d4..9df9cb6e85 100644 --- a/tests/Feature/VolumeCloneJobTest.php +++ b/tests/Feature/VolumeCloneJobTest.php @@ -6,6 +6,8 @@ use App\Models\InstanceSettings; use App\Models\LocalPersistentVolume; use App\Models\PrivateKey; use App\Models\Server; +use App\Models\Service; +use App\Models\ServiceApplication; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Process; @@ -192,3 +194,32 @@ test('a non-root server runs the volume create as one sudo shell script', functi expect(volumeCloneCommands())->toContain('sudo sh -c '.escapeshellarg($inner)) ->and(volumeCloneCommands())->toContain('sudo docker volume inspect'); }); + +test('a service clone creates the target volume with the Compose labels of the service project', function () { + $service = Service::factory()->create(); + $serviceApplication = ServiceApplication::create(['name' => 'gitea', 'service_id' => $service->id]); + $this->volume->update(['resource_type' => ServiceApplication::class, 'resource_id' => $serviceApplication->id]); + fakeVolumeCloneServer(['Name' => 'clone-source', 'Driver' => 'local', 'Options' => null]); + + (new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume->fresh()))->handle(); + + expect(volumeCloneCommands())->toContain(escapeshellarg( + "docker volume create --label 'com.docker.compose.project={$service->uuid}' --label 'com.docker.compose.volume=clone-target' 'clone-target'" + )); +}); + +test('an application clone creates the target volume with the Compose labels of the application project', function () { + $application = Application::factory()->create(); + $this->volume->update(['resource_type' => Application::class, 'resource_id' => $application->id]); + fakeVolumeCloneServer([ + 'Name' => 'clone-source', + 'Driver' => 'local', + 'Options' => ['type' => 'none', 'o' => 'bind', 'device' => '/srv/app-data'], + ]); + + (new VolumeCloneJob('clone-source', 'clone-target', $this->server, $this->server, $this->volume->fresh()))->handle(); + + expect(volumeCloneCommands())->toContain(escapeshellarg( + "docker volume create --label 'com.docker.compose.project={$application->uuid}' --label 'com.docker.compose.volume=clone-target' 'clone-target'" + )); +});