diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index da1255eb08..367bf28457 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -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, diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php index 677a01a35b..20ad2ff8a8 100644 --- a/app/Jobs/VolumeCloneJob.php +++ b/app/Jobs/VolumeCloneJob.php @@ -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) { diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index b310c248ee..99cacd1a93 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -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]; diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index c4f408e489..414af27128 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -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, diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 195d4dbc83..7c664649cb 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -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 $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; +} diff --git a/tests/Feature/CloneServiceVolumeDataTest.php b/tests/Feature/CloneServiceVolumeDataTest.php new file mode 100644 index 0000000000..68db8a10ba --- /dev/null +++ b/tests/Feature/CloneServiceVolumeDataTest.php @@ -0,0 +1,182 @@ +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 + */ +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 + */ +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 + */ +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); +}); diff --git a/tests/Feature/VolumeCloneJobTest.php b/tests/Feature/VolumeCloneJobTest.php index be0ba9c1e9..8a3c585f69 100644 --- a/tests/Feature/VolumeCloneJobTest.php +++ b/tests/Feature/VolumeCloneJobTest.php @@ -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 () {