Repository: softlayer/softlayer-python
Pull request #1091 (2019-01-31 16:50:13)

#1069  - Virtual Placement Group support
Adds the following CLI commands, and a manager for vs_placementgroups

```
slcli vs placementgroup --help
Usage: slcli vs placementgroup [OPTIONS] COMMAND [ARGS]...

  Base command for all capacity related concerns

Options:
  -h, --help  Show this message and exit.

Commands:
  create          Create a placement group
  create-options  List options for creating Reserved Capacity
  delete          Delete a placement group.
  detail          View details of a placement group.
  list            List Reserved Capacity groups.
```


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0f6fd444..0def2789 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,5 +25,15 @@ ## Code style
 * create pull request
 
 
+## Documentation
+
+CLI command should have a more human readable style of documentation.
+Manager methods should have a decent docblock describing any parameters and what the method does.
+
+Docs are generated with [Sphinx](https://docs.readthedocs.io/en/latest/intro/getting-started-with-sphinx.html) and once Sphinx is setup, you can simply do
+
+`make html` in the softlayer-python/docs directory, which should generate the HTML in softlayer-python/docs/_build/html for testing. 
+
+
 
 
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..50a35f03
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,192 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = sphinx-build
+PAPER         =
+BUILDDIR      = build
+
+# User-friendly check for sphinx-build
+ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
+$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
+endif
+
+# Internal variables.
+PAPEROPT_a4     = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext
+
+help:
+	@echo "Please use \`make <target>' where <target> is one of"
+	@echo "  html       to make standalone HTML files"
+	@echo "  dirhtml    to make HTML files named index.html in directories"
+	@echo "  singlehtml to make a single large HTML file"
+	@echo "  pickle     to make pickle files"
+	@echo "  json       to make JSON files"
+	@echo "  htmlhelp   to make HTML files and a HTML help project"
+	@echo "  qthelp     to make HTML files and a qthelp project"
+	@echo "  applehelp  to make an Apple Help Book"
+	@echo "  devhelp    to make HTML files and a Devhelp project"
+	@echo "  epub       to make an epub"
+	@echo "  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+	@echo "  latexpdf   to make LaTeX files and run them through pdflatex"
+	@echo "  latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
+	@echo "  text       to make text files"
+	@echo "  man        to make manual pages"
+	@echo "  texinfo    to make Texinfo files"
+	@echo "  info       to make Texinfo files and run them through makeinfo"
+	@echo "  gettext    to make PO message catalogs"
+	@echo "  changes    to make an overview of all changed/added/deprecated items"
+	@echo "  xml        to make Docutils-native XML files"
+	@echo "  pseudoxml  to make pseudoxml-XML files for display purposes"
+	@echo "  linkcheck  to check all external links for integrity"
+	@echo "  doctest    to run all doctests embedded in the documentation (if enabled)"
+	@echo "  coverage   to run coverage check of the documentation (if enabled)"
+
+clean:
+	rm -rf $(BUILDDIR)/*
+
+html:
+	$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+	@echo
+	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+	@echo
+	@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+	$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+	@echo
+	@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+	$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+	@echo
+	@echo "Build finished; now you can process the pickle files."
+
+json:
+	$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+	@echo
+	@echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+	$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+	@echo
+	@echo "Build finished; now you can run HTML Help Workshop with the" \
+	      ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+	$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+	@echo
+	@echo "Build finished; now you can run "qcollectiongenerator" with the" \
+	      ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+	@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/softlayer-python.qhcp"
+	@echo "To view the help file:"
+	@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/softlayer-python.qhc"
+
+applehelp:
+	$(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
+	@echo
+	@echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
+	@echo "N.B. You won't be able to view it unless you put it in" \
+	      "~/Library/Documentation/Help or install it in your application" \
+	      "bundle."
+
+devhelp:
+	$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+	@echo
+	@echo "Build finished."
+	@echo "To view the help file:"
+	@echo "# mkdir -p $$HOME/.local/share/devhelp/softlayer-python"
+	@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/softlayer-python"
+	@echo "# devhelp"
+
+epub:
+	$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+	@echo
+	@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo
+	@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+	@echo "Run \`make' in that directory to run these through (pdf)latex" \
+	      "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo "Running LaTeX files through pdflatex..."
+	$(MAKE) -C $(BUILDDIR)/latex all-pdf
+	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+latexpdfja:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo "Running LaTeX files through platex and dvipdfmx..."
+	$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
+	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+	$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+	@echo
+	@echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+	$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+	@echo
+	@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+texinfo:
+	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+	@echo
+	@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+	@echo "Run \`make' in that directory to run these through makeinfo" \
+	      "(use \`make info' here to do that automatically)."
+
+info:
+	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+	@echo "Running Texinfo files through makeinfo..."
+	make -C $(BUILDDIR)/texinfo info
+	@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+gettext:
+	$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+	@echo
+	@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+changes:
+	$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+	@echo
+	@echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+	$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+	@echo
+	@echo "Link check complete; look for any errors in the above output " \
+	      "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+	$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+	@echo "Testing of doctests in the sources finished, look at the " \
+	      "results in $(BUILDDIR)/doctest/output.txt."
+
+coverage:
+	$(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
+	@echo "Testing of coverage in the sources finished, look at the " \
+	      "results in $(BUILDDIR)/coverage/python.txt."
+
+xml:
+	$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
+	@echo
+	@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+
+pseudoxml:
+	$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
+	@echo
+	@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
diff --git a/README.rst b/README.rst
index 8a274c8e..177f1514 100644
--- a/README.rst
+++ b/README.rst
@@ -88,12 +88,14 @@ To get the exact API call that this library makes, you can do the following.
 For the CLI, just use the -vvv option. If you are using the REST endpoint, this will print out a curl command that you can use, if using XML, this will print the minimal python code to make the request without the softlayer library.
 
 .. code-block:: bash
+
   $ slcli -vvv vs list
 
 
 If you are using the library directly in python, you can do something like this.
 
 .. code-bock:: python
+
   import SoftLayer
   import logging
 
@@ -118,6 +120,8 @@ If you are using the library directly in python, you can do something like this.
       main.main()
       main.debug()
 
+
+
 System Requirements
 -------------------
 * Python 2.7, 3.3, 3.4, 3.5, 3.6, or 3.7.
diff --git a/SoftLayer/CLI/helpers.py b/SoftLayer/CLI/helpers.py
index f32595e5..24a5dd44 100644
--- a/SoftLayer/CLI/helpers.py
+++ b/SoftLayer/CLI/helpers.py
@@ -30,17 +30,20 @@ def multi_option(*param_decls, **attrs):
 def resolve_id(resolver, identifier, name='object'):
     """Resolves a single id using a resolver function.
 
-    :param resolver: function that resolves ids. Should return None or a list
-                     of ids.
+    :param resolver: function that resolves ids. Should return None or a list of ids.
     :param string identifier: a string identifier used to resolve ids
     :param string name: the object type, to be used in error messages
 
     """
+    try:
+        return int(identifier)
+    except ValueError:
+        pass  # It was worth a shot
+
     ids = resolver(identifier)
 
     if len(ids) == 0:
-        raise exceptions.CLIAbort("Error: Unable to find %s '%s'"
-                                  % (name, identifier))
+        raise exceptions.CLIAbort("Error: Unable to find %s '%s'" % (name, identifier))
 
     if len(ids) > 1:
         raise exceptions.CLIAbort(
diff --git a/SoftLayer/CLI/routes.py b/SoftLayer/CLI/routes.py
index cebf2bc0..51914c30 100644
--- a/SoftLayer/CLI/routes.py
+++ b/SoftLayer/CLI/routes.py
@@ -31,6 +31,7 @@
     ('virtual:upgrade', 'SoftLayer.CLI.virt.upgrade:cli'),
     ('virtual:credentials', 'SoftLayer.CLI.virt.credentials:cli'),
     ('virtual:capacity', 'SoftLayer.CLI.virt.capacity:cli'),
+    ('virtual:placementgroup', 'SoftLayer.CLI.virt.placementgroup:cli'),
 
     ('dedicatedhost', 'SoftLayer.CLI.dedicatedhost'),
     ('dedicatedhost:list', 'SoftLayer.CLI.dedicatedhost.list:cli'),
@@ -317,4 +318,5 @@
     'vm': 'virtual',
     'vs': 'virtual',
     'dh': 'dedicatedhost',
+    'pg': 'placementgroup',
 }
diff --git a/SoftLayer/CLI/virt/create.py b/SoftLayer/CLI/virt/create.py
index 3ee44b3a..63179347 100644
--- a/SoftLayer/CLI/virt/create.py
+++ b/SoftLayer/CLI/virt/create.py
@@ -32,6 +32,7 @@ def _update_with_like_args(ctx, _, value):
         'postinstall': like_details.get('postInstallScriptUri'),
         'dedicated': like_details['dedicatedAccountHostOnlyFlag'],
         'private': like_details['privateNetworkOnlyFlag'],
+        'placement_id': like_details.get('placementGroupId', None),
     }
 
     like_args['flavor'] = utils.lookup(like_details,
@@ -138,6 +139,10 @@ def _parse_create_args(client, args):
     if args.get('host_id'):
         data['host_id'] = args['host_id']
 
+    if args.get('placementgroup'):
+        resolver = SoftLayer.managers.PlacementManager(client).resolve_ids
+        data['placement_id'] = helpers.resolve_id(resolver, args.get('placementgroup'), 'PlacementGroup')
+
     return data
 
 
@@ -190,6 +195,8 @@ def _parse_create_args(client, args):
                       help=('Security group ID to associate with the private interface'))
 @click.option('--wait', type=click.INT,
               help="Wait until VS is finished provisioning for up to X seconds before returning")
+@click.option('--placementgroup',
+              help="Placement Group name or Id to order this guest on. See: slcli vs placementgroup list")
 @click.option('--ipv6', is_flag=True, help="Adds an IPv6 address to this guest")
 @environment.pass_env
 def cli(env, **args):
diff --git a/SoftLayer/CLI/virt/placementgroup/__init__.py b/SoftLayer/CLI/virt/placementgroup/__init__.py
new file mode 100644
index 00000000..02d5da98
--- /dev/null
+++ b/SoftLayer/CLI/virt/placementgroup/__init__.py
@@ -0,0 +1,47 @@
+"""Manages Reserved Capacity."""
+# :license: MIT, see LICENSE for more details.
+
+import importlib
+import os
+
+import click
+
+CONTEXT = {'help_option_names': ['-h', '--help'],
+           'max_content_width': 999}
+
+
+class PlacementGroupCommands(click.MultiCommand):
+    """Loads module for placement group related commands.
+
+    Currently the base command loader only supports going two commands deep.
+    So this small loader is required for going that third level.
+    """
+
+    def __init__(self, **attrs):
+        click.MultiCommand.__init__(self, **attrs)
+        self.path = os.path.dirname(__file__)
+
+    def list_commands(self, ctx):
+        """List all sub-commands."""
+        commands = []
+        for filename in os.listdir(self.path):
+            if filename == '__init__.py':
+                continue
+            if filename.endswith('.py'):
+                commands.append(filename[:-3].replace("_", "-"))
+        commands.sort()
+        return commands
+
+    def get_command(self, ctx, cmd_name):
+        """Get command for click."""
+        path = "%s.%s" % (__name__, cmd_name)
+        path = path.replace("-", "_")
+        module = importlib.import_module(path)
+        return getattr(module, 'cli')
+
+
+# Required to get the sub-sub-sub command to work.
+@click.group(cls=PlacementGroupCommands, context_settings=CONTEXT)
+def cli():
+    """Base command for all capacity related concerns"""
+    pass
diff --git a/SoftLayer/CLI/virt/placementgroup/create.py b/SoftLayer/CLI/virt/placementgroup/create.py
new file mode 100644
index 00000000..af1fb8db
--- /dev/null
+++ b/SoftLayer/CLI/virt/placementgroup/create.py
@@ -0,0 +1,31 @@
+"""Create a placement group"""
+
+import click
+
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import helpers
+from SoftLayer.managers.vs_placement import PlacementManager as PlacementManager
+
+
+@click.command()
+@click.option('--name', type=click.STRING, required=True, prompt=True, help="Name for this new placement group.")
+@click.option('--backend_router', '-b', required=True, prompt=True,
+              help="backendRouter, can be either the hostname or id.")
+@click.option('--rule', '-r', required=True, prompt=True,
+              help="The keyName or Id of the rule to govern this placement group.")
+@environment.pass_env
+def cli(env, **args):
+    """Create a placement group"""
+    manager = PlacementManager(env.client)
+    backend_router_id = helpers.resolve_id(manager.get_backend_router_id_from_hostname,
+                                           args.get('backend_router'),
+                                           'backendRouter')
+    rule_id = helpers.resolve_id(manager.get_rule_id_from_name, args.get('rule'), 'Rule')
+    placement_object = {
+        'name': args.get('name'),
+        'backendRouterId': backend_router_id,
+        'ruleId': rule_id
+    }
+
+    result = manager.create(placement_object)
+    click.secho("Successfully created placement group: ID: %s, Name: %s" % (result['id'], result['name']), fg='green')
diff --git a/SoftLayer/CLI/virt/placementgroup/create_options.py b/SoftLayer/CLI/virt/placementgroup/create_options.py
new file mode 100644
index 00000000..3107fc33
--- /dev/null
+++ b/SoftLayer/CLI/virt/placementgroup/create_options.py
@@ -0,0 +1,38 @@
+"""List options for creating Placement Groups"""
+# :license: MIT, see LICENSE for more details.
+
+import click
+
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer.managers.vs_placement import PlacementManager as PlacementManager
+
+
+@click.command()
+@environment.pass_env
+def cli(env):
+    """List options for creating Reserved Capacity"""
+    manager = PlacementManager(env.client)
+
+    routers = manager.get_routers()
+    env.fout(get_router_table(routers))
+
+    rules = manager.get_all_rules()
+    env.fout(get_rule_table(rules))
+
+
+def get_router_table(routers):
+    """Formats output from _get_routers and returns a table. """
+    table = formatting.Table(['Datacenter', 'Hostname', 'Backend Router Id'], "Available Routers")
+    for router in routers:
+        datacenter = router['topLevelLocation']['longName']
+        table.add_row([datacenter, router['hostname'], router['id']])
+    return table
+
+
+def get_rule_table(rules):
+    """Formats output from get_all_rules and returns a table. """
+    table = formatting.Table(['Id', 'KeyName'], "Rules")
+    for rule in rules:
+        table.add_row([rule['id'], rule['keyName']])
+    return table
diff --git a/SoftLayer/CLI/virt/placementgroup/delete.py b/SoftLayer/CLI/virt/placementgroup/delete.py
new file mode 100644
index 00000000..260b3cd3
--- /dev/null
+++ b/SoftLayer/CLI/virt/placementgroup/delete.py
@@ -0,0 +1,49 @@
+"""Delete a placement group."""
+
+import click
+
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import exceptions
+from SoftLayer.CLI import formatting
+from SoftLayer.CLI import helpers
+from SoftLayer.managers.vs import VSManager as VSManager
+from SoftLayer.managers.vs_placement import PlacementManager as PlacementManager
+
+
+@click.command(epilog="Once provisioned, virtual guests can be managed with the slcli vs commands")
+@click.argument('identifier')
+@click.option('--purge', is_flag=True,
+              help="Delete all guests in this placement group. "
+                   "The group itself can be deleted once all VMs are fully reclaimed")
+@environment.pass_env
+def cli(env, identifier, purge):
+    """Delete a placement group.
+
+    Placement Group MUST be empty before you can delete it.
+
+    IDENTIFIER can be either the Name or Id of the placement group you want to view
+    """
+    manager = PlacementManager(env.client)
+    group_id = helpers.resolve_id(manager.resolve_ids, identifier, 'placement_group')
+
+    if purge:
+        placement_group = manager.get_object(group_id)
+        guest_list = ', '.join([guest['fullyQualifiedDomainName'] for guest in placement_group['guests']])
+        if len(placement_group['guests']) < 1:
+            raise exceptions.CLIAbort('No virtual servers were found in placement group %s' % identifier)
+
+        click.secho("You are about to delete the following guests!\n%s" % guest_list, fg='red')
+        if not (env.skip_confirmations or formatting.confirm("This action will cancel all guests! Continue?")):
+            raise exceptions.CLIAbort('Aborting virtual server order.')
+        vm_manager = VSManager(env.client)
+        for guest in placement_group['guests']:
+            click.secho("Deleting %s..." % guest['fullyQualifiedDomainName'])
+            vm_manager.cancel_instance(guest['id'])
+        return
+
+    click.secho("You are about to delete the following placement group! %s" % identifier, fg='red')
+    if not (env.skip_confirmations or formatting.confirm("This action will cancel the placement group! Continue?")):
+        raise exceptions.CLIAbort('Aborting virtual server order.')
+    cancel_result = manager.delete(group_id)
+    if cancel_result:
+        click.secho("Placement Group %s has been canceld." % identifier, fg='green')
diff --git a/SoftLayer/CLI/virt/placementgroup/detail.py b/SoftLayer/CLI/virt/placementgroup/detail.py
new file mode 100644
index 00000000..9adf5893
--- /dev/null
+++ b/SoftLayer/CLI/virt/placementgroup/detail.py
@@ -0,0 +1,54 @@
+"""View details of a placement group"""
+
+import click
+
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer.CLI import helpers
+from SoftLayer.managers.vs_placement import PlacementManager as PlacementManager
+
+
+@click.command(epilog="Once provisioned, virtual guests can be managed with the slcli vs commands")
+@click.argument('identifier')
+@environment.pass_env
+def cli(env, identifier):
+    """View details of a placement group.
+
+    IDENTIFIER can be either the Name or Id of the placement group you want to view
+    """
+    manager = PlacementManager(env.client)
+    group_id = helpers.resolve_id(manager.resolve_ids, identifier, 'placement_group')
+    result = manager.get_object(group_id)
+    table = formatting.Table(["Id", "Name", "Backend Router", "Rule", "Created"])
+
+    table.add_row([
+        result['id'],
+        result['name'],
+        result['backendRouter']['hostname'],
+        result['rule']['name'],
+        result['createDate']
+    ])
+    guest_table = formatting.Table([
+        "Id",
+        "FQDN",
+        "Primary IP",
+        "Backend IP",
+        "CPU",
+        "Memory",
+        "Provisioned",
+        "Transaction"
+    ])
+    for guest in result['guests']:
+        guest_table.add_row([
+            guest.get('id'),
+            guest.get('fullyQualifiedDomainName'),
+            guest.get('primaryIpAddress'),
+            guest.get('primaryBackendIpAddress'),
+            guest.get('maxCpu'),
+            guest.get('maxMemory'),
+            guest.get('provisionDate'),
+            formatting.active_txn(guest)
+        ])
+
+    env.fout(table)
+    env.fout(guest_table)
diff --git a/SoftLayer/CLI/virt/placementgroup/list.py b/SoftLayer/CLI/virt/placementgroup/list.py
new file mode 100644
index 00000000..365205e7
--- /dev/null
+++ b/SoftLayer/CLI/virt/placementgroup/list.py
@@ -0,0 +1,30 @@
+"""List Placement Groups"""
+
+import click
+
+from SoftLayer.CLI import environment
+from SoftLayer.CLI import formatting
+from SoftLayer.managers.vs_placement import PlacementManager as PlacementManager
+
+
+@click.command()
+@environment.pass_env
+def cli(env):
+    """List Reserved Capacity groups."""
+    manager = PlacementManager(env.client)
+    result = manager.list()
+    table = formatting.Table(
+        ["Id", "Name", "Backend Router", "Rule", "Guests", "Created"],
+        title="Placement Groups"
+    )
+    for group in result:
+        table.add_row([
+            group['id'],
+            group['name'],
+            group['backendRouter']['hostname'],
+            group['rule']['name'],
+            group['guestCount'],
+            group['createDate']
+        ])
+
+    env.fout(table)
diff --git a/SoftLayer/fixtures/SoftLayer_Account.py b/SoftLayer/fixtures/SoftLayer_Account.py
index 891df9ec..b4bafac9 100644
--- a/SoftLayer/fixtures/SoftLayer_Account.py
+++ b/SoftLayer/fixtures/SoftLayer_Account.py
@@ -591,7 +591,7 @@
         'modifyDate': '',
         'name': 'test-capacity',
         'availableInstanceCount': 1,
-        'instanceCount': 2,
+        'instanceCount': 3,
         'occupiedInstanceCount': 1,
         'backendRouter': {
             'accountId': 1,
@@ -638,7 +638,27 @@
                     'description': 'B1.1x2 (1 Year Term)',
                     'hourlyRecurringFee': '.032'
                 }
+            },
+            {
+                'id': 3519
             }
         ]
     }
 ]
+
+
+getPlacementGroups = [{
+    "createDate": "2019-01-18T16:08:44-06:00",
+    "id": 12345,
+    "name": "test01",
+    "guestCount": 0,
+    "backendRouter": {
+        "hostname": "bcr01a.mex01",
+        "id": 329266
+    },
+    "rule": {
+        "id": 1,
+        "keyName": "SPREAD",
+        "name": "SPREAD"
+    }
+}]
diff --git a/SoftLayer/fixtures/SoftLayer_Virtual_PlacementGroup.py b/SoftLayer/fixtures/SoftLayer_Virtual_PlacementGroup.py
new file mode 100644
index 00000000..0159c633
--- /dev/null
+++ b/SoftLayer/fixtures/SoftLayer_Virtual_PlacementGroup.py
@@ -0,0 +1,63 @@
+getAvailableRouters = [{
+    "accountId": 1,
+    "fullyQualifiedDomainName": "bcr01.dal01.softlayer.com",
+    "hostname": "bcr01.dal01",
+    "id": 1,
+    "topLevelLocation": {
+        "id": 3,
+        "longName": "Dallas 1",
+        "name": "dal01",
+    }
+}]
+
+createObject = {
+    "accountId": 123,
+    "backendRouterId": 444,
+    "createDate": "2019-01-18T16:08:44-06:00",
+    "id": 5555,
+    "modifyDate": None,
+    "name": "test01",
+    "ruleId": 1
+}
+getObject = {
+    "createDate": "2019-01-17T14:36:42-06:00",
+    "id": 1234,
+    "name": "test-group",
+    "backendRouter": {
+        "hostname": "bcr01a.mex01",
+        "id": 329266
+    },
+    "guests": [{
+        "accountId": 123456789,
+        "createDate": "2019-01-17T16:44:46-06:00",
+        "domain": "test.com",
+        "fullyQualifiedDomainName": "issues10691547765077.test.com",
+        "hostname": "issues10691547765077",
+        "id": 69131875,
+        "maxCpu": 1,
+        "maxMemory": 1024,
+        "placementGroupId": 1234,
+        "provisionDate": "2019-01-17T16:47:17-06:00",
+        "activeTransaction": {
+            "id": 107585077,
+            "transactionStatus": {
+                "friendlyName": "TESTING TXN",
+                "name": "RECLAIM_WAIT"
+            }
+        },
+        "globalIdentifier": "c786ac04-b612-4649-9d19-9662434eeaea",
+        "primaryBackendIpAddress": "10.131.11.14",
+        "primaryIpAddress": "169.57.70.180",
+        "status": {
+            "keyName": "DISCONNECTED",
+            "name": "Disconnected"
+        }
+    }],
+    "rule": {
+        "id": 1,
+        "keyName": "SPREAD",
+        "name": "SPREAD"
+    }
+}
+
+deleteObject = True
diff --git a/SoftLayer/fixtures/SoftLayer_Virtual_PlacementGroup_Rule.py b/SoftLayer/fixtures/SoftLayer_Virtual_PlacementGroup_Rule.py
new file mode 100644
index 00000000..c933fd2d
--- /dev/null
+++ b/SoftLayer/fixtures/SoftLayer_Virtual_PlacementGroup_Rule.py
@@ -0,0 +1,7 @@
+getAllObjects = [
+    {
+        "id": 1,
+        "keyName": "SPREAD",
+        "name": "SPREAD"
+    }
+]
diff --git a/SoftLayer/managers/__init__.py b/SoftLayer/managers/__init__.py
index b6cc1faa..02e54b30 100644
--- a/SoftLayer/managers/__init__.py
+++ b/SoftLayer/managers/__init__.py
@@ -28,7 +28,7 @@
 from SoftLayer.managers.user import UserManager
 from SoftLayer.managers.vs import VSManager
 from SoftLayer.managers.vs_capacity import CapacityManager
-
+from SoftLayer.managers.vs_placement import PlacementManager
 
 __all__ = [
     'BlockStorageManager',
@@ -47,6 +47,7 @@
     'NetworkManager',
     'ObjectStorageManager',
     'OrderingManager',
+    'PlacementManager',
     'SshKeyManager',
     'SSLManager',
     'TicketManager',
diff --git a/SoftLayer/managers/vs.py b/SoftLayer/managers/vs.py
index 785ad383..0f5a6d26 100644
--- a/SoftLayer/managers/vs.py
+++ b/SoftLayer/managers/vs.py
@@ -233,7 +233,8 @@ def get_instance(self, instance_id, **kwargs):
                                          preset.keyName]],'''
                 'tagReferences[id,tag[name,id]],'
                 'networkVlans[id,vlanNumber,networkSpace],'
-                'dedicatedHost.id'
+                'dedicatedHost.id,'
+                'placementGroupId'
             )
 
         return self.guest.getObject(id=instance_id, **kwargs)
@@ -875,6 +876,7 @@ def order_guest(self, guest_object, test=False):
         :param dictionary guest_object: See SoftLayer.CLI.virt.create._parse_create_args
 
         Example::
+
             new_vsi = {
                 'domain': u'test01.labs.sftlyr.ws',
                 'hostname': u'minion05',
@@ -909,6 +911,8 @@ def order_guest(self, guest_object, test=False):
             # SL_Virtual_Guest::generateOrderTemplate() doesn't respect userData, so we need to add it ourself
             template['virtualGuests'][0]['userData'] = [{"value": guest_object.get('userdata')}]
 
+        if guest_object.get('placement_id'):
+            template['virtualGuests'][0]['placementGroupId'] = guest_object.get('placement_id')
         if test:
             result = self.client.call('Product_Order', 'verifyOrder', template)
         else:
diff --git a/SoftLayer/managers/vs_placement.py b/SoftLayer/managers/vs_placement.py
new file mode 100644
index 00000000..d40a845e
--- /dev/null
+++ b/SoftLayer/managers/vs_placement.py
@@ -0,0 +1,115 @@
+"""
+    SoftLayer.vs_placement
+    ~~~~~~~~~~~~~~~~~~~~~~~
+    Placement Group Manager
+
+    :license: MIT, see License for more details.
+"""
+
+import logging
+
+from SoftLayer import utils
+
+# Invalid names are ignored due to long method names and short argument names
+# pylint: disable=invalid-name, no-self-use
+
+LOGGER = logging.getLogger(__name__)
+
+
+class PlacementManager(utils.IdentifierMixin, object):
+    """Manages SoftLayer Reserved Capacity Groups.
+
+        Product Information
+
+        - https://console.test.cloud.ibm.com/docs/vsi/vsi_placegroup.html#placement-groups
+        - https://softlayer.github.io/reference/services/SoftLayer_Account/getPlacementGroups/
+        - https://softlayer.github.io/reference/services/SoftLayer_Virtual_PlacementGroup_Rule/
+
+        Existing instances cannot be added to a placement group.
+        You can only add a virtual server instance to a placement group at provisioning.
+        To remove an instance from a placement group, you must delete or reclaim the instance.
+
+    :param SoftLayer.API.BaseClient client: the client instance
+    """
+
+    def __init__(self, client):
+        self.client = client
+        self.account = client['Account']
+        self.resolvers = [self._get_id_from_name]
+
+    def list(self, mask=None):
+        """List existing placement groups
+
+        Calls SoftLayer_Account::getPlacementGroups
+         """
+        if mask is None:
+            mask = "mask[id, name, createDate, rule, guestCount, backendRouter[id, hostname]]"
+        groups = self.client.call('Account', 'getPlacementGroups', mask=mask, iter=True)
+        return groups
+
+    def create(self, placement_object):
+        """Creates a placement group
+
+        :param dictionary placement_object: Below are the fields you can specify, taken from
+            https://softlayer.github.io/reference/datatypes/SoftLayer_Virtual_PlacementGroup/
+            placement_object = {
+                'backendRouterId': 12345,
+                'name': 'Test Name',
+                'ruleId': 12345
+            }
+
+        """
+        return self.client.call('SoftLayer_Virtual_PlacementGroup', 'createObject', placement_object)
+
+    def get_routers(self):
+        """Calls SoftLayer_Virtual_PlacementGroup::getAvailableRouters()"""
+        return self.client.call('SoftLayer_Virtual_PlacementGroup', 'getAvailableRouters')
+
+    def get_object(self, group_id, mask=None):
+        """Returns a PlacementGroup Object
+
+        https://softlayer.github.io/reference/services/SoftLayer_Virtual_PlacementGroup/getObject
+        """
+        if mask is None:
+            mask = "mask[id, name, createDate, rule, backendRouter[id, hostname]," \
+                   "guests[activeTransaction[id,transactionStatus[name,friendlyName]]]]"
+        return self.client.call('SoftLayer_Virtual_PlacementGroup', 'getObject', id=group_id, mask=mask)
+
+    def delete(self, group_id):
+        """Deletes a PlacementGroup
+
+        Placement group must be empty to be deleted.
+        https://softlayer.github.io/reference/services/SoftLayer_Virtual_PlacementGroup/deleteObject
+        """
+        return self.client.call('SoftLayer_Virtual_PlacementGroup', 'deleteObject', id=group_id)
+
+    def get_all_rules(self):
+        """Returns all available rules for creating a placement group"""
+        return self.client.call('SoftLayer_Virtual_PlacementGroup_Rule', 'getAllObjects')
+
+    def get_rule_id_from_name(self, name):
+        """Finds the rule that matches name.
+
+        SoftLayer_Virtual_PlacementGroup_Rule.getAllObjects doesn't support objectFilters.
+        """
+        results = self.client.call('SoftLayer_Virtual_PlacementGroup_Rule', 'getAllObjects')
+        return [result['id'] for result in results if result['keyName'] == name.upper()]
+
+    def get_backend_router_id_from_hostname(self, hostname):
+        """Finds the backend router Id that matches the hostname given
+
+        No way to use an objectFilter to find a backendRouter, so we have to search the hard way.
+        """
+        results = self.client.call('SoftLayer_Network_Pod', 'getAllObjects')
+        return [result['backendRouterId'] for result in results if result['backendRouterName'] == hostname.lower()]
+
+    def _get_id_from_name(self, name):
+        """List placement group ids which match the given name."""
+        _filter = {
+            'placementGroups': {
+                'name': {'operation': name}
+            }
+        }
+        mask = "mask[id, name]"
+        results = self.client.call('Account', 'getPlacementGroups', filter=_filter, mask=mask)
+        return [result['id'] for result in results]
diff --git a/SoftLayer/transports.py b/SoftLayer/transports.py
index 3aa896f1..9ff8bdaf 100644
--- a/SoftLayer/transports.py
+++ b/SoftLayer/transports.py
@@ -34,7 +34,7 @@
 ]
 
 REST_SPECIAL_METHODS = {
-    'deleteObject': 'DELETE',
+    # 'deleteObject': 'DELETE',
     'createObject': 'POST',
     'createObjects': 'POST',
     'editObject': 'PUT',
diff --git a/docs/cli/users.rst b/docs/cli/users.rst
index 44cd7155..3c98199a 100644
--- a/docs/cli/users.rst
+++ b/docs/cli/users.rst
@@ -5,6 +5,7 @@ Users
 Version 5.6.0 introduces the ability to interact with user accounts from the cli. 
 
 .. _cli_user_create:
+
 user create
 -----------
 This command will create a user on your account.
@@ -19,6 +20,7 @@ Options
 -h, --help            Show this message and exit.
 
 ::
+
     slcli user create my@email.com -e my@email.com -p generate -a -t '{"firstName": "Test", "lastName": "Testerson"}'
 
 .. _cli_user_list:
@@ -83,11 +85,12 @@ Edit a User's details
 JSON strings should be enclosed in '' and each item should be enclosed in "\"
 
 ::
+
   slcli user edit-details testUser -t '{"firstName": "Test", "lastName": "Testerson"}'
 
 Options
 ^^^^^^^
--t, --template TEXT  A json string describing `SoftLayer_User_Customer
-https://softlayer.github.io/reference/datatypes/SoftLayer_User_Customer/`_.  [required]
+
+-t, --template TEXT  A json string describing `SoftLayer_User_Customer <https://softlayer.github.io/reference/datatypes/SoftLayer_User_Customer/>`_ .  [required]
 -h, --help           Show this message and exit.
 
diff --git a/docs/cli/vs.rst b/docs/cli/vs.rst
index 55ee3c18..2276bd7e 100644
--- a/docs/cli/vs.rst
+++ b/docs/cli/vs.rst
@@ -81,15 +81,33 @@ datacenter using the command `slcli vs create`.
 
 ::
 
-	$ slcli vs create --hostname=example --domain=softlayer.com --cpu 2 --memory 1024 -o DEBIAN_LATEST_64  --datacenter=ams01 --billing=hourly
+	$ slcli vs create --hostname=example --domain=softlayer.com -f B1_1X2X25 -o DEBIAN_LATEST_64  --datacenter=ams01 --billing=hourly
 	This action will incur charges on your account. Continue? [y/N]: y
-	:.........:......................................:
-	:    name : value                                :
-	:.........:......................................:
-	:      id : 1234567                              :
-	: created : 2013-06-13T08:29:44-06:00            :
-	:    guid : 6e013cde-a863-46ee-8s9a-f806dba97c89 :
-	:.........:......................................:
+        :..........:.................................:......................................:...........................:
+        :    ID    :               FQDN              :                 guid                 :         Order Date        :
+        :..........:.................................:......................................:...........................:
+        : 70112999 : testtesttest.test.com : 1abc7afb-9618-4835-89c9-586f3711d8ea : 2019-01-30T17:16:58-06:00 :
+        :..........:.................................:......................................:...........................:
+        :.........................................................................:
+        :                            OrderId: 12345678                            :
+        :.......:.................................................................:
+        :  Cost : Description                                                     :
+        :.......:.................................................................:
+        :   0.0 : Debian GNU/Linux 9.x Stretch/Stable - Minimal Install (64 bit)  :
+        :   0.0 : 25 GB (SAN)                                                     :
+        :   0.0 : Reboot / Remote Console                                         :
+        :   0.0 : 100 Mbps Public & Private Network Uplinks                       :
+        :   0.0 : 0 GB Bandwidth Allotment                                        :
+        :   0.0 : 1 IP Address                                                    :
+        :   0.0 : Host Ping and TCP Service Monitoring                            :
+        :   0.0 : Email and Ticket                                                :
+        :   0.0 : Automated Reboot from Monitoring                                :
+        :   0.0 : Unlimited SSL VPN Users & 1 PPTP VPN User per account           :
+        :   0.0 : Nessus Vulnerability Assessment & Reporting                     :
+        :   0.0 : 2 GB                                                            :
+        :   0.0 : 1 x 2.0 GHz or higher Core                                      :
+        : 0.000 : Total hourly cost                                               :
+        :.......:.................................................................:
 
 
 After the last command, the virtual server is now being built. It should
@@ -194,3 +212,10 @@ Reserved Capacity
 
     vs/reserved_capacity
 
+Placement Groups
+----------------
+.. toctree::
+    :maxdepth: 2
+
+    vs/placement_group
+
diff --git a/docs/cli/vs/placement_group.rst b/docs/cli/vs/placement_group.rst
new file mode 100644
index 00000000..c6aa0994
--- /dev/null
+++ b/docs/cli/vs/placement_group.rst
@@ -0,0 +1,132 @@
+.. _vs_placement_group_user_docs:
+
+Working with Placement Groups
+=============================
+A `Placement Group <https://cloud.ibm.com/docs/vsi/vsi_placegroup.html#placement-groups>`_  is a way to control which physical servers your virtual servers get provisioned onto. 
+
+To create a  `Virtual_PlacementGroup <https://softlayer.github.io/reference/services/SoftLayer_Virtual_PlacementGroup/>`_ object, you will need to know the following:
+
+- backendRouterId,  from `getAvailableRouters <https://softlayer.github.io/reference/services/SoftLayer_Virtual_PlacementGroup/getAvailableRouters>`_
+- ruleId, from `getAllObjects <https://softlayer.github.io/reference/services/SoftLayer_Virtual_PlacementGroup_Rule/getAllObjects/>`_
+- name, can be any string, but most be unique on your account
+
+Once a placement group is created, you can create new virtual servers in that group. Existing VSIs cannot be moved into a placement group. When ordering a VSI in a placement group, make sure to set the `placementGroupId <https://softlayer.github.io/reference/datatypes/SoftLayer_Virtual_Guest/#placementGroupId>`_ for each guest in your order. 
+
+use the --placementgroup option with `vs create` to specify creating a VSI in a specific group.
+
+::
+
+    
+    $ slcli  vs create -H testGroup001 -D test.com -f  B1_1X2X25 -d mex01 -o DEBIAN_LATEST --placementgroup testGroup
+
+Placement groups can only be deleted once all the virtual guests in the group have been reclaimed.
+
+.. _cli_vs_placementgroup_create:
+
+vs placementgroup create
+------------------------
+This command will create a placement group. 
+
+::
+
+    $ slcli vs placementgroup create --name testGroup -b bcr02a.dal06 -r SPREAD
+
+Options
+^^^^^^^
+--name TEXT                     Name for this new placement group.  [required]
+-b, --backend_router TEXT       backendRouter, can be either the hostname or id.  [required]
+-r, --rule TEXT            The keyName or Id of the rule to govern this placement group.  [required]
+
+
+.. _cli_vs_placementgroup_create_options:
+
+vs placementgroup create-options
+--------------------------------
+This command will print out the available routers and rule sets for use in creating a placement group.
+
+::
+
+    $ slcli vs placementgroup create-options
+    :.................................................:
+    :                Available Routers                :
+    :..............:..............:...................:
+    :  Datacenter  :   Hostname   : Backend Router Id :
+    :..............:..............:...................:
+    : Washington 1 : bcr01.wdc01  :       16358       :
+    :   Tokyo 5    : bcr01a.tok05 :      1587015      :
+    :..............:..............:...................:
+    :..............:
+    :    Rules     :
+    :....:.........:
+    : Id : KeyName :
+    :....:.........:
+    : 1  :  SPREAD :
+    :....:.........:
+
+.. _cli_vs_placementgroup_delete:
+
+vs placementgroup delete
+------------------------
+This command will remove a placement group. The placement group needs to be empty for this command to succeed.
+
+Options
+^^^^^^^
+--purge     Delete all guests in this placement group. The group itself can be deleted once all VMs are fully reclaimed
+
+::
+
+    $ slcli vs placementgroup delete testGroup
+
+You can use the flag --purge to auto-cancel all VSIs in a placement group. You will still need to wait for them to be reclaimed before proceeding to delete the group itself.
+
+::
+
+    $ slcli vs placementgroup delete testGroup --purge
+        You are about to delete the following guests!
+        issues10691547768562.test.com, issues10691547768572.test.com, issues10691547768552.test.com, issues10691548718280.test.com
+        This action will cancel all guests! Continue? [y/N]: y
+        Deleting issues10691547768562.test.com...
+        Deleting issues10691547768572.test.com...
+        Deleting issues10691547768552.test.com...
+        Deleting issues10691548718280.test.com...
+
+
+.. _cli_vs_placementgroup_list:
+
+vs placementgroup list
+----------------------
+This command will list all placement groups on your account. 
+
+::
+
+    $ slcli vs placementgroup list
+    :..........................................................................................:
+    :                                     Placement Groups                                     :
+    :.......:...................:................:........:........:...........................:
+    :   Id  :        Name       : Backend Router :  Rule  : Guests :          Created          :
+    :.......:...................:................:........:........:...........................:
+    : 31741 :       fotest      :  bcr01a.tor01  : SPREAD :   1    : 2018-11-22T14:36:10-06:00 :
+    : 64535 :     testGroup     :  bcr01a.mex01  : SPREAD :   3    : 2019-01-17T14:36:42-06:00 :
+    :.......:...................:................:........:........:...........................:
+
+.. _cli_vs_placementgroup_detail:
+
+vs placementgroup detail
+------------------------
+This command will provide some detailed information about a specific placement group
+
+::
+
+    $ slcli vs placementgroup detail testGroup
+    :.......:............:................:........:...........................:
+    :   Id  :     Name   : Backend Router :  Rule  :          Created          :
+    :.......:............:................:........:...........................:
+    : 64535 : testGroup  :  bcr01a.mex01  : SPREAD : 2019-01-17T14:36:42-06:00 :
+    :.......:............:................:........:...........................:
+    :..........:........................:...............:..............:.....:........:...........................:.............:
+    :    Id    :           FQDN         :   Primary IP  :  Backend IP  : CPU : Memory :        Provisioned        : Transaction :
+    :..........:........................:...............:..............:.....:........:...........................:.............:
+    : 69134895 : testGroup62.test.com   : 169.57.70.166 : 10.131.11.32 :  1  :  1024  : 2019-01-17T17:44:50-06:00 :      -      :
+    : 69134901 : testGroup72.test.com   : 169.57.70.184 : 10.131.11.59 :  1  :  1024  : 2019-01-17T17:44:53-06:00 :      -      :
+    : 69134887 : testGroup52.test.com   : 169.57.70.187 : 10.131.11.25 :  1  :  1024  : 2019-01-17T17:44:43-06:00 :      -      :
+    :..........:........................:...............:..............:.....:........:...........................:.............:
\ No newline at end of file


Test patch:
diff --git a/tests/CLI/modules/vs/vs_capacity_tests.py b/tests/CLI/modules/vs/vs_capacity_tests.py
index 3dafee34..2cee000a 100644
--- a/tests/CLI/modules/vs/vs_capacity_tests.py
+++ b/tests/CLI/modules/vs/vs_capacity_tests.py
@@ -4,6 +4,7 @@
 
     :license: MIT, see LICENSE for more details.
 """
+import json
 from SoftLayer.fixtures import SoftLayer_Product_Order
 from SoftLayer.fixtures import SoftLayer_Product_Package
 from SoftLayer import testing
@@ -15,6 +16,26 @@ def test_list(self):
         result = self.run_command(['vs', 'capacity', 'list'])
         self.assert_no_fail(result)
 
+    def test_list_no_billing(self):
+        account_mock = self.set_mock('SoftLayer_Account', 'getReservedCapacityGroups')
+        account_mock.return_value = [
+            {
+                'id': 3103,
+                'name': 'test-capacity',
+                'createDate': '2018-09-24T16:33:09-06:00',
+                'availableInstanceCount': 1,
+                'instanceCount': 3,
+                'occupiedInstanceCount': 1,
+                'backendRouter': {
+                    'hostname': 'bcr02a.dal13',
+                },
+                'instances': [{'id': 3501}]
+            }
+        ]
+        result = self.run_command(['vs', 'capacity', 'list'])
+        self.assert_no_fail(result)
+        self.assertEqual(json.loads(result.output)[0]['Flavor'], 'Unknown Billing Item')
+
     def test_detail(self):
         result = self.run_command(['vs', 'capacity', 'detail', '1234'])
         self.assert_no_fail(result)
diff --git a/tests/CLI/modules/vs/vs_placement_tests.py b/tests/CLI/modules/vs/vs_placement_tests.py
new file mode 100644
index 00000000..3b716a6c
--- /dev/null
+++ b/tests/CLI/modules/vs/vs_placement_tests.py
@@ -0,0 +1,109 @@
+"""
+    SoftLayer.tests.CLI.modules.vs_placement_tests
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    :license: MIT, see LICENSE for more details.
+"""
+import mock
+
+from SoftLayer import testing
+
+
+class VSPlacementTests(testing.TestCase):
+
+    def test_create_options(self):
+        result = self.run_command(['vs', 'placementgroup', 'create-options'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup', 'getAvailableRouters')
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup_Rule', 'getAllObjects')
+        self.assertEqual([], self.calls('SoftLayer_Virtual_PlacementGroup', 'createObject'))
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_create_group(self, confirm_mock):
+        confirm_mock.return_value = True
+        result = self.run_command(['vs', 'placementgroup', 'create', '--name=test', '--backend_router=1', '--rule=2'])
+        create_args = {
+            'name': 'test',
+            'backendRouterId': 1,
+            'ruleId': 2
+        }
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup', 'createObject', args=(create_args,))
+        self.assertEqual([], self.calls('SoftLayer_Virtual_PlacementGroup', 'getAvailableRouters'))
+
+    def test_list_groups(self):
+        result = self.run_command(['vs', 'placementgroup', 'list'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Account', 'getPlacementGroups')
+
+    def test_detail_group_id(self):
+        result = self.run_command(['vs', 'placementgroup', 'detail', '12345'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup', 'getObject', identifier=12345)
+
+    def test_detail_group_name(self):
+        result = self.run_command(['vs', 'placementgroup', 'detail', 'test'])
+        self.assert_no_fail(result)
+        group_filter = {
+            'placementGroups': {
+                'name': {'operation': 'test'}
+            }
+        }
+        self.assert_called_with('SoftLayer_Account', 'getPlacementGroups', filter=group_filter)
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup', 'getObject', identifier=12345)
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_delete_group_id(self, confirm_mock):
+        confirm_mock.return_value = True
+        result = self.run_command(['vs', 'placementgroup', 'delete', '12345'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup', 'deleteObject', identifier=12345)
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_delete_group_id_cancel(self, confirm_mock):
+        confirm_mock.return_value = False
+        result = self.run_command(['vs', 'placementgroup', 'delete', '12345'])
+        self.assertEqual(result.exit_code, 2)
+        self.assertEqual([], self.calls('SoftLayer_Virtual_PlacementGroup', 'deleteObject'))
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_delete_group_name(self, confirm_mock):
+        confirm_mock.return_value = True
+        result = self.run_command(['vs', 'placementgroup', 'delete', 'test'])
+        group_filter = {
+            'placementGroups': {
+                'name': {'operation': 'test'}
+            }
+        }
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Account', 'getPlacementGroups', filter=group_filter)
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup', 'deleteObject', identifier=12345)
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_delete_group_purge(self, confirm_mock):
+        confirm_mock.return_value = True
+        result = self.run_command(['vs', 'placementgroup', 'delete', '1234', '--purge'])
+        self.assert_no_fail(result)
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup', 'getObject')
+        self.assert_called_with('SoftLayer_Virtual_Guest', 'deleteObject', identifier=69131875)
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_delete_group_purge_cancel(self, confirm_mock):
+        confirm_mock.return_value = False
+        result = self.run_command(['vs', 'placementgroup', 'delete', '1234', '--purge'])
+        self.assertEqual(result.exit_code, 2)
+        self.assertEqual([], self.calls('SoftLayer_Virtual_Guest', 'deleteObject'))
+
+    @mock.patch('SoftLayer.CLI.formatting.confirm')
+    def test_delete_group_purge_nothing(self, confirm_mock):
+        group_mock = self.set_mock('SoftLayer_Virtual_PlacementGroup', 'getObject')
+        group_mock.return_value = {
+            "id": 1234,
+            "name": "test-group",
+            "guests": [],
+        }
+        confirm_mock.return_value = True
+        result = self.run_command(['vs', 'placementgroup', 'delete', '1234', '--purge'])
+        self.assertEqual(result.exit_code, 2)
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup', 'getObject')
+        self.assertEqual([], self.calls('SoftLayer_Virtual_Guest', 'deleteObject'))
diff --git a/tests/managers/vs/vs_capacity_tests.py b/tests/managers/vs/vs_capacity_tests.py
index 751b3175..5229ebec 100644
--- a/tests/managers/vs/vs_capacity_tests.py
+++ b/tests/managers/vs/vs_capacity_tests.py
@@ -46,6 +46,16 @@ def test_get_available_routers(self):
         self.assert_called_with('SoftLayer_Network_Pod', 'getAllObjects')
         self.assertEqual(result[0]['keyname'], 'WASHINGTON07')
 
+    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'}}
+        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)
+        self.assertEqual(result[0]['keyname'], 'WASHINGTON07')
+
     def test_create(self):
         item_mock = self.set_mock('SoftLayer_Product_Package', 'getItems')
         item_mock.return_value = SoftLayer_Product_Package.getItems_RESERVED_CAPACITY
diff --git a/tests/managers/vs/vs_placement_tests.py b/tests/managers/vs/vs_placement_tests.py
new file mode 100644
index 00000000..b492f69b
--- /dev/null
+++ b/tests/managers/vs/vs_placement_tests.py
@@ -0,0 +1,77 @@
+"""
+    SoftLayer.tests.managers.vs.vs_placement_tests
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    :license: MIT, see LICENSE for more details.
+
+"""
+import mock
+
+from SoftLayer.managers.vs_placement import PlacementManager
+from SoftLayer import testing
+
+
+class VSPlacementManagerTests(testing.TestCase):
+
+    def set_up(self):
+        self.manager = PlacementManager(self.client)
+
+    def test_list(self):
+        self.manager.list()
+        self.assert_called_with('SoftLayer_Account', 'getPlacementGroups', mask=mock.ANY)
+
+    def test_list_mask(self):
+        mask = "mask[id]"
+        self.manager.list(mask)
+        self.assert_called_with('SoftLayer_Account', 'getPlacementGroups', mask=mask)
+
+    def test_create(self):
+        placement_object = {
+            'backendRouter': 1234,
+            'name': 'myName',
+            'ruleId': 1
+        }
+        self.manager.create(placement_object)
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup', 'createObject', args=(placement_object,))
+
+    def test_get_object(self):
+        self.manager.get_object(1234)
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup', 'getObject', identifier=1234, mask=mock.ANY)
+
+    def test_get_object_with_mask(self):
+        mask = "mask[id]"
+        self.manager.get_object(1234, mask)
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup', 'getObject', identifier=1234, mask=mask)
+
+    def test_delete(self):
+        self.manager.delete(1234)
+        self.assert_called_with('SoftLayer_Virtual_PlacementGroup', 'deleteObject', identifier=1234)
+
+    def test_get_id_from_name(self):
+        self.manager._get_id_from_name('test')
+        _filter = {
+            'placementGroups': {
+                'name': {'operation': 'test'}
+            }
+        }
+        self.assert_called_with('SoftLayer_Account', 'getPlacementGroups', filter=_filter, mask="mask[id, name]")
+
+    def test_get_rule_id_from_name(self):
+        result = self.manager.get_rule_id_from_name('SPREAD')
+        self.assertEqual(result[0], 1)
+        result = self.manager.get_rule_id_from_name('SpReAd')
+        self.assertEqual(result[0], 1)
+
+    def test_get_rule_id_from_name_failure(self):
+        result = self.manager.get_rule_id_from_name('SPREAD1')
+        self.assertEqual(result, [])
+
+    def test_router_search(self):
+        result = self.manager.get_backend_router_id_from_hostname('bcr01a.ams01')
+        self.assertEqual(result[0], 117917)
+        result = self.manager.get_backend_router_id_from_hostname('bcr01A.AMS01')
+        self.assertEqual(result[0], 117917)
+
+    def test_router_search_failure(self):
+        result = self.manager.get_backend_router_id_from_hostname('1234.ams01')
+        self.assertEqual(result, [])