diff --git a/.travis.yml b/.travis.yml index ff4a0f909..8fb11f473 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,4 +11,3 @@ install: - "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..cfddce6bb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,15 +1,71 @@ +3.0.0 + + * Many bug fixes and consistency improvements + + * API: Removes old API client interfaces which have been deprecated in the v2. See link for more details: https://softlayer-api-python-client.readthedocs.org/en/latest/api/client/#backwards-compatibility + + * CLI+API: Improved dedicated server ordering. Adds power management for hardware servers: power-on, power-off, power-cycle, reboot + + * CLI+API: Adds a networking manager and adds several network-related CLI modules. This includes the ability to: + + * list, create, cancel and assign global IPs + + * list, create, cancel and detail subnets. Also has the ability to lookup details about an IP address with 'sl subnet lookup' + + * list, detail VLANs + + * show and edit RWhois data + + * CLI+API: Adds SoftLayer Message Queue Service bindings (as a manager) and a CLI counterpart. With this you can interact with existing message queue accounts + + * CLI+API: Ability to manage SSH Keys with a manager and a CLI module + + * CLI+API: Adds the ability to create CCIs with the following options: metadata, post-install script, SSH key + + * CLI: Adds templating for creating CCIs and hardware nodes which can be used to create more CCIs and hardware with the same settings + + * CLI+API: Adds the ability to create hardware servers with a default SSH key + + * CLI: Adds a --debug option to print out debugging information. --debug=3 is the highest log level which prints full HTTP request/responses including the body + + * CLI: The commands in the main help are now organized into categories + + +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 - * Added sphinx documentation. See it here: http://softlayer.github.com/softlayer-api-python-client + * Added sphinx documentation. See it here: https://softlayer-api-python-client.readthedocs.org * CCI: Adds Support for Additional Disks * 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/LICENSE b/LICENSE index 02476d93b..38a232e2a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,19 @@ -Copyright (c) 2013, SoftLayer Technologies, Inc. All rights reserved. +Copyright (c) 2013 SoftLayer Technologies, Inc. All rights reserved. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither SoftLayer Technologies, Inc. nor the names of its contributors may be - used to endorse or promote products derived from this software without - specific prior written permission. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index 689e50f5b..9d5d250d0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ include LICENSE -include README.md \ No newline at end of file +include README.rst diff --git a/README.md b/README.md deleted file mode 100644 index 6e53d3c5c..000000000 --- a/README.md +++ /dev/null @@ -1,44 +0,0 @@ -SoftLayer API Python Client -=========================== -SoftLayer API bindings for Python. For use with -[SoftLayer's API](http://sldn.softlayer.com/reference/softlayerapi). For more -documentation, [go here](http://softlayer.github.com/softlayer-api-python-client/). - -This library provides a simple interface to interact with SoftLayer's XML-RPC -API and provides support for many of SoftLayer API's features like -[object masks](http://sldn.softlayer.com/article/Using-Object-Masks-SoftLayerAPI). -It also contains a command-line interface that can be used to access various -SoftLayer services using the API. - -Installation ------------- -Install via pip: -``` -pip install softlayer -``` - -Or you can install from source. Download source and run: - -``` -python setup.py install -``` - - -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. - -System Requirements -------------------- -* 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. - - -Copyright ---------- -This software is Copyright (c) 2013 [SoftLayer Technologies, Inc](http://www.softlayer.com/). -See the bundled LICENSE file for more information. diff --git a/README.rst b/README.rst new file mode 100644 index 000000000..8999279b8 --- /dev/null +++ b/README.rst @@ -0,0 +1,53 @@ +SoftLayer API Python Client +=========================== +.. image:: https://api.travis-ci.org/softlayer/softlayer-api-python-client.png + :target: https://travis-ci.org/softlayer/softlayer-api-python-client + +.. image:: https://badge.fury.io/py/SoftLayer.png + :target: http://badge.fury.io/py/SoftLayer + +.. image:: https://pypip.in/d/SoftLayer/badge.png + :target: https://crate.io/packages/SoftLayer + +SoftLayer API bindings for Python. For use with `SoftLayer's API `_. + +This library provides a simple interface to interact with SoftLayer's XML-RPC API and provides support for many of SoftLayer API's features like `object masks `_ and a command-line interface that can be used to access various SoftLayer services using the API. + +Documentation +------------- +Documentation is available at https://softlayer-api-python-client.readthedocs.org/ + +Installation +------------ +Install via pip: + +.. code-block:: bash + + $ pip install softlayer + + +Or you can install from source. Download source and run: + +.. code-block:: bash + + $ python setup.py install + + +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. + +System Requirements +------------------- +* 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. + + +Copyright +--------- +This software is Copyright (c) 2013 SoftLayer Technologies, Inc. +See the bundled LICENSE file for more information. diff --git a/SoftLayer/API.py b/SoftLayer/API.py index dbcae121b..b3b2940ed 100644 --- a/SoftLayer/API.py +++ b/SoftLayer/API.py @@ -4,22 +4,20 @@ SoftLayer API bindings :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. - :license: BSD, see LICENSE for more details. + :license: MIT, see LICENSE for more details. """ -from SoftLayer.consts import API_PUBLIC_ENDPOINT, API_PRIVATE_ENDPOINT, \ - USER_AGENT -from SoftLayer.transport import make_xml_rpc_api_call -from SoftLayer.exceptions import SoftLayerError -from SoftLayer.deprecated import DeprecatedClientMixin -import os +import datetime +from consts import API_PUBLIC_ENDPOINT, API_PRIVATE_ENDPOINT, USER_AGENT +from transports import make_xml_rpc_api_call +from exceptions import SoftLayerError +from auth import TokenAuthentication +from config import get_client_settings -__all__ = ['Client', 'BasicAuthentication', 'TokenAuthentication', - 'API_PUBLIC_ENDPOINT', 'API_PRIVATE_ENDPOINT'] -API_USERNAME = None -API_KEY = None -API_BASE_URL = API_PUBLIC_ENDPOINT +__all__ = ['Client', 'TimedClient', 'API_PUBLIC_ENDPOINT', + 'API_PRIVATE_ENDPOINT'] + VALID_CALL_ARGS = set([ 'id', 'mask', @@ -31,53 +29,9 @@ ]) -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 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): +class Client(object): """ A SoftLayer API client. - :param service_name: the name of the SoftLayer API service to query - :param integer id: an optional object ID if you're instantiating a - particular SoftLayer_API object. Setting an ID defines this client's - initialization parameter. :param username: an optional API username if you wish to bypass the package's built-in username :param api_key: an optional API key if you wish to bypass the package's @@ -88,6 +42,7 @@ class Client(DeprecatedClientMixin, object): :param integer timeout: timeout for API requests :param auth: an object which responds to get_headers() to be inserted into the xml-rpc headers. Example: `BasicAuthentication` + :param config_file: A path to a configuration file used to load settings Usage: @@ -100,28 +55,22 @@ class Client(DeprecatedClientMixin, object): """ _prefix = "SoftLayer_" - def __init__(self, service_name=None, id=None, username=None, api_key=None, - endpoint_url=None, timeout=None, auth=None): - self._service_name = service_name - self._headers = {} - self._raw_headers = {} - - 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 - - super(Client, self).__init__( - service_name=service_name, id=id, username=username, - api_key=api_key, endpoint_url=endpoint_url, timeout=timeout, - auth=auth) + def __init__(self, username=None, api_key=None, endpoint_url=None, + timeout=None, auth=None, config_file=None): + + settings = get_client_settings(username=username, + api_key=api_key, + endpoint_url=endpoint_url, + timeout=timeout, + auth=auth, + config_file=config_file) + self.auth = settings.get('auth') + self.endpoint_url = ( + settings.get('endpoint_url') or API_PUBLIC_ENDPOINT).rstrip('/') + self.timeout = None + self.last_calls = [] + if settings.get('timeout'): + self.timeout = float(settings.get('timeout')) def authenticate_with_password(self, username, password, security_question_id=None, @@ -136,6 +85,7 @@ def authenticate_with_password(self, username, password, question """ + self.auth = None res = self['User_Customer'].getPortalLoginToken( username, password, @@ -150,6 +100,7 @@ def __getitem__(self, name): :param name: The name of the service. E.G. Account Usage: + >>> import SoftLayer >>> client = SoftLayer.Client() >>> client['Account'] @@ -169,6 +120,7 @@ def call(self, service, method, *args, **kwargs): :param service: the name of the SoftLayer API service Usage: + >>> import SoftLayer >>> client = SoftLayer.Client() >>> client['Account'].getVirtualGuests(mask="id", limit=10) [...] @@ -193,19 +145,8 @@ def call(self, service, method, *args, **kwargs): limit = kwargs.get('limit') offset = kwargs.get('offset', 0) - if not headers and self.auth: - headers = self.auth.get_headers() - - http_headers = { - 'User-Agent': USER_AGENT, - 'Content-Type': 'application/xml', - } - if self._raw_headers: - for name, value in self._raw_headers.items(): - http_headers[name] = value - if raw_headers: - for name, value in raw_headers.items(): - http_headers[name] = value + if self.auth: + headers.update(self.auth.get_headers()) if objectid is not None: headers[service + 'InitParameters'] = {'id': int(objectid)} @@ -221,7 +162,15 @@ def call(self, service, method, *args, **kwargs): 'limit': int(limit), 'offset': int(offset) } - uri = '/'.join([self._endpoint_url, service]) + + http_headers = { + 'User-Agent': USER_AGENT, + 'Content-Type': 'application/xml', + } + if raw_headers: + http_headers.update(raw_headers) + + uri = '/'.join([self.endpoint_url, service]) return make_xml_rpc_api_call(uri, method, args, headers=headers, http_headers=http_headers, @@ -307,11 +256,43 @@ def __format_object_mask(self, objectmask, service): def __repr__(self): return "" \ - % (self._endpoint_url, self.auth) + % (self.endpoint_url, self.auth) __str__ = __repr__ +class TimedClient(Client): + """ Subclass of Client() + + Using this class will time every call to the API and store it in an + internal list. This will have a slight impact on your client's memory + usage and performance. You should only use this for debugging. + """ + last_calls = [] + + def call(self, service, method, *args, **kwargs): + """ See Client.call for documentation. """ + start_time = datetime.datetime.utcnow() + result = super(TimedClient, self).call(service, method, *args, + **kwargs) + end_time = datetime.datetime.utcnow() + diff = end_time - start_time + self.last_calls.append((service + '.' + method, + start_time.strftime('%s'), + diff.total_seconds())) + return result + + def get_last_calls(self): + """ Retrieves the last_calls property. + + This property will contain a list of tuples in the form + ('SERVICE.METHOD', initiated_utc_timestamp, execution_time) + """ + last_calls = self.last_calls + self.last_calls = [] + return last_calls + + class Service(object): def __init__(self, client, name): self.client = client @@ -333,6 +314,8 @@ def call(self, name, *args, **kwargs): results Usage: + >>> import SoftLayer + >>> client = SoftLayer.Client() >>> client['Account'].getVirtualGuests(mask="id", limit=10) [...] @@ -351,6 +334,8 @@ def iter_call(self, name, *args, **kwargs): ``Service.call`` takes Usage: + >>> import SoftLayer + >>> client = SoftLayer.Client() >>> gen = client['Account'].getVirtualGuests(iter=True) >>> for virtual_guest in gen: ... virtual_guest['id'] diff --git a/SoftLayer/CLI/__init__.py b/SoftLayer/CLI/__init__.py index 7a28c736e..821f28710 100644 --- a/SoftLayer/CLI/__init__.py +++ b/SoftLayer/CLI/__init__.py @@ -4,7 +4,7 @@ Contains all code related to the CLI interface :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. - :license: BSD, see LICENSE for more details. + :license: MIT, see LICENSE for more details. """ diff --git a/SoftLayer/CLI/core.py b/SoftLayer/CLI/core.py index e795f6975..1a1428f57 100644 --- a/SoftLayer/CLI/core.py +++ b/SoftLayer/CLI/core.py @@ -7,18 +7,32 @@ SoftLayer Command-line Client The available modules are: - cci Manage, delete, order compute instances - 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 + +Compute: + bmc Bare Metal Cloud + cci Cloud Compute Instances image Manages compute and flex images metadata Get details about this machine. Also available with 'my' and 'meta' - nas View NAS details + server Hardware servers + sshkey Manage SSH keys on your account + +Networking: + dns Domain Name System + firewall Firewall rule and security management + globalip Global IP address management + rwhois RWhoIs operations ssl Manages SSL + subnet Subnet ordering and management + vlan Manage VLANs on your account + +Storage: + iscsi View iSCSI details + nas View NAS details + +General: + config View and edit configuration for this tool + summary Display an overall summary of your account + help Show help See 'sl help ' for more information on a specific module. @@ -26,21 +40,17 @@ The easiest way to do that is to use: 'sl config setup' """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. -# :license: BSD, see LICENSE for more details. +# :license: MIT, see LICENSE for more details. import sys -import os -import os.path import logging -from prettytable import FRAME, NONE from docopt import docopt, DocoptExit -from SoftLayer import Client, SoftLayerError +from SoftLayer import Client, TimedClient, SoftLayerError, SoftLayerAPIError from SoftLayer.consts import VERSION -from SoftLayer.CLI.helpers import ( - Table, CLIAbort, FormattedItem, listing, ArgumentError, SequentialOutput) -from SoftLayer.CLI.environment import ( +from helpers import CLIAbort, ArgumentError, format_output, KeyValueTable +from environment import ( Environment, CLIRunnableType, InvalidCommand, InvalidModule) @@ -51,55 +61,7 @@ '3': logging.DEBUG } - -def format_output(data, fmt='table'): - if isinstance(data, basestring): - return data - - if isinstance(data, Table): - if fmt == 'table': - return str(format_prettytable(data)) - elif fmt == 'raw': - return str(format_no_tty(data)) - - if fmt != 'raw' and isinstance(data, FormattedItem): - return str(data.formatted) - - if isinstance(data, SequentialOutput): - output = [format_output(d, fmt=fmt) for d in data] - if not data.blanks: - output = [x for x in output if len(x)] - return format_output(output, fmt=fmt) - - if isinstance(data, list) or isinstance(data, tuple): - output = [format_output(d, fmt=fmt) for d in data] - return format_output(listing(output, separator=os.linesep)) - - return str(data) - - -def format_prettytable(table): - for i, row in enumerate(table.rows): - for j, item in enumerate(row): - table.rows[i][j] = format_output(item) - t = table.prettytable() - t.hrules = FRAME - t.horizontal_char = '.' - t.vertical_char = ':' - t.junction_char = ':' - return t - - -def format_no_tty(table): - t = table.prettytable() - for col in table.columns: - t.align[col] = 'l' - t.hrules = NONE - t.border = False - t.header = False - t.left_padding_width = 0 - t.right_padding_width = 2 - return t +VALID_FORMATS = ['raw', 'table', 'json'] class CommandParser(object): @@ -136,6 +98,7 @@ def get_command_help(self, module_name, command_name): -C FILE --config=FILE Config file location. [Default: ~/.softlayer] --debug=LEVEL Specifies the debug noise level 1=warn, 2=info, 3=debug + --timings Time each API call and display after results -h --help Show this screen """ % default_format return arg_doc.strip() @@ -174,7 +137,9 @@ def parse(self, args): # handle `sl ...` module, module_args = self.parse_module_args( module_name, main_args['']) - command_name = module_args[''] + + # get the command argument + command_name = module_args.get('') # handle `sl ...` return self.parse_command_args( @@ -202,29 +167,32 @@ def main(args=sys.argv[1:], env=Environment()): logger.addHandler(h) logger.setLevel(DEBUG_LOGGING_MAP.get(debug_level, logging.DEBUG)) - # Parse Config - config_files = ["~/.softlayer"] - - if command_args.get('--config'): - config_files.append(command_args.get('--config')) - - env.load_config(config_files) - client = Client( - username=env.config.get('username'), - api_key=env.config.get('api_key'), - endpoint_url=env.config.get('endpoint_url')) + if command_args.get('--timings'): + client = TimedClient(config_file=command_args.get('--config')) + else: + client = Client(config_file=command_args.get('--config')) # Do the thing data = command.execute(client, command_args) if data: format = command_args.get('--format', 'table') - if format not in ['raw', 'table']: + if format not in VALID_FORMATS: raise ArgumentError('Invalid format "%s"' % format) s = format_output(data, fmt=format) if s: env.out(s) - except InvalidCommand, e: + if command_args.get('--timings'): + format = command_args.get('--format', 'table') + api_calls = client.get_last_calls() + t = KeyValueTable(['call', 'time']) + + for call, initiated, duration in api_calls: + t.add_row([call, duration]) + + env.err(format_output(t, fmt=format)) + + except InvalidCommand as e: env.err(resolver.get_module_help(e.module_name)) if e.command_name: env.err('') @@ -236,9 +204,7 @@ def main(args=sys.argv[1:], env=Environment()): env.err('') env.err(str(e)) exit_status = 1 - except (ValueError, KeyError): - raise - except DocoptExit, e: + except DocoptExit as e: env.err(e.usage) env.err( '\nUnknown argument(s), use -h or --help for available options') @@ -246,15 +212,22 @@ def main(args=sys.argv[1:], env=Environment()): except KeyboardInterrupt: env.out('') exit_status = 1 - except CLIAbort, e: + except CLIAbort as e: env.err(str(e.message)) exit_status = e.code - except SystemExit, e: + except SystemExit as e: exit_status = e.code - except SoftLayerError, e: + except SoftLayerAPIError as e: + if 'invalid api token' in e.faultString.lower(): + env.out("Authentication Failed: To update your credentials, use " + "'sl config setup'") + else: + env.err(str(e)) + exit_status = 1 + except SoftLayerError as e: env.err(str(e)) exit_status = 1 - except Exception, e: + except Exception as e: import traceback env.err(traceback.format_exc()) exit_status = 1 diff --git a/SoftLayer/CLI/environment.py b/SoftLayer/CLI/environment.py index 6e6f17d8b..3da51f654 100644 --- a/SoftLayer/CLI/environment.py +++ b/SoftLayer/CLI/environment.py @@ -1,19 +1,19 @@ """ - SoftLayer.environment - ~~~~~~~~~~~~~~~~~~~~~ + SoftLayer.CLI.environment + ~~~~~~~~~~~~~~~~~~~~~~~~~ Abstracts everything related to the user's environment when running the CLI :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. - :license: BSD, see LICENSE for more details. + :license: MIT, see LICENSE for more details. """ import sys +import getpass from importlib import import_module -from ConfigParser import SafeConfigParser import os import os.path from SoftLayer.CLI.modules import get_module_list -from SoftLayer import API_PUBLIC_ENDPOINT, SoftLayerError +from SoftLayer import SoftLayerError class InvalidCommand(SoftLayerError): @@ -39,8 +39,11 @@ class Environment(object): aliases = { 'meta': 'metadata', 'my': 'metadata', + 'vm': 'cci', + 'hardware': 'server', + 'hw': 'server', + 'bmetal': 'bmc', } - config = {} stdout = sys.stdout stderr = sys.stderr @@ -85,24 +88,8 @@ def err(self, s, nl=True): def input(self, prompt): return raw_input(prompt) - def load_config(self, files): - config_files = [os.path.expanduser(f) for f in files] - - cp = SafeConfigParser({ - 'username': os.environ.get('SL_USERNAME') or '', - 'api_key': os.environ.get('SL_API_KEY') or '', - 'endpoint_url': API_PUBLIC_ENDPOINT, - }) - cp.read(config_files) - config = {} - - if not cp.has_section('softlayer'): - cp.add_section('softlayer') - - for config_name in ['username', 'api_key', 'endpoint_url']: - config[config_name] = cp.get('softlayer', config_name) - - self.config = config + def getpass(self, prompt): + return getpass.getpass(prompt) def exit(self, code=0): sys.exit(code) @@ -116,3 +103,17 @@ def __init__(cls, name, bases, attrs): super(CLIRunnableType, cls).__init__(name, bases, attrs) if cls.env and name != 'CLIRunnable': cls.env.add_plugin(cls) + + +class CLIRunnable(object): + __metaclass__ = CLIRunnableType + options = [] + action = None + + @staticmethod + def add_additional_args(parser): + pass + + @staticmethod + def execute(client, args): + pass diff --git a/SoftLayer/CLI/exceptions.py b/SoftLayer/CLI/exceptions.py new file mode 100644 index 000000000..510f9859c --- /dev/null +++ b/SoftLayer/CLI/exceptions.py @@ -0,0 +1,26 @@ +""" + SoftLayer.CLI.exceptions + ~~~~~~~~~~~~~~~~~~~~~~~~ + Exceptions to be used in the CLI modules. + + :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. + :license: MIT, see LICENSE for more details. +""" + + +class CLIHalt(SystemExit): + def __init__(self, code=0, *args): + super(CLIHalt, self).__init__(*args) + self.code = code + + +class CLIAbort(CLIHalt): + def __init__(self, msg, *args): + super(CLIAbort, self).__init__(code=2, *args) + self.message = msg + + +class ArgumentError(CLIAbort): + def __init__(self, msg, *args): + super(CLIAbort, self).__init__(code=2, *args) + self.message = "Argument Error: %s" % msg diff --git a/SoftLayer/CLI/formatting.py b/SoftLayer/CLI/formatting.py new file mode 100644 index 000000000..53dc6cf9b --- /dev/null +++ b/SoftLayer/CLI/formatting.py @@ -0,0 +1,245 @@ +""" + SoftLayer.formatting + ~~~~~~~~~~~~~~~~~~~~ + Provider classes and helper functions to display output onto a + command-line. + + :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. + :license: MIT, see LICENSE for more details. +""" +import os +import json + +from prettytable import PrettyTable, FRAME, NONE + + +def format_output(data, fmt='table'): + """ Given some data, will format it for output + + :param data: One of: String, Table, FormattedItem, List, Tuple, + SequentialOutput + :param string fmt (optional): One of: table, raw, json, python + """ + if isinstance(data, basestring): + if fmt == 'json': + return json.dumps(data) + return data + + # responds to .prettytable() + if hasattr(data, 'prettytable'): + if fmt == 'table': + return str(format_prettytable(data)) + elif fmt == 'raw': + return str(format_no_tty(data)) + + # responds to .to_python() + if hasattr(data, 'to_python'): + if fmt == 'json': + return json.dumps( + format_output(data, fmt='python'), + indent=4, + cls=CLIJSONEncoder) + elif fmt == 'python': + return data.to_python() + + # responds to .formatted + if hasattr(data, 'formatted'): + if fmt == 'table': + return str(data.formatted) + + # responds to .separator + if hasattr(data, 'separator'): + output = [format_output(d, fmt=fmt) for d in data if d] + return str(SequentialOutput(data.separator, output)) + + # is iterable + if isinstance(data, list) or isinstance(data, tuple): + output = [format_output(d, fmt=fmt) for d in data] + if fmt == 'python': + return output + return format_output(listing(output, separator=os.linesep)) + + # fallback, convert this odd object to a string + return data + + +def format_prettytable(table): + for i, row in enumerate(table.rows): + for j, item in enumerate(row): + table.rows[i][j] = format_output(item) + t = table.prettytable() + t.hrules = FRAME + t.horizontal_char = '.' + t.vertical_char = ':' + t.junction_char = ':' + return t + + +def format_no_tty(table): + for i, row in enumerate(table.rows): + for j, item in enumerate(row): + table.rows[i][j] = format_output(item, fmt='raw') + t = table.prettytable() + for col in table.columns: + t.align[col] = 'l' + t.hrules = NONE + t.border = False + t.header = False + t.left_padding_width = 0 + t.right_padding_width = 2 + return t + + +def mb_to_gb(megabytes): + """ Takes in the number of megabytes and returns a FormattedItem that + displays gigabytes. + + :param int megabytes: number of megabytes + """ + return FormattedItem(megabytes, "%dG" % (float(megabytes) / 1024)) + + +def gb(gigabytes): + """ Takes in the number of gigabytes and returns a FormattedItem that + displays gigabytes. + + :param int gigabytes: number of 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 + """ + return FormattedItem(None, '-') + + +def listing(items, separator=','): + """ Given an iterable, returns a FormatedItem which display a list of + items + + :param items: An iterable that outputs strings + :param string separator: the separator to use + """ + return SequentialOutput(separator, items) + + +def valid_response(prompt, *valid): + ans = raw_input(prompt).lower() + + if ans in valid: + return True + elif ans == '': + return None + + return False + + +def confirm(prompt_str, default=False): + if default: + prompt = '%s [Y/n]: ' % prompt_str + else: + prompt = '%s [y/N]: ' % prompt_str + + response = valid_response(prompt, 'y', 'yes', 'yeah', 'yup', 'yolo') + + if response is None: + return default + + return response + + +def no_going_back(confirmation): + if not confirmation: + confirmation = 'yes' + + return valid_response( + 'This action cannot be undone! ' + 'Type "%s" or press Enter to abort: ' % confirmation, + str(confirmation)) + + +class SequentialOutput(list): + def __init__(self, separator=os.linesep, *args, **kwargs): + self.separator = separator + super(SequentialOutput, self).__init__(*args, **kwargs) + + def to_python(self): + return self + + def __str__(self): + return self.separator.join(str(x) for x in self) + + +class CLIJSONEncoder(json.JSONEncoder): + def default(self, obj): + if hasattr(obj, 'to_python'): + return obj.to_python() + return super(CLIJSONEncoder, self).default(obj) + + +class Table(object): + def __init__(self, columns): + self.columns = columns + self.rows = [] + self.align = {} + self.format = {} + self.sortby = None + + def add_row(self, row): + self.rows.append(row) + + def _format_python_value(self, value): + if hasattr(value, 'to_python'): + return value.to_python() + return value + + def to_python(self): + # Adding rows + l = [] + for row in self.rows: + formatted_row = [self._format_python_value(v) for v in row] + l.append(dict(zip(self.columns, formatted_row))) + return l + + def prettytable(self): + """ Returns a new prettytable instance. """ + t = PrettyTable(self.columns) + if self.sortby: + t.sortby = self.sortby + for a_col, alignment in self.align.items(): + t.align[a_col] = alignment + + # Adding rows + for row in self.rows: + t.add_row(row) + return t + + +class KeyValueTable(Table): + def to_python(self): + d = {} + for row in self.rows: + d[row[0]] = self._format_python_value(row[1]) + return d + + +class FormattedItem(object): + def __init__(self, original, formatted=None): + self.original = original + if formatted is not None: + self.formatted = formatted + else: + self.formatted = self.original + + def to_python(self): + return self.original + + def __str__(self): + if self.original is None: + return 'NULL' + return str(self.original) + + __repr__ = __str__ diff --git a/SoftLayer/CLI/helpers.py b/SoftLayer/CLI/helpers.py index b2083b940..c6e05e7f1 100644 --- a/SoftLayer/CLI/helpers.py +++ b/SoftLayer/CLI/helpers.py @@ -1,32 +1,34 @@ """ - SoftLayer.helpers - ~~~~~~~~~~~~~~~~~ + SoftLayer.CLI.helpers + ~~~~~~~~~~~~~~~~~~~~~ Helpers to be used in CLI modules in SoftLayer.CLI.modules.* :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. - :license: BSD, see LICENSE for more details. + :license: MIT, see LICENSE for more details. """ -from SoftLayer.CLI.environment import CLIRunnableType -from SoftLayer.utils import NestedDict -from prettytable import PrettyTable - -__all__ = ['Table', 'CLIRunnable', 'FormattedItem', 'valid_response', - 'confirm', 'no_going_back', 'mb_to_gb', 'gb', 'listing', 'CLIAbort', - 'NestedDict', 'resolve_id'] - -class FormattedItem(object): - def __init__(self, original, formatted=None): - self.original = original - if formatted is not None: - self.formatted = formatted - else: - self.formatted = self.original - - def __str__(self): - return str(self.original) - - __repr__ = __str__ +from SoftLayer.utils import NestedDict +from SoftLayer.CLI.environment import CLIRunnable +from exceptions import CLIHalt, CLIAbort, ArgumentError +from formatting import ( + Table, KeyValueTable, FormattedItem, SequentialOutput, confirm, + no_going_back, mb_to_gb, gb, listing, blank, format_output, valid_response) +from template import update_with_template_args, export_to_template + +__all__ = [ + # Core/Misc + 'CLIRunnable', 'NestedDict', 'FALSE_VALUES', 'resolve_id', + # Exceptions + 'CLIAbort', 'CLIHalt', 'ArgumentError', + # Formatting + 'Table', 'KeyValueTable', 'FormattedItem', 'SequentialOutput', + 'valid_response', 'confirm', 'no_going_back', 'mb_to_gb', 'gb', + 'listing', 'format_output', 'blank', + # Template + 'update_with_template_args', 'export_to_template', +] + +FALSE_VALUES = ['0', 'false', 'FALSE', 'no', 'False'] def resolve_id(resolver, identifier, name='object'): @@ -50,120 +52,3 @@ def resolve_id(resolver, identifier, name='object'): (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""" - return FormattedItem('NULL', '-') - - -def listing(item, separator=','): - l = separator.join((str(i) for i in item)) - return FormattedItem(l, l) - - -class CLIRunnable(object): - __metaclass__ = CLIRunnableType - options = [] - action = None - - @staticmethod - def add_additional_args(parser): - pass - - @staticmethod - def execute(client, args): - pass - - -def valid_response(prompt, *valid): - ans = raw_input(prompt).lower() - - if ans in valid: - return True - elif ans == '': - return None - - return False - - -def confirm(prompt_str, default=False): - if default: - prompt = '%s [Y/n]: ' % prompt_str - else: - prompt = '%s [y/N]: ' % prompt_str - - response = valid_response(prompt, 'y', 'yes', 'yeah', 'yup', 'yolo') - - if response is None: - return default - - return response - - -def no_going_back(confirmation): - if not confirmation: - confirmation = 'yes' - - return valid_response( - 'This action cannot be undone! ' - 'Type "%s" or press Enter to abort: ' % confirmation, - str(confirmation)) - - -class CLIHalt(SystemExit): - def __init__(self, code=0, *args): - super(CLIHalt, self).__init__(*args) - self.code = code - - -class CLIAbort(CLIHalt): - def __init__(self, msg, *args): - super(CLIAbort, self).__init__(code=2, *args) - self.message = msg - - -class ArgumentError(CLIAbort): - def __init__(self, msg, *args): - super(CLIAbort, self).__init__(code=2, *args) - self.message = "Argument Error: %s" % msg - - -class Table(object): - def __init__(self, columns): - self.columns = columns - self.rows = [] - self.align = {} - self.format = {} - self.sortby = None - - def add_row(self, row): - self.rows.append(row) - - def prettytable(self): - """ Returns a new prettytable instance. """ - t = PrettyTable(self.columns) - if self.sortby: - t.sortby = self.sortby - for a_col, alignment in self.align.items(): - t.align[a_col] = alignment - - # Adding rows - for row in self.rows: - t.add_row(row) - return t - - -class SequentialOutput(list): - def __init__(self, blanks=True, *args, **kwargs): - self.blanks = blanks diff --git a/SoftLayer/CLI/modules/__init__.py b/SoftLayer/CLI/modules/__init__.py index eb1e7073b..cecc50989 100644 --- a/SoftLayer/CLI/modules/__init__.py +++ b/SoftLayer/CLI/modules/__init__.py @@ -4,7 +4,7 @@ Contains all plugable modules for the CLI interface :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. - :license: BSD, see LICENSE for more details. + :license: MIT, see LICENSE for more details. """ from pkgutil import iter_modules diff --git a/SoftLayer/CLI/modules/bmetal.py b/SoftLayer/CLI/modules/bmc.py similarity index 74% rename from SoftLayer/CLI/modules/bmetal.py rename to SoftLayer/CLI/modules/bmc.py index 89b69569b..5aeac8629 100644 --- a/SoftLayer/CLI/modules/bmetal.py +++ b/SoftLayer/CLI/modules/bmc.py @@ -1,60 +1,51 @@ """ -usage: sl bmetal [] [...] [options] - sl bmetal [-h | --help] +usage: sl bmc [] [...] [options] + sl bmc [-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 + create Create a new bare metal instance + create-options Output available available options when creating a server For several commands, will be asked for. This can be the id, hostname or the ip address for a piece of hardware. """ +# :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. +# :license: MIT, see LICENSE for more details. 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 - + CLIRunnable, Table, KeyValueTable, no_going_back, confirm, listing, + FormattedItem) +from SoftLayer.CLI.helpers import ( + ArgumentError, CLIAbort, SequentialOutput, update_with_template_args, + FALSE_VALUES, resolve_id) +from SoftLayer import HardwareManager, SshKeyManager -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): +class BMCCreateOptions(CLIRunnable): """ -usage: sl bmetal create-options [options] +usage: sl bmc 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 + --datacenter Show datacenter options --disk Show disk options - --os Show operating system options --memory Show memory size options + --nic Show NIC speed options + --os Show operating system options """ action = 'create-options' options = ['datacenter', 'cpu', 'memory', 'os', 'disk', 'nic'] @classmethod def execute(cls, client, args): - t = Table(['Name', 'Value']) + t = KeyValueTable(['Name', 'Value']) t.align['Name'] = 'r' t.align['Value'] = 'l' @@ -78,31 +69,39 @@ def execute(cls, client, args): if args['--cpu'] or args['--memory'] or show_all: results = cls.get_create_options(bmi_options, 'cpu') - + memory_cpu_table = Table(['memory', '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])))]) + memory_cpu_table.add_row([ + result[0], + listing( + [item[0] for item in sorted( + result[1], key=lambda x: int(x[0]) + )])]) + t.add_row(['memory/cpu', memory_cpu_table]) 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]))]) + t.add_row([ + result[0], + listing( + [item[0] for item in sorted(result[1])], + separator=linesep + )]) 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]))]) + [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],))]) + [item[0] for item in sorted(result[1],)])]) return t @@ -143,7 +142,7 @@ def get_create_options(cls, bmi_options, section, pretty=True): key = memory if pretty: - key = 'cpus (%s gb ram)' % memory + key = memory results.append((key, mem_options[memory])) @@ -265,45 +264,61 @@ def _generate_windows_code(description): return [] -class CreateBMetalInstance(CLIRunnable): +class CreateBMCInstance(CLIRunnable): """ -usage: sl bmetal create --hostname=HOST --domain=DOMAIN --cpu=CPU --os=OS - --memory=MEMORY --disk=DISK... (--hourly | --monthly) - [options] +usage: sl bmc create [--disk=DISK...] [--key=KEY...] [options] -Order/create a bare metal instance. See 'sl bmetal create-options' for valid +Order/create a bare metal instance. See 'sl bmc create-options' for valid options +NOTE: Due to hardware configurations, the CPU and memory must match + appropriately. See create-options for 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. - + -D --domain=DOMAIN Domain portion of the FQDN example: example.com + -H --hostname=HOST Host portion of the FQDN. example: server + -m --memory=MEMORY Memory in mebibytes. Example: 2048 + -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 + -d DC, --datacenter=DC datacenter name Note: Omitting this value defaults + to the first available datacenter --dry-run, --test Do not create the instance, just get a quote + --export=FILE Exports options to a template file + -k KEY, --key=KEY SSH keys to assign to the root user. Can be + specified multiple times. + -n MBPS, --network=MBPS Network port speed in Mbps + --vlan_public=VLAN The ID of the public VLAN on which you want the CCI + placed. + --vlan_private=VLAN The ID of the private VLAN on which you want the CCI + placed. + -t, --template=FILE A template file that defaults the command-line + options using the long name in INI format + """ action = 'create' options = ['confirm'] + required_params = ['--hostname', '--domain', '--cpu', '--memory', '--os'] @classmethod def execute(cls, client, args): + update_with_template_args(args) mgr = HardwareManager(client) + # Disks will be a comma-separated list. Let's make it a real list. + if isinstance(args.get('--disk'), str): + args['--disk'] = args.get('--disk').split(',') + + # Do the same thing for SSH keys + if isinstance(args.get('--key'), str): + args['--key'] = args.get('--key').split(',') + + cls._validate_args(args) + bmi_options = mgr.get_bare_metal_create_options() order = { @@ -360,6 +375,21 @@ def execute(cls, client, args): else: raise CLIAbort('Invalid NIC speed specified.') + # Get the SSH keys + if args.get('--key'): + keys = [] + for key in args.get('--key'): + key_id = resolve_id(SshKeyManager(client).resolve_ids, key, + 'SshKey') + keys.append(key_id) + order['ssh_keys'] = keys + + if args.get('--vlan_public'): + order['public_vlan'] = args['--vlan_public'] + + if args.get('--vlan_private'): + order['private_vlan'] = args['--vlan_private'] + # Begin output t = Table(['Item', 'cost']) t.align['Item'] = 'r' @@ -389,18 +419,18 @@ def execute(cls, client, args): if args.get('--hourly'): billing_rate = 'hourly' t.add_row(['Total %s cost' % billing_rate, "%.2f" % total]) - output = SequentialOutput(blanks=False) + output = SequentialOutput() output.append(t) output.append(FormattedItem( '', ' -- ! Prices reflected here are retail and do not ' - 'take account level discounts and are not guarenteed.') + 'take account level discounts and are not guaranteed.') ) elif args['--really'] or confirm( "This action will incur charges on your account. Continue?"): result = mgr.place_order(**order) - t = Table(['name', 'value']) + t = KeyValueTable(['name', 'value']) t.align['name'] = 'r' t.align['value'] = 'l' t.add_row(['id', result['orderId']]) @@ -411,10 +441,29 @@ def execute(cls, client, args): return output + @classmethod + def _validate_args(cls, args): + invalid_args = [k for k in cls.required_params if args.get(k) is None] + if invalid_args: + raise ArgumentError('Missing required options: %s' + % ','.join(invalid_args)) + + if args['--hourly'] in FALSE_VALUES: + args['--hourly'] = False + + if args['--monthly'] in FALSE_VALUES: + args['--monthly'] = False + + if all([args['--hourly'], args['--monthly']]): + raise ArgumentError('[--hourly] not allowed with [--monthly]') + + if not any([args['--hourly'], args['--monthly']]): + raise ArgumentError('One of [--hourly | --monthly] is required') + @classmethod def _get_cpu_and_memory_price_ids(cls, bmi_options, cpu_value, memory_value): - bmi_obj = BMetalCreateOptions() + bmi_obj = BMCCreateOptions() price_id = None cpu_regex = re.compile('(\d+)') @@ -435,17 +484,17 @@ def _get_default_value(cls, bmi_options, option): 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)), + float(item.get('setupFee', 0)), + float(item.get('recurringFee', 0)), + float(item.get('hourlyRecurringFee', 0)), + float(item.get('oneTimeFee', 0)), + float(item.get('laborFee', 0)), ]): return item['price_id'] @classmethod def _get_price_id_from_options(cls, bmi_options, option, value): - bmi_obj = BMetalCreateOptions() + bmi_obj = BMCCreateOptions() price_id = None for k, v in bmi_obj.get_create_options(bmi_options, option, False): @@ -458,13 +507,13 @@ def _get_price_id_from_options(cls, bmi_options, option, value): class CancelInstance(CLIRunnable): """ -usage: sl bmetal cancel [options] +usage: sl bmc cancel [options] Cancel a bare metal instance Options: --immediate Cancels the instance immediately (instead of on the billing - anniversary). + anniversary) """ action = 'cancel' @@ -473,7 +522,8 @@ class CancelInstance(CLIRunnable): @staticmethod def execute(client, args): hw = HardwareManager(client) - hw_id = resolve_id(hw, args.get('')) + hw_id = resolve_id( + hw.resolve_ids, args.get(''), 'hardware') immediate = args.get('--immediate', False) diff --git a/SoftLayer/CLI/modules/cci.py b/SoftLayer/CLI/modules/cci.py index 055cf2d53..a2a9acf66 100755 --- a/SoftLayer/CLI/modules/cci.py +++ b/SoftLayer/CLI/modules/cci.py @@ -4,33 +4,40 @@ Manage, delete, order compute instances The available commands are: - network Manage network settings + cancel Cancel a running CCI create Order and create a CCI (see `sl cci create-options` for choices) - manage Manage active CCI - list List CCI's on the account + create-options Output available available options when creating a CCI 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 + edit Edit details of a CCI + list List CCI's on the account + nic-edit Edit NIC settings + pause Pauses an active CCI + power-off Powers off a running CCI + power-on Boots up a CCI ready Check if a CCI has finished provisioning + reboot Reboots a running CCI + reload Reload the OS on a CCI based on its current configuration + resume Resumes a paused CCI For several commands, will be asked for. This can be the id, hostname or the ip address for a CCI. """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. -# :license: BSD, see LICENSE for more details. +# :license: MIT, see LICENSE for more details. from os import linesep import os.path -from SoftLayer import CCIManager +from SoftLayer import CCIManager, SshKeyManager, DNSManager +from SoftLayer.utils import lookup 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, resolve_id) + CLIAbort, ArgumentError, NestedDict, blank, resolve_id, KeyValueTable, + update_with_template_args, FALSE_VALUES, export_to_template) class ListCCIs(CLIRunnable): @@ -51,16 +58,16 @@ class ListCCIs(CLIRunnable): Cores, memory, primary_ip, backend_ip Filters: - --hourly Show hourly instances - --monthly Show monthly instances - -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) + -D --domain=DOMAIN Domain portion of the FQDN. example: example.com -d DC, --datacenter=DC datacenter shortname (sng01, dal05, ...) + -H --hostname=HOST Host portion of the FQDN. example: server + -m --memory=MEMORY Memory in mebibytes -n MBPS, --network=MBPS Network port speed in Mbps + --hourly Show hourly instances + --monthly Show monthly instances --tags=ARG Only show instances that have one of these tags. - Comma-separated. (production,db) + Comma-separated. (production,db) For more on filters see 'sl help filters' """ @@ -96,7 +103,7 @@ def execute(client, args): guest = NestedDict(guest) t.add_row([ guest['id'], - guest['datacenter']['name'] or blank(), + guest['datacenter']['name'], guest['fullyQualifiedDomainName'], guest['maxCpu'], mb_to_gb(guest['maxMemory']), @@ -124,8 +131,7 @@ class CCIDetails(CLIRunnable): @staticmethod def execute(client, args): cci = CCIManager(client) - - t = Table(['Name', 'Value']) + t = KeyValueTable(['Name', 'Value']) t.align['Name'] = 'r' t.align['Value'] = 'l' @@ -135,26 +141,41 @@ def execute(client, args): t.add_row(['id', result['id']]) 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']['name'] or blank()]) - t.add_row(['cores', result['maxCpu']]) - t.add_row(['memory', mb_to_gb(result['maxMemory'])]) - t.add_row(['public_ip', result['primaryIpAddress'] or blank()]) - t.add_row(['private_ip', result['primaryBackendIpAddress'] or blank()]) + t.add_row(['status', FormattedItem( + result['status']['keyName'] or blank(), + result['status']['name'] or blank() + )]) + t.add_row(['state', FormattedItem( + lookup(result, 'powerState', 'keyName'), + lookup(result, 'powerState', 'name'), + )]) + t.add_row(['datacenter', result['datacenter']['name']]) + operating_system = lookup(result, + 'operatingSystem', + 'softwareLicense', + 'softwareDescription') t.add_row([ 'os', FormattedItem( - result['operatingSystem']['softwareLicense'] - ['softwareDescription']['referenceCode'] or blank(), - result['operatingSystem']['softwareLicense'] - ['softwareDescription']['name'] or blank() + operating_system['version'] or blank(), + operating_system['name'] or blank() )]) + t.add_row(['os_version', operating_system['version'] or blank()]) + t.add_row(['cores', result['maxCpu']]) + t.add_row(['memory', mb_to_gb(result['maxMemory'])]) + t.add_row(['public_ip', result['primaryIpAddress'] or blank()]) + t.add_row(['private_ip', result['primaryBackendIpAddress'] or blank()]) t.add_row(['private_only', result['privateNetworkOnlyFlag']]) t.add_row(['private_cpu', result['dedicatedAccountHostOnlyFlag']]) t.add_row(['created', result['createDate']]) t.add_row(['modified', result['modifyDate']]) + vlan_table = Table(['type', 'number', 'id']) + for vlan in result['networkVlans']: + vlan_table.add_row([ + vlan['networkSpace'], vlan['vlanNumber'], vlan['id']]) + t.add_row(['vlans', vlan_table]) + if result.get('notes'): t.add_row(['notes', result['notes']]) @@ -162,11 +183,10 @@ def execute(client, args): t.add_row(['price rate', result['billingItem']['recurringFee']]) if args.get('--passwords'): - user_strs = [] + pass_table = Table(['username', 'password']) for item in result['operatingSystem']['passwords']: - user_strs.append( - "%s %s" % (item['username'], item['password'])) - t.add_row(['users', listing(user_strs)]) + pass_table.add_row([item['username'], item['password']]) + t.add_row(['users', pass_table]) tag_row = [] for tag in result['tagReferences']: @@ -175,12 +195,13 @@ def execute(client, args): if tag_row: t.add_row(['tags', listing(tag_row, separator=',')]) - ptr_domains = client['Virtual_Guest'].\ - getReverseDomainRecords(id=cci_id) + if not result['privateNetworkOnlyFlag']: + ptr_domains = client['Virtual_Guest'].\ + getReverseDomainRecords(id=cci_id) - for ptr_domain in ptr_domains: - for ptr in ptr_domain['resourceRecords']: - t.add_row(['ptr', ptr['data']]) + for ptr_domain in ptr_domains: + for ptr in ptr_domain['resourceRecords']: + t.add_row(['ptr', ptr['data']]) return t @@ -193,12 +214,12 @@ class CreateOptionsCCI(CLIRunnable): Options: --all Show all options. default if no other option provided - --datacenter Show datacenter options --cpu Show CPU options - --nic Show NIC speed options + --datacenter Show datacenter options --disk Show disk options - --os Show operating system options --memory Show memory size options + --nic Show NIC speed options + --os Show operating system options """ action = 'create-options' options = ['datacenter', 'cpu', 'nic', 'disk', 'os', 'memory'] @@ -217,7 +238,7 @@ def execute(cls, client, args): if args['--all']: show_all = True - t = Table(['Name', 'Value']) + t = KeyValueTable(['Name', 'Value']) t.align['Name'] = 'r' t.align['Value'] = 'l' @@ -316,61 +337,236 @@ def block_rows(blocks, name): class CreateCCI(CLIRunnable): """ -usage: sl cci create --hostname=HOST --domain=DOMAIN --cpu=CPU --memory=MEMORY - (--os=OS | --image=GUID) (--hourly | --monthly) [options] +usage: sl cci create [--key=KEY...] [options] Order/create a CCI. See 'sl cci 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) - - -o OS, --os=OS OS install code. Tip: you can specify _LATEST - --image=GUID Image GUID. See: 'sl image list' for reference + -c, --cpu=CPU Number of CPU cores + -D, --domain=DOMAIN Domain portion of the FQDN. example: example.com + -H, --hostname=HOST Host portion of the FQDN. example: server + --image=GUID Image GUID. See: 'sl image list' for reference + -m, --memory=MEMORY Memory in mebibytes. example: 2048 + -o, --os=OS OS install code. Tip: you can specify _LATEST --hourly Hourly rate instance type --monthly Monthly rate instance type Optional: - -d DC, --datacenter=DC datacenter shortname (sng01, dal05, ...) - Note: Omitting this value defaults to the first - available datacenter - -n MBPS, --network=MBPS Network port speed in Mbps - --private Allocate a private CCI - --dry-run, --test Do not create CCI, just get a quote - - -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. + -d, --datacenter=DC Datacenter shortname (sng01, dal05, ...) + Note: Omitting this value defaults to the first + available datacenter + --dedicated Allocate a dedicated CCI (non-shared host) + --dry-run, --test Do not create CCI, just get a quote + --export=FILE Exports options to a template file + -F, --userfile=FILE Read userdata from file + (Only HTTPS executes, HTTP leaves file in /root) + -i, --postinstall=URI Post-install script to download + -k, --key=KEY SSH keys to add to the root user. Can be specified + multiple times + --like=IDENTIFIER Use the configuration from an existing CCI + -n, --network=MBPS Network port speed in Mbps + --private Forces the CCI to only have access the private + network + -t, --template=FILE A template file that defaults the command-line + options using the long name in INI format + -u, --userdata=DATA User defined metadata string + --vlan_public=VLAN The ID of the public VLAN on which you want the CCI + placed. + --vlan_private=VLAN The ID of the private VLAN on which you want the CCI + placed. + --wait=SECONDS Block until CCI is finished provisioning for up to X + seconds before returning """ action = 'create' options = ['confirm'] + required_params = ['--hostname', '--domain', '--cpu', '--memory'] - @staticmethod - def execute(client, args): + @classmethod + def execute(cls, client, args): + update_with_template_args(args) cci = CCIManager(client) + cls._update_with_like_args(cci, args) - if args['--userdata'] and args['--userfile']: + # SSH keys may be a comma-separated list. Let's make it a real list. + if isinstance(args.get('--key'), str): + args['--key'] = args.get('--key').split(',') + + cls._validate_args(args) + + # Do not create CCI with --test or --export + do_create = not (args['--export'] or args['--test']) + + t = Table(['Item', 'cost']) + t.align['Item'] = 'r' + t.align['cost'] = 'r' + data = cls._parse_create_args(client, args) + + output = [] + if args.get('--test'): + result = cci.verify_create_instance(**data) + total_monthly = 0.0 + total_hourly = 0.0 + + t = Table(['Item', 'cost']) + t.align['Item'] = 'r' + t.align['cost'] = 'r' + + 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.append(t) + output.append(FormattedItem( + None, + ' -- ! Prices reflected here are retail and do not ' + 'take account level discounts and are not guaranteed.') + ) + + if args['--export']: + export_file = args.pop('--export') + export_to_template(export_file, args, exclude=['--wait', '--test']) + return 'Successfully exported options to a template file.' + + if do_create: + if args['--really'] or confirm( + "This action will incur charges on your account. " + "Continue?"): + result = cci.create_instance(**data) + + t = KeyValueTable(['name', 'value']) + t.align['name'] = 'r' + t.align['value'] = 'l' + t.add_row(['id', result['id']]) + t.add_row(['created', result['createDate']]) + t.add_row(['guid', result['globalIdentifier']]) + output.append(t) + + if args.get('--wait'): + ready = cci.wait_for_transaction( + result['id'], int(args.get('--wait') or 1)) + t.add_row(['ready', ready]) + else: + raise CLIAbort('Aborting CCI order.') + + return output + + @classmethod + def _validate_args(cls, args): + invalid_args = [k for k in cls.required_params if args.get(k) is None] + if invalid_args: + raise ArgumentError('Missing required options: %s' + % ','.join(invalid_args)) + + if all([args['--userdata'], args['--userfile']]): raise ArgumentError('[-u | --userdata] not allowed with ' '[-F | --userfile]') + + if args['--hourly'] in FALSE_VALUES: + args['--hourly'] = False + + if args['--monthly'] in FALSE_VALUES: + args['--monthly'] = False + + if all([args['--hourly'], args['--monthly']]): + raise ArgumentError('[--hourly] not allowed with [--monthly]') + + if not any([args['--hourly'], args['--monthly']]): + raise ArgumentError('One of [--hourly | --monthly] is required') + + image_args = [args['--os'], args['--image']] + if all(image_args): + raise ArgumentError('[-o | --os] not allowed with [--image]') + + if not any(image_args): + raise ArgumentError('One of [--os | --image] is required') + if args['--userfile']: if not os.path.exists(args['--userfile']): raise ArgumentError( 'File does not exist [-u | --userfile] = %s' % args['--userfile']) + @staticmethod + def _update_with_like_args(cci, args): + """ Update arguments with options taken from a currently running CCI. + + :param CCIManager args: A CCIManager + :param dict args: CLI arguments + """ + if args['--like']: + cci_id = resolve_id(cci.resolve_ids, args.pop('--like'), 'CCI') + like_details = cci.get_instance(cci_id) + like_args = { + '--hostname': like_details['hostname'], + '--domain': like_details['domain'], + '--cpu': like_details['maxCpu'], + '--memory': like_details['maxMemory'], + '--hourly': like_details['hourlyBillingFlag'], + '--monthly': not like_details['hourlyBillingFlag'], + '--datacenter': like_details['datacenter']['name'], + '--network': like_details['networkComponents'][0]['maxSpeed'], + '--user-data': like_details['userData'] or None, + '--postinstall': like_details.get('postInstallScriptUri'), + '--dedicated': like_details['dedicatedAccountHostOnlyFlag'], + '--private': like_details['privateNetworkOnlyFlag'], + } + + # Handle mutually exclusive options + like_image = lookup(like_details, + 'blockDeviceTemplateGroup', + 'globalIdentifier') + like_os = lookup(like_details, + 'operatingSystem', + 'softwareLicense', + 'softwareDescription', + 'referenceCode') + if like_image and not args.get('--os'): + like_args['--image'] = like_image + elif like_os and not args.get('--image'): + like_args['--os'] = like_os + + if args.get('--hourly'): + like_args['--monthly'] = False + + if args.get('--monthly'): + like_args['--hourly'] = False + + # Merge like CCI options with the options passed in + for key, value in like_args.items(): + if args.get(key) in [None, False]: + args[key] = value + + @staticmethod + def _parse_create_args(client, args): + """ Converts CLI arguments to arguments that can be passed into + CCIManager.create_instance. + + :param dict args: CLI arguments + """ data = { "hourly": args['--hourly'], "cpus": args['--cpu'], "domain": args['--domain'], "hostname": args['--hostname'], "private": args['--private'], + "dedicated": args['--dedicated'], "local_disk": True, } @@ -415,61 +611,22 @@ def execute(client, args): if args.get('--postinstall'): data['post_uri'] = args.get('--postinstall') - t = Table(['Item', 'cost']) - t.align['Item'] = 'r' - t.align['cost'] = 'r' - - if args.get('--test'): - result = cci.verify_create_instance(**data) - 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]) + # Get the SSH keys + if args.get('--key'): + keys = [] + for key in args.get('--key'): + key_id = resolve_id(SshKeyManager(client).resolve_ids, key, + 'SshKey') + keys.append(key_id) + data['ssh_keys'] = keys - if args.get('--hourly'): - total = total_hourly - else: - total = total_monthly + if args.get('--vlan_public'): + data['public_vlan'] = args['--vlan_public'] - 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.') - ) + if args.get('--vlan_private'): + data['private_vlan'] = args['--vlan_private'] - elif args['--really'] or confirm( - "This action will incur charges on your account. Continue?"): - result = cci.create_instance(**data) - - t = Table(['name', 'value']) - t.align['name'] = 'r' - t.align['value'] = 'l' - t.add_row(['id', result['id']]) - t.add_row(['created', result['createDate']]) - t.add_row(['guid', result['globalIdentifier']]) - output = t - else: - raise CLIAbort('Aborting CCI order.') - - if args.get('--wait') or 0 and not args.get('--test'): - ready = cci.wait_for_transaction( - result['id'], int(args.get('--wait') or 1)) - t.add_row(['ready', ready]) - - return output + return data class ReadyCCI(CLIRunnable): @@ -504,7 +661,7 @@ class ReloadCCI(CLIRunnable): Reload the OS on a CCI based on its current configuration Optional: - -i, --postinstall=URI Post-install script to download + -i, --postinstall=URI Post-install script to download (Only HTTPS executes, HTTP leaves file in /root) """ @@ -541,145 +698,159 @@ def execute(client, args): CLIAbort('Aborted') -class ManageCCI(CLIRunnable): +class CCIPowerOff(CLIRunnable): """ -usage: sl cci manage poweroff [--cycle | --soft] [options] - sl cci manage reboot [--cycle | --soft] [options] - sl cci manage poweron [options] - sl cci manage pause [options] - sl cci manage resume [options] +usage: sl cci power-off [--hard] [options] -Manage active CCI +Power off an active CCI + +Optional: + --hard Perform a hard shutdown """ - action = 'manage' + action = 'power-off' options = ['confirm'] @classmethod def execute(cls, client, args): - if args['poweroff']: - return cls.exec_shutdown(client, args) + vg = client['Virtual_Guest'] + cci = CCIManager(client) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') + if args['--really'] or confirm('This will power off the CCI with id ' + '%s. Continue?' % cci_id): + if args['--hard']: + vg.powerOff(id=cci_id) + else: + vg.powerOffSoft(id=cci_id) + else: + raise CLIAbort('Aborted.') - if args['reboot']: - return cls.exec_reboot(client, args) - if args['poweron']: - return cls.exec_poweron(client, args) +class CCIReboot(CLIRunnable): + """ +usage: sl cci reboot [--hard | --soft] [options] - if args['pause']: - return cls.exec_pause(client, args) +Reboot an active CCI - if args['resume']: - return cls.exec_resume(client, args) +Optional: + --hard Perform an abrupt reboot + --soft Perform a graceful reboot +""" + action = 'reboot' + options = ['confirm'] - @staticmethod - def exec_shutdown(client, args): + @classmethod + def execute(cls, client, args): vg = client['Virtual_Guest'] cci = CCIManager(client) cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') - if args['--soft']: - result = vg.powerOffSoft(id=cci_id) - elif args['--cycle']: - result = vg.powerCycle(id=cci_id) + if args['--really'] or confirm('This will reboot the CCI with id ' + '%s. Continue?' % cci_id): + if args['--hard']: + vg.rebootHard(id=cci_id) + elif args['--soft']: + vg.rebootSoft(id=cci_id) + else: + vg.rebootDefault(id=cci_id) else: - result = vg.powerOff(id=cci_id) + raise CLIAbort('Aborted.') - return FormattedItem(result) - @staticmethod - def exec_poweron(client, args): - vg = client['Virtual_Guest'] - cci = CCIManager(client) - cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') - return vg.powerOn(id=cci_id) +class CCIPowerOn(CLIRunnable): + """ +usage: sl cci power-on [options] - @staticmethod - def exec_pause(client, args): +Power on a CCI +""" + action = 'power-on' + + @classmethod + def execute(cls, client, args): vg = client['Virtual_Guest'] cci = CCIManager(client) cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') - return vg.pause(id=cci_id) + vg.powerOn(id=cci_id) - @staticmethod - def exec_resume(client, args): + +class CCIPause(CLIRunnable): + """ +usage: sl cci pause [options] + +Pauses an active CCI +""" + action = 'pause' + options = ['confirm'] + + @classmethod + def execute(cls, client, args): vg = client['Virtual_Guest'] cci = CCIManager(client) cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') - return vg.resume(id=cci_id) - @staticmethod - def exec_reboot(client, args): + if args['--really'] or confirm('This will pause the CCI with id ' + '%s. Continue?' % cci_id): + vg.pause(id=cci_id) + else: + raise CLIAbort('Aborted.') + + +class CCIResume(CLIRunnable): + """ +usage: sl cci resume [options] + +Resumes a paused CCI +""" + action = 'resume' + + @classmethod + def execute(cls, client, args): vg = client['Virtual_Guest'] cci = CCIManager(client) cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') - if args['--cycle']: - result = vg.rebootHard(id=cci_id) - elif args['--soft']: - result = vg.rebootSoft(id=cci_id) - else: - result = vg.rebootDefault(id=cci_id) + vg.resume(id=cci_id) - return result - -class NetworkCCI(CLIRunnable): +class NicEditCCI(CLIRunnable): """ -usage: sl cci network port --speed=SPEED (--public | --private) - [options] +usage: sl cci nic-edit (public | private) --speed=SPEED [options] -Manage network settings +Manage NIC settings Options: --speed=SPEED Port speed. 0 disables the port. - [Options: 0, 10, 100, 1000, 10000] - --public Public network - --private Private network + [Options: 0, 10, 100, 1000, 10000] """ - action = 'network' + action = 'nic-edit' @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 + public = args['public'] cci = CCIManager(client) cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') - result = cci.change_port_speed(cci_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') + cci.change_port_speed(cci_id, public, args['--speed']) class CCIDNS(CLIRunnable): """ usage: sl cci dns sync [options] -DNS related actions for a CCI +Attempts to update DNS for the specified CCI. If you don't specify any +arguments, it will attempt to update both the A and PTR records. If you don't +want to update both records, you may use the -a or --ptr arguments to limit +the records updated. Options: - -a, -A Sync only the A record - --ptr, --PTR Sync only the PTR record + -a Sync the A record for the host + --ptr Sync the PTR record for the host + --ttl=TTL Sets the TTL for the A and/or PTR records """ action = 'dns' options = ['confirm'] @classmethod def execute(cls, client, args): + args['--ttl'] = args['--ttl'] or DNSManager.DEFAULT_TTL if args['sync']: return cls.dns_sync(client, args) @@ -689,11 +860,14 @@ def dns_sync(client, args): dns = DNSManager(client) cci = CCIManager(client) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') + instance = cci.get_instance(cci_id) + zone_id = resolve_id(dns.resolve_ids, instance['domain'], name='zone') + def sync_a_record(): - #hostname = instance['fullyQualifiedDomainName'] - records = dns.search_record( - instance['domain'], - instance['hostname'], + records = dns.get_records( + zone_id, + host=instance['hostname'], ) if not records: @@ -703,7 +877,7 @@ def sync_a_record(): instance['hostname'], 'a', instance['primaryIpAddress'], - ttl=7200) + ttl=args['--ttl']) else: recs = filter(lambda x: x['type'].lower() == 'a', records) if len(recs) != 1: @@ -711,6 +885,7 @@ def sync_a_record(): "A record exists!" % len(recs)) rec = recs[0] rec['data'] = instance['primaryIpAddress'] + rec['ttl'] = args['--ttl'] dns.edit_record(rec) def sync_ptr_record(): @@ -720,6 +895,7 @@ def sync_ptr_record(): edit_ptr = None for ptr in ptr_domains['resourceRecords']: if ptr['host'] == host_rec: + ptr['ttl'] = args['--ttl'] edit_ptr = ptr break @@ -732,16 +908,13 @@ def sync_ptr_record(): host_rec, 'ptr', instance['fullyQualifiedDomainName'], - ttl=7200) - - cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') - instance = cci.get_instance(cci_id) + ttl=args['--ttl']) if not instance['primaryIpAddress']: raise CLIAbort('No primary IP address associated with this CCI') try: - zone = dns.get_zone(instance['domain']) + zone = dns.get_zone(zone_id) except DNSZoneNotFound: raise CLIAbort("Unable to create A record, " "no zone found matching: %s" % instance['domain']) @@ -754,11 +927,56 @@ def sync_ptr_record(): raise CLIAbort("Aborting DNS sync") both = False - if not args.get('--PTR') and not args.get('-A'): + if not args['--ptr'] and not args['-a']: both = True - if both or args.get('-A'): + if both or args['-a']: sync_a_record() - if both or args.get('--PTR'): + if both or args['--ptr']: sync_ptr_record() + + +class EditCCI(CLIRunnable): + """ +usage: sl cci edit [options] + +Edit CCI details + +Options: + -D --domain=DOMAIN Domain portion of the FQDN example: example.com + -F --userfile=FILE Read userdata from file + -H --hostname=HOST Host portion of the FQDN. example: server + -u --userdata=DATA User defined metadata string +""" + action = 'edit' + + @staticmethod + def execute(client, args): + data = {} + + if args['--userdata'] and args['--userfile']: + raise ArgumentError('[-u | --userdata] not allowed with ' + '[-F | --userfile]') + if args['--userfile']: + if not os.path.exists(args['--userfile']): + raise ArgumentError( + 'File does not exist [-u | --userfile] = %s' + % args['--userfile']) + + if args.get('--userdata'): + data['userdata'] = args['--userdata'] + elif args.get('--userfile'): + f = open(args['--userfile'], 'r') + try: + data['userdata'] = f.read() + finally: + f.close() + + data['hostname'] = args.get('--hostname') + data['domain'] = args.get('--domain') + + cci = CCIManager(client) + cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') + if not cci.edit(cci_id, **data): + raise CLIAbort("Failed to update CCI") diff --git a/SoftLayer/CLI/modules/config.py b/SoftLayer/CLI/modules/config.py index 12870f184..b06649df4 100644 --- a/SoftLayer/CLI/modules/config.py +++ b/SoftLayer/CLI/modules/config.py @@ -8,14 +8,76 @@ show Show current configuration """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. -# :license: BSD, see LICENSE for more details. +# :license: MIT, see LICENSE for more details. import os.path -from SoftLayer.CLI import CLIRunnable, CLIAbort, Table, confirm +from SoftLayer import ( + Client, SoftLayerAPIError, API_PUBLIC_ENDPOINT, API_PRIVATE_ENDPOINT) +from SoftLayer.CLI import ( + CLIRunnable, CLIAbort, KeyValueTable, confirm, format_output) import ConfigParser +def get_settings_from_client(client): + """ Pull out settings from a SoftLayer.Client instance. + + :param client: SoftLayer.Client instance + """ + settings = { + 'username': '', + 'api_key': '', + 'timeout': client.timeout or '', + 'endpoint_url': client.endpoint_url, + } + try: + settings['username'] = client.auth.username + settings['api_key'] = client.auth.api_key + except AttributeError: + pass + + return settings + + +def config_table(settings): + t = KeyValueTable(['Name', 'Value']) + t.align['Name'] = 'r' + t.align['Value'] = 'l' + t.add_row(['Username', settings['username'] or 'not set']) + t.add_row(['API Key', settings['api_key'] or 'not set']) + t.add_row(['Endpoint URL', settings['endpoint_url'] or 'not set']) + t.add_row(['Timeout', settings['timeout'] or 'not set']) + return t + + +def get_api_key(username, secret, endpoint_url=None): + # Try to use a client with username/api key + try: + client = Client( + username=username, + api_key=secret, + endpoint_url=endpoint_url, + timeout=5) + + client['Account'].getCurrentUser() + return secret + except SoftLayerAPIError as e: + if 'invalid api token' not in e.faultString.lower(): + raise + + # Try to use a client with username/password + client = Client(endpoint_url=endpoint_url, timeout=5) + client.authenticate_with_password(username, secret) + + user_record = client['Account'].getCurrentUser( + mask='id, apiAuthenticationKeys') + api_keys = user_record['apiAuthenticationKeys'] + if len(api_keys) == 0: + return client['User_Customer'].addApiAuthenticationKey( + id=user_record['id']) + return api_keys[0]['authenticationKey'] + + class Setup(CLIRunnable): """ usage: sl config setup [options] @@ -26,35 +88,73 @@ class Setup(CLIRunnable): @classmethod def execute(cls, client, args): - username = cls.env.input('Username: ') - api_key = cls.env.input('API Key: ') - endpoint_url = cls.env.input( - 'Endpoint URL [%s]: ' % cls.env.config['endpoint_url']) - if not endpoint_url: - endpoint_url = cls.env.config['endpoint_url'] + settings = get_settings_from_client(client) + + # User Input + # Ask for username + while True: + username = cls.env.input( + 'Username [%s]: ' % settings['username']) \ + or settings['username'] + if username: + break + + # Ask for 'secret' which can be api_key or their password + while True: + secret = cls.env.getpass( + 'API Key or Password [%s]: ' % settings['api_key']) \ + or settings['api_key'] + if secret: + break + + # Ask for which endpoint they want to use + while True: + endpoint_type = cls.env.input('Endpoint (public|private|custom): ') + endpoint_type = endpoint_type.lower() + if not endpoint_type: + endpoint_url = API_PUBLIC_ENDPOINT + break + if endpoint_type == 'public': + endpoint_url = API_PUBLIC_ENDPOINT + break + elif endpoint_type == 'private': + endpoint_url = API_PRIVATE_ENDPOINT + break + elif endpoint_type == 'custom': + endpoint_url = cls.env.input( + 'Endpoint URL [%s]: ' % settings['endpoint_url'] + ) or settings['endpoint_url'] + break + + api_key = get_api_key(username, secret, endpoint_url=endpoint_url) + + settings['username'] = username + settings['api_key'] = api_key + settings['endpoint_url'] = endpoint_url path = '~/.softlayer' if args.get('--config'): path = args.get('--config') - config_path = os.path.expanduser(path) - c = confirm( - 'Are you sure you want to write settings to "%s"?' % config_path) - if not c: + cls.env.out(format_output(config_table(settings))) + + if not confirm('Are you sure you want to write settings to "%s"?' + % config_path, default=True): raise CLIAbort('Aborted.') + # Persist the config file. Read the target config file in before + # setting the values to avoid clobbering settings config = ConfigParser.RawConfigParser() - config.add_section('softlayer') - - if username: - config.set('softlayer', 'username', username) - - if api_key: - config.set('softlayer', 'api_key', api_key) + config.read(config_path) + try: + config.add_section('softlayer') + except ConfigParser.DuplicateSectionError: + pass - if endpoint_url: - config.set('softlayer', 'endpoint_url', endpoint_url) + config.set('softlayer', 'username', settings['username']) + config.set('softlayer', 'api_key', settings['api_key']) + config.set('softlayer', 'endpoint_url', settings['endpoint_url']) f = os.fdopen( os.open(config_path, os.O_WRONLY | os.O_CREAT, 0600), 'w') @@ -63,6 +163,8 @@ def execute(cls, client, args): finally: f.close() + return "Configuration Updated Successfully" + class Show(CLIRunnable): """ @@ -74,11 +176,5 @@ class Show(CLIRunnable): @classmethod def execute(cls, client, args): - t = Table(['Name', 'Value']) - t.align['Name'] = 'r' - t.align['Value'] = 'l' - config = cls.env.config - t.add_row(['Username', config.get('username', 'none set')]) - t.add_row(['API Key', config.get('api_key', 'none set')]) - t.add_row(['Endpoint URL', config.get('endpoint_url', 'none set')]) - return t + settings = get_settings_from_client(client) + return config_table(settings) diff --git a/SoftLayer/CLI/modules/dns.py b/SoftLayer/CLI/modules/dns.py index 61e171837..d8a2e852d 100755 --- a/SoftLayer/CLI/modules/dns.py +++ b/SoftLayer/CLI/modules/dns.py @@ -3,17 +3,19 @@ Manage DNS -The available commands are: - edit Update resource records (bulk/single) +The available zone commands are: create Create zone + delete Delete zone list List zones or a zone's records - remove Remove resource records - add Add resource record print Print zone in BIND format - delete Delete zone + +The available record commands are: + add Add resource record + edit Update resource records (bulk/single) + remove Remove resource records """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. -# :license: BSD, see LICENSE for more details. +# :license: MIT, see LICENSE for more details. from SoftLayer.CLI import ( CLIRunnable, no_going_back, Table, CLIAbort, resolve_id) @@ -77,7 +79,8 @@ def execute(client, args): if args['--really'] or no_going_back(args['']): manager.delete_zone(zone_id) - raise CLIAbort("Aborted.") + else: + raise CLIAbort("Aborted.") class ListZones(CLIRunnable): @@ -87,10 +90,10 @@ class ListZones(CLIRunnable): List zones and optionally, records 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 + --type=TYPE Record type, such as A or CNAME """ action = 'list' @@ -176,7 +179,7 @@ class AddRecord(CLIRunnable): Record data. NOTE: only minor validation is done Options: - --ttl=TTL Time to live [default: 7200] + --ttl=TTL Time to live """ action = 'add' @@ -185,6 +188,7 @@ def execute(client, args): manager = DNSManager(client) zone_id = resolve_id(manager.resolve_ids, args[''], name='zone') + args['--ttl'] = args['--ttl'] or DNSManager.DEFAULT_TTL manager.create_record( zone_id, @@ -207,8 +211,8 @@ class EditRecord(CLIRunnable): Options: --data=DATA - --ttl=TTL Time to live [default: 7200] --id=ID Modify only the given ID + --ttl=TTL Time to live """ action = 'edit' @@ -218,9 +222,9 @@ def execute(client, args): zone_id = resolve_id(manager.resolve_ids, args[''], name='zone') try: - results = manager.search_record( + results = manager.get_records( zone_id, - args['']) + host=args['']) except DNSZoneNotFound: raise CLIAbort("No zone found matching: %s" % args['']) @@ -257,9 +261,9 @@ def execute(client, args): records = [{'id': args['--id']}] else: try: - records = manager.search_record( + records = manager.get_records( zone_id, - args['']) + host=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 e2fde7000..bf5384b4b 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 hardware list --datacenter=dal05 - sl hardware list --hostname='prod*' + sl server list --datacenter=dal05 + sl server list --hostname='prod*' sl cci list --network=100 --cpu=2 sl cci list --network='< 100' --cpu=2 sl cci list --memory='>= 2048' @@ -30,4 +30,4 @@ and strings. """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. -# :license: BSD, see LICENSE for more details. +# :license: MIT, see LICENSE for more details. diff --git a/SoftLayer/CLI/modules/firewall.py b/SoftLayer/CLI/modules/firewall.py index ef2b05721..0ab7b16a3 100755 --- a/SoftLayer/CLI/modules/firewall.py +++ b/SoftLayer/CLI/modules/firewall.py @@ -7,7 +7,7 @@ list List active vlans with firewalls """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. -# :license: BSD, see LICENSE for more details. +# :license: MIT, see LICENSE for more details. from SoftLayer.CLI import CLIRunnable, Table, listing from SoftLayer.CLI.helpers import blank diff --git a/SoftLayer/CLI/modules/globalip.py b/SoftLayer/CLI/modules/globalip.py new file mode 100644 index 000000000..7eae77dc0 --- /dev/null +++ b/SoftLayer/CLI/modules/globalip.py @@ -0,0 +1,185 @@ +""" +usage: sl globalip [] [...] [options] + +Orders or configures global IP addresses + +The available commands are: + assign Assign a target to a global IP address + cancel Cancels a global IP + create Orders a new global IP address + list Display a list of global IP addresses + unassign Unassigns a global IP +""" +# :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. +# :license: MIT, see LICENSE for more details. + +from SoftLayer import NetworkManager +from SoftLayer.CLI import ( + CLIRunnable, Table, FormattedItem, confirm, no_going_back) +from SoftLayer.CLI.helpers import CLIAbort, SequentialOutput + + +class GlobalIpAssign(CLIRunnable): + """ +usage: sl globalip assign [options] + +Assigns a global IP to a target. + +Required: + The ID or address of the global IP + The IP address to assign to the global IP +""" + action = 'assign' + + @staticmethod + def execute(client, args): + mgr = NetworkManager(client) + + id = mgr.resolve_global_ip_ids(args.get('')) + if not id: + raise CLIAbort("Unable to find global IP record for " + + args['']) + mgr.assign_global_ip(id, args['']) + + +class GlobalIpCancel(CLIRunnable): + """ +usage: sl globalip cancel [options] + +Cancel a subnet +""" + + action = 'cancel' + options = ['confirm'] + + @staticmethod + def execute(client, args): + mgr = NetworkManager(client) + id = mgr.resolve_global_ip_ids(args.get('')) + + if args['--really'] or no_going_back(id): + mgr.cancel_global_ip(id) + else: + CLIAbort('Aborted') + + +class GlobalIpCreate(CLIRunnable): + """ +usage: + sl globalip create [options] + +Add a new global IP address to your account. + +Options: + --v6 Orders IPv6 + --dry-run, --test Do not order the IP; just get a quote +""" + action = 'create' + options = ['confirm'] + + @staticmethod + def execute(client, args): + mgr = NetworkManager(client) + + version = 4 + if args.get('--v6'): + version = 6 + if not args.get('--test') and not args['--really']: + if not confirm("This action will incur charges on your account." + "Continue?"): + raise CLIAbort('Cancelling order.') + result = mgr.add_global_ip(version=version, + test_order=args.get('--test')) + if not result: + return 'Unable to place order: No valid price IDs found.' + t = Table(['Item', 'cost']) + t.align['Item'] = 'r' + t.align['cost'] = 'r' + + total = 0.0 + for price in result['orderDetails']['prices']: + total += float(price.get('recurringFee', 0.0)) + rate = "%.2f" % float(price['recurringFee']) + + t.add_row([price['item']['description'], rate]) + + t.add_row(['Total monthly cost', "%.2f" % total]) + output = SequentialOutput() + output.append(t) + output.append(FormattedItem( + '', + ' -- ! Prices reflected here are retail and do not ' + 'take account level discounts and are not guarenteed.') + ) + return t + + +class GlobalIpList(CLIRunnable): + """ +usage: sl globalip list [options] + +Displays a list of global IPs + +Filters: + --v4 Display only IPV4 + --v6 Display only IPV6 +""" + action = 'list' + + @staticmethod + def execute(client, args): + mgr = NetworkManager(client) + + t = Table([ + 'id', 'ip', 'assigned', 'target' + ]) + t.sortby = args.get('--sortby') or 'id' + + version = 0 + if args.get('--v4'): + version = 4 + elif args.get('--v6'): + version = 6 + + ips = mgr.list_global_ips(version=version) + + for ip in ips: + assigned = 'No' + target = 'None' + if ip.get('destinationIpAddress'): + dest = ip['destinationIpAddress'] + assigned = 'Yes' + target = dest['ipAddress'] + if dest.get('virtualGuest'): + vg = dest['virtualGuest'] + target += ' (' + vg['fullyQualifiedDomainName'] + ')' + elif ip['destinationIpAddress'].get('hardware'): + target += ' (' + \ + dest['hardware']['fullyQualifiedDomainName'] + \ + ')' + + t.add_row([ip['id'], ip['ipAddress']['ipAddress'], assigned, + target]) + return t + + +class GlobalIpUnassign(CLIRunnable): + """ +usage: sl globalip unassign [options] + +Unassigns a global IP from a target. + +Required: + The ID or address of the global IP +""" + action = 'unassign' + + @staticmethod + def execute(client, args): + mgr = NetworkManager(client) + + id = mgr.resolve_global_ip_ids(args.get('')) + if not id: + raise CLIAbort("Unable to find global IP record for " + + args['']) + mgr.unassign_global_ip(id) diff --git a/SoftLayer/CLI/modules/help.py b/SoftLayer/CLI/modules/help.py index 9be1e53b0..11137a4a1 100644 --- a/SoftLayer/CLI/modules/help.py +++ b/SoftLayer/CLI/modules/help.py @@ -6,7 +6,7 @@ View help on a module or command. """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. -# :license: BSD, see LICENSE for more details. +# :license: MIT, see LICENSE for more details. from SoftLayer.CLI.core import CommandParser from SoftLayer.CLI import CLIRunnable diff --git a/SoftLayer/CLI/modules/image.py b/SoftLayer/CLI/modules/image.py index 45b27ee79..dae619a1e 100644 --- a/SoftLayer/CLI/modules/image.py +++ b/SoftLayer/CLI/modules/image.py @@ -4,10 +4,10 @@ Manage compute and flex images The available commands are: - list List active vlans with firewalls + list List images """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. -# :license: BSD, see LICENSE for more details. +# :license: MIT, see LICENSE for more details. from SoftLayer.CLI import CLIRunnable, Table from SoftLayer.CLI.helpers import blank @@ -17,11 +17,11 @@ class ListImages(CLIRunnable): """ usage: sl image list [--public | --private] [options] -List images on the account +List images Options: - --public Display only public images --private Display only private images + --public Display only public images """ action = 'list' diff --git a/SoftLayer/CLI/modules/iscsi.py b/SoftLayer/CLI/modules/iscsi.py index 3401705c6..216adbd60 100644 --- a/SoftLayer/CLI/modules/iscsi.py +++ b/SoftLayer/CLI/modules/iscsi.py @@ -7,7 +7,7 @@ list List iSCSI targets """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. -# :license: BSD, see LICENSE for more details. +# :license: MIT, see LICENSE for more details. from SoftLayer.CLI import CLIRunnable, Table, FormattedItem from SoftLayer.CLI.helpers import NestedDict, blank diff --git a/SoftLayer/CLI/modules/messaging.py b/SoftLayer/CLI/modules/messaging.py index 550c12738..b50fa1f31 100644 --- a/SoftLayer/CLI/modules/messaging.py +++ b/SoftLayer/CLI/modules/messaging.py @@ -1,18 +1,33 @@ """ usage: sl messaging [] [...] [options] -Manage SoftLayer Message Queue +Manage the SoftLayer Message Queue service. For most commands, a queue account +is required. Use 'sl messaging accounts-list' to list current accounts 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 + accounts-list List all queue accounts + endpoints-list List all service endpoints + ping Ping the service + + queue-add Create a new queue + queue-detail Prints the details of a queue + queue-edit Modifies an existing queue + queue-list Lists out all queues on an account + queue-pop Pop a message from a queue + queue-push Pushes a message into a queue + queue-remove Delete a queue + + topic-add Creates a new topic + topic-detail Prints the details of a topic + topic-list Lists out all topics on an account + topic-push Pushes a notification to a topic + topic-remove Deletes a topic + topic-subscribe Adds a subscription on a topic + topic-unsubscribe Remove a subscription on a topic + """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. -# :license: BSD, see LICENSE for more details. -# from SoftLayer import NetworkManager +# :license: MIT, see LICENSE for more details. import sys from SoftLayer import MessagingManager @@ -26,19 +41,14 @@ """ -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] +usage: sl messaging accounts-list [options] List SoftLayer Message Queue Accounts """ - action = 'list-accounts' + action = 'accounts-list' @staticmethod def execute(client, args): @@ -60,12 +70,12 @@ def execute(client, args): class ListEndpoints(CLIRunnable): """ -usage: sl messaging list-endpoints [options] +usage: sl messaging endpoints-list [options] List SoftLayer Message Queue Endpoints """ - action = 'list-endpoints' + action = 'endpoints-list' @staticmethod def execute(client, args): @@ -98,7 +108,7 @@ class Ping(CLIRunnable): def execute(client, args): manager = MessagingManager(client) ok = manager.ping( - endpoint_name=args['--datacenter'], network=args['--network']) + datacenter=args['--datacenter'], network=args['--network']) if ok: return 'OK' else: @@ -154,206 +164,367 @@ def subscription_table(sub): return t -class Queue(CLIRunnable): +class QueueList(CLIRunnable): + __doc__ = """ +usage: sl messaging queue-list [options] + +List all queues on an account + +""" + COMMON_MESSAGING_ARGS + action = 'queue-list' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = manager.get_connection(args['']) + + 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 + + +class QueueDetail(CLIRunnable): + __doc__ = """ +usage: sl messaging queue-detail [options] + +Detail a queue + +""" + COMMON_MESSAGING_ARGS + action = 'queue-detail' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = manager.get_connection(args['']) + queue = mq_client.get_queue(args['']) + return queue_table(queue) + + +class QueueCreate(CLIRunnable): + __doc__ = """ +usage: sl messaging queue-add [options] + +Create a queue + +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 + +""" + COMMON_MESSAGING_ARGS + action = 'queue-add' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = manager.get_connection(args['']) + 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) + + +class QueueModify(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] +usage: sl messaging queue-edit [options] -Manage queues +Modify a queue -Queue Create/modify Options: +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: +""" + COMMON_MESSAGING_ARGS + action = 'queue-edit' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = manager.get_connection(args['']) + 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) + + +class QueueDelete(CLIRunnable): + __doc__ = """ +usage: sl messaging queue-remove [] + [options] + +Delete a queue or a queued message + +Options: --force Flag to force the deletion of the queue even when there are messages -Pop Options: +""" + COMMON_MESSAGING_ARGS + action = 'queue-remove' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = manager.get_connection(args['']) + + if args['']: + mq_client.delete_message(args[''], + args['']) + else: + mq_client.delete_queue(args[''], args.get('--force')) + + +class QueuePush(CLIRunnable): + __doc__ = """ +usage: sl messaging queue-push ( | [-]) + [options] + +Push a message into a queue + +Options: + --force Flag to force the deletion of the queue even when there are messages + +""" + COMMON_MESSAGING_ARGS + action = 'queue-push' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = manager.get_connection(args['']) + body = '' + if args[''] is not None: + body = args[''] + else: + body = sys.stdin.read() + return message_table( + mq_client.push_queue_message(args[''], body)) + + +class QueuePop(CLIRunnable): + __doc__ = """ +usage: sl messaging queue-pop [options] + +Pops a message from a queue + +Options: --count=NUM Count of messages to pop --delete-after Remove popped messages from the queue """ + COMMON_MESSAGING_ARGS - action = 'queue' + action = 'queue-pop' @classmethod def execute(cls, client, args): manager = MessagingManager(client) - mq_client = get_mq_client(manager, args[''], cls.env) + mq_client = manager.get_connection(args['']) - # list - if args['list']: - queues = mq_client.get_queues()['items'] + messages = mq_client.pop_message( + args[''], + args.get('--count') or 1) + formatted_messages = [] + for message in messages['items']: + formatted_messages.append(message_table(message)) - 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 = [] + if args.get('--delete-after'): 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') + mq_client.delete_message( + args[''], + message['id']) + return formatted_messages + + +class TopicList(CLIRunnable): + __doc__ = """ +usage: sl messaging topic-list [options] + +List all topics on an account + +""" + COMMON_MESSAGING_ARGS + action = 'topic-list' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = manager.get_connection(args['']) + topics = mq_client.get_topics()['items'] + + t = Table(['name']) + for topic in topics: + t.add_row([topic['name']]) + return t + + +class TopicDetail(CLIRunnable): + __doc__ = """ +usage: sl messaging topic-detail [options] + +Detail a topic + +""" + COMMON_MESSAGING_ARGS + action = 'topic-detail' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = manager.get_connection(args['']) + 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] + + +class TopicCreate(CLIRunnable): + __doc__ = """ +usage: sl messaging topic-add [options] + +Create a new topic +""" + COMMON_MESSAGING_ARGS + action = 'topic-add' -class Topic(CLIRunnable): + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = manager.get_connection(args['']) + 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) + + +class TopicDelete(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] +usage: sl messaging topic-remove [options] + +Delete a topic or subscription -Manage topics and subscriptions +Options: + --force Flag to force the deletion of the topic even when there are + subscriptions +""" + COMMON_MESSAGING_ARGS + action = 'topic-remove' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = manager.get_connection(args['']) + mq_client.delete_topic(args[''], args.get('--force')) -Topic/Subscription Create Options: - --subscription Create a subscription + +class TopicSubscribe(CLIRunnable): + __doc__ = """ +usage: sl messaging topic-subscribe [options] + +Create a subscription on a topic + +Options: --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-subscribe' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = manager.get_connection(args['']) + 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) + + +class TopicUnsubscribe(CLIRunnable): + __doc__ = """ +usage: sl messaging topic-unsubscribe + [options] + +Remove a subscription on a topic """ + COMMON_MESSAGING_ARGS - action = 'topic' + action = 'topic-unsubscribe' @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)) + mq_client = manager.get_connection(args['']) + + mq_client.delete_subscription( + args[''], + args['']) + + +class TopicPush(CLIRunnable): + __doc__ = """ +usage: sl messaging topic-push ( | [-]) + [options] + +Push a message into a topic + +""" + COMMON_MESSAGING_ARGS + action = 'topic-push' + + @classmethod + def execute(cls, client, args): + manager = MessagingManager(client) + mq_client = manager.get_connection(args['']) + + # the message body comes from the positional argument or stdin + body = '' + if args[''] is not None: + body = args[''] else: - raise CLIAbort('Invalid command') + body = sys.stdin.read() + return message_table( + mq_client.push_topic_message(args[''], body)) diff --git a/SoftLayer/CLI/modules/metadata.py b/SoftLayer/CLI/modules/metadata.py index f70757687..f6b5727dd 100644 --- a/SoftLayer/CLI/modules/metadata.py +++ b/SoftLayer/CLI/modules/metadata.py @@ -21,10 +21,10 @@ user_data User-defined data """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. -# :license: BSD, see LICENSE for more details. +# :license: MIT, see LICENSE for more details. from SoftLayer import MetadataManager -from SoftLayer.CLI import CLIRunnable, Table, listing, CLIAbort +from SoftLayer.CLI import CLIRunnable, KeyValueTable, listing, CLIAbort class BackendMacAddresses(CLIRunnable): @@ -200,7 +200,7 @@ class Network(CLIRunnable): def execute(client, args): meta = MetadataManager() if args['']: - t = Table(['Name', 'Value']) + t = KeyValueTable(['Name', 'Value']) t.align['Name'] = 'r' t.align['Value'] = 'l' network = meta.public_network() @@ -217,7 +217,7 @@ def execute(client, args): return t if args['']: - t = Table(['Name', 'Value']) + t = KeyValueTable(['Name', 'Value']) t.align['Name'] = 'r' t.align['Value'] = 'l' network = meta.private_network() diff --git a/SoftLayer/CLI/modules/nas.py b/SoftLayer/CLI/modules/nas.py index 674d45a0a..0bdf06dc8 100644 --- a/SoftLayer/CLI/modules/nas.py +++ b/SoftLayer/CLI/modules/nas.py @@ -7,7 +7,7 @@ list List NAS accounts """ # :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. -# :license: BSD, see LICENSE for more details. +# :license: MIT, see LICENSE for more details. from SoftLayer.CLI import CLIRunnable, Table, FormattedItem from SoftLayer.CLI.helpers import NestedDict, blank diff --git a/SoftLayer/CLI/modules/rwhois.py b/SoftLayer/CLI/modules/rwhois.py new file mode 100644 index 000000000..0cf43ea86 --- /dev/null +++ b/SoftLayer/CLI/modules/rwhois.py @@ -0,0 +1,97 @@ +""" +usage: sl rwhois [] [...] [options] + +Manage the RWhoIs information on the account. + +The available commands are: + edit Edit the RWhois data on the account + show Show the RWhois data on the account +""" +# :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. +# :license: MIT, see LICENSE for more details. + +from SoftLayer import NetworkManager +from SoftLayer.CLI import CLIRunnable, KeyValueTable +from SoftLayer.CLI.helpers import CLIAbort + + +class RWhoisEdit(CLIRunnable): + """ +usage: sl rwhois edit [options] + +Updates the RWhois information on your account. Only the fields you +specify will be changed. To clear a value, specify an empty string like: "" + +Options: + --abuse=EMAIL Set the abuse email + --address1=ADDR Update the address 1 field + --address2=ADDR Update the address 2 field + --city=CITY Set the city information + --country=COUNTRY Set the country information. Use the two-letter + abbreviation. + --firstname=NAME Update the first name field + --lastname=NAME Update the last name field + --postal=CODE Set the postal code field + --private Flags the address as a private residence. + --public Flags the address as a public residence. + --state=STATE Set the state information. Use the two-letter + abbreviation. +""" + action = 'edit' + + @staticmethod + def execute(client, args): + mgr = NetworkManager(client) + + update = { + 'abuse_email': args.get('--abuse'), + 'address1': args.get('--address1'), + 'address2': args.get('--address2'), + 'city': args.get('--city'), + 'country': args.get('--country'), + 'first_name': args.get('--firstname'), + 'last_name': args.get('--lastname'), + 'postal_code': args.get('--postal'), + 'state': args.get('--state') + } + + if args.get('--private'): + update['private_residence'] = False + elif args.get('--public'): + update['private_residence'] = True + + check = [x for x in update.values() if x is not None] + if not check: + raise CLIAbort("You must specify at least one field to update.") + + mgr.edit_rwhois(**update) + + +class RWhoisShow(CLIRunnable): + """ +usage: sl rwhois show [options] + +Display the RWhois information for your account. +""" + action = 'show' + + @staticmethod + def execute(client, args): + mgr = NetworkManager(client) + result = mgr.get_rwhois() + + t = KeyValueTable(['Name', 'Value']) + t.align['Name'] = 'r' + t.align['Value'] = 'l' + t.add_row(['Name', result['firstName'] + ' ' + result['lastName']]) + t.add_row(['Company', result['companyName']]) + t.add_row(['Abuse Email', result['abuseEmail']]) + t.add_row(['Address 1', result['address1']]) + if result.get('address2'): + t.add_row(['Address 2', result['address2']]) + t.add_row(['City', result['city']]) + t.add_row(['State', result.get('state', '-')]) + t.add_row(['Postal Code', result.get('postalCode', '-')]) + t.add_row(['Country', result['country']]) + + return t diff --git a/SoftLayer/CLI/modules/hardware.py b/SoftLayer/CLI/modules/server.py similarity index 55% rename from SoftLayer/CLI/modules/hardware.py rename to SoftLayer/CLI/modules/server.py index 1fbe604f1..911d34f3e 100644 --- a/SoftLayer/CLI/modules/hardware.py +++ b/SoftLayer/CLI/modules/server.py @@ -1,55 +1,63 @@ """ -usage: sl hardware [] [...] [options] - sl hardware [-h | --help] +usage: sl server [] [...] [options] + sl server [-h | --help] -Manage hardware +Manage hardware servers The available commands are: - list List hardware devices - detail Retrieve hardware details - reload Perform an OS reload - cancel Cancel a dedicated server. + 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 Create a new dedicated server create-options Display a list of creation options for a specific chassis - create Create a new dedicated server + detail Retrieve hardware details + list List hardware devices + list-chassis Provide a list of all chassis available for ordering + nic-edit Edit NIC settings + power-cycle Issues power cycle to server + power-off Powers off a running server + power-on Boots up a server + reboot Reboots a running server + reload Perform an OS reload For several commands, will be asked for. This can be the id, hostname or the ip address for a piece of hardware. """ +# :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. +# :license: MIT, see LICENSE for more details. import re +import os 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 + CLIRunnable, Table, KeyValueTable, FormattedItem, NestedDict, CLIAbort, + blank, listing, gb, no_going_back, resolve_id, confirm, ArgumentError, + update_with_template_args, export_to_template) +from SoftLayer import HardwareManager, SshKeyManager -class ListHardware(CLIRunnable): +class ListServers(CLIRunnable): """ -usage: sl hardware list [options] +usage: sl server 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 + sl server list --datacenter=dal05 + sl server list --network=100 --domain=example.com + sl server 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) + -c, --cpu=CPU Number of CPU cores + -D, --domain=DOMAIN Domain portion of the FQDN. example: example.com + -d, --datacenter=DC Datacenter shortname (sng01, dal05, ...) + -H, --hostname=HOST Host portion of the FQDN. example: server + -m, --memory=MEMORY Memory in gigabytes + -n, --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' """ @@ -87,9 +95,9 @@ def execute(client, args): server = NestedDict(server) t.add_row([ server['id'], - server['datacenter']['name'] or blank(), + server['datacenter']['name'], server['fullyQualifiedDomainName'], - server['processorCoreAmount'], + server['processorPhysicalCoreAmount'], gb(server['memoryCapacity']), server['primaryIpAddress'] or blank(), server['primaryBackendIpAddress'] or blank(), @@ -98,9 +106,9 @@ def execute(client, args): return t -class HardwareDetails(CLIRunnable): +class ServerDetails(CLIRunnable): """ -usage: sl hardware detail [--passwords] [--price] [options] +usage: sl server detail [--passwords] [--price] [options] Get details for a hardware device @@ -114,7 +122,7 @@ class HardwareDetails(CLIRunnable): def execute(client, args): hardware = HardwareManager(client) - t = Table(['Name', 'Value']) + t = KeyValueTable(['Name', 'Value']) t.align['Name'] = 'r' t.align['Value'] = 'l' @@ -126,8 +134,8 @@ def execute(client, args): 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(['datacenter', result['datacenter']['name']]) + t.add_row(['cores', result['processorPhysicalCoreAmount']]) t.add_row(['memory', gb(result['memoryCapacity'])]) t.add_row(['public_ip', result['primaryIpAddress'] or blank()]) t.add_row( @@ -140,7 +148,14 @@ def execute(client, args): result['operatingSystem']['softwareLicense'] ['softwareDescription']['name'] or blank() )]) - t.add_row(['created', result['provisionDate']]) + t.add_row(['created', result['provisionDate'] or blank()]) + + vlan_table = Table(['type', 'number', 'id']) + for vlan in result['networkVlans']: + vlan_table.add_row([ + vlan['networkSpace'], vlan['vlanNumber'], vlan['id']]) + t.add_row(['vlans', vlan_table]) + if result.get('notes'): t.add_row(['notes', result['notes']]) @@ -161,25 +176,26 @@ def execute(client, args): if tag_row: t.add_row(['tags', listing(tag_row, separator=',')]) - ptr_domains = client['Hardware_Server'].getReverseDomainRecords( - id=hardware_id) + if not result['privateNetworkOnlyFlag']: + 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']]) + for ptr_domain in ptr_domains: + for ptr in ptr_domain['resourceRecords']: + t.add_row(['ptr', ptr['data']]) return t -class HardwareReload(CLIRunnable): +class ServerReload(CLIRunnable): """ -usage: sl hardware reload [options] +usage: sl server 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) + -i, --postinstall=URI Post-install script to download + (Only HTTPS executes, HTTP leaves file in /root) """ action = 'reload' @@ -196,27 +212,31 @@ def execute(client, args): CLIAbort('Aborted') -class CancelHardware(CLIRunnable): +class CancelServer(CLIRunnable): """ -usage: sl hardware cancel [options] +usage: sl server cancel [options] Cancel a dedicated server Options: - --reason An optional cancellation reason. See cancel-reasons for a list of - available options. + --comment=COMMENT An optional comment to add to the cancellation ticket + --reason=REASON An optional cancellation reason. See cancel-reasons for a + list of available options """ action = 'cancel' options = ['confirm'] - @staticmethod - def execute(client, args): + @classmethod + def execute(cls, client, args): hw = HardwareManager(client) - hw_id = resolve_id(hw, args.get('')) + hw_id = resolve_id( + hw.resolve_ids, args.get(''), 'hardware') + + comment = args.get('--comment') - print "(Optional) Add a cancellation comment:", - comment = raw_input() + if not comment and not args['--really']: + comment = cls.env.input("(Optional) Add a cancellation comment:") reason = args.get('--reason') @@ -226,9 +246,9 @@ def execute(client, args): CLIAbort('Aborted') -class HardwareCancelReasons(CLIRunnable): +class ServerCancelReasons(CLIRunnable): """ -usage: sl hardware cancel-reasons +usage: sl server cancel-reasons Display a list of cancellation reasons """ @@ -250,54 +270,126 @@ def execute(client, args): return t -class NetworkHardware(CLIRunnable): +class ServerPowerOff(CLIRunnable): """ -usage: sl hardware network port --speed=SPEED - (--public | --private) [options] +usage: sl server power-off [options] -Manage network settings +Power off an active server +""" + action = 'power-off' + options = ['confirm'] -Options: - --speed=SPEED Port speed. 0 disables the port. - [Options: 0, 10, 100, 1000, 10000] - --public Public network - --private Private network + @classmethod + def execute(cls, client, args): + hw = client['Hardware_Server'] + mgr = HardwareManager(client) + hw_id = resolve_id(mgr.resolve_ids, args.get(''), + 'hardware') + if args['--really'] or confirm('This will power off the server with ' + 'id %s. Continue?' % hw_id): + hw.powerOff(id=hw_id) + else: + raise CLIAbort('Aborted.') + + +class ServerReboot(CLIRunnable): + """ +usage: sl server reboot [--hard | --soft] [options] + +Reboot an active server + +Optional: + --hard Perform an abrupt reboot + --soft Perform a graceful reboot """ - action = 'network' + action = 'reboot' + options = ['confirm'] @classmethod def execute(cls, client, args): - if args['port']: - return cls.exec_port(client, args) + hw = client['Hardware_Server'] + mgr = HardwareManager(client) + hw_id = resolve_id(mgr.resolve_ids, args.get(''), + 'hardware') + if args['--really'] or confirm('This will power off the server with ' + 'id %s. Continue?' % hw_id): + if args['--hard']: + hw.rebootHard(id=hw_id) + elif args['--soft']: + hw.rebootSoft(id=hw_id) + else: + hw.rebootDefault(id=hw_id) + else: + raise CLIAbort('Aborted.') - if args['details']: - return cls.exec_detail(client, args) - @staticmethod - def exec_port(client, args): - public = True - if args['--private']: - public = False +class ServerPowerOn(CLIRunnable): + """ +usage: sl server power-on [options] + +Power on a server +""" + action = 'power-on' + @classmethod + def execute(cls, client, args): + hw = client['Hardware_Server'] mgr = HardwareManager(client) hw_id = resolve_id(mgr.resolve_ids, args.get(''), 'hardware') + hw.powerOn(id=hw_id) - result = mgr.change_port_speed(hw_id, public, args['--speed']) - if result: - return "Success" + +class ServerPowerCycle(CLIRunnable): + """ +usage: sl server power-cycle [options] + +Issues power cycle to server via the power strip +""" + action = 'power-cycle' + options = ['confirm'] + + @classmethod + def execute(cls, client, args): + hw = client['Hardware_Server'] + mgr = HardwareManager(client) + hw_id = resolve_id(mgr.resolve_ids, args.get(''), + 'hardware') + + if args['--really'] or confirm('This will power off the server with ' + 'id %s. Continue?' % hw_id): + hw.powerCycle(id=hw_id) else: - return result + raise CLIAbort('Aborted.') - @staticmethod - def exec_detail(client, args): - # TODO this should print out default gateway and stuff - raise CLIAbort('Not implemented') +class NicEditServer(CLIRunnable): + """ +usage: sl server nic-edit (public | private) --speed=SPEED + [options] + +Manage NIC settings + +Options: + --speed=SPEED Port speed. 0 disables the port. + [Options: 0, 10, 100, 1000, 10000] +""" + action = 'nic-edit' + + @classmethod + def execute(cls, client, args): + public = args['public'] + + mgr = HardwareManager(client) + hw_id = resolve_id(mgr.resolve_ids, args.get(''), + 'hardware') + + mgr.change_port_speed(hw_id, public, args['--speed']) -class ListChassisHardware(CLIRunnable): + +class ListChassisServer(CLIRunnable): """ -usage: sl hardware list-chassis +usage: sl server list-chassis [options] Display a list of chassis available for ordering dedicated servers. """ @@ -318,22 +410,22 @@ def execute(client, args): return t -class HardwareCreateOptions(CLIRunnable): +class ServerCreateOptions(CLIRunnable): """ -usage: sl hardware create-options [options] +usage: sl server 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 + --controller Show disk controller options --cpu Show CPU options - --nic Show NIC speed options + --datacenter Show datacenter options --disk Show disk options - --os Show operating system options --memory Show memory size options - --controller Show disk controller options + --nic Show NIC speed options + --os Show operating system options """ action = 'create-options' @@ -344,7 +436,7 @@ class HardwareCreateOptions(CLIRunnable): def execute(cls, client, args): mgr = HardwareManager(client) - t = Table(['Name', 'Value']) + t = KeyValueTable(['Name', 'Value']) t.align['Name'] = 'r' t.align['Value'] = 'l' @@ -369,9 +461,10 @@ def execute(cls, client, args): if args['--cpu'] or show_all: results = cls.get_create_options(ds_options, 'cpu') + cpu_table = Table(['id', 'description']) for result in sorted(results): - t.add_row([result[0], listing( - item[0] for item in sorted(result[1]))]) + cpu_table.add_row([result[1], result[0]]) + t.add_row(['cpu', cpu_table]) if args['--memory'] or show_all: results = cls.get_create_options(ds_options, 'memory')[0] @@ -383,14 +476,22 @@ def execute(cls, client, args): 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]))]) + t.add_row([ + result[0], + listing( + [item[0] for item in sorted(result[1])], + separator=linesep + )]) 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],))]) + t.add_row([ + results[0], + listing( + [item[0] for item in sorted(results[1])], + separator=linesep + )]) if args['--nic'] or show_all: results = cls.get_create_options(ds_options, 'nic') @@ -426,16 +527,12 @@ def get_create_options(cls, ds_options, section, pretty=True): 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'])])) + results.append(( + item['description'], + item['price_id'] + )) return results elif 'memory' == section: @@ -554,7 +651,7 @@ def _generate_windows_code(description): 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'])) + disks.append((disk_type, disk['price_id'], disk['id'])) return [('disk', disks)] elif 'nic' == section: @@ -566,7 +663,8 @@ def _generate_windows_code(description): dual.append((str(int(item['capacity'])) + '_DUAL', item['price_id'])) else: - single.append((int(item['capacity']), item['price_id'])) + single.append((str(int(item['capacity'])), + item['price_id'])) return [('single nic', single), ('dual nic', dual)] elif 'disk_controller' == section: @@ -581,41 +679,63 @@ def _generate_windows_code(description): return [('disk_controllers', options)] - return [] - -class CreateHardware(CLIRunnable): +class CreateServer(CLIRunnable): """ -usage: sl hardware create --hostname=HOST --domain=DOMAIN --cpu=CPU - --chassis=CHASSIS --memory=MEMORY --os=OS --disk=SIZE... [options] +usage: sl server create [--disk=SIZE...] [--key=KEY...] [options] -Order/create a dedicated server. See 'sl hardware list-chassis' and -'sl hardware create-options' for valid options +Order/create a dedicated server. See 'sl server list-chassis' and +'sl server create-options' for valid options. --disk can be repeated to +order multiple disks. Required: -H --hostname=HOST Host portion of the FQDN. example: server - -D --domain=DOMAIN Domain portion of the FQDN example: example.com + -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) + -m --memory=MEMORY Memory in gigabytes. example: 4 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 + -d, --datacenter=DC datacenter name + Note: Omitting this value defaults to the first + available datacenter + -n, --network=MBPS Network port speed in Mbps + -d, --disk=SIZE... Disks. Can be specified multiple times + --controller=RAID The RAID configuration for the server. + Defaults to None. + -k KEY, --key=KEY SSH keys to assign to the root user. Can be specified + multiple times. + --dry-run, --test Do not create the server, just get a quote + --vlan_public=VLAN The ID of the public VLAN on which you want the CCI + placed. + --vlan_private=VLAN The ID of the private VLAN on which you want the CCI + placed. + -t, --template=FILE A template file that defaults the command-line + options using the long name in INI format + --export=FILE Exports options to a template file """ action = 'create' + options = ['confirm'] + required_params = ['--hostname', '--domain', '--chassis', '--cpu', + '--memory', '--os'] @classmethod def execute(cls, client, args): + update_with_template_args(args) mgr = HardwareManager(client) + # Disks will be a comma-separated list. Let's make it a real list. + if isinstance(args.get('--disk'), str): + args['--disk'] = args.get('--disk').split(',') + + # Do the same thing for SSH keys + if isinstance(args.get('--key'), str): + args['--key'] = args.get('--key').split(',') + + cls._validate_args(args) + ds_options = mgr.get_dedicated_server_create_options(args['--chassis']) order = { @@ -635,16 +755,15 @@ def execute(cls, client, args): 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['server'] = args['--cpu'] order['ram'] = cls._get_price_id_from_options(ds_options, 'memory', int(args['--memory'])) # Set the disk sizes disk_prices = [] + disk_number = 0 for disk in args.get('--disk'): - disk_price = cls._get_price_id_from_options(ds_options, 'disk', - disk) - + disk_price = cls._get_disk_price(ds_options, disk, disk_number) + disk_number += 1 if disk_price: disk_prices.append(disk_price) @@ -666,7 +785,7 @@ def execute(cls, client, args): order['disk_controller'] = dc_price # Set the port speed - port_speed = args.get('--network') or 10 + port_speed = args.get('--network') or '100' nic_price = cls._get_price_id_from_options(ds_options, 'nic', port_speed) @@ -676,50 +795,77 @@ def execute(cls, client, args): else: raise CLIAbort('Invalid NIC speed specified.') - # Begin output - t = Table(['Item', 'cost']) - t.align['Item'] = 'r' - t.align['cost'] = 'r' + # Get the SSH keys + if args.get('--key'): + keys = [] + for key in args.get('--key'): + key_id = resolve_id(SshKeyManager(client).resolve_ids, key, + 'SshKey') + keys.append(key_id) + order['ssh_keys'] = keys + + if args.get('--vlan_public'): + order['public_vlan'] = args['--vlan_public'] + + if args.get('--vlan_private'): + order['private_vlan'] = args['--vlan_private'] + + # Do not create hardware server with --test or --export + do_create = not (args['--export'] or args['--test']) + output = None if args.get('--test'): result = mgr.verify_order(**order) + t = Table(['Item', 'cost']) + t.align['Item'] = 'r' + t.align['cost'] = 'r' + 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']) + 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) + t.add_row(['Total monthly cost', "%.2f" % total]) + output = [] output.append(t) output.append(FormattedItem( '', ' -- ! Prices reflected here are retail and do not ' - 'take account level discounts and are not guarenteed.') + 'take account level discounts and are not guaranteed.') ) - 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.') + + if args['--export']: + export_file = args.pop('--export') + export_to_template(export_file, args, exclude=['--wait', '--test']) + return 'Successfully exported options to a template file.' + + if do_create: + if args['--really'] or confirm( + "This action will incur charges on your account. " + "Continue?"): + result = mgr.place_order(**order) + + t = KeyValueTable(['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 _validate_args(cls, args): + invalid_args = [k for k in cls.required_params if args.get(k) is None] + if invalid_args: + raise ArgumentError('Missing required options: %s' + % ','.join(invalid_args)) + @classmethod def _get_default_value(cls, ds_options, option): if option not in ds_options['categories']: @@ -727,22 +873,82 @@ def _get_default_value(cls, ds_options, option): 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)), + float(item.get('setupFee', 0)), + float(item.get('recurringFee', 0)), + float(item.get('hourlyRecurringFee', 0)), + float(item.get('oneTimeFee', 0)), + float(item.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 + def _get_disk_price(cls, ds_options, value, number): + if not number: + return cls._get_price_id_from_options(ds_options, 'disk', value) + # This will get the item ID for the matching identifier string, which + # we can then use to get the price ID for our specific disk + item_id = cls._get_price_id_from_options(ds_options, 'disk', + value, True) + key = 'disk' + str(number) + if key in ds_options['categories']: + for item in ds_options['categories'][key]['items']: + if item['id'] == item_id: + return item['price_id'] + + @classmethod + def _get_price_id_from_options(cls, ds_options, option, value, + item_id=False): + ds_obj = ServerCreateOptions() 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] + if not item_id: + return item_options[1] + return item_options[2] + - return price_id +class EditServer(CLIRunnable): + """ +usage: sl server edit [options] + +Edit hardware details + +Options: + -D --domain=DOMAIN Domain portion of the FQDN example: example.com + -F --userfile=FILE Read userdata from file + -H --hostname=HOST Host portion of the FQDN. example: server + -u --userdata=DATA User defined metadata string +""" + action = 'edit' + + @staticmethod + def execute(client, args): + data = {} + + if args['--userdata'] and args['--userfile']: + raise ArgumentError('[-u | --userdata] not allowed with ' + '[-F | --userfile]') + if args['--userfile']: + if not os.path.exists(args['--userfile']): + raise ArgumentError( + 'File does not exist [-u | --userfile] = %s' + % args['--userfile']) + + if args.get('--userdata'): + data['userdata'] = args['--userdata'] + elif args.get('--userfile'): + f = open(args['--userfile'], 'r') + try: + data['userdata'] = f.read() + finally: + f.close() + + data['hostname'] = args.get('--hostname') + data['domain'] = args.get('--domain') + + hw = HardwareManager(client) + hw_id = resolve_id(hw.resolve_ids, args.get(''), + 'hardware') + if not hw.edit(hw_id, **data): + raise CLIAbort("Failed to update hardware") diff --git a/SoftLayer/CLI/modules/sshkey.py b/SoftLayer/CLI/modules/sshkey.py new file mode 100644 index 000000000..bd392c35b --- /dev/null +++ b/SoftLayer/CLI/modules/sshkey.py @@ -0,0 +1,170 @@ +""" +usage: sl sshkey [] [...] [options] + +Manage SSH keys + +The available commands are: + add Add a new SSH key to your account + remove Removes an SSH key + edit Edits information about the SSH key + list Display a list of SSH keys on your account + print Prints out an SSH key +""" +# :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. +# :license: MIT, see LICENSE for more details. + +from os.path import expanduser + +from SoftLayer import SshKeyManager +from SoftLayer.CLI import CLIRunnable, Table, no_going_back +from SoftLayer.CLI.helpers import CLIAbort, resolve_id, KeyValueTable + + +class AddSshKey(CLIRunnable): + """ +usage: sl sshkey add