mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
co-authored by
Claude Opus 5.5
parent
5f460c06c6
commit
ff4939a06f
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user