From 03ffce0a75b4ceadfe033fa7c805d4f771c017b8 Mon Sep 17 00:00:00 2001 From: Julien Duponchelle Date: Mon, 29 Feb 2016 21:08:25 +0100 Subject: [PATCH] Docker VNC support Ref https://github.com/GNS3/gns3-gui/issues/947 --- gns3server/handlers/api/docker_handler.py | 7 +- gns3server/modules/docker/docker_vm.py | 96 ++++++++++++------ gns3server/schemas/docker.py | 14 ++- tests/handlers/api/test_docker.py | 4 +- tests/modules/docker/test_docker_vm.py | 113 ++++++++++++++++------ 5 files changed, 168 insertions(+), 66 deletions(-) diff --git a/gns3server/handlers/api/docker_handler.py b/gns3server/handlers/api/docker_handler.py index ecb38534..467574d3 100644 --- a/gns3server/handlers/api/docker_handler.py +++ b/gns3server/handlers/api/docker_handler.py @@ -70,7 +70,10 @@ class DockerHandler: image=request.json.pop("image"), start_command=request.json.get("start_command"), environment=request.json.get("environment"), - adapters=request.json.get("adapters") + adapters=request.json.get("adapters"), + console=request.json.get("console"), + console_type=request.json.get("console_type"), + aux=request.json.get("aux") ) for name, value in request.json.items(): if name != "_vm_id": @@ -167,7 +170,7 @@ class DockerHandler: container = docker_manager.get_vm( request.match_info["id"], project_id=request.match_info["project_id"]) - yield from container.remove() + yield from container.delete() response.set_status(204) @classmethod diff --git a/gns3server/modules/docker/docker_vm.py b/gns3server/modules/docker/docker_vm.py index ff2861d8..5b280e1a 100644 --- a/gns3server/modules/docker/docker_vm.py +++ b/gns3server/modules/docker/docker_vm.py @@ -33,6 +33,7 @@ from ..base_vm import BaseVM from ..adapters.ethernet_adapter import EthernetAdapter from ..nios.nio_udp import NIOUDP from ...utils.asyncio.telnet_server import AsyncioTelnetServer +from ...utils.asyncio import wait_for_file_creation from ...ubridge.ubridge_error import UbridgeError, UbridgeNamespaceError @@ -50,11 +51,14 @@ class DockerVM(BaseVM): :param manager: Manager instance :param image: Docker image :param console: TCP console port + :param console_type: Console type :param aux: TCP aux console port """ - def __init__(self, name, vm_id, project, manager, image, console=None, aux=None, start_command=None, adapters=None, environment=None): - super().__init__(name, vm_id, project, manager, console=console, aux=aux, allocate_aux=True) + def __init__(self, name, vm_id, project, manager, image, + console=None, aux=None, start_command=None, + adapters=None, environment=None, console_type="telnet"): + super().__init__(name, vm_id, project, manager, console=console, aux=aux, allocate_aux=True, console_type=console_type) self._image = image self._start_command = start_command @@ -85,12 +89,25 @@ class DockerVM(BaseVM): "image": self._image, "adapters": self.adapters, "console": self.console, + "console_type": self.console_type, "aux": self.aux, "start_command": self.start_command, "environment": self.environment, "vm_directory": self.working_dir } + def _get_free_display_port(self): + """ + Search a free display port + """ + display = 100 + if not os.path.exists("/tmp/.X11-unix/"): + return display + while True: + if not os.path.exists("/tmp/.X11-unix/X{}".format(display)): + return display + display += 1 + @property def start_command(self): return self._start_command @@ -172,13 +189,19 @@ class DockerVM(BaseVM): "Privileged": True, "Binds": self._mount_binds(image_infos) }, - "Volumes": {} + "Volumes": {}, + "Env": [] } if self._start_command: params.update({"Cmd": shlex.split(self._start_command)}) if self._environment: - params.update({"Env": [e.strip() for e in self._environment.split("\n")]}) + params["Env"] += [e.strip() for e in self._environment.split("\n")] + + if self._console_type == "vnc": + yield from self._start_vnc() + params["Env"].append("DISPLAY=:{}".format(self._display)) + params["HostConfig"]["Binds"].append("/tmp/.X11-unix/:/tmp/.X11-unix/") result = yield from self.manager.query("POST", "containers/create", data=params) self._cid = result['Id'] @@ -195,7 +218,7 @@ class DockerVM(BaseVM): console = self.console state = yield from self._get_container_state() - yield from self.remove() + yield from self.close() yield from self.create() self.console = console if state == "running": @@ -229,11 +252,27 @@ class DockerVM(BaseVM): log.error(line) raise DockerError(logdata) - yield from self._start_console() + if self.console_type == "telnet": + yield from self._start_console() self.status = "started" log.info("Docker container '{name}' [{image}] started listen for telnet on {console}".format(name=self._name, image=self._image, console=self._console)) + @asyncio.coroutine + def _start_vnc(self): + """ + Start a VNC server for this container + """ + + self._display = self._get_free_display_port() + if shutil.which("Xvfb") is None or shutil.which("x11vnc") is None: + raise DockerError("Please install Xvfb and x11vnc before using the VNC support") + self._xvfb_process = yield from asyncio.create_subprocess_exec("Xvfb", "-nolisten", "tcp", ":{}".format(self._display), "-screen", "0", "1024x768x16") + self._x11vnc_process = yield from asyncio.create_subprocess_exec("x11vnc", "-forever", "-nopw", "-display", "WAIT:{}".format(self._display), "-rfbport", str(self.console), "-noncache", "-listen", self._manager.port_manager.console_host) + + x11_socket = os.path.join("/tmp/.X11-unix/", "X{}".format(self._display)) + yield from wait_for_file_creation(x11_socket) + @asyncio.coroutine def _start_console(self): """ @@ -348,23 +387,26 @@ class DockerVM(BaseVM): self.status = "started" @asyncio.coroutine - def remove(self): - """Removes this Docker container.""" + def close(self): + """Closes this Docker container.""" + + if not (yield from super().close()): + return False try: + if self.console_type == "vnc": + self._x11vnc_process.terminate() + self._xvfb_process.terminate() + yield from self._x11vnc_process.wait() + yield from self._xvfb_process.wait() + state = yield from self._get_container_state() - if state == "paused": - yield from self.unpause() - if state == "running": + if state == "paused" or state == "running": yield from self.stop() yield from self.manager.query("DELETE", "containers/{}".format(self._cid), params={"force": 1}) log.info("Docker container '{name}' [{image}] removed".format( name=self._name, image=self._image)) - if self._console: - self._manager.port_manager.release_tcp_port(self._console, self._project) - self._console = None - for adapter in self._ethernet_adapters: if adapter is not None: for nio in adapter.ports.values(): @@ -375,22 +417,6 @@ class DockerVM(BaseVM): log.debug("Docker error when closing: {}".format(str(e))) return - @asyncio.coroutine - def close(self): - """Closes this Docker container.""" - - if not (yield from super().close()): - return False - - for adapter in self._ethernet_adapters: - if adapter is not None: - for nio in adapter.ports.values(): - if nio and isinstance(nio, NIOUDP): - self.manager.port_manager.release_udp_port( - nio.lport, self._project) - - yield from self.remove() - @asyncio.coroutine def _add_ubridge_connection(self, nio, adapter_number, namespace): """ @@ -662,3 +688,11 @@ class DockerVM(BaseVM): result = yield from self.manager.query("GET", "containers/{}/logs".format(self._cid), params={"stderr": 1, "stdout": 1}) return result + + @asyncio.coroutine + def delete(self): + """ + Delete the VM (including all its files). + """ + yield from self.close() + yield from super().delete() diff --git a/gns3server/schemas/docker.py b/gns3server/schemas/docker.py index d90b3a8e..47baf97a 100644 --- a/gns3server/schemas/docker.py +++ b/gns3server/schemas/docker.py @@ -39,6 +39,10 @@ DOCKER_CREATE_SCHEMA = { "maximum": 65535, "type": ["integer", "null"] }, + "console_type": { + "description": "console type", + "enum": ["telnet", "vnc"] + }, "aux": { "description": "auxilary TCP port", "minimum": 1, @@ -88,6 +92,10 @@ DOCKER_UPDATE_SCHEMA = { "maximum": 65535, "type": ["integer", "null"] }, + "console_type": { + "description": "console type", + "enum": ["telnet", "vnc"] + }, "aux": { "description": "auxilary TCP port", "minimum": 1, @@ -143,6 +151,10 @@ DOCKER_OBJECT_SCHEMA = { "maximum": 65535, "type": ["integer", "null"] }, + "console_type": { + "description": "console type", + "enum": ["telnet", "vnc"] + }, "container_id": { "description": "Docker container ID", "type": "string", @@ -184,7 +196,7 @@ DOCKER_OBJECT_SCHEMA = { } }, "additionalProperties": False, - "required": ["vm_id", "project_id", "image", "container_id", "adapters", "aux", "console", "start_command", "environment", "vm_directory"] + "required": ["vm_id", "project_id", "image", "container_id", "adapters", "aux", "console", "console_type", "start_command", "environment", "vm_directory"] } diff --git a/tests/handlers/api/test_docker.py b/tests/handlers/api/test_docker.py index beaab7bd..5a9b96e5 100644 --- a/tests/handlers/api/test_docker.py +++ b/tests/handlers/api/test_docker.py @@ -30,7 +30,7 @@ from gns3server.modules.docker import Docker @pytest.fixture def base_params(): """Return standard parameters""" - return {"name": "PC TEST 1", "image": "nginx", "start_command": "nginx-daemon", "adapters": 2, "environment": "YES=1\nNO=0"} + return {"name": "PC TEST 1", "image": "nginx", "start_command": "nginx-daemon", "adapters": 2, "environment": "YES=1\nNO=0", "console_type": "telnet"} @pytest.yield_fixture(autouse=True) @@ -89,7 +89,7 @@ def test_docker_reload(server, vm): def test_docker_delete(server, vm): - with asyncio_patch("gns3server.modules.docker.docker_vm.DockerVM.remove", return_value=True) as mock: + with asyncio_patch("gns3server.modules.docker.docker_vm.DockerVM.delete", return_value=True) as mock: response = server.delete("/projects/{project_id}/docker/vms/{vm_id}".format(project_id=vm["project_id"], vm_id=vm["vm_id"])) assert mock.called assert response.status == 204 diff --git a/tests/modules/docker/test_docker_vm.py b/tests/modules/docker/test_docker_vm.py index fdb4936c..1d399356 100644 --- a/tests/modules/docker/test_docker_vm.py +++ b/tests/modules/docker/test_docker_vm.py @@ -54,6 +54,7 @@ def test_json(vm, project): 'vm_id': vm.id, 'adapters': 1, 'console': vm.console, + 'console_type': 'telnet', 'aux': vm.aux, 'start_command': vm.start_command, 'environment': vm.environment, @@ -93,8 +94,43 @@ def test_create(loop, project, manager): "NetworkDisabled": True, "Name": "test", "Hostname": "test", - "Image": "ubuntu" + "Image": "ubuntu", + "Env": [] + }) + assert vm._cid == "e90e34656806" + + +def test_create_vnc(loop, project, manager): + + response = { + "Id": "e90e34656806", + "Warnings": [] + } + + with asyncio_patch("gns3server.modules.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]) as mock_list_images: + with asyncio_patch("gns3server.modules.docker.Docker.query", return_value=response) as mock: + vm = DockerVM("test", str(uuid.uuid4()), project, manager, "ubuntu", console_type="vnc") + vm._start_vnc = MagicMock() + vm._display = 42 + loop.run_until_complete(asyncio.async(vm.create())) + mock.assert_called_with("POST", "containers/create", data={ + "Tty": True, + "OpenStdin": True, + "StdinOnce": False, + "HostConfig": + { + "CapAdd": ["ALL"], + "Binds": ['/tmp/.X11-unix/:/tmp/.X11-unix/'], + "Privileged": True + }, + "Volumes": {}, + "NetworkDisabled": True, + "Name": "test", + "Hostname": "test", + "Image": "ubuntu", + "Env": ['DISPLAY=:42'] }) + assert vm._start_vnc.called assert vm._cid == "e90e34656806" @@ -124,7 +160,8 @@ def test_create_start_cmd(loop, project, manager): "NetworkDisabled": True, "Name": "test", "Hostname": "test", - "Image": "ubuntu" + "Image": "ubuntu", + "Env": [] }) assert vm._cid == "e90e34656806" @@ -199,7 +236,8 @@ def test_create_image_not_available(loop, project, manager): "NetworkDisabled": True, "Name": "test", "Hostname": "test", - "Image": "ubuntu" + "Image": "ubuntu", + "Env": [] }) assert vm._cid == "e90e34656806" mock_pull.assert_called_with("ubuntu") @@ -402,7 +440,8 @@ def test_update(loop, vm): "NetworkDisabled": True, "Name": "test", "Hostname": "test", - "Image": "ubuntu" + "Image": "ubuntu", + "Env": [] }) assert vm.console == original_console @@ -437,39 +476,20 @@ def test_update_running(loop, vm): "NetworkDisabled": True, "Name": "test", "Hostname": "test", - "Image": "ubuntu" + "Image": "ubuntu", + "Env": [] }) assert vm.console == original_console assert vm.start.called -def test_remove(loop, vm): +def test_delete(loop, vm): with asyncio_patch("gns3server.modules.docker.DockerVM._get_container_state", return_value="stopped"): with asyncio_patch("gns3server.modules.docker.Docker.query") as mock_query: - loop.run_until_complete(asyncio.async(vm.remove())) - mock_query.assert_called_with("DELETE", "containers/e90e34656842", params={"force": 1}) - - -def test_remove_paused(loop, vm): - - with asyncio_patch("gns3server.modules.docker.DockerVM._get_container_state", return_value="paused"): - with asyncio_patch("gns3server.modules.docker.DockerVM.unpause") as mock_unpause: - with asyncio_patch("gns3server.modules.docker.Docker.query") as mock_query: - loop.run_until_complete(asyncio.async(vm.remove())) + loop.run_until_complete(asyncio.async(vm.delete())) mock_query.assert_called_with("DELETE", "containers/e90e34656842", params={"force": 1}) - assert mock_unpause.called - - -def test_remove_running(loop, vm): - - with asyncio_patch("gns3server.modules.docker.DockerVM._get_container_state", return_value="running"): - with asyncio_patch("gns3server.modules.docker.DockerVM.stop") as mock_stop: - with asyncio_patch("gns3server.modules.docker.Docker.query") as mock_query: - loop.run_until_complete(asyncio.async(vm.remove())) - mock_query.assert_called_with("DELETE", "containers/e90e34656842", params={"force": 1}) - assert mock_stop.called def test_close(loop, vm, port_manager): @@ -480,13 +500,30 @@ def test_close(loop, vm, port_manager): nio = vm.manager.create_nio(0, nio) loop.run_until_complete(asyncio.async(vm.adapter_add_nio_binding(0, nio))) - with asyncio_patch("gns3server.modules.docker.DockerVM.remove") as mock_remove: - loop.run_until_complete(asyncio.async(vm.close())) - assert mock_remove.called + with asyncio_patch("gns3server.modules.docker.DockerVM._get_container_state", return_value="stopped"): + with asyncio_patch("gns3server.modules.docker.Docker.query") as mock_query: + loop.run_until_complete(asyncio.async(vm.close())) + mock_query.assert_called_with("DELETE", "containers/e90e34656842", params={"force": 1}) + assert vm._closed is True assert "4242" not in port_manager.udp_ports +def test_close_vnc(loop, vm, port_manager): + + vm._console_type = "vnc" + vm._x11vnc_process = MagicMock() + vm._xvfb_process = MagicMock() + + with asyncio_patch("gns3server.modules.docker.DockerVM._get_container_state", return_value="stopped"): + with asyncio_patch("gns3server.modules.docker.Docker.query") as mock_query: + loop.run_until_complete(asyncio.async(vm.close())) + mock_query.assert_called_with("DELETE", "containers/e90e34656842", params={"force": 1}) + + assert vm._closed is True + assert vm._xvfb_process.terminate.called + + def test_get_namespace(loop, vm): response = { "State": { @@ -706,3 +743,19 @@ def test_mount_binds(vm, tmpdir): ] assert os.path.exists(dst) + + +def test_start_vnc(vm, loop): + with patch("shutil.which", return_value="/bin/x"): + with asyncio_patch("gns3server.modules.docker.docker_vm.wait_for_file_creation") as mock_wait: + with asyncio_patch("asyncio.create_subprocess_exec") as mock_exec: + loop.run_until_complete(asyncio.async(vm._start_vnc())) + assert vm._display is not None + mock_exec.assert_any_call("Xvfb", "-nolisten", "tcp", ":{}".format(vm._display), "-screen", "0", "1024x768x16") + mock_exec.assert_any_call("x11vnc", "-forever", "-nopw", "-display", "WAIT:{}".format(vm._display), "-rfbport", str(vm.console), "-noncache", "-listen", "127.0.0.1") + mock_wait.assert_called_with("/tmp/.X11-unix/X{}".format(vm._display)) + + +def test_start_vnc_xvfb_missing(vm, loop): + with pytest.raises(DockerError): + loop.run_until_complete(asyncio.async(vm._start_vnc()))