diff --git a/.github/workflows/test-snap-can-build.yml b/.github/workflows/test-snap-can-build.yml new file mode 100644 index 000000000..19a4086bb --- /dev/null +++ b/.github/workflows/test-snap-can-build.yml @@ -0,0 +1,28 @@ +name: Snap Builds + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v2 + + - uses: snapcore/action-build@v1 + id: build + + - uses: diddlesnaps/snapcraft-review-action@v1 + with: + snap: ${{ steps.build.outputs.snap }} + isClassic: 'false' + # Plugs and Slots declarations to override default denial (requires store assertion to publish) + # plugs: ./plug-declaration.json + # slots: ./slot-declaration.json diff --git a/.secrets.baseline b/.secrets.baseline index ea850e071..b256d881d 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$", "lines": null }, - "generated_at": "2024-04-25T01:18:20Z", + "generated_at": "2024-10-07T21:05:06Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -112,7 +112,7 @@ "hashed_secret": "6367c48dd193d56ea7b0baad25b19455e529f5ee", "is_secret": false, "is_verified": false, - "line_number": 121, + "line_number": 122, "type": "Secret Keyword", "verified_result": null }, @@ -120,7 +120,7 @@ "hashed_secret": "df51e37c269aa94d38f93e537bf6e2020b21406c", "is_secret": false, "is_verified": false, - "line_number": 1035, + "line_number": 1036, "type": "Secret Keyword", "verified_result": null } @@ -720,7 +720,7 @@ "hashed_secret": "9878e362285eb314cfdbaa8ee8c300c285856810", "is_secret": false, "is_verified": false, - "line_number": 323, + "line_number": 324, "type": "Secret Keyword", "verified_result": null } diff --git a/README-internal.md b/README-internal.md index 06e0a050e..df372ded0 100644 --- a/README-internal.md +++ b/README-internal.md @@ -12,7 +12,7 @@ security export -t certs -f pemseq -k /System/Library/Keychains/SystemRootCertif sudo cp bundleCA.pem /etc/ssl/certs/bundleCA.pem ``` Then in the `~/.softlayer` config, set `verify = /etc/ssl/certs/bundleCA.pem` and that should work. - +You may also need to set `REQUESTS_CA_BUNDLE` -> `export REQUESTS_CA_BUNDLE=/etc/ssl/certs/bundleCA.pem` to force python to load your CA bundle ## Certificate Example @@ -69,4 +69,4 @@ You can login and use the `slcli` with. Use the `-i` flag to make internal API c slcli -i emplogin ``` -If you want to use any of the built in commands, you may need to use the `-a ` flag. \ No newline at end of file +If you want to use any of the built in commands, you may need to use the `-a ` flag. diff --git a/README.rst b/README.rst index 39b0dfd4a..5f82bdd62 100644 --- a/README.rst +++ b/README.rst @@ -10,7 +10,8 @@ SoftLayer API Python Client :target: https://coveralls.io/github/softlayer/softlayer-python?branch=master .. image:: https://snapcraft.io//slcli/badge.svg :target: https://snapcraft.io/slcli - +.. image:: https://https://github.com/softlayer/softlayer-python/workflows/Snap%20Builds/badge.svg + :target: https://github.com/softlayer/softlayer-python/actions?query=workflow:"Snap+Builds" This library provides a simple Python client to interact with `SoftLayer's XML-RPC API `_. diff --git a/SoftLayer/API.py b/SoftLayer/API.py index 00dbfe74f..ea119a4b1 100644 --- a/SoftLayer/API.py +++ b/SoftLayer/API.py @@ -19,6 +19,7 @@ from SoftLayer import consts from SoftLayer import exceptions from SoftLayer import transports +from SoftLayer import utils LOGGER = logging.getLogger(__name__) API_PUBLIC_ENDPOINT = consts.API_PUBLIC_ENDPOINT @@ -403,6 +404,7 @@ def iter_call(self, service, method, *args, **kwargs): kwargs['iter'] = False result_count = 0 keep_looping = True + kwargs['filter'] = utils.fix_filter(kwargs.get('filter')) while keep_looping: # Get the next results diff --git a/SoftLayer/CLI/account/invoice_detail.py b/SoftLayer/CLI/account/invoice_detail.py index 281940ee5..4436c44d9 100644 --- a/SoftLayer/CLI/account/invoice_detail.py +++ b/SoftLayer/CLI/account/invoice_detail.py @@ -16,7 +16,13 @@ help="Shows a very detailed list of charges") @environment.pass_env def cli(env, identifier, details): - """Invoice details""" + """Invoice details + + Will display the top level invoice items for a given invoice. The cost displayed is the sum of the item's + cost along with all its child items. + The --details option will display any child items a top level item may have. Parent items will appear + in this list as well to display their specific cost. + """ manager = AccountManager(env.client) top_items = manager.get_billing_items(identifier) @@ -49,16 +55,31 @@ def get_invoice_table(identifier, top_items, details): description = nice_string(item.get('description')) if fqdn != '.': description = "%s (%s)" % (item.get('description'), fqdn) + total_recur, total_single = sum_item_charges(item) table.add_row([ item.get('id'), category, nice_string(description), - "$%.2f" % float(item.get('oneTimeAfterTaxAmount')), - "$%.2f" % float(item.get('recurringAfterTaxAmount')), + f"${total_single:,.2f}", + f"${total_recur:,.2f}", utils.clean_time(item.get('createDate'), out_format="%Y-%m-%d"), utils.lookup(item, 'location', 'name') ]) if details: + # This item has children, so we want to print out the parent item too. This will match the + # invoice from the portal. https://github.com/softlayer/softlayer-python/issues/2201 + if len(item.get('children')) > 0: + single = float(item.get('oneTimeAfterTaxAmount', 0.0)) + recurring = float(item.get('recurringAfterTaxAmount', 0.0)) + table.add_row([ + '>>>', + category, + nice_string(description), + f"${single:,.2f}", + f"${recurring:,.2f}", + '---', + '---' + ]) for child in item.get('children', []): table.add_row([ '>>>', @@ -70,3 +91,16 @@ def get_invoice_table(identifier, top_items, details): '---' ]) return table + + +def sum_item_charges(item: dict) -> (float, float): + """Takes a billing Item, sums up its child items and returns recurring, one_time prices""" + + # API returns floats as strings in this case + single = float(item.get('oneTimeAfterTaxAmount', 0.0)) + recurring = float(item.get('recurringAfterTaxAmount', 0.0)) + for child in item.get('children', []): + single = single + float(child.get('oneTimeAfterTaxAmount', 0.0)) + recurring = recurring + float(child.get('recurringAfterTaxAmount', 0.0)) + + return (recurring, single) diff --git a/SoftLayer/CLI/config/setup.py b/SoftLayer/CLI/config/setup.py index db895fa32..3dc15854c 100644 --- a/SoftLayer/CLI/config/setup.py +++ b/SoftLayer/CLI/config/setup.py @@ -78,6 +78,8 @@ def cli(env, auth): username = 'apikey' secret = env.getpass('Classic Infrastructure API Key', default=defaults['api_key']) new_client = SoftLayer.Client(username=username, api_key=secret, endpoint_url=endpoint_url, timeout=timeout) + env.client = new_client + env.client.transport = SoftLayer.DebugTransport(new_client.transport) api_key = get_api_key(new_client, username, secret) elif auth == 'sso': @@ -87,6 +89,8 @@ def cli(env, auth): username = env.input('Classic Infrastructure Username', default=defaults['username']) secret = env.getpass('Classic Infrastructure API Key', default=defaults['api_key']) new_client = SoftLayer.Client(username=username, api_key=secret, endpoint_url=endpoint_url, timeout=timeout) + env.client = new_client + env.client.transport = SoftLayer.DebugTransport(new_client.transport) api_key = get_api_key(new_client, username, secret) # Ask for timeout, convert to float, then to int diff --git a/SoftLayer/CLI/environment.py b/SoftLayer/CLI/environment.py index e2fde6e30..d5b8f584b 100644 --- a/SoftLayer/CLI/environment.py +++ b/SoftLayer/CLI/environment.py @@ -111,6 +111,7 @@ def getpass(self, prompt, default=None): # In windows, shift+insert actually inputs the below 2 characters # If we detect those 2 characters, need to manually read from the clipbaord instead # https://stackoverflow.com/questions/101128/how-do-i-read-text-from-the-clipboard + # LINUX NOTICE: `apt-get install python3-tk` required to install tk if password == 'àR': # tkinter is a built in python gui, but it has clipboard reading functions. # pylint: disable=import-outside-toplevel diff --git a/SoftLayer/CLI/hardware/list.py b/SoftLayer/CLI/hardware/list.py index 734f379d4..65a95718e 100644 --- a/SoftLayer/CLI/hardware/list.py +++ b/SoftLayer/CLI/hardware/list.py @@ -22,7 +22,7 @@ lambda server: formatting.active_txn(server), mask='activeTransaction[id, transactionStatus[name, friendlyName]]'), column_helper.Column( - 'created_by', + 'owner', lambda created_by: utils.lookup(created_by, 'billingItem', 'orderItem', 'order', 'userRecord', 'username'), mask='billingItem[id,orderItem[id,order[id,userRecord[username]]]]'), column_helper.Column( @@ -38,6 +38,8 @@ 'backend_ip', 'datacenter', 'action', + 'owner', + 'tags', ] @@ -48,6 +50,9 @@ @click.option('--hostname', '-H', help='Filter by hostname') @click.option('--memory', '-m', help='Filter by memory in gigabytes') @click.option('--network', '-n', help='Filter by network port speed in Mbps') +@click.option('--owner', help='Filter by created_by username') +@click.option('--primary_ip', help='Filter by Primary Ip Address') +@click.option('--backend_ip', help='Filter by Backend Ip Address') @click.option('--search', is_flag=False, flag_value="", default=None, help="Use the more flexible Search API to list instances. See `slcli search --types` for list " + "of searchable fields.") @@ -63,29 +68,41 @@ default=100, show_default=True) @environment.pass_env -def cli(env, sortby, cpu, domain, datacenter, hostname, memory, network, search, tag, columns, limit): +def cli(env, sortby, cpu, domain, datacenter, hostname, memory, network, owner, primary_ip, backend_ip, + search, tag, columns, limit): """List hardware servers.""" if search is not None: object_mask = "mask[resource(SoftLayer_Hardware)]" search_manager = SoftLayer.SearchManager(env.client) - servers = search_manager.search_hadrware_instances(hostname=hostname, domain=domain, datacenter=datacenter, - tags=tag, search_string=search, mask=object_mask) + servers = search_manager.search_hadrware_instances( + hostname=hostname, + domain=domain, + datacenter=datacenter, + tags=tag, + search_string=search, + mask=object_mask) else: manager = SoftLayer.HardwareManager(env.client) - servers = manager.list_hardware(hostname=hostname, - domain=domain, - cpus=cpu, - memory=memory, - datacenter=datacenter, - nic_speed=network, - tags=tag, - mask="mask(SoftLayer_Hardware_Server)[%s]" % columns.mask(), - limit=limit) + servers = manager.list_hardware( + hostname=hostname, + domain=domain, + cpus=cpu, + memory=memory, + datacenter=datacenter, + nic_speed=network, + tags=tag, + owner=owner, + public_ip=primary_ip, + private_ip=backend_ip, + mask="mask(SoftLayer_Hardware_Server)[%s]" % columns.mask(), + limit=limit) table = formatting.Table(columns.columns) table.sortby = sortby + table.align['created_by'] = 'l' + table.align['tags'] = 'l' for server in servers: table.add_row([value or formatting.blank() diff --git a/SoftLayer/CLI/search.py b/SoftLayer/CLI/search.py index 23329ce51..84a79ff6e 100644 --- a/SoftLayer/CLI/search.py +++ b/SoftLayer/CLI/search.py @@ -37,13 +37,14 @@ def cli(env, query, types, advanced): slcli -vvv search _objectType:SoftLayer_Hardware hostname:testibm --advanced """ - # Before any Search operation + # Checks to make sure we have at least 1 query. def check_opt(list_opt=None): check = False for input_ in list_opt: - if input_ is True: + if input_: check = True break + return check list_opt = [query, types, advanced] diff --git a/SoftLayer/CLI/virt/migrate.py b/SoftLayer/CLI/virt/migrate.py index b4a1edef9..c1a1028bf 100644 --- a/SoftLayer/CLI/virt/migrate.py +++ b/SoftLayer/CLI/virt/migrate.py @@ -19,7 +19,6 @@ def cli(env, guest, migrate_all, host): """Manage VSIs that require migration. Can migrate Dedicated Host VSIs as well.""" vsi = SoftLayer.VSManager(env.client) - pending_filter = {'virtualGuests': {'pendingMigrationFlag': {'operation': 1}}} dedicated_filter = {'virtualGuests': {'dedicatedHost': {'id': {'operation': 'not null'}}}} mask = """mask[ id, hostname, domain, datacenter, pendingMigrationFlag, powerState, @@ -28,21 +27,22 @@ def cli(env, guest, migrate_all, host): # No options, just print out a list of guests that can be migrated if not (guest or migrate_all): - require_migration = vsi.list_instances(filter=pending_filter, mask=mask) + require_migration = vsi.list_instances(mask=mask) require_table = formatting.Table(['id', 'hostname', 'domain', 'datacenter'], title="Require Migration") for vsi_object in require_migration: - require_table.add_row([ - vsi_object.get('id'), - vsi_object.get('hostname'), - vsi_object.get('domain'), - utils.lookup(vsi_object, 'datacenter', 'name') - ]) + if vsi_object.get('pendingMigrationFlag', False): + require_table.add_row([ + vsi_object.get('id'), + vsi_object.get('hostname'), + vsi_object.get('domain'), + utils.lookup(vsi_object, 'datacenter', 'name') + ]) - if require_migration: + if len(require_table.rows) > 0: env.fout(require_table) else: - click.secho("No guests require migration at this time", fg='green') + click.secho("No guests require migration at this time.", fg='green') migrateable = vsi.list_instances(filter=dedicated_filter, mask=mask) migrateable_table = formatting.Table(['id', 'hostname', 'domain', 'datacenter', 'Host Name', 'Host Id'], @@ -56,14 +56,20 @@ def cli(env, guest, migrate_all, host): utils.lookup(vsi_object, 'dedicatedHost', 'name'), utils.lookup(vsi_object, 'dedicatedHost', 'id') ]) - env.fout(migrateable_table) + if len(migrateable_table.rows) > 0: + env.fout(migrateable_table) + else: + click.secho("No dedicated guests to migrate.", fg='green') # Migrate all guests with pendingMigrationFlag=True elif migrate_all: - require_migration = vsi.list_instances(filter=pending_filter, mask="mask[id]") - if not require_migration: - click.secho("No guests require migration at this time", fg='green') + require_migration = vsi.list_instances(mask="mask[id,pendingMigrationFlag]") + migrated = 0 for vsi_object in require_migration: - migrate(vsi, vsi_object['id']) + if vsi_object.get('pendingMigrationFlag', False): + migrated = migrated + 1 + migrate(vsi, vsi_object['id']) + if migrated == 0: + click.secho("No guests require migration at this time", fg='green') # Just migrate based on the options else: migrate(vsi, guest, host) diff --git a/SoftLayer/CLI/vlan/detail.py b/SoftLayer/CLI/vlan/detail.py index 6e16d9d8c..8c772d8bf 100644 --- a/SoftLayer/CLI/vlan/detail.py +++ b/SoftLayer/CLI/vlan/detail.py @@ -12,14 +12,11 @@ @click.command(cls=SoftLayer.CLI.command.SLCommand, ) @click.argument('identifier') -@click.option('--no-vs', - is_flag=True, +@click.option('--no-vs', is_flag=True, help="Hide virtual server listing") -@click.option('--no-hardware', - is_flag=True, +@click.option('--no-hardware', is_flag=True, help="Hide hardware listing") -@click.option('--no-trunks', - is_flag=True, +@click.option('--no-trunks', is_flag=True, help="Hide devices with trunks") @environment.pass_env def cli(env, identifier, no_vs, no_hardware, no_trunks): @@ -28,11 +25,24 @@ def cli(env, identifier, no_vs, no_hardware, no_trunks): vlan_id = helpers.resolve_id(mgr.resolve_vlan_ids, identifier, 'VLAN') - mask = """mask[firewallInterfaces,primaryRouter[id, fullyQualifiedDomainName, datacenter], - totalPrimaryIpAddressCount,networkSpace,billingItem,hardware,subnets,virtualGuests, - networkVlanFirewall[id,fullyQualifiedDomainName,primaryIpAddress],attachedNetworkGateway[id,name,networkFirewall], - networkComponentTrunks[networkComponent[downlinkComponent[networkComponentGroup[membersDescription], - hardware[tagReferences]]]]]""" + mask = """mask[ +firewallInterfaces, primaryRouter[id, fullyQualifiedDomainName, datacenter[longName]], +totalPrimaryIpAddressCount, +networkSpace, id, vlanNumber, fullyQualifiedName, name, +hardware[id, hostname, domain, primaryIpAddress, primaryBackendIpAddress, tagReferences], +subnets[id, networkIdentifier, netmask, gateway, subnetType, usableIpAddressCount], +virtualGuests[id, hostname, domain, primaryIpAddress, primaryBackendIpAddress], +networkVlanFirewall[id,fullyQualifiedDomainName,primaryIpAddress], +attachedNetworkGateway[id,name,networkFirewall], +networkComponentTrunks[ + networkComponent[ + downlinkComponent[ + networkComponentGroup[membersDescription], + hardware[tagReferences] + ] + ] +] +]""" vlan = mgr.get_vlan(vlan_id, mask=mask) @@ -42,10 +52,8 @@ def cli(env, identifier, no_vs, no_hardware, no_trunks): table.add_row(['id', vlan.get('id')]) table.add_row(['number', vlan.get('vlanNumber')]) - table.add_row(['datacenter', - utils.lookup(vlan, 'primaryRouter', 'datacenter', 'longName')]) - table.add_row(['primary_router', - utils.lookup(vlan, 'primaryRouter', 'fullyQualifiedDomainName')]) + table.add_row(['datacenter', utils.lookup(vlan, 'primaryRouter', 'datacenter', 'longName')]) + table.add_row(['primary_router', utils.lookup(vlan, 'primaryRouter', 'fullyQualifiedDomainName')]) table.add_row(['Gateway/Firewall', get_gateway_firewall(vlan)]) if vlan.get('subnets'): @@ -93,12 +101,7 @@ def cli(env, identifier, no_vs, no_hardware, no_trunks): trunks = filter_trunks(vlan.get('networkComponentTrunks')) trunks_table = formatting.Table(['device', 'port', 'tags']) for trunk in trunks: - trunks_table.add_row([utils.lookup(trunk, 'networkComponent', 'downlinkComponent', - 'hardware', 'fullyQualifiedDomainName'), - utils.lookup(trunk, 'networkComponent', 'downlinkComponent', - 'networkComponentGroup', 'membersDescription'), - formatting.tags(utils.lookup(trunk, 'networkComponent', 'downlinkComponent', - 'hardware', 'tagReferences'))]) + trunks_table.add_row(get_trunk_row(trunk)) table.add_row(['trunks', trunks_table]) else: table.add_row(['trunks', '-']) @@ -106,6 +109,17 @@ def cli(env, identifier, no_vs, no_hardware, no_trunks): env.fout(table) +def get_trunk_row(trunk: dict) -> list: + """Parses a vlan trunk and returns a table row for it""" + dl_component = utils.lookup(trunk, 'networkComponent', 'downlinkComponent') + row = [ + utils.lookup(dl_component, 'hardware', 'fullyQualifiedDomainName'), + utils.lookup(dl_component, 'networkComponentGroup', 'membersDescription'), + formatting.tags(utils.lookup(dl_component, 'hardware', 'tagReferences')) + ] + return row + + def get_gateway_firewall(vlan): """Gets the name of a gateway/firewall from a VLAN. """ diff --git a/SoftLayer/CLI/vlan/list.py b/SoftLayer/CLI/vlan/list.py index ed55561db..918d58988 100644 --- a/SoftLayer/CLI/vlan/list.py +++ b/SoftLayer/CLI/vlan/list.py @@ -9,19 +9,21 @@ from SoftLayer.CLI.vlan.detail import get_gateway_firewall from SoftLayer import utils -COLUMNS = ['Id', - 'Number', - 'Fully qualified name', - 'Name', - 'Network', - 'Data center', - 'Pod', - 'Gateway/Firewall', - 'Hardware', - 'Virtual servers', - 'Public ips', - 'Premium', - 'Tags'] +COLUMNS = [ + 'Id', + 'Number', + 'Fully qualified name', + 'Name', + 'Network', + 'Data center', + 'Pod', + 'Gateway/Firewall', + 'Hardware', + 'Virtual servers', + 'Public ips', + 'Premium', + 'Tags' +] @click.command(cls=SoftLayer.CLI.command.SLCommand, ) @@ -49,9 +51,11 @@ def cli(env, sortby, datacenter, number, name, limit): name=name, limit=limit) - mask = """mask[name, datacenterLongName, frontendRouterId, capabilities, datacenterId, backendRouterId, - backendRouterName, frontendRouterName]""" - pods = mgr.get_pods(mask=mask) + pod_mask = """mask[ +name, datacenterLongName, capabilities, datacenterId, +backendRouterId, backendRouterName, frontendRouterName, frontendRouterId +]""" + pods = mgr.get_pods(mask=pod_mask) for vlan in vlans: billing = 'Yes' if vlan.get('billingItem') else 'No' diff --git a/SoftLayer/consts.py b/SoftLayer/consts.py index 136e2800d..2a1d23eaf 100644 --- a/SoftLayer/consts.py +++ b/SoftLayer/consts.py @@ -5,7 +5,7 @@ :license: MIT, see LICENSE for more details. """ -VERSION = 'v6.2.5' +VERSION = 'v6.2.6' API_PUBLIC_ENDPOINT = 'https://api.softlayer.com/xmlrpc/v3.1/' API_PRIVATE_ENDPOINT = 'https://api.service.softlayer.com/xmlrpc/v3.1/' API_PUBLIC_ENDPOINT_REST = 'https://api.softlayer.com/rest/v3.1/' diff --git a/SoftLayer/fixtures/SoftLayer_Account.py b/SoftLayer/fixtures/SoftLayer_Account.py index 96a1a0ee8..a8f1ff71c 100644 --- a/SoftLayer/fixtures/SoftLayer_Account.py +++ b/SoftLayer/fixtures/SoftLayer_Account.py @@ -35,7 +35,7 @@ 'globalIdentifier': '1a2b3c-1701', 'primaryBackendIpAddress': '10.45.19.37', 'hourlyBillingFlag': False, - + 'pendingMigrationFlag': True, 'billingItem': { 'id': 6327, 'recurringFee': 1.54, @@ -63,6 +63,7 @@ 'globalIdentifier': '05a8ac-6abf0', 'primaryBackendIpAddress': '10.45.19.35', 'hourlyBillingFlag': True, + 'pendingMigrationFlag': True, 'billingItem': { 'id': 6327, 'recurringFee': 1.54, diff --git a/SoftLayer/fixtures/SoftLayer_Billing_Invoice.py b/SoftLayer/fixtures/SoftLayer_Billing_Invoice.py index d4d89131c..eb9e1171d 100644 --- a/SoftLayer/fixtures/SoftLayer_Billing_Invoice.py +++ b/SoftLayer/fixtures/SoftLayer_Billing_Invoice.py @@ -1,23 +1,49 @@ getInvoiceTopLevelItems = [ { - 'categoryCode': 'sov_sec_ip_addresses_priv', - 'createDate': '2018-04-04T23:15:20-06:00', - 'description': '64 Portable Private IP Addresses', - 'id': 724951323, - 'oneTimeAfterTaxAmount': '0', - 'recurringAfterTaxAmount': '0', - 'hostName': 'bleg', - 'domainName': 'beh.com', - 'category': {'name': 'Private (only) Secondary VLAN IP Addresses'}, - 'children': [ + "categoryCode": "sov_sec_ip_addresses_priv", + "createDate": "2018-04-04T23:15:20-06:00", + "description": "64 Portable Private IP Addresses", + "id": 724951323, + "oneTimeAfterTaxAmount": "0", + "recurringAfterTaxAmount": "0", + "hostName": "bleg", + "domainName": "beh.com", + "category": {"name": "Private (only) Secondary VLAN IP Addresses"}, + "children": [ { - 'id': 12345, - 'category': {'name': 'Fake Child Category'}, - 'description': 'Blah', - 'oneTimeAfterTaxAmount': 55.50, - 'recurringAfterTaxAmount': 0.10 + "id": 12345, + "category": {"name": "Fake Child Category"}, + "description": "Blah", + "oneTimeAfterTaxAmount": 55.50, + "recurringAfterTaxAmount": 0.10 } ], - 'location': {'name': 'fra02'} + "location": {"name": "fra02"} + }, + { + "categoryCode": "reserved_capacity", + "createDate": "2024-07-03T22:08:36-07:00", + "description": "B1.1x2 (1 Year Term) (721hrs * .025)", + "id": 1111222, + "oneTimeAfterTaxAmount": "0", + "recurringAfterTaxAmount": "18.03", + "category": {"name": "Reserved Capacity"}, + "children": [ + { + "description": "1 x 2.0 GHz or higher Core", + "id": 29819, + "oneTimeAfterTaxAmount": "0", + "recurringAfterTaxAmount": "10.00", + "category": {"name": "Computing Instance"} + }, + { + "description": "2 GB", + "id": 123456, + "oneTimeAfterTaxAmount": "0", + "recurringAfterTaxAmount": "2.33", + "category": {"name": "RAM"} + } + ], + "location": {"name": "dal10"} } ] diff --git a/SoftLayer/managers/block.py b/SoftLayer/managers/block.py index f7d0f1f11..5286d485f 100644 --- a/SoftLayer/managers/block.py +++ b/SoftLayer/managers/block.py @@ -53,23 +53,21 @@ def list_block_volumes(self, datacenter=None, username=None, storage_type=None, _filter = utils.NestedDict(kwargs.get('filter') or {}) _filter['iscsiNetworkStorage']['serviceResource']['type']['type'] = utils.query_filter('!~ ISCSI') + _filter['iscsiNetworkStorage']['id'] = utils.query_filter_orderby() - _filter['iscsiNetworkStorage']['storageType']['keyName'] = ( - utils.query_filter('*BLOCK_STORAGE*')) + _filter['iscsiNetworkStorage']['storageType']['keyName'] = utils.query_filter('*BLOCK_STORAGE*') if storage_type: _filter['iscsiNetworkStorage']['storageType']['keyName'] = ( utils.query_filter('%s_BLOCK_STORAGE*' % storage_type.upper())) if datacenter: - _filter['iscsiNetworkStorage']['serviceResource']['datacenter'][ - 'name'] = utils.query_filter(datacenter) + _filter['iscsiNetworkStorage']['serviceResource']['datacenter']['name'] = utils.query_filter(datacenter) if username: _filter['iscsiNetworkStorage']['username'] = utils.query_filter(username) if order: - _filter['iscsiNetworkStorage']['billingItem']['orderItem'][ - 'order']['id'] = utils.query_filter(order) + _filter['iscsiNetworkStorage']['billingItem']['orderItem']['order']['id'] = utils.query_filter(order) kwargs['filter'] = _filter.to_dict() return self.client.call('Account', 'getIscsiNetworkStorage', iter=True, **kwargs) diff --git a/SoftLayer/managers/dns.py b/SoftLayer/managers/dns.py index 6484fd7d9..db65528bf 100644 --- a/SoftLayer/managers/dns.py +++ b/SoftLayer/managers/dns.py @@ -196,6 +196,7 @@ def get_records(self, zone_id, ttl=None, data=None, host=None, record_type=None) :returns: A list of dictionaries representing the matching records within the specified zone. """ _filter = utils.NestedDict() + _filter['resourceRecords']['id'] = utils.query_filter_orderby() if ttl: _filter['resourceRecords']['ttl'] = utils.query_filter(ttl) diff --git a/SoftLayer/managers/event_log.py b/SoftLayer/managers/event_log.py index cc0a7f5cd..417b91409 100644 --- a/SoftLayer/managers/event_log.py +++ b/SoftLayer/managers/event_log.py @@ -65,11 +65,8 @@ def build_filter(date_min=None, date_max=None, obj_event=None, obj_id=None, obj_ :returns: dict: The generated query filter """ - - if not any([date_min, date_max, obj_event, obj_id, obj_type]): - return {} - request_filter = {} + request_filter['traceId'] = utils.query_filter_orderby() if date_min and date_max: request_filter['eventCreateDate'] = utils.event_log_filter_between_date(date_min, date_max, utc_offset) diff --git a/SoftLayer/managers/file.py b/SoftLayer/managers/file.py index d7e3871b9..0489e597c 100644 --- a/SoftLayer/managers/file.py +++ b/SoftLayer/managers/file.py @@ -47,7 +47,7 @@ def list_file_volumes(self, datacenter=None, username=None, storage_type=None, o kwargs['mask'] = ','.join(items) _filter = utils.NestedDict(kwargs.get('filter') or {}) - + _filter['nasNetworkStorage']['id'] = utils.query_filter_orderby() _filter['nasNetworkStorage']['serviceResource']['type']['type'] = utils.query_filter('!~ NAS') _filter['nasNetworkStorage']['storageType']['keyName'] = ( diff --git a/SoftLayer/managers/hardware.py b/SoftLayer/managers/hardware.py index 6564fda0f..c8dea24b4 100644 --- a/SoftLayer/managers/hardware.py +++ b/SoftLayer/managers/hardware.py @@ -121,7 +121,7 @@ def cancel_hardware(self, hardware_id, reason='unneeded', comment='', immediate= @retry(logger=LOGGER) def list_hardware(self, tags=None, cpus=None, memory=None, hostname=None, - domain=None, datacenter=None, nic_speed=None, + domain=None, datacenter=None, nic_speed=None, owner=None, public_ip=None, private_ip=None, **kwargs): """List all hardware (servers and bare metal computing instances). @@ -169,6 +169,7 @@ def list_hardware(self, tags=None, cpus=None, memory=None, hostname=None, % (','.join(hw_items), ','.join(server_items))) _filter = utils.NestedDict(kwargs.get('filter') or {}) + _filter['hardware']['id'] = utils.query_filter_orderby() if tags: _filter['hardware']['tagReferences']['tag']['name'] = { 'operation': 'in', @@ -176,8 +177,7 @@ def list_hardware(self, tags=None, cpus=None, memory=None, hostname=None, } if cpus: - _filter['hardware']['processorPhysicalCoreAmount'] = ( - utils.query_filter(cpus)) + _filter['hardware']['processorPhysicalCoreAmount'] = utils.query_filter(cpus) if memory: _filter['hardware']['memoryCapacity'] = utils.query_filter(memory) @@ -189,20 +189,20 @@ def list_hardware(self, tags=None, cpus=None, memory=None, hostname=None, _filter['hardware']['domain'] = utils.query_filter(domain) if datacenter: - _filter['hardware']['datacenter']['name'] = ( - utils.query_filter(datacenter)) + _filter['hardware']['datacenter']['name'] = utils.query_filter(datacenter) if nic_speed: - _filter['hardware']['networkComponents']['maxSpeed'] = ( - utils.query_filter(nic_speed)) + _filter['hardware']['networkComponents']['maxSpeed'] = utils.query_filter(nic_speed) if public_ip: - _filter['hardware']['primaryIpAddress'] = ( - utils.query_filter(public_ip)) + _filter['hardware']['primaryIpAddress'] = utils.query_filter(public_ip) if private_ip: - _filter['hardware']['primaryBackendIpAddress'] = ( - utils.query_filter(private_ip)) + _filter['hardware']['primaryBackendIpAddress'] = utils.query_filter(private_ip) + + if owner: + _filter['hardware']['billingItem']['orderItem']['order']['userRecord']['username'] = ( + utils.query_filter(owner)) kwargs['filter'] = _filter.to_dict() kwargs['iter'] = True diff --git a/SoftLayer/managers/image.py b/SoftLayer/managers/image.py index f7f7005eb..84ab6665e 100644 --- a/SoftLayer/managers/image.py +++ b/SoftLayer/managers/image.py @@ -57,13 +57,12 @@ def list_private_images(self, guid=None, name=None, limit=100, **kwargs): kwargs['mask'] = IMAGE_MASK _filter = utils.NestedDict(kwargs.get('filter') or {}) + _filter['privateBlockDeviceTemplateGroups']['id'] = utils.query_filter_orderby() if name: - _filter['privateBlockDeviceTemplateGroups']['name'] = ( - utils.query_filter(name)) + _filter['privateBlockDeviceTemplateGroups']['name'] = utils.query_filter(name) if guid: - _filter['privateBlockDeviceTemplateGroups']['globalIdentifier'] = ( - utils.query_filter(guid)) + _filter['privateBlockDeviceTemplateGroups']['globalIdentifier'] = utils.query_filter(guid) kwargs['filter'] = _filter.to_dict() @@ -81,6 +80,7 @@ def list_public_images(self, guid=None, name=None, limit=100, **kwargs): kwargs['mask'] = IMAGE_MASK _filter = utils.NestedDict(kwargs.get('filter') or {}) + _filter['id'] = utils.query_filter_orderby() if name: _filter['name'] = utils.query_filter(name) diff --git a/SoftLayer/managers/network.py b/SoftLayer/managers/network.py index 49af7197f..6ac46a741 100644 --- a/SoftLayer/managers/network.py +++ b/SoftLayer/managers/network.py @@ -38,19 +38,7 @@ 'addressSpace', 'endPointIpAddress' ]) -DEFAULT_VLAN_MASK = ','.join([ - 'firewallInterfaces', - 'hardwareCount', - 'primaryRouter[id, fullyQualifiedDomainName, datacenter]', - 'subnetCount', - 'billingItem', - 'totalPrimaryIpAddressCount', - 'virtualGuestCount', - 'networkSpace', - 'networkVlanFirewall[id,fullyQualifiedDomainName,primaryIpAddress]', - 'attachedNetworkGateway[id,name,networkFirewall]', - 'tagReferences[tag[name]]', -]) + DEFAULT_GET_VLAN_MASK = ','.join([ 'firewallInterfaces', 'primaryRouter[id, fullyQualifiedDomainName, datacenter]', @@ -496,6 +484,7 @@ def list_subnets(self, identifier=None, datacenter=None, version=0, kwargs['mask'] = DEFAULT_SUBNET_MASK _filter = utils.NestedDict(kwargs.get('filter') or {}) + _filter['subnets']['id'] = utils.query_filter_orderby() if identifier: _filter['subnets']['networkIdentifier'] = ( @@ -527,6 +516,13 @@ def list_vlans(self, datacenter=None, vlan_number=None, name=None, limit=100, ma :param dict \\*\\*kwargs: response-level options (mask, limit, etc.) """ + vlan_mask = """mask[ +networkSpace, id, vlanNumber, fullyQualifiedName, name, +networkVlanFirewall[id,fullyQualifiedDomainName], attachedNetworkGateway[id,name], +firewallInterfaces, primaryRouter[id, fullyQualifiedDomainName, datacenter[name]], +hardwareCount, subnetCount, totalPrimaryIpAddressCount, virtualGuestCount, +billingItem[id], tagReferences[tag[name]] +]""" _filter = utils.NestedDict(_filter or {}) _filter['networkVlans']['id'] = utils.query_filter_orderby() @@ -541,7 +537,7 @@ def list_vlans(self, datacenter=None, vlan_number=None, name=None, limit=100, ma _filter['networkVlans']['primaryRouter']['datacenter']['name'] = utils.query_filter(datacenter) if mask is None: - mask = DEFAULT_VLAN_MASK + mask = vlan_mask # cf_call uses threads to get all results. return self.client.cf_call('SoftLayer_Account', 'getNetworkVlans', diff --git a/SoftLayer/managers/vs.py b/SoftLayer/managers/vs.py index 534ea246e..dddc6310e 100644 --- a/SoftLayer/managers/vs.py +++ b/SoftLayer/managers/vs.py @@ -131,6 +131,7 @@ def list_instances(self, hourly=True, monthly=True, tags=None, cpus=None, call = 'getMonthlyVirtualGuests' _filter = utils.NestedDict(kwargs.get('filter') or {}) + _filter['virtualGuests']['id'] = utils.query_filter_orderby() if tags: _filter['virtualGuests']['tagReferences']['tag']['name'] = { 'operation': 'in', diff --git a/SoftLayer/testing/xmlrpc.py b/SoftLayer/testing/xmlrpc.py index b60c2bf0c..e0e7e5ca2 100644 --- a/SoftLayer/testing/xmlrpc.py +++ b/SoftLayer/testing/xmlrpc.py @@ -3,6 +3,25 @@ ~~~~~~~~~~~~~~~~~~~~~~~~ XMP-RPC server which can use a transport to proxy requests for testing. + If you want to spin up a test XML server to make fake API calls with, try this: + + quick-server.py + --- + import SoftLayer + from SoftLayer.testing import xmlrpc + + my_xport = SoftLayer.FixtureTransport() + my_server = xmlrpc.create_test_server(my_xport, "localhost", port=4321) + print(f"Server running on http://{my_server.server_name}:{my_server.server_port}") + --- + $> python quick-server.py + $> curl -X POST -d " \ +getInvoiceTopLevelItemsheaders \ +SoftLayer_Billing_InvoiceInitParameters \ +id1234 \ +" \ +http://127.0.0.1:4321/SoftLayer_Billing_Invoice + :license: MIT, see LICENSE for more details. """ import http.server @@ -45,22 +64,22 @@ def do_POST(self): req.args = args[1:] req.filter = _item_by_key_postfix(headers, 'ObjectFilter') or None req.mask = _item_by_key_postfix(headers, 'ObjectMask').get('mask') - req.identifier = _item_by_key_postfix(headers, - 'InitParameters').get('id') - req.transport_headers = dict(((k.lower(), v) - for k, v in self.headers.items())) + req.identifier = _item_by_key_postfix(headers, 'InitParameters').get('id') + req.transport_headers = dict(((k.lower(), v) for k, v in self.headers.items())) req.headers = headers # Get response response = self.server.transport(req) - response_body = xmlrpc.client.dumps((response,), - allow_none=True, - methodresponse=True) + # Need to convert BACK to list, so xmlrpc can dump it out properly. + if isinstance(response, SoftLayer.transports.transport.SoftLayerListResult): + response = list(response) + response_body = xmlrpc.client.dumps((response,), allow_none=True, methodresponse=True) self.send_response(200) self.send_header("Content-type", "application/xml; charset=UTF-8") self.end_headers() + try: self.wfile.write(response_body.encode('utf-8')) except UnicodeDecodeError: @@ -70,22 +89,25 @@ def do_POST(self): self.send_response(200) self.end_headers() response = xmlrpc.client.Fault(404, str(ex)) - response_body = xmlrpc.client.dumps(response, - allow_none=True, - methodresponse=True) + response_body = xmlrpc.client.dumps(response, allow_none=True, methodresponse=True) self.wfile.write(response_body.encode('utf-8')) except SoftLayer.SoftLayerAPIError as ex: self.send_response(200) self.end_headers() response = xmlrpc.client.Fault(ex.faultCode, str(ex.reason)) - response_body = xmlrpc.client.dumps(response, - allow_none=True, - methodresponse=True) + response_body = xmlrpc.client.dumps(response, allow_none=True, methodresponse=True) + self.wfile.write(response_body.encode('utf-8')) + except OverflowError as ex: + self.send_response(555) + self.send_header("Content-type", "application/xml; charset=UTF-8") + self.end_headers() + response_body = '''OverflowError in XML response.''' self.wfile.write(response_body.encode('utf-8')) - except Exception: + logging.exception("Error while handling request: %s", ex) + except Exception as ex: self.send_response(500) - logging.exception("Error while handling request") + logging.exception("Error while handling request: %s", ex) def log_message(self, fmt, *args): """Override log_message.""" @@ -103,7 +125,6 @@ def _item_by_key_postfix(dictionary, key_prefix): def create_test_server(transport, host='localhost', port=0): """Create a test XML-RPC server in a new thread.""" server = TestServer(transport, (host, port), TestHandler) - thread = threading.Thread(target=server.serve_forever, - kwargs={'poll_interval': 0.01}) + thread = threading.Thread(target=server.serve_forever, kwargs={'poll_interval': 0.01}) thread.start() return server diff --git a/SoftLayer/transports/fixture.py b/SoftLayer/transports/fixture.py index 6975e92b6..7a6b64e19 100644 --- a/SoftLayer/transports/fixture.py +++ b/SoftLayer/transports/fixture.py @@ -8,6 +8,8 @@ import importlib +from .transport import SoftLayerListResult + class FixtureTransport(object): """Implements a transport which returns fixtures.""" @@ -21,7 +23,10 @@ def __call__(self, call): message = f'{call.service} fixture is not implemented' raise NotImplementedError(message) from ex try: - return getattr(module, call.method) + result = getattr(module, call.method) + if isinstance(result, list): + return SoftLayerListResult(result, len(result)) + return result except AttributeError as ex: message = f'{call.service}::{call.method} fixture is not implemented' raise NotImplementedError(message) from ex diff --git a/SoftLayer/transports/rest.py b/SoftLayer/transports/rest.py index 30ce11bad..9d2a13269 100644 --- a/SoftLayer/transports/rest.py +++ b/SoftLayer/transports/rest.py @@ -138,8 +138,7 @@ def __call__(self, request): request.result = result if isinstance(result, list): - return SoftLayerListResult( - result, int(resp.headers.get('softlayer-total-items', 0))) + return SoftLayerListResult(result, int(resp.headers.get('softlayer-total-items', 0))) else: return result except requests.HTTPError as ex: diff --git a/SoftLayer/transports/transport.py b/SoftLayer/transports/transport.py index ab5ebedde..9496be50b 100644 --- a/SoftLayer/transports/transport.py +++ b/SoftLayer/transports/transport.py @@ -121,6 +121,10 @@ def __init__(self, items=None, total_count=0): self.total_count = total_count super().__init__(items) + def get_total_items(self): + """A simple getter to totalCount, but its called getTotalItems since that is the header returned""" + return self.total_count + def _proxies_dict(proxy): """Makes a proxy dict appropriate to pass to requests.""" diff --git a/SoftLayer/transports/xmlrpc.py b/SoftLayer/transports/xmlrpc.py index 57ba4e9f6..16456eda9 100644 --- a/SoftLayer/transports/xmlrpc.py +++ b/SoftLayer/transports/xmlrpc.py @@ -100,8 +100,7 @@ def __call__(self, request): resp.raise_for_status() result = xmlrpc.client.loads(resp.content)[0][0] if isinstance(result, list): - return SoftLayerListResult( - result, int(resp.headers.get('softlayer-total-items', 0))) + return SoftLayerListResult(result, int(resp.headers.get('softlayer-total-items', 0))) else: return result except xmlrpc.client.Fault as ex: @@ -122,7 +121,8 @@ def __call__(self, request): _ex = error_mapping.get(ex.faultCode, exceptions.SoftLayerAPIError) raise _ex(ex.faultCode, ex.faultString) from ex except requests.HTTPError as ex: - raise exceptions.TransportError(ex.response.status_code, str(ex)) + err_message = f"{str(ex)} :: {ex.response.content}" + raise exceptions.TransportError(ex.response.status_code, err_message) except requests.RequestException as ex: raise exceptions.TransportError(0, str(ex)) diff --git a/SoftLayer/utils.py b/SoftLayer/utils.py index 9159eaaa6..4e1cb8636 100644 --- a/SoftLayer/utils.py +++ b/SoftLayer/utils.py @@ -6,6 +6,7 @@ """ import collections +import copy import datetime from json import JSONDecoder import re @@ -41,6 +42,32 @@ def lookup(dic, key, *keys): return dic.get(key) +def has_key_value(d: dict, key: str = "operation", value: str = "orderBy") -> bool: + """Scan through a dictionary looking for an orderBy clause, but can be used for any key/value combo""" + if d.get(key) and d.get(key) == value: + return True + for x in d.values(): + if isinstance(x, dict): + if has_key_value(x, key, value): + return True + return False + + +def fix_filter(sl_filter: dict = None) -> dict: + """Forces an object filter to have an orderBy clause if it doesn't have one already""" + + if sl_filter is None: + sl_filter = {} + + # Make a copy to prevent sl_filter from being modified by this function + this_filter = copy.copy(sl_filter) + if not has_key_value(this_filter, "operation", "orderBy"): + # Check to see if 'id' is already a filter, if so just skip + if not this_filter.get('id', False): + this_filter['id'] = query_filter_orderby() + return this_filter + + class NestedDict(dict): """This helps with accessing a heavily nested dictionary. diff --git a/docs/requirements.txt b/docs/requirements.txt index 2c1e4075b..118d14bbc 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ -sphinx==8.0.2 -sphinx_rtd_theme==2.0.0 +sphinx_rtd_theme==3.0.2 +sphinx==8.1.3 sphinx-click==6.0.0 click prettytable diff --git a/setup.py b/setup.py index 045cb6d75..6e02544b2 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ setup( name='SoftLayer', - version='v6.2.5', + version='v6.2.6', description=DESCRIPTION, long_description=LONG_DESCRIPTION, long_description_content_type='text/x-rst', @@ -38,7 +38,7 @@ 'prompt_toolkit >= 2', 'pygments >= 2.0.0', 'urllib3 >= 1.24', - 'rich == 13.7.1' + 'rich == 13.9.4' ], keywords=['softlayer', 'cloud', 'slcli', 'ibmcloud'], classifiers=[ diff --git a/snap/local/slcli.png b/snap/local/slcli.png new file mode 100644 index 000000000..5e273f36e Binary files /dev/null and b/snap/local/slcli.png differ diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 4b78a2c9d..9a54221f0 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -6,11 +6,32 @@ description: | SLCLI documentation can be found here: https://softlayer-python.readthedocs.io/en/latest/ license: MIT - -base: core22 +website: https://www.ibm.com/cloud +source-code: https://github.com/softlayer/softlayer-python +issues: https://github.com/softlayer/softlayer-python/issues +contact: https://github.com/softlayer/softlayer-python +icon: snap/local/slcli.png +base: core24 grade: stable confinement: strict +platforms: + amd64: + build-on: [amd64] + build-for: [amd64] + arm64: + build-on: [arm64] + build-for: [arm64] + armhf: + build-on: [armhf] + build-for: [armhf] + ppc64el: + build-on: [ppc64el] + build-for: [ppc64el] + s390x: + build-on: [s390x] + build-for: [s390x] + apps: slcli: command: bin/slcli @@ -25,10 +46,10 @@ parts: slcli: source: https://github.com/softlayer/softlayer-python source-type: git - plugin: python + plugin: python override-pull: | - snapcraftctl pull - snapcraftctl set-version "$(git describe --tags | sed 's/^v//')" + craftctl default + craftctl set version="$(git describe --tags | sed 's/^v//')" build-packages: - python3 diff --git a/tests/CLI/modules/account_tests.py b/tests/CLI/modules/account_tests.py index 06c718cb4..9dd4dd905 100644 --- a/tests/CLI/modules/account_tests.py +++ b/tests/CLI/modules/account_tests.py @@ -44,14 +44,11 @@ def test_event_jsonraw_output(self): command = '--format jsonraw account events' command_params = command.split() result = self.run_command(command_params) - json_text_tables = result.stdout.split('\n') - print(f"RESULT: {result.output}") # removing an extra item due to an additional Newline at the end of the output json_text_tables.pop() # each item in the json_text_tables should be a list for json_text_table in json_text_tables: - print(f"TESTING THIS: \n{json_text_table}\n") json_table = json.loads(json_text_table) self.assertIsInstance(json_table, list) @@ -66,6 +63,18 @@ def test_invoice_detail_details(self): self.assert_no_fail(result) self.assert_called_with('SoftLayer_Billing_Invoice', 'getInvoiceTopLevelItems', identifier='1234') + def test_invoice_detail_sum_children(self): + result = self.run_command(['--format=json', 'account', 'invoice-detail', '1234', '--details']) + self.assert_no_fail(result) + json_out = json.loads(result.output) + self.assertEqual(len(json_out), 7) + self.assertEqual(json_out[0]['Item Id'], 724951323) + self.assertEqual(json_out[0]['Single'], '$55.50') + self.assertEqual(json_out[0]['Monthly'], '$0.10') + self.assertEqual(json_out[3]['Item Id'], 1111222) + self.assertEqual(json_out[3]['Single'], '$0.00') + self.assertEqual(json_out[3]['Monthly'], '$30.36') + def test_invoice_detail_csv_output_format(self): result = self.run_command(["--format", "csv", 'account', 'invoice-detail', '1234']) result_output = result.output.replace('\r', '').split('\n') @@ -74,7 +83,7 @@ def test_invoice_detail_csv_output_format(self): '"Create Date","Location"') self.assertEqual(result_output[1], '724951323,"Private (only) Secondary VLAN IP Addresses",' '"64 Portable Private IP Addresses (bleg.beh.com)",' - '"$0.00","$0.00","2018-04-04","fra02"') + '"$55.50","$0.10","2018-04-04","fra02"') # slcli account invoices def test_invoices(self): diff --git a/tests/CLI/modules/block_tests.py b/tests/CLI/modules/block_tests.py index d21c8dece..a64efc432 100644 --- a/tests/CLI/modules/block_tests.py +++ b/tests/CLI/modules/block_tests.py @@ -125,7 +125,8 @@ def test_volume_detail_name_identifier(self): 'storageType': { 'keyName': {'operation': '*= BLOCK_STORAGE'} }, - 'username': {'operation': '_= SL-12345'} + 'username': {'operation': '_= SL-12345'}, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } } diff --git a/tests/CLI/modules/event_log_tests.py b/tests/CLI/modules/event_log_tests.py index 0f6bfa789..c7afacd8b 100644 --- a/tests/CLI/modules/event_log_tests.py +++ b/tests/CLI/modules/event_log_tests.py @@ -31,10 +31,9 @@ def test_get_event_log_empty(self): mock.return_value = None result = self.run_command(['event-log', 'get']) - expected = 'Event, Object, Type, Date, Username\n' \ - 'No logs available for filter {}.\n' + self.assert_no_fail(result) - self.assertEqual(expected, result.output) + self.assertIn("No logs available for filter ", result.output) def test_get_event_log_over_limit(self): result = self.run_command(['event-log', 'get', '-l 1']) diff --git a/tests/CLI/modules/file_tests.py b/tests/CLI/modules/file_tests.py index c68ac7a08..616afdd5c 100644 --- a/tests/CLI/modules/file_tests.py +++ b/tests/CLI/modules/file_tests.py @@ -210,14 +210,13 @@ def test_volume_detail_name_identifier(self): expected_filter = { 'nasNetworkStorage': { 'serviceResource': { - 'type': { - 'type': {'operation': '!~ NAS'} - } + 'type': {'type': {'operation': '!~ NAS'}} }, - 'storageType': { - 'keyName': {'operation': '*= FILE_STORAGE'} - }, - 'username': {'operation': '_= SL-12345'}}} + 'storageType': {'keyName': {'operation': '*= FILE_STORAGE'}}, + 'username': {'operation': '_= SL-12345'}, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} + } + } self.assert_called_with('SoftLayer_Account', 'getNasNetworkStorage', filter=expected_filter) self.assert_called_with('SoftLayer_Network_Storage', 'getObject', identifier=1) diff --git a/tests/CLI/modules/hardware/hardware_basic_tests.py b/tests/CLI/modules/hardware/hardware_basic_tests.py index d7c2ca9b3..a5597872e 100644 --- a/tests/CLI/modules/hardware/hardware_basic_tests.py +++ b/tests/CLI/modules/hardware/hardware_basic_tests.py @@ -169,47 +169,6 @@ def test_detail_drives(self): self.assertEqual(output['drives'][0]['Name'], 'Seagate Constellation ES') self.assertEqual(output['drives'][0]['Serial #'], 'z1w4sdf') - def test_list_servers(self): - result = self.run_command(['server', 'list', '--tag=openstack']) - - expected = [ - { - 'datacenter': 'TEST00', - 'primary_ip': '172.16.1.100', - 'hostname': 'hardware-test1', - 'id': 1000, - 'backend_ip': '10.1.0.2', - 'action': 'TXN_NAME', - }, - { - 'datacenter': 'TEST00', - 'primary_ip': '172.16.4.94', - 'hostname': 'hardware-test2', - 'id': 1001, - 'backend_ip': '10.1.0.3', - 'action': None, - }, - { - 'datacenter': 'TEST00', - 'primary_ip': '172.16.4.95', - 'hostname': 'hardware-bad-memory', - 'id': 1002, - 'backend_ip': '10.1.0.4', - 'action': None, - }, - { - 'action': None, - 'backend_ip': None, - 'datacenter': None, - 'hostname': None, - 'id': 1003, - 'primary_ip': None, - }, - ] - - self.assert_no_fail(result) - self.assertEqual(expected, json.loads(result.output)) - @mock.patch('SoftLayer.CLI.formatting.no_going_back') @mock.patch('SoftLayer.HardwareManager.reload') def test_server_reload(self, reload_mock, ngb_mock): @@ -992,17 +951,6 @@ def test_create_credential(self): '--notes', 'test slcli', '--software', 'system']) self.assert_no_fail(result) - def test_list_hw_search_noargs(self): - result = self.run_command(['hw', 'list', '--search']) - self.assert_no_fail(result) - self.assert_called_with('SoftLayer_Search', 'advancedSearch', args=('_objectType:SoftLayer_Hardware ',)) - - def test_list_hw_search_noargs_domain(self): - result = self.run_command(['hw', 'list', '--search', '-Dtest']) - self.assert_no_fail(result) - self.assert_called_with('SoftLayer_Search', 'advancedSearch', - args=('_objectType:SoftLayer_Hardware domain: *test*',)) - @mock.patch('SoftLayer.CLI.formatting.confirm') def test_hardware_cancel_no_force(self, confirm_mock): confirm_mock.return_value = False diff --git a/tests/CLI/modules/hardware/hardware_list_tests.py b/tests/CLI/modules/hardware/hardware_list_tests.py new file mode 100644 index 000000000..1532da60c --- /dev/null +++ b/tests/CLI/modules/hardware/hardware_list_tests.py @@ -0,0 +1,93 @@ +""" + SoftLayer.tests.CLI.modules.hardware.hardware_list_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + These tests the `slcli hw list` command. Its complex enough to warrant its own file + + :license: MIT, see LICENSE for more details. +""" + +import json + +from SoftLayer import testing +from SoftLayer import utils + + +class HardwareListCLITests(testing.TestCase): + def test_list_servers(self): + colums = 'datacenter,primary_ip,hostname,id,backend_ip,action' + result = self.run_command(['server', 'list', '--tag=openstack', f'--columns={colums}']) + + expected = [ + { + 'datacenter': 'TEST00', + 'primary_ip': '172.16.1.100', + 'hostname': 'hardware-test1', + 'id': 1000, + 'backend_ip': '10.1.0.2', + 'action': 'TXN_NAME', + }, + { + 'datacenter': 'TEST00', + 'primary_ip': '172.16.4.94', + 'hostname': 'hardware-test2', + 'id': 1001, + 'backend_ip': '10.1.0.3', + 'action': None, + }, + { + 'datacenter': 'TEST00', + 'primary_ip': '172.16.4.95', + 'hostname': 'hardware-bad-memory', + 'id': 1002, + 'backend_ip': '10.1.0.4', + 'action': None, + }, + { + 'action': None, + 'backend_ip': None, + 'datacenter': None, + 'hostname': None, + 'id': 1003, + 'primary_ip': None, + }, + ] + + self.assert_no_fail(result) + self.assertEqual(expected, json.loads(result.output)) + + def test_list_hw_search_noargs(self): + result = self.run_command(['hw', 'list', '--search']) + self.assert_no_fail(result) + self.assert_called_with('SoftLayer_Search', 'advancedSearch', args=('_objectType:SoftLayer_Hardware ',)) + + def test_list_hw_search_noargs_domain(self): + result = self.run_command(['hw', 'list', '--search', '-Dtest']) + self.assert_no_fail(result) + self.assert_called_with('SoftLayer_Search', 'advancedSearch', + args=('_objectType:SoftLayer_Hardware domain: *test*',)) + + def test_list_by_owner(self): + result = self.run_command(['hw', 'list', '--owner=testUser']) + self.assert_no_fail(result) + expectedFilter = utils.NestedDict() + expectedFilter['hardware']['id'] = utils.query_filter_orderby() + expectedFilter['hardware']['billingItem']['orderItem']['order']['userRecord']['username'] = ( + utils.query_filter('testUser')) + self.assert_called_with('SoftLayer_Account', 'getHardware', filter=expectedFilter) + + def test_list_by_pub_ip(self): + result = self.run_command(['hw', 'list', '--primary_ip=1.2.3.4']) + self.assert_no_fail(result) + expectedFilter = utils.NestedDict() + expectedFilter['hardware']['id'] = utils.query_filter_orderby() + expectedFilter['hardware']['primaryIpAddress'] = utils.query_filter('1.2.3.4') + self.assert_called_with('SoftLayer_Account', 'getHardware', filter=expectedFilter) + + def test_list_by_pri_ip(self): + result = self.run_command(['hw', 'list', '--backend_ip=1.2.3.4']) + self.assert_no_fail(result) + expectedFilter = utils.NestedDict() + expectedFilter['hardware']['id'] = utils.query_filter_orderby() + expectedFilter['hardware']['primaryBackendIpAddress'] = utils.query_filter('1.2.3.4') + self.assert_called_with('SoftLayer_Account', 'getHardware', filter=expectedFilter) diff --git a/tests/CLI/modules/search_tests.py b/tests/CLI/modules/search_tests.py index c14ce6b84..1b2540779 100644 --- a/tests/CLI/modules/search_tests.py +++ b/tests/CLI/modules/search_tests.py @@ -1,6 +1,6 @@ """ - SoftLayer.tests.CLI.modules.find_tests - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + SoftLayer.tests.CLI.modules.search_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :license: MIT, see LICENSE for more details. """ @@ -8,16 +8,23 @@ from SoftLayer import testing -class FindTests(testing.TestCase): +class SearchTests(testing.TestCase): def test_find(self): result = self.run_command(['search', '--types']) + self.assert_called_with("SoftLayer_Search", "getObjectTypes") self.assert_no_fail(result) def test_find_advanced(self): result = self.run_command(['search', 'hardware', '--advanced']) + self.assert_called_with("SoftLayer_Search", "advancedSearch", args=('hardware',)) self.assert_no_fail(result) def test_no_options(self): result = self.run_command(['search']) self.assertEqual(result.exit_code, 2) + + def test_find_single_item(self): + result = self.run_command(['search', 'test.com']) + self.assert_no_fail(result) + self.assert_called_with("SoftLayer_Search", "search", args=('test.com',)) diff --git a/tests/api_tests.py b/tests/api_tests.py index 438d77020..0ba0a51ad 100644 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -169,8 +169,8 @@ def test_iter_call(self, _call): self.assertEqual(list(range(125)), result) _call.assert_has_calls([ - mock.call('SERVICE', 'METHOD', limit=100, iter=False, offset=0), - mock.call('SERVICE', 'METHOD', limit=100, iter=False, offset=100), + mock.call('SERVICE', 'METHOD', limit=100, iter=False, offset=0, filter=mock.ANY), + mock.call('SERVICE', 'METHOD', limit=100, iter=False, offset=100, filter=mock.ANY), ]) _call.reset_mock() @@ -183,9 +183,9 @@ def test_iter_call(self, _call): result = list(self.client.iter_call('SERVICE', 'METHOD', iter=True)) self.assertEqual(list(range(200)), result) _call.assert_has_calls([ - mock.call('SERVICE', 'METHOD', limit=100, iter=False, offset=0), - mock.call('SERVICE', 'METHOD', limit=100, iter=False, offset=100), - mock.call('SERVICE', 'METHOD', limit=100, iter=False, offset=200), + mock.call('SERVICE', 'METHOD', limit=100, iter=False, offset=0, filter=mock.ANY), + mock.call('SERVICE', 'METHOD', limit=100, iter=False, offset=100, filter=mock.ANY), + mock.call('SERVICE', 'METHOD', limit=100, iter=False, offset=200, filter=mock.ANY), ]) _call.reset_mock() @@ -194,12 +194,11 @@ def test_iter_call(self, _call): transports.SoftLayerListResult(range(0, 25), 30), transports.SoftLayerListResult(range(25, 30), 30) ] - result = list(self.client.iter_call( - 'SERVICE', 'METHOD', iter=True, limit=25)) + result = list(self.client.iter_call('SERVICE', 'METHOD', iter=True, limit=25)) self.assertEqual(list(range(30)), result) _call.assert_has_calls([ - mock.call('SERVICE', 'METHOD', iter=False, limit=25, offset=0), - mock.call('SERVICE', 'METHOD', iter=False, limit=25, offset=25), + mock.call('SERVICE', 'METHOD', iter=False, limit=25, offset=0, filter=mock.ANY), + mock.call('SERVICE', 'METHOD', iter=False, limit=25, offset=25, filter=mock.ANY), ]) _call.reset_mock() @@ -208,7 +207,7 @@ def test_iter_call(self, _call): result = list(self.client.iter_call('SERVICE', 'METHOD', iter=True)) self.assertEqual(["test"], result) _call.assert_has_calls([ - mock.call('SERVICE', 'METHOD', iter=False, limit=100, offset=0), + mock.call('SERVICE', 'METHOD', iter=False, limit=100, offset=0, filter=mock.ANY), ]) _call.reset_mock() @@ -216,23 +215,19 @@ def test_iter_call(self, _call): transports.SoftLayerListResult(range(0, 25), 30), transports.SoftLayerListResult(range(25, 30), 30) ] - result = list(self.client.iter_call('SERVICE', 'METHOD', 'ARG', - iter=True, - limit=25, - offset=12)) + result = list( + self.client.iter_call('SERVICE', 'METHOD', 'ARG', iter=True, limit=25, offset=12) + ) self.assertEqual(list(range(30)), result) _call.assert_has_calls([ - mock.call('SERVICE', 'METHOD', 'ARG', - iter=False, limit=25, offset=12), - mock.call('SERVICE', 'METHOD', 'ARG', - iter=False, limit=25, offset=37), + mock.call('SERVICE', 'METHOD', 'ARG', iter=False, limit=25, offset=12, filter=mock.ANY), + mock.call('SERVICE', 'METHOD', 'ARG', iter=False, limit=25, offset=37, filter=mock.ANY), ]) # Chunk size of 0 is invalid self.assertRaises( AttributeError, - lambda: list(self.client.iter_call('SERVICE', 'METHOD', - iter=True, limit=0))) + lambda: list(self.client.iter_call('SERVICE', 'METHOD', iter=True, limit=0, filter=mock.ANY))) def test_call_invalid_arguments(self): self.assertRaises( diff --git a/tests/managers/block_tests.py b/tests/managers/block_tests.py index 54dfc6e36..2b0954c7d 100644 --- a/tests/managers/block_tests.py +++ b/tests/managers/block_tests.py @@ -125,8 +125,7 @@ def test_get_block_volume_details(self): def test_list_block_volumes(self): result = self.block.list_block_volumes() - self.assertEqual(SoftLayer_Account.getIscsiNetworkStorage, - result) + self.assertEqual(SoftLayer_Account.getIscsiNetworkStorage, result) expected_filter = { 'iscsiNetworkStorage': { @@ -134,10 +133,9 @@ def test_list_block_volumes(self): 'keyName': {'operation': '*= BLOCK_STORAGE'} }, 'serviceResource': { - 'type': { - 'type': {'operation': '!~ ISCSI'} - } - } + 'type': {'type': {'operation': '!~ ISCSI'}} + }, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } } @@ -161,8 +159,7 @@ def test_list_block_volumes(self): def test_list_block_volumes_additional_filter_order(self): result = self.block.list_block_volumes(order=1234567) - self.assertEqual(SoftLayer_Account.getIscsiNetworkStorage, - result) + self.assertEqual(SoftLayer_Account.getIscsiNetworkStorage, result) expected_filter = { 'iscsiNetworkStorage': { @@ -170,14 +167,12 @@ def test_list_block_volumes_additional_filter_order(self): 'keyName': {'operation': '*= BLOCK_STORAGE'} }, 'serviceResource': { - 'type': { - 'type': {'operation': '!~ ISCSI'} - } + 'type': {'type': {'operation': '!~ ISCSI'}} }, 'billingItem': { - 'orderItem': { - 'order': { - 'id': {'operation': 1234567}}}} + 'orderItem': {'order': {'id': {'operation': 1234567}}} + }, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } } @@ -199,27 +194,21 @@ def test_list_block_volumes_additional_filter_order(self): ) def test_list_block_volumes_with_additional_filters(self): - result = self.block.list_block_volumes(datacenter="dal09", - storage_type="Endurance", - username="username") + result = self.block.list_block_volumes(datacenter="dal09", storage_type="Endurance", username="username") - self.assertEqual(SoftLayer_Account.getIscsiNetworkStorage, - result) + self.assertEqual(SoftLayer_Account.getIscsiNetworkStorage, result) expected_filter = { 'iscsiNetworkStorage': { 'storageType': { 'keyName': {'operation': '^= ENDURANCE_BLOCK_STORAGE'} }, - 'username': {'operation': u'_= username'}, + 'username': {'operation': '_= username'}, 'serviceResource': { - 'datacenter': { - 'name': {'operation': u'_= dal09'} - }, - 'type': { - 'type': {'operation': '!~ ISCSI'} - } - } + 'datacenter': {'name': {'operation': u'_= dal09'}}, + 'type': {'type': {'operation': '!~ ISCSI'}} + }, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } } diff --git a/tests/managers/dedicated_host_tests.py b/tests/managers/dedicated_host_tests.py index 3c1293726..0b46f6585 100644 --- a/tests/managers/dedicated_host_tests.py +++ b/tests/managers/dedicated_host_tests.py @@ -714,7 +714,8 @@ def test_list_guests_with_filters(self): 'networkComponents': {'maxSpeed': {'operation': 100}}, 'primaryIpAddress': {'operation': '_= 1.2.3.4'}, 'primaryBackendIpAddress': {'operation': '_= 4.3.2.1'} - } + }, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } self.assert_called_with('SoftLayer_Virtual_DedicatedHost', 'getGuests', identifier=12345, filter=_filter) diff --git a/tests/managers/dns_tests.py b/tests/managers/dns_tests.py index 0c7c1cade..24519f6b8 100644 --- a/tests/managers/dns_tests.py +++ b/tests/managers/dns_tests.py @@ -167,13 +167,16 @@ def test_get_records(self): mock.return_value = [records[0]] self.dns_client.get_records(12345, record_type='a', host='hostname', data='a', ttl='86400') - _filter = {'resourceRecords': {'type': {'operation': '_= a'}, - 'host': {'operation': '_= hostname'}, - 'data': {'operation': '_= a'}, - 'ttl': {'operation': 86400}}} - self.assert_called_with('SoftLayer_Dns_Domain', 'getResourceRecords', - identifier=12345, - filter=_filter) + _filter = { + 'resourceRecords': { + 'type': {'operation': '_= a'}, + 'host': {'operation': '_= hostname'}, + 'data': {'operation': '_= a'}, + 'ttl': {'operation': 86400}, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} + } + } + self.assert_called_with('SoftLayer_Dns_Domain', 'getResourceRecords', identifier=12345, filter=_filter) def test_get_record(self): record_id = 1234 diff --git a/tests/managers/event_log_tests.py b/tests/managers/event_log_tests.py index e5c220835..8ccba40a2 100644 --- a/tests/managers/event_log_tests.py +++ b/tests/managers/event_log_tests.py @@ -39,8 +39,8 @@ def test_get_event_log_types(self): def test_build_filter_no_args(self): result = self.event_log.build_filter(None, None, None, None, None, None) - - self.assertEqual(result, {}) + expected = {'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]}} + self.assertDictEqual(result, expected) def test_build_filter_min_date(self): expected = { @@ -54,7 +54,8 @@ def test_build_filter_min_date(self): ] } ] - } + }, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } result = self.event_log.build_filter('10/30/2017', None, None, None, None, None) @@ -73,7 +74,8 @@ def test_build_filter_max_date(self): ] } ] - } + }, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } result = self.event_log.build_filter(None, '10/31/2017', None, None, None, None) @@ -98,7 +100,8 @@ def test_build_filter_min_max_date(self): ] } ] - } + }, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } result = self.event_log.build_filter('10/30/2017', '10/31/2017', None, None, None, None) @@ -117,7 +120,8 @@ def test_build_filter_min_date_pos_utc(self): ] } ] - } + }, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } result = self.event_log.build_filter('10/30/2017', None, None, None, None, '+0500') @@ -136,7 +140,8 @@ def test_build_filter_max_date_pos_utc(self): ] } ] - } + }, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } result = self.event_log.build_filter(None, '10/31/2017', None, None, None, '+0500') @@ -161,7 +166,8 @@ def test_build_filter_min_max_date_pos_utc(self): ] } ] - } + }, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } result = self.event_log.build_filter('10/30/2017', '10/31/2017', None, None, None, '+0500') @@ -180,7 +186,8 @@ def test_build_filter_min_date_neg_utc(self): ] } ] - } + }, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } result = self.event_log.build_filter('10/30/2017', None, None, None, None, '-0300') @@ -199,7 +206,8 @@ def test_build_filter_max_date_neg_utc(self): ] } ] - } + }, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } result = self.event_log.build_filter(None, '10/31/2017', None, None, None, '-0300') @@ -224,7 +232,8 @@ def test_build_filter_min_max_date_neg_utc(self): ] } ] - } + }, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } result = self.event_log.build_filter('10/30/2017', '10/31/2017', None, None, None, '-0300') @@ -232,21 +241,30 @@ def test_build_filter_min_max_date_neg_utc(self): self.assertEqual(expected, result) def test_build_filter_name(self): - expected = {'eventName': {'operation': 'Add Security Group'}} + expected = { + 'eventName': {'operation': 'Add Security Group'}, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} + } result = self.event_log.build_filter(None, None, 'Add Security Group', None, None, None) self.assertEqual(expected, result) def test_build_filter_id(self): - expected = {'objectId': {'operation': 1}} + expected = { + 'objectId': {'operation': 1}, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} + } result = self.event_log.build_filter(None, None, None, 1, None, None) self.assertEqual(expected, result) def test_build_filter_type(self): - expected = {'objectName': {'operation': 'CCI'}} + expected = { + 'objectName': {'operation': 'CCI'}, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} + } result = self.event_log.build_filter(None, None, None, None, 'CCI', None) diff --git a/tests/managers/file_tests.py b/tests/managers/file_tests.py index 11e35c001..647ddfc2c 100644 --- a/tests/managers/file_tests.py +++ b/tests/managers/file_tests.py @@ -356,7 +356,8 @@ def test_list_file_volumes(self): 'type': { 'type': {'operation': '!~ NAS'} } - } + }, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } } @@ -389,14 +390,12 @@ def test_list_file_volumes_additional_filter_order(self): 'keyName': {'operation': '*= FILE_STORAGE'} }, 'serviceResource': { - 'type': { - 'type': {'operation': '!~ NAS'} - } + 'type': {'type': {'operation': '!~ NAS'}} }, 'billingItem': { - 'orderItem': { - 'order': { - 'id': {'operation': 1234567}}}} + 'orderItem': {'order': {'id': {'operation': 1234567}}} + }, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } } @@ -430,15 +429,12 @@ def test_list_file_volumes_with_additional_filters(self): 'storageType': { 'keyName': {'operation': '^= ENDURANCE_FILE_STORAGE'} }, - 'username': {'operation': u'_= username'}, + 'username': {'operation': '_= username'}, 'serviceResource': { - 'datacenter': { - 'name': {'operation': u'_= dal09'} - }, - 'type': { - 'type': {'operation': '!~ NAS'} - } - } + 'datacenter': {'name': {'operation': '_= dal09'}}, + 'type': {'type': {'operation': '!~ NAS'}} + }, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } } diff --git a/tests/managers/hardware_tests.py b/tests/managers/hardware_tests.py index c067dd084..12870a43f 100644 --- a/tests/managers/hardware_tests.py +++ b/tests/managers/hardware_tests.py @@ -57,6 +57,7 @@ def test_list_hardware_with_filters(self): self.assertEqual(results, fixtures.SoftLayer_Account.getHardware) _filter = { 'hardware': { + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]}, 'datacenter': {'name': {'operation': '_= dal05'}}, 'domain': {'operation': '_= example.com'}, 'tagReferences': { @@ -73,8 +74,7 @@ def test_list_hardware_with_filters(self): 'networkComponents': {'maxSpeed': {'operation': 100}}, 'primaryBackendIpAddress': {'operation': '_= 4.3.2.1'}} } - self.assert_called_with('SoftLayer_Account', 'getHardware', - filter=_filter) + self.assert_called_with('SoftLayer_Account', 'getHardware', filter=_filter) def test_resolve_ids_ip(self): _id = self.hardware._get_ids_from_ip('172.16.1.100') diff --git a/tests/managers/image_tests.py b/tests/managers/image_tests.py index 13c5b0fdf..82a17d6ad 100644 --- a/tests/managers/image_tests.py +++ b/tests/managers/image_tests.py @@ -46,7 +46,9 @@ def test_list_private_images_with_filters(self): 'privateBlockDeviceTemplateGroups': { 'globalIdentifier': { 'operation': '_= 0FA9ECBD-CF7E-4A1F-1E36F8D27C2B'}, - 'name': {'operation': '_= name'}} + 'name': {'operation': '_= name'}, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} + } } self.assertEqual(len(results), 2) self.assert_called_with('SoftLayer_Account', 'getPrivateBlockDeviceTemplateGroups', filter=_filter) @@ -64,7 +66,8 @@ def test_list_public_images_with_filters(self): _filter = { 'globalIdentifier': { 'operation': '_= 0FA9ECBD-CF7E-4A1F-1E36F8D27C2B'}, - 'name': {'operation': '_= name'} + 'name': {'operation': '_= name'}, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } self.assert_called_with(IMAGE_SERVICE, 'getPublicImages', filter=_filter) diff --git a/tests/managers/network_tests.py b/tests/managers/network_tests.py index aa86fe245..915edad4d 100644 --- a/tests/managers/network_tests.py +++ b/tests/managers/network_tests.py @@ -327,6 +327,7 @@ def test_list_subnets_default(self): 'version': {'operation': 4}, 'subnetType': {'operation': '_= PRIMARY'}, 'networkVlan': {'networkSpace': {'operation': '_= PUBLIC'}}, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} } } @@ -602,7 +603,10 @@ def test_get_security_group_event_logs(self): result = self.network._get_security_group_event_logs() # Event log now returns a generator, so you have to get a result for it to make an API call log = result.__next__() - _filter = {'objectName': {'operation': 'Security Group'}} + _filter = { + 'objectName': {'operation': 'Security Group'}, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} + } self.assert_called_with('SoftLayer_Event_Log', 'getAllObjects', filter=_filter) self.assertEqual(100, log['accountId']) @@ -611,7 +615,10 @@ def test_get_cci_event_logs(self): result = self.network._get_cci_event_logs() # Event log now returns a generator, so you have to get a result for it to make an API call log = result.__next__() - _filter = {'objectName': {'operation': 'CCI'}} + _filter = { + 'objectName': {'operation': 'CCI'}, + 'traceId': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} + } self.assert_called_with('SoftLayer_Event_Log', 'getAllObjects', filter=_filter) self.assertEqual(100, log['accountId']) diff --git a/tests/managers/vs/vs_capacity_tests.py b/tests/managers/vs/vs_capacity_tests.py index 6fa6599e8..fbebc55a5 100644 --- a/tests/managers/vs/vs_capacity_tests.py +++ b/tests/managers/vs/vs_capacity_tests.py @@ -49,8 +49,13 @@ def test_get_available_routers(self): def test_get_available_routers_search(self): result = self.manager.get_available_routers('wdc07') - package_filter = {'keyName': {'operation': 'RESERVED_CAPACITY'}} - pod_filter = {'datacenterName': {'operation': 'wdc07'}} + package_filter = { + 'keyName': {'operation': 'RESERVED_CAPACITY'} + } + pod_filter = { + 'datacenterName': {'operation': 'wdc07'}, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} + } self.assert_called_with('SoftLayer_Product_Package', 'getAllObjects', mask=mock.ANY, filter=package_filter) self.assert_called_with('SoftLayer_Product_Package', 'getRegions', mask=mock.ANY) self.assert_called_with('SoftLayer_Network_Pod', 'getAllObjects', filter=pod_filter) diff --git a/tests/managers/vs/vs_tests.py b/tests/managers/vs/vs_tests.py index a0ac6dae8..7d644630c 100644 --- a/tests/managers/vs/vs_tests.py +++ b/tests/managers/vs/vs_tests.py @@ -65,6 +65,7 @@ def test_list_instances_with_filters(self): _filter = { 'virtualGuests': { + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]}, 'datacenter': { 'name': {'operation': '_= dal05'}}, 'domain': {'operation': '_= example.com'}, @@ -83,8 +84,7 @@ def test_list_instances_with_filters(self): 'transientGuestFlag': {'operation': False}, } } - self.assert_called_with('SoftLayer_Account', 'getVirtualGuests', - filter=_filter) + self.assert_called_with('SoftLayer_Account', 'getVirtualGuests', filter=_filter) def test_resolve_ids_ip(self): _id = self.vs._get_ids_from_ip('172.16.240.2') diff --git a/tests/transports/rest_tests.py b/tests/transports/rest_tests.py index 2c3d5f68f..20186e95f 100644 --- a/tests/transports/rest_tests.py +++ b/tests/transports/rest_tests.py @@ -37,6 +37,7 @@ def test_basic(self, request): self.assertEqual(resp, []) self.assertIsInstance(resp, transports.SoftLayerListResult) self.assertEqual(resp.total_count, 10) + self.assertEqual(resp.get_total_items(), 10) request.assert_called_with( 'GET', 'http://something9999999999999999999999.com/SoftLayer_Service/Resource.json', headers=mock.ANY, diff --git a/tests/transports/transport_tests.py b/tests/transports/transport_tests.py index c22d11b9d..e4f2d7280 100644 --- a/tests/transports/transport_tests.py +++ b/tests/transports/transport_tests.py @@ -20,6 +20,13 @@ def test_basic(self): resp = self.transport(req) self.assertEqual(resp['accountId'], 1234) + def test_total_items(self): + req = transports.Request() + req.service = 'SoftLayer_Account' + req.method = 'getHardware' + resp = self.transport(req) + self.assertEqual(resp.get_total_items(), 4) + def test_no_module(self): req = transports.Request() req.service = 'Doesnt_Exist' diff --git a/tests/transports/xmlrpc_tests.py b/tests/transports/xmlrpc_tests.py index 6e669279e..051e13822 100644 --- a/tests/transports/xmlrpc_tests.py +++ b/tests/transports/xmlrpc_tests.py @@ -409,6 +409,7 @@ def test_nonascii_characters(self, request): self.assertEqual(resp, []) self.assertIsInstance(resp, transports.SoftLayerListResult) self.assertEqual(resp.total_count, 10) + self.assertEqual(resp.get_total_items(), 10) @mock.patch('SoftLayer.transports.xmlrpc.requests.Session.request') diff --git a/tests/utils_tests.py b/tests/utils_tests.py new file mode 100644 index 000000000..2ee8474ca --- /dev/null +++ b/tests/utils_tests.py @@ -0,0 +1,84 @@ +""" + SoftLayer.tests.utils_tests + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + Tests shared code + + :license: MIT, see LICENSE for more details. +""" +from SoftLayer import testing +from SoftLayer import utils + + +TEST_FILTER = { + 'virtualGuests': { + 'provisionDate': { + 'operation': 'orderBy', + 'options': [ + {'name': 'sort', 'value': ['DESC']}, + {'name': 'sortOrder', 'value': [1]} + ] + }, + 'maxMemory': { + 'operation': 'orderBy', + 'options': [ + {'name': 'sort', 'value': ['ASC']}, + {'name': 'sortOrder', 'value': [0]} + ] + }, + }, + 'hardware': { + 'sparePoolBillingItem': { + 'id': {'operation': 'not null'} + } + }, + 'someProperty': { + 'provisionDate': { + 'operation': '> sysdate - 30' + } + } +} + + +class TestUtils(testing.TestCase): + + def test_find_key_simple(self): + """Simple test case""" + test_dict = {"key1": "value1", "nested": {"key2": "value2", "key3": "value4"}} + result = utils.has_key_value(test_dict, "key2", "value2") + self.assertIsNotNone(result) + self.assertTrue(result) + + def test_find_object_filter(self): + """Find first orderBy operation in a real-ish object filter""" + + result = utils.has_key_value(TEST_FILTER) + self.assertIsNotNone(result) + self.assertTrue(result) + + def test_not_found(self): + """Nothing to be found""" + test_dict = {"key1": "value1", "nested": {"key2": "value2", "key3": "value4"}} + result = utils.has_key_value(test_dict, "key23", "value2") + self.assertFalse(result) + + def test_fix_filter(self): + original_filter = {} + fixed_filter = utils.fix_filter(original_filter) + self.assertIsNotNone(fixed_filter) + self.assertEqual(fixed_filter.get('id'), utils.query_filter_orderby()) + # testing to make sure original doesn't get changed by the function call + self.assertIsNone(original_filter.get('id')) + + def test_billing_filter(self): + billing_filter = { + 'allTopLevelBillingItems': { + 'cancellationDate': {'operation': 'is null'}, + 'id': {'operation': 'orderBy', 'options': [{'name': 'sort', 'value': ['ASC']}]} + } + } + + fixed_filter = utils.fix_filter(billing_filter) + # Make sure we didn't add any more items + self.assertEqual(len(fixed_filter), 1) + self.assertEqual(len(fixed_filter.get('allTopLevelBillingItems')), 2) + self.assertDictEqual(fixed_filter, billing_filter) diff --git a/tools/requirements.txt b/tools/requirements.txt index 68582aec9..335a26b20 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -4,6 +4,6 @@ requests >= 2.32.2 prompt_toolkit >= 2 pygments >= 2.0.0 urllib3 >= 1.24 -rich == 13.7.1 +rich == 13.9.4 # only used for soap transport # softlayer-zeep >= 5.0.0 diff --git a/tox.ini b/tox.ini index 63e8ac7fc..fccc3fbc7 100644 --- a/tox.ini +++ b/tox.ini @@ -39,6 +39,7 @@ commands = -d consider-using-dict-comprehension \ -d useless-import-alias \ -d consider-using-f-string \ + -d too-many-positional-arguments \ --max-args=25 \ --max-branches=20 \ --max-statements=65 \ @@ -52,6 +53,7 @@ commands = pylint SoftLayer/fixtures \ -d invalid-name \ -d missing-docstring \ + -d too-many-positional-arguments \ --max-module-lines=2000 \ --min-similarity-lines=50 \ --max-line-length=120 \