From bb1fada600628a6c53c4062727747fffd50834b2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:06:47 +0100 Subject: [PATCH] fix(clone): replace the clone suffix instead of adding one for each clone Cloning a clone added another "-clone-" to the name, so after about seven clones the name was longer than 255 characters and the insert failed. Closes #9976 --- .ai/lessons.md | 1 + .../Controllers/Api/DatabasesController.php | 2 +- .../Controllers/Api/ServicesController.php | 2 +- app/Livewire/Project/CloneMe.php | 2 +- .../Project/Shared/ResourceOperations.php | 2 +- bootstrap/helpers/services.php | 2 +- bootstrap/helpers/shared.php | 12 +++++++++ tests/Feature/CloneServiceVolumeDataTest.php | 15 +++++++++++ tests/Unit/CloneResourceNameTest.php | 25 +++++++++++++++++++ 9 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 tests/Unit/CloneResourceNameTest.php diff --git a/.ai/lessons.md b/.ai/lessons.md index dfe393876b..36a776d5a4 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -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()`). diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 93c89c0ed1..4063230d7d 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -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([ diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index a4064f451d..0e0efecfba 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -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, [ diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index fce8bffd0b..19a32b70be 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -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) diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 198972406e..6173215742 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -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, diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 576089ba95..603b4fdc58 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -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, diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index e5c460cee8..cd78aeee34 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -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-" 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. * diff --git a/tests/Feature/CloneServiceVolumeDataTest.php b/tests/Feature/CloneServiceVolumeDataTest.php index c1343134b3..d85e64063c 100644 --- a/tests/Feature/CloneServiceVolumeDataTest.php +++ b/tests/Feature/CloneServiceVolumeDataTest.php @@ -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}"); +}); diff --git a/tests/Unit/CloneResourceNameTest.php b/tests/Unit/CloneResourceNameTest.php new file mode 100644 index 0000000000..07b1944545 --- /dev/null +++ b/tests/Unit/CloneResourceNameTest.php @@ -0,0 +1,25 @@ +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'); +});