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(); +});