Repository: softlayer/softlayer-python
Pull request #722 (2016-04-25 16:59:06)

Add block storage Support
This is a continuation of #672 which has the commits squashed (per author), is rebased from master, and a couple bug/style fixes from me.


Patch:
diff --git a/SoftLayer/CLI/block/__init__.py b/SoftLayer/CLI/block/__init__.py
new file mode 100644
index 00000000..7ccee03c
--- /dev/null
+++ b/SoftLayer/CLI/block/__init__.py
@@ -0,0 +1,1 @@
+"""Block Storage."""
diff --git a/SoftLayer/CLI/block/access_list.py b/SoftLayer/CLI/block/access_list.py
new file mode 100644
index 00000000..4b45a54b
--- /dev/null
+++ b/SoftLayer/CLI/block/access_list.py
@@ -0,0 +1,123 @@
+"""List hosts with access to volume."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+import SoftLayer
+from SoftLayer.CLI import columns as column_helper
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+
+
+def _format_name(obj):
+    if obj['type'] == 'VIRTUAL':
+        return "{0}.{1}".format(obj['hostname'], obj['domain'])
+
+    elif obj['type'] == 'HARDWARE':
+        return "{0}.{1}".format(obj['hostname'], obj['domain'])
+
+    elif obj['type'] == 'SUBNET':
+        name = "{0}/{1}".format(
+            obj['networkIdentifier'],
+            obj['cidr']
+        )
+        if 'note' in obj.keys():
+            name = "{0} ({1})".format(name, obj['note'])
+
+        return name
+
+    elif obj['type'] == 'IP':
+        name = obj['ipAddress']
+        if 'note' in obj.keys():
+            name = "{0} ({1})".format(name, obj['note'])
+
+        return name
+    else:
+        raise Exception('Unknown type %s' % obj['type'])
+
+
+COLUMNS = [
+    column_helper.Column('id', ('id',)),
+    column_helper.Column('name', _format_name, """
+allowedVirtualGuests[hostname,domain],
+allowedHardware[hostname,domain],
+allowedSubnets[networkIdentifier,cidr,note],
+allowedIpAddresses[ipAddress,note],
+"""),
+    column_helper.Column('type', ('type',)),
+    column_helper.Column(
+        'private_ip_address',
+        ('primaryBackendIpAddress',),
+        """
+allowedVirtualGuests.primaryBackendIpAddress
+allowedHardware.primaryBackendIpAddress
+allowedSubnets.primaryBackendIpAddress
+allowedIpAddresses.primaryBackendIpAddress
+"""),
+    column_helper.Column(
+        'host_iqn',
+        ('allowedHost', 'name',),
+        """
+allowedVirtualGuests.allowedHost.name
+allowedHardware.allowedHost.name
+allowedSubnets.allowedHost.name
+allowedIpAddresses.allowedHost.name
+"""),
+    column_helper.Column(
+        'username',
+        ('allowedHost', 'credential', 'username',),
+        """
+allowedVirtualGuests.allowedHost.credential.username
+allowedHardware.allowedHost.credential.username
+allowedSubnets.allowedHost.credential.username
+allowedIpAddresses.allowedHost.credential.username
+"""),
+    column_helper.Column(
+        'password',
+        ('allowedHost', 'credential', 'password',),
+        """
+allowedVirtualGuests.allowedHost.credential.password
+allowedHardware.allowedHost.credential.password
+allowedSubnets.allowedHost.credential.password
+allowedIpAddresses.allowedHost.credential.password
+"""),
+]
+
+
+DEFAULT_COLUMNS = [
+    'id',
+    'name',
+    'type',
+    'private_ip_address',
+    'host_iqn',
+    'username',
+    'password',
+]
+
+
+@click.command()
+@click.argument('volume_id')
+@click.option('--sortby', help='Column to sort by', default='name')
+@click.option('--columns',
+              callback=column_helper.get_formatter(COLUMNS),
+              help='Columns to display. Options: {0}'.format(
+                  ', '.join(column.name for column in COLUMNS)),
+              default=','.join(DEFAULT_COLUMNS))
+@environment.pass_env
+def cli(env, columns, sortby, volume_id):
+    """List ACLs."""
+    block_manager = SoftLayer.BlockStorageManager(env.client)
+    access_list = block_manager.get_block_volume_access_list(
+        volume_id=volume_id)
+    table = formatting.Table(columns.columns)
+    table.sortby = sortby
+
+    for key, type_name in [('allowedVirtualGuests', 'VIRTUAL'),
+                           ('allowedHardware', 'HARDWARE'),
+                           ('allowedSubnets', 'SUBNET'),
+                           ('allowedIpAddresses', 'IP')]:
+        for obj in access_list.get(key, []):
+            obj['type'] = type_name
+            table.add_row([value or formatting.blank()
+                           for value in columns.row(obj)])
+
+    env.fout(table)
diff --git a/SoftLayer/CLI/block/cancel.py b/SoftLayer/CLI/block/cancel.py
new file mode 100644
index 00000000..a23cee00
--- /dev/null
+++ b/SoftLayer/CLI/block/cancel.py
@@ -0,0 +1,28 @@
+"""Cancel an existing iSCSI account."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import exceptions
+from SoftLayer.CLI import formatting
+
+
+@click.command()
+@click.argument('volume-id')
+@click.option('--reason', help="An optional reason for cancellation")
+@click.option('--immediate',
+              is_flag=True,
+              help="Cancels the block storage volume immediately instead "
+                   "of on the billing anniversary")
+@environment.pass_env
+def cli(env, volume_id, reason, immediate):
+    """Cancel an existing block storage volume."""
+
+    block_storage_manager = SoftLayer.BlockStorageManager(env.client)
+
+    if not (env.skip_confirmations or formatting.no_going_back(volume_id)):
+        raise exceptions.CLIAbort('Aborted')
+
+    block_storage_manager.cancel_block_volume(volume_id, reason, immediate)
diff --git a/SoftLayer/CLI/block/detail.py b/SoftLayer/CLI/block/detail.py
new file mode 100644
index 00000000..c98736ed
--- /dev/null
+++ b/SoftLayer/CLI/block/detail.py
@@ -0,0 +1,59 @@
+"""Display details for a specified volume."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer import utils
+
+
+@click.command()
+@click.argument('volume_id')
+@environment.pass_env
+def cli(env, volume_id):
+    """Display details for a specified volume."""
+    block_manager = SoftLayer.BlockStorageManager(env.client)
+    block_volume = block_manager.get_block_volume_details(volume_id)
+    block_volume = utils.NestedDict(block_volume)
+
+    table = formatting.KeyValueTable(['Name', 'Value'])
+    table.align['Name'] = 'r'
+    table.align['Value'] = 'l'
+
+    storage_type = block_volume['storageType']['keyName'].split('_').pop(0)
+    table.add_row(['ID', block_volume['id']])
+    table.add_row(['Username', block_volume['username']])
+    table.add_row(['Type', storage_type])
+    table.add_row(['Capacity (GB)', "%iGB" % block_volume['capacityGb']])
+    table.add_row(['LUN Id', "%s" % block_volume['lunId']])
+
+    if block_volume.get('iops'):
+        table.add_row(['IOPs', block_volume['iops']])
+
+    if block_volume.get('storageTierLevel'):
+        table.add_row([
+            'Endurance Tier',
+            block_volume['storageTierLevel']['description'],
+        ])
+
+    table.add_row([
+        'Data Center',
+        block_volume['serviceResource']['datacenter']['name'],
+    ])
+    table.add_row([
+        'Target IP',
+        block_volume['serviceResourceBackendIpAddress'],
+    ])
+
+    if block_volume['snapshotCapacityGb']:
+        table.add_row([
+            'Snapshot Capacity (GB)',
+            block_volume['snapshotCapacityGb'],
+        ])
+        table.add_row([
+            'Snapshot Used (Bytes)',
+            block_volume['parentVolume']['snapshotSizeBytes'],
+        ])
+
+    env.fout(table)
diff --git a/SoftLayer/CLI/block/list.py b/SoftLayer/CLI/block/list.py
new file mode 100644
index 00000000..40edcb7c
--- /dev/null
+++ b/SoftLayer/CLI/block/list.py
@@ -0,0 +1,66 @@
+"""List block storage volumes."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+import SoftLayer
+from SoftLayer.CLI import columns as column_helper
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+
+
+COLUMNS = [
+    column_helper.Column('id', ('id',), mask="id"),
+    column_helper.Column('username', ('username',), mask="username"),
+    column_helper.Column('datacenter',
+                         ('serviceResource', 'datacenter', 'name'),
+                         mask="serviceResource.datacenter.name"),
+    column_helper.Column(
+        'storage_type',
+        lambda b: b['storageType']['keyName'].split('_').pop(0),
+        mask="storageType.keyName"),
+    column_helper.Column('capacity_gb', ('capacityGb',), mask="capacityGb"),
+    column_helper.Column('bytes_used', ('bytesUsed',), mask="bytesUsed"),
+    column_helper.Column('ip_addr', ('serviceResourceBackendIpAddress',),
+                         mask="serviceResourceBackendIpAddress"),
+]
+
+DEFAULT_COLUMNS = [
+    'id',
+    'username',
+    'datacenter',
+    'storage_type',
+    'capacity_gb',
+    'bytes_used',
+    'ip_addr'
+]
+
+
+@click.command()
+@click.option('--username', '-u', help='Volume username')
+@click.option('--datacenter', '-d', help='Datacenter shortname')
+@click.option('--storage-type',
+              help='Type of storage volume',
+              type=click.Choice(['performance', 'endurance']))
+@click.option('--sortby', help='Column to sort by', default='username')
+@click.option('--columns',
+              callback=column_helper.get_formatter(COLUMNS),
+              help='Columns to display. Options: {0}'.format(
+                  ', '.join(column.name for column in COLUMNS)),
+              default=','.join(DEFAULT_COLUMNS))
+@environment.pass_env
+def cli(env, sortby, columns, datacenter, username, storage_type):
+    """List block storage."""
+    block_manager = SoftLayer.BlockStorageManager(env.client)
+    block_volumes = block_manager.list_block_volumes(datacenter=datacenter,
+                                                     username=username,
+                                                     storage_type=storage_type,
+                                                     mask=columns.mask())
+
+    table = formatting.Table(columns.columns)
+    table.sortby = sortby
+
+    for block_volume in block_volumes:
+        table.add_row([value or formatting.blank()
+                       for value in columns.row(block_volume)])
+
+    env.fout(table)
diff --git a/SoftLayer/CLI/block/order.py b/SoftLayer/CLI/block/order.py
new file mode 100644
index 00000000..f1840078
--- /dev/null
+++ b/SoftLayer/CLI/block/order.py
@@ -0,0 +1,99 @@
+"""Order a block storage volume."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import exceptions
+
+
+CONTEXT_SETTINGS = dict(token_normalize_func=lambda x: x.upper())
+
+
+@click.command(context_settings=CONTEXT_SETTINGS)
+@click.option('--storage-type',
+              help='Type of storage volume',
+              type=click.Choice(['performance', 'endurance']),
+              required=True)
+@click.option('--size',
+              type=int,
+              help='Size of storage volume in GB',
+              required=True)
+@click.option('--iops',
+              type=int,
+              help='Performance Storage IOPs,'
+              ' between 100 and 6000 in multiples of 100'
+              '  [required for storage-type performance]')
+@click.option('--tier',
+              help='Endurance Storage Tier (IOP per GB)'
+              '  [required for storage-type endurance]',
+              type=click.Choice(['0.25', '2', '4']))
+@click.option('--os-type',
+              help='Operating System',
+              type=click.Choice([
+                  'HYPER_V',
+                  'LINUX',
+                  'VMWARE',
+                  'WINDOWS_2008',
+                  'WINDOWS_GPT',
+                  'WINDOWS',
+                  'XEN']),
+              required=True)
+@click.option('--location',
+              help='Datacenter short name (e.g.: dal09)',
+              required=True)
+@environment.pass_env
+def cli(env, storage_type, size, iops, tier, os_type, location):
+    """Order a block storage volume."""
+    block_manager = SoftLayer.BlockStorageManager(env.client)
+    storage_type = storage_type.lower()
+
+    if storage_type == 'performance':
+        if iops is None:
+            raise exceptions.CLIAbort(
+                'Option --iops required with Performance')
+
+        if iops < 100 or iops > 6000:
+            raise exceptions.CLIAbort(
+                'Option --iops must be between 100 and 6000, inclusive')
+
+        if iops % 100 != 0:
+            raise exceptions.CLIAbort(
+                'Option --iops must be a multiple of 100'
+            )
+
+        try:
+            order = block_manager.order_block_volume(
+                storage_type='performance_storage_iscsi',
+                location=location,
+                size=size,
+                iops=iops,
+                os_type=os_type
+            )
+        except ValueError as ex:
+            raise exceptions.ArgumentError(str(ex))
+
+    if storage_type == 'endurance':
+        if tier is None:
+            raise exceptions.CLIAbort(
+                'Option --tier required with Endurance in IOPS/GB [0.25,2,4]')
+
+        try:
+            order = block_manager.order_block_volume(
+                storage_type='storage_service_enterprise',
+                location=location,
+                size=size,
+                tier_level=float(tier),
+                os_type=os_type
+            )
+        except ValueError as ex:
+            raise exceptions.ArgumentError(str(ex))
+
+    if 'placedOrder' in order.keys():
+        click.echo("Order #{0} placed successfully!".format(
+            order['placedOrder']['id']))
+        for item in order['placedOrder']['items']:
+            click.echo(" > %s" % item['description'])
+    else:
+        click.echo("Order could not be placed! Please verify your options " +
+                   "and try again.")
diff --git a/SoftLayer/CLI/block/snapshot_delete.py b/SoftLayer/CLI/block/snapshot_delete.py
new file mode 100644
index 00000000..6daa3f4d
--- /dev/null
+++ b/SoftLayer/CLI/block/snapshot_delete.py
@@ -0,0 +1,15 @@
+"""Create a block storage snapshot."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+import SoftLayer
+from SoftLayer.CLI import environment
+
+
+@click.command()
+@click.argument('snapshot_id')
+@environment.pass_env
+def cli(env, snapshot_id):
+    """Deletes a snapshot on a given volume"""
+    block_manager = SoftLayer.BlockStorageManager(env.client)
+    block_manager.delete_snapshot(snapshot_id)
diff --git a/SoftLayer/CLI/block/snapshot_list.py b/SoftLayer/CLI/block/snapshot_list.py
new file mode 100644
index 00000000..7ffea3f5
--- /dev/null
+++ b/SoftLayer/CLI/block/snapshot_list.py
@@ -0,0 +1,58 @@
+"""List block storage snapshots."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+import SoftLayer
+from SoftLayer.CLI import columns as column_helper
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+
+
+COLUMNS = [
+    column_helper.Column(
+        'id',
+        ('snapshots', 'id',),
+        mask='snapshots.id'),
+    column_helper.Column('name', ('snapshots', 'notes',),
+                         mask='snapshots.notes'),
+    column_helper.Column('created',
+                         ('snapshots', 'snapshotCreationTimestamp',),
+                         mask='snapshots.snapshotCreationTimestamp'),
+    column_helper.Column('size_bytes', ('snapshots', 'snapshotSizeBytes',),
+                         mask='snapshots.snapshotSizeBytes'),
+]
+
+DEFAULT_COLUMNS = [
+    'id',
+    'name',
+    'created',
+    'size_bytes'
+]
+
+
+@click.command()
+@click.argument('volume_id')
+@click.option('--sortby', help='Column to sort by',
+              default='created')
+@click.option('--columns',
+              callback=column_helper.get_formatter(COLUMNS),
+              help='Columns to display. Options: {0}'.format(
+                  ', '.join(column.name for column in COLUMNS)),
+              default=','.join(DEFAULT_COLUMNS))
+@environment.pass_env
+def cli(env, sortby, columns, volume_id):
+    """List block storage snapshots."""
+    block_manager = SoftLayer.BlockStorageManager(env.client)
+    snapshots = block_manager.get_block_volume_snapshot_list(
+        volume_id=volume_id,
+        mask=columns.mask(),
+    )
+
+    table = formatting.Table(columns.columns)
+    table.sortby = sortby
+
+    for snapshot in snapshots:
+        table.add_row([value or formatting.blank()
+                       for value in columns.row(snapshot)])
+
+    env.fout(table)
diff --git a/SoftLayer/CLI/routes.py b/SoftLayer/CLI/routes.py
index 26378fb4..a9aec3c9 100644
--- a/SoftLayer/CLI/routes.py
+++ b/SoftLayer/CLI/routes.py
@@ -56,6 +56,15 @@
     ('dns:zone-list', 'SoftLayer.CLI.dns.zone_list:cli'),
     ('dns:zone-print', 'SoftLayer.CLI.dns.zone_print:cli'),
 
+    ('block', 'SoftLayer.CLI.block'),
+    ('block:volume-list', 'SoftLayer.CLI.block.list:cli'),
+    ('block:volume-detail', 'SoftLayer.CLI.block.detail:cli'),
+    ('block:volume-cancel', 'SoftLayer.CLI.block.cancel:cli'),
+    ('block:volume-order', 'SoftLayer.CLI.block.order:cli'),
+    ('block:snapshot-list', 'SoftLayer.CLI.block.snapshot_list:cli'),
+    ('block:snapshot-delete', 'SoftLayer.CLI.block.snapshot_delete:cli'),
+    ('block:access-list', 'SoftLayer.CLI.block.access_list:cli'),
+
     ('firewall', 'SoftLayer.CLI.firewall'),
     ('firewall:add', 'SoftLayer.CLI.firewall.add:cli'),
     ('firewall:cancel', 'SoftLayer.CLI.firewall.cancel:cli'),
diff --git a/SoftLayer/CLI/virt/detail.py b/SoftLayer/CLI/virt/detail.py
index a4464e61..957cce83 100644
--- a/SoftLayer/CLI/virt/detail.py
+++ b/SoftLayer/CLI/virt/detail.py
@@ -90,19 +90,9 @@ def cli(env, identifier, passwords=False, price=False):
                        result['billingItem']['recurringFee']])
 
     if passwords:
-        pass_table = formatting.Table(['software', 'username', 'password'])
-
-        for component in result['softwareComponents']:
-            for item in component['passwords']:
-                pass_table.add_row([
-                    utils.lookup(component,
-                                 'softwareLicense',
-                                 'softwareDescription',
-                                 'name'),
-                    item['username'],
-                    item['password'],
-                ])
-
+        pass_table = formatting.Table(['username', 'password'])
+        for item in result['operatingSystem']['passwords']:
+            pass_table.add_row([item['username'], item['password']])
         table.add_row(['users', pass_table])
 
     table.add_row(['tags', formatting.tags(result['tagReferences'])])
diff --git a/SoftLayer/fixtures/SoftLayer_Account.py b/SoftLayer/fixtures/SoftLayer_Account.py
index a1fde3c6..b03c26d0 100644
--- a/SoftLayer/fixtures/SoftLayer_Account.py
+++ b/SoftLayer/fixtures/SoftLayer_Account.py
@@ -509,3 +509,23 @@
 
 getHubNetworkStorage = [{'id': 12345, 'username': 'SLOS12345-1'},
                         {'id': 12346, 'username': 'SLOS12345-2'}]
+
+getIscsiNetworkStorage = [{
+    'accountId': 1234,
+    'billingItem': {'id': 449},
+    'capacityGb': 20,
+    'createDate': '2015:50:15-04:00',
+    'guestId': '',
+    'hardwareId': '',
+    'hostId': '',
+    'id': 100,
+    'nasType': 'ISCSI',
+    'notes': """{'status': 'available'}""",
+    'password': '',
+    'serviceProviderId': 1,
+    'serviceResource': {'datacenter': {'id': 449500}},
+    'serviceResourceBackendIpAddress': '10.1.2.3',
+    'serviceResourceName': 'Storage Type 01 Aggregate staaspar0101_pc01',
+    'username': 'username',
+    'storageType': {'keyName': 'ENDURANCE_STORAGE'},
+}]
diff --git a/SoftLayer/fixtures/SoftLayer_Network_Storage.py b/SoftLayer/fixtures/SoftLayer_Network_Storage.py
new file mode 100644
index 00000000..0257b167
--- /dev/null
+++ b/SoftLayer/fixtures/SoftLayer_Network_Storage.py
@@ -0,0 +1,65 @@
+getObject = {
+    'accountId': 1234,
+    'billingItem': {'id': 449},
+    'capacityGb': 20,
+    'createDate': '2015:50:15-04:00',
+    'guestId': '',
+    'hardwareId': '',
+    'hostId': '',
+    'id': 100,
+    'nasType': 'ISCSI',
+    'notes': """{'status': 'available'}""",
+    'password': '',
+    'serviceProviderId': 1,
+    'iops': 1000,
+    'storageTierLevel': {'description': 'Tier 1'},
+    'snapshotCapacityGb': 10,
+    'parentVolume': {'snapshotSizeBytes': 1024},
+    'serviceResource': {'datacenter': {'id': 449500, 'name': 'dal05'}},
+    'serviceResourceBackendIpAddress': '10.1.2.3',
+    'serviceResourceName': 'Storage Type 01 Aggregate staaspar0101_pc01',
+    'username': 'username',
+    'storageType': {'keyName': 'ENDURANCE_STORAGE'},
+    'allowedVirtualGuests': [{
+        'id': 1234,
+        'hostname': 'test-server',
+        'domain': 'example.com',
+        'primaryBackendIpAddress': '10.0.0.1',
+        'allowedHost': {
+            'name': 'test-server',
+            'credential': {'username': 'joe', 'password': '12345'},
+        },
+    }],
+    'lunId': 2,
+    'allowedHardware': [{
+        'id': 1234,
+        'hostname': 'test-server',
+        'domain': 'example.com',
+        'primaryBackendIpAddress': '10.0.0.2',
+        'allowedHost': {
+            'name': 'test-server',
+            'credential': {'username': 'joe', 'password': '12345'},
+        },
+    }],
+    'allowedSubnets': [{
+        'id': 1234,
+        'networkIdentifier': '10.0.0.1',
+        'cidr': '24',
+        'note': 'backend subnet',
+        'allowedHost': {
+            'name': 'test-server',
+            'credential': {'username': 'joe', 'password': '12345'},
+        },
+    }],
+    'allowedIpAddresses': [{
+        'id': 1234,
+        'ipAddress': '10.0.0.1',
+        'note': 'backend ip',
+        'allowedHost': {
+            'name': 'test-server',
+            'credential': {'username': 'joe', 'password': '12345'},
+        },
+    }],
+}
+getSnapshots = []
+deleteObject = True
diff --git a/SoftLayer/fixtures/SoftLayer_Virtual_Guest.py b/SoftLayer/fixtures/SoftLayer_Virtual_Guest.py
index cb067b44..43800962 100644
--- a/SoftLayer/fixtures/SoftLayer_Virtual_Guest.py
+++ b/SoftLayer/fixtures/SoftLayer_Virtual_Guest.py
@@ -40,11 +40,6 @@
             'softwareDescription': {'version': '12.04-64 Minimal for VSI',
                                     'name': 'Ubuntu'}}
     },
-    'softwareComponents': [{
-        'passwords': [{'username': 'user', 'password': 'pass'}],
-        'softwareLicense': {
-            'softwareDescription': {'name': 'Ubuntu'}}
-    }],
     'tagReferences': [{'tag': {'name': 'production'}}],
 }
 
diff --git a/SoftLayer/managers/__init__.py b/SoftLayer/managers/__init__.py
index ff8b040f..cab39fbb 100644
--- a/SoftLayer/managers/__init__.py
+++ b/SoftLayer/managers/__init__.py
@@ -7,6 +7,7 @@
 
     :license: MIT, see LICENSE for more details.
 """
