Repository: ak4zh/Caprover-API

Issue:
Add support for "command" in one-click apps service definition, via serviceUpdateOverride
* Caprover API version: 0.1.22
* Python version: 3.12.7
* Operating System: Ubuntu

### Description

Since caprover v1.8.0, caprover offers "Service Update Override", to expose certain Docker customizations that are not first-class entries in caprover's own data model: https://caprover.com/docs/service-update-override.html

And in particular, the caprover-frontend can construct some of these overrides from a one-click app definition (i.e. Docker compose service definition).  Specifically:

    compose:
    hostname:
    cap_add:

That is done in this file: https://github.com/caprover/caprover-frontend/blob/ffb2b69c1143262a241cd8005dddf263eece6bb1/src/utils/DockerComposeToServiceOverride.ts#L37
(I am bummed that they do this in the frontend, not the caprover server so that CaproverAPI could share the functionality).

It would be great if `CaproverAPI.deploy_one_click_app` could likewise parse `command` from a one-click app service definition and set the serviceUpdateOverride key in its call to `CaproverAPI.update_app`.

### Context

I often set up roughly the same stack for many different clients.  With caprover-frontend, even one-click apps require a lot of copy-pasting (e.g. of variable values) and clicking (e.g. to enable/force SSL after the app is created).  I use CaproverAPI because allows me to script all this.

Discussion:
@IamJeffG: ## Example

From https://github.com/caprover/one-click-apps/blob/b1fe2ccef23e2e1ec3c7d96fa25adcc297d33482/public/v4/apps/posthog.yml#L19
the line
```yaml
service:
  $$cap_appname-redis:
    command: sh -c "redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb"
```

results in a call to `/api/v2/user/apps/appDefinitions/update`
```json
{
  "appName": "posthog-redis",
  "serviceUpdateOverride": "TaskTemplate:\n  ContainerSpec:\n    Command:\n      - sh\n      - -c\n      - redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb\n",
  ...
}
```

Solution patch:
diff --git a/caprover_api/caprover_api.py b/caprover_api/caprover_api.py
index fdc9e3f..5425082 100644
--- a/caprover_api/caprover_api.py
+++ b/caprover_api/caprover_api.py
@@ -208,6 +208,38 @@ class CaproverAPI:
             )
         return raw_app_data
 
+    @staticmethod
+    def _parse_command(command):
+        """
+        Parse Docker Compose service command into a Docker override.
+
+        The override is compatible with Docker API's Service Update Object
+        and therefore is a valid Caprover Service Update Override.
+
+        Mimics caprover-frontend's functionality from:
+        https://github.com/caprover/caprover-frontend/blob/ffb2b69c1143262a241cd8005dddf263eece6bb1/src/utils/DockerComposeToServiceOverride.ts#L37
+
+        :param command: The command from the Docker Compose service definition
+            a string or a list of str
+        :return: A dict that can be converted to YAML for the service override.
+        """
+
+        def parse_docker_cmd(cmd_string):
+            # Matches sequences inside quotes or sequences without spaces
+            regex = r'[^\s"\'\n]+|"([^"]*)"|\'([^\']*)\''
+            args = []
+            for match in re.finditer(regex, cmd_string):
+                args.append(match.group(1) or match.group(2) or match.group(0))
+            return args
+
+        # Convert command to a list if it is a string
+        command_list = (
+            command if isinstance(command, list) else parse_docker_cmd(command)
+        )
+
+        # Build the service override dictionary
+        return {"TaskTemplate": {"ContainerSpec": {"Command": command_list}}}
+
     @retry(times=3, exceptions=COMMON_ERRORS)
     def get_system_info(self):
         response = self.session.get(
@@ -312,6 +344,15 @@ class CaproverAPI:
                     caprover_extras.get("containerHttpPort", 80)
                 )
 
+                # Parse command (if it exists) into service update override
+                command = service_data.get("command")
+                service_update_override = None
+                if command:
+                    service_override_dict = self._parse_command(command)
+                    service_update_override = yaml.dump(
+                        service_override_dict, default_style="|"
+                    )
+
                 # create app
                 self.create_app(
                     app_name=service_name,
@@ -325,7 +366,8 @@ class CaproverAPI:
                     persistent_directories=persistent_directories,
                     environment_variables=environment_variables,
                     expose_as_web_app=expose_as_web_app,
-                    container_http_port=container_http_port
+                    container_http_port=container_http_port,
+                    serviceUpdateOverride=service_update_override,
                 )
                 image_name = service_data.get("image")
                 docker_file_lines = caprover_extras.get("dockerfileLines")


Test patch:
diff --git a/tests/test_caprover_api.py b/tests/test_caprover_api.py
index d5469b4..461155c 100644
--- a/tests/test_caprover_api.py
+++ b/tests/test_caprover_api.py
@@ -101,6 +101,85 @@ caproverOneClickApp:
         )
 
 
+class TestServiceUpdateOverride(unittest.TestCase):
+    """Test unsupported docker-compose params as service override definition
+
+    Reference implementation:
+    https://github.com/caprover/caprover-frontend/blob/master/src/utils/DockerComposeToServiceOverride.ts
+    """
+
+    def setUp(self):
+        """Set up test fixtures"""
+        with patch.object(CaproverAPI, "get_system_info"), patch.object(
+            CaproverAPI, "_login"
+        ):
+            self.api = CaproverAPI(
+                dashboard_url="http://dummy", password="dummy"
+            )
+
+    def test_parse_command_single_string(self):
+        """
+        Test that a single string command is parsed into a list.
+        """
+        command = "redis-server --port 6380 --maxmemory 200mb"
+        expected_output = {
+            "TaskTemplate": {
+                "ContainerSpec": {
+                    "Command": [
+                        "redis-server",
+                        "--port",
+                        "6380",  # keep numbers as str
+                        "--maxmemory",
+                        "200mb",
+                    ]
+                }
+            }
+        }
+        result = self.api._parse_command(command)
+        self.assertEqual(result, expected_output)
+
+    def test_parse_command_list(self):
+        command = ["sh", "-c", "redis-server --port 6380"]
+        expected_output = {
+            "TaskTemplate": {
+                "ContainerSpec": {
+                    "Command": [
+                        "sh",
+                        "-c",
+                        "redis-server --port 6380",
+                    ]
+                }
+            }
+        }
+        result = self.api._parse_command(command)
+        self.assertEqual(result, expected_output)
+
+    def test_parse_command_multiline_string(self):
+        """
+        Test a multi-line string command (e.g. from YAML >| operator)
+        """
+        command = """sh -c "
+            echo 'Starting...'
+            redis-server
+            " """
+        expected_output = {
+            "TaskTemplate": {
+                "ContainerSpec": {
+                    "Command": [
+                        "sh",
+                        "-c",
+                        """
+            echo 'Starting...'
+            redis-server
+            """,
+                    ]
+                }
+            }
+        }
+        result = self.api._parse_command(command)
+        self.assertEqual(result, expected_output)
+
+
 class TestUpdateApp(unittest.TestCase):
     def setUp(self):
         with patch.object(CaproverAPI, "get_system_info"), patch.object(