From 99087924008662dd888dde7389036a1a0f6f21a7 Mon Sep 17 00:00:00 2001 From: Junaid Ebrahim Date: Wed, 10 Mar 2021 15:07:47 +0200 Subject: [PATCH] Docker enhancement to grant the user the ability to modify the container create Why: Several use cases require the need to alter the docker container create 1. Running systemd containers require special binds plus a custom termination signal 2. Running systemd in sysbox containers require custom runtime as well as Privileged=false 3. Many other use cases exist and will only grow as Docker becomes even more commonplace This change addresses the need by: 1. Adding a custom textedit to the Docker container Advanced tab 2. Adding the custom fields to the GNS3 schemas and objects 3. Adding logic to the Docker create function to merge the custom parameters NB: This change is depenant on modifications to both gns3-gui and gns3-server --- gns3server/compute/docker/docker_vm.py | 44 ++++++++++++++++- .../handlers/api/compute/docker_handler.py | 5 +- gns3server/schemas/docker.py | 10 ++++ gns3server/schemas/docker_template.py | 5 ++ tests/compute/docker/test_docker_vm.py | 47 +++++++++++++++++++ tests/handlers/api/compute/test_docker.py | 8 +++- 6 files changed, 113 insertions(+), 6 deletions(-) diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 940aeab64..6afba5e63 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -28,6 +28,7 @@ import subprocess import os import re +import json from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.utils.asyncio.raw_command_server import AsyncioRawCommandServer @@ -65,13 +66,14 @@ class DockerVM(BaseNode): :param console_resolution: Resolution of the VNC display :param console_http_port: Port to redirect HTTP queries :param console_http_path: Url part with the path of the web interface - :param extra_hosts: Hosts which will be written into /etc/hosts into docker conainer + :param extra_hosts: Hosts which will be written into /etc/hosts into docker container :param extra_volumes: Additional directories to make persistent + :param extra_parameters: Additional parameters used to create the docker container """ def __init__(self, name, node_id, project, manager, image, console=None, aux=None, start_command=None, adapters=None, environment=None, console_type="telnet", console_resolution="1024x768", - console_http_port=80, console_http_path="/", extra_hosts=None, extra_volumes=[]): + console_http_port=80, console_http_path="/", extra_hosts=None, extra_volumes=[], extra_parameters=None): super().__init__(name, node_id, project, manager, console=console, aux=aux, allocate_aux=True, console_type=console_type) @@ -94,6 +96,7 @@ def __init__(self, name, node_id, project, manager, image, console=None, aux=Non self._console_websocket = None self._extra_hosts = extra_hosts self._extra_volumes = extra_volumes or [] + self._extra_parameters = extra_parameters self._permissions_fixed = False self._display = None self._closing = False @@ -132,6 +135,7 @@ def __json__(self): "node_directory": self.working_path, "extra_hosts": self.extra_hosts, "extra_volumes": self.extra_volumes, + "extra_parameters": self.extra_parameters } def _get_free_display_port(self): @@ -211,6 +215,14 @@ def extra_volumes(self): def extra_volumes(self, extra_volumes): self._extra_volumes = extra_volumes + @property + def extra_parameters(self): + return self._extra_parameters + + @extra_parameters.setter + def extra_parameters(self, extra_parameters): + self._extra_parameters = extra_parameters + async def _get_container_state(self): """ Returns the container state (e.g. running, paused etc.) @@ -400,6 +412,9 @@ async def create(self): extra_hosts = self._format_extra_hosts(self._extra_hosts) if extra_hosts: params["Env"].append("GNS3_EXTRA_HOSTS={}".format(extra_hosts)) + + if self._extra_parameters: + params = self._merge_extra_parameters(params) result = await self.manager.query("POST", "containers/create", data=params) self._cid = result['Id'] @@ -424,6 +439,31 @@ def _format_extra_hosts(self, extra_hosts): except ValueError: raise DockerError("Can't apply `ExtraHosts`, wrong format: {}".format(extra_hosts)) return "\n".join(["{}\t{}".format(h[1], h[0]) for h in hosts]) + + def _merge_extra_parameters(self,params): + """ + Merge the user supplied extra parameters into the default Docker create parameters + """ + try: + #TODO: Determine additional validation needed, docker params can be quite complicated and could break the host if + # an a person does something stupid. Validation might be too complex for us to consider all the dangers. + # Also, I create an array of params that cannot/should'nt be overwritten , not sure if it's correct. + extra_params = json.loads(self._extra_parameters) + + merged = {**params, **extra_params} # merge values , common values will be overwritten so I will manually merge binds(see - if "HostConfig") + readonly_params = ["Hostname","Name","Entrypoint","Cmd","Image","Env","NetworkDisabled","Tty","OpenStdin","StdinOnce" ] + for param in readonly_params: # prevent certain parameters from being overwritten + merged[param] = params[param] + + if "HostConfig" in extra_params: # If extra params HostConfig sub section exists it would have overwritten GNS3 binds + merged["HostConfig"] = {**params["HostConfig"] , **extra_params["HostConfig"]} # merge the two HostConfig sections + if "Binds" in extra_params["HostConfig"]: # Add back the original GNS3 binds to the extra parameters binds + merged["HostConfig"]["Binds"].extend(params["HostConfig"]["Binds"]) + params=merged # Set params equal to our new merged config + + except ValueError: # if the variable text is not a valid json object then log the error + raise DockerError("Can't apply `ExtraParameters`, invalid json text:\n {}".format(self._extra_parameters)) + return params async def update(self): """ diff --git a/gns3server/handlers/api/compute/docker_handler.py b/gns3server/handlers/api/compute/docker_handler.py index e510a9f2a..d70fe7247 100644 --- a/gns3server/handlers/api/compute/docker_handler.py +++ b/gns3server/handlers/api/compute/docker_handler.py @@ -62,7 +62,8 @@ async def create(request, response): console_http_path=request.json.get("console_http_path", "/"), aux=request.json.get("aux"), extra_hosts=request.json.get("extra_hosts"), - extra_volumes=request.json.get("extra_volumes")) + extra_volumes=request.json.get("extra_volumes"), + extra_parameters=request.json.get("extra_parameters")) for name, value in request.json.items(): if name != "node_id": if hasattr(container, name) and getattr(container, name) != value: @@ -317,7 +318,7 @@ async def update(request, response): props = [ "name", "console", "aux", "console_type", "console_resolution", "console_http_port", "console_http_path", "start_command", - "environment", "adapters", "extra_hosts", "extra_volumes" + "environment", "adapters", "extra_hosts", "extra_volumes", "extra_parameters" ] changed = False diff --git a/gns3server/schemas/docker.py b/gns3server/schemas/docker.py index 6cea166a5..0d3da5b68 100644 --- a/gns3server/schemas/docker.py +++ b/gns3server/schemas/docker.py @@ -103,6 +103,11 @@ "type": "string" } }, + "extra_parameters": { + "description": "Docker extra create parameters (used in docker create)", + "type": ["string", "null"], + "minLength": 0, + }, "container_id": { "description": "Docker container ID Read only", "type": "string", @@ -214,6 +219,11 @@ "type": "string", } }, + "extra_parameters": { + "description": "Docker extra parameters ()", + "type": ["string", "null"], + "minLength": 0, + }, "node_directory": { "description": "Path to the node working directory Read only", "type": "string" diff --git a/gns3server/schemas/docker_template.py b/gns3server/schemas/docker_template.py index 0e04bbd13..08a0f37e1 100644 --- a/gns3server/schemas/docker_template.py +++ b/gns3server/schemas/docker_template.py @@ -87,6 +87,11 @@ "type": "array", "default": [] }, + "extra_parameters": { + "description": "Docker extra create parameters (used in docker create)", + "type": "string", + "default": "" + }, "custom_adapters": CUSTOM_ADAPTERS_ARRAY_SCHEMA } diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index 1cefc7c22..90d2db905 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -66,6 +66,7 @@ def test_json(vm, compute_project): 'console_http_path': '/', 'extra_hosts': None, 'extra_volumes': [], + 'extra_parameters': None, 'aux': vm.aux, 'start_command': vm.start_command, 'environment': vm.environment, @@ -224,6 +225,52 @@ async def test_create_with_extra_hosts(compute_project, manager): assert "GNS3_EXTRA_HOSTS=199.199.199.1\ttest\n199.199.199.1\ttest2" in called_kwargs["data"]["Env"] assert vm._extra_hosts == extra_hosts +async def test_create_with_extra_parameters(compute_project, manager): + + extra_parameters = """{ + "StopSignal": "SIGRTMIN+3", + "HostConfig": { + "CapAdd": [ + "ALL" + ], + "Runtime": "runc", + "Privileged": false, + "Binds": [ + "/sys/fs/cgroup:/sys/fs/cgroup:ro", + "/sys/fs/fuse:/sys/fs/fuse" + ], + "Tmpfs": { + "/tmp": "", + "/run": "", + "/run/lock": "" + } + } +}""" + ObjTmpfs = { + "/tmp": "", + "/run": "", + "/run/lock": "" + } + response = { + "Id": "e90e34656806", + "Warnings": [] + } + + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: + vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu", extra_parameters=extra_parameters) + await vm.create() + called_kwargs = mock.call_args[1] + assert called_kwargs["data"]["StopSignal"] == "SIGRTMIN+3" + assert ObjTmpfs == called_kwargs["data"]["HostConfig"]["Tmpfs"] # test object to Hostconfig append + assert "/sys/fs/cgroup:/sys/fs/cgroup:ro" in called_kwargs["data"]["HostConfig"]["Binds"] # test adding custom binds + assert "/sys/fs/fuse:/sys/fs/fuse" in called_kwargs["data"]["HostConfig"]["Binds"] + assert len(called_kwargs["data"]["HostConfig"]["Binds"]) > 2 # test other binds are not overwriten + assert called_kwargs["data"]["HostConfig"]["Privileged"] == False # test setting privileged + assert called_kwargs["data"]["HostConfig"]["Runtime"] == "runc" # test the addition of the runtime param + + + async def test_create_with_extra_hosts_wrong_format(compute_project, manager): extra_hosts = "test" diff --git a/tests/handlers/api/compute/test_docker.py b/tests/handlers/api/compute/test_docker.py index 217fb3b09..227625123 100644 --- a/tests/handlers/api/compute/test_docker.py +++ b/tests/handlers/api/compute/test_docker.py @@ -37,7 +37,8 @@ def base_params(): "environment": "YES=1\nNO=0", "console_type": "telnet", "console_resolution": "1280x1024", - "extra_hosts": "test:127.0.0.1" + "extra_hosts": "test:127.0.0.1", + "extra_parameters": """{"StopSignal": "SIGRTMIN+3"}""" } return params @@ -78,6 +79,7 @@ async def test_docker_create(compute_api, compute_project, base_params): assert response.json["environment"] == "YES=1\nNO=0" assert response.json["console_resolution"] == "1280x1024" assert response.json["extra_hosts"] == "test:127.0.0.1" + assert response.json["extra_parameters"] == """{"StopSignal": "SIGRTMIN+3"}""" async def test_docker_start(compute_api, vm): @@ -174,7 +176,8 @@ async def test_docker_update(compute_api, vm, free_console_port): "console": free_console_port, "start_command": "yes", "environment": "GNS3=1\nGNS4=0", - "extra_hosts": "test:127.0.0.1" + "extra_hosts": "test:127.0.0.1", + "extra_parameters": """{"StopSignal": "SIGRTMIN+3"}""" } with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.update") as mock: @@ -186,6 +189,7 @@ async def test_docker_update(compute_api, vm, free_console_port): assert response.json["start_command"] == "yes" assert response.json["environment"] == "GNS3=1\nGNS4=0" assert response.json["extra_hosts"] == "test:127.0.0.1" + assert response.json["extra_parameters"] == """{"StopSignal": "SIGRTMIN+3"}""" async def test_docker_start_capture(compute_api, vm):