diff --git a/gns3server/handlers/api/docker_handler.py b/gns3server/handlers/api/docker_handler.py index a7b1f2a5..0f015054 100644 --- a/gns3server/handlers/api/docker_handler.py +++ b/gns3server/handlers/api/docker_handler.py @@ -15,6 +15,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from aiohttp.web import HTTPConflict + from ...web.route import Route from ...modules.docker import Docker @@ -22,6 +24,7 @@ from ...schemas.docker import ( DOCKER_CREATE_SCHEMA, DOCKER_UPDATE_SCHEMA, DOCKER_CAPTURE_SCHEMA, DOCKER_OBJECT_SCHEMA ) +from ...schemas.nio import NIO_SCHEMA class DockerHandler: @@ -58,11 +61,13 @@ class DockerHandler: container = yield from docker_manager.create_vm( request.json.pop("name"), request.match_info["project_id"], - request.json.pop("imagename") + request.json.get("id"), + image=request.json.pop("imagename"), + startcmd=request.json.get("startcmd") ) # FIXME: DO WE NEED THIS? for name, value in request.json.items(): - if name != "vm_id": + if name != "_vm_id": if hasattr(container, name) and getattr(container, name) != value: setattr(container, name, value) @@ -181,3 +186,62 @@ class DockerHandler: project_id=request.match_info["project_id"]) yield from container.pause() response.set_status(204) + + @Route.post( + r"/projects/{project_id}/docker/images/{id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio", + parameters={ + "project_id": "UUID for the project", + "id": "ID of the container", + "adapter_number": "Adapter where the nio should be added", + "port_number": "Port on the adapter" + }, + status_codes={ + 201: "NIO created", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Add a NIO to a Docker container", + input=NIO_SCHEMA, + output=NIO_SCHEMA) + def create_nio(request, response): + docker_manager = Docker.instance() + container = docker_manager.get_container( + request.match_info["id"], + project_id=request.match_info["project_id"]) + nio_type = request.json["type"] + if nio_type not in ("nio_udp"): + raise HTTPConflict( + text="NIO of type {} is not supported".format(nio_type)) + nio = docker_manager.create_nio( + int(request.match_info["adapter_number"]), request.json) + adapter = container._ethernet_adapters[ + int(request.match_info["adapter_number"]) + ] + container.adapter_add_nio_binding( + int(request.match_info["adapter_number"]), nio) + response.set_status(201) + response.json(nio) + + @classmethod + @Route.delete( + r"/projects/{project_id}/docker/images/{id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio", + parameters={ + "project_id": "UUID for the project", + "id": "ID of the container", + "adapter_number": "Adapter where the nio should be added", + "port_number": "Port on the adapter" + }, + status_codes={ + 204: "NIO deleted", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Remove a NIO from a Docker container") + def delete_nio(request, response): + docker_manager = Docker.instance() + container = docker_manager.get_container( + request.match_info["id"], + project_id=request.match_info["project_id"]) + yield from container.adapter_remove_nio_binding( + int(request.match_info["adapter_number"])) + response.set_status(204) diff --git a/gns3server/modules/docker/__init__.py b/gns3server/modules/docker/__init__.py index 786ce1ea..c20a4efb 100644 --- a/gns3server/modules/docker/__init__.py +++ b/gns3server/modules/docker/__init__.py @@ -19,14 +19,11 @@ Docker server module. """ -import os -import sys -import shutil import asyncio -import subprocess import logging import aiohttp import docker +from requests.exceptions import ConnectionError log = logging.getLogger(__name__) @@ -44,7 +41,6 @@ class Docker(BaseManager): super().__init__() # FIXME: make configurable and start docker before trying self._server_url = 'unix://var/run/docker.sock' - # FIXME: handle client failure self._client = docker.Client(base_url=self._server_url) self._execute_lock = asyncio.Lock() @@ -60,7 +56,6 @@ class Docker(BaseManager): @server_url.setter def server_url(self, value): self._server_url = value - # FIXME: handle client failure self._client = docker.Client(base_url=value) @asyncio.coroutine @@ -68,29 +63,11 @@ class Docker(BaseManager): command = getattr(self._client, command) log.debug("Executing Docker with command: {}".format(command)) try: - # FIXME: async wait result = command(**kwargs) except Exception as error: raise DockerError("Docker has returned an error: {}".format(error)) return result - # FIXME: do this in docker - @asyncio.coroutine - def project_closed(self, project): - """Called when a project is closed. - - :param project: Project instance - """ - yield from super().project_closed(project) - hdd_files_to_close = yield from self._find_inaccessible_hdd_files() - for hdd_file in hdd_files_to_close: - log.info("Closing VirtualBox VM disk file {}".format(os.path.basename(hdd_file))) - try: - yield from self.execute("closemedium", ["disk", hdd_file]) - except VirtualBoxError as e: - log.warning("Could not close VirtualBox VM disk file {}: {}".format(os.path.basename(hdd_file), e)) - continue - @asyncio.coroutine def list_images(self): """Gets Docker image list. @@ -99,10 +76,15 @@ class Docker(BaseManager): :rtype: list """ images = [] - for image in self._client.images(): - for tag in image['RepoTags']: - images.append({'imagename': tag}) - return images + try: + for image in self._client.images(): + for tag in image['RepoTags']: + images.append({'imagename': tag}) + return images + except ConnectionError as error: + raise DockerError( + """Docker couldn't list images and returned an error: {} +Is the Docker service running?""".format(error)) @asyncio.coroutine def list_containers(self): @@ -122,7 +104,6 @@ class Docker(BaseManager): :returns: Docker container """ if project_id: - # check if the project_id exists project = ProjectManager.instance().get_project(project_id) if cid not in self._vms: diff --git a/gns3server/modules/docker/docker_vm.py b/gns3server/modules/docker/docker_vm.py index e70c982c..50e56472 100644 --- a/gns3server/modules/docker/docker_vm.py +++ b/gns3server/modules/docker/docker_vm.py @@ -19,19 +19,18 @@ Docker container instance. """ -import sys -import shlex -import re -import os -import tempfile -import json -import socket import asyncio +import shutil import docker +import netifaces +from docker.utils import create_host_config +from gns3server.ubridge.hypervisor import Hypervisor from pkg_resources import parse_version from .docker_error import DockerError from ..base_vm import BaseVM +from ..adapters.ethernet_adapter import EthernetAdapter +from ..nios.nio_udp import NIOUDP import logging log = logging.getLogger(__name__) @@ -41,15 +40,23 @@ class Container(BaseVM): """Docker container implementation. :param name: Docker container name + :param vm_id: Docker VM identifier :param project: Project instance :param manager: Manager instance :param image: Docker image """ - def __init__(self, name, image, project, manager): + def __init__(self, name, vm_id, project, manager, image, startcmd=None): self._name = name + self._id = vm_id self._project = project self._manager = manager self._image = image + self._startcmd = startcmd + self._veths = [] + self._ethernet_adapters = [] + self._ubridge_hypervisor = None + self._temporary_directory = None + self._hw_virtualization = False log.debug( "{module}: {name} [{image}] initialized.".format( @@ -60,11 +67,17 @@ class Container(BaseVM): def __json__(self): return { "name": self._name, - "id": self._id, + "vm_id": self._id, + "cid": self._cid, "project_id": self._project.id, "image": self._image, } + @property + def veths(self): + """Returns Docker host veth interfaces.""" + return self._veths + @asyncio.coroutine def _get_container_state(self): """Returns the container state (e.g. running, paused etc.) @@ -74,9 +87,16 @@ class Container(BaseVM): """ try: result = yield from self.manager.execute( - "inspect_container", {"container": self._id}) - for state, value in result["State"].items(): + "inspect_container", {"container": self._cid}) + result_dict = {state.lower(): value for state, value in result["State"].items()} + for state, value in result_dict.items(): if value is True: + # a container can be both paused and running + if state == "paused": + return "paused" + if state == "running": + if "paused" in result_dict and result_dict["paused"] is True: + return "paused" return state.lower() return 'exited' except Exception as err: @@ -86,22 +106,71 @@ class Container(BaseVM): @asyncio.coroutine def create(self): """Creates the Docker container.""" - result = yield from self.manager.execute( - "create_container", {"name": self._name, "image": self._image}) - self._id = result['Id'] + params = { + "name": self._name, + "image": self._image, + "network_disabled": True, + "host_config": create_host_config( + privileged=True, cap_add=['ALL']) + } + if self._startcmd: + params.update({'command': self._startcmd}) + + result = yield from self.manager.execute("create_container", params) + self._cid = result['Id'] log.info("Docker container '{name}' [{id}] created".format( name=self._name, id=self._id)) return True + @property + def ubridge_path(self): + """Returns the uBridge executable path. + + :returns: path to uBridge + """ + path = self._manager.config.get_section_config("Server").get( + "ubridge_path", "ubridge") + if path == "ubridge": + path = shutil.which("ubridge") + return path + + @asyncio.coroutine + def _start_ubridge(self): + """Starts uBridge (handles connections to and from this Docker VM).""" + server_config = self._manager.config.get_section_config("Server") + server_host = server_config.get("host") + self._ubridge_hypervisor = Hypervisor( + self._project, self.ubridge_path, self.working_dir, server_host) + + log.info("Starting new uBridge hypervisor {}:{}".format( + self._ubridge_hypervisor.host, self._ubridge_hypervisor.port)) + yield from self._ubridge_hypervisor.start() + log.info("Hypervisor {}:{} has successfully started".format( + self._ubridge_hypervisor.host, self._ubridge_hypervisor.port)) + yield from self._ubridge_hypervisor.connect() + if parse_version( + self._ubridge_hypervisor.version) < parse_version('0.9.1'): + raise DockerError( + "uBridge version must be >= 0.9.1, detected version is {}".format( + self._ubridge_hypervisor.version)) + @asyncio.coroutine def start(self): """Starts this Docker container.""" + state = yield from self._get_container_state() if state == "paused": - self.unpause() + yield from self.unpause() else: result = yield from self.manager.execute( - "start", {"container": self._id}) + "start", {"container": self._cid}) + + yield from self._start_ubridge() + for adapter_number in range(0, self.adapters): + nio = self._ethernet_adapters[adapter_number].get_nio(0) + if nio: + yield from self._add_ubridge_connection(nio, adapter_number) + log.info("Docker container '{name}' [{image}] started".format( name=self._name, image=self._image)) @@ -111,7 +180,7 @@ class Container(BaseVM): :returns: True or False :rtype: bool """ - state = self._get_container_state() + state = yield from self._get_container_state() if state == "running": return True return False @@ -120,15 +189,22 @@ class Container(BaseVM): def restart(self): """Restarts this Docker container.""" result = yield from self.manager.execute( - "restart", {"container": self._id}) + "restart", {"container": self._cid}) log.info("Docker container '{name}' [{image}] restarted".format( name=self._name, image=self._image)) @asyncio.coroutine def stop(self): """Stops this Docker container.""" + + if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running(): + yield from self._ubridge_hypervisor.stop() + + state = yield from self._get_container_state() + if state == "paused": + yield from self.unpause() result = yield from self.manager.execute( - "kill", {"container": self._id}) + "kill", {"container": self._cid}) log.info("Docker container '{name}' [{image}] stopped".format( name=self._name, image=self._image)) @@ -136,7 +212,7 @@ class Container(BaseVM): def pause(self): """Pauses this Docker container.""" result = yield from self.manager.execute( - "pause", {"container": self._id}) + "pause", {"container": self._cid}) log.info("Docker container '{name}' [{image}] paused".format( name=self._name, image=self._image)) @@ -144,7 +220,8 @@ class Container(BaseVM): def unpause(self): """Unpauses this Docker container.""" result = yield from self.manager.execute( - "unpause", {"container": self._id}) + "unpause", {"container": self._cid}) + state = yield from self._get_container_state() log.info("Docker container '{name}' [{image}] unpaused".format( name=self._name, image=self._image)) @@ -153,8 +230,176 @@ class Container(BaseVM): """Removes this Docker container.""" state = yield from self._get_container_state() if state == "paused": - self.unpause() + yield from self.unpause() + if state == "running": + yield from self.stop() result = yield from self.manager.execute( - "remove_container", {"container": self._id, "force": True}) + "remove_container", {"container": self._cid, "force": True}) log.info("Docker container '{name}' [{image}] removed".format( name=self._name, image=self._image)) + + @asyncio.coroutine + def close(self): + """Closes this Docker container.""" + log.debug("Docker container '{name}' [{id}] is closing".format( + name=self.name, id=self._cid)) + for adapter in self._ethernet_adapters.values(): + 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() + + log.info("Docker container '{name}' [{id}] closed".format( + name=self.name, id=self._cid)) + self._closed = True + + def _add_ubridge_connection(self, nio, adapter_number): + """ + Creates a connection in uBridge. + + :param nio: NIO instance + :param adapter_number: adapter number + """ + try: + adapter = self._ethernet_adapters[adapter_number] + except IndexError: + raise DockerError( + "Adapter {adapter_number} doesn't exist on Docker container '{name}'".format( + name=self.name, adapter_number=adapter_number)) + + if nio and isinstance(nio, NIOUDP): + ifcs = netifaces.interfaces() + for index in range(128): + ifcs = netifaces.interfaces() + if "gns3-veth{}ext".format(index) not in ifcs: + adapter.ifc = "eth{}".format(str(index)) + adapter.host_ifc = "gns3-veth{}ext".format(str(index)) + adapter.guest_ifc = "gns3-veth{}int".format(str(index)) + break + if not hasattr(adapter, "ifc"): + raise DockerError( + "Adapter {adapter_number} couldn't allocate interface on Docker container '{name}'".format( + name=self.name, adapter_number=adapter_number)) + + yield from self._ubridge_hypervisor.send( + 'docker create_veth {hostif} {guestif}'.format( + guestif=adapter.guest_ifc, hostif=adapter.host_ifc)) + self._veths.append(adapter.host_ifc) + + namespace = yield from self.get_namespace() + yield from self._ubridge_hypervisor.send( + 'docker move_to_ns {ifc} {ns}'.format( + ifc=adapter.guest_ifc, ns=namespace)) + + yield from self._ubridge_hypervisor.send( + 'bridge create bridge{}'.format(adapter_number)) + yield from self._ubridge_hypervisor.send( + 'bridge add_nio_linux_raw bridge{adapter} {ifc}'.format( + ifc=adapter.host_ifc, adapter=adapter_number)) + + if isinstance(nio, NIOUDP): + yield from self._ubridge_hypervisor.send( + 'bridge add_nio_udp bridge{adapter} {lport} {rhost} {rport}'.format( + adapter=adapter_number, lport=nio.lport, rhost=nio.rhost, + rport=nio.rport)) + + if nio.capturing: + yield from self._ubridge_hypervisor.send( + 'bridge start_capture bridge{adapter} "{pcap_file}"'.format( + adapter=adapter_number, pcap_file=nio.pcap_output_file)) + + yield from self._ubridge_hypervisor.send( + 'bridge start bridge{adapter}'.format(adapter=adapter_number)) + + def _delete_ubridge_connection(self, adapter_number): + """Deletes a connection in uBridge. + + :param adapter_number: adapter number + """ + yield from self._ubridge_hypervisor.send("bridge delete bridge{name}".format( + name=adapter_number)) + + adapter = self._ethernet_adapters[adapter_number] + yield from self._ubridge_hypervisor.send("docker delete_veth {name}".format( + name=adapter.host_ifc)) + + def adapter_add_nio_binding(self, adapter_number, nio): + """Adds an adapter NIO binding. + + :param adapter_number: adapter number + :param nio: NIO instance to add to the slot/port + """ + try: + adapter = self._ethernet_adapters[adapter_number] + except IndexError: + raise DockerError( + "Adapter {adapter_number} doesn't exist on Docker container '{name}'".format( + name=self.name, adapter_number=adapter_number)) + + adapter.add_nio(0, nio) + log.info( + "Docker container '{name}' [{id}]: {nio} added to adapter {adapter_number}".format( + name=self.name, + id=self._id, + nio=nio, + adapter_number=adapter_number)) + + def adapter_remove_nio_binding(self, adapter_number): + """ + Removes an adapter NIO binding. + + :param adapter_number: adapter number + + :returns: NIO instance + """ + try: + adapter = self._ethernet_adapters[adapter_number] + except IndexError: + raise DockerError( + "Adapter {adapter_number} doesn't exist on Docker VM '{name}'".format( + name=self.name, adapter_number=adapter_number)) + + adapter.remove_nio(0) + try: + yield from self._delete_ubridge_connection(adapter_number) + except: + pass + + log.info( + "Docker VM '{name}' [{id}]: {nio} removed from adapter {adapter_number}".format( + name=self.name, id=self.id, nio=adapter.host_ifc, + adapter_number=adapter_number)) + + @property + def adapters(self): + """Returns the number of Ethernet adapters for this Docker VM. + + :returns: number of adapters + :rtype: int + """ + return len(self._ethernet_adapters) + + @adapters.setter + def adapters(self, adapters): + """Sets the number of Ethernet adapters for this Docker container. + + :param adapters: number of adapters + """ + + self._ethernet_adapters.clear() + for adapter_number in range(0, adapters): + self._ethernet_adapters.append(EthernetAdapter()) + + log.info( + 'Docker container "{name}" [{id}]: number of Ethernet adapters changed to {adapters}'.format( + name=self._name, + id=self._id, + adapters=adapters)) + + def get_namespace(self): + result = yield from self.manager.execute( + "inspect_container", {"container": self._cid}) + return int(result['State']['Pid']) diff --git a/gns3server/modules/port_manager.py b/gns3server/modules/port_manager.py index 0b5fab97..74aff05d 100644 --- a/gns3server/modules/port_manager.py +++ b/gns3server/modules/port_manager.py @@ -227,7 +227,6 @@ class PortManager: :param project: Project instance """ - port = self.find_unused_port(self._udp_port_range[0], self._udp_port_range[1], host=self._udp_host, diff --git a/gns3server/schemas/docker.py b/gns3server/schemas/docker.py index cb10b7d8..1fb13190 100644 --- a/gns3server/schemas/docker.py +++ b/gns3server/schemas/docker.py @@ -21,11 +21,26 @@ DOCKER_CREATE_SCHEMA = { "description": "Request validation to create a new Docker container", "type": "object", "properties": { + "vm_id": { + "description": "Docker VM instance identifier", + "oneOf": [ + {"type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$"}, + {"type": "integer"} # for legacy projects + ] + }, "name": { "description": "Docker container name", "type": "string", "minLength": 1, }, + "startcmd": { + "description": "Docker CMD entry", + "type": "string", + "minLength": 1, + }, "imagename": { "description": "Docker image name", "type": "string", @@ -38,7 +53,7 @@ DOCKER_CREATE_SCHEMA = { "maximum": 64, }, "adapter_type": { - "description": "VirtualBox adapter type", + "description": "Docker adapter type", "type": "string", "minLength": 1, }, @@ -73,7 +88,7 @@ DOCKER_UPDATE_SCHEMA = { "maximum": 64, }, "adapter_type": { - "description": "VirtualBox adapter type", + "description": "Docker adapter type", "type": "string", "minLength": 1, }, @@ -106,7 +121,14 @@ DOCKER_OBJECT_SCHEMA = { "type": "string", "minLength": 1, }, - "id": { + "vm_id": { + "description": "Docker container instance UUID", + "type": "string", + "minLength": 36, + "maxLength": 36, + "pattern": "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" + }, + "cid": { "description": "Docker container ID", "type": "string", "minLength": 64, @@ -132,11 +154,11 @@ DOCKER_OBJECT_SCHEMA = { "maximum": 64, }, "adapter_type": { - "description": "VirtualBox adapter type", + "description": "Docker adapter type", "type": "string", "minLength": 1, }, }, "additionalProperties": False, - "required": ["id", "project_id"] + "required": ["vm_id", "project_id"] } diff --git a/requirements.txt b/requirements.txt index 636e980b..884281b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ aiohttp>=0.15.1 Jinja2>=2.7.3 raven>=5.2.0 gns3-netifaces==0.10.4.1 +docker-py==1.2.3 diff --git a/setup.py b/setup.py index 4ca5da87..c6e1f77b 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,8 @@ dependencies = [ "jsonschema>=2.4.0", "aiohttp>=0.15.1", "Jinja2>=2.7.3", - "raven>=5.2.0" + "raven>=5.2.0", + "docker-py>=1.2.3" ] if not sys.platform.startswith("win"):