From b9cae51c5d774ad14c4c44263c268947c3205acf Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:32:58 +0100 Subject: [PATCH] feat(service): add container label escape control to services API Add `is_container_label_escape_enabled` boolean field to services API, allowing users to control whether special characters in container labels are escaped. Defaults to true (escaping enabled). When disabled, users can use environment variables within labels. Includes validation rules and comprehensive test coverage. --- .../Controllers/Api/ServicesController.php | 20 ++++- .../ServiceContainerLabelEscapeApiTest.php | 75 +++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/ServiceContainerLabelEscapeApiTest.php diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index b4fe4e47b..32097443e 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -222,6 +222,7 @@ public function services(Request $request) ), ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], + 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], ], ), ), @@ -288,7 +289,7 @@ public function services(Request $request) )] public function create_service(Request $request) { - $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override']; + $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -317,6 +318,7 @@ public function create_service(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -429,6 +431,9 @@ public function create_service(Request $request) $service = Service::create($servicePayload); $service->name = $request->name ?? "$oneClickServiceName-".$service->uuid; $service->description = $request->description; + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); if ($oneClickDotEnvs?->count() > 0) { $oneClickDotEnvs->each(function ($value) use ($service) { @@ -485,7 +490,7 @@ public function create_service(Request $request) return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); } elseif (filled($request->docker_compose_raw)) { - $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override']; + $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'project_uuid' => 'string|required', @@ -503,6 +508,7 @@ public function create_service(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -609,6 +615,9 @@ public function create_service(Request $request) $service->destination_id = $destination->id; $service->destination_type = $destination->getMorphClass(); $service->connect_to_docker_network = $connectToDockerNetwork; + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); $service->parse(isNew: true); @@ -835,6 +844,7 @@ public function delete_by_uuid(Request $request) ), ], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], + 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'], ], ) ), @@ -923,7 +933,7 @@ public function update_by_uuid(Request $request) $this->authorize('update', $service); - $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override']; + $allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $validationRules = [ 'name' => 'string|max:255', @@ -936,6 +946,7 @@ public function update_by_uuid(Request $request) 'urls.*.name' => 'string|required', 'urls.*.url' => 'string|nullable', 'force_domain_override' => 'boolean', + 'is_container_label_escape_enabled' => 'boolean', ]; $validationMessages = [ 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', @@ -1001,6 +1012,9 @@ public function update_by_uuid(Request $request) if ($request->has('connect_to_docker_network')) { $service->connect_to_docker_network = $request->connect_to_docker_network; } + if ($request->has('is_container_label_escape_enabled')) { + $service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled'); + } $service->save(); $service->parse(); diff --git a/tests/Feature/ServiceContainerLabelEscapeApiTest.php b/tests/Feature/ServiceContainerLabelEscapeApiTest.php new file mode 100644 index 000000000..895d776f0 --- /dev/null +++ b/tests/Feature/ServiceContainerLabelEscapeApiTest.php @@ -0,0 +1,75 @@ + 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first(); +}); + +function serviceContainerLabelAuthHeaders($bearerToken): array +{ + return [ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ]; +} + +describe('PATCH /api/v1/services/{uuid}', function () { + test('accepts is_container_label_escape_enabled field', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(serviceContainerLabelAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'is_container_label_escape_enabled' => false, + ]); + + $response->assertStatus(200); + + $service->refresh(); + expect($service->is_container_label_escape_enabled)->toBeFalse(); + }); + + test('rejects invalid is_container_label_escape_enabled value', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders(serviceContainerLabelAuthHeaders($this->bearerToken)) + ->patchJson("/api/v1/services/{$service->uuid}", [ + 'is_container_label_escape_enabled' => 'not-a-boolean', + ]); + + $response->assertStatus(422); + }); +});