fix(clone): replace the clone suffix instead of adding one for each clone

Cloning a clone added another "-clone-<id>" to the name, so after about
seven clones the name was longer than 255 characters and the insert failed.

Closes #9976
This commit is contained in:
Andras Bacsai committed 2026-10-27 17:06:52 +01:00
1 parent 1440ea83d3
commit bb1fada600
9 files changed
+58 -5

No files matched your search

+1
View File
@@ -34,6 +34,7 @@
- Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`.
- The root team (id 0) has `personal_team = true`, so `personal_team` does not identify a user's own team. To clean up test users, detach them from shared teams and then delete the user; never delete a team selected by a filter. A Team delete revokes the team's API tokens and deletes its private keys before any guard can stop it.
- SQLite returns `0`/`1` for boolean columns without a `boolean` cast, while Postgres returns real booleans. A strict `=== false` check on such a column never matches in tests, so tests can pass on behavior that Postgres blocks. Add the cast or compare loosely.
- SQLite does not enforce `varchar(255)` lengths, while Postgres rejects a longer value (`SQLSTATE[22001]`). Tests cannot show a too-long name, so assert the length or the exact value of generated strings.
- The Compose parsers (`parse()`, `applicationParser`, `serviceParser`) run on page mounts, refreshes, and deployments. Do not dispatch jobs or run server commands from them; let the caller that changes data start the work. Code that only needs the service names reads `docker_compose_raw` (`Application::composeServicesWithoutParse()`).
@@ -5282,7 +5282,7 @@ class DatabasesController extends Controller
$uuid = new_public_id();
$name = $request->filled('name')
? $request->string('name')->toString()
: $database->name.'-clone-'.$uuid;
: clone_resource_name($database->name, $uuid);
$cloneVolumeData = $request->boolean('clone_volumes', false);
$newDatabase = $database->replicate([
@@ -3337,7 +3337,7 @@ class ServicesController extends Controller
$uuid = new_public_id();
$name = $request->filled('name')
? $request->string('name')->toString()
: $service->name.'-clone-'.$uuid;
: clone_resource_name($service->name, $uuid);
$cloneVolumeData = $request->boolean('clone_volumes', false);
$newService = clone_service($service, $destination, [
+1 -1
View File
@@ -63,7 +63,7 @@ class CloneMe extends Component
->where('team_id', $this->project->team_id)
->get()
->reject(fn ($server) => $server->isBuildServer());
$this->newName = str($this->project->name.'-clone-'.new_public_id())->slug();
$this->newName = str(clone_resource_name($this->project->name, new_public_id()))->slug();
}
public function toggleVolumeCloning(bool $value)
@@ -132,7 +132,7 @@ class ResourceOperations extends Component
'updated_at',
])->fill([
'uuid' => $uuid,
'name' => $this->resource->name.'-clone-'.$uuid,
'name' => clone_resource_name($this->resource->name, $uuid),
'status' => 'exited',
'started_at' => null,
'environment_id' => $new_environment->id,
+1 -1
View File
@@ -695,7 +695,7 @@ function clone_service(Service $source, StandaloneDocker|SwarmDocker $destinatio
'created_at',
'updated_at',
])->fill([
'name' => $source->name.'-clone-'.$uuid,
'name' => clone_resource_name($source->name, $uuid),
...$overrides,
'uuid' => $uuid,
'destination_id' => $destination->id,
+12
View File
@@ -134,6 +134,18 @@ function new_public_id(int $length = 24): string
return Str::lower(Str::random($length));
}
/**
* The name of a clone: the source name without the "-clone-<id>" suffixes of earlier clones, then a new suffix.
* Without this, each clone of a clone adds a suffix, and the name soon becomes longer than the 255 characters of the column.
*/
function clone_resource_name(string $name, string $uuid): string
{
$suffix = '-clone-'.$uuid;
$baseName = preg_replace('/(-clone-[a-z0-9]{7,})+$/i', '', $name);
return Str::limit($baseName, 255 - strlen($suffix), '').$suffix;
}
/**
* Validate that a path or identifier is safe for use in shell commands.
*
@@ -179,3 +179,18 @@ test('cloning a service without volume data dispatches no volume copy', function
Bus::assertNotDispatched(VolumeCloneJob::class);
expect(serviceVolumeNames($clone))->toHaveCount(2);
});
test('cloning a cloned service replaces the clone suffix instead of adding another one', function () {
$this->service->update(['name' => 'Gitea Runner']);
$source = $this->service;
foreach (range(1, 10) as $ignored) {
Livewire::test(ResourceOperations::class, ['resource' => $source])
->call('cloneTo', $this->destination->uuid)
->assertHasNoErrors();
$source = Service::query()->latest('id')->firstOrFail();
}
expect($source->name)->toBe("Gitea Runner-clone-{$source->uuid}");
});
+25
View File
@@ -0,0 +1,25 @@
<?php
it('adds a clone suffix to the source name', function () {
expect(clone_resource_name('Gitea Runner', 'abc123def456ghi789jkl012'))
->toBe('Gitea Runner-clone-abc123def456ghi789jkl012');
});
it('replaces the clone suffixes of earlier clones', function () {
$name = 'Gitea Runner-clone-sy2cojnseswr8szrpj1omsgw-clone-yvj9kdc2xuff8td0f0mps7eq-clone-ab12cd3';
expect(clone_resource_name($name, 'newid000000000000000000a'))
->toBe('Gitea Runner-clone-newid000000000000000000a');
});
it('keeps a name that only contains the word clone', function () {
expect(clone_resource_name('my-clone-db', 'abc123def456ghi789jkl012'))
->toBe('my-clone-db-clone-abc123def456ghi789jkl012');
});
it('keeps the clone name within the 255 characters of the name column', function () {
$name = clone_resource_name(str_repeat('a', 255), 'abc123def456ghi789jkl012');
expect($name)->toHaveLength(255)
->toEndWith('-clone-abc123def456ghi789jkl012');
});