Repository: ansible/tower-cli
Pull request #220 (2016-10-06 04:18:36)

Introduce Standard Out Streaming with wait as old monitoring
Connect #74 

Connect #213 
## Examples of Using This Feature

Launch a job from job template number 7. Stream its standard output.

`tower-cli job launch -J 7 --monitor`

Launch that job, hold on until it finishes, but use the old wait text (which will delete itself).

`tower-cli job launch -J 7 --wait`

Print the standard out of an existing job to the command line.

`tower-cli job stdout 119`

Sync an inventory group. Stream the standard out input from the inventory module.

`tower-cli group sync --name open_stack --monitor`

Note that you can grab both `status` and `stdout` for a project. This should raise eyebrows, because technically a project doesn't have standard out (only its project update does). Actually, we grab the last job ran and show the output from that. That design decision was made a while ago, but this PR extends the logic to connect more things.

Create a project and update it. Also stream the standard out of the update.

`tower-cli project create --name="AlanCoding examples" --organization "Default" --scm-type git --scm-url https://github.com/AlanCoding/permission-testing-playbooks.git --monitor`
## Game Plans

This contains a departure from previous behavior. Anyone who uses tower-cli to connect Jenkins to Tower would be best-advised to switch from `--monitor` flags to `--wait`. On the other hand, if you like details, don't change anything.

Those above examples should also probably be converted into docs sometime and somewhere.

As a final note, this is kind of a half-way toward giving tower-cli decent abstraction for Unified Job Templates versus Unified Jobs. I foresee this being built on top of if, for instance, there was a request to incorporate a new type of Unified Job Template that also had to manage its own unique type of jobs.

Additionally, a great amount of credit goes to @chrismeyersfsu for the original prototype of this.


