mirror of
https://github.com/GNS3/gns3-server
synced 2024-11-28 11:18:11 +00:00
Merge pull request #2 from planctechnologies/deadman
GNS-42 - Move deadman switch into gns3server codebase
This commit is contained in:
commit
174013da80
26
gns3dms/__init__.py
Normal file
26
gns3dms/__init__.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright (C) 2013 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# __version__ is a human-readable version number.
|
||||||
|
|
||||||
|
# __version_info__ is a four-tuple for programmatic comparison. The first
|
||||||
|
# three numbers are the components of the version number. The fourth
|
||||||
|
# is zero for an official release, positive for a development branch,
|
||||||
|
# or negative for a release candidate or beta (after the base version
|
||||||
|
# number has been incremented)
|
||||||
|
|
||||||
|
from .version import __version__
|
0
gns3dms/cloud/__init__.py
Normal file
0
gns3dms/cloud/__init__.py
Normal file
179
gns3dms/cloud/base_cloud_ctrl.py
Normal file
179
gns3dms/cloud/base_cloud_ctrl.py
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Base cloud controller class.
|
||||||
|
|
||||||
|
Base class for interacting with Cloud APIs to create and manage cloud
|
||||||
|
instances.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from libcloud.compute.base import NodeAuthSSHKey
|
||||||
|
from .exceptions import ItemNotFound, KeyPairExists, MethodNotAllowed
|
||||||
|
from .exceptions import OverLimit, BadRequest, ServiceUnavailable
|
||||||
|
from .exceptions import Unauthorized, ApiError
|
||||||
|
|
||||||
|
|
||||||
|
def parse_exception(exception):
|
||||||
|
"""
|
||||||
|
Parse the exception to separate the HTTP status code from the text.
|
||||||
|
|
||||||
|
Libcloud raises many exceptions of the form:
|
||||||
|
Exception("<http status code> <http error> <reponse body>")
|
||||||
|
|
||||||
|
in lieu of raising specific incident-based exceptions.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
e_str = str(exception)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = int(e_str[0:3])
|
||||||
|
error_text = e_str[3:]
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
status = None
|
||||||
|
error_text = e_str
|
||||||
|
|
||||||
|
return status, error_text
|
||||||
|
|
||||||
|
|
||||||
|
class BaseCloudCtrl(object):
|
||||||
|
|
||||||
|
""" Base class for interacting with a cloud provider API. """
|
||||||
|
|
||||||
|
http_status_to_exception = {
|
||||||
|
400: BadRequest,
|
||||||
|
401: Unauthorized,
|
||||||
|
404: ItemNotFound,
|
||||||
|
405: MethodNotAllowed,
|
||||||
|
413: OverLimit,
|
||||||
|
500: ApiError,
|
||||||
|
503: ServiceUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, username, api_key):
|
||||||
|
self.username = username
|
||||||
|
self.api_key = api_key
|
||||||
|
|
||||||
|
def _handle_exception(self, status, error_text, response_overrides=None):
|
||||||
|
""" Raise an exception based on the HTTP status. """
|
||||||
|
|
||||||
|
if response_overrides:
|
||||||
|
if status in response_overrides:
|
||||||
|
raise response_overrides[status](error_text)
|
||||||
|
|
||||||
|
raise self.http_status_to_exception[status](error_text)
|
||||||
|
|
||||||
|
def authenticate(self):
|
||||||
|
""" Validate cloud account credentials. Return boolean. """
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def list_sizes(self):
|
||||||
|
""" Return a list of NodeSize objects. """
|
||||||
|
|
||||||
|
return self.driver.list_sizes()
|
||||||
|
|
||||||
|
def create_instance(self, name, size, image, keypair):
|
||||||
|
"""
|
||||||
|
Create a new instance with the supplied attributes.
|
||||||
|
|
||||||
|
Return a Node object.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
auth_key = NodeAuthSSHKey(keypair.public_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.driver.create_node(
|
||||||
|
name=name,
|
||||||
|
size=size,
|
||||||
|
image=image,
|
||||||
|
auth=auth_key
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
status, error_text = parse_exception(e)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
self._handle_exception(status, error_text)
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def delete_instance(self, instance):
|
||||||
|
""" Delete the specified instance. Returns True or False. """
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.driver.destroy_node(instance)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
|
||||||
|
status, error_text = parse_exception(e)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
self._handle_exception(status, error_text)
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def get_instance(self, instance):
|
||||||
|
""" Return a Node object representing the requested instance. """
|
||||||
|
|
||||||
|
for i in self.driver.list_nodes():
|
||||||
|
if i.id == instance.id:
|
||||||
|
return i
|
||||||
|
|
||||||
|
raise ItemNotFound("Instance not found")
|
||||||
|
|
||||||
|
def list_instances(self):
|
||||||
|
""" Return a list of instances in the current region. """
|
||||||
|
|
||||||
|
return self.driver.list_nodes()
|
||||||
|
|
||||||
|
def create_key_pair(self, name):
|
||||||
|
""" Create and return a new Key Pair. """
|
||||||
|
|
||||||
|
response_overrides = {
|
||||||
|
409: KeyPairExists
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return self.driver.create_key_pair(name)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
status, error_text = parse_exception(e)
|
||||||
|
if status:
|
||||||
|
self._handle_exception(status, error_text, response_overrides)
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def delete_key_pair(self, keypair):
|
||||||
|
""" Delete the keypair. Returns True or False. """
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.driver.delete_key_pair(keypair)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
status, error_text = parse_exception(e)
|
||||||
|
if status:
|
||||||
|
self._handle_exception(status, error_text)
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def list_key_pairs(self):
|
||||||
|
""" Return a list of Key Pairs. """
|
||||||
|
|
||||||
|
return self.driver.list_key_pairs()
|
45
gns3dms/cloud/exceptions.py
Normal file
45
gns3dms/cloud/exceptions.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
""" Exception classes for CloudCtrl classes. """
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
""" Raised when the server returns 500 Compute Error. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class BadRequest(Exception):
|
||||||
|
""" Raised when the server returns 400 Bad Request. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ComputeFault(Exception):
|
||||||
|
""" Raised when the server returns 400|500 Compute Fault. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Forbidden(Exception):
|
||||||
|
""" Raised when the server returns 403 Forbidden. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ItemNotFound(Exception):
|
||||||
|
""" Raised when the server returns 404 Not Found. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class KeyPairExists(Exception):
|
||||||
|
""" Raised when the server returns 409 Conflict Key pair exists. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MethodNotAllowed(Exception):
|
||||||
|
""" Raised when the server returns 405 Method Not Allowed. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class OverLimit(Exception):
|
||||||
|
""" Raised when the server returns 413 Over Limit. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ServerCapacityUnavailable(Exception):
|
||||||
|
""" Raised when the server returns 503 Server Capacity Uavailable. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ServiceUnavailable(Exception):
|
||||||
|
""" Raised when the server returns 503 Service Unavailable. """
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Unauthorized(Exception):
|
||||||
|
""" Raised when the server returns 401 Unauthorized. """
|
||||||
|
pass
|
225
gns3dms/cloud/rackspace_ctrl.py
Normal file
225
gns3dms/cloud/rackspace_ctrl.py
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
""" Interacts with Rackspace API to create and manage cloud instances. """
|
||||||
|
|
||||||
|
from .base_cloud_ctrl import BaseCloudCtrl
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
from libcloud.compute.drivers.rackspace import ENDPOINT_ARGS_MAP
|
||||||
|
from libcloud.compute.providers import get_driver
|
||||||
|
from libcloud.compute.types import Provider
|
||||||
|
|
||||||
|
from .exceptions import ItemNotFound, ApiError
|
||||||
|
from ..version import __version__
|
||||||
|
|
||||||
|
import logging
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RACKSPACE_REGIONS = [{ENDPOINT_ARGS_MAP[k]['region']: k} for k in
|
||||||
|
ENDPOINT_ARGS_MAP]
|
||||||
|
|
||||||
|
GNS3IAS_URL = 'http://localhost:8888' # TODO find a place for this value
|
||||||
|
|
||||||
|
|
||||||
|
class RackspaceCtrl(BaseCloudCtrl):
|
||||||
|
|
||||||
|
""" Controller class for interacting with Rackspace API. """
|
||||||
|
|
||||||
|
def __init__(self, username, api_key):
|
||||||
|
super(RackspaceCtrl, self).__init__(username, api_key)
|
||||||
|
|
||||||
|
# set this up so it can be swapped out with a mock for testing
|
||||||
|
self.post_fn = requests.post
|
||||||
|
self.driver_cls = get_driver(Provider.RACKSPACE)
|
||||||
|
|
||||||
|
self.driver = None
|
||||||
|
self.region = None
|
||||||
|
self.instances = {}
|
||||||
|
|
||||||
|
self.authenticated = False
|
||||||
|
self.identity_ep = \
|
||||||
|
"https://identity.api.rackspacecloud.com/v2.0/tokens"
|
||||||
|
|
||||||
|
self.regions = []
|
||||||
|
self.token = None
|
||||||
|
|
||||||
|
def authenticate(self):
|
||||||
|
"""
|
||||||
|
Submit username and api key to API service.
|
||||||
|
|
||||||
|
If authentication is successful, set self.regions and self.token.
|
||||||
|
Return boolean.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.authenticated = False
|
||||||
|
|
||||||
|
if len(self.username) < 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(self.api_key) < 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
data = json.dumps({
|
||||||
|
"auth": {
|
||||||
|
"RAX-KSKEY:apiKeyCredentials": {
|
||||||
|
"username": self.username,
|
||||||
|
"apiKey": self.api_key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Content-type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.post_fn(self.identity_ep, data=data, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
|
||||||
|
api_data = response.json()
|
||||||
|
self.token = self._parse_token(api_data)
|
||||||
|
|
||||||
|
if self.token:
|
||||||
|
self.authenticated = True
|
||||||
|
user_regions = self._parse_endpoints(api_data)
|
||||||
|
self.regions = self._make_region_list(user_regions)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.regions = []
|
||||||
|
self.token = None
|
||||||
|
|
||||||
|
response.connection.close()
|
||||||
|
|
||||||
|
return self.authenticated
|
||||||
|
|
||||||
|
def list_regions(self):
|
||||||
|
""" Return a list the regions available to the user. """
|
||||||
|
|
||||||
|
return self.regions
|
||||||
|
|
||||||
|
def _parse_endpoints(self, api_data):
|
||||||
|
"""
|
||||||
|
Parse the JSON-encoded data returned by the Identity Service API.
|
||||||
|
|
||||||
|
Return a list of regions available for Compute v2.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
region_codes = []
|
||||||
|
|
||||||
|
for ep_type in api_data['access']['serviceCatalog']:
|
||||||
|
if ep_type['name'] == "cloudServersOpenStack" \
|
||||||
|
and ep_type['type'] == "compute":
|
||||||
|
|
||||||
|
for ep in ep_type['endpoints']:
|
||||||
|
if ep['versionId'] == "2":
|
||||||
|
region_codes.append(ep['region'])
|
||||||
|
|
||||||
|
return region_codes
|
||||||
|
|
||||||
|
def _parse_token(self, api_data):
|
||||||
|
""" Parse the token from the JSON-encoded data returned by the API. """
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = api_data['access']['token']['id']
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
def _make_region_list(self, region_codes):
|
||||||
|
"""
|
||||||
|
Make a list of regions for use in the GUI.
|
||||||
|
|
||||||
|
Returns a list of key-value pairs in the form:
|
||||||
|
<API's Region Name>: <libcloud's Region Name>
|
||||||
|
eg,
|
||||||
|
[
|
||||||
|
{'DFW': 'dfw'}
|
||||||
|
{'ORD': 'ord'},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
region_list = []
|
||||||
|
|
||||||
|
for ep in ENDPOINT_ARGS_MAP:
|
||||||
|
if ENDPOINT_ARGS_MAP[ep]['region'] in region_codes:
|
||||||
|
region_list.append({ENDPOINT_ARGS_MAP[ep]['region']: ep})
|
||||||
|
|
||||||
|
return region_list
|
||||||
|
|
||||||
|
def set_region(self, region):
|
||||||
|
""" Set self.region and self.driver. Returns True or False. """
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.driver = self.driver_cls(self.username, self.api_key,
|
||||||
|
region=region)
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.region = region
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_shared_images(self, username, region, gns3_version):
|
||||||
|
"""
|
||||||
|
Given a GNS3 version, ask gns3-ias to share compatible images
|
||||||
|
|
||||||
|
Response:
|
||||||
|
[{"created_at": "", "schema": "", "status": "", "member_id": "", "image_id": "", "updated_at": ""},]
|
||||||
|
or, if access was already asked
|
||||||
|
[{"image_id": "", "member_id": "", "status": "ALREADYREQUESTED"},]
|
||||||
|
"""
|
||||||
|
endpoint = GNS3IAS_URL+"/images/grant_access"
|
||||||
|
params = {
|
||||||
|
"user_id": username,
|
||||||
|
"user_region": region,
|
||||||
|
"gns3_version": gns3_version,
|
||||||
|
}
|
||||||
|
response = requests.get(endpoint, params=params)
|
||||||
|
status = response.status_code
|
||||||
|
if status == 200:
|
||||||
|
return response.json()
|
||||||
|
elif status == 404:
|
||||||
|
raise ItemNotFound()
|
||||||
|
else:
|
||||||
|
raise ApiError("IAS status code: %d" % status)
|
||||||
|
|
||||||
|
def list_images(self):
|
||||||
|
"""
|
||||||
|
Return a dictionary containing RackSpace server images
|
||||||
|
retrieved from gns3-ias server
|
||||||
|
"""
|
||||||
|
if not (self.username and self.region):
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._get_shared_images(self.username, self.region, __version__)
|
||||||
|
shared_images = json.loads(response)
|
||||||
|
images = {}
|
||||||
|
for i in shared_images:
|
||||||
|
images[i['image_id']] = i['image_name']
|
||||||
|
return images
|
||||||
|
except ItemNotFound:
|
||||||
|
return []
|
||||||
|
except ApiError as e:
|
||||||
|
log.error('Error while retrieving image list: %s' % e)
|
390
gns3dms/main.py
Normal file
390
gns3dms/main.py
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright (C) 2013 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# __version__ is a human-readable version number.
|
||||||
|
|
||||||
|
# __version_info__ is a four-tuple for programmatic comparison. The first
|
||||||
|
# three numbers are the components of the version number. The fourth
|
||||||
|
# is zero for an official release, positive for a development branch,
|
||||||
|
# or negative for a release candidate or beta (after the base version
|
||||||
|
# number has been incremented)
|
||||||
|
|
||||||
|
"""
|
||||||
|
Monitors communication with the GNS3 client via tmp file. Will terminate the instance if
|
||||||
|
communication is lost.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import getopt
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import configparser
|
||||||
|
from logging.handlers import *
|
||||||
|
from os.path import expanduser
|
||||||
|
|
||||||
|
SCRIPT_NAME = os.path.basename(__file__)
|
||||||
|
|
||||||
|
#Is the full path when used as an import
|
||||||
|
SCRIPT_PATH = os.path.dirname(__file__)
|
||||||
|
|
||||||
|
if not SCRIPT_PATH:
|
||||||
|
SCRIPT_PATH = os.path.join(os.path.dirname(os.path.abspath(
|
||||||
|
sys.argv[0])))
|
||||||
|
|
||||||
|
|
||||||
|
EXTRA_LIB = "%s/modules" % (SCRIPT_PATH)
|
||||||
|
sys.path.append(EXTRA_LIB)
|
||||||
|
|
||||||
|
from . import cloud
|
||||||
|
from rackspace_cloud import Rackspace
|
||||||
|
|
||||||
|
LOG_NAME = "gns3dms"
|
||||||
|
log = None
|
||||||
|
|
||||||
|
sys.path.append(EXTRA_LIB)
|
||||||
|
|
||||||
|
import daemon
|
||||||
|
|
||||||
|
my_daemon = None
|
||||||
|
|
||||||
|
usage = """
|
||||||
|
USAGE: %s
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
-d, --debug Enable debugging
|
||||||
|
-v, --verbose Enable verbose logging
|
||||||
|
-h, --help Display this menu :)
|
||||||
|
|
||||||
|
--cloud_api_key <api_key> Rackspace API key
|
||||||
|
--cloud_user_name
|
||||||
|
|
||||||
|
--instance_id ID of the Rackspace instance to terminate
|
||||||
|
|
||||||
|
--deadtime How long in seconds can the communication lose exist before we
|
||||||
|
shutdown this instance.
|
||||||
|
Default:
|
||||||
|
Example --deadtime=3600 (60 minutes)
|
||||||
|
|
||||||
|
--check-interval Defaults to --deadtime, used for debugging
|
||||||
|
|
||||||
|
--init-wait Inital wait time, how long before we start pulling the file.
|
||||||
|
Default: 300 (5 min)
|
||||||
|
Example --init-wait=300
|
||||||
|
|
||||||
|
--file The file we monitor for updates
|
||||||
|
|
||||||
|
-k Kill previous instance running in background
|
||||||
|
--background Run in background
|
||||||
|
|
||||||
|
""" % (SCRIPT_NAME)
|
||||||
|
|
||||||
|
# Parse cmd line options
|
||||||
|
def parse_cmd_line(argv):
|
||||||
|
"""
|
||||||
|
Parse command line arguments
|
||||||
|
|
||||||
|
argv: Pass in cmd line arguments
|
||||||
|
"""
|
||||||
|
|
||||||
|
short_args = "dvhk"
|
||||||
|
long_args = ("debug",
|
||||||
|
"verbose",
|
||||||
|
"help",
|
||||||
|
"cloud_user_name=",
|
||||||
|
"cloud_api_key=",
|
||||||
|
"instance_id=",
|
||||||
|
"deadtime=",
|
||||||
|
"init-wait=",
|
||||||
|
"check-interval=",
|
||||||
|
"file=",
|
||||||
|
"background",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
opts, extra_opts = getopt.getopt(argv[1:], short_args, long_args)
|
||||||
|
except getopt.GetoptError as e:
|
||||||
|
print("Unrecognized command line option or missing required argument: %s" %(e))
|
||||||
|
print(usage)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
cmd_line_option_list = {}
|
||||||
|
cmd_line_option_list["debug"] = False
|
||||||
|
cmd_line_option_list["verbose"] = True
|
||||||
|
cmd_line_option_list["cloud_user_name"] = None
|
||||||
|
cmd_line_option_list["cloud_api_key"] = None
|
||||||
|
cmd_line_option_list["instance_id"] = None
|
||||||
|
cmd_line_option_list["deadtime"] = 60 * 60 #minutes
|
||||||
|
cmd_line_option_list["check-interval"] = None
|
||||||
|
cmd_line_option_list["init-wait"] = 5 * 60
|
||||||
|
cmd_line_option_list["file"] = None
|
||||||
|
cmd_line_option_list["shutdown"] = False
|
||||||
|
cmd_line_option_list["daemon"] = False
|
||||||
|
cmd_line_option_list['starttime'] = datetime.datetime.now()
|
||||||
|
|
||||||
|
if sys.platform == "linux":
|
||||||
|
cmd_line_option_list['syslog'] = "/dev/log"
|
||||||
|
elif sys.platform == "osx":
|
||||||
|
cmd_line_option_list['syslog'] = "/var/run/syslog"
|
||||||
|
else:
|
||||||
|
cmd_line_option_list['syslog'] = ('localhost',514)
|
||||||
|
|
||||||
|
|
||||||
|
get_gns3secrets(cmd_line_option_list)
|
||||||
|
|
||||||
|
for opt, val in opts:
|
||||||
|
if (opt in ("-h", "--help")):
|
||||||
|
print(usage)
|
||||||
|
sys.exit(0)
|
||||||
|
elif (opt in ("-d", "--debug")):
|
||||||
|
cmd_line_option_list["debug"] = True
|
||||||
|
elif (opt in ("-v", "--verbose")):
|
||||||
|
cmd_line_option_list["verbose"] = True
|
||||||
|
elif (opt in ("--cloud_user_name")):
|
||||||
|
cmd_line_option_list["cloud_user_name"] = val
|
||||||
|
elif (opt in ("--cloud_api_key")):
|
||||||
|
cmd_line_option_list["cloud_api_key"] = val
|
||||||
|
elif (opt in ("--instance_id")):
|
||||||
|
cmd_line_option_list["instance_id"] = val
|
||||||
|
elif (opt in ("--deadtime")):
|
||||||
|
cmd_line_option_list["deadtime"] = int(val)
|
||||||
|
elif (opt in ("--check-interval")):
|
||||||
|
cmd_line_option_list["check-interval"] = int(val)
|
||||||
|
elif (opt in ("--init-wait")):
|
||||||
|
cmd_line_option_list["init-wait"] = int(val)
|
||||||
|
elif (opt in ("--file")):
|
||||||
|
cmd_line_option_list["file"] = val
|
||||||
|
elif (opt in ("-k")):
|
||||||
|
cmd_line_option_list["shutdown"] = True
|
||||||
|
elif (opt in ("--background")):
|
||||||
|
cmd_line_option_list["daemon"] = True
|
||||||
|
|
||||||
|
if cmd_line_option_list["shutdown"] == False:
|
||||||
|
|
||||||
|
if cmd_line_option_list["check-interval"] is None:
|
||||||
|
cmd_line_option_list["check-interval"] = cmd_line_option_list["deadtime"] + 120
|
||||||
|
|
||||||
|
if cmd_line_option_list["cloud_user_name"] is None:
|
||||||
|
print("You need to specify a username!!!!")
|
||||||
|
print(usage)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if cmd_line_option_list["cloud_api_key"] is None:
|
||||||
|
print("You need to specify an apikey!!!!")
|
||||||
|
print(usage)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if cmd_line_option_list["file"] is None:
|
||||||
|
print("You need to specify a file to watch!!!!")
|
||||||
|
print(usage)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if cmd_line_option_list["instance_id"] is None:
|
||||||
|
print("You need to specify an instance_id")
|
||||||
|
print(usage)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
return cmd_line_option_list
|
||||||
|
|
||||||
|
def get_gns3secrets(cmd_line_option_list):
|
||||||
|
"""
|
||||||
|
Load cloud credentials from .gns3secrets
|
||||||
|
"""
|
||||||
|
|
||||||
|
gns3secret_paths = [
|
||||||
|
os.path.expanduser("~/"),
|
||||||
|
SCRIPT_PATH,
|
||||||
|
]
|
||||||
|
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
|
||||||
|
for gns3secret_path in gns3secret_paths:
|
||||||
|
gns3secret_file = "%s/.gns3secrets.conf" % (gns3secret_path)
|
||||||
|
if os.path.isfile(gns3secret_file):
|
||||||
|
config.read(gns3secret_file)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for key, value in config.items("Cloud"):
|
||||||
|
cmd_line_option_list[key] = value.strip()
|
||||||
|
except configparser.NoSectionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def set_logging(cmd_options):
|
||||||
|
"""
|
||||||
|
Setup logging and format output for console and syslog
|
||||||
|
|
||||||
|
Syslog is using the KERN facility
|
||||||
|
"""
|
||||||
|
log = logging.getLogger("%s" % (LOG_NAME))
|
||||||
|
log_level = logging.INFO
|
||||||
|
log_level_console = logging.WARNING
|
||||||
|
|
||||||
|
if cmd_options['verbose'] == True:
|
||||||
|
log_level_console = logging.INFO
|
||||||
|
|
||||||
|
if cmd_options['debug'] == True:
|
||||||
|
log_level_console = logging.DEBUG
|
||||||
|
log_level = logging.DEBUG
|
||||||
|
|
||||||
|
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
sys_formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s')
|
||||||
|
|
||||||
|
console_log = logging.StreamHandler()
|
||||||
|
console_log.setLevel(log_level_console)
|
||||||
|
console_log.setFormatter(formatter)
|
||||||
|
|
||||||
|
syslog_hndlr = SysLogHandler(
|
||||||
|
address=cmd_options['syslog'],
|
||||||
|
facility=SysLogHandler.LOG_KERN
|
||||||
|
)
|
||||||
|
|
||||||
|
syslog_hndlr.setFormatter(sys_formatter)
|
||||||
|
|
||||||
|
log.setLevel(log_level)
|
||||||
|
log.addHandler(console_log)
|
||||||
|
log.addHandler(syslog_hndlr)
|
||||||
|
|
||||||
|
return log
|
||||||
|
|
||||||
|
def send_shutdown(pid_file):
|
||||||
|
"""
|
||||||
|
Sends the daemon process a kill signal
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(pid_file, 'r') as pidf:
|
||||||
|
pid = int(pidf.readline().strip())
|
||||||
|
pidf.close()
|
||||||
|
os.kill(pid, 15)
|
||||||
|
except:
|
||||||
|
log.info("No running instance found!!!")
|
||||||
|
log.info("Missing PID file: %s" % (pid_file))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_file_age(filename):
|
||||||
|
return datetime.datetime.fromtimestamp(
|
||||||
|
os.path.getmtime(filename)
|
||||||
|
)
|
||||||
|
|
||||||
|
def monitor_loop(options):
|
||||||
|
"""
|
||||||
|
Checks the options["file"] modification time against an interval. If the
|
||||||
|
modification time is too old we terminate the instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
log.debug("Waiting for init-wait to pass: %s" % (options["init-wait"]))
|
||||||
|
time.sleep(options["init-wait"])
|
||||||
|
|
||||||
|
log.info("Starting monitor_loop")
|
||||||
|
|
||||||
|
terminate_attempts = 0
|
||||||
|
|
||||||
|
while options['shutdown'] == False:
|
||||||
|
log.debug("In monitor_loop for : %s" % (
|
||||||
|
datetime.datetime.now() - options['starttime'])
|
||||||
|
)
|
||||||
|
|
||||||
|
file_last_modified = _get_file_age(options["file"])
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
|
||||||
|
delta = now - file_last_modified
|
||||||
|
log.debug("File last updated: %s seconds ago" % (delta.seconds))
|
||||||
|
|
||||||
|
if delta.seconds > options["deadtime"]:
|
||||||
|
log.warning("Deadtime exceeded, terminating instance ...")
|
||||||
|
#Terminate involes many layers of HTTP / API calls, lots of
|
||||||
|
#different errors types could occur here.
|
||||||
|
try:
|
||||||
|
rksp = Rackspace(options)
|
||||||
|
rksp.terminate()
|
||||||
|
except Exception as e:
|
||||||
|
log.critical("Exception during terminate: %s" % (e))
|
||||||
|
|
||||||
|
terminate_attempts+=1
|
||||||
|
log.warning("Termination sent, attempt: %s" % (terminate_attempts))
|
||||||
|
time.sleep(600)
|
||||||
|
else:
|
||||||
|
time.sleep(options["check-interval"])
|
||||||
|
|
||||||
|
log.info("Leaving monitor_loop")
|
||||||
|
log.info("Shutting down")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
global log
|
||||||
|
global my_daemon
|
||||||
|
options = parse_cmd_line(sys.argv)
|
||||||
|
log = set_logging(options)
|
||||||
|
|
||||||
|
def _shutdown(signalnum=None, frame=None):
|
||||||
|
"""
|
||||||
|
Handles the SIGINT and SIGTERM event, inside of main so it has access to
|
||||||
|
the log vars.
|
||||||
|
"""
|
||||||
|
|
||||||
|
log.info("Received shutdown signal")
|
||||||
|
options["shutdown"] = True
|
||||||
|
|
||||||
|
pid_file = "%s/.gns3ias.pid" % (expanduser("~"))
|
||||||
|
|
||||||
|
if options["shutdown"]:
|
||||||
|
send_shutdown(pid_file)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if options["daemon"]:
|
||||||
|
my_daemon = MyDaemon(pid_file, options)
|
||||||
|
|
||||||
|
# Setup signal to catch Control-C / SIGINT and SIGTERM
|
||||||
|
signal.signal(signal.SIGINT, _shutdown)
|
||||||
|
signal.signal(signal.SIGTERM, _shutdown)
|
||||||
|
|
||||||
|
log.info("Starting ...")
|
||||||
|
log.debug("Using settings:")
|
||||||
|
for key, value in iter(sorted(options.items())):
|
||||||
|
log.debug("%s : %s" % (key, value))
|
||||||
|
|
||||||
|
|
||||||
|
log.debug("Checking file ....")
|
||||||
|
if os.path.isfile(options["file"]) == False:
|
||||||
|
log.critical("File does not exist!!!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
test_acess = _get_file_age(options["file"])
|
||||||
|
if type(test_acess) is not datetime.datetime:
|
||||||
|
log.critical("Can't get file modification time!!!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if my_daemon:
|
||||||
|
my_daemon.start()
|
||||||
|
else:
|
||||||
|
monitor_loop(options)
|
||||||
|
|
||||||
|
|
||||||
|
class MyDaemon(daemon.daemon):
|
||||||
|
def run(self):
|
||||||
|
monitor_loop(self.options)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = main()
|
||||||
|
sys.exit(result)
|
||||||
|
|
||||||
|
|
24
gns3dms/modules/__init__.py
Normal file
24
gns3dms/modules/__init__.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright (C) 2013 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# __version__ is a human-readable version number.
|
||||||
|
|
||||||
|
# __version_info__ is a four-tuple for programmatic comparison. The first
|
||||||
|
# three numbers are the components of the version number. The fourth
|
||||||
|
# is zero for an official release, positive for a development branch,
|
||||||
|
# or negative for a release candidate or beta (after the base version
|
||||||
|
# number has been incremented)
|
123
gns3dms/modules/daemon.py
Normal file
123
gns3dms/modules/daemon.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
"""Generic linux daemon base class for python 3.x."""
|
||||||
|
|
||||||
|
import sys, os, time, atexit, signal
|
||||||
|
|
||||||
|
class daemon:
|
||||||
|
"""A generic daemon class.
|
||||||
|
|
||||||
|
Usage: subclass the daemon class and override the run() method."""
|
||||||
|
|
||||||
|
def __init__(self, pidfile, options):
|
||||||
|
self.pidfile = pidfile
|
||||||
|
self.options = options
|
||||||
|
|
||||||
|
def daemonize(self):
|
||||||
|
"""Deamonize class. UNIX double fork mechanism."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid = os.fork()
|
||||||
|
if pid > 0:
|
||||||
|
# exit first parent
|
||||||
|
sys.exit(0)
|
||||||
|
except OSError as err:
|
||||||
|
sys.stderr.write('fork #1 failed: {0}\n'.format(err))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# decouple from parent environment
|
||||||
|
os.chdir('/')
|
||||||
|
os.setsid()
|
||||||
|
os.umask(0)
|
||||||
|
|
||||||
|
# do second fork
|
||||||
|
try:
|
||||||
|
pid = os.fork()
|
||||||
|
if pid > 0:
|
||||||
|
|
||||||
|
# exit from second parent
|
||||||
|
sys.exit(0)
|
||||||
|
except OSError as err:
|
||||||
|
sys.stderr.write('fork #2 failed: {0}\n'.format(err))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# redirect standard file descriptors
|
||||||
|
sys.stdout.flush()
|
||||||
|
sys.stderr.flush()
|
||||||
|
si = open(os.devnull, 'r')
|
||||||
|
so = open(os.devnull, 'a+')
|
||||||
|
se = open(os.devnull, 'a+')
|
||||||
|
|
||||||
|
os.dup2(si.fileno(), sys.stdin.fileno())
|
||||||
|
os.dup2(so.fileno(), sys.stdout.fileno())
|
||||||
|
os.dup2(se.fileno(), sys.stderr.fileno())
|
||||||
|
|
||||||
|
# write pidfile
|
||||||
|
atexit.register(self.delpid)
|
||||||
|
|
||||||
|
pid = str(os.getpid())
|
||||||
|
with open(self.pidfile,'w+') as f:
|
||||||
|
f.write(pid + '\n')
|
||||||
|
|
||||||
|
def delpid(self):
|
||||||
|
os.remove(self.pidfile)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the daemon."""
|
||||||
|
|
||||||
|
# Check for a pidfile to see if the daemon already runs
|
||||||
|
try:
|
||||||
|
with open(self.pidfile,'r') as pf:
|
||||||
|
|
||||||
|
pid = int(pf.read().strip())
|
||||||
|
except IOError:
|
||||||
|
pid = None
|
||||||
|
|
||||||
|
if pid:
|
||||||
|
message = "pidfile {0} already exist. " + \
|
||||||
|
"Daemon already running?\n"
|
||||||
|
sys.stderr.write(message.format(self.pidfile))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Start the daemon
|
||||||
|
self.daemonize()
|
||||||
|
self.run()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the daemon."""
|
||||||
|
|
||||||
|
# Get the pid from the pidfile
|
||||||
|
try:
|
||||||
|
with open(self.pidfile,'r') as pf:
|
||||||
|
pid = int(pf.read().strip())
|
||||||
|
except IOError:
|
||||||
|
pid = None
|
||||||
|
|
||||||
|
if not pid:
|
||||||
|
message = "pidfile {0} does not exist. " + \
|
||||||
|
"Daemon not running?\n"
|
||||||
|
sys.stderr.write(message.format(self.pidfile))
|
||||||
|
return # not an error in a restart
|
||||||
|
|
||||||
|
# Try killing the daemon process
|
||||||
|
try:
|
||||||
|
while 1:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
time.sleep(0.1)
|
||||||
|
except OSError as err:
|
||||||
|
e = str(err.args)
|
||||||
|
if e.find("No such process") > 0:
|
||||||
|
if os.path.exists(self.pidfile):
|
||||||
|
os.remove(self.pidfile)
|
||||||
|
else:
|
||||||
|
print (str(err.args))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def restart(self):
|
||||||
|
"""Restart the daemon."""
|
||||||
|
self.stop()
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""You should override this method when you subclass Daemon.
|
||||||
|
|
||||||
|
It will be called after the process has been daemonized by
|
||||||
|
start() or restart()."""
|
68
gns3dms/modules/rackspace_cloud.py
Normal file
68
gns3dms/modules/rackspace_cloud.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright (C) 2013 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# __version__ is a human-readable version number.
|
||||||
|
|
||||||
|
# __version_info__ is a four-tuple for programmatic comparison. The first
|
||||||
|
# three numbers are the components of the version number. The fourth
|
||||||
|
# is zero for an official release, positive for a development branch,
|
||||||
|
# or negative for a release candidate or beta (after the base version
|
||||||
|
# number has been incremented)
|
||||||
|
|
||||||
|
import os, sys
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from gns3dms.cloud.rackspace_ctrl import RackspaceCtrl
|
||||||
|
|
||||||
|
|
||||||
|
LOG_NAME = "gns3dms.rksp"
|
||||||
|
log = logging.getLogger("%s" % (LOG_NAME))
|
||||||
|
|
||||||
|
class Rackspace(object):
|
||||||
|
def __init__(self, options):
|
||||||
|
self.username = options["cloud_user_name"]
|
||||||
|
self.apikey = options["cloud_api_key"]
|
||||||
|
self.authenticated = False
|
||||||
|
self.hostname = socket.gethostname()
|
||||||
|
self.instance_id = options["instance_id"]
|
||||||
|
|
||||||
|
log.debug("Authenticating with Rackspace")
|
||||||
|
log.debug("My hostname: %s" % (self.hostname))
|
||||||
|
self.rksp = RackspaceCtrl(self.username, self.apikey)
|
||||||
|
self.authenticated = self.rksp.authenticate()
|
||||||
|
|
||||||
|
def _find_my_instance(self):
|
||||||
|
if self.authenticated == False:
|
||||||
|
log.critical("Not authenticated against rackspace!!!!")
|
||||||
|
|
||||||
|
for region_dict in self.rksp.list_regions():
|
||||||
|
region_k, region_v = region_dict.popitem()
|
||||||
|
log.debug("Checking region: %s" % (region_k))
|
||||||
|
self.rksp.set_region(region_v)
|
||||||
|
for server in self.rksp.list_instances():
|
||||||
|
log.debug("Checking server: %s" % (server.name))
|
||||||
|
if server.name.lower() == self.hostname.lower() and server.id == self.instance_id:
|
||||||
|
log.info("Found matching instance: %s" % (server.id))
|
||||||
|
log.info("Startup id: %s" % (self.instance_id))
|
||||||
|
return server
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
server = self._find_my_instance()
|
||||||
|
log.warning("Sending termination")
|
||||||
|
self.rksp.delete_instance(server)
|
27
gns3dms/version.py
Normal file
27
gns3dms/version.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
#
|
||||||
|
# Copyright (C) 2013 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# __version__ is a human-readable version number.
|
||||||
|
|
||||||
|
# __version_info__ is a four-tuple for programmatic comparison. The first
|
||||||
|
# three numbers are the components of the version number. The fourth
|
||||||
|
# is zero for an official release, positive for a development branch,
|
||||||
|
# or negative for a release candidate or beta (after the base version
|
||||||
|
# number has been incremented)
|
||||||
|
|
||||||
|
__version__ = "0.1"
|
||||||
|
__version_info__ = (0, 0, 1, -99)
|
@ -2,3 +2,7 @@ netifaces
|
|||||||
tornado==3.2.2
|
tornado==3.2.2
|
||||||
pyzmq
|
pyzmq
|
||||||
jsonschema
|
jsonschema
|
||||||
|
pycurl
|
||||||
|
python-dateutil
|
||||||
|
apache-libcloud
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user