fix(clone): add Compose labels to cloned volumes

Closes #12429

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-02 13:59:22 +01:00
co-authored by Claude Opus 5.5
parent f2f89ccfae
commit 31653c27d1
2 changed files with 62 additions and 0 deletions
+31
View File
@@ -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<string, string>
*/
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
+31
View File
@@ -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'"
));
});