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 b4fe4e47b1..32097443e6 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -222,6 +222,7 @@ class ServicesController extends Controller ), ], '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 @@ class ServicesController extends Controller )] 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 @@ class ServicesController extends Controller '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 @@ class ServicesController extends Controller $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 @@ class ServicesController extends Controller 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 @@ class ServicesController extends Controller '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 @@ class ServicesController extends Controller $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 @@ class ServicesController extends Controller ), ], '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 @@ class ServicesController extends Controller $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 @@ class ServicesController extends Controller '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 @@ class ServicesController extends Controller 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 0000000000..895d776f09 --- /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); + }); +});