Initial Docker support from Google Summer of Code

This commit is contained in:
Goran Cetusic 2015-06-17 10:36:55 +02:00 committed by Julien Duponchelle
parent 9ee1d9d71a
commit 0fa300cb99
7 changed files with 374 additions and 61 deletions

View File

@ -15,6 +15,8 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from aiohttp.web import HTTPConflict
from ...web.route import Route from ...web.route import Route
from ...modules.docker import Docker from ...modules.docker import Docker
@ -22,6 +24,7 @@ from ...schemas.docker import (
DOCKER_CREATE_SCHEMA, DOCKER_UPDATE_SCHEMA, DOCKER_CAPTURE_SCHEMA, DOCKER_CREATE_SCHEMA, DOCKER_UPDATE_SCHEMA, DOCKER_CAPTURE_SCHEMA,
DOCKER_OBJECT_SCHEMA DOCKER_OBJECT_SCHEMA
) )
from ...schemas.nio import NIO_SCHEMA
class DockerHandler: class DockerHandler:
@ -58,11 +61,13 @@ class DockerHandler:
container = yield from docker_manager.create_vm( container = yield from docker_manager.create_vm(
request.json.pop("name"), request.json.pop("name"),
request.match_info["project_id"], 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? # FIXME: DO WE NEED THIS?
for name, value in request.json.items(): for name, value in request.json.items():
if name != "vm_id": if name != "_vm_id":
if hasattr(container, name) and getattr(container, name) != value: if hasattr(container, name) and getattr(container, name) != value:
setattr(container, name, value) setattr(container, name, value)
@ -181,3 +186,62 @@ class DockerHandler:
project_id=request.match_info["project_id"]) project_id=request.match_info["project_id"])
yield from container.pause() yield from container.pause()
response.set_status(204) 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)

View File

@ -19,14 +19,11 @@
Docker server module. Docker server module.
""" """
import os
import sys
import shutil
import asyncio import asyncio
import subprocess
import logging import logging
import aiohttp import aiohttp
import docker import docker
from requests.exceptions import ConnectionError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -44,7 +41,6 @@ class Docker(BaseManager):
super().__init__() super().__init__()
# FIXME: make configurable and start docker before trying # FIXME: make configurable and start docker before trying
self._server_url = 'unix://var/run/docker.sock' self._server_url = 'unix://var/run/docker.sock'
# FIXME: handle client failure
self._client = docker.Client(base_url=self._server_url) self._client = docker.Client(base_url=self._server_url)
self._execute_lock = asyncio.Lock() self._execute_lock = asyncio.Lock()
@ -60,7 +56,6 @@ class Docker(BaseManager):
@server_url.setter @server_url.setter
def server_url(self, value): def server_url(self, value):
self._server_url = value self._server_url = value
# FIXME: handle client failure
self._client = docker.Client(base_url=value) self._client = docker.Client(base_url=value)
@asyncio.coroutine @asyncio.coroutine
@ -68,29 +63,11 @@ class Docker(BaseManager):
command = getattr(self._client, command) command = getattr(self._client, command)
log.debug("Executing Docker with command: {}".format(command)) log.debug("Executing Docker with command: {}".format(command))
try: try:
# FIXME: async wait
result = command(**kwargs) result = command(**kwargs)
except Exception as error: except Exception as error:
raise DockerError("Docker has returned an error: {}".format(error)) raise DockerError("Docker has returned an error: {}".format(error))
return result 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 @asyncio.coroutine
def list_images(self): def list_images(self):
"""Gets Docker image list. """Gets Docker image list.
@ -99,10 +76,15 @@ class Docker(BaseManager):
:rtype: list :rtype: list
""" """
images = [] images = []
for image in self._client.images(): try:
for tag in image['RepoTags']: for image in self._client.images():
images.append({'imagename': tag}) for tag in image['RepoTags']:
return images 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 @asyncio.coroutine
def list_containers(self): def list_containers(self):
@ -122,7 +104,6 @@ class Docker(BaseManager):
:returns: Docker container :returns: Docker container
""" """
if project_id: if project_id:
# check if the project_id exists
project = ProjectManager.instance().get_project(project_id) project = ProjectManager.instance().get_project(project_id)
if cid not in self._vms: if cid not in self._vms:

View File