+from SoftLayer.managers.block import BlockStorageManager
 from SoftLayer.managers.cdn import CDNManager
 from SoftLayer.managers.dns import DNSManager
 from SoftLayer.managers.firewall import FirewallManager
@@ -25,6 +26,7 @@
 from SoftLayer.managers.vs import VSManager
 
 __all__ = [
+    'BlockStorageManager',
     'CDNManager',
     'DNSManager',
     'FirewallManager',
diff --git a/SoftLayer/managers/block.py b/SoftLayer/managers/block.py
new file mode 100644
index 00000000..906496ee
--- /dev/null
+++ b/SoftLayer/managers/block.py
@@ -0,0 +1,403 @@
+"""
+    SoftLayer.block
+    ~~~~~~~~~~~~~~~
+    Block Storage Manager
+
+    :license: MIT, see LICENSE for more details.
+"""
+from SoftLayer import exceptions
+from SoftLayer import utils
+
+
+ENDURANCE_TIERS = {
+    0.25: 100,
+    2: 200,
+    4: 300,
+}
+
+
+class BlockStorageManager(utils.IdentifierMixin, object):
+    """Manages Block Storage volumes."""
+
+    def __init__(self, client):
+        self.configuration = {}
+        self.client = client
+
+    def list_block_volumes(self, datacenter=None, username=None,
+                           storage_type=None, **kwargs):
+        """Returns a list of block volumes.
+
+        :param datacenter: Datacenter short name (e.g.: dal09)
+        :param username: Name of volume.
+        :param storage_type: Type of volume: Endurance or Performance
+        :param kwargs:
+        :return: Returns a list of block volumes.
+        """
+        if 'mask' not in kwargs:
+            items = [
+                'id',
+                'username',
+                'capacityGb',
+                'bytesUsed',
+                'serviceResource.datacenter[name]',
+                'serviceResourceBackendIpAddress'
+            ]
+            kwargs['mask'] = ','.join(items)
+
+        _filter = utils.NestedDict(kwargs.get('filter') or {})
+
+        _filter['iscsiNetworkStorage']['serviceResource']['type']['type'] = \
+            (utils.query_filter('!~ ISCSI'))
+
+        _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))
+
+        if username:
+            _filter['iscsiNetworkStorage']['username'] = \
+                (utils.query_filter(username))
+
+        kwargs['filter'] = _filter.to_dict()
+        return self.client.call('Account', 'getIscsiNetworkStorage', **kwargs)
+
+    def get_block_volume_details(self, volume_id, **kwargs):
+        """Returns details about the specified volume.
+
+        :param volume_id: ID of volume.
+        :param kwargs:
+        :return: Returns details about the specified volume.
+        """
+
+        if 'mask' not in kwargs:
+            items = [
+                'id',
+                'username',
+                'password',
+                'capacityGb',
+                'snapshotCapacityGb',
+                'parentVolume.snapshotSizeBytes',
+                'storageType.keyName',
+                'serviceResource.datacenter[name]',
+                'serviceResourceBackendIpAddress',
+                'storageTierLevel',
+                'iops',
+                'lunId',
+            ]
+            kwargs['mask'] = ','.join(items)
+        return self.client.call('Network_Storage', 'getObject',
+                                id=volume_id, **kwargs)
+
+    def get_block_volume_access_list(self, volume_id, **kwargs):
+        """Returns a list of authorized hosts for a specified volume.
+
+        :param volume_id: ID of volume.
+        :param kwargs:
+        :return: Returns a list of authorized hosts for a specified volume.
+        """
+        if 'mask' not in kwargs:
+            items = [
+                'id',
+                'allowedVirtualGuests[allowedHost[credential]]',
+                'allowedHardware[allowedHost[credential]]',
+                'allowedSubnets[allowedHost[credential]]',
+                'allowedIpAddresses[allowedHost[credential]]',
+            ]
+            kwargs['mask'] = ','.join(items)
+        return self.client.call('Network_Storage', 'getObject',
+                                id=volume_id, **kwargs)
+
+    def get_block_volume_snapshot_list(self, volume_id, **kwargs):
+        """Returns a list of snapshots for the specified volume.
+
+        :param volume_id: ID of volume.
+        :param kwargs:
+        :return: Returns a list of snapshots for the specified volume.
+        """
+        if 'mask' not in kwargs:
+            items = '''snapshots[
+    id,
+    notes,
+    snapshotSizeBytes,
+    storageType[keyName],
+    snapshotCreationTimestamp
+    hourlySchedule,
+    dailySchedule,
+    weeklySchedule
+]'''
+            kwargs['mask'] = ','.join(items)
+
+        return self.client.call('Network_Storage', 'getSnapshots',
+                                id=volume_id, **kwargs)
+
+    def delete_snapshot(self, snapshot_id):
+        """Deletes the specified snapshot object.
+
+        :param snapshot_id: The ID of the snapshot object to delete.
+        """
+        return self.client.call('Network_Storage', 'deleteObject',
+                                id=snapshot_id)
+
+    def order_block_volume(self, storage_type, location, size, os_type,
+                           iops=None, tier_level=None):
+        """Places an order for a block volume.
+
+        :param storage_type: "performance_storage_iscsi" (performance)
+                             or "storage_service_enterprise" (endurance)
+        :param location: Datacenter in which to order iSCSI volume
+        :param size: Size of the desired volume, in GB
+        :param os_type: OS Type to use for volume alignment, see help for list
+        :param iops: Number of IOPs for a "Performance" order
+        :param tier_level: Tier level to use for an "Endurance" order
+        """
+
+        try:
+            location_id = self._get_location_id(location)
+        except ValueError:
+            raise exceptions.SoftLayerError(
+                "Invalid datacenter name specified. "
+                "Please provide the lower case short name (e.g.: dal09)")
+
+        base_type_name = 'SoftLayer_Container_Product_Order_Network_'
+        package = self._get_package(storage_type)
+        if storage_type == 'performance_storage_iscsi':
+            complex_type = base_type_name + 'PerformanceStorage_Iscsi'
+            prices = [
+                _find_performance_block_price(package),
+                _find_performance_space_price(package, iops),
+                _find_performance_iops_price(package, size, iops),
+            ]
+        elif storage_type == 'storage_service_enterprise':
+            complex_type = base_type_name + 'Storage_Enterprise'
+            prices = [
+                _find_endurance_block_price(package),
+                _find_endurance_price(package),
+                _find_endurance_space_price(package, size, tier_level),
+                _find_endurance_tier_price(package, tier_level),
+            ]
+        else:
+            raise exceptions.SoftLayerError(
+                "storage_type must be either Performance or Endurance")
+
+        order = {
+            'complexType': complex_type,
+            'packageId': package['id'],
+            'osFormatType': {'keyName': os_type},
+            'prices': prices,
+            'quantity': 1,
+            'location': location_id,
+        }
+
+        return self.client.call('Product_Order', 'placeOrder', order)
+
+    def _get_package(self, category_code):
+        """Returns a product packaged based on type of storage.
+
+        :param category_code: Category code of product package.
+        :return: Returns a packaged based on type of storage.
+        """
+
+        _filter = utils.NestedDict({})
+        _filter['categories']['categoryCode'] = (
+            utils.query_filter(category_code))
+        _filter['statusCode'] = (utils.query_filter('ACTIVE'))
+
+        packages = self.client.call('Product_Package', 'getAllObjects',
+                                    filter=_filter.to_dict(),
+                                    mask="""
+id,
+name,
+items[prices[categories],attributes]
+""")
+        if len(packages) == 0:
+            raise ValueError('No packages were found for %s' % category_code)
+        if len(packages) > 1:
+            raise ValueError('More than one package was found for %s'
+                             % category_code)
+
+        return packages[0]
+
+    def _get_location_id(self, location):
+        """Returns location id
+
+        :param location: Datacenter short name
+        :return: Returns location id
+        """
+        loc_svc = self.client['Location_Datacenter']
+        datacenters = loc_svc.getDatacenters(mask='mask[longName,id,name]')
+        for datacenter in datacenters:
+            if datacenter['name'] == location:
+                location = datacenter['id']
+                return location
+        raise ValueError('Invalid datacenter name specified.')
+
+    def cancel_block_volume(self, volume_id,
+                            reason='No longer needed',
+                            immediate=False):
+        """Cancels the given block storage volume.
+
+        :param integer volume_id: the volume ID
+        :param string reason: The reason for cancellation
+        :param boolean immediate_flag: Cancel immediately or
+        on anniversary date
+        """
+        block_volume = self.get_block_volume_details(
+            volume_id,
+            mask='mask[id,billingItem[id]]')
+        billing_item_id = block_volume['billingItem']['id']
+
+        self.client['Billing_Item'].cancelItem(
+            immediate,
+            True,
+            reason,
+            id=billing_item_id)
+
+
+def _find_endurance_block_price(package):
+    for item in package['items']:
+        for price in item['prices']:
+            if price['locationGroupId'] != '':
+                continue
+
+            if not _has_category(price['categories'], 'storage_block'):
+                continue
+
+            return price
+
+    raise ValueError("Could not find price for block storage")
+
+
+def _find_endurance_price(package):
+    for item in package['items']:
+        for price in item['prices']:
+            if price['locationGroupId'] != '':
+                continue
+
+            if not _has_category(price['categories'],
+                                 'storage_service_enterprise'):
+                continue
+
+            return price
+
+    raise ValueError("Could not find price for endurance block storage")
+
+
+def _find_endurance_space_price(package, size, tier_level):
+    for item in package['items']:
+        if int(item['capacity']) != size:
+            continue
+
+        for price in item['prices']:
+            # Only collect prices from valid location groups.
+            if price['locationGroupId'] != '':
+                continue
+
+            if not _has_category(price['categories'],
+                                 'performance_storage_space'):
+                continue
+
+            level = ENDURANCE_TIERS.get(tier_level)
+            if level < int(price['capacityRestrictionMinimum']):
+                continue
+
+            if level > int(price['capacityRestrictionMaximum']):
+                continue
+
+            return price
+
+    raise ValueError("Could not find price for disk space")
+
+
+def _find_endurance_tier_price(package, tier_level):
+    for item in package['items']:
+        for attribute in item.get('attributes', []):
+            if int(attribute['value']) == ENDURANCE_TIERS.get(tier_level):
+                break
+        else:
+            continue
+
+        for price in item['prices']:
+            # Only collect prices from valid location groups.
+            if price['locationGroupId'] != '':
+                continue
+
+            if not _has_category(price['categories'], 'storage_tier_level'):
+                continue
+
+            return price
+
+    raise ValueError("Could not find price for tier")
+
+
+def _find_performance_block_price(package):
+    for item in package['items']:
+        for price in item['prices']:
+            # Only collect prices from valid location groups.
+            if price['locationGroupId'] != '':
+                continue
+
+            if not _has_category(price['categories'],
+                                 'performance_storage_iscsi'):
+                continue
+
+            return price
+
+    raise ValueError("Could not find price for performance storage")
+
+
+def _find_performance_space_price(package, size):
+    for item in package['items']:
+        if int(item['capacity']) != size:
+            continue
+
+        for price in item['prices']:
+            # Only collect prices from valid location groups.
+            if price['locationGroupId'] != '':
+                continue
+
+            if not _has_category(price['categories'],
+                                 'performance_storage_space'):
+                continue
+
+            return price
+
+    raise ValueError("Could not find price for disk space")
+
+
+def _find_performance_iops_price(package, size, iops):
+    for item in package['items']:
+        if int(item['capacity']) != int(iops):
+            continue
+
+        for price in item['prices']:
+            # Only collect prices from valid location groups.
+            if price['locationGroupId'] != '':
+                continue
+
+            if not _has_category(price['categories'],
+                                 'performance_storage_iops'):
+                continue
+
+            if size < int(price['capacityRestrictionMinimum']):
+                continue
+
+            if size > int(price['capacityRestrictionMaximum']):
+                continue
+
+            return price
+
+    raise ValueError("Could not find price for iops")
+
+
+def _has_category(categories, category_code):
+    return any(
+        True
+        for category
+        in categories
+        if category['categoryCode'] == category_code
+    )
diff --git a/SoftLayer/managers/vs.py b/SoftLayer/managers/vs.py
index 25090e07..831eca71 100644
--- a/SoftLayer/managers/vs.py
+++ b/SoftLayer/managers/vs.py
@@ -200,9 +200,6 @@ def get_instance(self, instance_id, **kwargs):
                 'blockDevices',
                 'blockDeviceTemplateGroup[id, name, globalIdentifier]',
                 'postInstallScriptUri',
