diff --git a/docs/api/v1/dynamips_device/projectsprojectiddynamipsdevicesdeviceid.rst b/docs/api/v1/dynamips_device/projectsprojectiddynamipsdevicesdeviceid.rst index 1ff726ba..0d8e9eab 100644 --- a/docs/api/v1/dynamips_device/projectsprojectiddynamipsdevicesdeviceid.rst +++ b/docs/api/v1/dynamips_device/projectsprojectiddynamipsdevicesdeviceid.rst @@ -63,6 +63,7 @@ Ethernet switch port port ✔ integer Port number type ✔ enum Possible values: access, dot1q, qinq vlan ✔ integer VLAN number + ethertype ✔ enum Possible values: 0x8100, 0x88A8, 0x9100, 0x9200 Body @@ -103,4 +104,3 @@ Response status codes - **400**: Invalid request - **404**: Instance doesn't exist - **204**: Instance deleted - diff --git a/gns3server/handlers/__init__.py b/gns3server/handlers/__init__.py index 68297c25..d2fb9b5f 100644 --- a/gns3server/handlers/__init__.py +++ b/gns3server/handlers/__init__.py @@ -24,6 +24,7 @@ from gns3server.handlers.api.dynamips_device_handler import DynamipsDeviceHandle from gns3server.handlers.api.dynamips_vm_handler import DynamipsVMHandler from gns3server.handlers.api.qemu_handler import QEMUHandler from gns3server.handlers.api.virtualbox_handler import VirtualBoxHandler +from gns3server.handlers.api.docker_handler import DockerHandler from gns3server.handlers.api.vpcs_handler import VPCSHandler from gns3server.handlers.api.vmware_handler import VMwareHandler from gns3server.handlers.api.config_handler import ConfigHandler diff --git a/gns3server/handlers/api/docker_handler.py b/gns3server/handlers/api/docker_handler.py new file mode 100644 index 00000000..a7b1f2a5 --- /dev/null +++ b/gns3server/handlers/api/docker_handler.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from ...web.route import Route +from ...modules.docker import Docker + +from ...schemas.docker import ( + DOCKER_CREATE_SCHEMA, DOCKER_UPDATE_SCHEMA, DOCKER_CAPTURE_SCHEMA, + DOCKER_OBJECT_SCHEMA +) + + +class DockerHandler: + """API entry points for Docker.""" + + @classmethod + @Route.get( + r"/docker/images", + status_codes={ + 200: "Success", + }, + description="Get all available Docker images") + def show(request, response): + docker_manager = Docker.instance() + images = yield from docker_manager.list_images() + response.json(images) + + @classmethod + @Route.post( + r"/projects/{project_id}/docker/images", + parameters={ + "project_id": "UUID for the project" + }, + status_codes={ + 201: "Instance created", + 400: "Invalid request", + 409: "Conflict" + }, + description="Create a new Docker container", + input=DOCKER_CREATE_SCHEMA, + output=DOCKER_OBJECT_SCHEMA) + def create(request, response): + docker_manager = Docker.instance() + container = yield from docker_manager.create_vm( + request.json.pop("name"), + request.match_info["project_id"], + request.json.pop("imagename") + ) + # FIXME: DO WE NEED THIS? + for name, value in request.json.items(): + if name != "vm_id": + if hasattr(container, name) and getattr(container, name) != value: + setattr(container, name, value) + + response.set_status(201) + response.json(container) + + @classmethod + @Route.post( + r"/projects/{project_id}/docker/images/{id}/start", + parameters={ + "project_id": "UUID of the project", + "id": "ID of the container" + }, + status_codes={ + 204: "Instance started", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Start a Docker container", + input=DOCKER_CREATE_SCHEMA, + output=DOCKER_OBJECT_SCHEMA) + def start(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.start() + response.set_status(204) + + @classmethod + @Route.post( + r"/projects/{project_id}/docker/images/{id}/stop", + parameters={ + "project_id": "UUID of the project", + "id": "ID of the container" + }, + status_codes={ + 204: "Instance stopped", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Stop a Docker container", + input=DOCKER_CREATE_SCHEMA, + output=DOCKER_OBJECT_SCHEMA) + def stop(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.stop() + response.set_status(204) + + @classmethod + @Route.post( + r"/projects/{project_id}/docker/images/{id}/reload", + parameters={ + "project_id": "UUID of the project", + "id": "ID of the container" + }, + status_codes={ + 204: "Instance restarted", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Restart a Docker container", + input=DOCKER_CREATE_SCHEMA, + output=DOCKER_OBJECT_SCHEMA) + def reload(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.restart() + response.set_status(204) + + @classmethod + @Route.delete( + r"/projects/{project_id}/docker/images/{id}", + parameters={ + "id": "ID for the container", + "project_id": "UUID for the project" + }, + status_codes={ + 204: "Instance deleted", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Delete a Docker container") + def delete(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.remove() + response.set_status(204) + + @classmethod + @Route.post( + r"/projects/{project_id}/docker/images/{id}/suspend", + parameters={ + "project_id": "UUID of the project", + "id": "ID of the container" + }, + status_codes={ + 204: "Instance paused", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + description="Pause a Docker container", + input=DOCKER_CREATE_SCHEMA, + output=DOCKER_OBJECT_SCHEMA) + def suspend(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.pause() + response.set_status(204) diff --git a/gns3server/modules/docker/__init__.py b/gns3server/modules/docker/__init__.py new file mode 100644 index 00000000..49a7f4b1 --- /dev/null +++ b/gns3server/modules/docker/__init__.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Docker server module. +""" + +import os +import sys +import shutil +import asyncio +import subprocess +import logging +import docker + +log = logging.getLogger(__name__) + +from ..base_manager import BaseManager +from ..project_manager import ProjectManager +from .docker_vm import Container +from .docker_error import DockerError + + +class Docker(BaseManager): + + _VM_CLASS = Container + + def __init__(self): + 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() + + @property + def server_url(self): + """Returns the Docker server url. + + :returns: url + :rtype: string + """ + return self._server_url + + @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 + def execute(self, command, kwargs, timeout=60): + 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. + + :returns: list of dicts + :rtype: list + """ + images = [] + for image in self._client.images(): + for tag in image['RepoTags']: + images.append({'imagename': tag}) + return images + + @asyncio.coroutine + def list_containers(self): + """Gets Docker container list. + + :returns: list of dicts + :rtype: list + """ + return self._client.containers() + + def get_container(self, cid, project_id=None): + """Returns a Docker container. + + :param id: Docker container identifier + :param project_id: Project identifier + + :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: + raise aiohttp.web.HTTPNotFound( + text="Docker container with ID {} doesn't exist".format(vm_id)) + + container = self._vms[cid] + if project_id: + if container.project.id != project.id: + raise aiohttp.web.HTTPNotFound( + text="Project ID {} doesn't belong to container {}".format( + project_id, container.name)) + return container diff --git a/gns3server/modules/docker/docker_error.py b/gns3server/modules/docker/docker_error.py new file mode 100644 index 00000000..2e03ace5 --- /dev/null +++ b/gns3server/modules/docker/docker_error.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Custom exceptions for the Docker module. +""" + +from ..vm_error import VMError + + +class DockerError(VMError): + pass diff --git a/gns3server/modules/docker/docker_vm.py b/gns3server/modules/docker/docker_vm.py new file mode 100644 index 00000000..d08cff02 --- /dev/null +++ b/gns3server/modules/docker/docker_vm.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Docker container instance. +""" + +import sys +import shlex +import re +import os +import tempfile +import json +import socket +import asyncio +import docker + +from pkg_resources import parse_version +from .docker_error import DockerError +from ..base_vm import BaseVM + +import logging +log = logging.getLogger(__name__) + + +class Container(BaseVM): + """Docker container implementation. + + :param name: Docker container name + :param project: Project instance + :param manager: Manager instance + :param image: Docker image + """ + def __init__(self, name, image, project, manager): + self._name = name + self._project = project + self._manager = manager + self._image = image + + log.debug( + "{module}: {name} [{image}] initialized.".format( + module=self.manager.module_name, + name=self.name, + image=self._image)) + + def __json__(self): + return { + "name": self._name, + "id": self._id, + "project_id": self._project.id, + "image": self._image, + } + + @asyncio.coroutine + def _get_container_state(self): + """Returns the container state (e.g. running, paused etc.) + + :returns: state + :rtype: str + """ + try: + result = yield from self.manager.execute( + "inspect_container", {"container": self._id}) + for state, value in result["State"].items(): + if value is True: + return state.lower() + return 'exited' + except Exception as err: + raise DockerError("Could not get container state for {0}: ".format( + self._name), str(err)) + + @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'] + log.info("Docker container '{name}' [{id}] created".format( + name=self._name, id=self._id)) + return True + + @asyncio.coroutine + def start(self): + """Starts this Docker container.""" + state = yield from self._get_container_state() + if state == "paused": + self.unpause() + else: + result = yield from self.manager.execute( + "start", {"container": self._id}) + log.info("Docker container '{name}' [{image}] started".format( + name=self._name, image=self._image)) + + def is_running(self): + """Checks if the container is running. + + :returns: True or False + :rtype: bool + """ + state = self._get_container_state() + if state == "running": + return True + return False + + @asyncio.coroutine + def restart(self): + """Restarts this Docker container.""" + result = yield from self.manager.execute( + "restart", {"container": self._id}) + log.info("Docker container '{name}' [{image}] restarted".format( + name=self._name, image=self._image)) + + @asyncio.coroutine + def stop(self): + """Stops this Docker container.""" + result = yield from self.manager.execute( + "stop", {"container": self._id}) + log.info("Docker container '{name}' [{image}] stopped".format( + name=self._name, image=self._image)) + + @asyncio.coroutine + def pause(self): + """Pauses this Docker container.""" + result = yield from self.manager.execute( + "pause", {"container": self._id}) + log.info("Docker container '{name}' [{image}] paused".format( + name=self._name, image=self._image)) + + @asyncio.coroutine + def unpause(self): + """Unpauses this Docker container.""" + result = yield from self.manager.execute( + "unpause", {"container": self._id}) + log.info("Docker container '{name}' [{image}] unpaused".format( + name=self._name, image=self._image)) + + @asyncio.coroutine + def remove(self): + """Removes this Docker container.""" + state = yield from self._get_container_state() + if state == "paused": + self.unpause() + result = yield from self.manager.execute( + "remove_container", {"container": self._id, "force": True}) + log.info("Docker container '{name}' [{image}] removed".format( + name=self._name, image=self._image)) diff --git a/gns3server/modules/dynamips/nodes/ethernet_switch.py b/gns3server/modules/dynamips/nodes/ethernet_switch.py index 2f8f0d87..72645679 100644 --- a/gns3server/modules/dynamips/nodes/ethernet_switch.py +++ b/gns3server/modules/dynamips/nodes/ethernet_switch.py @@ -59,7 +59,8 @@ class EthernetSwitch(Device): for port_number, settings in self._mappings.items(): ports.append({"port": port_number, "type": settings[0], - "vlan": settings[1]}) + "vlan": settings[1], + "ethertype": settings[2] if len(settings) > 2 else ""}) ethernet_switch_info["ports"] = ports return ethernet_switch_info @@ -192,7 +193,7 @@ class EthernetSwitch(Device): elif settings["type"] == "dot1q": yield from self.set_dot1q_port(port_number, settings["vlan"]) elif settings["type"] == "qinq": - yield from self.set_qinq_port(port_number, settings["vlan"]) + yield from self.set_qinq_port(port_number, settings["vlan"], settings["ethertype"] ) @asyncio.coroutine def set_access_port(self, port_number, vlan_id): @@ -242,7 +243,7 @@ class EthernetSwitch(Device): self._mappings[port_number] = ("dot1q", native_vlan) @asyncio.coroutine - def set_qinq_port(self, port_number, outer_vlan): + def set_qinq_port(self, port_number, outer_vlan, ethertype): """ Sets the specified port as a trunk (QinQ) port. @@ -254,15 +255,17 @@ class EthernetSwitch(Device): raise DynamipsError("Port {} is not allocated".format(port_number)) nio = self._nios[port_number] - yield from self._hypervisor.send('ethsw set_qinq_port "{name}" {nio} {outer_vlan}'.format(name=self._name, + yield from self._hypervisor.send('ethsw set_qinq_port "{name}" {nio} {outer_vlan} {ethertype}'.format(name=self._name, nio=nio, - outer_vlan=outer_vlan)) + outer_vlan=outer_vlan, + ethertype=ethertype if ethertype != "0x8100" else "")) - log.info('Ethernet switch "{name}" [{id}]: port {port} set as a QinQ port with outer VLAN {vlan_id}'.format(name=self._name, + log.info('Ethernet switch "{name}" [{id}]: port {port} set as a QinQ ({ethertype}) port with outer VLAN {vlan_id}'.format(name=self._name, id=self._id, port=port_number, - vlan_id=outer_vlan)) - self._mappings[port_number] = ("qinq", outer_vlan) + vlan_id=outer_vlan, + ethertype=ethertype)) + self._mappings[port_number] = ("qinq", outer_vlan, ethertype) @asyncio.coroutine def get_mac_addr_table(self): diff --git a/gns3server/schemas/docker.py b/gns3server/schemas/docker.py new file mode 100644 index 00000000..15c03ab3 --- /dev/null +++ b/gns3server/schemas/docker.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2015 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +DOCKER_CREATE_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to create a new Docker container", + "type": "object", + "properties": { + "name": { + "description": "Docker container name", + "type": "string", + "minLength": 1, + }, + "imagename": { + "description": "Docker image name", + "type": "string", + "minLength": 1, + }, + "adapters": { + "description": "number of adapters", + "type": "integer", + "minimum": 0, + "maximum": 64, + }, + "adapter_type": { + "description": "VirtualBox adapter type", + "type": "string", + "minLength": 1, + }, + }, + "additionalProperties": False, +} + +DOCKER_UPDATE_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to update a Docker container", + "type": "object", + "properties": { + "name": { + "description": "Docker container name", + "type": "string", + "minLength": 1, + }, + "image": { + "description": "Docker image name", + "type": "string", + "minLength": 1, + }, + "adapters": { + "description": "number of adapters", + "type": "integer", + "minimum": 0, + "maximum": 64, + }, + "adapter_type": { + "description": "VirtualBox adapter type", + "type": "string", + "minLength": 1, + }, + }, + "additionalProperties": False, +} + +DOCKER_CAPTURE_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to start a packet capture on a Docker container port", + "type": "object", + "properties": { + "capture_file_name": { + "description": "Capture file name", + "type": "string", + "minLength": 1, + }, + }, + "additionalProperties": False, + "required": ["capture_file_name"] +} + +DOCKER_OBJECT_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Docker instance", + "type": "object", + "properties": { + "name": { + "description": "Docker container name", + "type": "string", + "minLength": 1, + }, + "id": { + "description": "Docker container ID", + "type": "string", + "minLength": 64, + "maxLength": 64, + "pattern": "^[a-zA-Z0-9_.-]{64}$" + }, + "project_id": { + "description": "Project 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}$" + }, + "image": { + "description": "Docker image name", + "type": "string", + "minLength": 1, + }, + "adapters": { + "description": "number of adapters", + "type": "integer", + "minimum": 0, + "maximum": 64, + }, + "adapter_type": { + "description": "VirtualBox adapter type", + "type": "string", + "minLength": 1, + }, + }, + "additionalProperties": False, + "required": ["id", "project_id"] +} diff --git a/gns3server/schemas/dynamips_device.py b/gns3server/schemas/dynamips_device.py index 201cd805..45d6da0c 100644 --- a/gns3server/schemas/dynamips_device.py +++ b/gns3server/schemas/dynamips_device.py @@ -63,10 +63,15 @@ DEVICE_UPDATE_SCHEMA = { "description": "Port type", "enum": ["access", "dot1q", "qinq"], }, + "vlan": {"description": "VLAN number", "type": "integer", "minimum": 1 }, + "ethertype": { + "description": "QinQ Ethertype", + "enum": ["", "0x8100", "0x88A8", "0x9100", "0x9200"], + }, }, "required": ["port", "type", "vlan"], "additionalProperties": False @@ -112,6 +117,10 @@ DEVICE_OBJECT_SCHEMA = { "type": "integer", "minimum": 1 }, + "ethertype": { + "description": "QinQ Ethertype", + "enum": ["", "0x8100", "0x88A8", "0x9100", "0x9200"], + }, }, "required": ["port", "type", "vlan"], "additionalProperties": False @@ -321,6 +330,10 @@ DEVICE_NIO_SCHEMA = { "type": "integer", "minimum": 1 }, + "ethertype": { + "description": "QinQ Ethertype", + "enum": ["", "0x8100", "0x88A8", "0x9100", "0x9200"], + }, }, "required": ["type", "vlan"], "additionalProperties": False