Repository: softlayer/softlayer-python
Pull request #1756 (2022-09-23 20:31:11)

New Command: Hardware notifications
[#issue1752](https://github.com/softlayer/softlayer-python/issues/1752)

![image](https://user-images.githubusercontent.com/22535437/192051752-efade4ab-507b-4dec-b0d7-ff4d27919ae3.png)



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef9cc56c..c6b5e22b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,25 +1,5 @@
 # Change Log
 
-## [6.1.2] - 2022-09-23
-
-#### What's Changed
-* Snapcraft: Updated to Core22 and  add homeishome-launch by @kz6fittycent in https://github.com/softlayer/softlayer-python/pull/1740
-* Add status, create date and domain columns in `slcli vs list command` by @BrianSantivanez in https://github.com/softlayer/softlayer-python/pull/1728
-* New command: ipsec cancel by @caberos in https://github.com/softlayer/softlayer-python/pull/1729
-* New command: subnet clear-route by @caberos in https://github.com/softlayer/softlayer-python/pull/1738
-* Deprecate slcli hw guests by @caberos in https://github.com/softlayer/softlayer-python/pull/1736
-* Remove real usersnames from test fixtrues by @caberos in https://github.com/softlayer/softlayer-python/pull/1743
-* Fix tox request.get hangout issue by @caberos in https://github.com/softlayer/softlayer-python/pull/1746
-* add vs user-access command by @caberos in https://github.com/softlayer/softlayer-python/pull/1741
-* Update Help message for commands that take in multiple arguments by @caberos in https://github.com/softlayer/softlayer-python/pull/1748
-* Error with slcli order item-list by @caberos in https://github.com/softlayer/softlayer-python/pull/1751
-* deprecate sl `autoscale` by @BrianSantivanez in https://github.com/softlayer/softlayer-python/pull/1753
-* Unhandled error running a subcommand in slcli by @caberos in https://github.com/softlayer/softlayer-python/pull/1754
-
-
-**Full Changelog**: https://github.com/softlayer/softlayer-python/compare/v6.1.1...v6.1.2
-
-
 ## [6.1.1] - 2022-08-18
 
 #### What's Changed
diff --git a/SoftLayer/CLI/autoscale/create.py b/SoftLayer/CLI/autoscale/create.py
new file mode 100644
index 00000000..0ca1fd20
--- /dev/null
+++ b/SoftLayer/CLI/autoscale/create.py
@@ -0,0 +1,138 @@
+"""Order/Create a scale group."""
+# :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
+from SoftLayer.CLI import helpers
+from SoftLayer.managers.autoscale import AutoScaleManager
+
+
+@click.command()
+@click.option('--name', required=True, prompt=True, help="Scale group's name.")
+@click.option('--cooldown', required=True, prompt=True, type=click.INT,
+              help="The number of seconds this group will wait after lastActionDate before performing another action.")
+@click.option('--min', 'minimum', required=True, prompt=True, type=click.INT, help="Set the minimum number of guests")
+@click.option('--max', 'maximum', required=True, prompt=True, type=click.INT, help="Set the maximum number of guests")
+@click.option('--regional', required=True, prompt=True, type=click.INT,
+              help="The identifier of the regional group this scaling group is assigned to.")
+@click.option('--postinstall', '-i', help="Post-install script to download")
+@click.option('--os', '-o', required=True, prompt=True, help="OS install code. Tip: you can specify <OS>_LATEST")
+@click.option('--datacenter', '-d', required=True, prompt=True, help="Datacenter shortname")
+@click.option('--hostname', '-H', required=True, prompt=True, help="Host portion of the FQDN")
+@click.option('--domain', '-D', required=True, prompt=True, help="Domain portion of the FQDN")
+@click.option('--cpu', required=True, prompt=True, type=click.INT,
+              help="Number of CPUs for new guests (existing not effected")
+@click.option('--memory', required=True, prompt=True, type=click.INT,
+              help="RAM in MB or GB for new guests (existing not effected")
+@click.option('--policy-relative', required=True, prompt=True,
+              help="The type of scale to perform(ABSOLUTE, PERCENT, RELATIVE).")
+@click.option('--termination-policy',
+              help="The termination policy for the group(CLOSEST_TO_NEXT_CHARGE=1, NEWEST=2, OLDEST=3).")
+@click.option('--policy-name', help="Collection of policies for this group. This can be empty.")
+@click.option('--policy-amount', help="The number to scale by. This number has different meanings based on type.")
+@click.option('--userdata', help="User defined metadata string")
+@helpers.multi_option('--key', '-k', help="SSH keys to add to the root user")
+@helpers.multi_option('--disk', required=True, prompt=True, help="Disk sizes")
+@environment.pass_env
+def cli(env, **args):
+    """Order/Create a scale group.
+
+    E.g.
+
+    'slcli autoscale create --name test --cooldown 3600 --min 1 --max 2 -o CENTOS_7_64 --datacenter dal10
+    --termination-policy 2 -H testvs -D test.com --cpu 2 --memory 1024 --policy-relative absolute
+    --policy-name policytest --policy-amount 3 --regional 102 --disk 25 --disk 30 --disk 25'
+
+    """
+    scale = AutoScaleManager(env.client)
+    network = SoftLayer.NetworkManager(env.client)
+
+    datacenter = network.get_datacenter(args.get('datacenter'))
+
+    ssh_keys = []
+    for key in args.get('key'):
+        resolver = SoftLayer.SshKeyManager(env.client).resolve_ids
+        key_id = helpers.resolve_id(resolver, key, 'SshKey')
+        ssh_keys.append(key_id)
+    scale_actions = [
+        {
+            "amount": args['policy_amount'],
+            "scaleType": args['policy_relative']
+        }
+    ]
+    policy_template = {
+        'name': args['policy_name'],
+        'scaleActions': scale_actions
+
+    }
+    policies = []
+    policies.append(policy_template)
+    block = []
+    number_disk = 0
+    for guest_disk in args['disk']:
+        if number_disk == 1:
+            number_disk = 2
+        disks = {'diskImage': {'capacity': guest_disk}, 'device': number_disk}
+        block.append(disks)
+        number_disk += 1
+
+    virt_template = {
+        'localDiskFlag': False,
+        'domain': args['domain'],
+        'hostname': args['hostname'],
+        'sshKeys': ssh_keys,
+        'postInstallScriptUri': args.get('postinstall'),
+        'operatingSystemReferenceCode': args['os'],
+        'maxMemory': args.get('memory'),
+        'datacenter': {'id': datacenter[0]['id']},
+        'startCpus': args.get('cpu'),
+        'blockDevices': block,
+        'hourlyBillingFlag': True,
+        'privateNetworkOnlyFlag': False,
+        'networkComponents': [{'maxSpeed': 100}],
+        'typeId': 1,
+        'userData': [{
+            'value': args.get('userdata')
+        }],
+        'networkVlans': [],
+
+    }
+
+    order = {
+        'name': args['name'],
+        'cooldown': args['cooldown'],
+        'maximumMemberCount': args['maximum'],
+        'minimumMemberCount': args['minimum'],
+        'regionalGroupId': args['regional'],
+        'suspendedFlag': False,
+        'balancedTerminationFlag': False,
+        'virtualGuestMemberTemplate': virt_template,
+        'virtualGuestMemberCount': 0,
+        'policies': policies,
+        'terminationPolicyId': args['termination_policy']
+    }
+
+    if not (env.skip_confirmations or formatting.confirm(
+            "This action will incur charges on your account. Continue?")):
+        raise exceptions.CLIAbort('Aborting scale group order.')
+
+    result = scale.create(order)
+
+    table = formatting.KeyValueTable(['name', 'value'])
+    vsi_table = formatting.KeyValueTable(['Id', 'Domain', 'Hostmane'])
+    table.align['name'] = 'r'
+    table.align['value'] = 'l'
+    table.add_row(['Id', result['id']])
+    table.add_row(['Created', result['createDate']])
+    table.add_row(['Name', result['name']])
+    for vsi in result['virtualGuestMembers']:
+        vsi_table.add_row([vsi['virtualGuest']['id'], vsi['virtualGuest']['domain'], vsi['virtualGuest']['hostname']])
+
+    table.add_row(['VirtualGuests', vsi_table])
+    output = table
+
+    env.fout(output)
diff --git a/SoftLayer/CLI/autoscale/edit.py b/SoftLayer/CLI/autoscale/edit.py
new file mode 100644
index 00000000..a4f90076
--- /dev/null
+++ b/SoftLayer/CLI/autoscale/edit.py
@@ -0,0 +1,58 @@
+"""Edits an Autoscale group."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+from SoftLayer.CLI.command import SLCommand as SLCommand
+from SoftLayer.CLI import environment
+from SoftLayer.managers.autoscale import AutoScaleManager
+
+
+@click.command(cls=SLCommand)
+@click.argument('identifier')
+@click.option('--name', help="Scale group's name.")
+@click.option('--min', 'minimum', type=click.INT, help="Set the minimum number of guests")
+@click.option('--max', 'maximum', type=click.INT, help="Set the maximum number of guests")
+@click.option('--userdata', help="User defined metadata string")
+@click.option('--userfile', '-F', help="Read userdata from a file",
+              type=click.Path(exists=True, readable=True, resolve_path=True))
+@click.option('--cpu', type=click.INT, help="Number of CPUs for new guests (existing not effected")
+@click.option('--memory', type=click.INT, help="RAM in MB or GB for new guests (existing not effected")
+@environment.pass_env
+def cli(env, identifier, name, minimum, maximum, userdata, userfile, cpu, memory):
+    """Edits an Autoscale group."""
+
+    template = {}
+    autoscale = AutoScaleManager(env.client)
+    group = autoscale.details(identifier)
+
+    template['name'] = name
+    template['minimumMemberCount'] = minimum
+    template['maximumMemberCount'] = maximum
+    virt_template = {}
+    if userdata:
+        virt_template['userData'] = [{"value": userdata}]
+    elif userfile:
+        with open(userfile, 'r', encoding="utf-8") as userfile_obj:
+            virt_template['userData'] = [{"value": userfile_obj.read()}]
+    virt_template['startCpus'] = cpu
+    virt_template['maxMemory'] = memory
+
+    # Remove any entries that are `None` as the API will complain about them.
+    template['virtualGuestMemberTemplate'] = clean_dict(virt_template)
+    clean_template = clean_dict(template)
+
+    # If there are any values edited in the template, we need to get the OLD template values and replace them.
+    if template['virtualGuestMemberTemplate']:
+        # Update old template with new values
+        for key, value in clean_template['virtualGuestMemberTemplate'].items():
+            group['virtualGuestMemberTemplate'][key] = value
+        clean_template['virtualGuestMemberTemplate'] = group['virtualGuestMemberTemplate']
+
+    autoscale.edit(identifier, clean_template)
+    click.echo("Done")
+
+
+def clean_dict(dictionary):
+    """Removes any `None` entires from the dictionary"""
+    return {k: v for k, v in dictionary.items() if v}
diff --git a/SoftLayer/CLI/autoscale/logs.py b/SoftLayer/CLI/autoscale/logs.py
new file mode 100644
index 00000000..6be46183
--- /dev/null
+++ b/SoftLayer/CLI/autoscale/logs.py
@@ -0,0 +1,38 @@
+"""Retreive logs for an autoscale group"""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+from SoftLayer.CLI.command import SLCommand as SLCommand
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer.managers.autoscale import AutoScaleManager
+from SoftLayer import utils
+
+
+@click.command(cls=SLCommand)
+@click.argument('identifier')
+@click.option('--date-min', '-d', 'date_min', type=click.DateTime(formats=["%Y-%m-%d", "%m/%d/%Y"]),
+              help='Earliest date to retreive logs for.')
+@environment.pass_env
+def cli(env, identifier, date_min):
+    """Retreive logs for an autoscale group"""
+
+    autoscale = AutoScaleManager(env.client)
+
+    mask = "mask[id,createDate,description]"
+    object_filter = {}
+    if date_min:
+        object_filter['logs'] = {
+            'createDate': {
+                'operation': 'greaterThanDate',
+                'options': [{'name': 'date', 'value': [date_min.strftime("%m/%d/%Y")]}]
+            }
+        }
+
+    logs = autoscale.get_logs(identifier, mask=mask, object_filter=object_filter)
+    table = formatting.Table(['Date', 'Entry'], title="Logs")
+    table.align['Entry'] = 'l'
+    for log in logs:
+        table.add_row([utils.clean_time(log.get('createDate')), log.get('description')])
+    env.fout(table)
diff --git a/SoftLayer/CLI/autoscale/scale.py b/SoftLayer/CLI/autoscale/scale.py
new file mode 100644
index 00000000..ea5b069b
--- /dev/null
+++ b/SoftLayer/CLI/autoscale/scale.py
@@ -0,0 +1,56 @@
+"""Scales an Autoscale group"""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+from SoftLayer.CLI.command import SLCommand as SLCommand
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer.managers.autoscale import AutoScaleManager
+from SoftLayer import utils
+
+
+@click.command(cls=SLCommand)
+@click.argument('identifier')
+@click.option('--up/--down', 'scale_up', is_flag=True, default=True,
+              help="'--up' adds guests, '--down' removes guests.")
+@click.option('--by/--to', 'scale_by', is_flag=True, required=True,
+              help="'--by' will add/remove the specified number of guests."
+              " '--to' will add/remove a number of guests to get the group's guest count to the specified number.")
+@click.option('--amount', required=True, type=click.INT, help="Number of guests for the scale action.")
+@environment.pass_env
+def cli(env, identifier, scale_up, scale_by, amount):
+    """Scales an Autoscale group. Bypasses a scale group's cooldown period."""
+
+    autoscale = AutoScaleManager(env.client)
+
+    # Scale By, and go down, need to use negative amount
+    if not scale_up and scale_by:
+        amount = amount * -1
+
+    result = []
+    if scale_by:
+        click.secho("Scaling group {} by {}".format(identifier, amount), fg='green')
+        result = autoscale.scale(identifier, amount)
+    else:
+        click.secho("Scaling group {} to {}".format(identifier, amount), fg='green')
+        result = autoscale.scale_to(identifier, amount)
+
+    try:
+        # Check if the first guest has a cancellation date, assume we are removing guests if it is.
+        status = result[0]['virtualGuest']['status']['keyName'] or False
+    except (IndexError, KeyError, TypeError):
+        status = False
+
+    if status == 'ACTIVE':
+        member_table = formatting.Table(['Id', 'Hostname', 'Created'], title="Added Guests")
+    else:
+        member_table = formatting.Table(['Id', 'Hostname', 'Created'], title="Cancelled Guests")
+
+    for guest in result:
+        real_guest = guest.get('virtualGuest')
+        member_table.add_row([
+            guest.get('id'), real_guest.get('hostname'), utils.clean_time(real_guest.get('createDate'))
+        ])
+
+    env.fout(member_table)
diff --git a/SoftLayer/CLI/autoscale/tag.py b/SoftLayer/CLI/autoscale/tag.py
new file mode 100644
index 00000000..b8f95935
--- /dev/null
+++ b/SoftLayer/CLI/autoscale/tag.py
@@ -0,0 +1,34 @@
+"""Tags all guests in an autoscale group."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+from SoftLayer.CLI.command import SLCommand as SLCommand
+from SoftLayer.CLI import environment
+from SoftLayer.managers.autoscale import AutoScaleManager
+from SoftLayer.managers.vs import VSManager
+
+
+@click.command(cls=SLCommand)
+@click.argument('identifier')
+@click.option('--tags', '-g', help="Tags to set for each guest in this group. Existing tags are overwritten. "
+                                   "An empty string will remove all tags")
+@environment.pass_env
+def cli(env, identifier, tags):
+    """Tags all guests in an autoscale group.
+
+    --tags "Use, quotes, if you, want whitespace"
+
+    --tags Otherwise,Just,commas
+    """
+
+    autoscale = AutoScaleManager(env.client)
+    vsmanager = VSManager(env.client)
+    mask = "mask[id,virtualGuestId,virtualGuest[tagReferences,id,hostname]]"
+    guests = autoscale.get_virtual_guests(identifier, mask=mask)
+    click.echo("New Tags: {}".format(tags))
+    for guest in guests:
+        real_guest = guest.get('virtualGuest')
+        click.echo("Setting tags for {}".format(real_guest.get('hostname')))
+        vsmanager.set_tags(tags, real_guest.get('id'),)
+    click.echo("Done")
diff --git a/SoftLayer/CLI/hardware/add_notification.py b/SoftLayer/CLI/hardware/add_notification.py
new file mode 100644
index 00000000..5a7ba9ba
--- /dev/null
+++ b/SoftLayer/CLI/hardware/add_notification.py
@@ -0,0 +1,31 @@
+"""Create a user hardware notification entry."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+
+
+@click.command(cls=SoftLayer.CLI.command.SLCommand, )
+@click.argument('identifier')
+@click.option('--users', multiple=True,
+              help='UserId to be notified on monitoring failure.')
+@environment.pass_env
+def cli(env, identifier, users):
+    """Create a user hardware notification entry."""
+
+    hardware = SoftLayer.HardwareManager(env.client)
+
+    table = formatting.KeyValueTable(['Id', 'Hostmane', 'Username', 'Email', 'FirstName', 'Lastname'])
+    table.align['Id'] = 'r'
+    table.align['Username'] = 'l'
+
+    for user in users:
+        notification = hardware.add_notification(identifier, user)
+        table.add_row([notification['id'], notification['hardware']['fullyQualifiedDomainName'],
+                       notification['user']['username'], notification['user']['email'],
+                       notification['user']['firstName'], notification['user']['lastName']])
+
+    env.fout(table)
diff --git a/SoftLayer/CLI/hardware/notifications.py b/SoftLayer/CLI/hardware/notifications.py
new file mode 100644
index 00000000..76c2bee6
--- /dev/null
+++ b/SoftLayer/CLI/hardware/notifications.py
@@ -0,0 +1,30 @@
+"""Get all hardware notifications associated with the passed hardware ID."""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+import SoftLayer
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+
+
+@click.command(cls=SoftLayer.CLI.command.SLCommand, )
+@click.argument('identifier')
+@environment.pass_env
+def cli(env, identifier):
+    """Get all hardware notifications."""
+
+    hardware = SoftLayer.HardwareManager(env.client)
+
+    notifications = hardware.get_notifications(identifier)
+
+    table = formatting.KeyValueTable(['Domain', 'Hostmane', 'Username', 'Email', 'FirstName', 'Lastname'])
+    table.align['Domain'] = 'r'
+    table.align['Username'] = 'l'
+
+    for notification in notifications:
+        table.add_row([notification['hardware']['fullyQualifiedDomainName'], notification['hardware']['hostname'],
+                       notification['user']['username'], notification['user']['email'],
+                       notification['user']['firstName'], notification['user']['lastName']])
+
+    env.fout(table)
diff --git a/SoftLayer/CLI/object_storage/credential/__init__.py b/SoftLayer/CLI/object_storage/credential/__init__.py
index f3bc2723..1f71754b 100644
--- a/SoftLayer/CLI/object_storage/credential/__init__.py
+++ b/SoftLayer/CLI/object_storage/credential/__init__.py
@@ -32,12 +32,8 @@ def get_command(self, ctx, cmd_name):
         """Get command for click."""
         path = "%s.%s" % (__name__, cmd_name)
         path = path.replace("-", "_")
-        try:
-            module = importlib.import_module(path)
-            return getattr(module, 'cli')
-        except ModuleNotFoundError as ex:
-            print(ex.name)
-            return None
+        module = importlib.import_module(path)
+        return getattr(module, 'cli')
 
 
 # Required to get the sub-sub-sub command to work.
diff --git a/SoftLayer/CLI/order/item_list.py b/SoftLayer/CLI/order/item_list.py
index 9285c738..173e30a2 100644
--- a/SoftLayer/CLI/order/item_list.py
+++ b/SoftLayer/CLI/order/item_list.py
@@ -125,7 +125,7 @@ def _location_item_prices(location_prices, location, tables):
     :param list tables: Table list to add location prices table.
     """
     location_prices_table = formatting.Table(COLUMNS_ITEM_PRICES_LOCATION, title="Item Prices for %s" % location)
-    location_prices_table.sortby = 'KeyName'
+    location_prices_table.sortby = 'keyName'
     location_prices_table.align = 'l'
     for price in location_prices:
         cr_max = get_item_price_data(price, 'capacityRestrictionMaximum')
diff --git a/SoftLayer/CLI/routes.py b/SoftLayer/CLI/routes.py
index 234a4c03..9136aca7 100644
--- a/SoftLayer/CLI/routes.py
+++ b/SoftLayer/CLI/routes.py
@@ -292,6 +292,8 @@
     ('hardware:upgrade', 'SoftLayer.CLI.hardware.upgrade:cli'),
     ('hardware:sensor', 'SoftLayer.CLI.hardware.sensor:cli'),
     ('hardware:monitoring', 'SoftLayer.CLI.hardware.monitoring:cli'),
+    ('hardware:notifications', 'SoftLayer.CLI.hardware.notifications:cli'),
+    ('hardware:add-notification', 'SoftLayer.CLI.hardware.add_notification:cli'),
 
     ('securitygroup', 'SoftLayer.CLI.securitygroup'),
     ('securitygroup:list', 'SoftLayer.CLI.securitygroup.list:cli'),
@@ -386,7 +388,12 @@
 
     ('autoscale', 'SoftLayer.CLI.autoscale'),
     ('autoscale:list', 'SoftLayer.CLI.autoscale.list:cli'),
+    ('autoscale:create', 'SoftLayer.CLI.autoscale.create:cli'),
     ('autoscale:detail', 'SoftLayer.CLI.autoscale.detail:cli'),
+    ('autoscale:scale', 'SoftLayer.CLI.autoscale.scale:cli'),
+    ('autoscale:logs', 'SoftLayer.CLI.autoscale.logs:cli'),
+    ('autoscale:tag', 'SoftLayer.CLI.autoscale.tag:cli'),
+    ('autoscale:edit', 'SoftLayer.CLI.autoscale.edit:cli'),
     ('autoscale:delete', 'SoftLayer.CLI.autoscale.delete:cli'),
 ]
 
diff --git a/SoftLayer/CLI/virt/capacity/__init__.py b/SoftLayer/CLI/virt/capacity/__init__.py
index 21ff940a..3f891c19 100644
--- a/SoftLayer/CLI/virt/capacity/__init__.py
+++ b/SoftLayer/CLI/virt/capacity/__init__.py
@@ -37,12 +37,8 @@ def get_command(self, ctx, cmd_name):
         """Get command for click."""
         path = "%s.%s" % (__name__, cmd_name)
         path = path.replace("-", "_")
-        try:
-            module = importlib.import_module(path)
-            return getattr(module, 'cli')
-        except ModuleNotFoundError as ex:
-            print(ex.name)
-            return None
+        module = importlib.import_module(path)
+        return getattr(module, 'cli')
 
 
 # Required to get the sub-sub-sub command to work.
diff --git a/SoftLayer/CLI/virt/placementgroup/__init__.py b/SoftLayer/CLI/virt/placementgroup/__init__.py
index 1aa89a16..aa748a5b 100644
--- a/SoftLayer/CLI/virt/placementgroup/__init__.py
+++ b/SoftLayer/CLI/virt/placementgroup/__init__.py
@@ -36,12 +36,8 @@ def get_command(self, ctx, cmd_name):
         """Get command for click."""
         path = "%s.%s" % (__name__, cmd_name)
         path = path.replace("-", "_")
-        try:
-            module = importlib.import_module(path)
-            return getattr(module, 'cli')
-        except ModuleNotFoundError as ex:
-            print(ex.name)
-            return None
+        module = importlib.import_module(path)
+        return getattr(module, 'cli')
 
 
 # Required to get the sub-sub-sub command to work.
diff --git a/SoftLayer/consts.py b/SoftLayer/consts.py
index 74858c3a..8ed28cac 100644
--- a/SoftLayer/consts.py
+++ b/SoftLayer/consts.py
@@ -5,7 +5,7 @@
 
     :license: MIT, see LICENSE for more details.
 """
-VERSION = 'v6.1.2'
+VERSION = 'v6.1.1'
 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_User_Customer_Notification_Hardware.py b/SoftLayer/fixtures/SoftLayer_User_Customer_Notification_Hardware.py
new file mode 100644
index 00000000..1441b858
--- /dev/null
+++ b/SoftLayer/fixtures/SoftLayer_User_Customer_Notification_Hardware.py
@@ -0,0 +1,70 @@
+findByHardwareId = [
+    {
+        'hardwareId': 147258,
+        'id': 1232569,
+        'userId': 3698,
+        'hardware': {
+            'accountId': 963258,
+            'domain': 'testedit.com',
+            'fullyQualifiedDomainName': 'testslcli.testedit.com',
+            'hostname': 'bardcabero',
+            'id': 1403539,
+            'notes': 'My golang note',
+            'provisionDate': '2020-04-27T16:10:56-06:00',
+        },
+        'user': {
+            'accountId': 307608,
+            'email': 'cgallo@us.ibm.com',
+            'firstName': 'CHRISTOPHER',
+            'id': 167758,
+            'lastName': 'GALLO',
+            'username': 'SL307608',
+        }
+    },
+    {
+        'hardwareId': 1403539,
+        'id': 1408587,
+        'userId': 9734826,
+        'hardware': {
+            'accountId': 963258,
+            'domain': 'testedit.com',
+            'fullyQualifiedDomainName': 'testslcli.testedit.com',
+            'hostname': 'bardcabero',
+            'id': 1403539,
+            'notes': 'My golang note',
+            'provisionDate': '2020-04-27T16:10:56-06:00',
+        },
+        'user': {
+            'accountId': 307608,
+            'email': 'Brian.Flores@ibm.com',
+            'firstName': 'Brian',
+            'id': 9734826,
+            'lastName': 'Flores',
+            'username': '307608_brian.flores@ibm.com',
+        }
+    }
+]
+
+createObject = {
+    'hardwareId': 1403539,
+    'id': 1408593,
+    'userId': 7650493,
+    'hardware': {
+        'accountId': 307608,
+        'domain': 'testedit.com',
+        'fullyQualifiedDomainName': 'bardcabero.testedit.com',
+        'hostname': 'bardcabero',
+        'id': 1403539,
+        'notes': 'My golang note',
+
+    },
+    'user': {
+        'accountId': 307608,
+        'email': 'daniel.cabero@jalasoft.com',
+        'firstName': 'daniel',
+        'id': 7650493,
+        'lastName': 'cabero',
+        'username': 'sl307608-dcabero'
+
+    }
+}
diff --git a/SoftLayer/managers/hardware.py b/SoftLayer/managers/hardware.py
index be374b50..92a820ca 100644
--- a/SoftLayer/managers/hardware.py
+++ b/SoftLayer/managers/hardware.py
@@ -1092,6 +1092,16 @@ def get_sensors(self, hardware_id):
         """Returns Hardware sensor data"""
         return self.client.call('Hardware', 'getSensorData', id=hardware_id)
 
+    def get_notifications(self, hardware_id):
+        """Returns all hardware notifications."""
+        return self.client.call('SoftLayer_User_Customer_Notification_Hardware', 'findByHardwareId', hardware_id)
+
+    def add_notification(self, hardware_id, user_id):
+        """Create a user hardware notification entry"""
+
+        template = {"hardwareId": hardware_id, "userId": user_id}
+        return self.client.call('SoftLayer_User_Customer_Notification_Hardware', 'createObject', template)
+
 
 def _get_bandwidth_key(items, hourly=True, no_public=False, location=None):
     """Picks a valid Bandwidth Item, returns the KeyName"""
diff --git a/docs/cli/hardware.rst b/docs/cli/hardware.rst
index 6e9e9d49..dfd10e82 100644
--- a/docs/cli/hardware.rst
+++ b/docs/cli/hardware.rst
@@ -131,3 +131,11 @@ This function updates the firmware of a server. If already at the latest version
 .. click:: SoftLayer.CLI.hardware.sensor:cli
    :prog: hardware sensor
    :show-nested:
+
+.. click:: SoftLayer.CLI.hardware.notifications:cli
+   :prog: hardware notifications
+   :show-nested:
+
+.. click:: SoftLayer.CLI.hardware.add_notification:cli
+   :prog: hardware add-notification
+   :show-nested:
diff --git a/setup.py b/setup.py
index 0af3c807..b087359e 100644
--- a/setup.py
+++ b/setup.py
@@ -16,7 +16,7 @@
 
 setup(
     name='SoftLayer',
-    version='6.1.2',
+    version='6.1.1',
     description=DESCRIPTION,
     long_description=LONG_DESCRIPTION,
     long_description_content_type='text/x-rst',


Test patch:
diff --git a/tests/CLI/modules/autoscale_tests.py b/tests/CLI/modules/autoscale_tests.py
index 2628d503..fbdfd7f0 100644
--- a/tests/CLI/modules/autoscale_tests.py
+++ b/tests/CLI/modules/autoscale_tests.py
@@ -8,11 +8,42 @@
     Tests for the autoscale cli command
 """
 
+import sys
+from unittest import mock as mock
+
+from SoftLayer import fixtures
 from SoftLayer import testing
 
+import tempfile
+
 
 class AutoscaleTests(testing.TestCase):
 
+    def test_logs_dates(self):
+        result = self.run_command(['autoscale', 'logs', '123456', '-d', '2019-02-02'])
+        print(result)
+        self.assert_no_fail(result)
+
+    def test_scale_down(self):
+        result = self.run_command(['autoscale', 'scale', '123456', '--down', '--by', '--amount', '2'])
+        self.assert_no_fail(result)
+
+    def test_scale_up(self):
+        result = self.run_command(['autoscale', 'scale', '123456', '--up', '--to', '--amount', '2'])
+        self.assert_no_fail(result)
+
+    def test_scale_to(self):
+        result = self.run_command(['autoscale', 'scale', '789654123', '--down', '--to', '--amount', '2'])
+        self.assert_no_fail(result)
+
+    def test_scale_by_up(self):
+        result = self.run_command(['autoscale', 'scale', '789654123', '--by', '--down', '--amount', '-1'])
+        self.assert_no_fail(result)
+
+    def test_scale_cancel(self):
+        result = self.run_command(['autoscale', 'scale', '789654123', '--by', '--down', '--amount', '1'])
+        self.assert_no_fail(result)
+
     def test_autoscale_list(self):
         result = self.run_command(['autoscale', 'list'])
         self.assert_no_fail(result)
@@ -21,6 +52,66 @@ def test_autoscale_detail(self):
         result = self.run_command(['autoscale', 'detail', '12222222'])
         self.assert_no_fail(result)
 
+    def test_autoscale_tag(self):
+        result = self.run_command(['autoscale', 'tag', '12345'])
+        self.assert_no_fail(result)
+
+    @mock.patch('SoftLayer.managers.autoscale.AutoScaleManager.edit')
+    def test_autoscale_edit(self, manager):
+        result = self.run_command(['autoscale', 'edit', '12345', '--name', 'test'])
+        self.assert_no_fail(result)
+        manager.assert_called_with('12345', {'name': 'test'})
+
+    @mock.patch('SoftLayer.managers.autoscale.AutoScaleManager.edit')
+    def test_autoscale_edit_userdata(self, manager):
+        group = fixtures.SoftLayer_Scale_Group.getObject
+        template = {
+            'virtualGuestMemberTemplate': group['virtualGuestMemberTemplate']
+        }
+        template['virtualGuestMemberTemplate']['userData'] = [{'value': 'test'}]
+
+        result = self.run_command(['autoscale', 'edit', '12345', '--userdata', 'test'])
+        self.assert_no_fail(result)
+        manager.assert_called_with('12345', template)
+
+    @mock.patch('SoftLayer.managers.autoscale.AutoScaleManager.edit')
+    def test_autoscale_edit_userfile(self, manager):
+        # On windows, python cannot edit a NamedTemporaryFile.
+        if (sys.platform.startswith("win")):
+            self.skipTest("Test doesn't work in Windows")
+        group = fixtures.SoftLayer_Scale_Group.getObject
+        template = {
+            'virtualGuestMemberTemplate': group['virtualGuestMemberTemplate']
+        }
+        template['virtualGuestMemberTemplate']['userData'] = [{'value': ''}]
+
+        with tempfile.NamedTemporaryFile() as userfile:
+            result = self.run_command(['autoscale', 'edit', '12345', '--userfile', userfile.name])
+        self.assert_no_fail(result)
+        manager.assert_called_with('12345', template)
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_autoscale_create(self, confirm_mock):
+        confirm_mock.return_value = True
+        result = self.run_command(['autoscale', 'create',
+                                   '--name=test',
+                                   '--cooldown=3600',
+                                   '--min=1',
+                                   '--max=3',
+                                   '-o=CENTOS_7_64',
+                                   '--datacenter=ams01',
+                                   '--termination-policy=2',
+                                   '-H=testvs',
+                                   '-D=test.com',
+                                   '--cpu=2',
+                                   '--memory=1024',
+                                   '--policy-relative=absolute',
+                                   '--policy-amount=3',
+                                   '--regional=102',
+                                   '--disk=25'])
+        self.assert_no_fail(result)
+        self.assertEqual(result.exit_code, 0)
+
     def test_autoscale_delete(self):
         result = self.run_command(['autoscale', 'delete', '12345'])
         self.assert_no_fail(result)
diff --git a/tests/CLI/modules/server_tests.py b/tests/CLI/modules/server_tests.py
index 46e72eb2..7df09599 100644
--- a/tests/CLI/modules/server_tests.py
+++ b/tests/CLI/modules/server_tests.py
@@ -704,19 +704,19 @@ def test_dns_sync_both(self, confirm_mock):
                                            'getResourceRecords')
         getResourceRecords.return_value = []
         createAargs = ({
-            'type': 'a',
-            'host': 'hardware-test1',
-            'domainId': 12345,  # from SoftLayer_Account::getDomains
-            'data': '172.16.1.100',
-            'ttl': 7200
-        },)
+                           'type': 'a',
+                           'host': 'hardware-test1',
+                           'domainId': 12345,  # from SoftLayer_Account::getDomains
+                           'data': '172.16.1.100',
+                           'ttl': 7200
+                       },)
         createPTRargs = ({
-            'type': 'ptr',
-            'host': '100',
-            'domainId': 123456,
-            'data': 'hardware-test1.test.sftlyr.ws',
-            'ttl': 7200
-        },)
+                             'type': 'ptr',
+                             'host': '100',
+                             'domainId': 123456,
+                             'data': 'hardware-test1.test.sftlyr.ws',
+                             'ttl': 7200
+                         },)
 
         result = self.run_command(['hw', 'dns-sync', '1000'])
 
@@ -759,12 +759,12 @@ def test_dns_sync_v6(self, confirm_mock):
             }
         }
         createV6args = ({
-            'type': 'aaaa',
-            'host': 'hardware-test1',
-            'domainId': 12345,  # from SoftLayer_Account::getDomains
-            'data': '2607:f0d0:1b01:0023:0000:0000:0000:0004',
-            'ttl': 7200
-        },)
+                            'type': 'aaaa',
+                            'host': 'hardware-test1',
+                            'domainId': 12345,  # from SoftLayer_Account::getDomains
+                            'data': '2607:f0d0:1b01:0023:0000:0000:0000:0004',
+                            'ttl': 7200
+                        },)
         server.return_value = test_server
         result = self.run_command(['hw', 'dns-sync', '--aaaa-record', '1000'])
         self.assert_no_fail(result)
@@ -1026,3 +1026,11 @@ def test_check_for_closing(self, confirm_mock):
                                    '--flavor', 'B1_2X8X25', '--datacenter', 'mex01', '--os', 'UBUNTU_LATEST'])
         self.assert_no_fail(result)
         self.assertNotIn('Warning: Closed soon: mex01', result.output)
+
+    def test_notifications(self):
+        result = self.run_command(['hardware', 'notifications', '100'])
+        self.assert_no_fail(result)
+
+    def test_add_notification(self):
+        result = self.run_command(['hardware', 'add-notification', '100', '--users', '123456'])
+        self.assert_no_fail(result)
diff --git a/tests/managers/hardware_tests.py b/tests/managers/hardware_tests.py
index 4a42910b..b4e177bd 100644
--- a/tests/managers/hardware_tests.py
+++ b/tests/managers/hardware_tests.py
@@ -920,6 +920,14 @@ def test_sensor(self):
         self.hardware.get_sensors(100)
         self.assert_called_with('SoftLayer_Hardware', 'getSensorData')
 
+    def test_notification(self):
+        self.hardware.get_notifications(100)
+        self.assert_called_with('SoftLayer_User_Customer_Notification_Hardware', 'findByHardwareId')
+
+    def test_add_notification(self):
+        self.hardware.add_notification(100, 123456)
+        self.assert_called_with('SoftLayer_User_Customer_Notification_Hardware', 'createObject')
+
 
 class HardwareHelperTests(testing.TestCase):