From 266d414a0418bd8e55699e9469ce8a4654add01a Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:18:14 +0200 Subject: [PATCH 1/4] feat(docker): add container name prefix for generated container names --- app/Models/ApplicationSetting.php | 14 ++++++++++++++ .../ApplicationConfigurationSnapshot.php | 1 + bootstrap/helpers/docker.php | 2 +- ...e_prefix_to_application_settings_table.php | 18 ++++++++++++++++++ ...plicationDeploymentContainerNamingTest.php | 19 +++++++++++++++++++ 5 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 91c38b8790..60fb9b2336 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -32,6 +32,7 @@ use OpenApi\Attributes as OA; 'is_stripprefix_enabled' => ['type' => 'boolean'], 'connect_to_docker_network' => ['type' => 'boolean'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true], 'is_container_label_escape_enabled' => ['type' => 'boolean'], 'is_env_sorting_enabled' => ['type' => 'boolean'], 'is_container_label_readonly_enabled' => ['type' => 'boolean'], @@ -106,6 +107,7 @@ class ApplicationSetting extends Model 'is_stripprefix_enabled', 'connect_to_docker_network', 'custom_internal_name', + 'custom_container_name_prefix', 'is_container_label_escape_enabled', 'is_env_sorting_enabled', 'is_container_label_readonly_enabled', @@ -121,6 +123,18 @@ class ApplicationSetting extends Model 'stop_grace_period', ]; + /** + * Like custom container names, a prefix must be unique per server so that uuid, custom container + * name and prefix each identify one container when resolving connections. + */ + public static function isContainerNamePrefixInUse(string $prefix, Server $server, ?int $ignoreApplicationId = null): bool + { + return $server->applications()->contains(function (Application $application) use ($prefix, $ignoreApplicationId) { + return $application->id !== $ignoreApplicationId + && in_array($prefix, [$application->uuid, $application->settings->custom_container_name_prefix, $application->settings->custom_internal_name], true); + }); + } + public function stopGracePeriodSeconds(): int { if ( diff --git a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php index e3ba77163d..184aa01eb3 100644 --- a/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php +++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php @@ -170,6 +170,7 @@ class ApplicationConfigurationSnapshot $this->item('custom_network_aliases', 'Network aliases', $this->application->custom_network_aliases, 'redeploy'), $this->item('connect_to_docker_network', 'Connect to Docker network', data_get($this->application, 'settings.connect_to_docker_network'), 'redeploy'), $this->item('custom_internal_name', 'Custom container name', data_get($this->application, 'settings.custom_internal_name'), 'redeploy'), + $this->item('custom_container_name_prefix', 'Container name prefix', data_get($this->application, 'settings.custom_container_name_prefix'), 'redeploy'), $this->item('is_consistent_container_name_enabled', 'Consistent container name', data_get($this->application, 'settings.is_consistent_container_name_enabled'), 'redeploy'), $this->item('is_container_label_escape_enabled', 'Escape container labels', data_get($this->application, 'settings.is_container_label_escape_enabled'), 'redeploy'), $this->item('is_container_label_readonly_enabled', 'Read-only container labels', data_get($this->application, 'settings.is_container_label_readonly_enabled'), 'redeploy'), diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 38cf3fefc4..3c87882d6f 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -358,7 +358,7 @@ function generateApplicationContainerName(Application $application, $pull_reques return $name; } - return $application->uuid.'-'.$now; + return ($application->settings->custom_container_name_prefix ?: $application->uuid).'-'.$now; } } diff --git a/database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php b/database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php new file mode 100644 index 0000000000..d49c1d57aa --- /dev/null +++ b/database/migrations/2026_09_08_214513_add_custom_container_name_prefix_to_application_settings_table.php @@ -0,0 +1,18 @@ +string('custom_container_name_prefix')->nullable(); + }); + } +}; diff --git a/tests/Unit/ApplicationDeploymentContainerNamingTest.php b/tests/Unit/ApplicationDeploymentContainerNamingTest.php index 1a268d4f38..385e547eb6 100644 --- a/tests/Unit/ApplicationDeploymentContainerNamingTest.php +++ b/tests/Unit/ApplicationDeploymentContainerNamingTest.php @@ -64,3 +64,22 @@ it('recognises generated container names in both timestamp formats', function () ->and(isGeneratedContainerName('application-uuid-pr-42'))->toBeFalse() ->and(isGeneratedContainerName('my-api'))->toBeFalse(); }); + +function applicationWithContainerNamePrefix(string $prefix = 'my-api', bool $consistent = false): Application +{ + $application = new Application; + $application->forceFill(['uuid' => 'application-uuid']); + $application->setRelation('settings', new ApplicationSetting([ + 'custom_container_name_prefix' => $prefix, + 'is_consistent_container_name_enabled' => $consistent, + ])); + + return $application; +} + +it('uses the container name prefix for generated container names only', function () { + expect(generateApplicationContainerName(applicationWithContainerNamePrefix()))->toMatch('/^my-api-\d{8}T\d{6}$/') + ->and(generateApplicationContainerName(applicationWithContainerNamePrefix('')))->toMatch('/^application-uuid-\d{8}T\d{6}$/') + ->and(generateApplicationContainerName(applicationWithContainerNamePrefix(consistent: true)))->toBe('application-uuid') + ->and(generateApplicationContainerName(applicationWithContainerNamePrefix(), 42))->toBe('application-uuid-pr-42'); +}); From 7efece06c435959f59ffd775b0a975e9ae0d5105 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:18:34 +0200 Subject: [PATCH 2/4] feat(ui): add container name prefix to application container settings --- app/Livewire/Project/Application/Advanced.php | 28 ++++++++++++ .../project/application/advanced.blade.php | 19 ++++++-- .../ApplicationConfigAuthorizationTest.php | 12 +++++ .../AdvancedContainerNamingTest.php | 44 +++++++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index a9e1c0be28..b3564df0bb 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Application; use App\Models\Application; +use App\Models\ApplicationSetting; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; @@ -69,6 +70,9 @@ class Advanced extends Component #[Validate(['string', 'nullable'])] public ?string $customInternalName = null; + #[Validate(['string', 'nullable', 'max:47'])] + public ?string $customContainerNamePrefix = null; + #[Validate(['boolean'])] public bool $isGzipEnabled = true; @@ -111,6 +115,7 @@ class Advanced extends Component $this->application->settings->is_build_server_enabled = $this->isBuildServerEnabled; $this->application->settings->is_consistent_container_name_enabled = $this->isConsistentContainerNameEnabled; $this->application->settings->custom_internal_name = $this->customInternalName; + $this->application->settings->custom_container_name_prefix = $this->customContainerNamePrefix; $this->application->settings->is_gzip_enabled = $this->isGzipEnabled; $this->application->settings->is_stripprefix_enabled = $this->isStripprefixEnabled; $this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled; @@ -137,6 +142,7 @@ class Advanced extends Component $this->isBuildServerEnabled = $this->application->settings->is_build_server_enabled; $this->isConsistentContainerNameEnabled = $this->application->settings->is_consistent_container_name_enabled; $this->customInternalName = $this->application->settings->custom_internal_name; + $this->customContainerNamePrefix = $this->application->settings->custom_container_name_prefix; $this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled; $this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network; $this->disableBuildCache = $this->application->settings->disable_build_cache; @@ -258,6 +264,28 @@ class Advanced extends Component } } + public function saveCustomNamePrefix() + { + try { + $this->authorize('update', $this->application); + + $this->customContainerNamePrefix = str($this->customContainerNamePrefix)->slug()->value() ?: null; + + if ($this->customContainerNamePrefix && ApplicationSetting::isContainerNamePrefixInUse($this->customContainerNamePrefix, $this->application->destination->server, $this->application->id)) { + $this->customContainerNamePrefix = $this->application->settings->custom_container_name_prefix; + $this->dispatch('error', 'This container name prefix is already in use by another application on this Coolify instance.'); + + return; + } + + $this->syncData(true); + $this->dispatch('success', 'Container name prefix saved.'); + $this->dispatch('configurationChanged'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function saveStopGracePeriod() { try { diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index c6113808c5..3a5549e0ea 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -45,10 +45,21 @@ ['value' => true, 'label' => 'Consistent name (no rolling updates)'], ]" :disabled="! $canUpdate" /> @if ($isConsistentContainerNameEnabled === true) - +
+ + + + @else +
+ + + @endif diff --git a/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php b/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php index eb331a775f..93c9658a7b 100644 --- a/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php +++ b/tests/Feature/Authorization/ApplicationConfigAuthorizationTest.php @@ -247,6 +247,18 @@ test('member cannot submit application advanced settings', function () { ->assertDispatched('error'); }); +test('member cannot save the application container name prefix', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApplicationAdvanced::class, ['application' => $this->application]) + ->set('customContainerNamePrefix', 'member-prefix') + ->call('saveCustomNamePrefix') + ->assertDispatched('error'); + + expect($this->application->settings->fresh()->custom_container_name_prefix)->toBeNull(); +}); + test('the private application advanced syncData helper is not remotely callable', function () { $this->actingAs($this->member); session(['currentTeam' => $this->team]); diff --git a/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php b/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php index 079bd6daa4..ac381762cb 100644 --- a/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php +++ b/tests/Feature/Livewire/Project/Application/AdvancedContainerNamingTest.php @@ -104,3 +104,47 @@ it('only shows the custom container name for consistent naming', function () { ->set('isConsistentContainerNameEnabled', true) ->assertSee('Custom container name'); }); + +it('saves a slugged container name prefix in generated naming mode', function () { + $otherTeamApplication = createApplicationForContainerNamingTest(); + $otherTeamApplication->settings->update(['custom_container_name_prefix' => 'my-api']); + + $application = createApplicationForContainerNamingTest(); + $application->settings->update(['custom_internal_name' => 'legacy-name']); + $application = $application->fresh(['environment.project', 'settings', 'destination']); + + Livewire::test(Advanced::class, ['application' => $application]) + ->assertSee('Container name prefix') + ->set('customContainerNamePrefix', 'My API') + ->call('saveCustomNamePrefix') + ->assertDispatched('success') + ->assertSet('customContainerNamePrefix', 'my-api'); + + $settings = $application->settings()->first(); + expect($settings->custom_container_name_prefix)->toBe('my-api') + ->and($settings->custom_internal_name)->toBe('legacy-name'); +}); + +it('rejects a container name prefix already used on the server', function () { + $application = createApplicationForContainerNamingTest(); + $sibling = fn () => Application::factory()->create([ + 'environment_id' => $application->environment_id, + 'destination_id' => $application->destination_id, + 'destination_type' => $application->destination_type, + ]); + $prefixedApplication = $sibling(); + $prefixedApplication->settings->update(['custom_container_name_prefix' => 'shared-prefix']); + $sibling()->settings->update(['custom_internal_name' => 'api']); + + $application = $application->fresh(['environment.project', 'settings', 'destination']); + $component = Livewire::test(Advanced::class, ['application' => $application]); + + foreach (['shared-prefix', 'api', $prefixedApplication->uuid] as $takenPrefix) { + $component->set('customContainerNamePrefix', $takenPrefix) + ->call('saveCustomNamePrefix') + ->assertDispatched('error') + ->assertSet('customContainerNamePrefix', null); + } + + expect($application->settings()->first()->custom_container_name_prefix)->toBeNull(); +}); From 0d8ef9411fda2289d2fccadc576caaee324f0dfb Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:18:27 +0200 Subject: [PATCH 3/4] feat(api): allow setting the container name prefix --- .../Api/ApplicationsController.php | 31 +++++++++++++++++++ bootstrap/helpers/api.php | 2 ++ .../Api/ApplicationSettingsApiTest.php | 29 +++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 246214986c..14b89887d7 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Controller; use App\Jobs\DeleteResourceJob; use App\Models\Application; use App\Models\ApplicationPreview; +use App\Models\ApplicationSetting; use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\LocalFileVolume; @@ -61,6 +62,7 @@ class ApplicationsController extends Controller 'gpu_options', 'is_consistent_container_name_enabled', 'custom_internal_name', + 'custom_container_name_prefix', ]; private const BOOLEAN_APPLICATION_SETTING_FIELDS = [ @@ -153,9 +155,26 @@ class ApplicationsController extends Controller : $request->input($field); } + if (array_key_exists('custom_container_name_prefix', $settings)) { + $settings['custom_container_name_prefix'] = str($settings['custom_container_name_prefix'])->slug()->value() ?: null; + } + return $settings; } + private function containerNamePrefixValidationResponse(array $settings, Server $server, ?Application $application = null): ?JsonResponse + { + $prefix = $settings['custom_container_name_prefix'] ?? null; + if (! filled($prefix) || ! ApplicationSetting::isContainerNamePrefixInUse($prefix, $server, $application?->id)) { + return null; + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['custom_container_name_prefix' => ['This container name prefix is already in use by another application.']], + ], 422); + } + private function applyApplicationSettings(Application $application, array $settings): void { if ($settings === []) { @@ -393,6 +412,7 @@ class ApplicationsController extends Controller 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], @@ -587,6 +607,7 @@ class ApplicationsController extends Controller 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], @@ -781,6 +802,7 @@ class ApplicationsController extends Controller 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], @@ -946,6 +968,7 @@ class ApplicationsController extends Controller 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], @@ -1107,6 +1130,7 @@ class ApplicationsController extends Controller 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], @@ -1335,6 +1359,9 @@ class ApplicationsController extends Controller ], 422); } } + if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $destination->server)) { + return $prefixValidation; + } if ($type === 'public') { $validationRules = [ 'git_repository' => ['string', 'required', new ValidGitRepositoryUrl], @@ -2967,6 +2994,7 @@ class ApplicationsController extends Controller 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], @@ -3140,6 +3168,9 @@ class ApplicationsController extends Controller } $applicationSettings = $this->applicationSettingsFromRequest($request); + if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) { + return $prefixValidation; + } $requestedBuildPack = $request->input('build_pack', $application->build_pack); if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') { return response()->json([ diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index b8001497ba..81676ab070 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -141,6 +141,7 @@ function sharedDataApplications() 'gpu_options' => 'string|nullable', 'is_consistent_container_name_enabled' => 'boolean', 'custom_internal_name' => 'string|nullable', + 'custom_container_name_prefix' => 'string|nullable|max:47', 'preview_url_template' => 'string', 'max_restart_count' => 'integer|min:0', 'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS, @@ -408,6 +409,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('gpu_options'); $request->offsetUnset('is_consistent_container_name_enabled'); $request->offsetUnset('custom_internal_name'); + $request->offsetUnset('custom_container_name_prefix'); $request->offsetUnset('docker_compose_raw'); $request->offsetUnset('tags'); } diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php index a4c22a2b7c..578b70acfe 100644 --- a/tests/Feature/Api/ApplicationSettingsApiTest.php +++ b/tests/Feature/Api/ApplicationSettingsApiTest.php @@ -467,3 +467,32 @@ test('rejects swarm fields on application update', function (string $field, mixe 'swarm_placement_constraints' => ['swarm_placement_constraints', 'node.role==worker'], 'is_swarm_only_worker_nodes' => ['is_swarm_only_worker_nodes', true], ]); + +test('PATCH /api/v1/applications/{uuid} saves a slugged container name prefix', function () { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", ['custom_container_name_prefix' => 'My API']) + ->assertOk(); + + expect($this->application->fresh()->settings->custom_container_name_prefix)->toBe('my-api'); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->getJson("/api/v1/applications/{$this->application->uuid}") + ->assertOk() + ->assertJsonPath('settings.custom_container_name_prefix', 'my-api'); +}); + +test('PATCH /api/v1/applications/{uuid} rejects a container name prefix that is in use', function () { + $otherApplication = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $otherApplication->settings->update(['custom_container_name_prefix' => 'shared-prefix']); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", ['custom_container_name_prefix' => 'shared-prefix']) + ->assertUnprocessable() + ->assertJsonValidationErrors('custom_container_name_prefix'); + + expect($this->application->fresh()->settings->custom_container_name_prefix)->toBeNull(); +}); From 6ad739a53cac0bbd761ab0cde6db76ab6e41e915 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:41:44 +0200 Subject: [PATCH 4/4] feat(docker): limit container name prefixes to 30 characters --- app/Livewire/Project/Application/Advanced.php | 2 +- app/Models/ApplicationSetting.php | 6 ++++++ bootstrap/helpers/api.php | 3 ++- .../views/livewire/project/application/advanced.blade.php | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/Livewire/Project/Application/Advanced.php b/app/Livewire/Project/Application/Advanced.php index b3564df0bb..a57d529bcb 100644 --- a/app/Livewire/Project/Application/Advanced.php +++ b/app/Livewire/Project/Application/Advanced.php @@ -70,7 +70,7 @@ class Advanced extends Component #[Validate(['string', 'nullable'])] public ?string $customInternalName = null; - #[Validate(['string', 'nullable', 'max:47'])] + #[Validate(['string', 'nullable', 'max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH])] public ?string $customContainerNamePrefix = null; #[Validate(['boolean'])] diff --git a/app/Models/ApplicationSetting.php b/app/Models/ApplicationSetting.php index 60fb9b2336..18b26f454f 100644 --- a/app/Models/ApplicationSetting.php +++ b/app/Models/ApplicationSetting.php @@ -50,6 +50,12 @@ use OpenApi\Attributes as OA; )] class ApplicationSetting extends Model { + /** + * Keeps generated names (prefix, timestamp and for compose apps the service name) well below the + * 63 character DNS label limit, with room for a longer suffix in the future. + */ + public const MAX_CONTAINER_NAME_PREFIX_LENGTH = 30; + protected $casts = [ 'is_static' => 'boolean', 'is_spa' => 'boolean', diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 81676ab070..b32870a5f7 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -4,6 +4,7 @@ use App\Actions\Shared\MigrateResourceToDestination; use App\Enums\BuildPackTypes; use App\Enums\RedirectTypes; use App\Enums\StaticImageTypes; +use App\Models\ApplicationSetting; use App\Models\Environment; use App\Models\StandaloneDocker; use App\Models\SwarmDocker; @@ -141,7 +142,7 @@ function sharedDataApplications() 'gpu_options' => 'string|nullable', 'is_consistent_container_name_enabled' => 'boolean', 'custom_internal_name' => 'string|nullable', - 'custom_container_name_prefix' => 'string|nullable|max:47', + 'custom_container_name_prefix' => 'string|nullable|max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH, 'preview_url_template' => 'string', 'max_restart_count' => 'integer|min:0', 'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS, diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index 3a5549e0ea..27b40546bb 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -56,7 +56,7 @@