coveralls: 
[![Coverage Status](https://coveralls.io/builds/8208807/badge)](https://coveralls.io/builds/8208807)

Coverage decreased (-0.7%) to 98.314% when pulling **ac68f3fa8e13e2bfaf1df0851bfae690cae90652 on AlanCoding:stdm3** into **7c283036d0f4e50c6250bc20c7c48f5ed674a92b on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8208815/badge)](https://coveralls.io/builds/8208815)

Coverage decreased (-0.7%) to 98.314% when pulling **ac68f3fa8e13e2bfaf1df0851bfae690cae90652 on AlanCoding:stdm3** into **7c283036d0f4e50c6250bc20c7c48f5ed674a92b on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8214577/badge)](https://coveralls.io/builds/8214577)

Coverage decreased (-0.7%) to 98.313% when pulling **007575bcaa583e07ceab1dfe1e922f25548192a5 on AlanCoding:stdm3** into **7c283036d0f4e50c6250bc20c7c48f5ed674a92b on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8214612/badge)](https://coveralls.io/builds/8214612)

Coverage decreased (-0.7%) to 98.312% when pulling **a1230c71cb689314201d63f0ed6a17563f7ab715 on AlanCoding:stdm3** into **7c283036d0f4e50c6250bc20c7c48f5ed674a92b on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8214928/badge)](https://coveralls.io/builds/8214928)

Coverage decreased (-0.7%) to 98.321% when pulling **d5a891a4b7d0c03e1481e8a14bdf636f46c3a9d6 on AlanCoding:stdm3** into **7c283036d0f4e50c6250bc20c7c48f5ed674a92b on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8214992/badge)](https://coveralls.io/builds/8214992)

Coverage decreased (-0.7%) to 98.315% when pulling **839ff3ca7f438e07846bcda3c2345e027f41cb73 on AlanCoding:stdm3** into **7c283036d0f4e50c6250bc20c7c48f5ed674a92b on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8215035/badge)](https://coveralls.io/builds/8215035)

Coverage decreased (-0.7%) to 98.316% when pulling **6748c32ea7accfe231445217f5ed3e96cb39736a on AlanCoding:stdm3** into **7c283036d0f4e50c6250bc20c7c48f5ed674a92b on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8262212/badge)](https://coveralls.io/builds/8262212)

Coverage decreased (-0.7%) to 98.363% when pulling **4c15d4b6da905e25497eb29c7e9afb66fe0b43c4 on AlanCoding:stdm3** into **7c283036d0f4e50c6250bc20c7c48f5ed674a92b on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8262212/badge)](https://coveralls.io/builds/8262212)

Coverage decreased (-0.7%) to 98.363% when pulling **4c15d4b6da905e25497eb29c7e9afb66fe0b43c4 on AlanCoding:stdm3** into **7c283036d0f4e50c6250bc20c7c48f5ed674a92b on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8262212/badge)](https://coveralls.io/builds/8262212)

Coverage decreased (-0.7%) to 98.363% when pulling **4c15d4b6da905e25497eb29c7e9afb66fe0b43c4 on AlanCoding:stdm3** into **7c283036d0f4e50c6250bc20c7c48f5ed674a92b on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8263748/badge)](https://coveralls.io/builds/8263748)

Coverage decreased (-0.6%) to 98.363% when pulling **fad63795420c6fa444f142081acf8cf8fa2b1f03 on AlanCoding:stdm3** into **9a7574a896f92be5802e0504897e8458f858e6e9 on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8263748/badge)](https://coveralls.io/builds/8263748)

Coverage decreased (-0.6%) to 98.363% when pulling **fad63795420c6fa444f142081acf8cf8fa2b1f03 on AlanCoding:stdm3** into **9a7574a896f92be5802e0504897e8458f858e6e9 on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8304275/badge)](https://coveralls.io/builds/8304275)

Coverage decreased (-0.6%) to 98.362% when pulling **9099d060ff7a11fdf61f20e970bff3c0f74e2994 on AlanCoding:stdm3** into **9a7574a896f92be5802e0504897e8458f858e6e9 on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8304275/badge)](https://coveralls.io/builds/8304275)

Coverage decreased (-0.6%) to 98.362% when pulling **9099d060ff7a11fdf61f20e970bff3c0f74e2994 on AlanCoding:stdm3** into **9a7574a896f92be5802e0504897e8458f858e6e9 on ansible:master**.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/8304275/badge)](https://coveralls.io/builds/8304275)

Coverage decreased (-0.6%) to 98.362% when pulling **9099d060ff7a11fdf61f20e970bff3c0f74e2994 on AlanCoding:stdm3** into **9a7574a896f92be5802e0504897e8458f858e6e9 on ansible:master**.


Patch:
diff --git a/tower_cli/models/base.py b/tower_cli/models/base.py
index d6b515d..6dde641 100644
--- a/tower_cli/models/base.py
+++ b/tower_cli/models/base.py
@@ -25,6 +25,7 @@ import re
 import sys
 import time
 from copy import copy
+from base64 import b64decode
 
 import six
 
@@ -829,11 +830,150 @@ class MonitorableResource(ResourceMethods):
     """
     abstract = True  # Not inherited.
 
+    def __init__(self, *args, **kwargs):
+        if not hasattr(self, 'unified_job_type'):
+            self.unified_job_type = self.endpoint
+        return super(MonitorableResource, self).__init__(*args, **kwargs)
+
     def status(self, pk, detail=False):
         """A stub method requesting the status of the resource."""
         raise NotImplementedError('This resource does not implement a status '
                                   'method, and must do so.')
 
+    def last_job_data(self, pk=None, **kwargs):
+        """
+        Internal utility function for Unified Job Templates
+        Returns data about the last job run off of that UJT
+        """
+        ujt = self.get(pk, include_debug_header=True, **kwargs)
+
+        # Determine the appropriate inventory source update.
+        if 'current_update' in ujt['related']:
+            debug.log('A current job; retrieving it.', header='details')
+            return client.get(ujt['related']['current_update'][7:]).json()
+        elif ujt['related'].get('last_update', None):
+            debug.log('No current job or update exists; retrieving the most '
+                      'recent.', header='details')
+            return client.get(ujt['related']['last_update'][7:]).json()
+        else:
+            raise exc.NotFound('No related jobs or updates exist.')
+
+    def lookup_stdout(self, pk=None, start_line=None, end_line=None):
+        """
+        Internal utility function to return standard out
+        requires the pk of a unified job
+        """
+        stdout_url = '%s%d/stdout/' % (self.unified_job_type, pk)
+        payload = {
+            'format': 'json', 'content_encoding': 'base64',
+            'content_format': 'ansi'}
+        if start_line:
+            payload['start_line'] = start_line
+        if end_line:
+            payload['end_line'] = end_line
+        debug.log('Requesting a copy of job standard output', header='details')
+        resp = client.get(stdout_url, params=payload).json()
+        content = b64decode(resp['content'])
+
+        return content
+
+    @resources.command
+    @click.option('--start-line', required=False, type=int,
+                  help='Line at which to start printing the standard out.')
+    @click.option('--end-line', required=False, type=int,
+                  help='Line at which to end printing the standard out.')
+    def stdout(self, pk, start_line=None, end_line=None, **kwargs):
+        """
+        Print out the standard out of a unified job to the command line.
+        For Projects, print the standard out of most recent update
+        For Inventory Sources, print standard out of most recent sync
+        For Jobs, print the job's standard out
+        """
+        # resource is Unified Job Template
+        if self.unified_job_type != self.endpoint:
+            unified_job = self.last_job_data(pk, **kwargs)
+            pk = unified_job['id']
+        # resource is Unified Job, but pk not given
+        elif not pk:
+            unified_job = self.get(**kwargs)
+            pk = unified_job['id']
+
+        content = self.lookup_stdout(pk, start_line, end_line)
+        if len(content) > 0:
+            click.echo(content, nl=1)
+
+        return {"changed": False}
+
+    @resources.command
+    @click.option('--interval', default=0.2,
+                  help='Polling interval to refresh content from Tower.')
+    @click.option('--timeout', required=False, type=int,
+                  help='If provided, this command (not the job) will time out '
+                       'after the given number of seconds.')
+    def monitor(self, pk, parent_pk=None, timeout=None, interval=0.2,
+                outfile=sys.stdout, **kwargs):
+        """
+        Stream the standard output from a
+            job, project update, or inventory udpate.
+        """
+        # If we do not have the unified job info, infer it from parent
+        if pk is None:
+            pk = self.last_job_data(parent_pk, **kwargs)['id']
+        job_endpoint = '%s%s/' % (self.unified_job_type, pk)
+
+        # Pause until job is in running state
+        self.wait(pk, exit_on=['running', 'successful'])
+
+        # Loop initialization
+        start = time.time()
+        start_line = 0
+        result = client.get(job_endpoint).json()
+
+        click.echo('\033[0;91m------Starting Standard Out Stream------\033[0m',
+                   nl=2, file=outfile)
+
+        # Poll the Ansible Tower instance for status and content,
+        # and print standard out to the out file
+        while not result['failed'] and result['status'] != 'successful':
+
+            result = client.get(job_endpoint).json()
+
+            # Put the process to sleep briefly.
+            time.sleep(interval)
+
+            # Make request to get standard out
+            content = self.lookup_stdout(pk, start_line)
+
+            # In the first moments of running the job, the standard out
+            # may not be available yet
+            if not content.startswith("Waiting for results"):
+                line_count = len(content.splitlines())
+                start_line += line_count
+                click.echo(content, nl=0)
+
+            if timeout and time.time() - start > timeout:
+                raise exc.Timeout('Monitoring aborted due to timeout.')
+
+        click.echo('\033[0;91m------End of Standard Out Stream--------\033[0m',
+                   nl=2, file=outfile)
+
+        if result['failed']:
+            raise exc.JobFailure('Job failed.')
+
+        # Return the job ID and other response data
+        answer = OrderedDict((
+            ('changed', True),
+            ('id', pk),
+        ))
+        answer.update(result)
+        # Make sure to return ID of resource and not update number
+        # relevant for project creation and update
+        if parent_pk:
+            answer['id'] = parent_pk
+        else:
+            answer['id'] = pk
+        return answer
+
     @resources.command
     @click.option('--min-interval',
                   default=1, help='The minimum interval to request an update '
@@ -844,13 +984,19 @@ class MonitorableResource(ResourceMethods):
     @click.option('--timeout', required=False, type=int,
                   help='If provided, this command (not the job) will time out '
                        'after the given number of seconds.')
-    def monitor(self, pk, min_interval=1, max_interval=30,
-                timeout=None, outfile=sys.stdout, **kwargs):
-        """Monitor a running job.
-
+    def wait(self, pk, parent_pk=None, min_interval=1, max_interval=30,
+             timeout=None, outfile=sys.stdout, exit_on=['successful'],
+             **kwargs):
+        """
+        Wait for a running job to finish.
         Blocks further input until the job completes (whether successfully or
         unsuccessfully) and a final status can be given.
         """
+        # If we do not have the unified job info, infer it from parent
+        if pk is None:
+            pk = self.last_job_data(parent_pk, **kwargs)['id']
+        job_endpoint = '%s%s/' % (self.unified_job_type, pk)
+
         dots = itertools.cycle([0, 1, 2, 3])
         longest_string = 0
         interval = min_interval
@@ -864,10 +1010,10 @@ class MonitorableResource(ResourceMethods):
         # and run in Python.  This seems fine; outfile can be set to /dev/null
         # and very much the normal use for this method should be CLI
         # monitoring.
-        result = self.status(pk, detail=True)
+        result = client.get(job_endpoint).json()
         last_poll = time.time()
         timeout_check = 0
-        while result['status'] != 'successful':
+        while result['status'] not in exit_on:
             # If the job has failed, we want to raise an Exception for that
             # so we get a non-zero response.
             if result['failed']:
@@ -916,7 +1062,7 @@ class MonitorableResource(ResourceMethods):
             # to the next time that we intend to do a check, and once that
             # time hits, we do the status check as part of the normal cycle.
             if time.time() - last_poll > interval:
-                result = self.status(pk, detail=True)
+                result = client.get(job_endpoint).json()
                 last_poll = time.time()
                 interval = min(interval * 1.5, max_interval)
 
@@ -939,7 +1085,10 @@ class MonitorableResource(ResourceMethods):
         answer.update(result)
         # Make sure to return ID of resource and not update number
         # relevant for project creation and update
-        answer['id'] = pk
+        if parent_pk:
+            answer['id'] = parent_pk
+        else:
+            answer['id'] = pk
         return answer
 
 
diff --git a/tower_cli/resources/ad_hoc.py b/tower_cli/resources/ad_hoc.py
index 33e6431..053d75d 100644
--- a/tower_cli/resources/ad_hoc.py
+++ b/tower_cli/resources/ad_hoc.py
@@ -77,6 +77,9 @@ class Resource(models.ExeResource):
     @click.option('--monitor', is_flag=True, default=False,
                   help='If sent, immediately calls `monitor` on the newly '
                        'launched command rather than exiting with a success.')
+    @click.option('--wait', is_flag=True, default=False,
+                  help='Monitor the status of the job, but do not print '
+                       'while job is in progress.')
     @click.option('--timeout', required=False, type=int,
                   help='If provided with --monitor, this attempt'
                        ' will time out after the given number of seconds. '
@@ -84,7 +87,8 @@ class Resource(models.ExeResource):
     @click.option('--become', required=False, is_flag=True,
                   help='If used, privledge escalation will be enabled for '
                        'this command.')
-    def launch(self, monitor=False, timeout=None, become=False, **kwargs):
+    def launch(self, monitor=False, wait=False, timeout=None, become=False,
+               **kwargs):
         """Launch a new ad-hoc command.
 
         Runs a user-defined command from Ansible Tower, immediately starts it,
@@ -115,6 +119,8 @@ class Resource(models.ExeResource):
         # monitor from here.
         if monitor:
             return self.monitor(command_id, timeout=timeout)
+        elif wait:
+            return self.wait(command_id, timeout=timeout)
 
         # Return the command ID and other response data
         answer = OrderedDict((
diff --git a/tower_cli/resources/group.py b/tower_cli/resources/group.py
index ccb0bb4..1b505fc 100644
--- a/tower_cli/resources/group.py
+++ b/tower_cli/resources/group.py
@@ -234,7 +234,7 @@ class Resource(models.Resource):
         # Return the superclass implementation.
         return super(Resource, self).list(**kwargs)
 
-    @click.argument('group', type=types.Related('group'))
+    @click.argument('group', required=False, type=types.Related('group'))
     @click.option('--monitor', is_flag=True, default=False,
                   help='If sent, immediately calls `monitor` on the newly '
                        'launched job rather than exiting with a success.')
@@ -242,14 +242,27 @@ class Resource(models.Resource):
                   help='If provided with --monitor, this command (not the job)'
                        ' will time out after the given number of seconds. '
                        'Does nothing if --monitor is not sent.')
-    @resources.command(use_fields_as_options=False, no_args_is_help=True)
+    @resources.command(no_args_is_help=True)
     def sync(self, group, monitor=False, timeout=None, **kwargs):
         """Update the given group's inventory source."""
 
         isrc = get_resource('inventory_source')
-        isid = self._get_inventory_source_id(group)
+        isid = self._get_inventory_source_id(group, kwargs)
         return isrc.update(isid, monitor=monitor, timeout=timeout, **kwargs)
 
+    @resources.command
+    @click.argument('group', required=False, type=types.Related('group'))
+    @click.option('--start-line', required=False, type=int,
+                  help='Line at which to start printing the standard out.')
+    @click.option('--end-line', required=False, type=int,
+                  help='Line at which to end printing the standard out.')
+    def stdout(self, group, start_line=None, end_line=None, **kwargs):
+        """Print the standard out of the last group update."""
+
+        isrc = get_resource('inventory_source')
+        isid = self._get_inventory_source_id(group, kwargs)
+        return isrc.stdout(isid)
+
     @resources.command(use_fields_as_options=False)
     @click.option('--group', help='The group to move.')
     @click.option('--parent', help='Destination group to move into.')
@@ -274,12 +287,15 @@ class Resource(models.Resource):
             group, kwargs.get('inventory', None))['id']
         return self._disassoc('children', parent_id, group_id)
 
-    def _get_inventory_source_id(self, group):
+    def _get_inventory_source_id(self, group, data=None):
         """Return the inventory source ID given a group dictionary returned
-        from the Tower API.
+        from the Tower API. Alternatively, get it from a group's identity
+        set in data.
         """
+        if group is None:
+            group = self.get(**data)
         # If we got a group ID rather than a group, get the group.
-        if isinstance(group, int):
+        elif isinstance(group, int):
             group = self.get(group)
 
         # Return the inventory source ID.
diff --git a/tower_cli/resources/inventory_source.py b/tower_cli/resources/inventory_source.py
index 8caab75..d73033d 100644
--- a/tower_cli/resources/inventory_source.py
+++ b/tower_cli/resources/inventory_source.py
@@ -24,10 +24,11 @@ class Resource(models.MonitorableResource):
     cli_help = 'Manage inventory sources within Ansible Tower.'
     endpoint = '/inventory_sources/'
     internal = True
+    unified_job_type = '/inventory_updates/'
 
     credential = models.Field(type=types.Related('credential'), required=False)
     source = models.Field(
-        default='manual',
+        default=None,
         help_text='The type of inventory source in use.',
         type=click.Choice(['', 'file', 'ec2', 'rax', 'vmware',
                            'gce', 'azure', 'azure_rm', 'openstack',
@@ -57,7 +58,8 @@ class Resource(models.MonitorableResource):
                        ' will time out after the given number of seconds. '
                        'Does nothing if --monitor is not sent.')
     @resources.command(use_fields_as_options=False, no_args_is_help=True)
-    def update(self, inventory_source, monitor=False, timeout=None, **kwargs):
+    def update(self, inventory_source, monitor=False, wait=False,
+               timeout=None, **kwargs):
         """Update the given inventory source."""
 
         # Establish that we are able to update this inventory source
@@ -74,8 +76,16 @@ class Resource(models.MonitorableResource):
         r = client.post('%s%d/update/' % (self.endpoint, inventory_source))
 
         # If we were told to monitor the project update's status, do so.
-        if monitor:
-            result = self.monitor(inventory_source, timeout=timeout)
+        if monitor or wait:
+            inventory_update_id = r.json()['inventory_update']
+            if monitor:
+                result = self.monitor(
+                    inventory_update_id, parent_pk=inventory_source,
+                    timeout=timeout)
+            elif wait:
+                result = self.wait(
+                    inventory_update_id, parent_pk=inventory_source,
+                    timeout=timeout)
             inventory = client.get('/inventory_sources/%d/' %
                                    result['inventory_source'])\
                               .json()['inventory']
@@ -88,23 +98,10 @@ class Resource(models.MonitorableResource):
     @resources.command
     @click.option('--detail', is_flag=True, default=False,
                   help='Print more detail.')
-    def status(self, pk, detail=False):
-        """Print the current job status."""
-        # Get the job from Ansible Tower.
-        debug.log('Asking for inventory source status.', header='details')
-        inv_src = client.get('/inventory_sources/%d/' % pk).json()
-
-        # Determine the appropriate inventory source update.
-        if 'current_update' in inv_src['related']:
-            debug.log('A current update exists; retrieving it.',
-                      header='details')
-            job = client.get(inv_src['related']['current_update'][7:]).json()
-        elif inv_src['related'].get('last_update', None):
-            debug.log('No current update exists; retrieving the most '
-                      'recent update.', header='details')
-            job = client.get(inv_src['related']['last_update'][7:]).json()
-        else:
-            raise exc.NotFound('No inventory source updates exist.')
+    def status(self, pk, detail=False, **kwargs):
+        """Print the status of the most recent sync."""
+        # Obtain the most recent inventory sync
+        job = self.last_job_data(pk, **kwargs)
 
         # In most cases, we probably only want to know the status of the job
         # and the amount of time elapsed. However, if we were asked for
diff --git a/tower_cli/resources/job.py b/tower_cli/resources/job.py
index 7aa3e23..717a9bf 100644
--- a/tower_cli/resources/job.py
+++ b/tower_cli/resources/job.py
@@ -56,6 +56,9 @@ class Resource(models.ExeResource):
     @click.option('--monitor', is_flag=True, default=False,
                   help='If sent, immediately calls `job monitor` on the newly '
                        'launched job rather than exiting with a success.')
+    @click.option('--wait', is_flag=True, default=False,
+                  help='Monitor the status of the job, but do not print '
+                       'while job is in progress.')
     @click.option('--timeout', required=False, type=int,
                   help='If provided with --monitor, this command (not the job)'
                        ' will time out after the given number of seconds. '
@@ -85,8 +88,8 @@ class Resource(models.ExeResource):
     @click.option('--use-job-endpoint', required=False, default=False,
                   is_flag=True, help='A flag that disable launching jobs'
                   ' from job template when set.')
-    def launch(self, job_template=None, monitor=False, timeout=None,
-               no_input=True, extra_vars=None, **kwargs):
+    def launch(self, job_template=None, monitor=False, wait=False,
+               timeout=None, no_input=True, extra_vars=None, **kwargs):
         """Launch a new job based on a job template.
 
         Creates a new job in Ansible Tower, immediately starts it, and
@@ -233,5 +236,7 @@ class Resource(models.ExeResource):
         # monitor from here.
         if monitor:
             return self.monitor(job_id, timeout=timeout)
+        elif wait:
+            return self.wait(job_id, timeout=timeout)
 
         return result
diff --git a/tower_cli/resources/project.py b/tower_cli/resources/project.py
index d0022d1..6df455f 100644
--- a/tower_cli/resources/project.py
+++ b/tower_cli/resources/project.py
@@ -23,6 +23,7 @@ from tower_cli.utils import debug, exceptions as exc, types
 class Resource(models.Resource, models.MonitorableResource):
     cli_help = 'Manage projects within Ansible Tower.'
     endpoint = '/projects/'
+    unified_job_type = '/project_updates/'
 
     name = models.Field(unique=True)
     description = models.Field(required=False, display=False)
@@ -60,8 +61,8 @@ class Resource(models.Resource, models.MonitorableResource):
                   help='If provided with --monitor, the SCM update'
                        ' will time out after the given number of seconds. '
                        'Does nothing if --monitor is not sent.')
-    def create(self, organization=None, monitor=False, timeout=None,
-               fail_on_found=False, force_on_exists=False,
+    def create(self, organization=None, monitor=False, wait=False,
+               timeout=None, fail_on_found=False, force_on_exists=False,
                **kwargs):
         """Create a new item of resource, with or w/o org.
         This would be a shared class with user, but it needs the ability
@@ -100,7 +101,9 @@ class Resource(models.Resource, models.MonitorableResource):
 
         # if the monitor flag is set, wait for the SCM to update
         if monitor and answer.get('changed', False):
-            return self.monitor(project_id, timeout=timeout)
+            return self.monitor(pk=None, parent_pk=project_id, timeout=timeout)
+        elif wait and answer.get('changed', False):
+            return self.wait(pk=None, parent_pk=project_id, timeout=timeout)
 
         return answer
 
@@ -137,7 +140,7 @@ class Resource(models.Resource, models.MonitorableResource):
                        ' will time out after the given number of seconds. '
                        'Does nothing if --monitor is not sent.')
     def update(self, pk=None, create_on_missing=False, monitor=False,
-               timeout=None, name=None, organization=None):
+               wait=False, timeout=None, name=None, organization=None):
         """Trigger a project update job within Ansible Tower.
         Only meaningful on non-manual projects.
         """
@@ -161,7 +164,12 @@ class Resource(models.Resource, models.MonitorableResource):
 
         # If we were told to monitor the project update's status, do so.
         if monitor:
-            return self.monitor(pk, timeout=timeout)
+            project_update_id = result.json()['project_update']
+            return self.monitor(project_update_id, parent_pk=pk,
+                                timeout=timeout)
+        elif wait:
+            project_update_id = result.json()['project_update']
+            return self.wait(project_update_id, parent_pk=pk, timeout=timeout)
 
         # Return the project update ID.
         return {
@@ -172,22 +180,9 @@ class Resource(models.Resource, models.MonitorableResource):
     @click.option('--detail', is_flag=True, default=False,
                   help='Print more detail.')
     def status(self, pk=None, detail=False, **kwargs):
-        """Print the current job status."""
-        # Get the job from Ansible Tower.
-        debug.log('Asking for project update status.', header='details')
-        project = client.get('/projects/%d/' % pk).json()
-
-        # Determine the appropriate project update.
-        if 'current_update' in project['related']:
-            debug.log('A current update exists; retrieving it.',
-                      header='details')
-            job = client.get(project['related']['current_update'][7:]).json()
-        elif project['related'].get('last_update', None):
-            debug.log('No current update exists; retrieving the most '
-                      'recent update.', header='details')
-            job = client.get(project['related']['last_update'][7:]).json()
-        else:
-            raise exc.NotFound('No project updates exist.')
+        """Print the status of the most recent update."""
+        # Obtain the most recent project update
+        job = self.last_job_data(pk, **kwargs)
 
         # In most cases, we probably only want to know the status of the job
         # and the amount of time elapsed. However, if we were asked for


Test patch:
diff --git a/tests/test_models_unified_jobs.py b/tests/test_models_unified_jobs.py
new file mode 100644
index 0000000..be6a704
--- /dev/null
+++ b/tests/test_models_unified_jobs.py
@@ -0,0 +1,96 @@
+# Copyright 2016, Ansible by Red Hat
+# Alan Rominger <arominge@redhat.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import time
+import click
+
+import tower_cli
+from tower_cli.api import client
+
+from tests.compat import unittest, mock
+
+
+def project_update_registration(t):
+    t.register_json('/project_updates/54/', {
+        'elapsed': 1335024000.0,
+        'failed': False,
+        'status': 'successful',
+    }, method='GET')
+
+
+class StandardOutTests(unittest.TestCase):
+    """
+    Test that standard out lookup and wrapper methods
+    """
+    def setUp(self):
+        # Representative of a Unified Job Template
+        self.res = tower_cli.get_resource('project')
+        # Representative of a Unified Job
+        self.job_res = tower_cli.get_resource('job')
+
+    def test_lookup_stdout(self):
+        "Test the method that makes a call to get standard out."
+        with client.test_mode as t:
+            # note:
+            # 'foobar' = 'Zm9vYmFy' via
+            # base64.standard_b64encode('foobar'.encode('ascii'))
+            # but python versions can't all agree on how to trim the string
+            t.register_json(
+                '/project_updates/42/stdout/',
+                {'content': 'Zm9vYmFy'}, method='GET')
+            stdout = self.res.lookup_stdout(42, start_line=0, end_line=1)
+            assert 'foobar' in str(stdout)
+
+    def test_stdout(self):
+        "Test that printing standard out works with project-like things."
+        with mock.patch.object(type(self.res), 'lookup_stdout') as lookup:
+            with mock.patch.object(type(self.res), 'last_job_data') as job:
+                with mock.patch.object(click, 'echo') as mock_echo:
+                    job.return_value = {'id': 42}
+                    lookup.return_value = 'foobar'
+                    result = self.res.stdout(42)
+                    assert not result['changed']
+                    mock_echo.assert_called_once_with('foobar', nl=1)
+
+    def test_stdout_with_lookup(self):
+        "Test that unified job will be automatically looked up."
+        with mock.patch.object(type(self.job_res), 'lookup_stdout'):
+            with mock.patch.object(type(self.job_res), 'get') as get:
+                with mock.patch.object(click, 'echo'):
+                    self.job_res.stdout(pk=None, name="test-proj")
+                    get.assert_called_once_with(name="test-proj")
+
+    def test_call_wait_with_parent(self):
+        "Test auto-lookup of last job is called for wait"
+        with client.test_mode as t:
+            project_update_registration(t)
+            with mock.patch.object(type(self.res), 'last_job_data') as job:
+                job.return_value = {'id': 54}
+                with mock.patch.object(click, 'echo'):
+                    with mock.patch.object(time, 'sleep'):
+                        self.res.wait(pk=None, parent_pk=42)
+                        job.assert_called_once_with(42)
+
+    def test_call_monitor_with_parent(self):
+        "Test auto-lookup when the monitor method is called"
+        with client.test_mode as t:
+            project_update_registration(t)
+            with mock.patch.object(type(self.res), 'last_job_data') as job:
+                job.return_value = {'id': 54}
+                with mock.patch.object(click, 'echo'):
+                    with mock.patch.object(type(self.res), 'wait'):
+                        with mock.patch.object(time, 'sleep'):
+                            self.res.monitor(pk=None, parent_pk=42)
+                            job.assert_called_once_with(42)
diff --git a/tests/test_resources_inventory_source.py b/tests/test_resources_inventory_source.py
index 9eef33a..abe982f 100644
--- a/tests/test_resources_inventory_source.py
+++ b/tests/test_resources_inventory_source.py
@@ -57,12 +57,16 @@ class UpdateTests(unittest.TestCase):
             t.register_json('/inventory_sources/1/update/',
                             {'can_update': True}, method='GET')
             t.register_json('/inventory_sources/1/update/',
-                            {}, method='POST')
+                            {'inventory_update': 32}, method='POST')
             t.register_json('/inventory_sources/1/', {'inventory': 1},
                             method='GET')
             with mock.patch.object(type(self.isr), 'monitor') as monitor:
                 self.isr.update(1, monitor=True)
-                monitor.assert_called_once_with(1, timeout=None)
+                monitor.assert_called_once_with(32, parent_pk=1, timeout=None)
+            # Check wait method, following same pattern
+            with mock.patch.object(type(self.isr), 'wait') as wait:
+                self.isr.update(1, wait=True)
+                wait.assert_called_once_with(32, parent_pk=1, timeout=None)
 
 
 class StatusTests(unittest.TestCase):
diff --git a/tests/test_resources_job.py b/tests/test_resources_job.py
index a7c9fc6..8891003 100644
--- a/tests/test_resources_job.py
+++ b/tests/test_resources_job.py
@@ -133,15 +133,15 @@ class LaunchTests(unittest.TestCase):
             result = self.res.launch(1, extra_vars=())
             self.assertDictContainsSubset({'changed': True, 'id': 42}, result)
 
-    def test_basic_launch_monitor_option(self):
+    def test_basic_launch_wait_option(self):
         """Establish that we are able to create a job that doesn't require
-        any invocation-time input, and that monitor is called if requested.
+        any invocation-time input, and that wait is called if requested.
         """
         with client.test_mode as t:
             standard_registration(t)
-            with mock.patch.object(type(self.res), 'monitor') as monitor:
-                self.res.launch(1, monitor=True)
-                monitor.assert_called_once_with(42, timeout=None)
+            with mock.patch.object(type(self.res), 'wait') as wait:
+                self.res.launch(1, wait=True)
+                wait.assert_called_once_with(42, timeout=None)
 
     def test_extra_vars_at_runtime(self):
         """Establish that if we should be asking for extra variables at
@@ -427,15 +427,15 @@ class StatusTests(unittest.TestCase):
             self.assertEqual(len(t.requests), 1)
 
 
-class MonitorTests(unittest.TestCase):
-    """A set of tests to establish that the job monitor command works in the
-    way that we expect.
+class MonitorWaitTests(unittest.TestCase):
+    """A set of tests to establish that the job monitor and wait commands
+    works in the way that we expect.
     """
     def setUp(self):
         self.res = tower_cli.get_resource('job')
 
     def test_already_successful(self):
-        """Establish that if we attempt to monitor an already successful job,
+        """Establish that if we attempt to wait an already successful job,
         we simply get back the job success report.
         """
         with client.test_mode as t:
@@ -445,9 +445,25 @@ class MonitorTests(unittest.TestCase):
                 'status': 'successful',
             })
             with mock.patch.object(time, 'sleep') as sleep:
-                result = self.res.monitor(42)
+                result = self.res.wait(42)
                 self.assertEqual(sleep.call_count, 0)
-        self.assertEqual(result['status'], 'successful')
+                self.assertEqual(result['status'], 'successful')
+
+    def test_already_successful_monitor(self):
+        """Pass-through sucessful job with monitor method"""
+        with client.test_mode as t:
+            t.register_json('/jobs/42/', {
+                'elapsed': 1335024000.0,
+                'failed': False,
+                'status': 'successful',
+            })
+            # Test same for monitor
+            with mock.patch.object(time, 'sleep') as sleep:
+                with mock.patch.object(type(self.res), 'wait'):
+                    with mock.patch.object(click, 'echo'):
+                        result = self.res.monitor(42)
+                        self.assertEqual(sleep.call_count, 0)
+                        self.assertEqual(result['status'], 'successful')
 
     def test_failure(self):
         """Establish that if the job has failed, that we raise the
@@ -459,6 +475,13 @@ class MonitorTests(unittest.TestCase):
                 'failed': True,
                 'status': 'failed',
             })
