fix(clone): copy service volume data and keep file owners when cloning

The UI service clone iterated relation builders before parse() created
the sub-resources, so no volume copy ever ran. All service clone paths
now use clone_service(), which parses first and matches volumes by
mount path. VolumeCloneJob no longer forces chown 1000:1000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-24 18:25:31 +02:00
co-authored by Claude Opus 5.5
parent 5f460c06c6
commit ff4939a06f
7 changed files with 326 additions and 461 deletions
+2 -143
View File
@@ -7,7 +7,6 @@ use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Jobs\VolumeCloneJob;
use App\Models\EnvironmentVariable;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
@@ -23,7 +22,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
@@ -3337,149 +3335,10 @@ class ServicesController extends Controller
: $service->name.'-clone-'.$uuid;
$cloneVolumeData = $request->boolean('clone_volumes', false);
$newService = $service->replicate([
'id',
'created_at',
'updated_at',
])->fill([
$newService = clone_service($service, $destination, [
'uuid' => $uuid,
'name' => $name,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'server_id' => $destination->server_id,
]);
$newService->save();
foreach ($service->tags as $tag) {
$newService->tags()->attach($tag->id);
}
foreach ($service->scheduled_tasks()->get() as $task) {
$task->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'uuid' => new_public_id(),
'service_id' => $newService->id,
'team_id' => $teamId,
])->save();
}
foreach ($service->environment_variables()->get() as $environmentVariable) {
$environmentVariable->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'resourceable_id' => $newService->id,
'resourceable_type' => $newService->getMorphClass(),
])->save();
}
// Create applications/databases (and their volumes) for the clone first.
// Child rows are not copied by Service::replicate().
$newService->parse();
$newService->refresh();
$sourceApplicationsByName = $service->applications()->get()->keyBy('name');
$sourceDatabasesByName = $service->databases()->get()->keyBy('name');
$pendingVolumeClones = [];
$sourceServer = $service->destination?->server;
$targetServer = $newService->destination?->server;
foreach ($newService->applications()->get() as $application) {
$application->fill(['status' => 'exited'])->save();
$sourceApplication = $sourceApplicationsByName->get($application->name);
if (! $sourceApplication) {
continue;
}
if ($cloneVolumeData) {
$targetVolumesByMount = $application->persistentStorages()->get()->keyBy('mount_path');
foreach ($sourceApplication->persistentStorages()->get() as $sourceVolume) {
$targetVolume = $targetVolumesByMount->get($sourceVolume->mount_path);
if (! $targetVolume) {
continue;
}
$pendingVolumeClones[] = [
'source' => $sourceVolume->name,
'target' => $targetVolume->name,
'model' => $targetVolume,
];
}
}
}
foreach ($newService->databases()->get() as $database) {
$database->fill(['status' => 'exited'])->save();
$sourceDatabase = $sourceDatabasesByName->get($database->name);
if (! $sourceDatabase) {
continue;
}
if ($cloneVolumeData) {
$targetVolumesByMount = $database->persistentStorages()->get()->keyBy('mount_path');
foreach ($sourceDatabase->persistentStorages()->get() as $sourceVolume) {
$targetVolume = $targetVolumesByMount->get($sourceVolume->mount_path);
if (! $targetVolume) {
continue;
}
$pendingVolumeClones[] = [
'source' => $sourceVolume->name,
'target' => $targetVolume->name,
'model' => $targetVolume,
];
}
}
foreach ($sourceDatabase->scheduledBackups()->get() as $backup) {
$newBackup = $backup->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'uuid' => new_public_id(),
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
'team_id' => $teamId,
]);
$newBackup->save();
$backup->copyS3StoragesTo($newBackup);
}
}
if ($cloneVolumeData && $pendingVolumeClones !== [] && $sourceServer && $targetServer) {
try {
$chain = [
function () use ($service) {
StopService::run($service);
},
];
foreach ($pendingVolumeClones as $clone) {
$chain[] = new VolumeCloneJob(
$clone['source'],
$clone['target'],
$sourceServer,
$targetServer,
$clone['model'],
);
}
$chain[] = function () use ($service) {
StartService::run($service);
};
Bus::chain($chain)->onQueue('high')->dispatch();
} catch (\Exception $e) {
\Log::error('Failed to queue service volume clone for '.$service->uuid.': '.$e->getMessage());
}
}
], $cloneVolumeData);
auditLog('api.service.cloned', [
'team_id' => $teamId,
+2 -2
View File
@@ -69,7 +69,7 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
instant_remote_process([
$createTargetVolume,
"docker run --rm -v {$srcVol}:/source -v {$tgtVol}:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'",
"docker run --rm -v {$srcVol}:/source -v {$tgtVol}:/target alpine sh -c 'cp -a /source/. /target/'",
], $this->sourceServer);
$this->rememberTargetVolumeWithoutDriverOptions($sourceConfiguration);
}
@@ -123,7 +123,7 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
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 && chown -R 1000:1000 /target'",
"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) {
+3 -177
View File
@@ -4,8 +4,6 @@ namespace App\Livewire\Project;
use App\Actions\Database\StartDatabase;
use App\Actions\Database\StopDatabase;
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Jobs\VolumeCloneJob;
use App\Models\Environment;
use App\Models\Project;
@@ -299,182 +297,10 @@ class CloneMe extends Component
}
foreach ($services as $service) {
$uuid = new_public_id();
$newService = $service->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'uuid' => $uuid,
clone_service($service, $selectedDestination, [
'name' => $service->name,
'environment_id' => $environment->id,
'destination_id' => $selectedDestination->id,
'destination_type' => $selectedDestination->getMorphClass(),
'server_id' => $selectedDestination->server_id,
]);
$newService->save();
$tags = $service->tags;
foreach ($tags as $tag) {
$newService->tags()->attach($tag->id);
}
$scheduledTasks = $service->scheduled_tasks()->get();
foreach ($scheduledTasks as $task) {
$newTask = $task->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'uuid' => new_public_id(),
'service_id' => $newService->id,
'team_id' => $teamId,
]);
$newTask->save();
}
$environmentVariables = $service->environment_variables()->get();
foreach ($environmentVariables as $environmentVariable) {
$newEnvironmentVariable = $environmentVariable->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'resourceable_id' => $newService->id,
'resourceable_type' => $newService->getMorphClass(),
]);
$newEnvironmentVariable->save();
}
$service->cloneSecretManagerLinkTo($newService);
foreach ($newService->applications() as $application) {
$application->fill([
'status' => 'exited',
])->save();
$persistentVolumes = $application->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
$newName = '';
if (str_starts_with($volume->name, $application->uuid)) {
$newName = str($volume->name)->replace($application->uuid, $application->uuid);
} else {
$newName = $application->uuid.'-'.$volume->name;
}
$newPersistentVolume = $volume->replicate([
'id',
'created_at',
'updated_at',
'uuid',
])->fill([
'name' => $newName,
'resource_id' => $application->id,
]);
$newPersistentVolume->save();
if ($this->cloneVolumeData) {
try {
StopService::dispatch($application);
$sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name;
$sourceServer = $application->service->destination->server;
$targetServer = $newService->destination->server;
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
StartService::dispatch($application);
} catch (\Exception $e) {
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
}
}
}
$fileStorages = $application->fileStorages()->get();
foreach ($fileStorages as $storage) {
$newStorage = $storage->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'resource_id' => $application->id,
]);
$newStorage->save();
}
}
foreach ($newService->databases() as $database) {
$database->fill([
'status' => 'exited',
])->save();
$persistentVolumes = $database->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
$newName = '';
if (str_starts_with($volume->name, $database->uuid)) {
$newName = str($volume->name)->replace($database->uuid, $database->uuid);
} else {
$newName = $database->uuid.'-'.$volume->name;
}
$newPersistentVolume = $volume->replicate([
'id',
'created_at',
'updated_at',
'uuid',
])->fill([
'name' => $newName,
'resource_id' => $database->id,
]);
$newPersistentVolume->save();
if ($this->cloneVolumeData) {
try {
StopService::dispatch($database->service);
$sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name;
$sourceServer = $database->service->destination->server;
$targetServer = $newService->destination->server;
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
StartService::dispatch($database->service);
} catch (\Exception $e) {
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
}
}
}
$fileStorages = $database->fileStorages()->get();
foreach ($fileStorages as $storage) {
$newStorage = $storage->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'resource_id' => $database->id,
]);
$newStorage->save();
}
$scheduledBackups = $database->scheduledBackups()->get();
foreach ($scheduledBackups as $backup) {
$uuid = new_public_id();
$newBackup = $backup->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'uuid' => $uuid,
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
'team_id' => $teamId,
]);
$newBackup->save();
$backup->copyS3StoragesTo($newBackup);
}
}
$newService->parse();
], $this->cloneVolumeData);
}
return [$project, $environment];
@@ -4,8 +4,6 @@ namespace App\Livewire\Project\Shared;
use App\Actions\Database\StartDatabase;
use App\Actions\Database\StopDatabase;
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Actions\Shared\MigrateResourceToDestination;
use App\Jobs\VolumeCloneJob;
use App\Models\Application;
@@ -259,143 +257,9 @@ class ResourceOperations extends Component
return redirect()->to($route);
} elseif ($this->resource->type() === 'service') {
$uuid = new_public_id();
$new_resource = $this->resource->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'uuid' => $uuid,
'name' => $this->resource->name.'-clone-'.$uuid,
$new_resource = clone_service($this->resource, $new_destination, [
'environment_id' => $new_environment->id,
'destination_id' => $new_destination->id,
'destination_type' => $new_destination->getMorphClass(),
'server_id' => $new_destination->server_id,
]);
$new_resource->save();
$tags = $this->resource->tags;
foreach ($tags as $tag) {
$new_resource->tags()->attach($tag->id);
}
$scheduledTasks = $this->resource->scheduled_tasks()->get();
foreach ($scheduledTasks as $task) {
$newTask = $task->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'uuid' => new_public_id(),
'service_id' => $new_resource->id,
'team_id' => $this->resourceTeamId(),
]);
$newTask->save();
}
$environmentVariables = $this->resource->environment_variables()->get();
foreach ($environmentVariables as $environmentVariable) {
$newEnvironmentVariable = $environmentVariable->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'resourceable_id' => $new_resource->id,
'resourceable_type' => $new_resource->getMorphClass(),
]);
$newEnvironmentVariable->save();
}
$this->resource->cloneSecretManagerLinkTo($new_resource);
foreach ($new_resource->applications() as $application) {
$application->fill([
'status' => 'exited',
])->save();
$persistentVolumes = $application->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
$newName = '';
if (str_starts_with($volume->name, $volume->resource->uuid)) {
$newName = str($volume->name)->replace($volume->resource->uuid, $application->uuid);
} else {
$newName = $application->uuid.'-'.str($volume->name)->afterLast('-');
}
$newPersistentVolume = $volume->replicate([
'id',
'created_at',
'updated_at',
'uuid',
])->fill([
'name' => $newName,
'resource_id' => $application->id,
]);
$newPersistentVolume->save();
if ($this->cloneVolumeData) {
try {
StopService::dispatch($application);
$sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name;
$sourceServer = $application->service->destination->server;
$targetServer = $new_resource->destination->server;
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
StartService::dispatch($application);
} catch (\Exception $e) {
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
}
}
}
}
foreach ($new_resource->databases() as $database) {
$database->fill([
'status' => 'exited',
])->save();
$persistentVolumes = $database->persistentStorages()->get();
foreach ($persistentVolumes as $volume) {
$newName = '';
if (str_starts_with($volume->name, $volume->resource->uuid)) {
$newName = str($volume->name)->replace($volume->resource->uuid, $database->uuid);
} else {
$newName = $database->uuid.'-'.str($volume->name)->afterLast('-');
}
$newPersistentVolume = $volume->replicate([
'id',
'created_at',
'updated_at',
'uuid',
])->fill([
'name' => $newName,
'resource_id' => $database->id,
]);
$newPersistentVolume->save();
if ($this->cloneVolumeData) {
try {
StopService::dispatch($database->service);
$sourceVolume = $volume->name;
$targetVolume = $newPersistentVolume->name;
$sourceServer = $database->service->destination->server;
$targetServer = $new_resource->destination->server;
VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume);
StartService::dispatch($database->service);
} catch (\Exception $e) {
\Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage());
}
}
}
}
$new_resource->parse();
], $this->cloneVolumeData);
$route = route('project.service.configuration', [
'project_uuid' => $new_environment->project->uuid,
+132
View File
@@ -25,6 +25,9 @@ function service_logo_urls(mixed $logo): array
];
}
use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Jobs\VolumeCloneJob;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
@@ -32,7 +35,11 @@ use App\Models\LocalFileVolume;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Stringable;
use Spatie\Url\Url;
@@ -666,3 +673,128 @@ function applyServiceApplicationPrerequisites(Service $service): void
]);
}
}
/**
* Clones a service to a destination. The applications, databases, and volumes of the clone come from parse(),
* so their names are the names that the compose file of the clone uses.
*
* @param array<string, mixed> $overrides Attributes for the new service, for example uuid, name, and environment_id.
*/
function clone_service(Service $source, StandaloneDocker|SwarmDocker $destination, array $overrides = [], bool $cloneVolumeData = false): Service
{
$teamId = $source->team()->id;
$uuid = $overrides['uuid'] ?? new_public_id();
$newService = $source->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'name' => $source->name.'-clone-'.$uuid,
...$overrides,
'uuid' => $uuid,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'server_id' => $destination->server_id,
]);
$newService->save();
foreach ($source->tags as $tag) {
$newService->tags()->attach($tag->id);
}
foreach ($source->scheduled_tasks()->get() as $task) {
$task->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'uuid' => new_public_id(),
'service_id' => $newService->id,
'team_id' => $teamId,
])->save();
}
foreach ($source->environment_variables()->get() as $environmentVariable) {
$environmentVariable->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'resourceable_id' => $newService->id,
'resourceable_type' => $newService->getMorphClass(),
])->save();
}
$source->cloneSecretManagerLinkTo($newService);
// Service::replicate() does not copy the child rows, so parse() creates them for the clone.
$newService->parse();
$newService->refresh();
$sourceApplicationsByName = $source->applications()->get()->keyBy('name');
$sourceDatabasesByName = $source->databases()->get()->keyBy('name');
$pendingVolumeClones = [];
foreach ([
[$newService->applications()->get(), $sourceApplicationsByName],
[$newService->databases()->get(), $sourceDatabasesByName],
] as [$newResources, $sourcesByName]) {
foreach ($newResources as $newResource) {
$newResource->fill(['status' => 'exited'])->save();
$sourceResource = $sourcesByName->get($newResource->name);
if (! $sourceResource || ! $cloneVolumeData) {
continue;
}
$targetVolumesByMount = $newResource->persistentStorages()->get()->keyBy('mount_path');
foreach ($sourceResource->persistentStorages()->get() as $sourceVolume) {
$targetVolume = $targetVolumesByMount->get($sourceVolume->mount_path);
if ($targetVolume) {
$pendingVolumeClones[] = [$sourceVolume->name, $targetVolume];
}
}
}
}
foreach ($newService->databases()->get() as $database) {
$sourceDatabase = $sourceDatabasesByName->get($database->name);
foreach ($sourceDatabase?->scheduledBackups()->get() ?? [] as $backup) {
$newBackup = $backup->replicate([
'id',
'created_at',
'updated_at',
])->fill([
'uuid' => new_public_id(),
'database_id' => $database->id,
'database_type' => $database->getMorphClass(),
'team_id' => $teamId,
]);
$newBackup->save();
$backup->copyS3StoragesTo($newBackup);
}
}
$sourceServer = $source->destination?->server;
$targetServer = $newService->destination?->server;
if ($pendingVolumeClones !== [] && $sourceServer && $targetServer) {
$chain = [
function () use ($source) {
StopService::run($source);
},
...array_map(
fn (array $clone) => new VolumeCloneJob($clone[0], $clone[1]->name, $sourceServer, $targetServer, $clone[1]),
$pendingVolumeClones,
),
function () use ($source) {
StartService::run($source);
},
];
// The jobs load the new volume rows, so they must not run before the transaction of the caller commits.
DB::afterCommit(fn () => Bus::chain($chain)->onQueue('high')->dispatch());
}
return $newService;
}
@@ -0,0 +1,182 @@
<?php
use App\Jobs\VolumeCloneJob;
use App\Livewire\Project\CloneMe;
use App\Livewire\Project\Shared\ResourceOperations;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Queue\CallQueuedClosure;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
const CLONE_SERVICE_COMPOSE = <<<'YAML'
services:
web:
image: nginx:alpine
volumes:
- 'web-data:/usr/share/nginx/html'
db:
image: postgres:16-alpine
volumes:
- 'db-data:/var/lib/postgresql/data'
YAML;
beforeEach(function () {
$this->withoutVite();
Server::flushIdentityMap();
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
config(['constants.ssh.mux_enabled' => false]);
Bus::fake();
Queue::fake();
Process::fake(['*' => Process::result(output: '')]);
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]);
$server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = $this->project->environments()->first()
?? Environment::factory()->create(['project_id' => $this->project->id]);
$this->service = Service::factory()->create([
'docker_compose_raw' => CLONE_SERVICE_COMPOSE,
'environment_id' => $this->environment->id,
'server_id' => $server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->service->parse();
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
/**
* The source and target volume names of the VolumeCloneJobs in the dispatched chain, in chain order.
*
* @return array<int, array{0: string, 1: string}>
*/
function chainedServiceVolumeClones(): array
{
$first = Bus::dispatched(CallQueuedClosure::class)->first();
expect($first)->not->toBeNull('No volume clone chain was dispatched.');
return collect($first->chained)
->map(fn (string $job) => unserialize($job))
->filter(fn ($job) => $job instanceof VolumeCloneJob)
->map(fn (VolumeCloneJob $job) => (fn () => [$this->sourceVolume, $this->targetVolume])->call($job))
->values()
->all();
}
/**
* @return array<int, string>
*/
function serviceVolumeNames(Service $service): array
{
$resources = $service->applications()->get()->concat($service->databases()->get());
return LocalPersistentVolume::query()
->where(function ($query) use ($resources) {
foreach ($resources as $resource) {
$query->orWhere(fn ($query) => $query
->where('resource_type', $resource->getMorphClass())
->where('resource_id', $resource->id));
}
})
->orderBy('name')
->pluck('name')
->all();
}
/**
* @return array<int, array{0: string, 1: string}>
*/
function expectedServiceVolumeClones(Service $source, Service $clone): array
{
return collect(['web-data', 'db-data'])
->map(fn (string $volume) => ["{$source->uuid}_{$volume}", "{$clone->uuid}_{$volume}"])
->all();
}
test('cloning a service from resource operations copies the data of its named volumes', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->service])
->set('cloneVolumeData', true)
->call('cloneTo', $this->destination->uuid)
->assertHasNoErrors();
$clone = Service::query()->whereKeyNot($this->service->id)->sole();
expect(chainedServiceVolumeClones())->toEqualCanonicalizing(expectedServiceVolumeClones($this->service, $clone))
->and(serviceVolumeNames($clone))->toBe(["{$clone->uuid}_db-data", "{$clone->uuid}_web-data"]);
});
test('cloning an environment copies the data of the named volumes of its services', function () {
Livewire::test(CloneMe::class, [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('selectedDestination', $this->destination->uuid)
->set('newName', 'cloned-env')
->set('cloneVolumeData', true)
->call('clone', 'environment')
->assertHasNoErrors();
$clone = Service::query()->whereKeyNot($this->service->id)->sole();
expect(chainedServiceVolumeClones())->toEqualCanonicalizing(expectedServiceVolumeClones($this->service, $clone))
->and(serviceVolumeNames($clone))->toBe(["{$clone->uuid}_db-data", "{$clone->uuid}_web-data"]);
});
test('cloning a service through the API copies the data of its named volumes', function () {
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'clone-service-test',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
auth()->forgetGuards();
$this->withHeaders(['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken])
->postJson("/api/v1/services/{$this->service->uuid}/clone", [
'destination_uuid' => $this->destination->uuid,
'clone_volumes' => true,
])
->assertCreated();
$clone = Service::query()->whereKeyNot($this->service->id)->sole();
expect(chainedServiceVolumeClones())->toEqualCanonicalizing(expectedServiceVolumeClones($this->service, $clone));
});
test('cloning a service without volume data dispatches no volume copy', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->service])
->call('cloneTo', $this->destination->uuid)
->assertHasNoErrors();
$clone = Service::query()->whereKeyNot($this->service->id)->sole();
Bus::assertNotDispatched(CallQueuedClosure::class);
Bus::assertNotDispatched(VolumeCloneJob::class);
expect(serviceVolumeNames($clone))->toHaveCount(2);
});
+3 -1
View File
@@ -75,6 +75,7 @@ test('a local clone creates the target volume with the driver options of the sou
expect($commands)->toContain('docker volume inspect '.escapeshellarg('clone-source'))
->and($commands)->toContain(escapeshellarg("docker volume create --opt 'type=nfs' --opt 'o=addr=10.0.0.5,rw,nfsvers=4' --opt 'device=:/exports/data' 'clone-target'"))
->and($commands)->toContain('cp -a /source/. /target/')
->and($commands)->not->toContain('chown')
->and($this->volume->fresh()->ignores_compose_driver_options)->toBeFalse();
});
@@ -150,7 +151,8 @@ test('a remote clone creates the target volume with the source driver options on
$commands = volumeCloneCommands();
expect($commands)->toContain(escapeshellarg("docker volume create --opt 'type=nfs' --opt 'o=addr=10.0.0.5' --opt 'device=:/exports/data' 'clone-target'"))
->and($commands)->toContain('tar xzf /clone/volume-data.tar.gz');
->and($commands)->toContain('tar xzf /clone/volume-data.tar.gz')
->and($commands)->not->toContain('chown');
});
test('a remote bind-type clone gets independent empty storage without copying the host folder', function () {