-                '''softwareComponents[
-                    passwords[username,password,notes],
-                    softwareLicense[softwareDescription[name]]]''',
                 '''operatingSystem[passwords[username,password],
                                    softwareLicense.softwareDescription[
                                        manufacturer,name,version,
diff --git a/tox.ini b/tox.ini
index edf21817..41a70f0e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,15 +2,12 @@
 envlist = py27,py33,py34,py35,pypy,analysis,coverage
 
 [testenv]
-setenv =
-    LANG=C.UTF-8
-    LC_ALL=C.UTF-8
 deps = -r{toxinidir}/tools/test-requirements.txt
-commands = py.test tests
+commands = py.test {posargs:tests}
 
 [testenv:coverage]
 basepython = python2.7
-commands = py.test tests \
+commands = py.test {posargs:tests} \
            --cov=SoftLayer \
            --cov-fail-under=77 \
            --cov-report=html \


Test patch:
diff --git a/tests/CLI/modules/block_tests.py b/tests/CLI/modules/block_tests.py
new file mode 100644
index 00000000..49368e14
--- /dev/null
+++ b/tests/CLI/modules/block_tests.py
@@ -0,0 +1,98 @@
+"""
+    SoftLayer.tests.CLI.modules.block_tests
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    :license: MIT, see LICENSE for more details.
+"""
+from SoftLayer import testing
+
+import json
+
+
+class BlockTests(testing.TestCase):
+
+    def test_access_list(self):
+        result = self.run_command(['block', 'access-list', '1234'])
+
+        self.assertEqual(result.exit_code, 0)
+        self.assertEqual([
+            {
+                'username': 'joe',
+                'name': 'test-server.example.com',
+                'type': 'VIRTUAL',
+                'host_iqn': 'test-server',
+                'password': '12345',
+                'private_ip_address': '10.0.0.1',
+                'id': 1234,
+            },
+            {
+                'username': 'joe',
+                'name': 'test-server.example.com',
+                'type': 'HARDWARE',
+                'host_iqn': 'test-server',
+                'password': '12345',
+                'private_ip_address': '10.0.0.2',
+                'id': 1234,
+            },
+            {
+                'username': 'joe',
+                'name': '10.0.0.1/24 (backend subnet)',
+                'type': 'SUBNET',
+                'host_iqn': 'test-server',
+                'password': '12345',
+                'private_ip_address': None,
+                'id': 1234,
+            },
+            {
+                'username': 'joe',
+                'name': '10.0.0.1 (backend ip)',
+                'type': 'IP',
+                'host_iqn': 'test-server',
+                'password': '12345',
+                'private_ip_address': None,
+                'id': 1234,
+            }],
+            json.loads(result.output),)
+
+    def test_volume_cancel(self):
+        result = self.run_command([
+            '--really', 'block', 'volume-cancel', '1234'])
+
+        self.assertEqual(result.exit_code, 0)
+        self.assertEqual("", result.output)
+        self.assert_called_with('SoftLayer_Billing_Item', 'cancelItem',
+                                args=(False, True, None))
+
+    def test_volume_detail(self):
+        result = self.run_command(['block', 'volume-detail', '1234'])
+
+        self.assertEqual(result.exit_code, 0)
+        self.assertEqual({
+            'Username': 'username',
+            'LUN Id': '2',
+            'Endurance Tier': 'Tier 1',
+            'IOPs': 1000,
+            'Snapshot Capacity (GB)': 10,
+            'Snapshot Used (Bytes)': 1024,
+            'Capacity (GB)': '20GB',
+            'Target IP': '10.1.2.3',
+            'Data Center': 'dal05',
+            'Type': 'ENDURANCE',
+            'ID': 100,
+        }, json.loads(result.output))
+
+    def test_volume_list(self):
+        result = self.run_command(['block', 'volume-list'])
+
+        self.assertEqual(result.exit_code, 0)
+        self.assertEqual([
+            {
+                'bytes_used': None,
+                'capacity_gb': 20,
+                'datacenter': None,
+                'id': 100,
+                'ip_addr': '10.1.2.3',
+                'storage_type': 'ENDURANCE',
+                'username': 'username'
+            }],
+            json.loads(result.output))
diff --git a/tests/CLI/modules/vs_tests.py b/tests/CLI/modules/vs_tests.py
index 4eb31578..573de5fe 100644
--- a/tests/CLI/modules/vs_tests.py
+++ b/tests/CLI/modules/vs_tests.py
@@ -61,9 +61,7 @@ def test_detail_vs(self):
                           'public_ip': '172.16.240.2',
                           'state': 'RUNNING',
                           'status': 'ACTIVE',
-                          'users': [{'software': 'Ubuntu',
-                                     'password': 'pass',
-                                     'username': 'user'}],
+                          'users': [{'password': 'pass', 'username': 'user'}],
                           'vlans': [{'type': 'PUBLIC',
                                      'number': 23,
                                      'id': 1}],
diff --git a/tests/managers/block_tests.py b/tests/managers/block_tests.py
new file mode 100644
index 00000000..a6b6f866
--- /dev/null
+++ b/tests/managers/block_tests.py
@@ -0,0 +1,212 @@
+"""
+    SoftLayer.tests.managers.block_tests
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    :license: MIT, see LICENSE for more details.
+"""
+
+import SoftLayer
+from SoftLayer import fixtures
+from SoftLayer import testing
+
+
+class BlockTests(testing.TestCase):
+    def set_up(self):
+        self.block = SoftLayer.BlockStorageManager(self.client)
+
+    def test_cancel_block_volume_immediately(self):
+        self.block.cancel_block_volume(123, immediate=True)
+
+        self.assert_called_with('SoftLayer_Billing_Item', 'cancelItem',
+                                args=(True, True, 'No longer needed'),
+                                identifier=449)
+
+    def test_get_block_volume_details(self):
+        result = self.block.get_block_volume_details(100)
+
+        self.assertEqual(fixtures.SoftLayer_Network_Storage.getObject,
+                         result)
+        self.assert_called_with('SoftLayer_Network_Storage', 'getObject',
+                                identifier=100)
+
+    def test_list_block_volumes(self):
+        result = self.block.list_block_volumes()
+
+        self.assertEqual(fixtures.SoftLayer_Account.getIscsiNetworkStorage,
+                         result)
+        self.assert_called_with('SoftLayer_Account', 'getIscsiNetworkStorage')
+
+    def test_get_block_volume_access_list(self):
+        result = self.block.get_block_volume_access_list(100)
+
+        self.assertEqual(fixtures.SoftLayer_Network_Storage.getObject,
+                         result)
+        self.assert_called_with('SoftLayer_Network_Storage', 'getObject',
+                                identifier=100)
+
+    def test_get_block_volume_snapshot_list(self):
+        result = self.block.get_block_volume_snapshot_list(100)
+
+        self.assertEqual(fixtures.SoftLayer_Network_Storage.getSnapshots,
+                         result)
+        self.assert_called_with('SoftLayer_Network_Storage', 'getSnapshots',
+                                identifier=100)
+
+    def test_delete_snapshot(self):
+        result = self.block.delete_snapshot(100)
+
+        self.assertEqual(fixtures.SoftLayer_Network_Storage.deleteObject,
+                         result)
+        self.assert_called_with('SoftLayer_Network_Storage', 'deleteObject',
+                                identifier=100)
+
+    def test_order_block_volume_no_package(self):
+        mock = self.set_mock('SoftLayer_Product_Package', 'getAllObjects')
+        mock.return_value = []
+
+        self.assertRaises(
+            ValueError,
+            self.block.order_block_volume,
+            "performance_storage_iscsi", "dal05", 100, "LINUX", iops=100,
+        )
+
+    def test_order_block_volume_too_many_packages(self):
+        mock = self.set_mock('SoftLayer_Product_Package', 'getAllObjects')
+        mock.return_value = [{}, {}]
+
+        self.assertRaises(
+            ValueError,
+            self.block.order_block_volume,
+            "performance_storage_iscsi", "dal05", 100, "LINUX", iops=100,
+        )
+
+    def test_order_block_volume_performance(self):
+        mock = self.set_mock('SoftLayer_Product_Package', 'getAllObjects')
+        mock.return_value = [{
+            'id': 1,
+            'name': 'Performance',
+            'items': [{
+                'capacity': '1',
+                'prices': [{
+                    'id': 1,
+                    'locationGroupId': '',
+                    'categories': [{
+                        'categoryCode': 'performance_storage_iscsi',
+                    }],
+                }],
+            }, {
+                'capacity': '100',
+                'prices': [{
+                    'id': 2,
+                    'locationGroupId': '',
+                    'categories': [{
+                        'categoryCode': 'performance_storage_space',
+                    }],
+                }],
+            }, {
+                'capacity': '100',
+                'prices': [{
+                    'id': 3,
+                    'locationGroupId': '',
+                    'categories': [{
+                        'categoryCode': 'performance_storage_iops',
+                    }],
+                    'capacityRestrictionMinimum': '100',
+                    'capacityRestrictionMaximum': '100',
+                }],
+            }],
+        }]
+
+        result = self.block.order_block_volume(
+            "performance_storage_iscsi", "dal05", 100, "LINUX", iops=100)
+
+        self.assertEqual(
+            result,
+            {
+                'orderDate': '2013-08-01 15:23:45',
+                'orderId': 1234,
+                'prices': [{
+                    'hourlyRecurringFee': '2',
+                    'id': 1,
+                    'item': {'description': 'this is a thing', 'id': 1},
+                    'laborFee': '2',
+                    'oneTimeFee': '2',
+                    'oneTimeFeeTax': '.1',
+                    'quantity': 1,
+                    'recurringFee': '2',
+                    'recurringFeeTax': '.1',
+                    'setupFee': '1'}],
+                },
+            )
+
+    def test_order_block_volume_endurance(self):
+        mock = self.set_mock('SoftLayer_Product_Package', 'getAllObjects')
+        mock.return_value = [{
+            'id': 1,
+            'name': 'Performance',
+            'items': [{
+                'capacity': '1',
+                'prices': [{
+                    'id': 1,
+                    'locationGroupId': '',
+                    'categories': [{
+                        'categoryCode': 'storage_block',
+                    }],
+                }],
+            }, {
+                'capacity': '1',
+                'prices': [{
+                    'id': 2,
+                    'locationGroupId': '',
+                    'categories': [{
+                        'categoryCode': 'storage_service_enterprise',
+                    }],
+                }],
+            }, {
+                'capacity': '100',
+                'prices': [{
+                    'id': 3,
+                    'locationGroupId': '',
+                    'categories': [{
+                        'categoryCode': 'performance_storage_space',
+                    }],
+                    'capacityRestrictionMinimum': '100',
+                    'capacityRestrictionMaximum': '100',
+                }],
+            }, {
+                'capacity': '100',
+                'attributes': [{
+                    'value': '100',
+                }],
+                'prices': [{
+                    'id': 4,
+                    'locationGroupId': '',
+                    'categories': [{
+                        'categoryCode': 'storage_tier_level',
+                    }],
+                }],
+            }],
+        }]
+
+        result = self.block.order_block_volume(
+            "storage_service_enterprise", "dal05", 100, "LINUX",
+            tier_level=0.25)
+
+        self.assertEqual(
+            result,
+            {
+                'orderDate': '2013-08-01 15:23:45',
+                'orderId': 1234,
+                'prices': [{
+                    'hourlyRecurringFee': '2',
+                    'id': 1,
+                    'item': {'description': 'this is a thing', 'id': 1},
+                    'laborFee': '2',
+                    'oneTimeFee': '2',
+                    'oneTimeFeeTax': '.1',
+                    'quantity': 1,
+                    'recurringFee': '2',
+                    'recurringFeeTax': '.1',
+                    'setupFee': '1'}],
+                },
+            )