+            with self.assertRaises(exc.JobFailure):
+                with mock.patch.object(click, 'secho') as secho:
+                    with mock.patch('tower_cli.models.base.is_tty') as tty:
+                        tty.return_value = True
+                        self.res.wait(42)
+                self.assertTrue(secho.call_count >= 1)
+            # Test the same with the monitor method
             with self.assertRaises(exc.JobFailure):
                 with mock.patch.object(click, 'secho') as secho:
                     with mock.patch('tower_cli.models.base.is_tty') as tty:
@@ -481,10 +504,10 @@ class MonitorTests(unittest.TestCase):
                 with mock.patch.object(click, 'echo') as echo:
                     with mock.patch('tower_cli.models.base.is_tty') as tty:
                         tty.return_value = False
-                        self.res.monitor(42)
+                        self.res.wait(42)
                 self.assertTrue(echo.call_count >= 1)
 
-    def test_monitoring(self):
+    def test_waiting(self):
         """Establish that if the first status call returns a pending job,
         and the second a success, that both calls are made, and a success
         finally returned.
@@ -508,16 +531,47 @@ class MonitorTests(unittest.TestCase):
                 with mock.patch.object(click, 'secho') as secho:
                     with mock.patch('tower_cli.models.base.is_tty') as tty:
                         tty.return_value = True
-                        self.res.monitor(42, min_interval=0.21)
+                        self.res.wait(42, min_interval=0.21)
                 self.assertTrue(secho.call_count >= 100)
 
             # We should have gotten two requests total, to the same URL.
             self.assertEqual(len(t.requests), 2)
             self.assertEqual(t.requests[0].url, t.requests[1].url)
 
+    def test_monitor(self):
+        """Establish that if the first status call returns a pending job,
+        and the second a success, that both calls are made, and a success
+        finally returned.
+        """
+        # Set up our data object.
+        data = {'elapsed': 1335024000.0, 'failed': False, 'status': 'pending'}
+
+        # Register the initial request's response.
+        with client.test_mode as t:
+            t.register_json('/jobs/42/', copy(data))
+
+            # Create a way to assign a successful data object to the request.
+            def assign_success(*args):
+                t.clear()
+                t.register_json('/jobs/42/', dict(data, status='successful'))
+
+            # Make the successful state assignment occur when time.sleep()
+            # is called between requests.
+            with mock.patch.object(time, 'sleep') as sleep:
+                sleep.side_effect = assign_success
+                with mock.patch.object(click, 'echo'):
+                    with mock.patch.object(type(self.res), 'wait'):
+                        with mock.patch.object(
+                                type(self.res), 'lookup_stdout'):
+                            self.res.monitor(42, min_interval=0.21)
+
+            # We should have gotten 3 requests total, to the same URL.
+            self.assertEqual(len(t.requests), 3)
+            self.assertEqual(t.requests[0].url, t.requests[1].url)
+
     def test_timeout(self):
         """Establish that the --timeout flag is honored if sent to