@ -19,19 +19,18 @@
Docker container instance. Docker container instance.
""" """
import sys
import shlex
import re
import os
import tempfile
import json
import socket
import asyncio import asyncio
import shutil
import docker import docker
import netifaces
from docker.utils import create_host_config
from gns3server.ubridge.hypervisor import Hypervisor
from pkg_resources import parse_version from pkg_resources import parse_version
from .docker_error import DockerError from .docker_error import DockerError
from ..base_vm import BaseVM from ..base_vm import BaseVM
from ..adapters.ethernet_adapter import EthernetAdapter
from ..nios.nio_udp import NIOUDP
import logging import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -41,15 +40,23 @@ class Container(BaseVM):
"""Docker container implementation. """Docker container implementation.
:param name: Docker container name :param name: Docker container name
:param vm_id: Docker VM identifier
:param project: Project instance :param project: Project instance
:param manager: Manager instance :param manager: Manager instance
:param image: Docker image :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._name = name
self._id = vm_id
self._project = project self._project = project
self._manager = manager self._manager = manager
self._image = image 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( log.debug(
"{module}: {name} [{image}] initialized.".format( "{module}: {name} [{image}] initialized.".format(
@ -60,11 +67,17 @@ class Container(BaseVM):
def __json__(self): def __json__(self):
return { return {
"name": self._name, "name": self._name,
"id": self._id, "vm_id": self._id,
"cid": self._cid,
"project_id": self._project.id, "project_id": self._project.id,
"image": self._image, "image": self._image,
} }
@property
def veths(self):
"""Returns Docker host veth interfaces."""
return self._veths
@asyncio.coroutine @asyncio.coroutine
def _get_container_state(self): def _get_container_state(self):
"""Returns the container state (e.g. running, paused etc.) """Returns the container state (e.g. running, paused etc.)
@ -74,9 +87,16 @@ class Container(BaseVM):
""" """
try: try:
result = yield from self.manager.execute( result = yield from self.manager.execute(
"inspect_container", {"container": self._id}) "inspect_container", {"container": self._cid})
for state, value in result["State"].items(): result_dict = {state.lower(): value for state, value in result["State"].items()}
for state, value in result_dict.items():
if value is True: 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 state.lower()
return 'exited' return 'exited'
except Exception as err: except Exception as err:
@ -86,22 +106,71 @@ class Container(BaseVM):
@asyncio.coroutine @asyncio.coroutine
def create(self): def create(self):
"""Creates the Docker container.""" """Creates the Docker container."""
result = yield from self.manager.execute( params = {
"create_container", {"name": self._name, "image": self._image}) "name": self._name,
self._id = result['Id'] "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( log.info("Docker container '{name}' [{id}] created".format(
name=self._name, id=self._id)) name=self._name, id=self._id))
return True 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 @asyncio.coroutine
def start(self): def start(self):
"""Starts this Docker container.""" """Starts this Docker container."""
state = yield from self._get_container_state() state = yield from self._get_container_state()
if state == "paused": if state == "paused":
self.unpause() yield from self.unpause()
else: else:
result = yield from self.manager.execute( 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( log.info("Docker container '{name}' [{image}] started".format(
name=self._name, image=self._image)) name=self._name, image=self._image))
@ -111,7 +180,7 @@ class Container(BaseVM):
:returns: True or False :returns: True or False
:rtype: bool :rtype: bool
""" """
state = self._get_container_state() state = yield from self._get_container_state()
if state == "running": if state == "running":
return True return True
return False return False
@ -120,15 +189,22 @@ class Container(BaseVM):
def restart(self): def restart(self):
"""Restarts this Docker container.""" """Restarts this Docker container."""
result = yield from self.manager.execute( result = yield from self.manager.execute(
"restart", {"container": self._id}) "restart", {"container": self._cid})
log.info("Docker container '{name}' [{image}] restarted".format( log.info("Docker container '{name}' [{image}] restarted".format(
name=self._name, image=self._image)) name=self._name, image=self._image))
@asyncio.coroutine @asyncio.coroutine
def stop(self): def stop(self):
"""Stops this Docker container.""" """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( result = yield from self.manager.execute(
"kill", {"container": self._id}) "kill", {"container": self._cid})
log.info("Docker container '{name}' [{image}] stopped".format( log.info("Docker container '{name}' [{image}] stopped".format(
name=self._name, image=self._image)) name=self._name, image=self._image))
@ -136,7 +212,7 @@ class Container(BaseVM):
def pause(self): def pause(self):
"""Pauses this Docker container.""" """Pauses this Docker container."""
result = yield from self.manager.execute( result = yield from self.manager.execute(
"pause", {"container": self._id}) "pause", {"container": self._cid})
log.info("Docker container '{name}' [{image}] paused".format( log.info("Docker container '{name}' [{image}] paused".format(
name=self._name, image=self._image)) name=self._name, image=self._image))
@ -144,7 +220,8 @@ class Container(BaseVM):
def unpause(self): def unpause(self):
"""Unpauses this Docker container.""" """Unpauses this Docker container."""
result = yield from self.manager.execute( 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( log.info("Docker container '{name}' [{image}] unpaused".format(
name=self._name, image=self._image)) name=self._name, image=self._image))
@ -153,8 +230,176 @@ class Container(BaseVM):
"""Removes this Docker container.""" """Removes this Docker container."""
state = yield from self._get_container_state() state = yield from self._get_container_state()
if state == "paused": if state == "paused":
self.unpause() yield from self.unpause()
if state == "running":
yield from self.stop()
result = yield from self.manager.execute( 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( log.info("Docker container '{name}' [{image}] removed".format(
name=self._name, image=self._image)) 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'])

View File

@ -227,7 +227,6 @@ class PortManager:
:param project: Project instance :param project: Project instance
""" """
port = self.find_unused_port(self._udp_port_range[0], port = self.find_unused_port(self._udp_port_range[0],
self._udp_port_range[1], self._udp_port_range[1],
host=self._udp_host, host=self._udp_host,

View File

@ -21,11 +21,26 @@ DOCKER_CREATE_SCHEMA = {
"description": "Request validation to create a new Docker container", "description": "Request validation to create a new Docker container",
"type": "object", "type": "object",
"properties": { "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": { "name": {
"description": "Docker container name", "description": "Docker container name",
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
}, },
"startcmd": {
"description": "Docker CMD entry",
"type": "string",
"minLength": 1,
},
"imagename": { "imagename": {
"description": "Docker image name", "description": "Docker image name",
"type": "string", "type": "string",
@ -38,7 +53,7 @@ DOCKER_CREATE_SCHEMA = {
"maximum": 64, "maximum": 64,
}, },
"adapter_type": { "adapter_type": {
"description": "VirtualBox adapter type", "description": "Docker adapter type",
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
}, },
@ -73,7 +88,7 @@ DOCKER_UPDATE_SCHEMA = {
"maximum": 64, "maximum": 64,
}, },
"adapter_type": { "adapter_type": {
"description": "VirtualBox adapter type", "description": "Docker adapter type",
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
}, },
@ -106,7 +121,14 @@ DOCKER_OBJECT_SCHEMA = {
"type": "string", "type": "string",
"minLength": 1, "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", "description": "Docker container ID",
"type": "string", "type": "string",
"minLength": 64, "minLength": 64,
@ -132,11 +154,11 @@ DOCKER_OBJECT_SCHEMA = {
"maximum": 64, "maximum": 64,
}, },
"adapter_type": { "adapter_type": {
"description": "VirtualBox adapter type", "description": "Docker adapter type",
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
}, },
}, },
"additionalProperties": False, "additionalProperties": False,
"required": ["id", "project_id"] "required": ["vm_id", "project_id"]
} }

View File

@ -3,3 +3,4 @@ aiohttp>=0.15.1
Jinja2>=2.7.3 Jinja2>=2.7.3
raven>=5.2.0 raven>=5.2.0
gns3-netifaces==0.10.4.1 gns3-netifaces==0.10.4.1
docker-py==1.2.3

View File

@ -42,7 +42,8 @@ dependencies = [
"jsonschema>=2.4.0", "jsonschema>=2.4.0",
"aiohttp>=0.15.1", "aiohttp>=0.15.1",
"Jinja2>=2.7.3", "Jinja2>=2.7.3",
"raven>=5.2.0" "raven>=5.2.0",
"docker-py>=1.2.3"
] ]
if not sys.platform.startswith("win"): if not sys.platform.startswith("win"):