diff --git a/CHANGELOG b/CHANGELOG index cfddce6bb..790c07eb4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,9 @@ +3.0.1 + + * CLI: Fixed an error message about pricing information that appeared when ordering a new private subnet. + + * CLI+API: Added ability to specify SSH keys when reloading CCIs and servers. + 3.0.0 * Many bug fixes and consistency improvements @@ -7,7 +13,7 @@ * 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' diff --git a/SoftLayer/API.py b/SoftLayer/API.py index b3b2940ed..5f2b4175a 100644 --- a/SoftLayer/API.py +++ b/SoftLayer/API.py @@ -10,7 +10,6 @@ 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 @@ -149,7 +148,7 @@ def call(self, service, method, *args, **kwargs): headers.update(self.auth.get_headers()) if objectid is not None: - headers[service + 'InitParameters'] = {'id': int(objectid)} + headers[service + 'InitParameters'] = {'id': objectid} if objectmask is not None: headers.update(self.__format_object_mask(objectmask, service)) @@ -159,8 +158,8 @@ def call(self, service, method, *args, **kwargs): if limit: headers['resultLimit'] = { - 'limit': int(limit), - 'offset': int(offset) + 'limit': limit, + 'offset': offset, } http_headers = { @@ -242,15 +241,9 @@ def __format_object_mask(self, objectmask, service): mheader = self._prefix + 'ObjectMask' objectmask = objectmask.strip() - if objectmask.startswith('mask'): - objectmask = objectmask[4:] - if objectmask[0] == '.': - objectmask = objectmask[1:] - elif objectmask[0] == '[' and objectmask[-1] == ']': - objectmask = objectmask[1:-1] - else: - raise SoftLayerError('Malformed Mask: %s' % objectmask) - objectmask = "mask[%s]" % objectmask + if not objectmask.startswith('mask') \ + and not objectmask.startswith('['): + objectmask = "mask[%s]" % objectmask return {mheader: {'mask': objectmask}} diff --git a/SoftLayer/CLI/core.py b/SoftLayer/CLI/core.py index 1a1428f57..1ab546509 100644 --- a/SoftLayer/CLI/core.py +++ b/SoftLayer/CLI/core.py @@ -198,7 +198,7 @@ def main(args=sys.argv[1:], env=Environment()): env.err('') env.err(str(e)) exit_status = 1 - except InvalidModule, e: + except InvalidModule as e: env.err(resolver.get_main_help()) if e.module_name: env.err('') diff --git a/SoftLayer/CLI/formatting.py b/SoftLayer/CLI/formatting.py index 53dc6cf9b..69f008496 100644 --- a/SoftLayer/CLI/formatting.py +++ b/SoftLayer/CLI/formatting.py @@ -126,6 +126,21 @@ def listing(items, separator=','): return SequentialOutput(separator, items) +def active_txn(item): + """ Returns a FormattedItem describing the active transaction (if any) on + the given object. If no active transaction is running, returns a blank + FormattedItem. + + :param item: An object capable of having an active transaction + """ + if not item['activeTransaction']['transactionStatus']: + return blank() + + return FormattedItem( + item['activeTransaction']['transactionStatus'].get('name'), + item['activeTransaction']['transactionStatus'].get('friendlyName')) + + def valid_response(prompt, *valid): ans = raw_input(prompt).lower() diff --git a/SoftLayer/CLI/helpers.py b/SoftLayer/CLI/helpers.py index c6e05e7f1..20bb1dae5 100644 --- a/SoftLayer/CLI/helpers.py +++ b/SoftLayer/CLI/helpers.py @@ -12,7 +12,8 @@ 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) + no_going_back, mb_to_gb, gb, listing, blank, format_output, + active_txn, valid_response) from template import update_with_template_args, export_to_template __all__ = [ @@ -23,7 +24,7 @@ # Formatting 'Table', 'KeyValueTable', 'FormattedItem', 'SequentialOutput', 'valid_response', 'confirm', 'no_going_back', 'mb_to_gb', 'gb', - 'listing', 'format_output', 'blank', + 'listing', 'format_output', 'blank', 'active_txn', # Template 'update_with_template_args', 'export_to_template', ] diff --git a/SoftLayer/CLI/modules/cci.py b/SoftLayer/CLI/modules/cci.py index a2a9acf66..3af2c684e 100755 --- a/SoftLayer/CLI/modules/cci.py +++ b/SoftLayer/CLI/modules/cci.py @@ -37,7 +37,8 @@ FormattedItem) from SoftLayer.CLI.helpers import ( CLIAbort, ArgumentError, NestedDict, blank, resolve_id, KeyValueTable, - update_with_template_args, FALSE_VALUES, export_to_template) + update_with_template_args, FALSE_VALUES, export_to_template, + active_txn) class ListCCIs(CLIRunnable): @@ -109,8 +110,7 @@ def execute(client, args): mb_to_gb(guest['maxMemory']), guest['primaryIpAddress'] or blank(), guest['primaryBackendIpAddress'] or blank(), - guest['activeTransaction']['transactionStatus'].get( - 'friendlyName') or blank(), + active_txn(guest), ]) return t @@ -145,6 +145,7 @@ def execute(client, args): result['status']['keyName'] or blank(), result['status']['name'] or blank() )]) + t.add_row(['active_transaction', active_txn(result)]) t.add_row(['state', FormattedItem( lookup(result, 'powerState', 'keyName'), lookup(result, 'powerState', 'name'), @@ -656,13 +657,15 @@ def execute(client, args): class ReloadCCI(CLIRunnable): """ -usage: sl cci reload [options] +usage: sl cci reload [--key=KEY...] [options] Reload the OS on a CCI based on its current configuration Optional: - -i, --postinstall=URI Post-install script to download - (Only HTTPS executes, HTTP leaves file in /root) + -i, --postinstall=URI Post-install script to download + (Only HTTPS executes, HTTP leaves file in /root) + -k, --key=KEY SSH keys to add to the root user. Can be specified + multiple times """ action = 'reload' @@ -672,8 +675,14 @@ class ReloadCCI(CLIRunnable): def execute(client, args): cci = CCIManager(client) cci_id = resolve_id(cci.resolve_ids, args.get(''), 'CCI') + keys = [] + if args.get('--key'): + for key in args.get('--key'): + key_id = resolve_id(SshKeyManager(client).resolve_ids, key, + 'SshKey') + keys.append(key_id) if args['--really'] or no_going_back(cci_id): - cci.reload_instance(cci_id, args['--postinstall']) + cci.reload_instance(cci_id, args['--postinstall'], keys) else: CLIAbort('Aborted') diff --git a/SoftLayer/CLI/modules/config.py b/SoftLayer/CLI/modules/config.py index b06649df4..e5ade3cd3 100644 --- a/SoftLayer/CLI/modules/config.py +++ b/SoftLayer/CLI/modules/config.py @@ -156,8 +156,8 @@ def execute(cls, client, args): 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') + f = os.fdopen(os.open( + config_path, (os.O_WRONLY | os.O_CREAT), 0600), 'w') try: config.write(f) finally: diff --git a/SoftLayer/CLI/modules/server.py b/SoftLayer/CLI/modules/server.py index 911d34f3e..116fc7fe2 100644 --- a/SoftLayer/CLI/modules/server.py +++ b/SoftLayer/CLI/modules/server.py @@ -29,8 +29,8 @@ from os import linesep from SoftLayer.CLI.helpers import ( CLIRunnable, Table, KeyValueTable, FormattedItem, NestedDict, CLIAbort, - blank, listing, gb, no_going_back, resolve_id, confirm, ArgumentError, - update_with_template_args, export_to_template) + blank, listing, gb, active_txn, no_going_back, resolve_id, confirm, + ArgumentError, update_with_template_args, export_to_template) from SoftLayer import HardwareManager, SshKeyManager @@ -87,7 +87,8 @@ def execute(client, args): 'cores', 'memory', 'primary_ip', - 'backend_ip' + 'backend_ip', + 'active_transaction' ]) t.sortby = args.get('--sortby') or 'host' @@ -101,6 +102,7 @@ def execute(client, args): gb(server['memoryCapacity']), server['primaryIpAddress'] or blank(), server['primaryBackendIpAddress'] or blank(), + active_txn(server), ]) return t @@ -189,13 +191,15 @@ def execute(client, args): class ServerReload(CLIRunnable): """ -usage: sl server reload [options] +usage: sl server reload [--key=KEY...] [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) + -k, --key=KEY SSH keys to add to the root user. Can be specified + multiple times """ action = 'reload' @@ -206,8 +210,14 @@ def execute(client, args): hardware = HardwareManager(client) hardware_id = resolve_id( hardware.resolve_ids, args.get(''), 'hardware') + keys = [] + if args.get('--key'): + for key in args.get('--key'): + key_id = resolve_id(SshKeyManager(client).resolve_ids, key, + 'SshKey') + keys.append(key_id) if args['--really'] or no_going_back(hardware_id): - hardware.reload(hardware_id, args['--postinstall']) + hardware.reload(hardware_id, args['--postinstall'], keys) else: CLIAbort('Aborted') diff --git a/SoftLayer/CLI/modules/subnet.py b/SoftLayer/CLI/modules/subnet.py index 06c3ab318..ee68219ff 100644 --- a/SoftLayer/CLI/modules/subnet.py +++ b/SoftLayer/CLI/modules/subnet.py @@ -93,7 +93,7 @@ def execute(client, args): t.align['cost'] = 'r' total = 0.0 - for price in result['prices']: + for price in result['orderDetails']['prices']: total += float(price.get('recurringFee', 0.0)) rate = "%.2f" % float(price['recurringFee']) diff --git a/SoftLayer/consts.py b/SoftLayer/consts.py index 5fac18271..f16bcc04c 100644 --- a/SoftLayer/consts.py +++ b/SoftLayer/consts.py @@ -6,7 +6,7 @@ :copyright: (c) 2013, SoftLayer Technologies, Inc. All rights reserved. :license: MIT, see LICENSE for more details. """ -VERSION = 'v3.0.0' +VERSION = 'v3.0.1' API_PUBLIC_ENDPOINT = 'https://api.softlayer.com/xmlrpc/v3/' API_PRIVATE_ENDPOINT = 'https://api.service.softlayer.com/xmlrpc/v3/' API_PUBLIC_ENDPOINT_REST = 'https://api.softlayer.com/rest/v3/' diff --git a/SoftLayer/managers/cci.py b/SoftLayer/managers/cci.py index 8e33b2435..b231be7db 100644 --- a/SoftLayer/managers/cci.py +++ b/SoftLayer/managers/cci.py @@ -197,13 +197,13 @@ def cancel_instance(self, id): """ return self.guest.deleteObject(id=id) - def reload_instance(self, id, post_uri=None): + def reload_instance(self, id, post_uri=None, ssh_keys=None): """ Perform an OS reload of an instance with its current configuration. :param integer id: the instance ID to reload :param string post_url: The URI of the post-install script to run after reload - + :param list ssh_keys: The SSH keys to add to the root user """ payload = { 'token': 'FORCE', @@ -213,6 +213,9 @@ def reload_instance(self, id, post_uri=None): if post_uri: payload['config']['customProvisionScriptUri'] = post_uri + if ssh_keys: + payload['config']['sshKeyIds'] = [key_id for key_id in ssh_keys] + return self.guest.reloadOperatingSystem('FORCE', payload['config'], id=id) @@ -306,7 +309,7 @@ def _generate_create_dict( "networkVlan": {"id": int(private_vlan)}}}) if userdata: - data['userData'] = [{'value': userdata}, ] + data['userData'] = [{'value': userdata}] if nic_speed: data['networkComponents'] = [{'maxSpeed': nic_speed}] @@ -331,7 +334,7 @@ def _generate_create_dict( data['postInstallScriptUri'] = post_uri if ssh_keys: - data['ssh_keys'] = ssh_keys + data['sshKeys'] = [{'id': key_id} for key_id in ssh_keys] return data @@ -365,16 +368,7 @@ def create_instance(self, **kwargs): """ Orders a new instance. See :func:`_generate_create_dict` for a list of available options. """ create_options = self._generate_create_dict(**kwargs) - - # createObject doesn't support SSH keys yet, so if we want to add an - # SSH key, we need to do something a bit more awkward - if kwargs.get('ssh_keys'): - order = self.guest.generateOrderTemplate(create_options) - order['sshKeys'] = [{'sshKeyIds': kwargs.get('ssh_keys')}] - result = self.client['Product_Order'].placeOrder(order) - return result['orderDetails']['virtualGuests'][0] - else: - return self.guest.createObject(create_options) + return self.guest.createObject(create_options) def change_port_speed(self, id, public, speed): """ Allows you to change the port speed of a CCI's NICs. diff --git a/SoftLayer/managers/hardware.py b/SoftLayer/managers/hardware.py index 0b3a5a4df..ccf2af125 100644 --- a/SoftLayer/managers/hardware.py +++ b/SoftLayer/managers/hardware.py @@ -99,7 +99,7 @@ def list_hardware(self, tags=None, cpus=None, memory=None, hostname=None, """ if 'mask' not in kwargs: - items = set([ + hw_items = set([ 'id', 'hostname', 'domain', @@ -112,7 +112,14 @@ def list_hardware(self, tags=None, cpus=None, memory=None, hostname=None, 'primaryIpAddress', 'datacenter', ]) - kwargs['mask'] = "mask[%s]" % ','.join(items) + server_items = set([ + 'activeTransaction[id, transactionStatus[friendlyName,name]]', + ]) + + kwargs['mask'] = '[mask[%s],' \ + ' mask(SoftLayer_Hardware_Server)[%s]]' % \ + (','.join(hw_items), + ','.join(server_items)) _filter = NestedDict(kwargs.get('filter') or {}) if tags: @@ -256,7 +263,7 @@ def get_hardware(self, id, **kwargs): 'networkComponents.primarySubnet[id, netmask,' 'broadcastAddress, networkIdentifier, gateway]', 'hardwareChassis[id,name]', - 'activeTransaction.id', + 'activeTransaction[id, transactionStatus[friendlyName,name]]', 'operatingSystem.softwareLicense.' 'softwareDescription[manufacturer,name,version,referenceCode]', 'operatingSystem.passwords[username,password]', @@ -269,13 +276,13 @@ def get_hardware(self, id, **kwargs): return self.hardware.getObject(id=id, **kwargs) - def reload(self, id, post_uri=None): + def reload(self, id, post_uri=None, ssh_keys=None): """ Perform an OS reload of a server with its current configuration. :param integer id: the instance ID to reload :param string post_url: The URI of the post-install script to run after reload - + :param list ssh_keys: The SSH keys to add to the root user """ payload = { @@ -286,6 +293,9 @@ def reload(self, id, post_uri=None): if post_uri: payload['config']['customProvisionScriptUri'] = post_uri + if ssh_keys: + payload['config']['sshKeyIds'] = [key_id for key_id in ssh_keys] + return self.hardware.reloadOperatingSystem('FORCE', payload['config'], id=id) diff --git a/SoftLayer/managers/metadata.py b/SoftLayer/managers/metadata.py index 00419c795..a129dd6b9 100644 --- a/SoftLayer/managers/metadata.py +++ b/SoftLayer/managers/metadata.py @@ -64,7 +64,7 @@ def make_request(self, path): return make_rest_api_call('GET', url, http_headers={'User-Agent': USER_AGENT}, timeout=self.timeout) - except SoftLayerAPIError, e: + except SoftLayerAPIError as e: if e.faultCode == 404: return None raise e diff --git a/SoftLayer/tests/CLI/modules/server_tests.py b/SoftLayer/tests/CLI/modules/server_tests.py index 17f62fbef..57cd00f8f 100644 --- a/SoftLayer/tests/CLI/modules/server_tests.py +++ b/SoftLayer/tests/CLI/modules/server_tests.py @@ -171,7 +171,8 @@ def test_ListServers(self): 'memory': 2048, 'cores': 2, 'id': 1000, - 'backend_ip': '10.1.0.2' + 'backend_ip': '10.1.0.2', + 'active_transaction': 'TXN_NAME' }, { 'datacenter': 'TEST00', @@ -180,7 +181,8 @@ def test_ListServers(self): 'memory': 4096, 'cores': 4, 'id': 1001, - 'backend_ip': '10.1.0.3' + 'backend_ip': '10.1.0.3', + 'active_transaction': None } ] @@ -197,10 +199,10 @@ def test_ServerReload( ngb_mock.return_value = False # Check the positive case - args = {'--really': True, '--postinstall': None} + args = {'--really': True, '--postinstall': None, '--key': [12345]} server.ServerReload.execute(self.client, args) - reload_mock.assert_called_with(hw_id, args['--postinstall']) + reload_mock.assert_called_with(hw_id, args['--postinstall'], [12345]) # Now check to make sure we properly call CLIAbort in the negative case args['--really'] = False diff --git a/SoftLayer/tests/api_tests.py b/SoftLayer/tests/api_tests.py index 4919184f0..06c84c5c5 100644 --- a/SoftLayer/tests/api_tests.py +++ b/SoftLayer/tests/api_tests.py @@ -6,7 +6,6 @@ :license: MIT, see LICENSE for more details. """ from mock import patch, call, Mock, MagicMock -import datetime import SoftLayer import SoftLayer.API @@ -141,20 +140,27 @@ def test_mask_call_v2_dot(self, make_xml_rpc_api_call): headers={ 'authenticate': { 'username': 'doesnotexist', 'apiKey': 'issurelywrong'}, - 'SoftLayer_ObjectMask': {'mask': 'mask[something.nested]'}}, + 'SoftLayer_ObjectMask': {'mask': 'mask.something.nested'}}, timeout=None, http_headers={ 'Content-Type': 'application/xml', 'User-Agent': USER_AGENT, }) - def test_mask_call_invalid_mask(self): - try: - self.client['SERVICE'].METHOD(mask="mask[something.nested") - except SoftLayer.SoftLayerError, e: - self.assertIn('Malformed Mask', str(e)) - else: - self.fail('No exception raised') + @patch('SoftLayer.API.make_xml_rpc_api_call') + def test_mask_call_no_mask_prefix(self, make_xml_rpc_api_call): + self.client['SERVICE'].METHOD(mask="something.nested") + make_xml_rpc_api_call.assert_called_with( + 'ENDPOINT/SoftLayer_SERVICE', 'METHOD', (), + headers={ + 'authenticate': { + 'username': 'doesnotexist', 'apiKey': 'issurelywrong'}, + 'SoftLayer_ObjectMask': {'mask': 'mask[something.nested]'}}, + timeout=None, + http_headers={ + 'Content-Type': 'application/xml', + 'User-Agent': USER_AGENT, + }) @patch('SoftLayer.API.Client.iter_call') def test_iterate(self, _iter_call): diff --git a/SoftLayer/tests/managers/cci_tests.py b/SoftLayer/tests/managers/cci_tests.py index 909f40729..02cf03c74 100644 --- a/SoftLayer/tests/managers/cci_tests.py +++ b/SoftLayer/tests/managers/cci_tests.py @@ -141,11 +141,12 @@ def test_cancel_instance(self): def test_reload_instance(self): post_uri = 'http://test.sftlyr.ws/test.sh' - self.cci.reload_instance(id=1, post_uri=post_uri) + self.cci.reload_instance(id=1, post_uri=post_uri, ssh_keys=[1701]) service = self.client['Virtual_Guest'] f = service.reloadOperatingSystem f.assert_called_once_with('FORCE', - {'customProvisionScriptUri': post_uri}, id=1) + {'customProvisionScriptUri': post_uri, + 'sshKeyIds': [1701]}, id=1) @patch('SoftLayer.managers.cci.CCIManager._generate_create_dict') def test_create_verify(self, create_dict): @@ -163,23 +164,6 @@ def test_create_instance(self, create_dict): self.client['Virtual_Guest'].createObject.assert_called_once_with( {'test': 1, 'verify': 1}) - @patch('SoftLayer.managers.cci.CCIManager._generate_create_dict') - def test_create_instance_with_ssh_keys(self, create_dict): - create_dict.return_value = {'test': 1, 'verify': 1} - f = self.client['Virtual_Guest'].generateOrderTemplate - f.return_value = { - 'prices': [100, 200] - } - - self.cci.create_instance(test=1, verify=1, ssh_keys=[30, 40]) - - create_dict.assert_called_once_with(test=1, verify=1, - ssh_keys=[30, 40]) - f.assert_called_once_with({'test': 1, 'verify': 1}) - self.client['Product_Order'].placeOrder.assert_called_once_with( - {'prices': [100, 200], 'sshKeys': [{'sshKeyIds': [30, 40]}]} - ) - def test_generate_os_and_image(self): self.assertRaises( ValueError, @@ -464,7 +448,7 @@ def test_generate_sshkey(self): 'localDiskFlag': True, 'operatingSystemReferenceCode': "STRING", 'hourlyBillingFlag': True, - 'ssh_keys': [543], + 'sshKeys': [{'id': 543}], } self.assertEqual(data, assert_data) diff --git a/SoftLayer/tests/managers/hardware_tests.py b/SoftLayer/tests/managers/hardware_tests.py index 9691054ad..e5680782a 100644 --- a/SoftLayer/tests/managers/hardware_tests.py +++ b/SoftLayer/tests/managers/hardware_tests.py @@ -95,10 +95,11 @@ def test_get_hardware(self): def test_reload(self): post_uri = 'http://test.sftlyr.ws/test.sh' - self.hardware.reload(id=1, post_uri=post_uri) + self.hardware.reload(id=1, post_uri=post_uri, ssh_keys=[1701]) f = self.client.__getitem__().reloadOperatingSystem f.assert_called_once_with('FORCE', - {'customProvisionScriptUri': post_uri}, id=1) + {'customProvisionScriptUri': post_uri, + 'sshKeyIds': [1701]}, id=1) def test_get_bare_metal_create_options_returns_none_on_error(self): self.client['Product_Package'].getAllObjects.return_value = [ diff --git a/SoftLayer/tests/mocks/hardware_mock.py b/SoftLayer/tests/mocks/hardware_mock.py index 6355cd252..e225c25fd 100644 --- a/SoftLayer/tests/mocks/hardware_mock.py +++ b/SoftLayer/tests/mocks/hardware_mock.py @@ -82,6 +82,13 @@ def get_raw_hardware_mocks(): 'tagReferences': [ {'tag': {'name': 'test_tag'}} ], + 'activeTransaction': { + 'transactionStatus': { + 'name': 'TXN_NAME', + 'friendlyName': 'Friendly Transaction Name', + 'id': 6660 + } + } }, 1001: { 'id': 1001, diff --git a/setup.cfg b/setup.cfg index f443a1601..eb9d326aa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,5 +2,7 @@ verbosity=2 detailed-errors=1 with-coverage=1 +cover-min-percentage = 100 +cover-erase = true cover-package=SoftLayer -cover-html=1 \ No newline at end of file +cover-html=1 diff --git a/setup.py b/setup.py index e2e4d00e9..51a2fbb3b 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ setup( name='SoftLayer', - version='3.0.0', + version='3.0.1', description=description, long_description=long_description, author='SoftLayer Technologies, Inc.',