-        `tower-cli job monitor`.
+        `tower-cli job wait`.
         """
         # Set up our data object.
         # This doesn't have to change; it will always be pending
@@ -531,11 +585,11 @@ class MonitorTests(unittest.TestCase):
                 with self.assertRaises(exc.Timeout):
                     with mock.patch('tower_cli.models.base.is_tty') as tty:
                         tty.return_value = True
-                        self.res.monitor(42, min_interval=0.21, timeout=0.1)
+                        self.res.wait(42, min_interval=0.21, timeout=0.1)
                 self.assertTrue(secho.call_count >= 1)
 
-    def test_monitoring_not_tty(self):
-        """Establish that the monitor command prints more useful output
+    def test_waiting_not_tty(self):
+        """Establish that the wait command prints more useful output
         for logging if not connected to a tty.
         """
         # Set up our data object.
@@ -557,7 +611,7 @@ class MonitorTests(unittest.TestCase):
                 with mock.patch.object(click, 'echo') as echo:
                     with mock.patch('tower_cli.models.base.is_tty') as tty:
                         tty.return_value = False
-                        self.res.monitor(42, min_interval=0.21)
+                        self.res.wait(42, min_interval=0.21)
                 self.assertTrue(echo.call_count >= 1)
 
             # We should have gotten two requests total, to the same URL.
