diff --git a/.travis.yml b/.travis.yml index 9ff7115c1..ff4a0f909 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: python python: - - "2.5" - "2.6" - "2.7" - "3.2" @@ -9,7 +8,7 @@ python: # command to install dependencies install: - "pip install -r requirements.txt --use-mirrors" - - "if [[ $TRAVIS_PYTHON_VERSION = 2.6 || $TRAVIS_PYTHON_VERSION = 2.5 ]]; then pip install unittest2 --use-mirrors; fi" + - "if [[ $TRAVIS_PYTHON_VERSION = 2.6 ]]; then pip install unittest2 --use-mirrors; fi" # command to run tests script: python setup.py nosetests diff --git a/CHANGELOG b/CHANGELOG index 4a4701907..a3c15562b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,26 @@ +2.3.0 + + * Several bug fixes and improvements + + * Removed Python 2.5 support. Some stuff MIGHT work with 2.5 but it is no longer tested + + * API: Refactored managers into their own module to not clutter the top level + + * CLI+API: Added much more hardware support: Filters for hardware listing, dedicated server/bare metal cloud ordering, hardware cancellation + + * CLI+API: Added DNS Zone filtering (server side) + + * CLI+API: Added Post Install script support for CCIs and hardware + + * CLI: Added Message queue functionality + + * CLI: Added --debug option to CLI commands + + * API: Added more logging + + * API: Added token-based auth so you can use the API bindings with your username/password if you want. (It's still highly recommended to use your API key instead of your password) + + 2.2.0 * Consistency changes/bug fixes @@ -8,8 +31,8 @@ * CCI: Adds a way to block until transactions are done on a CCI - * CLI(CCI): For most commands, you can specify id, hostname, private ip or public ip as + * CLI: For most CCI commands, you can specify id, hostname, private ip or public ip as - * CLI(CCI): Adds the ability to filter list results for CCIs + * CLI: Adds the ability to filter list results for CCIs - * API: for large result sets, requests can now be chunked into smaller batches on the server side. Using service.iter_call('getObjects', ...) or service.getObjects(..., iter=True) will return a generator regardless of the results returned. offset and limit can be passed in like normal. An additional named parameter of 'chunk' is used to limit the number of items coming back in a single request, defaults to 100. + * API: for large result sets, requests can now be chunked into smaller batches on the server side. Using service.iter_call('getObjects', ...) or service.getObjects(..., iter=True) will return a generator regardless of the results returned. offset and limit can be passed in like normal. An additional named parameter of 'chunk' is used to limit the number of items coming back in a single request, defaults to 100 diff --git a/README.md b/README.md index 496da2123..6e53d3c5c 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ library. System Requirements ------------------- -* This library has been tested on Python 2.5, 2.6, 2.7, 3.2 and 3.3. +* This library has been tested on Python 2.6, 2.7, 3.2 and 3.3. * A valid SoftLayer API username and key are required to call SoftLayer's API * A connection to SoftLayer's private network is required to connect to SoftLayer’s private network API endpoints. diff --git a/SoftLayer/API.py b/SoftLayer/API.py index bfe27ffd4..dbcae121b 100644 --- a/SoftLayer/API.py +++ b/SoftLayer/API.py @@ -8,16 +8,70 @@ """ from SoftLayer.consts import API_PUBLIC_ENDPOINT, API_PRIVATE_ENDPOINT, \ USER_AGENT -from SoftLayer.transport import make_api_call +from SoftLayer.transport import make_xml_rpc_api_call from SoftLayer.exceptions import SoftLayerError +from SoftLayer.deprecated import DeprecatedClientMixin import os + +__all__ = ['Client', 'BasicAuthentication', 'TokenAuthentication', + 'API_PUBLIC_ENDPOINT', 'API_PRIVATE_ENDPOINT'] + API_USERNAME = None API_KEY = None API_BASE_URL = API_PUBLIC_ENDPOINT +VALID_CALL_ARGS = set([ + 'id', + 'mask', + 'filter', + 'headers', + 'raw_headers', + 'limit', + 'offset', +]) + + +class AuthenticationBase(object): + def get_headers(self): + raise NotImplementedError + + +class TokenAuthentication(AuthenticationBase): + def __init__(self, user_id, auth_token): + self.user_id = user_id + self.auth_token = auth_token + + def get_headers(self): + return { + 'authenticate': { + 'complexType': 'PortalLoginToken', + 'userId': self.user_id, + 'authToken': self.auth_token, + } + } + def __repr__(self): + return "" % (self.user_id, self.auth_token) -class Client(object): + +class BasicAuthentication(AuthenticationBase): + def __init__(self, username, api_key): + self.username = username + self.api_key = api_key + + def get_headers(self): + return { + 'authenticate': { + 'username': self.username, + 'apiKey': self.api_key, + } + } + + def __repr__(self): + return "" % (self.username) + + +class Client(DeprecatedClientMixin, object): """ A SoftLayer API client. :param service_name: the name of the SoftLayer API service to query @@ -32,7 +86,8 @@ class Client(object): Set this to API_PRIVATE_ENDPOINT to connect via SoftLayer's private network. :param integer timeout: timeout for API requests - :param boolean verbose: prints details about every HTTP request if true + :param auth: an object which responds to get_headers() to be inserted into + the xml-rpc headers. Example: `BasicAuthentication` Usage: @@ -46,142 +101,48 @@ class Client(object): _prefix = "SoftLayer_" def __init__(self, service_name=None, id=None, username=None, api_key=None, - endpoint_url=None, timeout=None, verbose=False): + endpoint_url=None, timeout=None, auth=None): self._service_name = service_name - self.verbose = verbose self._headers = {} self._raw_headers = {} - self.username = username or API_USERNAME or \ - os.environ.get('SL_USERNAME') or '' - self.api_key = api_key or API_KEY or os.environ.get('SL_API_KEY') or '' - self.set_authentication(self.username, self.api_key) - - if id is not None: - self.set_init_parameter(int(id)) + self.auth = auth + if self.auth is None: + username = username or API_USERNAME or \ + os.environ.get('SL_USERNAME') or '' + api_key = api_key or API_KEY or os.environ.get('SL_API_KEY') or '' + if username and api_key: + self.auth = BasicAuthentication(username, api_key) self._endpoint_url = (endpoint_url or API_BASE_URL or API_PUBLIC_ENDPOINT).rstrip('/') self.timeout = timeout - def add_raw_header(self, name, value): - """ Set HTTP headers for API calls. - - :param name: the header name - :param value: the header value - - .. deprecated:: 2.0.0 - - """ - self._raw_headers[name] = value - - def add_header(self, name, value): - """ Set a SoftLayer API call header. - - :param name: the header name - :param value: the header value - - .. deprecated:: 2.0.0 - - """ - name = name.strip() - if name is None or name == '': - raise SoftLayerError('Please specify a header name.') - - self._headers[name] = value - - def remove_header(self, name): - """ Remove a SoftLayer API call header. - - :param name: the header name - - .. deprecated:: 2.0.0 - - """ - if name in self._headers: - del self._headers[name.strip()] - - def set_authentication(self, username, api_key): - """ Set user and key to authenticate a SoftLayer API call. - - Use this method if you wish to bypass the API_USER and API_KEY class - constants and set custom authentication per API call. - - See https://manage.softlayer.com/Administrative/apiKeychain for more - information. - - :param username: the username to authenticate with - :param api_key: the user's API key + super(Client, self).__init__( + service_name=service_name, id=id, username=username, + api_key=api_key, endpoint_url=endpoint_url, timeout=timeout, + auth=auth) - .. deprecated:: 2.0.0 + def authenticate_with_password(self, username, password, + security_question_id=None, + security_question_answer=None): + """ Performs Username/Password Authentication and gives back an auth + handler to use to create a client that uses token-based auth. - """ - self.add_header('authenticate', { - 'username': username.strip(), - 'apiKey': api_key.strip(), - }) - - def set_init_parameter(self, id): - """ Set an initialization parameter header. - - Initialization parameters instantiate a SoftLayer API service object to - act upon during your API method call. For instance, if your account has - a server with ID number 1234, then setting an initialization parameter - of 1234 in the SoftLayer_Hardware_Server Service instructs the API to - act on server record 1234 in your method calls. - - See http://sldn.softlayer.com/article/Using-Initialization-Parameters-SoftLayer-API # NOQA - for more information. - - :param id: the ID of the SoftLayer API object to instantiate - - .. deprecated:: 2.0.0 + :param string username: your SoftLayer username + :param string password: your SoftLayer password + :param int security_question_id: The security question id to answer + :param string security_question_answer: The answer to the security + question """ - self.add_header(self._service_name + 'InitParameters', { - 'id': int(id) - }) - - def set_object_mask(self, mask): - """ Set an object mask to a SoftLayer API call. - - Use an object mask to retrieve data related your API call's result. - Object masks are skeleton objects, or strings that define nested - relational properties to retrieve along with an object's local - properties. See - http://sldn.softlayer.com/article/Using-Object-Masks-SoftLayer-API - for more information. - - :param mask: the object mask you wish to define - - .. deprecated:: 2.0.0 - - """ - header = 'SoftLayer_ObjectMask' - - if isinstance(mask, dict): - header = '%sObjectMask' % self._service_name - - self.add_header(header, {'mask': mask}) - - def set_result_limit(self, limit, offset=0): - """ Set a result limit on a SoftLayer API call. - - Many SoftLayer API methods return a group of results. These methods - support a way to limit the number of results retrieved from the - SoftLayer API in a way akin to an SQL LIMIT statement. - - :param limit: the number of results to limit a SoftLayer API call to - :param offset: An optional offset at which to begin a SoftLayer API - call's returned result - - .. deprecated:: 2.0.0 - - """ - self.add_header('resultLimit', { - 'limit': int(limit), - 'offset': int(offset) - }) + res = self['User_Customer'].getPortalLoginToken( + username, + password, + security_question_id, + security_question_answer) + self.auth = TokenAuthentication(res['userId'], res['hash']) + return (res['userId'], res['hash']) def __getitem__(self, name): """ Get a SoftLayer Service. @@ -189,6 +150,7 @@ def __getitem__(self, name): :param name: The name of the service. E.G. Account Usage: + >>> client = SoftLayer.Client() >>> client['Account'] @@ -207,30 +169,32 @@ def call(self, service, method, *args, **kwargs): :param service: the name of the SoftLayer API service Usage: + >>> client = SoftLayer.Client() >>> client['Account'].getVirtualGuests(mask="id", limit=10) [...] """ - if kwargs.get('iter'): + if kwargs.pop('iter', False): return self.iter_call(service, method, *args, **kwargs) + invalid_kwargs = set(kwargs.keys()) - VALID_CALL_ARGS + if invalid_kwargs: + raise TypeError( + 'Invalid keyword arguments: %s' % ','.join(invalid_kwargs)) + if not service.startswith(self._prefix): service = self._prefix + service objectid = kwargs.get('id') objectmask = kwargs.get('mask') objectfilter = kwargs.get('filter') - headers = kwargs.get('headers') + headers = kwargs.get('headers', {}) raw_headers = kwargs.get('raw_headers') limit = kwargs.get('limit') offset = kwargs.get('offset', 0) - if headers is None: - headers = { - 'authenticate': { - 'username': self.username, - 'apiKey': self.api_key, - }} + if not headers and self.auth: + headers = self.auth.get_headers() http_headers = { 'User-Agent': USER_AGENT, @@ -258,9 +222,10 @@ def call(self, service, method, *args, **kwargs): 'offset': int(offset) } uri = '/'.join([self._endpoint_url, service]) - return make_api_call(uri, method, args, headers=headers, - http_headers=http_headers, timeout=self.timeout, - verbose=self.verbose) + return make_xml_rpc_api_call(uri, method, args, + headers=headers, + http_headers=http_headers, + timeout=self.timeout) __call__ = call @@ -340,34 +305,9 @@ def __format_object_mask(self, objectmask, service): return {mheader: {'mask': objectmask}} - def __getattr__(self, name): - """ Attempt a SoftLayer API call. - - Use this as a catch-all so users can call SoftLayer API methods - directly against their client object. If the property or method - relating to their client object doesn't exist then assume the user is - attempting a SoftLayer API call and return a simple function that makes - an XML-RPC call. - - :param name: method name - - .. deprecated:: 2.0.0 - - """ - if name in ["__name__", "__bases__"]: - raise AttributeError("'Obj' object has no attribute '%s'" % name) - - def call_handler(*args, **kwargs): - if self._service_name is None: - raise SoftLayerError( - "Service is not set on Client instance.") - kwargs['headers'] = self._headers - return self.call(self._service_name, name, *args, **kwargs) - return call_handler - def __repr__(self): - return "" \ - % (self._endpoint_url, self.username) + return "" \ + % (self._endpoint_url, self.auth) __str__ = __repr__ diff --git a/SoftLayer/CLI/__init__.py b/SoftLayer/CLI/__init__.py index 7d84715c3..7a28c736e 100644 --- a/SoftLayer/CLI/__init__.py +++ b/SoftLayer/CLI/__init__.py @@ -8,5 +8,5 @@ """ -import SoftLayer.CLI.core -from SoftLayer.CLI.helpers import * +import SoftLayer.CLI.core # NOQA +from SoftLayer.CLI.helpers import * # NOQA diff --git a/SoftLayer/CLI/core.py b/SoftLayer/CLI/core.py index 114544cf1..e795f6975 100644 --- a/SoftLayer/CLI/core.py +++ b/SoftLayer/CLI/core.py @@ -1,22 +1,26 @@ """ -usage: sl [...] - sl help +usage: sl [...] + sl help + sl help sl [-h | --help] SoftLayer Command-line Client -The available commands are: - firewall Firewall rule and security management - image Manages compute and flex images - ssl Manages SSL +The available modules are: cci Manage, delete, order compute instances - dns Manage DNS config View and edit configuration for this tool + dns Manage DNS + firewall Firewall rule and security management + hardware View hardware details + bmetal Interact with bare metal instances + help Show help + iscsi View iSCSI details + image Manages compute and flex images metadata Get details about this machine. Also available with 'my' and 'meta' nas View NAS details - iscsi View iSCSI details + ssl Manages SSL -See 'sl help ' for more information on a specific command. +See 'sl help ' for more information on a specific module. To use most commands your SoftLayer username and api_key need to be configured. The easiest way to do that is to use: 'sl config setup' @@ -27,15 +31,25 @@ import sys import os import os.path +import logging from prettytable import FRAME, NONE -from docopt import docopt +from docopt import docopt, DocoptExit from SoftLayer import Client, SoftLayerError from SoftLayer.consts import VERSION from SoftLayer.CLI.helpers import ( Table, CLIAbort, FormattedItem, listing, ArgumentError, SequentialOutput) -from SoftLayer.CLI.environment import Environment, CLIRunnableType +from SoftLayer.CLI.environment import ( + Environment, CLIRunnableType, InvalidCommand, InvalidModule) + + +DEBUG_LOGGING_MAP = { + '0': logging.CRITICAL, + '1': logging.WARNING, + '2': logging.INFO, + '3': logging.DEBUG +} def format_output(data, fmt='table'): @@ -88,97 +102,111 @@ def format_no_tty(table): return t -def parse_main_args(args=sys.argv[1:]): - arguments = docopt(__doc__, version=VERSION, argv=args, options_first=True) - return arguments - +class CommandParser(object): + def __init__(self, env): + self.env = env -def parse_module_args(module, args): + def get_main_help(self): + return __doc__.strip() - arg_doc = module.__doc__ + """ -Standard Options: - -h --help Show this screen -""" - arguments = docopt( - arg_doc, version=VERSION, argv=args, options_first=True) - return arguments + def get_module_help(self, module_name): + module = self.env.load_module(module_name) + arg_doc = module.__doc__ + return arg_doc.strip() + def get_command_help(self, module_name, command_name): + command = self.env.get_command(module_name, command_name) -def parse_submodule_args(submodule, args): - default_format = 'raw' - if sys.stdout.isatty(): - default_format = 'table' + default_format = 'raw' + if sys.stdout.isatty(): + default_format = 'table' - arg_doc = submodule.__doc__ + arg_doc = command.__doc__ - if 'confirm' in submodule.options: - arg_doc += """ + if 'confirm' in command.options: + arg_doc += """ Prompt Options: -y, --really Confirm all prompt actions """ - arg_doc += """ + if '[options]' in arg_doc: + arg_doc += """ Standard Options: --format=ARG Output format. [Options: table, raw] [Default: %s] -C FILE --config=FILE Config file location. [Default: ~/.softlayer] + --debug=LEVEL Specifies the debug noise level + 1=warn, 2=info, 3=debug -h --help Show this screen """ % default_format + return arg_doc.strip() + + def parse_main_args(self, args): + main_help = self.get_main_help() + arguments = docopt( + main_help, + version=VERSION, + argv=args, + options_first=True) + arguments[''] = self.env.get_module_name(arguments['']) + return arguments + + def parse_module_args(self, module_name, args): + arg_doc = self.get_module_help(module_name) + arguments = docopt( + arg_doc, + version=VERSION, + argv=[module_name] + args, + options_first=True) + module = self.env.load_module(module_name) + return module, arguments + + def parse_command_args(self, module_name, command_name, args): + command = self.env.get_command(module_name, command_name) + arg_doc = self.get_command_help(module_name, command_name) + arguments = docopt(arg_doc, version=VERSION, argv=[module_name] + args) + return command, arguments + + def parse(self, args): + # handle `sl ...` + main_args = self.parse_main_args(args) + module_name = main_args[''] + + # handle `sl ...` + module, module_args = self.parse_module_args( + module_name, main_args['']) + command_name = module_args[''] - arguments = docopt(arg_doc, version=VERSION, argv=args) - return arguments + # handle `sl ...` + return self.parse_command_args( + module_name, + command_name, + main_args['']) def main(args=sys.argv[1:], env=Environment()): """ - Handle conditions in this order: - - sl [help] [(-h | --help)] -> show main help - sl help -> show command-specific help - sl [(-h | --help)] -> show command-specific help - sl invalid_command -> show main help - sl (-h | --help) -> show subcommand-specific help - sl [options] -> execute subcommand + Entry point for the command-line client. """ # Parse Top-Level Arguments CLIRunnableType.env = env exit_status = 0 + resolver = CommandParser(env) try: - # handle `sl ...` - main_args = parse_main_args(args) - module_name = env.get_module_name(main_args['']) - - # handle `sl help ` - if module_name == 'help' and len(main_args['']) > 0: - module = env.load_module(main_args[''][0]) - parse_module_args(module, ['--help', main_args[''][0]]) - - # handle `sl --help` and `sl invalidcommand` - if module_name not in env.plugin_list(): - parse_main_args(['--help']) + command, command_args = resolver.parse(args) - module = env.load_module(module_name) - actions = env.plugins.get(module_name) or [] - - # handle `sl ...` - module_args = parse_module_args( - module, [module_name] + main_args['']) - action_name = module_args[''] - - # handle `sl invalidcommand` - if action_name not in actions: - parse_module_args(module, ['--help', module_name, action_name]) - - action = actions[action_name] - - # handle `sl ...` - submodule_args = parse_submodule_args( - action, [module_name] + main_args['']) + # Set logging level + debug_level = command_args.get('--debug') + if debug_level: + logger = logging.getLogger() + h = logging.StreamHandler() + logger.addHandler(h) + logger.setLevel(DEBUG_LOGGING_MAP.get(debug_level, logging.DEBUG)) # Parse Config config_files = ["~/.softlayer"] - if submodule_args.get('--config'): - config_files.append(submodule_args.get('--config')) + if command_args.get('--config'): + config_files.append(command_args.get('--config')) env.load_config(config_files) client = Client( @@ -187,17 +215,34 @@ def main(args=sys.argv[1:], env=Environment()): endpoint_url=env.config.get('endpoint_url')) # Do the thing - data = action.execute(client, submodule_args) + data = command.execute(client, command_args) if data: - format = submodule_args.get('--format', 'table') + format = command_args.get('--format', 'table') if format not in ['raw', 'table']: - raise ArgumentError('Invalid Format "%s"' % format) + raise ArgumentError('Invalid format "%s"' % format) s = format_output(data, fmt=format) if s: env.out(s) + except InvalidCommand, e: + env.err(resolver.get_module_help(e.module_name)) + if e.command_name: + env.err('') + env.err(str(e)) + exit_status = 1 + except InvalidModule, e: + env.err(resolver.get_main_help()) + if e.module_name: + env.err('') + env.err(str(e)) + exit_status = 1 except (ValueError, KeyError): raise + except DocoptExit, e: + env.err(e.usage) + env.err( + '\nUnknown argument(s), use -h or --help for available options') + exit_status = 127 except KeyboardInterrupt: env.out('') exit_status = 1 @@ -206,8 +251,12 @@ def main(args=sys.argv[1:], env=Environment()): exit_status = e.code except SystemExit, e: exit_status = e.code - except (SoftLayerError, Exception), e: + except SoftLayerError, e: env.err(str(e)) exit_status = 1 + except Exception, e: + import traceback + env.err(traceback.format_exc()) + exit_status = 1 sys.exit(exit_status) diff --git a/SoftLayer/CLI/environment.py b/SoftLayer/CLI/environment.py index 8d3bcecca..6e6f17d8b 100644 --- a/SoftLayer/CLI/environment.py +++ b/SoftLayer/CLI/environment.py @@ -13,7 +13,24 @@ import os.path from SoftLayer.CLI.modules import get_module_list -from SoftLayer import API_PUBLIC_ENDPOINT +from SoftLayer import API_PUBLIC_ENDPOINT, SoftLayerError + + +class InvalidCommand(SoftLayerError): + " Raised when trying to use a command that does not exist " + def __init__(self, module_name, command_name, *args): + self.module_name = module_name + self.command_name = command_name + error = 'Invalid command: "%s".' % self.command_name + SoftLayerError.__init__(self, error, *args) + + +class InvalidModule(SoftLayerError): + " Raised when trying to use a module that does not exist " + def __init__(self, module_name, *args): + self.module_name = module_name + error = 'Invalid module: "%s".' % self.module_name + SoftLayerError.__init__(self, error, *args) class Environment(object): @@ -27,13 +44,24 @@ class Environment(object): stdout = sys.stdout stderr = sys.stderr + def get_command(self, module_name, command_name): + actions = self.plugins.get(module_name) or {} + if command_name in actions: + return actions[command_name] + if None in actions: + return actions[None] + raise InvalidCommand(module_name, command_name) + def get_module_name(self, module_name): if module_name in self.aliases: return self.aliases[module_name] return module_name - def load_module(self, mod): # pragma: no cover - return import_module('SoftLayer.CLI.modules.%s' % mod) + def load_module(self, module_name): # pragma: no cover + try: + return import_module('SoftLayer.CLI.modules.%s' % module_name) + except ImportError: + raise InvalidModule(module_name) def add_plugin(self, cls): command = cls.__module__.split('.')[-1] @@ -76,6 +104,9 @@ def load_config(self, files): self.config = config + def exit(self, code=0): + sys.exit(code) + class CLIRunnableType(type): diff --git a/SoftLayer/CLI/helpers.py b/SoftLayer/CLI/helpers.py index df803a0f3..b2083b940 100644 --- a/SoftLayer/CLI/helpers.py +++ b/SoftLayer/CLI/helpers.py @@ -11,8 +11,8 @@ from prettytable import PrettyTable __all__ = ['Table', 'CLIRunnable', 'FormattedItem', 'valid_response', - 'confirm', 'no_going_back', 'mb_to_gb', 'listing', 'CLIAbort', - 'NestedDict'] + 'confirm', 'no_going_back', 'mb_to_gb', 'gb', 'listing', 'CLIAbort', + 'NestedDict', 'resolve_id'] class FormattedItem(object): @@ -29,10 +29,38 @@ def __str__(self): __repr__ = __str__ +def resolve_id(resolver, identifier, name='object'): + """ Resolves a single id using an id resolver function which returns a list + of ids. + + :param resolver: function that resolves ids. Should return None or a list + of ids. + :param string identifier: a string identifier used to resolve ids + :param string name: the object type, to be used in error messages + + """ + ids = resolver(identifier) + + if len(ids) == 0: + raise CLIAbort("Error: Unable to find %s '%s'" % (name, identifier)) + + if len(ids) > 1: + raise CLIAbort( + "Error: Multiple %s found for '%s': %s" % + (name, identifier, ', '.join([str(_id) for _id in ids]))) + + return ids[0] + + def mb_to_gb(megabytes): return FormattedItem(megabytes, "%dG" % (float(megabytes) / 1024)) +def gb(gigabytes): + return FormattedItem(int(float(gigabytes)) * 1024, + "%dG" % int(float(gigabytes))) + + def blank(): """ Returns FormatedItem to make pretty output use a dash and raw formatting to use NULL""" diff --git a/SoftLayer/CLI/modules/bmetal.py b/SoftLayer/CLI/modules/bmetal.py new file mode 100644 index 000000000..89b69569b --- /dev/null +++ b/SoftLayer/CLI/modules/bmetal.py @@ -0,0 +1,483 @@ +""" +usage: sl bmetal [] [...] [options] + sl bmetal [-h | --help] + +Manage bare metal instances + +The available commands are: + create-options Output available available options when creating a server + create Create a new bare metal instance + cancel Cancels a bare metal instance + +For several commands, will be asked for. This can be the id, +hostname or the ip address for a piece of hardware. +""" +import re +from os import linesep +from SoftLayer.CLI import ( + CLIRunnable, Table, no_going_back, confirm, listing, FormattedItem) +from SoftLayer.CLI.helpers import (CLIAbort, SequentialOutput) +from SoftLayer import HardwareManager + + +def resolve_id(manager, identifier): + ids = manager.resolve_ids(identifier) + + if len(ids) == 0: + raise CLIAbort("Error: Unable to find hardware '%s'" % identifier) + + if len(ids) > 1: + raise CLIAbort( + "Error: Multiple hardware found for '%s': %s" % + (identifier, ', '.join([str(_id) for _id in ids]))) + + return ids[0] + + +class BMetalCreateOptions(CLIRunnable): + """ +usage: sl bmetal create-options [options] + +Output available available options when creating a bare metal instance. + +Options: + --all Show all options. default if no other option provided + --datacenter Show datacenter options + --cpu Show CPU options + --nic Show NIC speed options + --disk Show disk options + --os Show operating system options + --memory Show memory size options +""" + action = 'create-options' + options = ['datacenter', 'cpu', 'memory', 'os', 'disk', 'nic'] + + @classmethod + def execute(cls, client, args): + t = Table(['Name', 'Value']) + t.align['Name'] = 'r' + t.align['Value'] = 'l' + + show_all = True + for opt_name in cls.options: + if args.get("--" + opt_name): + show_all = False + break + + mgr = HardwareManager(client) + + bmi_options = mgr.get_bare_metal_create_options() + + if args['--all']: + show_all = True + + if args['--datacenter'] or show_all: + results = cls.get_create_options(bmi_options, 'datacenter')[0] + + t.add_row([results[0], listing(sorted(results[1]))]) + + if args['--cpu'] or args['--memory'] or show_all: + results = cls.get_create_options(bmi_options, 'cpu') + + for result in results: + t.add_row([result[0], listing( + item[0] for item in sorted(result[1], + key=lambda x: int(x[0])))]) + + if args['--os'] or show_all: + results = cls.get_create_options(bmi_options, 'os') + + for result in results: + t.add_row([result[0], linesep.join( + item[0] for item in sorted(result[1]))]) + + if args['--disk'] or show_all: + results = cls.get_create_options(bmi_options, 'disk')[0] + + t.add_row([results[0], listing( + item[0] for item in sorted(results[1]))]) + + if args['--nic'] or show_all: + results = cls.get_create_options(bmi_options, 'nic') + + for result in results: + t.add_row([result[0], listing( + item[0] for item in sorted(result[1],))]) + + return t + + @classmethod + def get_create_options(cls, bmi_options, section, pretty=True): + """ This method can be used to parse the bare metal instance creation + options into different sections. This can be useful for data validation + as well as printing the options on a help screen. + + :param dict bmi_options: The instance options to parse. Must come from + the .get_bare_metal_create_options() function + in the HardwareManager. + :param string section: The section to parse out. + :param bool pretty: If true, it will return the results in a 'pretty' + format that's easier to print. + """ + if 'datacenter' == section: + datacenters = [loc['keyname'] + for loc in bmi_options['locations']] + return [('datacenter', datacenters)] + elif 'cpu' == section or 'memory' == section: + mem_options = {} + cpu_regex = re.compile('(\d+) x ') + memory_regex = re.compile(' - (\d+) GB Ram', re.I) + + for item in bmi_options['categories']['server_core']['items']: + cpu = cpu_regex.search(item['description']).group(1) + memory = memory_regex.search(item['description']).group(1) + + if cpu and memory: + if memory not in mem_options: + mem_options[memory] = [] + + mem_options[memory].append((cpu, item['price_id'])) + + results = [] + for memory in sorted(mem_options.keys(), key=int): + key = memory + + if pretty: + key = 'cpus (%s gb ram)' % memory + + results.append((key, mem_options[memory])) + + return results + elif 'os' == section: + os_regex = re.compile('(^[A-Za-z\s\/]+) ([\d\.]+)') + bit_regex = re.compile(' \((\d+)\s*bit') + extra_regex = re.compile(' - (.+)\(') + + # Encapsulate the code for generating the operating system code + def _generate_os_code(name, version, bits, extra_info): + name = name.replace(' Linux', '') + name = name.replace('Enterprise', '') + name = name.replace('GNU/Linux', '') + + os_code = name.strip().replace(' ', '_').upper() + + if os_code == 'RED_HAT': + os_code = 'REDHAT' + + if 'UBUNTU' in os_code: + version = re.sub('\.\d+', '', version) + + os_code += '_' + version.replace('.0', '') + + if bits: + os_code += '_' + bits + + if extra_info: + os_code += '_' + extra_info.strip() \ + .replace(' Install', '').upper() + + return os_code + + # Also separate out the code for generating the Windows OS code + # since it's significantly different from the rest. + def _generate_windows_code(description): + version_check = re.search('Windows Server (\d+)', description) + version = version_check.group(1) + + os_code = 'WIN_' + version + + if 'Datacenter' in description: + os_code += '-DC' + elif 'Enterprise' in description: + os_code += '-ENT' + else: + os_code += '-STD' + + if 'ith R2' in description: + os_code += '-R2' + elif 'ith Hyper-V' in description: + os_code += '-HYPERV' + + bit_check = re.search('\((\d+)\s*bit', description) + if bit_check: + os_code += '_' + bit_check.group(1) + + return os_code + + # Loop through the operating systems and get their OS codes + os_list = {} + flat_list = [] + + for os in bmi_options['categories']['os']['items']: + if 'Windows Server' in os['description']: + os_code = _generate_windows_code(os['description']) + else: + os_results = os_regex.search(os['description']) + name = os_results.group(1) + version = os_results.group(2) + bits = bit_regex.search(os['description']) + extra_info = extra_regex.search(os['description']) + + if bits: + bits = bits.group(1) + if extra_info: + extra_info = extra_info.group(1) + + os_code = _generate_os_code(name, version, bits, + extra_info) + + name = os_code.split('_')[0] + + if name not in os_list: + os_list[name] = [] + + os_list[name].append((os_code, os['price_id'])) + flat_list.append((os_code, os['price_id'])) + + if pretty: + results = [] + for os in sorted(os_list.keys()): + results.append(('os (%s)' % os, os_list[os])) + + return results + else: + return [('os', flat_list)] + + elif 'disk' == section: + disks = [] + for disk in bmi_options['categories']['disk0']['items']: + disks.append((int(disk['capacity']), disk['price_id'])) + + return [('disks', disks)] + elif 'nic' == section: + single = [] + dual = [] + + for item in bmi_options['categories']['port_speed']['items']: + if 'dual' in item['description'].lower(): + dual.append((str(int(item['capacity'])) + '_DUAL', + item['price_id'])) + else: + single.append((int(item['capacity']), item['price_id'])) + + return [('single nic', single), ('dual nic', dual)] + + return [] + + +class CreateBMetalInstance(CLIRunnable): + """ +usage: sl bmetal create --hostname=HOST --domain=DOMAIN --cpu=CPU --os=OS + --memory=MEMORY --disk=DISK... (--hourly | --monthly) + [options] + +Order/create a bare metal instance. See 'sl bmetal create-options' for valid +options + +Required: + -H --hostname=HOST Host portion of the FQDN. example: server + -D --domain=DOMAIN Domain portion of the FQDN example: example.com + -c --cpu=CPU Number of CPU cores + -m --memory=MEMORY Memory in mebibytes (n * 1024) + + NOTE: Due to hardware configurations, the CPU and memory + must match appropriately. See create-options for + options. + + -o OS, --os=OS OS install code. + + --hourly Hourly rate instance type + --monthly Monthly rate instance type + + +Optional: + -d DC, --datacenter=DC datacenter name + Note: Omitting this value defaults to the first + available datacenter + -n MBPS, --network=MBPS Network port speed in Mbps + --dry-run, --test Do not create the instance, just get a quote +""" + action = 'create' + options = ['confirm'] + + @classmethod + def execute(cls, client, args): + mgr = HardwareManager(client) + + bmi_options = mgr.get_bare_metal_create_options() + + order = { + 'hostname': args['--hostname'], + 'domain': args['--domain'], + 'bare_metal': True, + } + + # Validate the CPU/Memory combination and get the price ID + server_core = cls._get_cpu_and_memory_price_ids(bmi_options, + args['--cpu'], + args['--memory']) + + if server_core: + order['server'] = server_core + else: + raise CLIAbort('Invalid CPU/memory combination specified.') + + order['hourly'] = args['--hourly'] + + # Convert the OS code back into a price ID + os_price = cls._get_price_id_from_options(bmi_options, 'os', + args['--os']) + + if os_price: + order['os'] = os_price + else: + raise CLIAbort('Invalid operating system specified.') + + order['location'] = args['--datacenter'] or 'FIRST_AVAILABLE' + + # Set the disk size + disk_prices = [] + for disk in args.get('--disk'): + disk_price = cls._get_price_id_from_options(bmi_options, 'disk', + disk) + + if disk_price: + disk_prices.append(disk_price) + + if not disk_prices: + disk_prices.append(cls._get_default_value(bmi_options, 'disk0')) + + order['disks'] = disk_prices + + # Set the port speed + port_speed = args.get('--network') or 10 + + nic_price = cls._get_price_id_from_options(bmi_options, 'nic', + port_speed) + + if nic_price: + order['port_speed'] = nic_price + else: + raise CLIAbort('Invalid NIC speed specified.') + + # Begin output + t = Table(['Item', 'cost']) + t.align['Item'] = 'r' + t.align['cost'] = 'r' + + if args.get('--test'): + result = mgr.verify_order(**order) + + total_monthly = 0.0 + total_hourly = 0.0 + for price in result['prices']: + total_monthly += float(price.get('recurringFee', 0.0)) + total_hourly += float(price.get('hourlyRecurringFee', 0.0)) + if args.get('--hourly'): + rate = "%.2f" % float(price['hourlyRecurringFee']) + else: + rate = "%.2f" % float(price['recurringFee']) + + t.add_row([price['item']['description'], rate]) + + if args.get('--hourly'): + total = total_hourly + else: + total = total_monthly + + billing_rate = 'monthly' + if args.get('--hourly'): + billing_rate = 'hourly' + t.add_row(['Total %s cost' % billing_rate, "%.2f" % total]) + output = SequentialOutput(blanks=False) + output.append(t) + output.append(FormattedItem( + '', + ' -- ! Prices reflected here are retail and do not ' + 'take account level discounts and are not guarenteed.') + ) + elif args['--really'] or confirm( + "This action will incur charges on your account. Continue?"): + result = mgr.place_order(**order) + + t = Table(['name', 'value']) + t.align['name'] = 'r' + t.align['value'] = 'l' + t.add_row(['id', result['orderId']]) + t.add_row(['created', result['orderDate']]) + output = t + else: + raise CLIAbort('Aborting bare metal instance order.') + + return output + + @classmethod + def _get_cpu_and_memory_price_ids(cls, bmi_options, cpu_value, + memory_value): + bmi_obj = BMetalCreateOptions() + price_id = None + + cpu_regex = re.compile('(\d+)') + for k, v in bmi_obj.get_create_options(bmi_options, 'cpu'): + cpu = cpu_regex.search(k).group(1) + + if cpu == cpu_value: + for mem_options in v: + if mem_options[0] == memory_value: + price_id = mem_options[1] + + return price_id + + @classmethod + def _get_default_value(cls, bmi_options, option): + if option not in bmi_options['categories']: + return + + for item in bmi_options['categories'][option]['items']: + if not any([ + float(item['prices'][0].get('setupFee', 0)), + float(item['prices'][0].get('recurringFee', 0)), + float(item['prices'][0].get('hourlyRecurringFee', 0)), + float(item['prices'][0].get('oneTimeFee', 0)), + float(item['prices'][0].get('laborFee', 0)), + ]): + return item['price_id'] + + @classmethod + def _get_price_id_from_options(cls, bmi_options, option, value): + bmi_obj = BMetalCreateOptions() + price_id = None + + for k, v in bmi_obj.get_create_options(bmi_options, option, False): + for item_options in v: + if item_options[0] == value: + price_id = item_options[1] + + return price_id + + +class CancelInstance(CLIRunnable): + """ +usage: sl bmetal cancel [options] + +Cancel a bare metal instance + +Options: + --immediate Cancels the instance immediately (instead of on the billing + anniversary). +""" + + action = 'cancel' + options = ['confirm'] + + @staticmethod + def execute(client, args): + hw = HardwareManager(client) + hw_id = resolve_id(hw, args.get('')) + + immediate = args.get('--immediate', False) + + if args['--really'] or no_going_back(hw_id): + hw.cancel_metal(hw_id, immediate) + else: + CLIAbort('Aborted') diff --git a/SoftLayer/CLI/modules/cci.py b/SoftLayer/CLI/modules/cci.py index ec3b30d3a..055cf2d53 100755 --- a/SoftLayer/CLI/modules/cci.py +++ b/SoftLayer/CLI/modules/cci.py @@ -25,27 +25,12 @@ from os import linesep import os.path -from SoftLayer.CCI import CCIManager +from SoftLayer import CCIManager from SoftLayer.CLI import ( CLIRunnable, Table, no_going_back, confirm, mb_to_gb, listing, FormattedItem) from SoftLayer.CLI.helpers import ( - CLIAbort, ArgumentError, SequentialOutput, - NestedDict, blank) - - -def resolve_id(cci_manager, identifier): - cci_ids = cci_manager.resolve_ids(identifier) - - if len(cci_ids) == 0: - raise CLIAbort("Error: Unable to find CCI '%s'" % identifier) - - if len(cci_ids) > 1: - raise CLIAbort( - "Error: Multiple CCIs found for '%s': %s" % - (identifier, ', '.join([str(_id) for _id in cci_ids]))) - - return cci_ids[0] + CLIAbort, ArgumentError, SequentialOutput, NestedDict, blank, resolve_id) class ListCCIs(CLIRunnable): @@ -108,16 +93,17 @@ def execute(client, args): t.sortby = args.get('--sortby') or 'host' for guest in guests: + guest = NestedDict(guest) t.add_row([ guest['id'], - guest.get('datacenter', {}).get('name', 'unknown'), + guest['datacenter']['name'] or blank(), guest['fullyQualifiedDomainName'], guest['maxCpu'], mb_to_gb(guest['maxMemory']), - guest.get('primaryIpAddress', blank()), - guest.get('primaryBackendIpAddress', blank()), - guest.get('activeTransaction', {}).get( - 'transactionStatus', {}).get('friendlyName', blank()), + guest['primaryIpAddress'] or blank(), + guest['primaryBackendIpAddress'] or blank(), + guest['activeTransaction']['transactionStatus'].get( + 'friendlyName') or blank(), ]) return t @@ -143,7 +129,7 @@ def execute(client, args): t.align['Name'] = 'r' t.align['Value'] = 'l' - cci_id = resolve_id(cci, args.get('')) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') result = cci.get_instance(cci_id) result = NestedDict(result) @@ -151,19 +137,18 @@ def execute(client, args): t.add_row(['hostname', result['fullyQualifiedDomainName']]) t.add_row(['status', result['status']['name']]) t.add_row(['state', result['powerState']['name']]) - t.add_row(['datacenter', result['datacenter'].get('name', blank())]) + t.add_row(['datacenter', result['datacenter']['name'] or blank()]) t.add_row(['cores', result['maxCpu']]) t.add_row(['memory', mb_to_gb(result['maxMemory'])]) - t.add_row(['public_ip', result.get('primaryIpAddress', blank())]) - t.add_row( - ['private_ip', result.get('primaryBackendIpAddress', blank())]) + t.add_row(['public_ip', result['primaryIpAddress'] or blank()]) + t.add_row(['private_ip', result['primaryBackendIpAddress'] or blank()]) t.add_row([ 'os', FormattedItem( result['operatingSystem']['softwareLicense'] - ['softwareDescription'].get('referenceCode', blank()), + ['softwareDescription']['referenceCode'] or blank(), result['operatingSystem']['softwareLicense'] - ['softwareDescription'].get('name', blank()) + ['softwareDescription']['name'] or blank() )]) t.add_row(['private_only', result['privateNetworkOnlyFlag']]) t.add_row(['private_cpu', result['dedicatedAccountHostOnlyFlag']]) @@ -359,6 +344,8 @@ class CreateCCI(CLIRunnable): -u --userdata=DATA User defined metadata string -F --userfile=FILE Read userdata from file + -i --postinstall=URI Post-install script to download + (Only HTTPS executes, HTTP leaves file in /root) --wait=SECONDS Block until CCI is finished provisioning for up to X seconds before returning. """ @@ -402,29 +389,32 @@ def execute(client, args): data["memory"] = memory if args['--monthly']: - data["hourly"] = False + data['hourly'] = False if args.get('--os'): - data["os_code"] = args['--os'] + data['os_code'] = args['--os'] if args.get('--image'): - data["image_id"] = args['--image'] + data['image_id'] = args['--image'] if args.get('--datacenter'): - data["datacenter"] = args['--datacenter'] + data['datacenter'] = args['--datacenter'] if args.get('--network'): data['nic_speed'] = args.get('--network') if args.get('--userdata'): data['userdata'] = args['--userdata'] - elif args.get('userfile'): + elif args.get('--userfile'): f = open(args['--userfile'], 'r') try: data['userdata'] = f.read() finally: f.close() + if args.get('--postinstall'): + data['post_uri'] = args.get('--postinstall') + t = Table(['Item', 'cost']) t.align['Item'] = 'r' t.align['cost'] = 'r' @@ -498,7 +488,7 @@ class ReadyCCI(CLIRunnable): def execute(client, args): cci = CCIManager(client) - cci_id = resolve_id(cci, args.get('')) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') ready = cci.wait_for_transaction(cci_id, int(args.get('--wait') or 0)) if ready: @@ -512,6 +502,10 @@ class ReloadCCI(CLIRunnable): usage: sl cci reload [options] Reload the OS on a CCI based on its current configuration + +Optional: + -i, --postinstall=URI Post-install script to download + (Only HTTPS executes, HTTP leaves file in /root) """ action = 'reload' @@ -520,9 +514,9 @@ class ReloadCCI(CLIRunnable): @staticmethod def execute(client, args): cci = CCIManager(client) - cci_id = resolve_id(cci, args.get('')) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') if args['--really'] or no_going_back(cci_id): - cci.reload_instance(cci_id) + cci.reload_instance(cci_id, args['--postinstall']) else: CLIAbort('Aborted') @@ -540,7 +534,7 @@ class CancelCCI(CLIRunnable): @staticmethod def execute(client, args): cci = CCIManager(client) - cci_id = resolve_id(cci, args.get('')) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') if args['--really'] or no_going_back(cci_id): cci.cancel_instance(cci_id) else: @@ -581,7 +575,7 @@ def execute(cls, client, args): def exec_shutdown(client, args): vg = client['Virtual_Guest'] cci = CCIManager(client) - cci_id = resolve_id(cci, args.get('')) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') if args['--soft']: result = vg.powerOffSoft(id=cci_id) elif args['--cycle']: @@ -595,28 +589,28 @@ def exec_shutdown(client, args): def exec_poweron(client, args): vg = client['Virtual_Guest'] cci = CCIManager(client) - cci_id = resolve_id(cci, args.get('')) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') return vg.powerOn(id=cci_id) @staticmethod def exec_pause(client, args): vg = client['Virtual_Guest'] cci = CCIManager(client) - cci_id = resolve_id(cci, args.get('')) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') return vg.pause(id=cci_id) @staticmethod def exec_resume(client, args): vg = client['Virtual_Guest'] cci = CCIManager(client) - cci_id = resolve_id(cci, args.get('')) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') return vg.resume(id=cci_id) @staticmethod def exec_reboot(client, args): vg = client['Virtual_Guest'] cci = CCIManager(client) - cci_id = resolve_id(cci, args.get('')) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') if args['--cycle']: result = vg.rebootHard(id=cci_id) elif args['--soft']: @@ -652,16 +646,14 @@ def execute(cls, client, args): @staticmethod def exec_port(client, args): - vg = client['Virtual_Guest'] - if args['--public']: - func = vg.setPublicNetworkInterfaceSpeed - elif args['--private']: - func = vg.setPrivateNetworkInterfaceSpeed + public = True + if args['--private']: + public = False cci = CCIManager(client) - cci_id = resolve_id(cci, args.get('')) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') - result = func(args['--speed'], id=cci_id) + result = cci.change_port_speed(cci_id, public, args['--speed']) if result: return "Success" else: @@ -693,7 +685,7 @@ def execute(cls, client, args): @staticmethod def dns_sync(client, args): - from SoftLayer.DNS import DNSManager, DNSZoneNotFound + from SoftLayer import DNSManager, DNSZoneNotFound dns = DNSManager(client) cci = CCIManager(client) @@ -742,7 +734,7 @@ def sync_ptr_record(): instance['fullyQualifiedDomainName'], ttl=7200) - cci_id = resolve_id(cci, args.get('')) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') instance = cci.get_instance(cci_id) if not instance['primaryIpAddress']: diff --git a/SoftLayer/CLI/modules/config.py b/SoftLayer/CLI/modules/config.py index 3604c99e1..12870f184 100644 --- a/SoftLayer/CLI/modules/config.py +++ b/SoftLayer/CLI/modules/config.py @@ -56,7 +56,8 @@ def execute(cls, client, args): if endpoint_url: config.set('softlayer', 'endpoint_url', endpoint_url) - f = open(config_path, 'w') + f = os.fdopen( + os.open(config_path, os.O_WRONLY | os.O_CREAT, 0600), 'w') try: config.write(f) finally: diff --git a/SoftLayer/CLI/modules/dns.py b/SoftLayer/CLI/modules/dns.py index 9f208dff1..61e171837 100755 --- a/SoftLayer/CLI/modules/dns.py +++ b/SoftLayer/CLI/modules/dns.py @@ -4,10 +4,9 @@ Manage DNS The available commands are: - search Look for a resource record by exact name edit Update resource records (bulk/single) create Create zone - list List zones + list List zones or a zone's records remove Remove resource records add Add resource record print Print zone in BIND format @@ -16,8 +15,9 @@ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. # :license: BSD, see LICENSE for more details. -from SoftLayer.CLI import CLIRunnable, no_going_back, Table, CLIAbort -from SoftLayer.DNS import DNSManager, DNSZoneNotFound +from SoftLayer.CLI import ( + CLIRunnable, no_going_back, Table, CLIAbort, resolve_id) +from SoftLayer import DNSManager, DNSZoneNotFound class DumpZone(CLIRunnable): @@ -34,8 +34,9 @@ class DumpZone(CLIRunnable): @staticmethod def execute(client, args): manager = DNSManager(client) + zone_id = resolve_id(manager.resolve_ids, args[''], name='zone') try: - return manager.dump_zone(manager.get_zone(args[''])['id']) + return manager.dump_zone(zone_id) except DNSZoneNotFound: raise CLIAbort("No zone found matching: %s" % args['']) @@ -72,21 +73,73 @@ class DeleteZone(CLIRunnable): @staticmethod def execute(client, args): manager = DNSManager(client) + zone_id = resolve_id(manager.resolve_ids, args[''], name='zone') + if args['--really'] or no_going_back(args['']): - manager.delete_zone(args['']) + manager.delete_zone(zone_id) raise CLIAbort("Aborted.") class ListZones(CLIRunnable): """ -usage: sl dns list [options] +usage: sl dns list [] [options] + +List zones and optionally, records -List zones +Filters: + --type=TYPE Record type, such as A or CNAME + --data=DATA Record data, such as an IP address + --record=HOST Host record, such as www + --ttl=TTL TTL value in seconds, such as 86400 """ action = 'list' + @classmethod + def execute(cls, client, args): + if args['']: + return cls.list_zone(client, args[''], args) + + return cls.list_all_zones(client) + @staticmethod - def execute(client, args): + def list_zone(client, zone, args): + manager = DNSManager(client) + t = Table([ + "record", + "type", + "ttl", + "value", + ]) + + t.align['ttl'] = 'l' + t.align['record'] = 'r' + t.align['value'] = 'l' + + zone_id = resolve_id(manager.resolve_ids, args[''], name='zone') + + try: + records = manager.get_records( + zone_id, + type=args.get('--type'), + host=args.get('--record'), + ttl=args.get('--ttl'), + data=args.get('--data'), + ) + except DNSZoneNotFound: + raise CLIAbort("No zone found matching: %s" % args['']) + + for rr in records: + t.add_row([ + rr['host'], + rr['type'].upper(), + rr['ttl'], + rr['data'] + ]) + + return t + + @staticmethod + def list_all_zones(client): manager = DNSManager(client) zones = manager.list_zones() t = Table([ @@ -130,12 +183,11 @@ class AddRecord(CLIRunnable): @staticmethod def execute(client, args): manager = DNSManager(client) - try: - zone = manager.get_zone(args[''])['id'] - except DNSZoneNotFound: - raise CLIAbort("No zone found matching: %s" % args['']) + + zone_id = resolve_id(manager.resolve_ids, args[''], name='zone') + manager.create_record( - zone, + zone_id, args[''], args[''], args[''], @@ -163,9 +215,11 @@ class EditRecord(CLIRunnable): @staticmethod def execute(client, args): manager = DNSManager(client) + zone_id = resolve_id(manager.resolve_ids, args[''], name='zone') + try: results = manager.search_record( - args[''], + zone_id, args['']) except DNSZoneNotFound: raise CLIAbort("No zone found matching: %s" % args['']) @@ -178,39 +232,6 @@ def execute(client, args): manager.edit_record(r) -class RecordSearch(CLIRunnable): - """ -usage: sl dns search [options] - -Look for a resource record by exact name - -Arguments: - Zone name (softlayer.com) - Resource record (www) -""" - action = 'search' - - @staticmethod - def execute(client, args): - manager = DNSManager(client) - results = [] - try: - results = manager.search_record( - args[''], - args['']) - except DNSZoneNotFound: - raise CLIAbort("No zone found matching: %s" % args['']) - - t = Table(['id', 'type', 'ttl', 'data']) - - t.align['ttl'] = 'c' - - for r in results: - t.add_row([r['id'], r['type'], r['ttl'], r['data']]) - - return t - - class RecordRemove(CLIRunnable): """ usage: sl dns remove [--id=ID] [options] @@ -230,13 +251,14 @@ class RecordRemove(CLIRunnable): @staticmethod def execute(client, args): manager = DNSManager(client) + zone_id = resolve_id(manager.resolve_ids, args[''], name='zone') if args['--id']: records = [{'id': args['--id']}] else: try: records = manager.search_record( - args[''], + zone_id, args['']) except DNSZoneNotFound: raise CLIAbort("No zone found matching: %s" % args['']) diff --git a/SoftLayer/CLI/modules/filters.py b/SoftLayer/CLI/modules/filters.py index 1e4274ac0..e2fde7000 100644 --- a/SoftLayer/CLI/modules/filters.py +++ b/SoftLayer/CLI/modules/filters.py @@ -20,8 +20,8 @@ '<= value' Less than or equal to value Examples: - sl cci list --datacenter=dal05 - sl cci list --hostname='prod*' + sl hardware list --datacenter=dal05 + sl hardware list --hostname='prod*' sl cci list --network=100 --cpu=2 sl cci list --network='< 100' --cpu=2 sl cci list --memory='>= 2048' diff --git a/SoftLayer/CLI/modules/firewall.py b/SoftLayer/CLI/modules/firewall.py index 37982b0f6..ef2b05721 100755 --- a/SoftLayer/CLI/modules/firewall.py +++ b/SoftLayer/CLI/modules/firewall.py @@ -11,7 +11,7 @@ from SoftLayer.CLI import CLIRunnable, Table, listing from SoftLayer.CLI.helpers import blank -from SoftLayer.firewall import FirewallManager +from SoftLayer import FirewallManager class FWList(CLIRunnable): diff --git a/SoftLayer/CLI/modules/hardware.py b/SoftLayer/CLI/modules/hardware.py new file mode 100644 index 000000000..1fbe604f1 --- /dev/null +++ b/SoftLayer/CLI/modules/hardware.py @@ -0,0 +1,748 @@ +""" +usage: sl hardware [] [...] [options] + sl hardware [-h | --help] + +Manage hardware + +The available commands are: + list List hardware devices + detail Retrieve hardware details + reload Perform an OS reload + cancel Cancel a dedicated server. + cancel-reasons Provides the list of possible cancellation reasons + network Manage network settings + list-chassis Provide a list of all chassis available for ordering + create-options Display a list of creation options for a specific chassis + create Create a new dedicated server + +For several commands, will be asked for. This can be the id, +hostname or the ip address for a piece of hardware. +""" +import re +from os import linesep +from SoftLayer.CLI.helpers import ( + CLIRunnable, Table, FormattedItem, NestedDict, CLIAbort, blank, listing, + SequentialOutput, gb, no_going_back, resolve_id, confirm) +from SoftLayer import HardwareManager + + +class ListHardware(CLIRunnable): + """ +usage: sl hardware list [options] + +List hardware servers on the acount + +Examples: + sl hardware list --datacenter=dal05 + sl hardware list --network=100 --domain=example.com + sl hardware list --tags=production,db + +Options: + --sortby=ARG Column to sort by. options: id, datacenter, host, cores, + memory, primary_ip, backend_ip + +Filters: + -H --hostname=HOST Host portion of the FQDN. example: server + -D --domain=DOMAIN Domain portion of the FQDN. example: example.com + -c --cpu=CPU Number of CPU cores + -m --memory=MEMORY Memory in gigabytes + -d DC, --datacenter=DC datacenter shortname (sng01, dal05, ...) + -n MBPS, --network=MBPS Network port speed in Mbps + --tags=ARG Only show instances that have one of these tags. + Comma-separated. (production,db) + +For more on filters see 'sl help filters' +""" + action = 'list' + + @staticmethod + def execute(client, args): + manager = HardwareManager(client) + + tags = None + if args.get('--tags'): + tags = [tag.strip() for tag in args.get('--tags').split(',')] + + servers = manager.list_hardware( + hostname=args.get('--hostname'), + domain=args.get('--domain'), + cpus=args.get('--cpu'), + memory=args.get('--memory'), + datacenter=args.get('--datacenter'), + nic_speed=args.get('--network'), + tags=tags) + + t = Table([ + 'id', + 'datacenter', + 'host', + 'cores', + 'memory', + 'primary_ip', + 'backend_ip' + ]) + t.sortby = args.get('--sortby') or 'host' + + for server in servers: + server = NestedDict(server) + t.add_row([ + server['id'], + server['datacenter']['name'] or blank(), + server['fullyQualifiedDomainName'], + server['processorCoreAmount'], + gb(server['memoryCapacity']), + server['primaryIpAddress'] or blank(), + server['primaryBackendIpAddress'] or blank(), + ]) + + return t + + +class HardwareDetails(CLIRunnable): + """ +usage: sl hardware detail [--passwords] [--price] [options] + +Get details for a hardware device + +Options: + --passwords Show passwords (check over your shoulder!) + --price Show associated prices +""" + action = 'detail' + + @staticmethod + def execute(client, args): + hardware = HardwareManager(client) + + t = Table(['Name', 'Value']) + t.align['Name'] = 'r' + t.align['Value'] = 'l' + + hardware_id = resolve_id( + hardware.resolve_ids, args.get(''), 'hardware') + result = hardware.get_hardware(hardware_id) + result = NestedDict(result) + + t.add_row(['id', result['id']]) + t.add_row(['hostname', result['fullyQualifiedDomainName']]) + t.add_row(['status', result['hardwareStatus']['status']]) + t.add_row(['datacenter', result['datacenter']['name'] or blank()]) + t.add_row(['cores', result['processorCoreAmount']]) + t.add_row(['memory', gb(result['memoryCapacity'])]) + t.add_row(['public_ip', result['primaryIpAddress'] or blank()]) + t.add_row( + ['private_ip', result['primaryBackendIpAddress'] or blank()]) + t.add_row([ + 'os', + FormattedItem( + result['operatingSystem']['softwareLicense'] + ['softwareDescription']['referenceCode'] or blank(), + result['operatingSystem']['softwareLicense'] + ['softwareDescription']['name'] or blank() + )]) + t.add_row(['created', result['provisionDate']]) + if result.get('notes'): + t.add_row(['notes', result['notes']]) + + if args.get('--price'): + t.add_row(['price rate', result['billingItem']['recurringFee']]) + + if args.get('--passwords'): + user_strs = [] + for item in result['operatingSystem']['passwords']: + user_strs.append( + "%s %s" % (item['username'], item['password'])) + t.add_row(['users', listing(user_strs)]) + + tag_row = [] + for tag in result['tagReferences']: + tag_row.append(tag['tag']['name']) + + if tag_row: + t.add_row(['tags', listing(tag_row, separator=',')]) + + ptr_domains = client['Hardware_Server'].getReverseDomainRecords( + id=hardware_id) + + for ptr_domain in ptr_domains: + for ptr in ptr_domain['resourceRecords']: + t.add_row(['ptr', ptr['data']]) + + return t + + +class HardwareReload(CLIRunnable): + """ +usage: sl hardware reload [options] + +Reload the OS on a hardware server based on its current configuration + +Optional: + -i, --postinstall=URI Post-install script to download + (Only HTTPS executes, HTTP leaves file in /root) +""" + + action = 'reload' + options = ['confirm'] + + @staticmethod + def execute(client, args): + hardware = HardwareManager(client) + hardware_id = resolve_id( + hardware.resolve_ids, args.get(''), 'hardware') + if args['--really'] or no_going_back(hardware_id): + hardware.reload(hardware_id, args['--postinstall']) + else: + CLIAbort('Aborted') + + +class CancelHardware(CLIRunnable): + """ +usage: sl hardware cancel [options] + +Cancel a dedicated server + +Options: + --reason An optional cancellation reason. See cancel-reasons for a list of + available options. +""" + + action = 'cancel' + options = ['confirm'] + + @staticmethod + def execute(client, args): + hw = HardwareManager(client) + hw_id = resolve_id(hw, args.get('')) + + print "(Optional) Add a cancellation comment:", + comment = raw_input() + + reason = args.get('--reason') + + if args['--really'] or no_going_back(hw_id): + hw.cancel_hardware(hw_id, reason, comment) + else: + CLIAbort('Aborted') + + +class HardwareCancelReasons(CLIRunnable): + """ +usage: sl hardware cancel-reasons + +Display a list of cancellation reasons +""" + + action = 'cancel-reasons' + + @staticmethod + def execute(client, args): + t = Table(['Code', 'Reason']) + t.align['Code'] = 'r' + t.align['Reason'] = 'l' + + mgr = HardwareManager(client) + reasons = mgr.get_cancellation_reasons().iteritems() + + for code, reason in reasons: + t.add_row([code, reason]) + + return t + + +class NetworkHardware(CLIRunnable): + """ +usage: sl hardware network port --speed=SPEED + (--public | --private) [options] + +Manage network settings + +Options: + --speed=SPEED Port speed. 0 disables the port. + [Options: 0, 10, 100, 1000, 10000] + --public Public network + --private Private network +""" + action = 'network' + + @classmethod + def execute(cls, client, args): + if args['port']: + return cls.exec_port(client, args) + + if args['details']: + return cls.exec_detail(client, args) + + @staticmethod + def exec_port(client, args): + public = True + if args['--private']: + public = False + + mgr = HardwareManager(client) + hw_id = resolve_id(mgr.resolve_ids, args.get(''), + 'hardware') + + result = mgr.change_port_speed(hw_id, public, args['--speed']) + if result: + return "Success" + else: + return result + + @staticmethod + def exec_detail(client, args): + # TODO this should print out default gateway and stuff + raise CLIAbort('Not implemented') + + +class ListChassisHardware(CLIRunnable): + """ +usage: sl hardware list-chassis + +Display a list of chassis available for ordering dedicated servers. +""" + action = 'list-chassis' + + @staticmethod + def execute(client, args): + t = Table(['Code', 'Chassis']) + t.align['Code'] = 'r' + t.align['Chassis'] = 'l' + + mgr = HardwareManager(client) + chassis = mgr.get_available_dedicated_server_packages() + + for chassis in chassis: + t.add_row([chassis[0], chassis[1]]) + + return t + + +class HardwareCreateOptions(CLIRunnable): + """ +usage: sl hardware create-options [options] + +Output available available options when creating a dedicated server with the +specified chassis. + +Options: + --all Show all options. default if no other option provided + --datacenter Show datacenter options + --cpu Show CPU options + --nic Show NIC speed options + --disk Show disk options + --os Show operating system options + --memory Show memory size options + --controller Show disk controller options +""" + + action = 'create-options' + options = ['datacenter', 'cpu', 'memory', 'os', 'disk', 'nic', + 'controller'] + + @classmethod + def execute(cls, client, args): + mgr = HardwareManager(client) + + t = Table(['Name', 'Value']) + t.align['Name'] = 'r' + t.align['Value'] = 'l' + + chassis_id = args.get('') + + ds_options = mgr.get_dedicated_server_create_options(chassis_id) + + show_all = True + for opt_name in cls.options: + if args.get("--" + opt_name): + show_all = False + break + + if args['--all']: + show_all = True + + if args['--datacenter'] or show_all: + results = cls.get_create_options(ds_options, 'datacenter')[0] + + t.add_row([results[0], listing(sorted(results[1]))]) + + if args['--cpu'] or show_all: + results = cls.get_create_options(ds_options, 'cpu') + + for result in sorted(results): + t.add_row([result[0], listing( + item[0] for item in sorted(result[1]))]) + + if args['--memory'] or show_all: + results = cls.get_create_options(ds_options, 'memory')[0] + + t.add_row([results[0], listing( + item[0] for item in sorted(results[1]))]) + + if args['--os'] or show_all: + results = cls.get_create_options(ds_options, 'os') + + for result in results: + t.add_row([result[0], linesep.join( + item[0] for item in sorted(result[1]))]) + + if args['--disk'] or show_all: + results = cls.get_create_options(ds_options, 'disk')[0] + + t.add_row([results[0], linesep.join( + item[0] for item in sorted(results[1],))]) + + if args['--nic'] or show_all: + results = cls.get_create_options(ds_options, 'nic') + + for result in results: + t.add_row([result[0], listing( + item[0] for item in sorted(result[1],))]) + + if args['--controller'] or show_all: + results = cls.get_create_options(ds_options, 'disk_controller')[0] + + t.add_row([results[0], listing( + item[0] for item in sorted(results[1],))]) + + return t + + @classmethod + def get_create_options(cls, ds_options, section, pretty=True): + """ This method can be used to parse the bare metal instance creation + options into different sections. This can be useful for data validation + as well as printing the options on a help screen. + + :param dict ds_options: The instance options to parse. Must come from + the .get_bare_metal_create_options() function + in the HardwareManager. + :param string section: The section to parse out. + :param bool pretty: If true, it will return the results in a 'pretty' + format that's easier to print. + """ + if 'datacenter' == section: + datacenters = [loc['keyname'] + for loc in ds_options['locations']] + return [('datacenter', datacenters)] + elif 'cpu' == section: + results = [] + cpu_regex = re.compile('\s(\w+)\s(\d+)\s+\-\s+([\d\.]+GHz)' + '\s+\([\w ]+\)\s+\-\s+(.+)$') + + for item in ds_options['categories']['server']['items']: + cpu = cpu_regex.search(item['description']) + text = 'cpu: ' + cpu.group(1) + ' ' + cpu.group(2) + ' (' \ + + cpu.group(3) + ', ' + cpu.group(4) + ')' + + if cpu: + results.append((text, [(cpu.group(2), item['price_id'])])) + + return results + elif 'memory' == section: + ram = [] + for option in ds_options['categories']['ram']['items']: + ram.append((int(option['capacity']), option['price_id'])) + + return [('memory', ram)] + elif 'os' == section: + os_regex = re.compile('(^[A-Za-z\s\/\-]+) ([\d\.]+)') + bit_regex = re.compile(' \((\d+)\s*bit') + extra_regex = re.compile(' - (.+)\(') + + # Encapsulate the code for generating the operating system code + def _generate_os_code(name, version, bits, extra_info): + name = name.replace(' Linux', '') + name = name.replace('Enterprise', '') + name = name.replace('GNU/Linux', '') + + os_code = name.strip().replace(' ', '_').upper() + + if os_code.startswith('RED_HAT'): + os_code = 'REDHAT' + + if 'UBUNTU' in os_code: + version = re.sub('\.\d+', '', version) + + os_code += '_' + version.replace('.0', '') + + if bits: + os_code += '_' + bits + + if extra_info: + garbage = ['Install', '(32 bit)', '(64 bit)'] + + for g in garbage: + extra_info = extra_info.replace(g, '') + + os_code += '_' + \ + extra_info.strip().replace(' ', '_').upper() + + return os_code + + # Also separate out the code for generating the Windows OS code + # since it's significantly different from the rest. + def _generate_windows_code(description): + version_check = re.search('Windows Server (\d+)', description) + version = version_check.group(1) + + os_code = 'WIN_' + version + + if 'Datacenter' in description: + os_code += '-DC' + elif 'Enterprise' in description: + os_code += '-ENT' + else: + os_code += '-STD' + + if 'ith R2' in description: + os_code += '-R2' + elif 'ith Hyper-V' in description: + os_code += '-HYPERV' + + bit_check = re.search('\((\d+)\s*bit', description) + if bit_check: + os_code += '_' + bit_check.group(1) + + return os_code + + # Loop through the operating systems and get their OS codes + os_list = {} + flat_list = [] + + for os in ds_options['categories']['os']['items']: + if 'Windows Server' in os['description']: + os_code = _generate_windows_code(os['description']) + else: + os_results = os_regex.search(os['description']) + name = os_results.group(1) + version = os_results.group(2) + bits = bit_regex.search(os['description']) + extra_info = extra_regex.search(os['description']) + + if bits: + bits = bits.group(1) + if extra_info: + extra_info = extra_info.group(1) + + os_code = _generate_os_code(name, version, bits, + extra_info) + + name = os_code.split('_')[0] + + if name not in os_list: + os_list[name] = [] + + os_list[name].append((os_code, os['price_id'])) + flat_list.append((os_code, os['price_id'])) + + if pretty: + results = [] + for os in sorted(os_list.keys()): + results.append(('os (%s)' % os, os_list[os])) + + return results + else: + return [('os', flat_list)] + + elif 'disk' == section: + disks = [] + type_regex = re.compile('^[\d\.]+[GT]B\s+(.+)$') + for disk in ds_options['categories']['disk0']['items']: + disk_type = 'SATA' + disk_type = type_regex.match(disk['description']).group(1) + + disk_type = disk_type.replace('RPM', '').strip() + disk_type = disk_type.replace(' ', '_').upper() + disk_type = str(int(disk['capacity'])) + '_' + disk_type + disks.append((disk_type, disk['price_id'])) + + return [('disk', disks)] + elif 'nic' == section: + single = [] + dual = [] + + for item in ds_options['categories']['port_speed']['items']: + if 'dual' in item['description'].lower(): + dual.append((str(int(item['capacity'])) + '_DUAL', + item['price_id'])) + else: + single.append((int(item['capacity']), item['price_id'])) + + return [('single nic', single), ('dual nic', dual)] + elif 'disk_controller' == section: + options = [] + for item in ds_options['categories']['disk_controller']['items']: + text = item['description'].replace(' ', '') + + if 'Non-RAID' == text: + text = 'None' + + options.append((text, item['price_id'])) + + return [('disk_controllers', options)] + + return [] + + +class CreateHardware(CLIRunnable): + """ +usage: sl hardware create --hostname=HOST --domain=DOMAIN --cpu=CPU + --chassis=CHASSIS --memory=MEMORY --os=OS --disk=SIZE... [options] + +Order/create a dedicated server. See 'sl hardware list-chassis' and +'sl hardware create-options' for valid options + +Required: + -H --hostname=HOST Host portion of the FQDN. example: server + -D --domain=DOMAIN Domain portion of the FQDN example: example.com + --chassis=CHASSIS The chassis to use for the new server + -c --cpu=CPU CPU model + -o OS, --os=OS OS install code. + -m --memory=MEMORY Memory in mebibytes (n * 1024) + + +Optional: + -d DC, --datacenter=DC datacenter name + Note: Omitting this value defaults to the first + available datacenter + -n MBPS, --network=MBPS Network port speed in Mbps + --controller=RAID The RAID configuration for the server. + Defaults to None. + --dry-run, --test Do not create the server, just get a quote +""" + action = 'create' + + @classmethod + def execute(cls, client, args): + mgr = HardwareManager(client) + + ds_options = mgr.get_dedicated_server_create_options(args['--chassis']) + + order = { + 'hostname': args['--hostname'], + 'domain': args['--domain'], + 'bare_metal': False, + 'package_id': args['--chassis'], + } + + # Convert the OS code back into a price ID + os_price = cls._get_price_id_from_options(ds_options, 'os', + args['--os']) + + if os_price: + order['os'] = os_price + else: + raise CLIAbort('Invalid operating system specified.') + + order['location'] = args['--datacenter'] or 'FIRST_AVAILABLE' + order['server'] = cls._get_price_id_from_options(ds_options, 'cpu', + args['--cpu']) + order['ram'] = cls._get_price_id_from_options(ds_options, 'memory', + int(args['--memory'])) + # Set the disk sizes + disk_prices = [] + for disk in args.get('--disk'): + disk_price = cls._get_price_id_from_options(ds_options, 'disk', + disk) + + if disk_price: + disk_prices.append(disk_price) + + if not disk_prices: + disk_prices.append(cls._get_default_value(ds_options, 'disk0')) + + order['disks'] = disk_prices + + # Set the disk controller price + if args.get('--controller'): + dc_price = cls._get_price_id_from_options(ds_options, + 'disk_controller', + args.get('--controller')) + else: + dc_price = cls._get_price_id_from_options(ds_options, + 'disk_controller', + 'None') + + order['disk_controller'] = dc_price + + # Set the port speed + port_speed = args.get('--network') or 10 + + nic_price = cls._get_price_id_from_options(ds_options, 'nic', + port_speed) + + if nic_price: + order['port_speed'] = nic_price + else: + raise CLIAbort('Invalid NIC speed specified.') + + # Begin output + t = Table(['Item', 'cost']) + t.align['Item'] = 'r' + t.align['cost'] = 'r' + + if args.get('--test'): + result = mgr.verify_order(**order) + + total = 0.0 + for price in result['prices']: + total += float(price.get('recurringFee', 0.0)) + if args.get('--hourly'): + rate = "%.2f" % float(price['hourlyRecurringFee']) + else: + rate = "%.2f" % float(price['recurringFee']) + + t.add_row([price['item']['description'], rate]) + + billing_rate = 'monthly' + if args.get('--hourly'): + billing_rate = 'hourly' + t.add_row(['Total %s cost' % billing_rate, "%.2f" % total]) + output = SequentialOutput(blanks=False) + output.append(t) + output.append(FormattedItem( + '', + ' -- ! Prices reflected here are retail and do not ' + 'take account level discounts and are not guarenteed.') + ) + elif args.get('--really') or confirm( + "This action will incur charges on your account. Continue?"): + result = mgr.place_order(**order) + + t = Table(['name', 'value']) + t.align['name'] = 'r' + t.align['value'] = 'l' + t.add_row(['id', result['orderId']]) + t.add_row(['created', result['orderDate']]) + output = t + else: + raise CLIAbort('Aborting dedicated server order.') + + return output + + @classmethod + def _get_default_value(cls, ds_options, option): + if option not in ds_options['categories']: + return + + for item in ds_options['categories'][option]['items']: + if not any([ + float(item['prices'][0].get('setupFee', 0)), + float(item['prices'][0].get('recurringFee', 0)), + float(item['prices'][0].get('hourlyRecurringFee', 0)), + float(item['prices'][0].get('oneTimeFee', 0)), + float(item['prices'][0].get('laborFee', 0)), + ]): + return item['price_id'] + + @classmethod + def _get_price_id_from_options(cls, ds_options, option, value): + ds_obj = HardwareCreateOptions() + price_id = None + + for k, v in ds_obj.get_create_options(ds_options, option, False): + for item_options in v: + if item_options[0] == value: + price_id = item_options[1] + + return price_id diff --git a/SoftLayer/CLI/modules/help.py b/SoftLayer/CLI/modules/help.py new file mode 100644 index 000000000..9be1e53b0 --- /dev/null +++ b/SoftLayer/CLI/modules/help.py @@ -0,0 +1,28 @@ +""" +usage: sl help [options] + sl help [options] + sl help [options] + +View help on a module or command. +""" +# :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. +# :license: BSD, see LICENSE for more details. + +from SoftLayer.CLI.core import CommandParser +from SoftLayer.CLI import CLIRunnable + + +class Show(CLIRunnable): + # Use the same documentation as the module + __doc__ = __doc__ + action = None + + @classmethod + def execute(cls, client, args): + parser = CommandParser(cls.env) + cls.env.load_module(args['']) + if args['']: + return parser.get_command_help(args[''], args['']) + elif args['']: + return parser.get_module_help(args['']) + return parser.get_main_help() diff --git a/SoftLayer/CLI/modules/iscsi.py b/SoftLayer/CLI/modules/iscsi.py index 79aeffc88..3401705c6 100644 --- a/SoftLayer/CLI/modules/iscsi.py +++ b/SoftLayer/CLI/modules/iscsi.py @@ -4,7 +4,7 @@ Manage iSCSI targets The available commands are: - list List NAS accounts + list List iSCSI targets """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. # :license: BSD, see LICENSE for more details. diff --git a/SoftLayer/CLI/modules/messaging.py b/SoftLayer/CLI/modules/messaging.py new file mode 100644 index 000000000..550c12738 --- /dev/null +++ b/SoftLayer/CLI/modules/messaging.py @@ -0,0 +1,359 @@ +""" +usage: sl messaging [] [...] [options] + +Manage SoftLayer Message Queue + +The available commands are: + list-accounts List all queue accounts + list-endpoints List all service endpoints + ping Ping the service + queue Queue-related commands + topic Topic-related commands +""" +# :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. +# :license: BSD, see LICENSE for more details. +# from SoftLayer import NetworkManager +import sys + +from SoftLayer import MessagingManager +from SoftLayer.CLI import CLIRunnable, Table +from SoftLayer.CLI.helpers import CLIAbort, listing, ArgumentError, blank + + +COMMON_MESSAGING_ARGS = """Service Options: + --datacenter=NAME Datacenter, E.G.: dal05 + --network=TYPE Network type, [Options: public, private] +""" + + +def get_mq_client(manager, account_id, env): + return manager.get_connection( + account_id, env.config.get('username'), env.config.get('api_key')) + + +class ListAccounts(CLIRunnable): + """ +usage: sl messaging list-accounts [options] + +List SoftLayer Message Queue Accounts + +""" + action = 'list-accounts' + + @staticmethod + def execute(client, args): + manager = MessagingManager(client) + accounts = manager.list_accounts() + + t = Table([ + 'id', 'name', 'status' + ]) + for account in accounts: + t.add_row([ + account['nodes'][0]['accountName'], + account['name'], + account['status']['name'], + ]) + + return t + + +class ListEndpoints(CLIRunnable): + """ +usage: sl messaging list-endpoints [options] + +List SoftLayer Message Queue Endpoints + +""" + action = 'list-endpoints' + + @staticmethod + def execute(client, args): + manager = MessagingManager(client) + regions = manager.get_endpoints() + + t = Table([ + 'name', 'public', 'private' + ]) + for region, endpoints in regions.items(): + t.add_row([ + region, + endpoints.get('public') or blank(), + endpoints.get('private') or blank(), + ]) + + return t + + +class Ping(CLIRunnable): + __doc__ = """ +usage: sl messaging ping [options] + +Ping the SoftLayer Message Queue service + +""" + COMMON_MESSAGING_ARGS + action = 'ping' + + @staticmethod + def execute(client, args): + manager = MessagingManager(client) + ok = manager.ping( + endpoint_name=args['--datacenter'], network=args['--network']) + if ok: + return 'OK' + else: + CLIAbort('Ping failed') + + +def queue_table(queue): + t = Table(['property', 'value']) + t.align['property'] = 'r' + t.align['value'] = 'l' + + t.add_row(['name', queue['name']]) + t.add_row(['message_count', queue['message_count']]) + t.add_row(['visible_message_count', queue['visible_message_count']]) + t.add_row(['tags', listing(queue['tags'] or [])]) + t.add_row(['expiration', queue['expiration']]) + t.add_row(['visibility_interval', queue['visibility_interval']]) + return t + + +def message_table(message): + t = Table(['property', 'value']) + t.align['property'] = 'r' + t.align['value'] = 'l' + + t.add_row(['id', message['id']]) + t.add_row(['initial_entry_time', message['initial_entry_time']]) + t.add_row(['visibility_delay', message['visibility_delay']]) + t.add_row(['visibility_interval', message['visibility_interval']]) + t.add_row(['fields', message['fields']]) + return [t, message['body']] + + +def topic_table(topic): + t = Table(['property', 'value']) + t.align['property'] = 'r' + t.align['value'] = 'l' + + t.add_row(['name', topic['name']]) + t.add_row(['tags', listing(topic['tags'] or [])]) + return t + + +def subscription_table(sub): + t = Table(['property', 'value']) + t.align['property'] = 'r' + t.align['value'] = 'l' + + t.add_row(['id', sub['id']]) + t.add_row(['endpoint_type', sub['endpoint_type']]) + for k, v in sub['endpoint'].items(): + t.add_row([k, v]) + return t + + +class Queue(CLIRunnable): + __doc__ = """ +usage: sl messaging queue list [options] + sl messaging queue detail [options] + sl messaging queue create [options] + sl messaging queue modify [options] + sl messaging queue delete [] [options] + sl messaging queue push ( | [-]) [options] + sl messaging queue pop [options] + +Manage queues + +Queue Create/modify Options: + --visibility_interval=SECONDS Time in seconds that messages will re-appear + after being popped + --expiration=SECONDS Time in seconds that messages will live + --tags=TAGS Comma-separated list of tags + +Queue Delete Options: + --force Flag to force the deletion of the queue even when there are messages + +Pop Options: + --count=NUM Count of messages to pop + --delete-after Remove popped messages from the queue + +""" + COMMON_MESSAGING_ARGS + action = 'queue' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = get_mq_client(manager, args[''], cls.env) + + # list + if args['list']: + queues = mq_client.get_queues()['items'] + + t = Table([ + 'name', 'message_count', 'visible_message_count' + ]) + for queue in queues: + t.add_row([ + queue['name'], + queue['message_count'], + queue['visible_message_count'], + ]) + return t + # detail + elif args['detail']: + queue = mq_client.get_queue(args['']) + return queue_table(queue) + # create + elif args['create'] or args['modify']: + tags = None + if args.get('--tags'): + tags = [tag.strip() for tag in args.get('--tags').split(',')] + + queue = mq_client.create_queue( + args[''], + visibility_interval=int(args.get('--visibility_interval') or 30), + expiration=int(args.get('--expiration') or 604800), + tags=tags, + ) + return queue_table(queue) + # delete + elif args['delete']: + if args['']: + messages = mq_client.delete_message( + args[''], + ['']) + else: + mq_client.delete_queue( + args[''], + args.get('--force')) + # push message + elif args['push']: + # the message body comes from the positional argument or stdin + body = '' + if args[''] is not None: + body = args[''] + else: + body = sys.stdin.read() + return message_table( + mq_client.push_queue_message(args[''], body)) + # pop message + elif args['pop']: + messages = mq_client.pop_message( + args[''], + args.get('--count') or 1) + formatted_messages = [] + for message in messages['items']: + formatted_messages.append(message_table(message)) + + if args.get('--delete-after'): + for message in messages['items']: + mq_client.delete_message( + args[''], + message['id']) + return formatted_messages + else: + raise CLIAbort('Invalid command') + + +class Topic(CLIRunnable): + __doc__ = """ +usage: sl messaging topic list [options] + sl messaging topic detail [options] + sl messaging topic create [options] + sl messaging topic delete [] [options] + sl messaging topic push ( | [-]) [options] + +Manage topics and subscriptions + +Topic/Subscription Create Options: + --subscription Create a subscription + --type=TYPE Type of endpoint, [Options: http, queue] + --queue-name=NAME Queue name. Required if --type is queue + --http-method=METHOD HTTP Method to use if --type is http + --http-url=URL HTTP/HTTPS URL to use. Required if --type is http + --http-body=BODY HTTP Body template to use if --type is http + +Topic Delete Options: + --force Flag to force the deletion of the topic even when there are subscriptions + +""" + COMMON_MESSAGING_ARGS + action = 'topic' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = get_mq_client(manager, args[''], cls.env) + + # list + if args['list']: + topics = mq_client.get_topics()['items'] + + t = Table(['name']) + for topic in topics: + t.add_row([topic['name']]) + return t + # detail + elif args['detail']: + topic = mq_client.get_topic(args['']) + subscriptions = mq_client.get_subscriptions(args['']) + tables = [] + for sub in subscriptions['items']: + tables.append(subscription_table(sub)) + return [topic_table(topic), tables] + # create + elif args['create']: + if args['--subscription']: + if args['--type'] == 'queue': + subscription = mq_client.create_subscription( + args[''], + 'queue', + queue_name=args['--queue-name'], + ) + elif args['--type'] == 'http': + subscription = mq_client.create_subscription( + args[''], + 'http', + method=args['--http-method'] or 'GET', + url=args['--http-url'], + body=args['--http-body'] + ) + else: + raise ArgumentError( + '--type should be either queue or http.') + return subscription_table(subscription) + else: + tags = None + if args.get('--tags'): + tags = [tag.strip() for tag in args.get('--tags').split(',')] + + topic = mq_client.create_topic( + args[''], + visibility_interval=int(args.get('--visibility_interval') or 30), + expiration=int(args.get('--expiration') or 604800), + tags=tags, + ) + return topic_table(topic) + # delete + elif args['delete']: + if args['']: + mq_client.delete_subscription( + args[''], + args['']) + else: + mq_client.delete_topic( + args[''], + args.get('--force')) + # push message + elif args['push']: + # the message body comes from the positional argument or stdin + body = '' + if args[''] is not None: + body = args[''] + else: + body = sys.stdin.read() + return message_table( + mq_client.push_topic_message(args[''], body)) + else: + raise CLIAbort('Invalid command') diff --git a/SoftLayer/CLI/modules/metadata.py b/SoftLayer/CLI/modules/metadata.py index 6289e9212..f70757687 100644 --- a/SoftLayer/CLI/modules/metadata.py +++ b/SoftLayer/CLI/modules/metadata.py @@ -6,24 +6,24 @@ resources. The available commands are: - datacenter Datacenter name - backend_mac Backend mac addresses - ip Primary ip address backend_ip Primary backend ip address - tags Tags - hostname Hostname - fqdn Fully qualified domain name - user_data User-defined data + backend_mac Backend mac addresses + datacenter Datacenter name datacenter_id Datacenter id + fqdn Fully qualified domain name frontend_mac Frontend mac addresses - provision_state Provision state + hostname Hostname id Id + ip Primary ip address network Details about either the public or private network + provision_state Provision state + tags Tags + user_data User-defined data """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. # :license: BSD, see LICENSE for more details. -from SoftLayer.metadata import MetadataManager +from SoftLayer import MetadataManager from SoftLayer.CLI import CLIRunnable, Table, listing, CLIAbort diff --git a/SoftLayer/CLI/modules/nas.py b/SoftLayer/CLI/modules/nas.py index e8faa23f9..674d45a0a 100644 --- a/SoftLayer/CLI/modules/nas.py +++ b/SoftLayer/CLI/modules/nas.py @@ -31,14 +31,15 @@ def execute(client, args): mask='eventCount,serviceResource[datacenter.name]') nas = [NestedDict(n) for n in nas] - t = Table(['id', 'datacenter', 'size', 'username', - 'password', 'server']) + t = Table(['id', 'datacenter', 'size', 'username', 'password', + 'server']) for n in nas: t.add_row([ n['id'], n['serviceResource']['datacenter'].get('name', blank()), - FormattedItem(n.get('capacityGb', blank()), + FormattedItem( + n.get('capacityGb', blank()), "%dGB" % n.get('capacityGb', 0)), n.get('username', blank()), n.get('password', blank()), diff --git a/SoftLayer/CLI/modules/ssl.py b/SoftLayer/CLI/modules/ssl.py index 393655281..9013172b7 100755 --- a/SoftLayer/CLI/modules/ssl.py +++ b/SoftLayer/CLI/modules/ssl.py @@ -16,7 +16,7 @@ from SoftLayer.CLI.helpers import CLIRunnable, no_going_back, Table, CLIAbort from SoftLayer.CLI.helpers import blank -from SoftLayer.SSL import SSLManager +from SoftLayer import SSLManager class ListCerts(CLIRunnable): diff --git a/SoftLayer/__init__.py b/SoftLayer/__init__.py index 920e5a73b..2d470535f 100644 --- a/SoftLayer/__init__.py +++ b/SoftLayer/__init__.py @@ -16,10 +16,8 @@ """ from SoftLayer.consts import VERSION -from API import Client, API_PUBLIC_ENDPOINT, API_PRIVATE_ENDPOINT -from DNS import DNSManager -from CCI import CCIManager -from metadata import MetadataManager +from API import * # NOQA +from managers import * # NOQA from SoftLayer.exceptions import * # NOQA __title__ = 'SoftLayer' @@ -27,6 +25,5 @@ __author__ = 'SoftLayer Technologies, Inc.' __license__ = 'The BSD License' __copyright__ = 'Copyright 2013 SoftLayer Technologies, Inc.' -__all__ = ['Client', 'SoftLayerError', 'SoftLayerAPIError', - 'API_PUBLIC_ENDPOINT', 'API_PRIVATE_ENDPOINT', - 'DNSManager', 'CCIManager', 'MetadataManager'] +__all__ = ['Client', 'BasicAuthentication', 'SoftLayerError', + 'SoftLayerAPIError', 'API_PUBLIC_ENDPOINT', 'API_PRIVATE_ENDPOINT'] diff --git a/SoftLayer/consts.py b/SoftLayer/consts.py index 0b6a68321..99b3e25e4 100644 --- a/SoftLayer/consts.py +++ b/SoftLayer/consts.py @@ -6,8 +6,9 @@ :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. :license: BSD, see LICENSE for more details. """ -VERSION = 'v2.2.0' +VERSION = 'v2.3.0' API_PUBLIC_ENDPOINT = 'https://api.softlayer.com/xmlrpc/v3/' API_PRIVATE_ENDPOINT = 'https://api.service.softlayer.com/xmlrpc/v3/' +API_PUBLIC_ENDPOINT_REST = 'https://api.softlayer.com/rest/v3/' API_PRIVATE_ENDPOINT_REST = 'https://api.service.softlayer.com/rest/v3/' USER_AGENT = "SoftLayer Python %s" % VERSION diff --git a/SoftLayer/deprecated.py b/SoftLayer/deprecated.py new file mode 100644 index 000000000..1c236e230 --- /dev/null +++ b/SoftLayer/deprecated.py @@ -0,0 +1,180 @@ +""" + SoftLayer.deprecated + ~~~~~~~~~~~~~~~~~~~~ + This is where deprecated APIs go for their eternal slumber + + :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. + :license: BSD, see LICENSE for more details. +""" +from warnings import warn +from SoftLayer.exceptions import SoftLayerError + + +class DeprecatedClientMixin(): + """ This mixin is to be used in SoftLayer.Client so all of these methods + should be available to the client but are all deprecated. + """ + + def __init__(self, id=None, username=None, api_key=None, **kwargs): + + if id is not None: + warn("The id parameter is deprecated", DeprecationWarning) + self.set_init_parameter(int(id)) + + if username and api_key: + self._headers['authenticate'] = { + 'username': username.strip(), + 'apiKey': api_key.strip(), + } + + def __getattr__(self, name): + """ Attempt a SoftLayer API call. + + Use this as a catch-all so users can call SoftLayer API methods + directly against their client object. If the property or method + relating to their client object doesn't exist then assume the user is + attempting a SoftLayer API call and return a simple function that makes + an XML-RPC call. + + :param name: method name + + .. deprecated:: 2.0.0 + + """ + warn("deprecated", DeprecationWarning) + if name in ["__name__", "__bases__"]: + raise AttributeError("'Obj' object has no attribute '%s'" % name) + + def call_handler(*args, **kwargs): + if self._service_name is None: + raise SoftLayerError( + "Service is not set on Client instance.") + kwargs['headers'] = self._headers + return self.call(self._service_name, name, *args, **kwargs) + return call_handler + + def add_raw_header(self, name, value): + """ Set HTTP headers for API calls. + + :param name: the header name + :param value: the header value + + .. deprecated:: 2.0.0 + + """ + warn("deprecated", DeprecationWarning) + self._raw_headers[name] = value + + def add_header(self, name, value): + """ Set a SoftLayer API call header. + + :param name: the header name + :param value: the header value + + .. deprecated:: 2.0.0 + + """ + warn("deprecated", DeprecationWarning) + name = name.strip() + if name is None or name == '': + raise SoftLayerError('Please specify a header name.') + + self._headers[name] = value + + def remove_header(self, name): + """ Remove a SoftLayer API call header. + + :param name: the header name + + .. deprecated:: 2.0.0 + + """ + warn("deprecated", DeprecationWarning) + if name in self._headers: + del self._headers[name.strip()] + + def set_authentication(self, username, api_key): + """ Set user and key to authenticate a SoftLayer API call. + + Use this method if you wish to bypass the API_USER and API_KEY class + constants and set custom authentication per API call. + + See https://manage.softlayer.com/Administrative/apiKeychain for more + information. + + :param username: the username to authenticate with + :param api_key: the user's API key + + .. deprecated:: 2.0.0 + + """ + warn("deprecated", DeprecationWarning) + self.add_header('authenticate', { + 'username': username.strip(), + 'apiKey': api_key.strip(), + }) + + def set_init_parameter(self, id): + """ Set an initialization parameter header. + + Initialization parameters instantiate a SoftLayer API service object to + act upon during your API method call. For instance, if your account has + a server with ID number 1234, then setting an initialization parameter + of 1234 in the SoftLayer_Hardware_Server Service instructs the API to + act on server record 1234 in your method calls. + + See http://sldn.softlayer.com/article/Using-Initialization-Parameters-SoftLayer-API # NOQA + for more information. + + :param id: the ID of the SoftLayer API object to instantiate + + .. deprecated:: 2.0.0 + + """ + warn("deprecated", DeprecationWarning) + self.add_header(self._service_name + 'InitParameters', { + 'id': int(id) + }) + + def set_object_mask(self, mask): + """ Set an object mask to a SoftLayer API call. + + Use an object mask to retrieve data related your API call's result. + Object masks are skeleton objects, or strings that define nested + relational properties to retrieve along with an object's local + properties. See + http://sldn.softlayer.com/article/Using-Object-Masks-SoftLayer-API + for more information. + + :param mask: the object mask you wish to define + + .. deprecated:: 2.0.0 + + """ + warn("deprecated", DeprecationWarning) + header = 'SoftLayer_ObjectMask' + + if isinstance(mask, dict): + header = '%sObjectMask' % self._service_name + + self.add_header(header, {'mask': mask}) + + def set_result_limit(self, limit, offset=0): + """ Set a result limit on a SoftLayer API call. + + Many SoftLayer API methods return a group of results. These methods + support a way to limit the number of results retrieved from the + SoftLayer API in a way akin to an SQL LIMIT statement. + + :param limit: the number of results to limit a SoftLayer API call to + :param offset: An optional offset at which to begin a SoftLayer API + call's returned result + + .. deprecated:: 2.0.0 + + """ + warn("deprecated", DeprecationWarning) + self.add_header('resultLimit', { + 'limit': int(limit), + 'offset': int(offset) + }) diff --git a/SoftLayer/exceptions.py b/SoftLayer/exceptions.py index 72d82f23c..3a24bfe95 100644 --- a/SoftLayer/exceptions.py +++ b/SoftLayer/exceptions.py @@ -12,6 +12,10 @@ class SoftLayerError(StandardError): " The base SoftLayer error. " +class Unauthenticated(StandardError): + " Unauthenticated " + + class SoftLayerAPIError(SoftLayerError): """ SoftLayerAPIError is an exception raised whenever an error is returned from the API. @@ -78,3 +82,7 @@ class InvalidMethodParameters(ServerError): class InternalError(ServerError): pass + + +class DNSZoneNotFound(SoftLayerError): + pass diff --git a/SoftLayer/managers/__init__.py b/SoftLayer/managers/__init__.py new file mode 100644 index 000000000..72278d4f5 --- /dev/null +++ b/SoftLayer/managers/__init__.py @@ -0,0 +1,20 @@ +""" + SoftLayer.managers + ~~~~~~~~~~~~~~~~~~ + Managers mask out a lot of the complexities of using the API into classes + that provide a simpler interface to various services. These are + higher-level interfaces to the SoftLayer API. + + :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. + :license: BSD, see LICENSE for more details. +""" +from SoftLayer.managers.cci import CCIManager +from SoftLayer.managers.dns import DNSManager +from SoftLayer.managers.firewall import FirewallManager +from SoftLayer.managers.hardware import HardwareManager +from SoftLayer.managers.messaging import MessagingManager +from SoftLayer.managers.metadata import MetadataManager +from SoftLayer.managers.ssl import SSLManager + +__all__ = ['CCIManager', 'DNSManager', 'FirewallManager', 'HardwareManager', + 'MessagingManager', 'MetadataManager', 'SSLManager'] diff --git a/SoftLayer/CCI.py b/SoftLayer/managers/cci.py similarity index 86% rename from SoftLayer/CCI.py rename to SoftLayer/managers/cci.py index bccb10ac0..54a5b571f 100644 --- a/SoftLayer/CCI.py +++ b/SoftLayer/managers/cci.py @@ -7,21 +7,10 @@ :license: BSD, see LICENSE for more details. """ import socket - -from SoftLayer.exceptions import SoftLayerError -from SoftLayer.utils import NestedDict, query_filter, IdentifierMixin from time import sleep from itertools import repeat - -class CCICreateMissingRequired(SoftLayerError): - def __init__(self): - self.message = "cpu, memory, hostname, and domain are required" - - -class CCICreateMutuallyExclusive(SoftLayerError): - def __init__(self, *args): - self.message = "Can only specify one of:", ','.join(args) +from SoftLayer.utils import NestedDict, query_filter, IdentifierMixin class CCIManager(IdentifierMixin, object): @@ -57,6 +46,8 @@ def list_instances(self, hourly=True, monthly=True, tags=None, cpus=None, items = set([ 'id', 'globalIdentifier', + 'hostname', + 'domain', 'fullyQualifiedDomainName', 'primaryBackendIpAddress', 'primaryIpAddress', @@ -67,7 +58,6 @@ def list_instances(self, hourly=True, monthly=True, tags=None, cpus=None, 'datacenter.name', 'activeTransaction.transactionStatus[friendlyName,name]', 'status.name', - 'tagReferences[id,tag[name,id]]', ]) kwargs['mask'] = "mask[%s]" % ','.join(items) @@ -143,6 +133,8 @@ def get_instance(self, id, **kwargs): 'privateNetworkOnlyFlag', 'primaryBackendIpAddress', 'primaryIpAddress', + 'networkComponents[id, status, speed, maxSpeed, name,' + 'macAddress, primaryIpAddress, port, primarySubnet]', 'lastKnownPowerState.name', 'powerState.name', 'maxCpu', @@ -173,20 +165,31 @@ def cancel_instance(self, id): """ return self.guest.deleteObject(id=id) - def reload_instance(self, id): + def reload_instance(self, id, post_uri=None): """ Perform an OS reload of an instance with its current configuration. :param integer id: the instance ID to reload + :param string post_url: The URI of the post-install script to run + after reload """ - return self.guest.reloadCurrentOperatingSystemConfiguration(id=id) + payload = { + 'token': 'FORCE', + 'config': {}, + } + + if post_uri: + payload['config']['customProvisionScriptUri'] = post_uri + + return self.guest.reloadOperatingSystem('FORCE', payload['config'], + id=id) def _generate_create_dict( - self, cpus=None, memory=None, hourly=True, - hostname=None, domain=None, local_disk=True, - datacenter=None, os_code=None, image_id=None, - private=False, public_vlan=None, private_vlan=None, - userdata=None, nic_speed=None, disks=None): + self, cpus=None, memory=None, hourly=True, + hostname=None, domain=None, local_disk=True, + datacenter=None, os_code=None, image_id=None, + private=False, public_vlan=None, private_vlan=None, + userdata=None, nic_speed=None, disks=None, post_uri=None): required = [cpus, memory, hostname, domain] @@ -195,11 +198,12 @@ def _generate_create_dict( ] if not all(required): - raise CCICreateMissingRequired() + raise ValueError("cpu, memory, hostname, and domain are required") for me in mutually_exclusive: if all(me.values()): - raise CCICreateMutuallyExclusive(*me.keys()) + raise ValueError( + 'Can only specify one of: %s' % (','.join(me.keys()))) data = { "startCpus": int(cpus), @@ -253,6 +257,9 @@ def _generate_create_dict( } ) + if post_uri: + data['postInstallScriptUri'] = post_uri + return data def wait_for_transaction(self, id, limit, delay=1): @@ -277,6 +284,14 @@ def create_instance(self, **kwargs): create_options = self._generate_create_dict(**kwargs) return self.guest.createObject(create_options) + def change_port_speed(self, id, public, speed): + if public: + func = self.guest.setPublicNetworkInterfaceSpeed + else: + func = self.guest.setPrivateNetworkInterfaceSpeed + + return func(speed, id=id) + def _get_ids_from_hostname(self, hostname): results = self.list_instances(hostname=hostname, mask="id") return [result['id'] for result in results] diff --git a/SoftLayer/DNS.py b/SoftLayer/managers/dns.py similarity index 59% rename from SoftLayer/DNS.py rename to SoftLayer/managers/dns.py index 8bb1fcb6e..a98023934 100644 --- a/SoftLayer/DNS.py +++ b/SoftLayer/managers/dns.py @@ -7,17 +7,12 @@ :license: BSD, see LICENSE for more details. """ from time import strftime -from SoftLayer.exceptions import SoftLayerError +from SoftLayer.exceptions import DNSZoneNotFound +from SoftLayer.utils import NestedDict, query_filter, IdentifierMixin -__all__ = ["DNSZoneNotFound", "DNSManager"] - -class DNSZoneNotFound(SoftLayerError): - pass - - -class DNSManager(object): +class DNSManager(IdentifierMixin, object): """ Manage DNS zones. """ def __init__(self, client): @@ -29,6 +24,12 @@ def __init__(self, client): self.client = client self.service = self.client['Dns_Domain'] self.record = self.client['Dns_Domain_ResourceRecord'] + self.resolvers = [self._get_zone_id_from_name] + + def _get_zone_id_from_name(self, name): + results = self.client['Account'].getDomains( + filter={"domains": {"name": query_filter(name)}}) + return [x['id'] for x in results] def list_zones(self, **kwargs): """ Retrieve a list of all DNS zones. @@ -38,22 +39,21 @@ def list_zones(self, **kwargs): """ return self.client['Account'].getDomains(**kwargs) - def get_zone(self, zone): + def get_zone(self, zone_id, records=True): """ Get a zone and its records. :param zone: the zone name """ - zone = zone.lower() - results = self.service.getByDomainName( - zone, - mask={'resourceRecords': {}}) - matches = filter(lambda x: x['name'].lower() == zone, results) + mask = None + if records: + mask = 'resourceRecords' + results = self.service.getObject(id=zone_id, mask=mask) try: - return matches[0] + return results[0] except IndexError: - raise DNSZoneNotFound(zone) + raise DNSZoneNotFound(zone_id) def create_zone(self, zone, serial=None): """ Create a zone for the specified zone. @@ -85,7 +85,7 @@ def edit_zone(self, zone): """ self.service.editObject(zone) - def create_record(self, id, record, type, data, ttl=60): + def create_record(self, zone_id, record, type, data, ttl=60): """ Create a resource record on a domain. :param integer id: the zone's ID @@ -96,32 +96,54 @@ def create_record(self, id, record, type, data, ttl=60): """ self.record.createObject({ - 'domainId': id, + 'domainId': zone_id, 'ttl': ttl, 'host': record, 'type': type, 'data': data}) - def delete_record(self, recordid): + def delete_record(self, record_id): """ Delete a resource record by its ID. :param integer id: the record's ID """ - self.record.deleteObject(id=recordid) + self.record.deleteObject(id=record_id) - def search_record(self, zone, record): - """ Search for records on a zone that match a specific name. - Useful for validating whether a record exists or that it has the - correct value. + def get_records(self, zone_id, ttl=None, data=None, host=None, + type=None, **kwargs): + """ List, and optionally filter, records within a zone. :param zone: the zone name in which to search. - :param record: the record name to search for + :param int ttl: optionally, time in seconds: + :param data: optionally, the records data + :param host: optionally, record's host + :param type: optionally, the type of record: + :returns list: """ - rrs = self.get_zone(zone)['resourceRecords'] - records = filter(lambda x: x['host'].lower() == record.lower(), rrs) - return records + _filter = NestedDict() + + if ttl: + _filter['resourceRecords']['ttl'] = query_filter(ttl) + + if host: + _filter['resourceRecords']['host'] = query_filter(host) + + if data: + _filter['resourceRecords']['data'] = query_filter(data) + + if type: + _filter['resourceRecords']['type'] = query_filter(type.lower()) + + results = self.service.getResourceRecords( + id=zone_id, + mask='id,expire,domainId,host,minimum,refresh,retry,' + 'mxPriority,ttl,type,data,responsiblePerson', + filter=_filter.to_dict(), + ) + + return results def edit_record(self, record): """ Update an existing record with the options provided. The provided @@ -133,10 +155,10 @@ def edit_record(self, record): """ self.record.editObject(record, id=record['id']) - def dump_zone(self, id): + def dump_zone(self, zone_id): """ Retrieve a zone dump in BIND format. :param integer id: The zone ID to dump """ - return self.service.getZoneFileContents(id=id) + return self.service.getZoneFileContents(id=zone_id) diff --git a/SoftLayer/firewall.py b/SoftLayer/managers/firewall.py similarity index 55% rename from SoftLayer/firewall.py rename to SoftLayer/managers/firewall.py index cd59b17c2..626605dd0 100644 --- a/SoftLayer/firewall.py +++ b/SoftLayer/managers/firewall.py @@ -6,7 +6,6 @@ :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. :license: BSD, see LICENSE for more details. """ -__all__ = ['FirewallManager'] def has_firewall(vlan): @@ -29,16 +28,18 @@ def __init__(self, client): self.client = client def get_firewalls(self): - results = filter(has_firewall, self.client['Account'].getObject( - mask={'networkVlans': { - 'firewallNetworkComponents': None, - 'networkVlanFirewall': None, - 'dedicatedFirewallFlag': None, - 'firewallGuestNetworkComponents': None, - 'firewallInterfaces': {}, - 'firewallRules': None, - 'highAvailabilityFirewallFlag': None, - #'primarySubnet': None, - }})['networkVlans']) - - return results + results = self.client['Account'].getObject( + mask={ + 'networkVlans': { + 'firewallNetworkComponents': None, + 'networkVlanFirewall': None, + 'dedicatedFirewallFlag': None, + 'firewallGuestNetworkComponents': None, + 'firewallInterfaces': {}, + 'firewallRules': None, + 'highAvailabilityFirewallFlag': None, + #'primarySubnet': None, + } + })['networkVlans'] + + return filter(has_firewall, results) diff --git a/SoftLayer/managers/hardware.py b/SoftLayer/managers/hardware.py new file mode 100644 index 000000000..d51fdfb56 --- /dev/null +++ b/SoftLayer/managers/hardware.py @@ -0,0 +1,492 @@ +""" + SoftLayer.hardware + ~~~~~~~~~~~~~~~~~~ + Hardware Manager/helpers + + :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. + :license: BSD, see LICENSE for more details. +""" + +import socket +from SoftLayer.utils import NestedDict, query_filter, IdentifierMixin + + +class HardwareManager(IdentifierMixin, object): + """ Manages hardware devices. """ + + def __init__(self, client): + """ HardwareManager initialization. + + :param SoftLayer.API.Client client: an API client instance + + """ + self.client = client + self.hardware = self.client['Hardware_Server'] + self.account = self.client['Account'] + self.resolvers = [self._get_ids_from_ip, self._get_ids_from_hostname] + + def cancel_hardware(self, id, reason='unneeded', comment=''): + """ Cancels the specified dedicated server. + + :param int id: The ID of the hardware to be cancelled. + :param bool immediate: If true, the hardware will be cancelled + immediately. Otherwise, it will be + scheduled to cancel on the anniversary date. + :param string reason: The reason code for the cancellation. + """ + + reasons = self.get_cancellation_reasons() + cancel_reason = reasons['unneeded'] + + if reason in reasons: + cancel_reason = reasons[reason] + + # Arguments per SLDN: + # attachmentId - Hardware ID + # Reason + # content - Comment about the cancellation + # cancelAssociatedItems + # attachmentType - Only option is HARDWARE + ticket_obj = self.client['Ticket'] + return ticket_obj.createCancelServerTicket(id, cancel_reason, + comment, True, + 'HARDWARE') + + def cancel_metal(self, id, immediate=False): + """ Cancels the specified bare metal instance. + + :param int id: The ID of the bare metal instance to be cancelled. + :param bool immediate: If true, the bare metal instance will be + cancelled immediately. Otherwise, it will be + scheduled to cancel on the anniversary date. + """ + hw_billing = self.get_hardware(id=id, + mask='mask[id, billingItem.id]') + + billing_id = hw_billing['billingItem']['id'] + + billing_item = self.client['Billing_Item'] + + if immediate: + return billing_item.cancelService(id=billing_id) + else: + return billing_item.cancelServiceOnAnniversaryDate(id=billing_id) + + def list_hardware(self, tags=None, cpus=None, memory=None, hostname=None, + domain=None, datacenter=None, nic_speed=None, + public_ip=None, private_ip=None, **kwargs): + """ List all hardware. + + :param list tags: filter based on tags + :param integer cpus: filter based on number of CPUS + :param integer memory: filter based on amount of memory in gigabytes + :param string hostname: filter based on hostname + :param string domain: filter based on domain + :param string datacenter: filter based on datacenter + :param integer nic_speed: filter based on network speed (in MBPS) + :param string public_ip: filter based on public ip address + :param string private_ip: filter based on private ip address + :param dict \*\*kwargs: response-level arguments (limit, offset, etc.) + + """ + if 'mask' not in kwargs: + items = set([ + 'id', + 'hostname', + 'domain', + 'hardwareStatusId', + 'globalIdentifier', + 'fullyQualifiedDomainName', + 'processorCoreAmount', + 'memoryCapacity', + 'primaryBackendIpAddress', + 'primaryIpAddress', + 'datacenter.name', + ]) + kwargs['mask'] = "mask[%s]" % ','.join(items) + + _filter = NestedDict(kwargs.get('filter') or {}) + if tags: + _filter['hardware']['tagReferences']['tag']['name'] = { + 'operation': 'in', + 'options': [{'name': 'data', 'value': tags}], + } + + if cpus: + _filter['hardware']['processorCoreAmount'] = query_filter(cpus) + + if memory: + _filter['hardware']['memoryCapacity'] = query_filter(memory) + + if hostname: + _filter['hardware']['hostname'] = query_filter(hostname) + + if domain: + _filter['hardware']['domain'] = query_filter(domain) + + if datacenter: + _filter['hardware']['datacenter']['name'] = \ + query_filter(datacenter) + + if nic_speed: + _filter['hardware']['networkComponents']['maxSpeed'] = \ + query_filter(nic_speed) + + if public_ip: + _filter['hardware']['primaryIpAddress'] = \ + query_filter(public_ip) + + if private_ip: + _filter['hardware']['primaryBackendIpAddress'] = \ + query_filter(private_ip) + + kwargs['filter'] = _filter.to_dict() + return self.account.getHardware(**kwargs) + + def get_bare_metal_create_options(self): + """ Retrieves the available options for creating a bare metal server. + + The information for ordering bare metal instances comes from multiple + API calls. In order to make the process easier, this function will + make those calls and reformat the results into a dictionary that's + easier to manage. It's recommended that you cache these results with a + reasonable lifetime for performance reasons. + """ + hw_id = self._get_bare_metal_package_id() + + if not hw_id: + return None + + return self._parse_package_data(hw_id) + + def get_available_dedicated_server_packages(self): + """ Retrieves a list of packages that are available for ordering + dedicated servers. + + Note - This currently returns a hard coded list until the API is + updated to allow filtering on packages to just those for ordering + servers. + """ + package_ids = [13, 15, 23, 25, 26, 27, 29, 32, 41, 42, 43, 44, 49, 51, + 52, 53, 54, 55, 56, 57, 126, 140, 141, 142, 143, 144, + 145, 146, 147, 148, 158] + + package_obj = self.client['Product_Package'] + packages = [] + + for package_id in package_ids: + package = package_obj.getObject(id=package_id, + mask='mask[id, name, description]') + + if (package.get('name')): + packages.append((package['id'], package['name'], + package['description'])) + + return packages + + def get_dedicated_server_create_options(self, package_id): + """ Retrieves the available options for creating a dedicated server in + a specific chassis (based on package ID). + + The information for ordering dedicated servers comes from multiple + API calls. In order to make the process easier, this function will + make those calls and reformat the results into a dictionary that's + easier to manage. It's recommended that you cache these results with a + reasonable lifetime for performance reasons. + """ + return self._parse_package_data(package_id) + + def get_hardware(self, id, **kwargs): + """ Get details about a hardware device + + :param integer id: the hardware ID + + """ + + if 'mask' not in kwargs: + items = set([ + 'id', + 'globalIdentifier', + 'fullyQualifiedDomainName', + 'hostname', + 'domain', + 'provisionDate', + 'hardwareStatus', + 'processorCoreAmount', + 'memoryCapacity', + 'notes', + 'primaryBackendIpAddress', + 'primaryIpAddress', + 'datacenter.name', + 'networkComponents[id, status, speed, maxSpeed, name,' + 'ipmiMacAddress, ipmiIpAddress, macAddress, primaryIpAddress,' + 'port, primarySubnet]', + 'networkComponents.primarySubnet[id, netmask,' + 'broadcastAddress, networkIdentifier, gateway]', + 'activeTransaction.id', + 'operatingSystem.softwareLicense.' + 'softwareDescription[manufacturer,name,version,referenceCode]', + 'operatingSystem.passwords[username,password]', + 'billingItem.recurringFee', + 'tagReferences[id,tag[name,id]]', + ]) + kwargs['mask'] = "mask[%s]" % ','.join(items) + + return self.hardware.getObject(id=id, **kwargs) + + def reload(self, id, post_uri=None): + """ Perform an OS reload of a server with its current configuration. + + :param integer id: the instance ID to reload + :param string post_url: The URI of the post-install script to run + after reload + + """ + + payload = { + 'token': 'FORCE', + 'config': {}, + } + + if post_uri: + payload['config']['customProvisionScriptUri'] = post_uri + + return self.hardware.reloadOperatingSystem('FORCE', payload['config'], + id=id) + + def change_port_speed(self, id, public, speed): + """ Allows you to change the port speed of a server's NICs. + + :param int id: The ID of the server + :param bool public: Flag to indicate which interface to change. + True (default) means the public interface. + False indicates the private interface. + :param int speed: The port speed to set. + """ + if public: + func = self.hardware.setPublicNetworkInterfaceSpeed + else: + func = self.hardware.setPrivateNetworkInterfaceSpeed + + return func(speed, id=id) + + def place_order(self, **kwargs): + """ Places an order for a piece of hardware. See _generate_create_dict + for a list of available options. + """ + create_options = self._generate_create_dict(**kwargs) + return self.client['Product_Order'].placeOrder(create_options) + + def verify_order(self, **kwargs): + """ Verifies an order for a piece of hardware without actually placing + it. See _generate_create_dict for a list of available options. + """ + create_options = self._generate_create_dict(**kwargs) + return self.client['Product_Order'].verifyOrder(create_options) + + def get_cancellation_reasons(self): + return { + 'unneeded': 'No longer needed', + 'closing': 'Business closing down', + 'cost': 'Server / Upgrade Costs', + 'migrate_larger': 'Migrating to larger server', + 'migrate_smaller': 'Migrating to smaller server', + 'datacenter': 'Migrating to a different SoftLayer datacenter', + 'performance': 'Network performance / latency', + 'support': 'Support response / timing', + 'sales': 'Sales process / upgrades', + 'moving': 'Moving to competitor', + } + + def _generate_create_dict( + self, server=None, hostname=None, domain=None, hourly=False, + location=None, os=None, disks=None, port_speed=None, + bare_metal=None, ram=None, package_id=None, disk_controller=None): + """ + Translates a list of arguments into a dictionary necessary for creating + a server. NOTE - All items here must be price IDs, NOT quantities! + + :param string server: The identification string for the server to + order. This will either be the CPU/Memory + combination ID for bare metal instances or the + CPU model for dedicated servers. + :param string hostname: The hostname to use for the new server. + :param string domain: The domain to use for the new server. + :param bool hourly: Flag to indicate if this server should be billed + hourly (default) or monthly. Only applies to bare + metal instances. + :param string location: The location string (data center) for the + server + :param int os: The operating system to use + :param array disks: An array of disks for the server. Disks will be + added in the order specified. + :param int port_speed: The port speed for the server. + :param bool bare_metal: Flag to indicate if this is a bare metal server + or a dedicated server (default). + :param int ram: The amount of RAM to order. Only applies to dedicated + servers. + :param int package_id: The package_id to use for the server. This + should either be a chassis ID for dedicated + servers or the bare metal instance package ID, + which can be obtained by calling + _get_bare_metal_package_id + :param int disk_controller: The disk controller to use. + """ + arguments = ['server', 'hostname', 'domain', 'location', 'os', 'disks', + 'port_speed', 'bare_metal', 'ram', 'package_id', + 'disk_controller', 'server_core', 'disk0'] + + order = { + 'hardware': [{ + 'bareMetalInstanceFlag': bare_metal, + 'hostname': hostname, + 'domain': domain, + }], + 'location': location, + 'prices': [ + ], + } + + if bare_metal: + order['packageId'] = self._get_bare_metal_package_id() + order['prices'].append({'id': int(server)}) + p_options = self.get_bare_metal_create_options() + if hourly: + order['hourlyBillingFlag'] = True + else: + order['packageId'] = package_id + order['prices'].append({'id': int(server)}) + p_options = self.get_dedicated_server_create_options(package_id) + + if disks: + for disk in disks: + order['prices'].append({'id': int(disk)}) + + if os: + order['prices'].append({'id': int(os)}) + + if port_speed: + order['prices'].append({'id': int(port_speed)}) + + if ram: + order['prices'].append({'id': int(ram)}) + + if disk_controller: + order['prices'].append({'id': int(disk_controller)}) + + # Find all remaining required categories so we can auto-default them + required_fields = [] + for category, data in p_options['categories'].iteritems(): + if data.get('is_required') and category not in arguments: + required_fields.append(category) + + for category in required_fields: + price = get_default_value(p_options, category) + order['prices'].append({'id': price}) + + return order + + def _get_bare_metal_package_id(self): + packages = self.client['Product_Package'].getAllObjects( + mask='mask[id, name]', + filter={'name': query_filter('Bare Metal Instance')}) + + hw_id = 0 + for package in packages: + if 'Bare Metal Instance' == package['name']: + hw_id = package['id'] + break + + return hw_id + + def _get_ids_from_hostname(self, hostname): + results = self.list_hardware(hostname=hostname, mask="id") + return [result['id'] for result in results] + + def _get_ids_from_ip(self, ip): + try: + # Does it look like an ip address? + socket.inet_aton(ip) + except socket.error: + return [] + + # Find the CCI via ip address. First try public ip, then private + results = self.list_hardware(public_ip=ip, mask="id") + if results: + return [result['id'] for result in results] + + results = self.list_hardware(private_ip=ip, mask="id") + if results: + return [result['id'] for result in results] + + def _parse_package_data(self, id): + package = self.client['Product_Package'] + + results = { + 'categories': {}, + 'locations': [] + } + + # First pull the list of available locations. We do it with the + # getObject() call so that we get access to the delivery time info. + object_data = package.getRegions(id=id) + + for loc in object_data: + details = loc['location']['locationPackageDetails'][0] + + results['locations'].append({ + 'delivery_information': details.get('deliveryTimeInformation'), + 'keyname': loc['keyname'], + 'long_name': loc['description'], + }) + + mask = 'mask[itemCategory[group]]' + + for config in package.getConfiguration(id=id, mask=mask): + code = config['itemCategory']['categoryCode'] + group = NestedDict(config['itemCategory']) or {} + category = { + 'sort': config['sort'], + 'step': config['orderStepId'], + 'is_required': config['isRequired'], + 'name': config['itemCategory']['name'], + 'group': group['group']['name'], + 'items': [], + } + + results['categories'][code] = category + + # Now pull in the available package item + for item in package.getItems(id=id, mask='mask[itemCategory]'): + category_code = item['itemCategory']['categoryCode'] + + if category_code not in results['categories']: + results['categories'][category_code] = {'name': category_code, + 'items': []} + results['categories'][category_code]['items'].append({ + 'id': item['id'], + 'description': item['description'], + 'prices': item['prices'], + 'sort': item['prices'][0]['sort'], + 'price_id': item['prices'][0]['id'], + 'recurring_fee': float(item['prices'][0].get('recurringFee', + 0)), + 'capacity': float(item.get('capacity', 0)), + }) + + return results + + +def get_default_value(package_options, category): + if category not in package_options['categories']: + return + + for item in package_options['categories'][category]['items']: + if not any([ + float(item['prices'][0].get('setupFee', 0)), + float(item['prices'][0].get('recurringFee', 0)), + float(item['prices'][0].get('hourlyRecurringFee', 0)), + float(item['prices'][0].get('oneTimeFee', 0)), + float(item['prices'][0].get('laborFee', 0)), + ]): + return item['price_id'] diff --git a/SoftLayer/managers/messaging.py b/SoftLayer/managers/messaging.py new file mode 100644 index 000000000..ad5d7e9be --- /dev/null +++ b/SoftLayer/managers/messaging.py @@ -0,0 +1,359 @@ +import json +import requests.auth + +from SoftLayer.consts import USER_AGENT +from SoftLayer.exceptions import Unauthenticated + +ENDPOINTS = { + "dal05": { + "public": "dal05.mq.softlayer.net", + "private": "dal05.mq.service.networklayer.com" + } +} + + +class QueueAuth(requests.auth.AuthBase): + """ Message Queue authentication for requests + + :param endpoint: endpoint URL + :param username: SoftLayer username + :param api_key: SoftLayer API Key + :param auth_token: (optional) Starting auth token + """ + def __init__(self, endpoint, username, api_key, auth_token=None): + self.endpoint = endpoint + self.username = username + self.api_key = api_key + self.auth_token = auth_token + + def auth(self): + """ Do Authentication """ + headers = { + 'X-Auth-User': self.username, + 'X-Auth-Key': self.api_key + } + resp = requests.post(self.endpoint, headers=headers) + if resp.ok: + self.auth_token = resp.headers['X-Auth-Token'] + else: + raise Unauthenticated("Error while authenticating", resp) + + def handle_error(self, r, **kwargs): + """ Handle errors """ + r.request.deregister_hook('response', self.handle_error) + if r.status_code == 503: + r.request.send(anyway=True) + elif r.status_code == 401: + self.auth() + r.request.headers['X-Auth-Token'] = self.auth_token + r.request.send(anyway=True) + + def __call__(self, r): + """ Attach auth token to the request. Do authentication if an auth + token isn't available + """ + if not self.auth_token: + self.auth() + r.register_hook('response', self.handle_error) + r.headers['X-Auth-Token'] = self.auth_token + return r + + +class MessagingManager(object): + """ Manage SoftLayer Message Queue """ + def __init__(self, client): + self.client = client + + def list_accounts(self, **kwargs): + """ List message queue accounts + + :param dict \*\*kwargs: response-level arguments (limit, offset, etc.) + """ + if 'mask' not in kwargs: + items = set([ + 'id', + 'name', + 'status', + 'nodes', + ]) + kwargs['mask'] = "mask[%s]" % ','.join(items) + + return self.client['Account'].getMessageQueueAccounts(**kwargs) + + def get_endpoint(self, datacenter=None, network=None): + """ Get a message queue endpoint based on datacenter/network type + + :param datacenter: datacenter code + :param network: network ('public' or 'private') + """ + if datacenter is None: + datacenter = 'dal05' + if network is None: + network = 'public' + try: + host = ENDPOINTS[datacenter][network] + return "https://%s" % host + except KeyError: + raise TypeError('Invalid endpoint %s/%s' + % (datacenter, network)) + + def get_endpoints(self): + """ Get all known message queue endpoints """ + return ENDPOINTS + + def get_connection(self, id, username, api_key, datacenter=None, + network=None): + """ Get connection to Message Queue Service + + :param id: Message Queue Account id + :param username: SoftLayer username + :param api_key: SoftLayer API key + :param datacenter: Datacenter code + :param network: network ('public' or 'private') + """ + client = MessagingConnection( + id, endpoint=self.get_endpoint(datacenter, network)) + client.authenticate(username, api_key) + return client + + def ping(self, datacenter=None, network=None): + r = requests.get('%s/v1/ping' % + self.get_endpoint(datacenter, network)) + r.raise_for_status() + return True + + +class MessagingConnection(object): + """ Message Queue Service Connection + + :param id: Message Queue Account id + :param endpoint: Endpoint URL + """ + def __init__(self, id, endpoint=None): + self.account_id = id + self.endpoint = endpoint + self.auth = None + + def _make_request(self, method, path, **kwargs): + """ Make request. Generally not called directly + + :param method: HTTP Method + :param path: resource Path + :param dict \*\*kwargs: extra request arguments + """ + headers = { + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, + } + headers.update(kwargs.get('headers', {})) + kwargs['headers'] = headers + kwargs['auth'] = self.auth + + url = '/'.join((self.endpoint, 'v1', self.account_id, path)) + r = requests.request(method, url, **kwargs) + r.raise_for_status() + return r + + def authenticate(self, username, api_key, auth_token=None): + """ Make request. Generally not called directly + + :param username: SoftLayer username + :param api_key: SoftLayer API Key + :param auth_token: (optional) Starting auth token + """ + auth_endpoint = '/'.join((self.endpoint, 'v1', self.account_id, 'auth')) + auth = QueueAuth(auth_endpoint, username, api_key, + auth_token=auth_token) + auth.auth() + self.auth = auth + + def stats(self, period='hour'): + """ Get account stats + + :param period: 'hour', 'day', 'week', 'month' + """ + r = self._make_request('get', 'stats/%s' % period) + return json.loads(r.content) + + # QUEUE METHODS + + def get_queues(self, tags=None): + """ Get listing of queues + + :param list tags: (optional) list of tags to filter by + """ + params = {} + if tags: + params['tags'] = ','.join(tags) + r = self._make_request('get', 'queues', params=params) + return json.loads(r.content) + + def create_queue(self, queue_name, **kwargs): + """ Create Queue + + :param queue_name: Queue Name + :param dict \*\*kwargs: queue options + """ + queue = {} + queue.update(kwargs) + data = json.dumps(queue) + r = self._make_request('put', 'queues/%s' % queue_name, data=data) + return json.loads(r.content) + + def modify_queue(self, queue_name, **kwargs): + """ Modify Queue + + :param queue_name: Queue Name + :param dict \*\*kwargs: queue options + """ + return self.create_queue(queue_name, **kwargs) + + def get_queue(self, queue_name): + """ Get queue details + + :param queue_name: Queue Name + """ + r = self._make_request('get', 'queues/%s' % queue_name) + return json.loads(r.content) + + def delete_queue(self, queue_name, force=False): + """ Delete Queue + + :param queue_name: Queue Name + :param force: (optional) Force queue to be deleted even if there + are pending messages + """ + params = {} + if force: + params['force'] = 1 + self._make_request('delete', 'queues/%s' % queue_name, params=params) + return True + + def push_queue_message(self, queue_name, body, **kwargs): + """ Create Queue Message + + :param queue_name: Queue Name + :param body: Message body + :param dict \*\*kwargs: Message options + """ + message = {'body': body} + message.update(kwargs) + r = self._make_request('post', 'queues/%s/messages' % queue_name, + data=json.dumps(message)) + return json.loads(r.content) + + def pop_message(self, queue_name, count=1): + """ Pop message from a queue + + :param queue_name: Queue Name + :param count: (optional) number of messages to retrieve + """ + r = self._make_request('get', 'queues/%s/messages' % queue_name, + params={'batch': count}) + return json.loads(r.content) + + def delete_message(self, queue_name, message_id): + """ Delete a message + + :param queue_name: Queue Name + :param message_id: Message id + """ + self._make_request('delete', 'queues/%s/messages/%s' + % (queue_name, message_id)) + return True + + # TOPIC METHODS + + def get_topics(self, tags=None): + """ Get listing of topics + + :param list tags: (optional) list of tags to filter by + """ + params = {} + if tags: + params['tags'] = ','.join(tags) + r = self._make_request('get', 'topics', params=params) + return json.loads(r.content) + + def create_topic(self, topic_name, **kwargs): + """ Create Topic + + :param topic_name: Topic Name + :param dict \*\*kwargs: Topic options + """ + data = json.dumps(kwargs) + r = self._make_request('put', 'topics/%s' % topic_name, data=data) + return json.loads(r.content) + + def modify_topic(self, topic_name, **kwargs): + """ Modify Topic + + :param topic_name: Topic Name + :param dict \*\*kwargs: Topic options + """ + return self.create_topic(topic_name, **kwargs) + + def get_topic(self, topic_name): + """ Get topic details + + :param topic_name: Topic Name + """ + r = self._make_request('get', 'topics/%s' % topic_name) + return json.loads(r.content) + + def delete_topic(self, topic_name, force=False): + """ Delete Topic + + :param topic_name: Topic Name + :param force: (optional) Force topic to be deleted even if there + are attached subscribers + """ + params = {} + if force: + params['force'] = 1 + self._make_request('delete', 'topics/%s' % topic_name, params=params) + return True + + def push_topic_message(self, topic_name, body, **kwargs): + """ Create Topic Message + + :param topic_name: Topic Name + :param body: Message body + :param dict \*\*kwargs: Topic message options + """ + message = {'body': body} + message.update(kwargs) + r = self._make_request('post', 'topics/%s/messages' % topic_name, + data=json.dumps(message)) + return json.loads(r.content) + + def get_subscriptions(self, topic_name): + """ Listing of subscriptions on a topic + + :param topic_name: Topic Name + """ + r = self._make_request('get', 'topics/%s/subscriptions' % topic_name) + return json.loads(r.content) + + def create_subscription(self, topic_name, type, **kwargs): + """ Create Subscription + + :param topic_name: Topic Name + :param type: type ('queue' or 'http') + :param dict \*\*kwargs: Subscription options + """ + r = self._make_request( + 'post', 'topics/%s/subscriptions' % topic_name, + data=json.dumps({'endpoint_type': type, 'endpoint': kwargs})) + return json.loads(r.content) + + def delete_subscription(self, topic_name, subscription_id): + """ Delete a subscription + + :param topic_name: Topic Name + :param subscription_id: Subscription id + """ + self._make_request('delete', 'topics/%s/subscriptions/%s' % + (topic_name, subscription_id)) + return True + diff --git a/SoftLayer/metadata.py b/SoftLayer/managers/metadata.py similarity index 78% rename from SoftLayer/metadata.py rename to SoftLayer/managers/metadata.py index faede438f..e4b8435be 100644 --- a/SoftLayer/metadata.py +++ b/SoftLayer/managers/metadata.py @@ -6,16 +6,10 @@ :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. :license: BSD, see LICENSE for more details. """ -try: - import simplejson as json -except ImportError: # pragma: no cover - import json # NOQA -import urllib2 - +from SoftLayer.transport import make_rest_api_call from SoftLayer.consts import API_PRIVATE_ENDPOINT_REST, USER_AGENT from SoftLayer.exceptions import SoftLayerAPIError, SoftLayerError -__all__ = ["MetadataManager"] METADATA_MAPPING = { 'backend_mac': {'call': 'BackendMacAddresses'}, @@ -64,26 +58,14 @@ def __init__(self, client=None, timeout=5): def make_request(self, path): url = '/'.join([self.url, 'SoftLayer_Resource_Metadata', path]) - req = urllib2.Request(url) - req.add_header('User-Agent', USER_AGENT) - try: - resp = urllib2.urlopen(req, timeout=self.timeout) - except urllib2.HTTPError, e: # pragma: no cover - if e.code == 404: + return make_rest_api_call('GET', url, + http_headers={'User-Agent': USER_AGENT}, + timeout=self.timeout) + except SoftLayerAPIError, e: + if e.faultCode == 404: return None - - try: - content = json.loads(e.read()) - raise SoftLayerAPIError(content['code'], content['error']) - except (ValueError, KeyError): - pass - - raise SoftLayerAPIError(e.code, e.reason) - except urllib2.URLError, e: - raise SoftLayerAPIError(0, e.reason) - else: - return resp.read() + raise e def get(self, name, param=None): """ Retreive a metadata attribute @@ -104,14 +86,11 @@ def get(self, name, param=None): if not param: raise SoftLayerError( 'Parameter required to get this attribute.') - url = "%s/%s%s" % (self.attribs[name]['call'], param, extension) + path = "%s/%s%s" % (self.attribs[name]['call'], param, extension) else: - url = "%s%s" % (self.attribs[name]['call'], extension) + path = "%s%s" % (self.attribs[name]['call'], extension) - data = self.make_request(url) - if data and extension == '.json': - return json.loads(data) - return data + return self.make_request(path) def _get_network(self, kind, router=True, vlans=True, vlan_ids=True): network = {} diff --git a/SoftLayer/SSL.py b/SoftLayer/managers/ssl.py similarity index 98% rename from SoftLayer/SSL.py rename to SoftLayer/managers/ssl.py index 893cef292..2d4f4b9d3 100644 --- a/SoftLayer/SSL.py +++ b/SoftLayer/managers/ssl.py @@ -6,7 +6,6 @@ :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. :license: BSD, see LICENSE for more details. """ -__all__ = ["SSLManager"] class SSLManager(object): diff --git a/SoftLayer/tests/API/dns_tests.py b/SoftLayer/tests/API/dns_tests.py deleted file mode 100644 index 2de214d18..000000000 --- a/SoftLayer/tests/API/dns_tests.py +++ /dev/null @@ -1,113 +0,0 @@ -""" - SoftLayer.tests.API.dns_tests - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - - :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. - :license: BSD, see LICENSE for more details. -""" -import SoftLayer - -try: - import unittest2 as unittest -except ImportError: - import unittest # NOQA -from mock import MagicMock, ANY - - -class DNSTests(unittest.TestCase): - - def setUp(self): - self.client = MagicMock() - self.dns_client = SoftLayer.DNSManager(self.client) - - def test_init_exercise(self): - self.assertTrue(hasattr(self.dns_client, 'service')) - self.assertTrue(hasattr(self.dns_client, 'record')) - - def test_list_zones(self): - zone_list = ['test'] - self.client.__getitem__().getDomains.return_value = zone_list - zones = self.dns_client.list_zones() - self.assertEqual(zones, zone_list) - - def test_get_zone(self): - zone_list = [ - {'name': 'test-example.com'}, - {'name': 'example.com'}, - ] - - # match - self.client.__getitem__().getByDomainName.return_value = \ - zone_list - res = self.dns_client.get_zone('example.com') - self.assertEqual(res, zone_list[1]) - - # no match - from SoftLayer.DNS import DNSZoneNotFound - self.assertRaises( - DNSZoneNotFound, - self.dns_client.get_zone, - 'shouldnt-match.com') - - def test_create_zone(self): - self.client.__getitem__().createObject.return_value = \ - {'name': 'example.com'} - - res = self.dns_client.create_zone('example.com') - - self.client.__getitem__().createObject.assert_called_once_with( - {'name': 'example.com', "resourceRecords": {}, "serial": ANY}) - - self.assertEqual(res, {'name': 'example.com'}) - - def test_delete_zone(self): - self.dns_client.delete_zone(1) - self.client.__getitem__().deleteObject.assert_called_once_with(id=1) - - def test_edit_zone(self): - self.dns_client.edit_zone('example.com') - self.client.__getitem__().editObject.assert_called_once_with( - 'example.com') - - def test_create_record(self): - self.dns_client.create_record(1, 'test', 'TXT', 'testing', ttl=1200) - - self.client.__getitem__().createObject.assert_called_once_with( - { - 'domainId': 1, - 'ttl': 1200, - 'host': 'test', - 'type': 'TXT', - 'data': 'testing' - }) - - def test_delete_record(self): - self.dns_client.delete_record(1) - self.client.__getitem__().deleteObject.assert_called_once_with(id=1) - - def test_search_record(self): - self.client.__getitem__().getByDomainName.return_value = [{ - 'name': 'example.com', - 'resourceRecords': [ - {'host': 'TEST1'}, - {'host': 'test2'}, - {'host': 'test3'}, - ] - }] - - res = self.dns_client.search_record('example.com', 'test1') - self.assertEqual(res, [{'host': 'TEST1'}]) - - def test_edit_record(self): - self.dns_client.edit_record({'id': 1, 'name': 'test'}) - self.client.__getitem__().editObject.assert_called_once_with( - {'id': 1, 'name': 'test'}, - id=1 - ) - - def test_dump_zone(self): - self.client.__getitem__().getZoneFileContents.return_value = ( - 'lots of text') - self.dns_client.dump_zone(1) - self.client.__getitem__().getZoneFileContents.assert_called_once_with( - id=1) diff --git a/SoftLayer/tests/API/transport_tests.py b/SoftLayer/tests/API/transport_tests.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/SoftLayer/tests/CLI/core_tests.py b/SoftLayer/tests/CLI/core_tests.py index ccd26f010..d1634ebea 100644 --- a/SoftLayer/tests/CLI/core_tests.py +++ b/SoftLayer/tests/CLI/core_tests.py @@ -16,6 +16,7 @@ import SoftLayer import SoftLayer.CLI as cli from SoftLayer.CLI.helpers import CLIAbort +from SoftLayer.CLI.environment import Environment, InvalidModule def module_fixture(): @@ -39,18 +40,32 @@ def execute(client, args): return "test" +class EnvironmentFixture(Environment): + plugins = {'cci': {'list': submodule_fixture}} + aliases = { + 'meta': 'metadata', + 'my': 'metadata', + } + config = {} + + def load_module(self, *args, **kwargs): + return module_fixture + + def plugin_list(self, *args, **kwargs): + return self.plugins.keys() + + class CommandLineTests(unittest.TestCase): def setUp(self): - self.env = MagicMock() - self.env.plugin_list.return_value = ['cci'] - self.env.plugins = {'cci': {'list': submodule_fixture}} - self.env.load_module.return_value = module_fixture + self.env = EnvironmentFixture() + self.env.get_module_name = MagicMock() def test_normal_path(self): self.env.get_module_name.return_value = 'cci' self.assertRaises( SystemExit, cli.core.main, - args=['cci', 'list', '--config=path/to/config'], env=self.env) + args=['cci', 'list', '--config=path/to/config'], + env=self.env) self.assertRaises( SystemExit, cli.core.main, args=['cci', 'nope', '--config=path/to/config'], env=self.env) @@ -58,6 +73,17 @@ def test_normal_path(self): SystemExit, cli.core.main, args=['cci', 'list', '--format=totallynotvalid'], env=self.env) + @patch('logging.getLogger') + @patch('logging.StreamHandler') + def test_with_debug(self, stream_handler, logger): + self.env.get_module_name.return_value = 'cci' + self.assertRaises( + SystemExit, cli.core.main, + args=['cci', 'list', '--debug=3'], + env=self.env) + logger().setLevel.assert_called_with(10) + logger().addHandler.assert_called_with(stream_handler()) + def test_invalid_module(self): self.env.get_module_name.return_value = 'nope' self.assertRaises( @@ -80,11 +106,21 @@ def test_abort(self): self.assertRaises( SystemExit, cli.core.main, args=['cci', 'list'], env=self.env) + def test_invalid_module_error(self): + self.env.get_module_name.side_effect = InvalidModule('cci') + self.assertRaises( + SystemExit, cli.core.main, args=['cci', 'list'], env=self.env) + def test_softlayer_error(self): self.env.get_module_name.side_effect = SoftLayer.SoftLayerError self.assertRaises( SystemExit, cli.core.main, args=['cci', 'list'], env=self.env) + def test_system_exit_error(self): + self.env.get_module_name.side_effect = SystemExit + self.assertRaises( + SystemExit, cli.core.main, args=['cci', 'list'], env=self.env) + def test_value_key_errors(self): self.env.get_module_name.side_effect = ValueError self.assertRaises( @@ -94,60 +130,79 @@ def test_value_key_errors(self): self.assertRaises( KeyError, cli.core.main, args=['cci', 'list'], env=self.env) + @patch('traceback.format_exc') + def test_uncaught_error(self, m): + # Exceptions not caught should just Exit + errors = [TypeError, RuntimeError, NameError, OSError, SystemError] + for err in errors: + m.reset_mock() + m.return_value = 'testing' + self.env.get_module_name.side_effect = err + self.assertRaises( + SystemExit, cli.core.main, args=['cci', 'list'], env=self.env) + m.assert_called_once_with() + + +class TestCommandParser(unittest.TestCase): + def setUp(self): + self.env = EnvironmentFixture() + self.parser = cli.core.CommandParser(self.env) -class TestParseMainArgs(unittest.TestCase): def test_main(self,): - args = cli.core.parse_main_args( + args = self.parser.parse_main_args( args=['cci', 'list']) self.assertEqual(args['help'], False) - self.assertEqual(args[''], 'cci') + self.assertEqual(args[''], 'cci') self.assertEqual(args[''], ['list']) def test_primary_help(self): - args = cli.core.parse_main_args(args=[]) + args = self.parser.parse_main_args(args=[]) self.assertEqual({ '--help': False, '-h': False, '': [], + '': None, '': None, 'help': False, }, args) - args = cli.core.parse_main_args(args=['help']) + args = self.parser.parse_main_args(args=['help']) self.assertEqual({ '--help': False, '-h': False, '': [], - '': 'help', + '': 'help', + '': None, 'help': False, }, args) - args = cli.core.parse_main_args(args=['help', 'module']) + args = self.parser.parse_main_args(args=['help', 'module']) self.assertEqual({ '--help': False, '-h': False, '': ['module'], - '': 'help', + '': 'help', + '': None, 'help': False, }, args) self.assertRaises( - SystemExit, cli.core.parse_main_args, args=['--help']) - + SystemExit, self.parser.parse_main_args, args=['--help']) -class TestParseSubmoduleArgs(unittest.TestCase): @patch('sys.stdout.isatty', return_value=True) def test_tty(self, tty): self.assertRaises( - SystemExit, cli.core.parse_submodule_args, submodule_fixture, []) + SystemExit, self.parser.parse_command_args, 'cci', 'list', []) def test_confirm(self): - submodule = MagicMock() - submodule.options = ['confirm'] - submodule.__doc__ = 'usage: sl cci list [options]' + command = MagicMock() + command.options = ['confirm'] + command.__doc__ = 'usage: sl cci list [options]' + self.env.get_command = MagicMock() + self.env.get_command.return_value = command self.assertRaises( - SystemExit, cli.core.parse_submodule_args, submodule, ['']) + SystemExit, self.parser.parse_command_args, 'cci', 'list', []) class TestFormatOutput(unittest.TestCase): diff --git a/SoftLayer/tests/CLI/environment_tests.py b/SoftLayer/tests/CLI/environment_tests.py index d5b8f4ab0..109849929 100644 --- a/SoftLayer/tests/CLI/environment_tests.py +++ b/SoftLayer/tests/CLI/environment_tests.py @@ -10,11 +10,11 @@ try: import unittest2 as unittest except ImportError: - import unittest # NOQA + import unittest # NOQA from mock import patch, MagicMock from SoftLayer import API_PUBLIC_ENDPOINT -import SoftLayer.CLI as cli +from SoftLayer.CLI.environment import Environment, InvalidCommand if sys.version_info >= (3,): raw_input_path = 'builtins.input' @@ -26,7 +26,7 @@ class EnvironmentTests(unittest.TestCase): def setUp(self): - self.env = cli.environment.Environment() + self.env = Environment() def test_plugin_list(self): actions = self.env.plugin_list() @@ -93,3 +93,22 @@ def test_get_module_name(self): r = self.env.get_module_name('realname') self.assertEqual(r, 'realname') + + def test_get_command_invalid(self): + self.assertRaises(InvalidCommand, self.env.get_command, 'cci', 'list') + + def test_get_command(self): + self.env.plugins = {'cci': {'list': 'something'}} + command = self.env.get_command('cci', 'list') + self.assertEqual(command, 'something') + + def test_get_command_none(self): + # If None is in the action list, anything that doesn't exist as a + # command will return the value of the None key. This is to support + # sl help any_module_name + self.env.plugins = {'cci': {None: 'something'}} + command = self.env.get_command('cci', 'something else') + self.assertEqual(command, 'something') + + def test_exit(self): + self.assertRaises(SystemExit, self.env.exit, 1) diff --git a/SoftLayer/tests/CLI/helper_tests.py b/SoftLayer/tests/CLI/helper_tests.py index b35fc7287..fef62c6a0 100644 --- a/SoftLayer/tests/CLI/helper_tests.py +++ b/SoftLayer/tests/CLI/helper_tests.py @@ -9,10 +9,9 @@ try: import unittest2 as unittest except ImportError: - import unittest # NOQA + import unittest # NOQA from mock import patch - import SoftLayer.CLI as cli if sys.version_info >= (3,): @@ -119,6 +118,19 @@ def test_mb_to_gb(self): self.assertRaises(ValueError, cli.mb_to_gb, '1024string') + def test_gb(self): + item = cli.gb(2) + self.assertEqual(2048, item.original) + self.assertEqual('2G', item.formatted) + + item = cli.gb('2') + self.assertEqual(2048, item.original) + self.assertEqual('2G', item.formatted) + + item = cli.gb('2.0') + self.assertEqual(2048, item.original) + self.assertEqual('2G', item.formatted) + def test_blank(self): item = cli.helpers.blank() self.assertEqual('NULL', item.original) @@ -144,3 +156,22 @@ class TestCommand(cli.CLIRunnable): self.assertEqual( cli.environment.CLIRunnableType.env.plugins, {'helper_tests': {'test': TestCommand}}) + + +class ResolveIdTests(unittest.TestCase): + + def test_resolve_id_one(self): + resolver = lambda r: [12345] + id = cli.helpers.resolve_id(resolver, 'test') + + self.assertEqual(id, 12345) + + def test_resolve_id_none(self): + resolver = lambda r: [] + self.assertRaises( + cli.helpers.CLIAbort, cli.helpers.resolve_id, resolver, 'test') + + def test_resolve_id_multiple(self): + resolver = lambda r: [12345, 54321] + self.assertRaises( + cli.helpers.CLIAbort, cli.helpers.resolve_id, resolver, 'test') diff --git a/SoftLayer/tests/API/client_tests.py b/SoftLayer/tests/api_tests.py similarity index 74% rename from SoftLayer/tests/API/client_tests.py rename to SoftLayer/tests/api_tests.py index 072167957..d63248491 100644 --- a/SoftLayer/tests/API/client_tests.py +++ b/SoftLayer/tests/api_tests.py @@ -1,6 +1,6 @@ """ - SoftLayer.tests.API.client_tests - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + SoftLayer.tests.api_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~ :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. :license: BSD, see LICENSE for more details. @@ -8,7 +8,7 @@ try: import unittest2 as unittest except ImportError: - import unittest # NOQA + import unittest # NOQA from mock import patch, MagicMock, call @@ -112,34 +112,29 @@ def test_service_repr(self): self.assertIn("Service", repr(client['SERVICE'])) -class APICalls(unittest.TestCase): - def setUp(self): - self.client = SoftLayer.Client( - username='doesnotexist', api_key='issurelywrong', - endpoint_url="ENDPOINT") +class OldAPIClient(unittest.TestCase): - @patch('SoftLayer.API.make_api_call') - def test_old_api(self, make_api_call): + @patch('SoftLayer.API.make_xml_rpc_api_call') + def test_old_api(self, make_xml_rpc_api_call): client = SoftLayer.API.Client( 'SoftLayer_SERVICE', None, 'doesnotexist', 'issurelywrong', endpoint_url="ENDPOINT") client.METHOD() - make_api_call.assert_called_with( + make_xml_rpc_api_call.assert_called_with( 'ENDPOINT/SoftLayer_SERVICE', 'METHOD', (), headers={ 'authenticate': { 'username': 'doesnotexist', 'apiKey': 'issurelywrong'}}, - verbose=False, timeout=None, http_headers={ 'Content-Type': 'application/xml', 'User-Agent': USER_AGENT, }) - @patch('SoftLayer.API.make_api_call') - def test_complex_old_api(self, make_api_call): + @patch('SoftLayer.API.make_xml_rpc_api_call') + def test_complex_old_api(self, make_xml_rpc_api_call): client = SoftLayer.API.Client( 'SoftLayer_SERVICE', None, 'doesnotexist', 'issurelywrong', endpoint_url="ENDPOINT") @@ -156,7 +151,7 @@ def test_complex_old_api(self, make_api_call): 'TYPE': {'obj': {'attribute': {'operation': '^= prefix'}}}}, limit=9, offset=10) - make_api_call.assert_called_with( + make_xml_rpc_api_call.assert_called_with( 'ENDPOINT/SoftLayer_SERVICE', 'METHOD', (1234, ), headers={ 'SoftLayer_SERVICEObjectMask': { @@ -168,7 +163,6 @@ def test_complex_old_api(self, make_api_call): 'username': 'doesnotexist', 'apiKey': 'issurelywrong'}, 'SoftLayer_SERVICEInitParameters': {'id': 5678}, 'resultLimit': {'limit': 9, 'offset': 10}}, - verbose=False, timeout=None, http_headers={ 'RAW': 'HEADER', @@ -181,23 +175,29 @@ def test_old_api_no_service(self): api_key='issurelywrong') self.assertRaises(SoftLayer.SoftLayerError, client.METHOD) - @patch('SoftLayer.API.make_api_call') - def test_simple_call(self, make_api_call): + +class APIClient(unittest.TestCase): + def setUp(self): + self.client = SoftLayer.Client( + username='doesnotexist', api_key='issurelywrong', + endpoint_url="ENDPOINT") + + @patch('SoftLayer.API.make_xml_rpc_api_call') + def test_simple_call(self, make_xml_rpc_api_call): self.client['SERVICE'].METHOD() - make_api_call.assert_called_with( + make_xml_rpc_api_call.assert_called_with( 'ENDPOINT/SoftLayer_SERVICE', 'METHOD', (), headers={ 'authenticate': { 'username': 'doesnotexist', 'apiKey': 'issurelywrong'}}, - verbose=False, timeout=None, http_headers={ 'Content-Type': 'application/xml', 'User-Agent': USER_AGENT, }) - @patch('SoftLayer.API.make_api_call') - def test_complex(self, make_api_call): + @patch('SoftLayer.API.make_xml_rpc_api_call') + def test_complex(self, make_xml_rpc_api_call): self.client['SERVICE'].METHOD( 1234, id=5678, @@ -207,7 +207,7 @@ def test_complex(self, make_api_call): 'TYPE': {'obj': {'attribute': {'operation': '^= prefix'}}}}, limit=9, offset=10) - make_api_call.assert_called_with( + make_xml_rpc_api_call.assert_called_with( 'ENDPOINT/SoftLayer_SERVICE', 'METHOD', (1234, ), headers={ 'SoftLayer_SERVICEObjectMask': { @@ -219,7 +219,6 @@ def test_complex(self, make_api_call): 'username': 'doesnotexist', 'apiKey': 'issurelywrong'}, 'SoftLayer_SERVICEInitParameters': {'id': 5678}, 'resultLimit': {'limit': 9, 'offset': 10}}, - verbose=False, timeout=None, http_headers={ 'RAW': 'HEADER', @@ -227,42 +226,40 @@ def test_complex(self, make_api_call): 'User-Agent': USER_AGENT, }) - @patch('SoftLayer.API.make_api_call') - def test_mask_call_v2(self, make_api_call): + @patch('SoftLayer.API.make_xml_rpc_api_call') + def test_mask_call_v2(self, make_xml_rpc_api_call): self.client['SERVICE'].METHOD( mask="mask[something[nested]]") - make_api_call.assert_called_with( + make_xml_rpc_api_call.assert_called_with( 'ENDPOINT/SoftLayer_SERVICE', 'METHOD', (), headers={ 'authenticate': { 'username': 'doesnotexist', 'apiKey': 'issurelywrong'}, 'SoftLayer_ObjectMask': {'mask': 'mask[something[nested]]'}}, - verbose=False, timeout=None, http_headers={ 'Content-Type': 'application/xml', 'User-Agent': USER_AGENT, }) - @patch('SoftLayer.API.make_api_call') - def test_mask_call_v2_dot(self, make_api_call): + @patch('SoftLayer.API.make_xml_rpc_api_call') + def test_mask_call_v2_dot(self, make_xml_rpc_api_call): self.client['SERVICE'].METHOD( mask="mask.something.nested") - make_api_call.assert_called_with( + make_xml_rpc_api_call.assert_called_with( 'ENDPOINT/SoftLayer_SERVICE', 'METHOD', (), headers={ 'authenticate': { 'username': 'doesnotexist', 'apiKey': 'issurelywrong'}, 'SoftLayer_ObjectMask': {'mask': 'mask[something.nested]'}}, - verbose=False, timeout=None, http_headers={ 'Content-Type': 'application/xml', 'User-Agent': USER_AGENT, }) - @patch('SoftLayer.API.make_api_call') - def test_mask_call_invalid_mask(self, make_api_call): + @patch('SoftLayer.API.make_xml_rpc_api_call') + def test_mask_call_invalid_mask(self, make_xml_rpc_api_call): try: self.client['SERVICE'].METHOD(mask="mask[something.nested") except SoftLayer.SoftLayerError, e: @@ -273,7 +270,7 @@ def test_mask_call_invalid_mask(self, make_api_call): @patch('SoftLayer.API.Client.iter_call') def test_iterate(self, _iter_call): self.client['SERVICE'].METHOD(iter=True) - _iter_call.assert_called_with('SERVICE', 'METHOD', iter=True) + _iter_call.assert_called_with('SERVICE', 'METHOD') @patch('SoftLayer.API.Client.iter_call') def test_service_iter_call(self, _iter_call): @@ -339,3 +336,88 @@ def test_iter_call(self, _call): AttributeError, lambda: list(self.client.iter_call( 'SERVICE', 'METHOD', iter=True, chunk=0))) + + def test_call_invalid_arguments(self): + self.assertRaises( + TypeError, + self.client.call, 'SERVICE', 'METHOD', invalid_kwarg='invalid') + + +class UnauthenticatedAPIClient(unittest.TestCase): + def setUp(self): + self.client = SoftLayer.Client(endpoint_url="ENDPOINT") + + @patch.dict('os.environ', {'SL_USERNAME': '', 'SL_API_KEY': ''}) + def test_init(self): + client = SoftLayer.Client() + self.assertIsNone(client.auth) + + @patch('SoftLayer.API.Client.call') + def test_authenticate_with_password(self, _call): + _call.return_value = { + 'userId': 12345, + 'hash': 'TOKEN', + } + self.client.authenticate_with_password('USERNAME', 'PASSWORD') + _call.assert_called_with( + 'User_Customer', + 'getPortalLoginToken', + 'USERNAME', + 'PASSWORD', + None, + None) + self.assertIsNotNone(self.client.auth) + self.assertEquals(self.client.auth.user_id, 12345) + self.assertEquals(self.client.auth.auth_token, 'TOKEN') + + +class TestAuthenticationBase(unittest.TestCase): + def test_get_headers(self): + auth = SoftLayer.API.AuthenticationBase() + self.assertRaises(NotImplementedError, auth.get_headers) + + +class TestBasicAuthentication(unittest.TestCase): + def setUp(self): + self.auth = SoftLayer.BasicAuthentication('USERNAME', 'APIKEY') + + def test_attribs(self): + self.assertEquals(self.auth.username, 'USERNAME') + self.assertEquals(self.auth.api_key, 'APIKEY') + + def test_get_headers(self): + self.assertEquals(self.auth.get_headers(), { + 'authenticate': { + 'username': 'USERNAME', + 'apiKey': 'APIKEY', + } + }) + + def test_repr(self): + s = repr(self.auth) + self.assertIn('BasicAuthentication', s) + self.assertIn('USERNAME', s) + + +class TestTokenAuthentication(unittest.TestCase): + def setUp(self): + self.auth = SoftLayer.TokenAuthentication(12345, 'TOKEN') + + def test_attribs(self): + self.assertEquals(self.auth.user_id, 12345) + self.assertEquals(self.auth.auth_token, 'TOKEN') + + def test_get_headers(self): + self.assertEquals(self.auth.get_headers(), { + 'authenticate': { + 'complexType': 'PortalLoginToken', + 'userId': 12345, + 'authToken': 'TOKEN', + } + }) + + def test_repr(self): + s = repr(self.auth) + self.assertIn('TokenAuthentication', s) + self.assertIn('12345', s) + self.assertIn('TOKEN', s) diff --git a/SoftLayer/tests/API/functional_tests.py b/SoftLayer/tests/functional_tests.py similarity index 68% rename from SoftLayer/tests/API/functional_tests.py rename to SoftLayer/tests/functional_tests.py index 4947e09aa..1bc07d381 100644 --- a/SoftLayer/tests/API/functional_tests.py +++ b/SoftLayer/tests/functional_tests.py @@ -1,6 +1,6 @@ """ - SoftLayer.tests.API.functional_tests - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + SoftLayer.tests.functional_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. :license: BSD, see LICENSE for more details. @@ -11,9 +11,7 @@ try: import unittest2 as unittest except ImportError: - import unittest # NOQA -from mock import patch -from SoftLayer.transport import xmlrpclib_transport + import unittest # NOQA def get_creds(): @@ -38,14 +36,6 @@ def test_failed_auth(self): self.assertRaises(SoftLayer.SoftLayerAPIError, client.getPortalLoginToken) - @patch('SoftLayer.API.make_api_call', xmlrpclib_transport.make_api_call) - def test_with_xmlrpc_transport(self): - client = SoftLayer.Client( - 'SoftLayer_User_Customer', None, 'doesnotexist', 'issurelywrong', - timeout=20) - self.assertRaises(SoftLayer.SoftLayerAPIError, - client.getPortalLoginToken) - def test_404(self): client = SoftLayer.Client( 'SoftLayer_User_Customer', None, 'doesnotexist', 'issurelywrong', @@ -60,35 +50,10 @@ def test_404(self): except: self.fail('No Exception Raised') - @patch('SoftLayer.API.make_api_call', xmlrpclib_transport.make_api_call) - def test_404_with_xmlrpc_transport(self): - client = SoftLayer.Client( - 'SoftLayer_User_Customer', None, 'doesnotexist', 'issurelywrong', - timeout=20, endpoint_url='http://httpbin.org/status/404') - - try: - client.doSomething() - except SoftLayer.SoftLayerAPIError, e: - self.assertEqual(e.faultCode, 404) - self.assertIn('NOT FOUND', e.faultString) - self.assertIn('NOT FOUND', e.reason) - def test_no_hostname(self): try: # This test will fail if 'notvalidsoftlayer.com' becomes a thing - SoftLayer.API.make_api_call( - 'http://notvalidsoftlayer.com', 'getObject') - except SoftLayer.SoftLayerAPIError, e: - self.assertEqual(e.faultCode, 0) - self.assertIn('not known', e.faultString) - self.assertIn('not known', e.reason) - except: - self.fail('No Exception Raised') - - def test_no_hostname_with_xmlrpc_transport(self): - try: - # This test will fail if 'notvalidsoftlayer.com' becomes a thing - xmlrpclib_transport.make_api_call( + SoftLayer.API.make_xml_rpc_api_call( 'http://notvalidsoftlayer.com', 'getObject') except SoftLayer.SoftLayerAPIError, e: self.assertEqual(e.faultCode, 0) diff --git a/SoftLayer/tests/API/__init__.py b/SoftLayer/tests/managers/__init__.py similarity index 100% rename from SoftLayer/tests/API/__init__.py rename to SoftLayer/tests/managers/__init__.py diff --git a/SoftLayer/tests/API/cci_tests.py b/SoftLayer/tests/managers/cci_tests.py similarity index 80% rename from SoftLayer/tests/API/cci_tests.py rename to SoftLayer/tests/managers/cci_tests.py index 2acd7f676..4717c60f6 100644 --- a/SoftLayer/tests/API/cci_tests.py +++ b/SoftLayer/tests/managers/cci_tests.py @@ -1,29 +1,28 @@ """ - SoftLayer.tests.API.cci_tests - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + SoftLayer.tests.managers.cci_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. :license: BSD, see LICENSE for more details. """ -import SoftLayer -import SoftLayer.CCI +from SoftLayer import CCIManager try: import unittest2 as unittest except ImportError: - import unittest # NOQA + import unittest # NOQA from mock import MagicMock, ANY, call, patch -class CCITests_unittests(unittest.TestCase): +class CCITests(unittest.TestCase): def setUp(self): self.client = MagicMock() - self.cci = SoftLayer.CCIManager(self.client) + self.cci = CCIManager(self.client) def test_list_instances(self): mcall = call(mask=ANY, filter={}) - service = self.client.__getitem__() + service = self.client['Account'] self.cci.list_instances(hourly=True, monthly=True) service.getVirtualGuests.assert_has_calls(mcall) @@ -53,7 +52,7 @@ def test_list_instances_with_filters(self): private_ip='4.3.2.1', ) - service = self.client.__getitem__() + service = self.client['Account'] service.getVirtualGuests.assert_has_calls(call( filter={ 'virtualGuests': { @@ -77,13 +76,13 @@ def test_list_instances_with_filters(self): )) def test_resolve_ids_ip(self): - self.client.__getitem__().getVirtualGuests.return_value = \ - [{'id': '1234'}] + self.client['Account'].getVirtualGuests.return_value = [{'id': '1234'}] _id = self.cci._get_ids_from_ip('1.2.3.4') self.assertEqual(_id, ['1234']) - self.client.__getitem__().getVirtualGuests.side_effect = \ - [[], [{'id': '4321'}]] + self.client['Account'].getVirtualGuests.side_effect = [ + [], [{'id': '4321'}] + ] _id = self.cci._get_ids_from_ip('4.3.2.1') self.assertEqual(_id, ['4321']) @@ -91,51 +90,54 @@ def test_resolve_ids_ip(self): self.assertEqual(_id, []) def test_resolve_ids_hostname(self): - self.client.__getitem__().getVirtualGuests.return_value = \ + self.client['Account'].getVirtualGuests.return_value = \ [{'id': '1234'}] _id = self.cci._get_ids_from_hostname('hostname') self.assertEqual(_id, ['1234']) def test_get_instance(self): - self.client.__getitem__().getObject.return_value = { + self.client['Virtual_Guest'].getObject.return_value = { 'hourlyVirtualGuests': "this is unique"} self.cci.get_instance(1) - self.client.__getitem__().getObject.assert_called_once_with( + self.client['Virtual_Guest'].getObject.assert_called_once_with( id=1, mask=ANY) def test_get_create_options(self): self.cci.get_create_options() - f = self.client.__getitem__().getCreateObjectOptions + f = self.client['Virtual_Guest'].getCreateObjectOptions f.assert_called_once_with() def test_cancel_instance(self): self.cci.cancel_instance(id=1) - self.client.__getitem__().deleteObject.assert_called_once_with(id=1) + self.client['Virtual_Guest'].deleteObject.assert_called_once_with(id=1) def test_reload_instance(self): - self.cci.reload_instance(id=1) - f = self.client.__getitem__().reloadCurrentOperatingSystemConfiguration - f.assert_called_once_with(id=1) - - @patch('SoftLayer.CCI.CCIManager._generate_create_dict') + post_uri = 'http://test.sftlyr.ws/test.sh' + self.cci.reload_instance(id=1, post_uri=post_uri) + service = self.client['Virtual_Guest'] + f = service.reloadOperatingSystem + f.assert_called_once_with('FORCE', + {'customProvisionScriptUri': post_uri}, id=1) + + @patch('SoftLayer.managers.cci.CCIManager._generate_create_dict') def test_create_verify(self, create_dict): create_dict.return_value = {'test': 1, 'verify': 1} self.cci.verify_create_instance(test=1, verify=1) create_dict.assert_called_once_with(test=1, verify=1) - f = self.client.__getitem__().generateOrderTemplate + f = self.client['Virtual_Guest'].generateOrderTemplate f.assert_called_once_with({'test': 1, 'verify': 1}) - @patch('SoftLayer.CCI.CCIManager._generate_create_dict') + @patch('SoftLayer.managers.cci.CCIManager._generate_create_dict') def test_create_instance(self, create_dict): create_dict.return_value = {'test': 1, 'verify': 1} self.cci.create_instance(test=1, verify=1) create_dict.assert_called_once_with(test=1, verify=1) - self.client.__getitem__().createObject.assert_called_once_with( + self.client['Virtual_Guest'].createObject.assert_called_once_with( {'test': 1, 'verify': 1}) def test_generate_os_and_image(self): self.assertRaises( - SoftLayer.CCI.CCICreateMutuallyExclusive, + ValueError, self.cci._generate_create_dict, cpus=1, memory=1, @@ -146,15 +148,8 @@ def test_generate_os_and_image(self): ) def test_generate_missing(self): - self.assertRaises( - SoftLayer.CCI.CCICreateMissingRequired, - self.cci._generate_create_dict, - ) - self.assertRaises( - SoftLayer.CCI.CCICreateMissingRequired, - self.cci._generate_create_dict, - cpus=1 - ) + self.assertRaises(ValueError, self.cci._generate_create_dict) + self.assertRaises(ValueError, self.cci._generate_create_dict, cpus=1) def test_generate_basic(self): data = self.cci._generate_create_dict( @@ -358,6 +353,29 @@ def test_generate_network(self): self.assertEqual(data, assert_data) + def test_generate_post_uri(self): + data = self.cci._generate_create_dict( + cpus=1, + memory=1, + hostname='test', + domain='example.com', + os_code="STRING", + post_uri='https://example.com/boostrap.sh', + ) + + assert_data = { + 'startCpus': 1, + 'maxMemory': 1, + 'hostname': 'test', + 'domain': 'example.com', + 'localDiskFlag': True, + 'operatingSystemReferenceCode': "STRING", + 'hourlyBillingFlag': True, + 'postInstallScriptUri': 'https://example.com/boostrap.sh', + } + + self.assertEqual(data, assert_data) + def test_generate_no_disks(self): data = self.cci._generate_create_dict( cpus=1, @@ -381,7 +399,7 @@ def test_generate_single_disk(self): assert_data = { 'blockDevices': [ - {"device": "0", "diskImage":{"capacity": 50}}] + {"device": "0", "diskImage": {"capacity": 50}}] } self.assertTrue(data.get('blockDevices')) @@ -399,17 +417,17 @@ def test_generate_multi_disk(self): assert_data = { 'blockDevices': [ - {"device": "0", "diskImage":{"capacity": 50}}, - {"device": "2", "diskImage":{"capacity": 70}}, - {"device": "3", "diskImage":{"capacity": 100}}] + {"device": "0", "diskImage": {"capacity": 50}}, + {"device": "2", "diskImage": {"capacity": 70}}, + {"device": "3", "diskImage": {"capacity": 100}}] } self.assertTrue(data.get('blockDevices')) self.assertEqual(data['blockDevices'], assert_data['blockDevices']) - @patch('SoftLayer.CCI.sleep') + @patch('SoftLayer.managers.cci.sleep') def test_wait(self, _sleep): - guestObject = self.client.__getitem__().getObject + guestObject = self.client['Virtual_Guest'].getObject # test 4 iterations with positive match guestObject.side_effect = [ @@ -476,3 +494,21 @@ def test_wait(self, _sleep): _sleep.assert_has_calls([ call(10), call(10), call(10), call(10), call(10), call(10), call(10), call(10), call(10), call(10)]) + + def test_change_port_speed_public(self): + cci_id = 1 + speed = 100 + self.cci.change_port_speed(cci_id, True, speed) + + service = self.client['Virtual_Guest'] + f = service.setPublicNetworkInterfaceSpeed + f.assert_called_once_with(speed, id=cci_id) + + def test_change_port_speed_private(self): + cci_id = 2 + speed = 10 + self.cci.change_port_speed(cci_id, False, speed) + + service = self.client['Virtual_Guest'] + f = service.setPrivateNetworkInterfaceSpeed + f.assert_called_once_with(speed, id=cci_id) diff --git a/SoftLayer/tests/managers/dns_tests.py b/SoftLayer/tests/managers/dns_tests.py new file mode 100644 index 000000000..d3fc77b5f --- /dev/null +++ b/SoftLayer/tests/managers/dns_tests.py @@ -0,0 +1,179 @@ +""" + SoftLayer.tests.managers.dns_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. + :license: BSD, see LICENSE for more details. +""" +from SoftLayer import DNSManager, DNSZoneNotFound + +try: + import unittest2 as unittest +except ImportError: + import unittest # NOQA +from mock import MagicMock, ANY + + +class DNSTests(unittest.TestCase): + + def setUp(self): + self.client = MagicMock() + self.dns_client = DNSManager(self.client) + + def test_init_exercise(self): + self.assertTrue(hasattr(self.dns_client, 'service')) + self.assertTrue(hasattr(self.dns_client, 'record')) + + def test_list_zones(self): + zone_list = ['test'] + self.client['Account'].getDomains.return_value = zone_list + zones = self.dns_client.list_zones() + self.assertEqual(zones, zone_list) + + def test_get_zone(self): + zone_list = [ + {'id': 98765, 'name': 'test-example.com'}, + {'id': 12345, 'name': 'example.com', "resourceRecords": ["test"]}, + ] + + # match, with defaults + self.client['Account'].getObject.return_value = [zone_list[1]] + res = self.dns_client.get_zone(12345) + self.assertEqual(res, zone_list[1]) + self.client['Account'].getObject.assert_called_once_with( + id=12345, + mask='resourceRecords') + + # no match + self.client['Account'].getObject.return_value = [] + self.assertRaises( + DNSZoneNotFound, + self.dns_client.get_zone, + 5) + + # No records masked in + self.client['Account'].getObject.reset_mock() + self.client['Account'].getObject.return_value = [zone_list[1]] + self.dns_client.get_zone(12345, records=False) + self.client['Account'].getObject.assert_called_once_with( + id=12345, + mask=None) + + def test_resolve_zone_name(self): + zone_list = [{'name': 'example.com', 'id': 12345}] + # matching domain + self.client['Account'].getDomains.return_value = zone_list + res = self.dns_client._get_zone_id_from_name('example.com') + self.assertEqual([12345], res) + self.client['Account'].getDomains.assert_called_once_with( + filter={"domains": {"name": {"operation": "_= example.com"}}}) + + # no matches + self.client['Account'].getDomains.reset_mock() + self.client['Account'].getDomains.return_value = [] + res = self.dns_client._get_zone_id_from_name('example.com') + self.assertEqual([], res) + self.client['Account'].getDomains.assert_called_once_with( + filter={"domains": {"name": {"operation": "_= example.com"}}}) + + def test_create_zone(self): + call = self.client['Dns_Domain'].createObject + call.return_value = {'name': 'example.com'} + + res = self.dns_client.create_zone('example.com') + + call.assert_called_once_with({ + 'name': 'example.com', "resourceRecords": {}, "serial": ANY + }) + + self.assertEqual(res, {'name': 'example.com'}) + + def test_delete_zone(self): + self.dns_client.delete_zone(1) + self.client['Dns_Domain'].deleteObject.assert_called_once_with(id=1) + + def test_edit_zone(self): + self.dns_client.edit_zone('example.com') + self.client['Dns_Domain'].editObject.assert_called_once_with( + 'example.com') + + def test_create_record(self): + self.dns_client.create_record(1, 'test', 'TXT', 'testing', ttl=1200) + + f = self.client['Dns_Domain_ResourceRecord'].createObject + f.assert_called_once_with( + { + 'domainId': 1, + 'ttl': 1200, + 'host': 'test', + 'type': 'TXT', + 'data': 'testing' + }) + + def test_delete_record(self): + self.dns_client.delete_record(1) + f = self.client['Dns_Domain_ResourceRecord'].deleteObject + f.assert_called_once_with(id=1) + + def test_edit_record(self): + self.dns_client.edit_record({'id': 1, 'name': 'test'}) + f = self.client['Dns_Domain_ResourceRecord'].editObject + f.assert_called_once_with( + {'id': 1, 'name': 'test'}, + id=1 + ) + + def test_dump_zone(self): + f = self.client['Dns_Domain'].getZoneFileContents + f.return_value = 'lots of text' + self.dns_client.dump_zone(1) + f.assert_called_once_with(id=1) + + def test_get_record(self): + records = [ + {'ttl': 7200, 'data': 'd', 'host': 'a', 'type': 'cname'}, + {'ttl': 900, 'data': '1', 'host': 'b', 'type': 'a'}, + {'ttl': 900, 'data': 'x', 'host': 'c', 'type': 'ptr'}, + {'ttl': 86400, 'data': 'b', 'host': 'd', 'type': 'txt'}, + {'ttl': 86400, 'data': 'b', 'host': 'e', 'type': 'txt'}, + {'ttl': 600, 'data': 'b', 'host': 'f', 'type': 'txt'}, + ] + + D = self.client['Dns_Domain'].getResourceRecords + + # maybe valid domain, but no records matching + D.return_value = [] + self.assertEqual(self.dns_client.get_records(12345), + []) + + D.reset_mock() + D.return_value = [records[1]] + self.dns_client.get_records(12345, type='a') + D.assert_called_once_with( + id=12345, + filter={'resourceRecords': {'type': {"operation": "_= a"}}}, + mask=ANY) + + D.reset_mock() + D.return_value = [records[0]] + self.dns_client.get_records(12345, host='a') + D.assert_called_once_with( + id=12345, + filter={'resourceRecords': {'host': {"operation": "_= a"}}}, + mask=ANY) + + D.reset_mock() + D.return_value = records[3:5] + self.dns_client.get_records(12345, data='a') + D.assert_called_once_with( + id=12345, + filter={'resourceRecords': {'data': {"operation": "_= a"}}}, + mask=ANY) + + D.reset_mock() + D.return_value = records[3:5] + self.dns_client.get_records(12345, ttl='86400') + D.assert_called_once_with( + id=12345, + filter={'resourceRecords': {'ttl': {"operation": 86400}}}, + mask=ANY) diff --git a/SoftLayer/tests/managers/firewall_tests.py b/SoftLayer/tests/managers/firewall_tests.py new file mode 100644 index 000000000..e5a1c32a7 --- /dev/null +++ b/SoftLayer/tests/managers/firewall_tests.py @@ -0,0 +1,33 @@ +""" + SoftLayer.tests.managers.firewall_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. + :license: BSD, see LICENSE for more details. +""" +from SoftLayer import FirewallManager + +try: + import unittest2 as unittest +except ImportError: + import unittest # NOQA +from mock import MagicMock, ANY + + +class FirewallTests(unittest.TestCase): + + def setUp(self): + self.client = MagicMock() + self.firewall = FirewallManager(self.client) + + def test_get_firewalls(self): + vlan = { + 'dedicatedFirewallFlag': True, + } + call = self.client['Account'].getObject + call.return_value = {'networkVlans': [vlan]} + + firewalls = self.firewall.get_firewalls() + + self.assertEquals([vlan], firewalls) + call.assert_called_once_with(mask=ANY) diff --git a/SoftLayer/tests/managers/hardware_tests.py b/SoftLayer/tests/managers/hardware_tests.py new file mode 100644 index 000000000..da6d285aa --- /dev/null +++ b/SoftLayer/tests/managers/hardware_tests.py @@ -0,0 +1,427 @@ +""" + SoftLayer.tests.managers.hardware_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. + :license: BSD, see LICENSE for more details. +""" +from SoftLayer import HardwareManager +from SoftLayer.managers.hardware import get_default_value + +try: + import unittest2 as unittest +except ImportError: + import unittest # NOQA +from mock import MagicMock, ANY, call, patch + + +class HardwareTests(unittest.TestCase): + + def setUp(self): + self.client = MagicMock() + self.hardware = HardwareManager(self.client) + + def test_list_hardware(self): + mcall = call(mask=ANY, filter={}) + service = self.client.__getitem__() + + self.hardware.list_hardware() + service.getHardware.assert_has_calls(mcall) + + def test_list_hardware_with_filters(self): + self.hardware.list_hardware( + tags=['tag1', 'tag2'], + cpus=2, + memory=1, + hostname='hostname', + domain='example.com', + datacenter='dal05', + nic_speed=100, + public_ip='1.2.3.4', + private_ip='4.3.2.1', + ) + service = self.client.__getitem__() + service.getHardware.assert_has_calls(call( + filter={ + 'hardware': { + 'datacenter': {'name': {'operation': '_= dal05'}}, + 'domain': {'operation': '_= example.com'}, + 'tagReferences': { + 'tag': {'name': { + 'operation': 'in', + 'options': [ + {'name': 'data', 'value': ['tag1', 'tag2']}] + }} + }, + 'memoryCapacity': {'operation': 1}, + 'processorCoreAmount': {'operation': 2}, + 'hostname': {'operation': '_= hostname'}, + 'primaryIpAddress': {'operation': '_= 1.2.3.4'}, + 'networkComponents': {'maxSpeed': {'operation': 100}}, + 'primaryBackendIpAddress': {'operation': '_= 4.3.2.1'}} + }, + mask=ANY, + )) + + def test_resolve_ids_ip(self): + self.client.__getitem__().getHardware.return_value = [{'id': '1234'}] + _id = self.hardware._get_ids_from_ip('1.2.3.4') + self.assertEqual(_id, ['1234']) + + self.client.__getitem__().getHardware.side_effect = \ + [[], [{'id': '4321'}]] + _id = self.hardware._get_ids_from_ip('4.3.2.1') + self.assertEqual(_id, ['4321']) + + _id = self.hardware._get_ids_from_ip('nope') + self.assertEqual(_id, []) + + def test_resolve_ids_hostname(self): + self.client.__getitem__().getHardware.return_value = [{'id': '1234'}] + _id = self.hardware._get_ids_from_hostname('hostname') + self.assertEqual(_id, ['1234']) + + def test_get_hardware(self): + self.client.__getitem__().getObject.return_value = { + 'hourlyVirtualGuests': "this is unique"} + self.hardware.get_hardware(1) + self.client.__getitem__().getObject.assert_called_once_with( + id=1, mask=ANY) + + def test_reload(self): + post_uri = 'http://test.sftlyr.ws/test.sh' + self.hardware.reload(id=1, post_uri=post_uri) + f = self.client.__getitem__().reloadOperatingSystem + f.assert_called_once_with('FORCE', + {'customProvisionScriptUri': post_uri}, id=1) + + def test_get_bare_metal_create_options_returns_none_on_error(self): + self.client['Product_Package'].getAllObjects.return_value = [ + {'name': 'No Matching Instances', 'id': 0}] + + self.assertIsNone(self.hardware.get_bare_metal_create_options()) + + def test_get_bare_metal_create_options(self): + package_id = 50 + + self.client['Product_Package'].getAllObjects.return_value = [ + {'name': 'Bare Metal Instance', 'id': package_id}] + + self.client['Product_Package'].getRegions.return_value = [{ + 'location': { + 'locationPackageDetails': [{ + 'deliveryTimeInformation': 'Typically 2-4 hours', + }], + }, + 'keyname': 'RANDOM_LOCATION', + 'description': 'Random unit testing location', + }] + + self.client['Product_Package'].getConfiguration.return_value = [{ + 'itemCategory': { + 'categoryCode': 'random', + 'name': 'Random Category', + }, + 'sort': 0, + 'orderStepId': 1, + 'isRequired': 0, + }] + + prices = [{'sort': 0, 'id': 999}] + self.client['Product_Package'].getItems.return_value = [{ + 'itemCategory': { + 'categoryCode': 'random2', + 'name': 'Another Category', + }, + 'id': 1000, + 'description': 'Astronaut Sloths', + 'prices': prices, + 'capacity': 0, + }] + self.hardware.get_bare_metal_create_options() + + f1 = self.client['Product_Package'].getRegions + f1.assert_called_once_with(id=package_id) + + f2 = self.client['Product_Package'].getConfiguration + f2.assert_called_once_with(id=package_id, + mask='mask[itemCategory[group]]') + + f3 = self.client['Product_Package'].getItems + f3.assert_called_once_with(id=package_id, + mask='mask[itemCategory]') + + def test_generate_create_dict_with_all_bare_metal_options(self): + package_id = 50 + + prices = [{ + 'id': 888, + 'price_id': 1888, + 'sort': 0, + 'setupFee': 0, + 'recurringFee': 0, + 'hourlyRecurringFee': 0, + 'oneTimeFee': 0, + 'laborFee': 0, + }] + + self.client['Product_Package'].getAllObjects.return_value = [ + {'name': 'Bare Metal Instance', 'id': package_id}] + + self.client['Product_Package'].getRegions.return_value = [{ + 'location': { + 'locationPackageDetails': [{ + 'deliveryTimeInformation': 'Typically 2-4 hours', + }], + }, + 'keyname': 'RANDOM_LOCATION', + 'description': 'Random unit testing location', + }] + + self.client['Product_Package'].getConfiguration.return_value = [{ + 'itemCategory': { + 'categoryCode': 'random', + 'name': 'Random Category', + }, + 'sort': 0, + 'orderStepId': 1, + 'isRequired': 1, + 'prices': prices, + }] + + self.client['Product_Package'].getItems.return_value = [{ + 'itemCategory': { + 'categoryCode': 'random', + 'name': 'Random Category', + }, + 'id': 1000, + 'description': 'Astronaut Sloths', + 'prices': prices, + 'capacity': 0, + 'isRequired': 1, + }] + + args = { + 'server': 100, + 'hourly': False, + 'hostname': 'unicorn', + 'domain': 'giggles.woo', + 'disks': [500], + 'location': 'Wyrmshire', + 'os': 200, + 'port_speed': 600, + 'bare_metal': True, + 'hourly': True, + } + + assert_data = { + 'hardware': [{ + 'bareMetalInstanceFlag': args['bare_metal'], + 'hostname': args['hostname'], + 'domain': args['domain'], + }], + 'location': args['location'], + 'packageId': package_id, + 'hourlyBillingFlag': True, + 'prices': [ + {'id': args['server']}, + {'id': args['disks'][0]}, + {'id': args['os']}, + {'id': args['port_speed']}, + {'id': prices[0]['id']}, + ], + } + + data = self.hardware._generate_create_dict(**args) + + self.assertEqual(data, assert_data) + + def test_generate_create_dict_with_all_dedicated_server_options(self): + args = { + 'server': 100, + 'hostname': 'unicorn', + 'domain': 'giggles.woo', + 'disks': [500], + 'location': 'Wyrmshire', + 'os': 200, + 'port_speed': 600, + 'bare_metal': False, + 'package_id': 13, + 'ram': 1400, + 'disk_controller': 1500, + } + + assert_data = { + 'hardware': [{ + 'bareMetalInstanceFlag': args['bare_metal'], + 'hostname': args['hostname'], + 'domain': args['domain'], + }], + 'location': args['location'], + 'packageId': 13, + 'prices': [ + {'id': args['server']}, + {'id': args['disks'][0]}, + {'id': args['os']}, + {'id': args['port_speed']}, + {'id': args['ram']}, + {'id': args['disk_controller']}, + ], + } + + data = self.hardware._generate_create_dict(**args) + + self.assertEqual(data, assert_data) + + @patch('SoftLayer.managers.hardware.HardwareManager._generate_create_dict') + def test_verify_order(self, create_dict): + create_dict.return_value = {'test': 1, 'verify': 1} + self.hardware.verify_order(test=1, verify=1) + create_dict.assert_called_once_with(test=1, verify=1) + f = self.client['Product_Order'].verifyOrder + f.assert_called_once_with({'test': 1, 'verify': 1}) + + @patch('SoftLayer.managers.hardware.HardwareManager._generate_create_dict') + def test_place_order(self, create_dict): + create_dict.return_value = {'test': 1, 'verify': 1} + self.hardware.place_order(test=1, verify=1) + create_dict.assert_called_once_with(test=1, verify=1) + f = self.client['Product_Order'].placeOrder + f.assert_called_once_with({'test': 1, 'verify': 1}) + + def test_cancel_metal_immediately(self): + b_id = 5678 + self.client.__getitem__().getObject.return_value = {'id': '1234', + 'billingItem': { + 'id': b_id, + }} + self.hardware.cancel_metal(b_id, True) + f = self.client['Billing_Item'].cancelService + f.assert_called_once_with(id=b_id) + + def test_cancel_metal_on_anniversary(self): + b_id = 5678 + self.client.__getitem__().getObject.return_value = {'id': '1234', + 'billingItem': { + 'id': b_id, + }} + self.hardware.cancel_metal(b_id, False) + f = self.client['Billing_Item'].cancelServiceOnAnniversaryDate + f.assert_called_once_with(id=b_id) + + def test_cancel_hardware_without_reason(self): + hw_id = 987 + + self.hardware.cancel_hardware(hw_id) + + reasons = self.hardware.get_cancellation_reasons() + + f = self.client['Ticket'].createCancelServerTicket + f.assert_called_once_with(hw_id, reasons['unneeded'], '', True, + 'HARDWARE') + + def test_cancel_hardware_with_reason_and_comment(self): + hw_id = 987 + reason = 'sales' + comment = 'Test Comment' + + self.hardware.cancel_hardware(hw_id, reason, comment) + + reasons = self.hardware.get_cancellation_reasons() + + f = self.client['Ticket'].createCancelServerTicket + f.assert_called_once_with(hw_id, reasons[reason], comment, True, + 'HARDWARE') + + def test_change_port_speed_public(self): + hw_id = 1 + speed = 100 + self.hardware.change_port_speed(hw_id, True, speed) + + service = self.client['Hardware_Server'] + f = service.setPublicNetworkInterfaceSpeed + f.assert_called_once_with(speed, id=hw_id) + + def test_change_port_speed_private(self): + hw_id = 2 + speed = 10 + self.hardware.change_port_speed(hw_id, False, speed) + + service = self.client['Hardware_Server'] + f = service.setPrivateNetworkInterfaceSpeed + f.assert_called_once_with(speed, id=hw_id) + + def test_get_available_dedicated_server_packages(self): + self.hardware.get_available_dedicated_server_packages() + + service = self.client['Product_Package'] + f = service.getObject + f.assert_has_calls([call(id=13, mask='mask[id, name, description]')]) + + def test_get_dedicated_server_options(self): + package_id = 13 + + self.client['Product_Package'].getRegions.return_value = [{ + 'location': { + 'locationPackageDetails': [{ + 'deliveryTimeInformation': 'Typically 2-4 hours', + }], + }, + 'keyname': 'RANDOM_LOCATION', + 'description': 'Random unit testing location', + }] + + self.client['Product_Package'].getConfiguration.return_value = [{ + 'itemCategory': { + 'categoryCode': 'random', + 'name': 'Random Category', + }, + 'sort': 0, + 'orderStepId': 1, + 'isRequired': 0, + }] + + prices = [{'sort': 0, 'id': 999}] + self.client['Product_Package'].getItems.return_value = [{ + 'itemCategory': { + 'categoryCode': 'random2', + 'name': 'Another Category', + }, + 'id': 1000, + 'description': 'Astronaut Sloths', + 'prices': prices, + 'capacity': 0, + }] + self.hardware.get_dedicated_server_create_options(package_id) + + f1 = self.client['Product_Package'].getRegions + f1.assert_called_once_with(id=package_id) + + f2 = self.client['Product_Package'].getConfiguration + f2.assert_called_once_with(id=package_id, + mask='mask[itemCategory[group]]') + + f3 = self.client['Product_Package'].getItems + f3.assert_called_once_with(id=package_id, + mask='mask[itemCategory]') + + def test_get_default_value_returns_none_for_unknown_category(self): + package_options = {'categories': ['Cat1', 'Cat2']} + + self.assertEqual(None, get_default_value(package_options, + 'Unknown Category')) + + def test_get_default_value(self): + price_id = 9876 + package_options = {'categories': + {'Cat1': { + 'items': [{ + 'prices': [{ + 'setupFee': 0, + 'recurringFee': 0, + }], + 'price_id': price_id, + }] + }}} + + self.assertEqual(price_id, get_default_value(package_options, 'Cat1')) diff --git a/SoftLayer/tests/API/metadata_tests.py b/SoftLayer/tests/managers/metadata_tests.py similarity index 52% rename from SoftLayer/tests/API/metadata_tests.py rename to SoftLayer/tests/managers/metadata_tests.py index d27622931..0004e9b1c 100644 --- a/SoftLayer/tests/API/metadata_tests.py +++ b/SoftLayer/tests/managers/metadata_tests.py @@ -1,43 +1,35 @@ """ - SoftLayer.tests.API.metadata_tests - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + SoftLayer.tests.managers.metadata_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. :license: BSD, see LICENSE for more details. """ -import SoftLayer +from SoftLayer import MetadataManager, SoftLayerError, SoftLayerAPIError +from SoftLayer.consts import API_PRIVATE_ENDPOINT_REST try: import unittest2 as unittest except ImportError: - import unittest # NOQA + import unittest # NOQA from mock import patch, MagicMock -import urllib2 -import sys - -if sys.version_info >= (3,): - REQ_PATH = 'urllib.request.Request' - URLOPEN_PATH = 'urllib.request.urlopen' -else: - REQ_PATH = 'urllib2.Request' - URLOPEN_PATH = 'urllib2.urlopen' class MetadataTests(unittest.TestCase): def setUp(self): - self.metadata = SoftLayer.MetadataManager() + self.metadata = MetadataManager() self.make_request = MagicMock() self.metadata.make_request = self.make_request def test_no_param(self): - self.make_request.return_value = '"dal01"' + self.make_request.return_value = 'dal01' r = self.metadata.get('datacenter') self.make_request.assert_called_with("Datacenter.json") self.assertEqual('dal01', r) def test_w_param(self): - self.make_request.return_value = '[123]' + self.make_request.return_value = [123] r = self.metadata.get('vlans', '1:2:3:4:5') self.make_request.assert_called_with("Vlans/1:2:3:4:5.json") self.assertEqual([123], r) @@ -55,58 +47,64 @@ def test_return_none(self): self.assertEqual(None, r) def test_w_param_error(self): - self.assertRaises(SoftLayer.SoftLayerError, self.metadata.get, 'vlans') + self.assertRaises(SoftLayerError, self.metadata.get, 'vlans') def test_not_exists(self): - self.assertRaises( - SoftLayer.SoftLayerError, self.metadata.get, 'something') + self.assertRaises(SoftLayerError, self.metadata.get, 'something') def test_networks_not_exist(self): - self.make_request.return_value = '[]' + self.make_request.return_value = [] r = self.metadata.public_network() self.assertEqual({'mac_addresses': []}, r) def test_networks(self): - resp = '["list", "of", "stuff"]' - resp_list = ['list', 'of', 'stuff'] + resp = ['list', 'of', 'stuff'] self.make_request.return_value = resp r = self.metadata.public_network() self.assertEqual({ - 'vlan_ids': resp_list, - 'router': resp_list, - 'vlans': resp_list, - 'mac_addresses': resp_list + 'vlan_ids': resp, + 'router': resp, + 'vlans': resp, + 'mac_addresses': resp }, r) r = self.metadata.private_network() self.assertEqual({ - 'vlan_ids': resp_list, - 'router': resp_list, - 'vlans': resp_list, - 'mac_addresses': resp_list + 'vlan_ids': resp, + 'router': resp, + 'vlans': resp, + 'mac_addresses': resp }, r) class MetadataTestsMakeRequest(unittest.TestCase): def setUp(self): - self.metadata = SoftLayer.MetadataManager() + self.metadata = MetadataManager() self.url = '/'.join([ - SoftLayer.consts.API_PRIVATE_ENDPOINT_REST.rstrip('/'), + API_PRIVATE_ENDPOINT_REST.rstrip('/'), 'SoftLayer_Resource_Metadata', 'something.json']) - @patch(REQ_PATH) - @patch(URLOPEN_PATH) - def test_basic(self, urlopen, req): + @patch('SoftLayer.managers.metadata.make_rest_api_call') + def test_basic(self, make_api_call): r = self.metadata.make_request('something.json') - req.assert_called_with(self.url) - self.assertEqual(r, urlopen().read()) - - @patch(REQ_PATH) - @patch(URLOPEN_PATH) - def test_raise_urlerror(self, urlopen, req): - urlopen.side_effect = urllib2.URLError('Error') + make_api_call.assert_called_with( + 'GET', self.url, + timeout=5, + http_headers={'User-Agent': 'SoftLayer Python v2.3.0'}) + self.assertEqual(make_api_call(), r) + + @patch('SoftLayer.managers.metadata.make_rest_api_call') + def test_raise_error(self, make_api_call): + make_api_call.side_effect = SoftLayerAPIError( + 'faultCode', 'faultString') self.assertRaises( - SoftLayer.SoftLayerAPIError, + SoftLayerAPIError, self.metadata.make_request, 'something.json') + + @patch('SoftLayer.managers.metadata.make_rest_api_call') + def test_raise_404_error(self, make_api_call): + make_api_call.side_effect = SoftLayerAPIError(404, 'faultString') + r = self.metadata.make_request('something.json') + self.assertEqual(r, None) diff --git a/SoftLayer/tests/managers/queue_tests.py b/SoftLayer/tests/managers/queue_tests.py new file mode 100644 index 000000000..d95fcf8fc --- /dev/null +++ b/SoftLayer/tests/managers/queue_tests.py @@ -0,0 +1,413 @@ +""" + SoftLayer.tests.managers.cci_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. + :license: BSD, see LICENSE for more details. +""" +from SoftLayer import MessagingManager, Unauthenticated +import SoftLayer.managers.messaging +from SoftLayer.consts import USER_AGENT + +import json +try: + import unittest2 as unittest +except ImportError: + import unittest # NOQA +from mock import MagicMock, patch, ANY + +QUEUE_1 = { + 'expiration': 40000, + 'message_count': 0, + 'name': 'example_queue', + 'tags': ['tag1', 'tag2', 'tag3'], + 'visibility_interval': 10, + 'visible_message_count': 0} +QUEUE_LIST = {'item_count': 1, 'items': [QUEUE_1]} +MESSAGE_1 = { + 'body': '', + 'fields': {'field': 'value'}, + 'id': 'd344a01133b61181f57d9950a852eb10', + 'initial_entry_time': 1343402631.3917992, + 'message': 'Object created', + 'visibility_delay': 0, + 'visibility_interval': 30000} + +TOPIC_1 = {'name': 'example_topic', 'tags': ['tag1', 'tag2', 'tag3']} +TOPIC_LIST = {'item_count': 1, 'items': [TOPIC_1]} +SUBSCRIPTION_1 = { + 'endpoint': { + 'account_id': 'test', + 'queue_name': 'topic_subscription_queue'}, + 'endpoint_type': 'queue', + 'id': 'd344a01133b61181f57d9950a85704d4', + 'message': 'Object created'} +SUBSCRIPTION_LIST = {'item_count': 1, 'items': [SUBSCRIPTION_1]} + + + +def mocked_auth_call(self): + self.auth_token = 'NEW_AUTH_TOKEN' + + +class QueueAuthTests(unittest.TestCase): + def setUp(self): + self.auth = SoftLayer.managers.messaging.QueueAuth( + 'endpoint', 'username', 'api_key', auth_token='auth_token') + + def test_init(self): + auth = SoftLayer.managers.messaging.QueueAuth( + 'endpoint', 'username', 'api_key', auth_token='auth_token') + self.assertEqual(auth.endpoint, 'endpoint') + self.assertEqual(auth.username, 'username') + self.assertEqual(auth.api_key, 'api_key') + self.assertEqual(auth.auth_token, 'auth_token') + + @patch('SoftLayer.managers.messaging.requests.post') + def test_auth(self, post): + post().headers = {'X-Auth-Token': 'NEW_AUTH_TOKEN'} + post().ok = True + self.auth.auth() + self.auth.auth_token = 'NEW_AUTH_TOKEN' + + post().ok = False + self.assertRaises(Unauthenticated, self.auth.auth) + + @patch('SoftLayer.managers.messaging.QueueAuth.auth', mocked_auth_call) + def test_handle_error_200(self): + # No op on no error + request = MagicMock() + request.status_code = 200 + self.auth.handle_error(request) + + self.assertEqual(self.auth.auth_token, 'auth_token') + self.assertFalse(request.request.send.called) + + @patch('SoftLayer.managers.messaging.QueueAuth.auth', mocked_auth_call) + def test_handle_error_503(self): + # Retry once more on 503 error + request = MagicMock() + request.status_code = 503 + self.auth.handle_error(request) + + self.assertEqual(self.auth.auth_token, 'auth_token') + request.request.send.assert_called_with(anyway=True) + + @patch('SoftLayer.managers.messaging.QueueAuth.auth', mocked_auth_call) + def test_handle_error_401(self): + # Re-auth on 401 + request = MagicMock() + request.status_code = 401 + request.request.headers = {'X-Auth-Token': 'OLD_AUTH_TOKEN'} + self.auth.handle_error(request) + + self.assertEqual(self.auth.auth_token, 'NEW_AUTH_TOKEN') + request.request.send.assert_called_with(anyway=True) + + @patch('SoftLayer.managers.messaging.QueueAuth.auth', mocked_auth_call) + def test_call_unauthed(self): + request = MagicMock() + request.headers = {} + self.auth.auth_token = None + self.auth(request) + + self.assertEqual(self.auth.auth_token, 'NEW_AUTH_TOKEN') + request.register_hook.assert_called_with( + 'response', self.auth.handle_error) + self.assertEqual(request.headers, {'X-Auth-Token': 'NEW_AUTH_TOKEN'}) + + + +class MessagingManagerTests(unittest.TestCase): + + def setUp(self): + self.client = MagicMock() + self.manager = MessagingManager(self.client) + + def test_list_accounts(self): + self.manager.list_accounts() + self.client['Account'].getMessageQueueAccounts.assert_called_with( + mask=ANY) + + def test_get_endpoints(self): + endpoints = self.manager.get_endpoints() + self.assertEqual(endpoints, SoftLayer.managers.messaging.ENDPOINTS) + + @patch('SoftLayer.managers.messaging.ENDPOINTS', { + 'datacenter01': { + 'private': 'private_endpoint', 'public': 'public_endpoint'}, + 'dal05': { + 'private': 'dal05_private', 'public': 'dal05_public'}}) + def test_get_endpoint(self): + # Defaults to dal05, public + endpoint = self.manager.get_endpoint() + self.assertEqual(endpoint, 'https://dal05_public') + + endpoint = self.manager.get_endpoint(network='private') + self.assertEqual(endpoint, 'https://dal05_private') + + endpoint = self.manager.get_endpoint(datacenter='datacenter01') + self.assertEqual(endpoint, 'https://public_endpoint') + + endpoint = self.manager.get_endpoint(datacenter='datacenter01', + network='private') + self.assertEqual(endpoint, 'https://private_endpoint') + + endpoint = self.manager.get_endpoint(datacenter='datacenter01', + network='private') + self.assertEqual(endpoint, 'https://private_endpoint') + + # ERROR CASES + self.assertRaises( + TypeError, + self.manager.get_endpoint, datacenter='doesnotexist') + + self.assertRaises( + TypeError, + self.manager.get_endpoint, network='doesnotexist') + + # MessagingConnection + @patch('SoftLayer.managers.messaging.MessagingConnection') + def test_get_connection(self, conn): + queue_conn = self.manager.get_connection( + 'QUEUE_ACCOUNT_ID', 'USERNAME', 'API_KEY') + conn.assert_called_with( + 'QUEUE_ACCOUNT_ID', endpoint='https://dal05.mq.softlayer.net') + conn().authenticate.assert_called_with( + 'USERNAME', 'API_KEY') + self.assertEqual(queue_conn, conn()) + + @patch('SoftLayer.managers.messaging.requests.get') + def test_ping(self, get): + result = self.manager.ping() + + get.assert_called_with('https://dal05.mq.softlayer.net/v1/ping') + get().raise_for_status.assert_called_with() + self.assertTrue(result) + + +class MessagingConnectionTests(unittest.TestCase): + + def setUp(self): + self.conn = SoftLayer.managers.messaging.MessagingConnection( + 'acount_id', endpoint='endpoint') + self.auth = MagicMock() + self.conn.auth = self.auth + + def test_init(self): + self.assertEqual(self.conn.account_id, 'acount_id') + self.assertEqual(self.conn.endpoint, 'endpoint') + self.assertEqual(self.conn.auth, self.auth) + + @patch('SoftLayer.managers.messaging.requests.request') + def test_make_request(self, request): + resp = self.conn._make_request('GET', 'path') + request.assert_called_with( + 'GET', 'endpoint/v1/acount_id/path', + headers={ + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT}, + auth=self.auth) + request().raise_for_status.assert_called_with() + self.assertEqual(resp, request()) + + @patch('SoftLayer.managers.messaging.QueueAuth') + def test_authenticate(self, auth): + self.conn.authenticate('username', 'api_key', auth_token='auth_token') + + auth.assert_called_with( + 'endpoint/v1/acount_id/auth', 'username', 'api_key', + auth_token='auth_token') + auth().auth.assert_called_with() + self.assertEqual(self.conn.auth, auth()) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_stats(self, make_request): + content = { + 'notifications': [{'key': [2012, 7, 27, 14, 31], 'value': 2}], + 'requests': [{'key': [2012, 7, 27, 14, 31], 'value': 11}]} + make_request().content = json.dumps(content) + result = self.conn.stats() + + make_request.assert_called_with('get', 'stats/hour') + self.assertEqual(content, result) + + # Queue-based Tests + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_get_queues(self, make_request): + make_request().content = json.dumps(QUEUE_LIST) + result = self.conn.get_queues() + + make_request.assert_called_with('get', 'queues', params={}) + self.assertEqual(QUEUE_LIST, result) + + # with tags + result = self.conn.get_queues(tags=['tag1', 'tag2']) + + make_request.assert_called_with( + 'get', 'queues', params={'tags': 'tag1,tag2'}) + self.assertEqual(QUEUE_LIST, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_create_queue(self, make_request): + make_request().content = json.dumps(QUEUE_1) + result = self.conn.create_queue('example_queue') + + make_request.assert_called_with( + 'put', 'queues/example_queue', data='{}') + self.assertEqual(QUEUE_1, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_modify_queue(self, make_request): + make_request().content = json.dumps(QUEUE_1) + result = self.conn.modify_queue('example_queue') + + make_request.assert_called_with( + 'put', 'queues/example_queue', data='{}') + self.assertEqual(QUEUE_1, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_get_queue(self, make_request): + make_request().content = json.dumps(QUEUE_1) + result = self.conn.get_queue('example_queue') + + make_request.assert_called_with('get', 'queues/example_queue') + self.assertEqual(QUEUE_1, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_delete_queue(self, make_request): + result = self.conn.delete_queue('example_queue') + make_request.assert_called_with( + 'delete', 'queues/example_queue', params={}) + self.assertTrue(result) + + # With Force + result = self.conn.delete_queue('example_queue', force=True) + make_request.assert_called_with( + 'delete', 'queues/example_queue', params={'force': 1}) + self.assertTrue(result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_push_queue_message(self, make_request): + make_request().content = json.dumps(MESSAGE_1) + result = self.conn.push_queue_message('example_queue', '') + + make_request.assert_called_with( + 'post', 'queues/example_queue/messages', data='{"body": ""}') + self.assertEqual(MESSAGE_1, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_pop_message(self, make_request): + make_request().content = json.dumps(MESSAGE_1) + result = self.conn.pop_message('example_queue') + + make_request.assert_called_with( + 'get', 'queues/example_queue/messages', params={'batch': 1}) + self.assertEqual(MESSAGE_1, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_delete_message(self, make_request): + result = self.conn.delete_message('example_queue', MESSAGE_1['id']) + + make_request.assert_called_with( + 'delete', 'queues/example_queue/messages/%s' % MESSAGE_1['id']) + self.assertTrue(result) + + # Topic-based Tests + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_get_topics(self, make_request): + make_request().content = json.dumps(TOPIC_LIST) + result = self.conn.get_topics() + + make_request.assert_called_with('get', 'topics', params={}) + self.assertEqual(TOPIC_LIST, result) + + # with tags + result = self.conn.get_topics(tags=['tag1', 'tag2']) + + make_request.assert_called_with( + 'get', 'topics', params={'tags': 'tag1,tag2'}) + self.assertEqual(TOPIC_LIST, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_create_topic(self, make_request): + make_request().content = json.dumps(TOPIC_1) + result = self.conn.create_topic('example_topic') + + make_request.assert_called_with( + 'put', 'topics/example_topic', data='{}') + self.assertEqual(TOPIC_1, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_modify_topic(self, make_request): + make_request().content = json.dumps(TOPIC_1) + result = self.conn.modify_topic('example_topic') + + make_request.assert_called_with( + 'put', 'topics/example_topic', data='{}') + self.assertEqual(TOPIC_1, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_get_topic(self, make_request): + make_request().content = json.dumps(TOPIC_1) + result = self.conn.get_topic('example_topic') + + make_request.assert_called_with('get', 'topics/example_topic') + self.assertEqual(TOPIC_1, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_delete_topic(self, make_request): + result = self.conn.delete_topic('example_topic') + make_request.assert_called_with( + 'delete', 'topics/example_topic', params={}) + self.assertTrue(result) + + # With Force + result = self.conn.delete_topic('example_topic', force=True) + make_request.assert_called_with( + 'delete', 'topics/example_topic', params={'force': 1}) + self.assertTrue(result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_push_topic_message(self, make_request): + make_request().content = json.dumps(MESSAGE_1) + result = self.conn.push_topic_message('example_topic', '') + + make_request.assert_called_with( + 'post', 'topics/example_topic/messages', data='{"body": ""}') + self.assertEqual(MESSAGE_1, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_get_subscriptions(self, make_request): + make_request().content = json.dumps(SUBSCRIPTION_LIST) + result = self.conn.get_subscriptions('example_topic') + + make_request.assert_called_with( + 'get', 'topics/example_topic/subscriptions') + self.assertEqual(SUBSCRIPTION_LIST, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_create_subscription(self, make_request): + make_request().content = json.dumps(SUBSCRIPTION_1) + endpoint_details = { + 'account_id': 'test', + 'queue_name': 'topic_subscription_queue'} + result = self.conn.create_subscription( + 'example_topic', 'queue', **endpoint_details) + + make_request.assert_called_with( + 'post', 'topics/example_topic/subscriptions', + data=json.dumps({ + 'endpoint_type': 'queue', 'endpoint': endpoint_details})) + self.assertEqual(SUBSCRIPTION_1, result) + + @patch('SoftLayer.managers.messaging.MessagingConnection._make_request') + def test_delete_subscription(self, make_request): + make_request().content = json.dumps(SUBSCRIPTION_1) + result = self.conn.delete_subscription( + 'example_topic', SUBSCRIPTION_1['id']) + + make_request.assert_called_with( + 'delete', + 'topics/example_topic/subscriptions/%s' % SUBSCRIPTION_1['id']) + self.assertTrue(result) diff --git a/SoftLayer/tests/API/ssl_tests.py b/SoftLayer/tests/managers/ssl_tests.py similarity index 58% rename from SoftLayer/tests/API/ssl_tests.py rename to SoftLayer/tests/managers/ssl_tests.py index 5f887be4a..b8ddd2124 100644 --- a/SoftLayer/tests/API/ssl_tests.py +++ b/SoftLayer/tests/managers/ssl_tests.py @@ -1,11 +1,11 @@ """ - SoftLayer.tests.API.ssl_tests - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + SoftLayer.tests.managers.ssl_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. :license: BSD, see LICENSE for more details. """ -from SoftLayer.SSL import SSLManager +from SoftLayer import SSLManager try: import unittest2 as unittest @@ -14,7 +14,7 @@ from mock import MagicMock, ANY -class SSLTests_unittests(unittest.TestCase): +class SSLTests(unittest.TestCase): def setUp(self): self.client = MagicMock() @@ -23,16 +23,16 @@ def setUp(self): def test_list_certs(self): self.ssl.list_certs('valid') - self.client.__getitem__() \ - .getValidSecurityCertificates.assert_called_once_with(mask=ANY) + f = self.client['Account'].getValidSecurityCertificates + f.assert_called_once_with(mask=ANY) self.ssl.list_certs('expired') - self.client.__getitem__() \ - .getExpiredSecurityCertificates.assert_called_once_with(mask=ANY) + f = self.client['Account'].getExpiredSecurityCertificates + f.assert_called_once_with(mask=ANY) self.ssl.list_certs('all') - self.client.__getitem__() \ - .getSecurityCertificates.assert_called_once_with(mask=ANY) + f = self.client['Account'].getSecurityCertificates + f.assert_called_once_with(mask=ANY) def test_add_certificate(self): test_cert = { @@ -42,13 +42,13 @@ def test_add_certificate(self): self.ssl.add_certificate(test_cert) - self.client.__getitem__().createObject.assert_called_once_with( - test_cert) + f = self.client['Security_Certificate'].createObject + f.assert_called_once_with(test_cert) def test_remove_certificate(self): self.ssl.remove_certificate(self.test_id) - self.client.__getitem__() \ - .deleteObject.assert_called_once_with(id=self.test_id) + f = self.client['Security_Certificate'].deleteObject + f.assert_called_once_with(id=self.test_id) def test_edit_certificate(self): test_cert = { @@ -58,7 +58,8 @@ def test_edit_certificate(self): } self.ssl.edit_certificate(test_cert) - self.client.__getitem__().editObject.assert_called_once_with( + f = self.client['Security_Certificate'].editObject + f.assert_called_once_with( { 'id': self.test_id, 'certificate': 'cert', @@ -68,5 +69,5 @@ def test_edit_certificate(self): def test_get_certificate(self): self.ssl.get_certificate(self.test_id) - self.client.__getitem__().getObject.assert_called_once_with( - id=self.test_id) + f = self.client['Security_Certificate'].getObject + f.assert_called_once_with(id=self.test_id) diff --git a/SoftLayer/tests/transport_tests.py b/SoftLayer/tests/transport_tests.py new file mode 100644 index 000000000..f64aff440 --- /dev/null +++ b/SoftLayer/tests/transport_tests.py @@ -0,0 +1,124 @@ +""" + SoftLayer.tests.transport_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. + :license: BSD, see LICENSE for more details. +""" +try: + import unittest2 as unittest +except ImportError: + import unittest # NOQA +from mock import patch, MagicMock + +from SoftLayer import SoftLayerAPIError, TransportError +from SoftLayer.transport import make_rest_api_call, make_xml_rpc_api_call +from requests import HTTPError, RequestException + + +class TestXmlRpcAPICall(unittest.TestCase): + + @patch('SoftLayer.transport.requests.post') + def test_call(self, post): + post().content = ''' + + + + + + + + + +''' + + data = ''' + +getObject + + + + +headers + + + + + +''' + resp = make_xml_rpc_api_call( + 'http://something.com/path/to/resource', 'getObject') + self.assertEqual(resp, []) + post.assert_called_with( + 'http://something.com/path/to/resource', + data=data, + headers=None, + timeout=None,) + + +class TestRestAPICall(unittest.TestCase): + + @patch('SoftLayer.transport.requests.request') + def test_json(self, request): + request().content = '{}' + resp = make_rest_api_call( + 'GET', 'http://something.com/path/to/resource.json') + self.assertEqual(resp, {}) + request.assert_called_with( + 'GET', 'http://something.com/path/to/resource.json', + headers=None, + timeout=None) + + # Test JSON Error + e = HTTPError('error') + e.response = MagicMock() + e.response.status_code = 404 + e.response.content = '''{ + "error": "description", + "code": "Error Code" + }''' + request().raise_for_status.side_effect = e + + self.assertRaises( + SoftLayerAPIError, + make_rest_api_call, + 'GET', + 'http://something.com/path/to/resource.json') + + @patch('SoftLayer.transport.requests.request') + def test_text(self, request): + request().text = 'content' + resp = make_rest_api_call( + 'GET', 'http://something.com/path/to/resource.txt') + self.assertEqual(resp, 'content') + request.assert_called_with( + 'GET', 'http://something.com/path/to/resource.txt', + headers=None, + timeout=None) + + # Test Text Error + e = HTTPError('error') + e.response = MagicMock() + e.response.status_code = 404 + e.response.content = 'Error Code' + request().raise_for_status.side_effect = e + + self.assertRaises( + SoftLayerAPIError, + make_rest_api_call, + 'GET', + 'http://something.com/path/to/resource.txt') + + @patch('SoftLayer.transport.requests.request') + def test_unknown_error(self, request): + e = RequestException('error') + e.response = MagicMock() + e.response.status_code = 404 + e.response.content = 'Error Code' + request().raise_for_status.side_effect = e + + self.assertRaises( + TransportError, + make_rest_api_call, + 'GET', + 'http://something.com/path/to/resource.txt') diff --git a/SoftLayer/transport/requests_transport.py b/SoftLayer/transport.py similarity index 52% rename from SoftLayer/transport/requests_transport.py rename to SoftLayer/transport.py index d999d2f83..3b3984f27 100644 --- a/SoftLayer/transport/requests_transport.py +++ b/SoftLayer/transport.py @@ -1,6 +1,6 @@ """ - SoftLayer.transport.requests_transport - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + SoftLayer.transport + ~~~~~~~~~~~~~~~~~~~ XML-RPC transport layer that uses the requests library. :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. @@ -11,11 +11,23 @@ SpecViolation, MethodNotFound, InvalidMethodParameters, InternalError, ApplicationError, RemoteSystemError, TransportError) import xmlrpclib +import logging import requests +import json +log = logging.getLogger(__name__) -def make_api_call(uri, method, args=None, headers=None, - http_headers=None, timeout=None, verbose=False): + +def make_xml_rpc_api_call(uri, method, args=None, headers=None, + http_headers=None, timeout=None): + """ Makes a SoftLayer API call against the XML-RPC endpoint + + :param string uri: endpoint URL + :param string method: method to call E.G.: 'getObject' + :param dict headers: XML-RPC headers to use for the request + :param dict http_headers: HTTP headers to use for the request + :param int timeout: number of seconds to use as a timeout + """ if args is None: args = tuple() try: @@ -24,11 +36,12 @@ def make_api_call(uri, method, args=None, headers=None, payload = xmlrpclib.dumps(tuple(largs), methodname=method, allow_none=True) - + log.info('POST %s' % (uri)) + log.debug(payload) response = requests.post(uri, data=payload, headers=http_headers, timeout=timeout) - + log.debug(response.content) response.raise_for_status() result = xmlrpclib.loads(response.content,)[0][0] return result @@ -53,3 +66,31 @@ def make_api_call(uri, method, args=None, headers=None, raise TransportError(e.response.status_code, str(e)) except requests.RequestException, e: raise TransportError(0, str(e)) + + +def make_rest_api_call(method, url, http_headers=None, timeout=None): + """ Makes a SoftLayer API call against the REST endpoint + + :param string method: HTTP method: GET, POST, PUT, DELETE + :param string url: endpoint URL + :param dict http_headers: HTTP headers to use for the request + :param int timeout: number of seconds to use as a timeout + """ + log.info('%s %s' % (method, url)) + resp = requests.request(method, url, headers=http_headers, timeout=timeout) + try: + resp.raise_for_status() + except requests.HTTPError, e: + if url.endswith('.json'): + content = json.loads(e.response.content) + raise SoftLayerAPIError(e.response.status_code, content['error']) + else: + raise SoftLayerAPIError(e.response.status_code, e.response.text) + except requests.RequestException, e: + raise TransportError(0, str(e)) + + log.debug(resp.content) + if url.endswith('.json'): + return json.loads(resp.content) + else: + return resp.text diff --git a/SoftLayer/transport/__init__.py b/SoftLayer/transport/__init__.py deleted file mode 100644 index 4368b3855..000000000 --- a/SoftLayer/transport/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -try: - from SoftLayer.transport.requests_transport import make_api_call -except ImportError: # pragma: no cover - from SoftLayer.transport.xmlrpclib_transport import make_api_call diff --git a/SoftLayer/transport/xmlrpclib_transport.py b/SoftLayer/transport/xmlrpclib_transport.py deleted file mode 100644 index f82616892..000000000 --- a/SoftLayer/transport/xmlrpclib_transport.py +++ /dev/null @@ -1,81 +0,0 @@ -""" - SoftLayer.transport.requests_transport - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - XML-RPC transport layer that uses the built-in xmlrpclib library. This - exists to support Python 2.5. - - :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. - :license: BSD, see LICENSE for more details. -""" -from SoftLayer.exceptions import ( - SoftLayerAPIError, NotWellFormed, UnsupportedEncoding, InvalidCharacter, - SpecViolation, MethodNotFound, InvalidMethodParameters, InternalError, - ApplicationError, RemoteSystemError, TransportError) -from urlparse import urlparse -import xmlrpclib - -import socket - - -def make_api_call(uri, method, args=None, headers=None, http_headers=None, - timeout=None, verbose=False): - if args is None: - args = tuple() - http_protocol = urlparse(uri).scheme - - if http_protocol == "https": - transport = SecureProxyTransport() - else: - transport = ProxyTransport() - - if http_headers: - for name, value in http_headers.items(): - transport.set_raw_header(name, value) - - __prevDefaultTimeout = socket.getdefaulttimeout() - try: - socket.setdefaulttimeout(timeout) - proxy = xmlrpclib.ServerProxy(uri, transport=transport, - verbose=verbose, allow_none=True) - return getattr(proxy, method)({'headers': headers}, *args) - except xmlrpclib.Fault, e: - # These exceptions are formed from the XML-RPC spec - # http://xmlrpc-epi.sourceforge.net/specs/rfc.fault_codes.php - error_mapping = { - '-32700': NotWellFormed, - '-32701': UnsupportedEncoding, - '-32702': InvalidCharacter, - '-32600': SpecViolation, - '-32601': MethodNotFound, - '-32602': InvalidMethodParameters, - '-32603': InternalError, - '-32500': ApplicationError, - '-32400': RemoteSystemError, - '-32300': TransportError, - } - raise error_mapping.get(e.faultCode, SoftLayerAPIError)( - e.faultCode, e.faultString) - except xmlrpclib.ProtocolError, e: - raise TransportError(e.errcode, e.errmsg) - except socket.error, e: - raise TransportError(0, str(e)) - finally: - socket.setdefaulttimeout(__prevDefaultTimeout) - - -class ProxyTransport(xmlrpclib.Transport): - __extra_headers = None - - def set_raw_header(self, name, value): - if self.__extra_headers is None: - self.__extra_headers = {} - self.__extra_headers[name] = value - - def send_user_agent(self, connection): - if self.__extra_headers: - for k, v in self.__extra_headers.iteritems(): - connection.putheader(k, v) - - -class SecureProxyTransport(xmlrpclib.SafeTransport, ProxyTransport): - __extra_headers = {} diff --git a/SoftLayer/utils.py b/SoftLayer/utils.py index 935a397c5..b506b2588 100644 --- a/SoftLayer/utils.py +++ b/SoftLayer/utils.py @@ -47,6 +47,11 @@ def query_filter(query): :param string query: query string """ + try: + query = int(query) + except ValueError: + pass + if isinstance(query, basestring): query = query.strip() for op in KNOWN_OPERATIONS: @@ -76,16 +81,29 @@ def resolve_ids(self, identifier): :param string identifier: identifying string + :returns list: """ - # Before doing anything, let's see if this is an integer - try: - return [int(identifier)] - except ValueError: - pass # It was worth a shot - - for resolver in self.resolvers: - ids = resolver(identifier) - if ids: - return ids - - return [] + + return resolve_ids(identifier, self.resolvers) + + +def resolve_ids(identifier, resolvers): + """ Resolves IDs given a list of functions + + :param string identifier: identifier string + :param list resolvers: a list of functions + :returns list: + """ + + # Before doing anything, let's see if this is an integer + try: + return [int(identifier)] + except ValueError: + pass # It was worth a shot + + for resolver in resolvers: + ids = resolver(identifier) + if ids: + return ids + + return [] diff --git a/docs/api/client.rst b/docs/api/client.rst index af405c41b..dfdf115cd 100644 --- a/docs/api/client.rst +++ b/docs/api/client.rst @@ -125,6 +125,10 @@ Backwards Compatibility ----------------------- If you've been using the older Python client (<2.0), you'll be happy to know that the old API is still currently working. However, you should deprecate use of the old stuff. Below is an example of the old API converted to the new one. +.. automodule:: SoftLayer.deprecated + :members: + :undoc-members: + :: import SoftLayer.API diff --git a/docs/api/managers.rst b/docs/api/managers.rst index 1f866357a..c2bf1fe83 100644 --- a/docs/api/managers.rst +++ b/docs/api/managers.rst @@ -4,7 +4,8 @@ Managers -------- :: - >>> from SoftLayer.CCI import CCIManager + >>> from SoftLayer import CCIManager, Client + >>> client = Client(...) >>> cci = CCIManager(client) >>> cci.list_instances() [...] diff --git a/docs/api/managers/cci.rst b/docs/api/managers/cci.rst index 5af2a9a1d..d845c44cc 100644 --- a/docs/api/managers/cci.rst +++ b/docs/api/managers/cci.rst @@ -1,6 +1,6 @@ .. _cci: -.. automodule:: SoftLayer.CCI +.. automodule:: SoftLayer.managers.cci :members: :inherited-members: :undoc-members: diff --git a/docs/api/managers/dns.rst b/docs/api/managers/dns.rst index 047a1ea49..eef936177 100644 --- a/docs/api/managers/dns.rst +++ b/docs/api/managers/dns.rst @@ -1,6 +1,6 @@ .. _dns: -.. automodule:: SoftLayer.DNS +.. automodule:: SoftLayer.managers.dns :members: :inherited-members: :undoc-members: diff --git a/docs/api/managers/firewall.rst b/docs/api/managers/firewall.rst index ec4eda1a2..8bb5cd5b1 100644 --- a/docs/api/managers/firewall.rst +++ b/docs/api/managers/firewall.rst @@ -1,6 +1,6 @@ .. _firewall: -.. automodule:: SoftLayer.firewall +.. automodule:: SoftLayer.managers.firewall :members: :inherited-members: :undoc-members: diff --git a/docs/api/managers/hardware.rst b/docs/api/managers/hardware.rst new file mode 100644 index 000000000..6be2bbba8 --- /dev/null +++ b/docs/api/managers/hardware.rst @@ -0,0 +1,6 @@ +.. _hardware: + +.. automodule:: SoftLayer.managers.hardware + :members: + :inherited-members: + :undoc-members: diff --git a/docs/api/managers/messaging.rst b/docs/api/managers/messaging.rst new file mode 100644 index 000000000..e04257917 --- /dev/null +++ b/docs/api/managers/messaging.rst @@ -0,0 +1,8 @@ +.. _messaging: + +SoftLayer.messaging +=================== +.. automodule:: SoftLayer.managers.messaging + :members: + :inherited-members: + :undoc-members: diff --git a/docs/api/managers/metadata.rst b/docs/api/managers/metadata.rst index e5cb87b2d..e37ec86ca 100644 --- a/docs/api/managers/metadata.rst +++ b/docs/api/managers/metadata.rst @@ -1,8 +1,8 @@ .. _metadata: -.. automodule:: SoftLayer.metadata +.. automodule:: SoftLayer.managers.metadata :members: :inherited-members: :undoc-members: -.. autoattribute:: SoftLayer.metadata.METADATA_ATTRIBUTES \ No newline at end of file +.. autoattribute:: SoftLayer.managers.metadata.METADATA_ATTRIBUTES \ No newline at end of file diff --git a/docs/api/managers/ssl.rst b/docs/api/managers/ssl.rst index 17c9e053e..b1fb6dd1f 100644 --- a/docs/api/managers/ssl.rst +++ b/docs/api/managers/ssl.rst @@ -1,6 +1,6 @@ .. _ssl: -.. automodule:: SoftLayer.SSL +.. automodule:: SoftLayer.managers.ssl :members: :inherited-members: :undoc-members: diff --git a/docs/cli.rst b/docs/cli.rst index bcfa9727a..37eca772f 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -47,13 +47,13 @@ The only required fields are `username` and `api_key`. You can optionally also/e [softlayer] username = username api_key = oyVmeipYQCNrjVS4rF9bHWV7D75S6pa1fghFl384v7mwRCbHTfuJ8qRORIqoVnha - endpoint_url = http://api.softlayer.com/xmlrpc/v3/ + endpoint_url = https://api.softlayer.com/xmlrpc/v3/ *exclusive url* :: [softlayer] - endpoint_url = http://api.softlayer.com/xmlrpc/v3/ + endpoint_url = https://api.softlayer.com/xmlrpc/v3/ Usage Examples @@ -154,4 +154,4 @@ Most commands will take in additional options/arguments. To see all available ac Standard Options: --format=ARG Output format. [Options: table, raw] [Default: table] -C FILE --config=FILE Config file location. [Default: ~/.softlayer] - -h --help Show this screen \ No newline at end of file + -h --help Show this screen diff --git a/docs/cli/cci.rst b/docs/cli/cci.rst new file mode 100644 index 000000000..60fda6ffb --- /dev/null +++ b/docs/cli/cci.rst @@ -0,0 +1,177 @@ +.. _cci: + +Working with Cloud Compute Instances +==================================== +Using the SoftLayer portal for ordering Cloud Compute Instances is fine but for a number of reasons it's sometimes to use the command-line. For this, you can use the SoftLayer command-line client to make administrative tasks quicker and easier. This page gives an intro to working with SoftLayer Cloud Compute Instances using the SoftLayer command-line client. + +.. note:: + + The following assumes that the client is already :ref:`configured with valid SoftLayer credentials`. + + +First, let's list the current Cloud Compute Instances with `sl cci list`. +:: + + $ sl cci list + :....:............:......:.......:........:............:............:....................: + : id : datacenter : host : cores : memory : primary_ip : backend_ip : active_transaction : + :....:............:......:.......:........:............:............:....................: + :....:............:......:.......:........:............:............:....................: + +We don't have any Cloud Compute Instances! Let's fix that. Before we can create a CCI, we need to know what options are available to me: RAM, CPU, operating systems, disk sizes, disk types, datacenters. Luckily, there's a simple command to do that, `sl cci create-options`. + +:: + + $ sl cci create-options + :.................:..............................................................................................: + : Name : Value : + :.................:..............................................................................................: + : datacenter : ams01,dal01,dal05,sea01,sjc01,sng01,wdc01 : + : cpus (private) : 1,2,4,8 : + : cpus (standard) : 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16 : + : memory : 1024,2048,3072,4096,5120,6144,7168,8192,9216,10240,11264,12288,13312,14336,15360,16384,32768 : + : os (CENTOS) : CENTOS_5_32 : + : : CENTOS_5_64 : + : : CENTOS_6_32 : + : : CENTOS_6_64 : + : os (CLOUDLINUX) : CLOUDLINUX_5_32 : + : : CLOUDLINUX_5_64 : + : : CLOUDLINUX_6_32 : + : : CLOUDLINUX_6_64 : + : os (DEBIAN) : DEBIAN_5_32 : + : : DEBIAN_5_64 : + : : DEBIAN_6_32 : + : : DEBIAN_6_64 : + : : DEBIAN_7_32 : + : : DEBIAN_7_64 : + : os (REDHAT) : REDHAT_5_64 : + : : REDHAT_6_32 : + : : REDHAT_6_64 : + : os (UBUNTU) : UBUNTU_10_32 : + : : UBUNTU_10_64 : + : : UBUNTU_12_32 : + : : UBUNTU_12_64 : + : : UBUNTU_8_32 : + : : UBUNTU_8_64 : + : os (VYATTACE) : VYATTACE_6.5_64 : + : os (WIN) : WIN_2003-DC-SP2-1_32 : + : : WIN_2003-DC-SP2-1_64 : + : : WIN_2003-ENT-SP2-5_32 : + : : WIN_2003-ENT-SP2-5_64 : + : : WIN_2003-STD-SP2-5_32 : + : : WIN_2003-STD-SP2-5_64 : + : : WIN_2008-DC-R2_64 : + : : WIN_2008-DC-SP2_32 : + : : WIN_2008-DC-SP2_64 : + : : WIN_2008-ENT-R2_64 : + : : WIN_2008-ENT-SP2_32 : + : : WIN_2008-ENT-SP2_64 : + : : WIN_2008-STD-R2-SP1_64 : + : : WIN_2008-STD-R2_64 : + : : WIN_2008-STD-SP2_32 : + : : WIN_2008-STD-SP2_64 : + : : WIN_2012-DC_64 : + : : WIN_2012-STD_64 : + : : WIN_7-ENT_32 : + : : WIN_7-PRO_32 : + : : WIN_8-ENT_64 : + : local disk(0) : 25,100 : + : local disk(2) : 25,100,150,200,300 : + : san disk(0) : 25,100 : + : san disk(2) : 10,20,25,30,40,50,75,100,125,150,175,200,250,300,350,400,500,750,1000,1500,2000 : + : san disk(3) : 10,20,25,30,40,50,75,100,125,150,175,200,250,300,350,400,500,750,1000,1500,2000 : + : san disk(4) : 10,20,25,30,40,50,75,100,125,150,175,200,250,300,350,400,500,750,1000,1500,2000 : + : san disk(5) : 10,20,25,30,40,50,75,100,125,150,175,200,250,300,350,400,500,750,1000,1500,2000 : + : nic : 10,100,1000 : + :.................:..............................................................................................: + +Here's the command to create a 2-core, 1G memory, Ubuntu 12.04 hourly instance in the San Jose datacenter using the command `sl cci create`. + +:: + + $ sl cci create --host=example --domain=softlayer.com -c 2 -m 1024 -o UBUNTU_12_64 --hourly --datacenter sjc01 + This action will incur charges on your account. Continue? [y/N]: y + :.........:......................................: + : name : value : + :.........:......................................: + : id : 1234567 : + : created : 2013-06-13T08:29:44-06:00 : + : guid : 6e013cde-a863-46ee-8s9a-f806dba97c89 : + :.........:......................................: + + +With the last command, the Cloud Compute Instance has begun being created. It should instantly appear in your listing now. + +:: + + $ sl cci list + :.........:............:.......................:.......:........:................:..............:....................: + : id : datacenter : host : cores : memory : primary_ip : backend_ip : active_transaction : + :.........:............:.......................:.......:........:................:..............:....................: + : 1234567 : sjc01 : example.softlayer.com : 2 : 1G : 108.168.200.11 : 10.54.80.200 : Assign Host : + :.........:............:.......................:.......:........:................:..............:....................: + +Cool. You may ask "It's creating... but how do I know when it's done?". Well, here's how: + +:: + + $ sl cci ready 'example' --wait=600 + READY + +When the previous command returns, I know that the Cloud Compute Instance has finished the provisioning process and is ready to use. This is *very* useful for chaining commands together. Now that you have your Cloud Compute Instance, let's get access to it. To do that, use the `sl cci detail` command. From the example below, you can see that the username is 'root' and password is 'ABCDEFGH'. + +.. warning:: + + Be careful when using the `--passwords` flag. This will print the password to the Cloud Compute Instance onto the screen. Make sure no one is looking over your shoulder. It's also advisable to change your root password soon after creating your Cloud Compute Instance. + +:: + + $ sl cci detail example --passwords + :..............:...........................: + : Name : Value : + :..............:...........................: + : id : 1234567 : + : hostname : example.softlayer.com : + : status : Active : + : state : Running : + : datacenter : sjc01 : + : cores : 2 : + : memory : 1G : + : public_ip : 108.168.200.11 : + : private_ip : 10.54.80.200 : + : os : Ubuntu : + : private_only : False : + : private_cpu : False : + : created : 2013-06-13T08:29:44-06:00 : + : modified : 2013-06-13T08:31:57-06:00 : + : users : root ABCDEFGH : + :..............:...........................: + + +There are many other commands to help manage Cloud Compute Instances. To see them all, use `sl help cci`. + +:: + + $ sl help cci + usage: sl cci [] [...] [options] + + Manage, delete, order compute instances + + The available commands are: + network Manage network settings + create Order and create a CCI + (see `sl cci create-options` for choices) + manage Manage active CCI + list List CCI's on the account + detail Output details about a CCI + dns DNS related actions to a CCI + cancel Cancel a running CCI + create-options Output available available options when creating a CCI + reload Reload the OS on a CCI based on its current configuration + ready Check if a CCI has finished provisioning + + For several commands, will be asked for. This can be the id, + hostname or the ip address for a CCI. + + Standard Options: + -h --help Show this screen diff --git a/docs/conf.py b/docs/conf.py index e8ddfdeb8..01d493dd1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,9 +49,9 @@ # built documents. # # The short X.Y version. -version = '2.2.0' +version = '2.3.0' # The full version, including alpha/beta/rc tags. -release = '2.2.0' +release = '2.3.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/index.rst b/docs/index.rst index c088c90c9..21f2fcd28 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -46,6 +46,7 @@ Command-Line Interface :maxdepth: 2 cli + cli/cci cli/dev diff --git a/docs/install.rst b/docs/install.rst index f9513a68b..41d8f26e0 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -14,7 +14,7 @@ Install from source gia pip (requires git): :: $ pip install git+git://github.com/softlayer/softlayer-api-python-client.git -The most up to date version of this library can be found on the SoftLayer GitHub public repositories: http://github.com/softlayer. Please post to the SoftLayer forums http://forums.softlayer.com/ or open a support ticket in the SoftLayer customer portal if you have any questions regarding use of this library. +The most up to date version of this library can be found on the SoftLayer GitHub public repositories: https://github.com/softlayer. Please post to the SoftLayer forums https://forums.softlayer.com/ or open a support ticket in the SoftLayer customer portal if you have any questions regarding use of this library. From Source @@ -25,7 +25,7 @@ The project is developed on GitHub, at `github.com/softlayer/softlayer-api-pytho You can clone the public repository:: - git clone git://github.com/softlayer/softlayer-api-python-client.git + $ git clone git://github.com/softlayer/softlayer-api-python-client.git Or, Download the `tarball `_:: @@ -39,4 +39,4 @@ Or, download the `zipball = (3,): extra['use_2to3'] = True -requires = ['distribute', 'prettytable', 'docopt==0.6.1'] +requires = [ + 'distribute', + 'prettytable >= 0.7.0', + 'docopt == 0.6.1', + 'requests' +] if sys.version_info < (2, 7): requires.append('importlib') -elif sys.version_info >= (2, 6): - requires.append('requests') -if sys.version_info <= (2, 6): - requires.append('simplejson') description = "A library to use SoftLayer's API" @@ -29,12 +38,20 @@ setup( name='SoftLayer', - version='2.2.0', + version='2.3.0', description=description, long_description=long_description, author='SoftLayer Technologies, Inc.', author_email='sldn@softlayer.com', - packages=find_packages(), + packages=[ + 'SoftLayer', + 'SoftLayer.CLI', + 'SoftLayer.CLI.modules', + 'SoftLayer.managers', + 'SoftLayer.tests', + 'SoftLayer.tests.CLI', + 'SoftLayer.tests.managers', + ], license='The BSD License', zip_safe=False, url='http://github.com/softlayer/softlayer-api-python-client', @@ -46,6 +63,7 @@ package_data={ 'SoftLayer': ['tests/fixtures/*'], }, + test_suite = 'nose.collector', install_requires=requires, classifiers=[ 'Environment :: Console', @@ -55,13 +73,11 @@ 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Topic :: Software Development :: Libraries :: Python Modules', - 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ], diff --git a/tox.ini b/tox.ini index 0cf1f4a2b..931c60a03 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py25,py26,py27,py32,py33,pypy +envlist = py26,py27,py32,py33,pypy [testenv] commands = nosetests []