diff --git a/tests/test_resources_project.py b/tests/test_resources_project.py
index 1317ba5..d4b5fae 100644
--- a/tests/test_resources_project.py
+++ b/tests/test_resources_project.py
@@ -100,8 +100,8 @@ class CreateTests(unittest.TestCase):
             self.assertEqual(t.requests[1].method, 'POST')
             self.assertEqual(len(t.requests), 2)
 
-    def test_create_monitor(self):
-        """Establish that a project can be created with the monitor flag
+    def test_create_wait(self):
+        """Establish that a project can be created with the wait flag
         enabled and still sucessfully exit and complete.
         """
         with client.test_mode as t:
@@ -112,7 +112,7 @@ class CreateTests(unittest.TestCase):
                             method='GET')
             t.register_json('/projects/', {'changed': True, 'id': 42},
                             method='POST')
-            # Endpoints related to monitoring the resource
+            # Endpoints related to waiting for the resource
             t.register_json(
                 '/projects/42/', {
                     'status': 'successful',
@@ -125,7 +125,7 @@ class CreateTests(unittest.TestCase):
                 method='GET'
             )
 
-            result = self.res.create(name='bar', scm_type="git", monitor=True)
+            result = self.res.create(name='bar', scm_type="git", wait=True)
             self.assertEqual(t.requests[0].method, 'GET')
             self.assertEqual(t.requests[-1].method, 'GET')
             self.assertDictContainsSubset({'changed': True}, result)
@@ -187,7 +187,11 @@ class UpdateTests(unittest.TestCase):
                             method='POST')
             with mock.patch.object(type(self.res), 'monitor') as monitor:
                 self.res.update(1, monitor=True)
-                monitor.assert_called_once_with(1, timeout=None)
+                monitor.assert_called_once_with(42, parent_pk=1, timeout=None)
+            # Test wait method, which follows same pattern
+            with mock.patch.object(type(self.res), 'wait') as wait:
+                self.res.update(1, wait=True)
+                wait.assert_called_once_with(42, parent_pk=1, timeout=None)
 
     def test_cannot_update(self):
         """Establish that attempting to update a non-updatable project