You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@aurora.apache.org by mc...@apache.org on 2014/01/27 19:48:39 UTC

git commit: Merged all of the open clientv2 reviews into one unified change.

Updated Branches:
  refs/heads/master 1925b817a -> 3a23a501a


Merged all of the open clientv2 reviews into one unified change.

- Add clientv2 implementation of "job status" with jobkey wildcard support.
- Add clientv2 implementations of "job update" and "job list"
- Add clientv2 implementation of "job restart"
- Add clientv2 implementation of "job cancel_update"
- Extract more common options into "options.py".
- General code cleanup.

This supersedes the following reviews:
- https://reviews.apache.org/r/17153/
- https://reviews.apache.org/r/17051/
- https://reviews.apache.org/r/17154/
- https://reviews.apache.org/r/16306/

Testing Done:
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 3 items

src/test/python/apache/aurora/admin/test_mesos_maintenance.py ...

=========================== 3 passed in 0.29 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 2 items

src/test/python/apache/aurora/client/test_binding_helper.py ..

=========================== 2 passed in 0.30 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 6 items

src/test/python/apache/aurora/client/test_config.py ......

=========================== 6 passed in 0.40 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 6 items

src/test/python/apache/aurora/client/api/test_disambiguator.py ......

=========================== 6 passed in 0.25 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 1 items

src/test/python/apache/aurora/client/api/test_job_monitor.py .

=========================== 1 passed in 0.22 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 6 items

src/test/python/apache/aurora/client/api/test_restarter.py ......

=========================== 6 passed in 0.21 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 47 items / 1 skipped

src/test/python/apache/aurora/client/api/test_scheduler_client.py ...............................................

===================== 47 passed, 1 skipped in 0.59 seconds =====================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 20 items

src/test/python/apache/aurora/client/api/test_instance_watcher.py ........
src/test/python/apache/aurora/client/api/test_health_check.py ............

========================== 20 passed in 0.24 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 26 items

src/test/python/apache/aurora/client/api/test_updater.py ..........................

========================== 26 passed in 0.43 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 6 items

src/test/python/apache/aurora/client/api/test_quota_check.py ......

=========================== 6 passed in 0.09 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 25 items

src/test/python/apache/aurora/client/cli/test_cancel_update.py ..
src/test/python/apache/aurora/client/cli/test_create.py ....
src/test/python/apache/aurora/client/cli/test_diff.py ...
src/test/python/apache/aurora/client/cli/test_kill.py .....
src/test/python/apache/aurora/client/cli/test_restart.py ...
src/test/python/apache/aurora/client/cli/test_status.py .....
src/test/python/apache/aurora/client/cli/test_update.py ...

========================== 25 passed in 1.42 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 24 items

src/test/python/apache/aurora/client/commands/test_cancel_update.py ..
src/test/python/apache/aurora/client/commands/test_create.py ......
src/test/python/apache/aurora/client/commands/test_diff.py ...
src/test/python/apache/aurora/client/commands/test_kill.py ...
src/test/python/apache/aurora/client/commands/test_listjobs.py ..
src/test/python/apache/aurora/client/commands/test_restart.py ...
src/test/python/apache/aurora/client/commands/test_status.py ..
src/test/python/apache/aurora/client/commands/test_update.py ...

========================== 24 passed in 1.12 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 1 items

src/test/python/apache/aurora/client/commands/test_run.py .

=========================== 1 passed in 0.30 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 1 items

src/test/python/apache/aurora/client/commands/test_ssh.py .

=========================== 1 passed in 0.28 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 12 items

src/test/python/apache/aurora/client/hooks/test_hooked_api.py ............

========================== 12 passed in 0.23 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 4 items

src/test/python/apache/aurora/client/hooks/test_non_hooked_api.py ....

=========================== 4 passed in 0.22 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 1 items

src/test/python/apache/aurora/common/test_aurora_job_key.py .

=========================== 1 passed in 0.05 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 1 items

src/test/python/apache/aurora/common/test_cluster.py .

=========================== 1 passed in 0.02 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 4 items

src/test/python/apache/aurora/common/test_clusters.py ....

=========================== 4 passed in 0.11 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 2 items

src/test/python/apache/aurora/common/test_cluster_option.py ..

=========================== 2 passed in 0.04 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 3 items

src/test/python/apache/aurora/common/test_http_signaler.py ...

=========================== 3 passed in 0.15 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 7 items

src/test/python/apache/aurora/config/test_base.py .......

=========================== 7 passed in 1.24 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 1 items

src/test/python/apache/aurora/config/test_constraint_parsing.py .

=========================== 1 passed in 0.10 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 6 items

src/test/python/apache/aurora/config/test_loader.py ......

=========================== 6 passed in 0.13 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 10 items

src/test/python/apache/aurora/config/test_thrift.py ..........

========================== 10 passed in 1.40 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 4 items

src/test/python/apache/aurora/executor/test_executor_detector.py ....

=========================== 4 passed in 0.05 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 4 items

src/test/python/apache/aurora/executor/test_executor_vars.py ....

=========================== 4 passed in 0.12 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 5 items

src/test/python/apache/aurora/executor/test_thermos_task_runner.py .....

========================== 5 passed in 17.95 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 2 items

src/test/python/apache/aurora/executor/common/test_directory_sandbox.py ..

=========================== 2 passed in 0.07 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 3 items

src/test/python/apache/aurora/executor/common/test_health_checker.py ...

=========================== 3 passed in 1.41 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 1 items

src/test/python/apache/aurora/executor/common/test_status_checker.py .

=========================== 1 passed in 0.05 seconds ===========================
============================= test session starts ==============================
platform darwin -- Python 2.7.2 -- pytest-2.5.1
collected 1 items

src/test/python/apache/aurora/executor/common/test_task_info.py .

=========================== 1 passed in 0.14 seconds ===========================
Build operating on targets: OrderedSet([PythonTestSuite(src/test/python/apache/aurora/BUILD:all)])
src.test.python.apache.aurora.admin.mesos_maintenance                           .....   SUCCESS
src.test.python.apache.aurora.client.api.disambiguator                          .....   SUCCESS
src.test.python.apache.aurora.client.api.instance_watcher                       .....   SUCCESS
src.test.python.apache.aurora.client.api.job_monitor                            .....   SUCCESS
src.test.python.apache.aurora.client.api.quota_check                            .....   SUCCESS
src.test.python.apache.aurora.client.api.restarter                              .....   SUCCESS
src.test.python.apache.aurora.client.api.scheduler_client                       .....   SUCCESS
src.test.python.apache.aurora.client.api.updater                                .....   SUCCESS
src.test.python.apache.aurora.client.binding_helper                             .....   SUCCESS
src.test.python.apache.aurora.client.cli.job                                    .....   SUCCESS
src.test.python.apache.aurora.client.commands.core                              .....   SUCCESS
src.test.python.apache.aurora.client.commands.run                               .....   SUCCESS
src.test.python.apache.aurora.client.commands.ssh                               .....   SUCCESS
src.test.python.apache.aurora.client.config                                     .....   SUCCESS
src.test.python.apache.aurora.client.hooks.hooked_api                           .....   SUCCESS
src.test.python.apache.aurora.client.hooks.non_hooked_api                       .....   SUCCESS
src.test.python.apache.aurora.common.test_aurora_job_key                        .....   SUCCESS
src.test.python.apache.aurora.common.test_cluster                               .....   SUCCESS
src.test.python.apache.aurora.common.test_cluster_option                        .....   SUCCESS
src.test.python.apache.aurora.common.test_clusters                              .....   SUCCESS
src.test.python.apache.aurora.common.test_http_signaler                         .....   SUCCESS
src.test.python.apache.aurora.config.test_base                                  .....   SUCCESS
src.test.python.apache.aurora.config.test_constraint_parsing                    .....   SUCCESS
src.test.python.apache.aurora.config.test_loader                                .....   SUCCESS
src.test.python.apache.aurora.config.test_thrift                                .....   SUCCESS
src.test.python.apache.aurora.executor.common.directory_sandbox                 .....   SUCCESS
src.test.python.apache.aurora.executor.common.health_checker                    .....   SUCCESS
src.test.python.apache.aurora.executor.common.status_checker                    .....   SUCCESS
src.test.python.apache.aurora.executor.common.task_info                         .....   SUCCESS
src.test.python.apache.aurora.executor.executor_detector                        .....   SUCCESS
src.test.python.apache.aurora.executor.executor_vars                            .....   SUCCESS
src.test.python.apache.aurora.executor.thermos_task_runner                      .....   SUCCESS

Bugs closed: aurora-53, aurora-54

Reviewed at https://reviews.apache.org/r/17185/


Project: http://git-wip-us.apache.org/repos/asf/incubator-aurora/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-aurora/commit/3a23a501
Tree: http://git-wip-us.apache.org/repos/asf/incubator-aurora/tree/3a23a501
Diff: http://git-wip-us.apache.org/repos/asf/incubator-aurora/diff/3a23a501

Branch: refs/heads/master
Commit: 3a23a501a1c5678e4999d3fbf284f0996055bf34
Parents: 1925b81
Author: Mark Chu-Carroll <mc...@twopensource.com>
Authored: Mon Jan 27 13:39:18 2014 -0500
Committer: Mark Chu-Carroll <mc...@twitter.com>
Committed: Mon Jan 27 13:39:18 2014 -0500

----------------------------------------------------------------------
 .../python/apache/aurora/client/cli/context.py  |  45 +-
 .../python/apache/aurora/client/cli/jobs.py     | 515 ++++++++++++-------
 .../python/apache/aurora/client/cli/options.py  |  49 +-
 .../apache/aurora/client/commands/core.py       |   6 +-
 src/test/python/apache/aurora/client/cli/BUILD  |  10 +-
 .../aurora/client/cli/test_cancel_update.py     | 115 +++++
 .../apache/aurora/client/cli/test_create.py     |  11 -
 .../apache/aurora/client/cli/test_kill.py       |   2 +-
 .../apache/aurora/client/cli/test_restart.py    | 141 +++++
 .../apache/aurora/client/cli/test_status.py     |   1 -
 .../apache/aurora/client/cli/test_update.py     | 246 +++++++++
 .../python/apache/aurora/client/cli/util.py     |   3 +-
 12 files changed, 928 insertions(+), 216 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/3a23a501/src/main/python/apache/aurora/client/cli/context.py
----------------------------------------------------------------------
diff --git a/src/main/python/apache/aurora/client/cli/context.py b/src/main/python/apache/aurora/client/cli/context.py
index 78e54a2..b54c5a0 100644
--- a/src/main/python/apache/aurora/client/cli/context.py
+++ b/src/main/python/apache/aurora/client/cli/context.py
@@ -24,9 +24,9 @@ from apache.aurora.common.aurora_job_key import AuroraJobKey
 from apache.aurora.client.base import synthesize_url
 from apache.aurora.client.cli import (
     Context,
-    EXIT_NETWORK_ERROR,
+    EXIT_API_ERROR,
+    EXIT_INVALID_CONFIGURATION,
     EXIT_INVALID_PARAMETER,
-    EXIT_NETWORK_ERROR
 )
 from apache.aurora.client.config import get_config
 from apache.aurora.client.factory import make_client
@@ -61,39 +61,44 @@ class AuroraCommandContext(Context):
   def get_job_config(self, jobkey, config_file):
     """Loads a job configuration from a config file."""
     jobname = jobkey.name
-    return get_config(
-      jobname,
-      config_file,
-      self.options.json,
-      self.options.bindings,
-      select_cluster=jobkey.cluster,
-      select_role=jobkey.role,
-      select_env=jobkey.env)
-
-  def print_out(self, str):
+    try:
+      return get_config(
+        jobname,
+        config_file,
+        self.options.read_json,
+        self.options.bindings,
+        select_cluster=jobkey.cluster,
+        select_role=jobkey.role,
+        select_env=jobkey.env)
+    except Exception as e:
+      raise self.CommandError(EXIT_INVALID_CONFIGURATION, 'Error loading configuration: %s' % e)
+
+  def print_out(self, str, indent=0):
     """Prints output. For debugging purposes, it's nice to be able to patch this
     and capture output.
     """
-    print(str)
+    indent_str = ' ' * indent
+    print('%s%s' % (indent_str, str))
 
-  def print_err(self, str):
+  def print_err(self, str, indent=0):
     """Prints output to standard error."""
-    print(str, file=sys.stderr)
+    indent_str = ' ' * indent
+    print('%s%s' % (indent_str, str), file=sys.stderr)
 
   def open_page(self, url):
     import webbrowser
     webbrowser.open_new_tab(url)
 
   def open_job_page(self, api, jobkey):
-    """Open the page for a job in the system web browser."""
-    self.open_page(synthesize_url(api.scheduler.scheduler().url, jobkey.role,
+    """Opens the page for a job in the system web browser."""
+    self.open_page(synthesize_url(api.scheduler_proxy.scheduler_client().url, jobkey.role,
         jobkey.env, jobkey.name))
 
   def check_and_log_response(self, resp):
     log.info('Response from scheduler: %s (message: %s)'
         % (ResponseCode._VALUES_TO_NAMES[resp.responseCode], resp.message))
     if resp.responseCode != ResponseCode.OK:
-      raise self.CommandError(EXIT_NETWORK_ERROR, resp.message)
+      raise self.CommandError(EXIT_API_ERROR, resp.message)
 
   @classmethod
   def parse_partial_jobkey(cls, key):
@@ -110,7 +115,7 @@ class AuroraCommandContext(Context):
     return PartialJobKey(*parts)
 
   def get_job_list(self, clusters, role=None):
-    """Get a list of all jobs from a group of clusters.
+    """Get a list of jobs from a group of clusters.
     :param clusters: the clusters to query for jobs
     :param role: if specified, only return jobs for the role; otherwise, return all jobs.
     """
@@ -129,7 +134,7 @@ class AuroraCommandContext(Context):
   def get_jobs_matching_key(self, key):
     """Finds all jobs matching a key containing wildcard segments.
     This is potentially slow!
-    TODO: insert a warning to users about slowness if the key contains wildcards!
+    TODO(mchucarroll): insert a warning to users about slowness if the key contains wildcards!
     """
 
     def is_fully_bound(key):

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/3a23a501/src/main/python/apache/aurora/client/cli/jobs.py
----------------------------------------------------------------------
diff --git a/src/main/python/apache/aurora/client/cli/jobs.py b/src/main/python/apache/aurora/client/cli/jobs.py
index f60d7e9..caff6d8 100644
--- a/src/main/python/apache/aurora/client/cli/jobs.py
+++ b/src/main/python/apache/aurora/client/cli/jobs.py
@@ -22,8 +22,10 @@ import pprint
 import subprocess
 import sys
 from tempfile import NamedTemporaryFile
+import time
 
 from apache.aurora.client.api.job_monitor import JobMonitor
+from apache.aurora.client.api.updater_util import UpdaterConfig
 from apache.aurora.client.cli import (
     EXIT_COMMAND_FAILURE,
     EXIT_INVALID_CONFIGURATION,
@@ -34,11 +36,17 @@ from apache.aurora.client.cli import (
 )
 from apache.aurora.client.cli.context import AuroraCommandContext
 from apache.aurora.client.cli.options import (
+    BATCH_OPTION,
     BIND_OPTION,
     BROWSER_OPTION,
     CONFIG_ARGUMENT,
+    FORCE_OPTION,
+    HEALTHCHECK_OPTION,
+    INSTANCES_OPTION,
     JOBSPEC_ARGUMENT,
-    JSON_OPTION,
+    JSON_READ_OPTION,
+    JSON_WRITE_OPTION,
+    WATCH_OPTION,
 )
 from apache.aurora.common.aurora_job_key import AuroraJobKey
 
@@ -54,26 +62,36 @@ from thrift.TSerialization import serialize
 from thrift.protocol import TJSONProtocol
 
 
-def parse_instances(instances):
-  """Parse lists of instances or instance ranges into a set().
-     Examples:
-       0-2
-       0,1-3,5
-       1,3,5
-  """
-  if instances is None or instances == '':
-    return None
-  result = set()
-  for part in instances.split(','):
-    x = part.split('-')
-    result.update(range(int(x[0]), int(x[-1]) + 1))
-  return sorted(result)
-
-
 def arg_type_jobkey(key):
   return AuroraCommandContext.parse_partial_jobkey(key)
 
 
+class CancelUpdateCommand(Verb):
+  @property
+  def name(self):
+    return 'cancel_update'
+
+  @property
+  def help(self):
+    return """Usage: aurora job cancel_update [--config_file=path [--json]] cluster/role/env/name
+
+    Cancels an in-progress update operation, releasing the update lock
+    """
+
+  def setup_options_parser(self, parser):
+    self.add_option(parser, JSON_READ_OPTION)
+    parser.add_argument('--config', type=str, default=None, dest='config_file',
+         help='Config file for the job, possibly containing hooks')
+    self.add_option(parser, JOBSPEC_ARGUMENT)
+
+  def execute(self, context):
+    api = context.get_api(context.options.jobspec.cluster)
+    config = (context.get_job_config(context.options.jobspec, context.options.config_file)
+        if context.options.config_file else None)
+    resp = api.cancel_update(context.options.jobspec, config=config)
+    context.check_and_log_response(resp)
+
+
 class CreateJobCommand(Verb):
   @property
   def name(self):
@@ -81,17 +99,17 @@ class CreateJobCommand(Verb):
 
   @property
   def help(self):
-    return '''Usage: aurora create cluster/role/env/job config.aurora
+    return """Usage: aurora create cluster/role/env/job config.aurora
 
     Create a job using aurora
-    '''
+    """
 
   CREATE_STATES = ('PENDING', 'RUNNING', 'FINISHED')
 
   def setup_options_parser(self, parser):
     self.add_option(parser, BIND_OPTION)
     self.add_option(parser, BROWSER_OPTION)
-    self.add_option(parser, JSON_OPTION)
+    self.add_option(parser, JSON_READ_OPTION)
     parser.add_argument('--wait_until', choices=self.CREATE_STATES,
         default='PENDING',
         help=('Block the client until all the tasks have transitioned into the requested state. '
@@ -99,12 +117,9 @@ class CreateJobCommand(Verb):
     self.add_option(parser, JOBSPEC_ARGUMENT)
     self.add_option(parser, CONFIG_ARGUMENT)
 
+
   def execute(self, context):
-    try:
-      config = context.get_job_config(context.options.jobspec, context.options.config_file)
-    except Config.InvalidConfigError as e:
-      raise context.CommandError(EXIT_INVALID_CONFIGURATION,
-          'Error loading job configuration: %s' % e)
+    config = context.get_job_config(context.options.jobspec, context.options.config_file)
     api = context.get_api(config.cluster())
     monitor = JobMonitor(api, config.role(), config.environment(), config.name())
     resp = api.create_job(config)
@@ -121,6 +136,158 @@ class CreateJobCommand(Verb):
     return EXIT_OK
 
 
+class DiffCommand(Verb):
+  def __init__(self):
+    super(DiffCommand, self).__init__()
+    self.prettyprinter = pprint.PrettyPrinter(indent=2)
+
+  @property
+  def help(self):
+    return """Usage: diff cluster/role/env/job config
+
+  Compares a job configuration against a running job.
+  By default the diff will be displayed using 'diff', though you may choose an alternate
+  diff program by setting the DIFF_VIEWER environment variable.
+  """
+
+  @property
+  def name(self):
+    return 'diff'
+
+  def setup_options_parser(self, parser):
+    self.add_option(parser, BIND_OPTION)
+    self.add_option(parser, JSON_READ_OPTION)
+    parser.add_argument('--from', dest='rename_from', type=AuroraJobKey.from_path, default=None,
+        help='If specified, the job key to diff against.')
+    self.add_option(parser, JOBSPEC_ARGUMENT)
+    self.add_option(parser, CONFIG_ARGUMENT)
+
+  def pretty_print_task(self, task):
+    task.configuration = None
+    task.executorConfig = ExecutorConfig(
+        name=AURORA_EXECUTOR_NAME,
+        data=json.loads(task.executorConfig.data))
+    return self.prettyprinter.pformat(vars(task))
+
+  def pretty_print_tasks(self, tasks):
+    return ',\n'.join(self.pretty_print_task(t) for t in tasks)
+
+  def dump_tasks(self, tasks, out_file):
+    out_file.write(self.pretty_print_tasks(tasks))
+    out_file.write('\n')
+    out_file.flush()
+
+  def execute(self, context):
+    config = context.get_job_config(context.options.jobspec, context.options.config_file)
+    if context.options.rename_from is not None:
+      cluster = context.options.rename_from.cluster
+      role = context.options.rename_from.role
+      env = context.options.rename_from.environment
+      name = context.options.rename_from.name
+    else:
+      cluster = config.cluster()
+      role = config.role()
+      env = config.environment()
+      name = config.name()
+    api = context.get_api(cluster)
+    resp = api.query(api.build_query(role, name, statuses=ACTIVE_STATES, env=env))
+    if resp.responseCode != ResponseCode.OK:
+      raise context.CommandError(EXIT_INVALID_PARAMETER, 'Could not find job to diff against')
+    remote_tasks = [t.assignedTask.task for t in resp.result.scheduleStatusResult.tasks]
+    resp = api.populate_job_config(config)
+    if resp.responseCode != ResponseCode.OK:
+      raise context.CommandError(EXIT_INVALID_CONFIGURATION,
+          'Error loading configuration: %s' % resp.message)
+    local_tasks = resp.result.populateJobResult.populated
+    diff_program = os.environ.get('DIFF_VIEWER', 'diff')
+    with NamedTemporaryFile() as local:
+      self.dump_tasks(local_tasks, local)
+      with NamedTemporaryFile() as remote:
+        self.dump_tasks(remote_tasks, remote)
+        result = subprocess.call([diff_program, remote.name, local.name])
+        # Unlike most commands, diff doesn't return zero on success; it returns
+        # 1 when a successful diff is non-empty.
+        if result not in (0, 1):
+          raise context.CommandError(EXIT_COMMAND_FAILURE, 'Error running diff command')
+        else:
+          return EXIT_OK
+
+
+class InspectCommand(Verb):
+  @property
+  def help(self):
+    return """Usage: inspect cluster/role/env/job config
+
+  Verifies that a job can be parsed from a configuration file, and displays
+  the parsed configuration.
+  """
+
+  @property
+  def name(self):
+    return 'inspect'
+
+  def setup_options_parser(self, parser):
+    self.add_option(parser, BIND_OPTION)
+    self.add_option(parser, JSON_READ_OPTION)
+    parser.add_argument('--local', dest='local', default=False, action='store_true',
+        help='Inspect the configuration as would be created by the "spawn" command.')
+    parser.add_argument('--raw', dest='raw', default=False, action='store_true',
+        help='Show the raw configuration.')
+    self.add_option(parser, JOBSPEC_ARGUMENT)
+    self.add_option(parser, CONFIG_ARGUMENT)
+
+  def execute(self, context):
+    config = context.get_job_config(context.options.jobspec, context.options.config_file)
+    if context.options.raw:
+      context.print(config.job())
+      return EXIT_OK
+
+    job = config.raw()
+    job_thrift = config.job()
+    context.print_out('Job level information')
+    context.print_out('name:       %s' % job.name(), indent=2)
+    context.print_out('role:       %s' % job.role(), indent=2)
+    context.print_out('contact:    %s' % job.contact(), indent=2)
+    context.print_out('cluster:    %s' % job.cluster(), indent=2)
+    context.print_out('instances:  %s' % job.instances(), indent=2)
+    if job.has_cron_schedule():
+      context.print_out('cron:', indent=2)
+      context.print_out('schedule: %s' % job.cron_schedule(), ident=4)
+      context.print_out('policy:   %s' % job.cron_collision_policy(), indent=4)
+    if job.has_constraints():
+      context.print_out('constraints:', indent=2)
+      for constraint, value in job.constraints().get().items():
+        context.print_out('%s: %s' % (constraint, value), indent=4)
+    context.print_out('service:    %s' % job_thrift.taskConfig.isService, indent=2)
+    context.print_out('production: %s' % bool(job.production().get()), indent=2)
+    context.print()
+
+    task = job.task()
+    context.print_out('Task level information')
+    context.print_out('name: %s' % task.name(), indent=2)
+
+    if len(task.constraints().get()) > 0:
+      context.print_out('constraints:', indent=2)
+      for constraint in task.constraints():
+        context.print_out('%s' % (' < '.join(st.get() for st in constraint.order() or [])),
+            indent=2)
+    context.print_out()
+
+    processes = task.processes()
+    for process in processes:
+      context.print_out('Process %s:' % process.name())
+      if process.daemon().get():
+        context.print_out('daemon', indent=2)
+      if process.ephemeral().get():
+        context.print_out('ephemeral', indent=2)
+      if process.final().get():
+        context.print_out('final', indent=2)
+      context.print_out('cmdline:', indent=2)
+      for line in process.cmdline().get().splitlines():
+        context.print_out(line, indent=4)
+      context.print_out()
+
+
 class KillJobCommand(Verb):
   @property
   def name(self):
@@ -128,23 +295,20 @@ class KillJobCommand(Verb):
 
   @property
   def help(self):
-    return '''Usage: kill cluster/role/env/job
+    return """Usage: kill cluster/role/env/job
 
     Kill a scheduled job
-    '''
+    """
 
   def setup_options_parser(self, parser):
     self.add_option(parser, BROWSER_OPTION)
-    parser.add_argument('--instances', type=parse_instances, dest='instances', default=None,
-        help='A list of instance ids to act on. Can either be a comma-separated list (e.g. 0,1,2) '
-            'or a range (e.g. 0-2) or any combination of the two (e.g. 0-2,5,7-9). If not set, '
-            'all instances will be acted on.')
+    self.add_option(parser, INSTANCES_OPTION)
     parser.add_argument('--config', type=str, default=None, dest='config',
          help='Config file for the job, possibly containing hooks')
     self.add_option(parser, JOBSPEC_ARGUMENT)
 
   def execute(self, context):
-    # TODO: Check for wildcards; we don't allow wildcards for job kill.
+    # TODO(mchucarroll): Check for wildcards; we don't allow wildcards for job kill.
     api = context.get_api(context.options.jobspec.cluster)
     resp = api.kill_job(context.options.jobspec, context.options.instances)
     if resp.responseCode != ResponseCode.OK:
@@ -154,22 +318,104 @@ class KillJobCommand(Verb):
       context.open_job_page(api, context.options.jobspec)
 
 
+class ListJobsCommand(Verb):
+  @property
+  def help(self):
+    return """Usage: aurora job list jobspec
+
+    Lists jobs that match a jobkey or jobkey pattern.
+    """
+
+  @property
+  def name(self):
+    return 'list'
+
+  def setup_options_parser(self, parser):
+    parser.add_argument('jobspec', type=arg_type_jobkey)
+
+  def execute(self, context):
+    jobs = context.get_jobs_matching_key(context.options.jobspec)
+    for j in jobs:
+      context.print('%s/%s/%s/%s' % (j.cluster, j.role, j.env, j.name))
+    result = self.get_status_for_jobs(jobs, context)
+    context.print_out(result)
+
+
+class RestartCommand(Verb):
+  @property
+  def name(self):
+    return 'restart'
+
+  def setup_options_parser(self, parser):
+    self.add_option(parser, BATCH_OPTION)
+    self.add_option(parser, BIND_OPTION)
+    self.add_option(parser, BROWSER_OPTION)
+    self.add_option(parser, FORCE_OPTION)
+    self.add_option(parser, HEALTHCHECK_OPTION)
+    self.add_option(parser, INSTANCES_OPTION)
+    self.add_option(parser, JSON_READ_OPTION)
+    self.add_option(parser, WATCH_OPTION)
+    parser.add_argument('--max_per_shard_failures', type=int, default=0,
+        help='Maximum number of restarts per shard during restart. Increments total failure '
+            'count when this limit is exceeded.')
+    parser.add_argument('--restart_threshold', type=int, default=60,
+        help='Maximum number of seconds before a shard must move into the RUNNING state before '
+             'considered a failure.')
+    parser.add_argument('--max_total_failures', type=int, default=0,
+        help='Maximum number of shard failures to be tolerated in total during restart.')
+    self.add_option(parser, JOBSPEC_ARGUMENT)
+    self.add_option(parser, CONFIG_ARGUMENT)
+
+  @property
+  def help(self):
+    return """Usage: restart cluster/role/env/job
+    [--shards=SHARDS]
+    [--batch_size=INT]
+    [--updater_health_check_interval_seconds=SECONDS]
+    [--max_per_shard_failures=INT]
+    [--max_total_failures=INT]
+    [--restart_threshold=INT]
+    [--watch_secs=SECONDS]
+    [--open_browser]
+
+  Performs a rolling restart of shards within a job.
+
+  Restarts are fully controlled client-side, so aborting halts the restart.
+  """
+
+  def execute(self, context):
+    api = context.get_api(context.options.jobspec.cluster)
+    config = (context.get_job_config(context.options.jobspec, context.options.config_file)
+        if context.options.config_file else None)
+    updater_config = UpdaterConfig(
+        context.options.batch_size,
+        context.options.restart_threshold,
+        context.options.watch_secs,
+        context.options.max_per_shard_failures,
+        context.options.max_total_failures)
+    resp = api.restart(context.options.jobspec, context.options.instances, updater_config,
+        context.options.healthcheck_interval_seconds, config=config)
+
+    context.check_and_log_response(resp)
+    if context.options.open_browser:
+      context.open_job_page(api, context.options.jobspec)
+
+
 class StatusCommand(Verb):
   @property
   def help(self):
-    return '''Usage: aurora status jobspec
+    return """Usage: aurora status jobspec
 
     Get status information about a scheduled job or group of jobs. The
     jobspec parameter can ommit parts of the jobkey, or use shell-style globs.
-    '''
+    """
 
   @property
   def name(self):
     return 'status'
 
   def setup_options_parser(self, parser):
-    parser.add_argument('--json', default=False, action='store_true',
-        help='Show status information in machine-processable JSON format')
+    self.add_option(parser, JSON_WRITE_OPTION)
     parser.add_argument('jobspec', type=arg_type_jobkey)
 
   def render_tasks_json(self, jobkey, active_tasks, inactive_tasks):
@@ -197,7 +443,7 @@ class StatusCommand(Verb):
             task_info.numCpus, task_info.ramMb, task_info.diskMb))
       if assigned_task.assignedPorts:
         task_strings.append('ports: %s' % assigned_task.assignedPorts)
-        # TODO: only add the max if taskInfo is filled in!
+        # TODO(mchucarroll): only add the max if taskInfo is filled in!
         task_strings.append('failure count: %s (max %s)' % (scheduled_task.failureCount,
             task_info.maxTaskFailures))
         task_strings.append('events:')
@@ -228,11 +474,11 @@ class StatusCommand(Verb):
       job_tasks = context.get_job_status(jk)
       active_tasks = [t for t in job_tasks if is_active(t)]
       inactive_tasks = [t for t in job_tasks if not is_active(t)]
-      if context.options.json:
+      if context.options.write_json:
         result.append(self.render_tasks_json(jk, active_tasks, inactive_tasks))
       else:
         result.append(self.render_tasks_pretty(jk, active_tasks, inactive_tasks))
-    if context.options.json:
+    if context.options.write_json:
       return json.dumps(result, indent=2, separators=[',', ': '], sort_keys=False)
     else:
       return ''.join(result)
@@ -243,160 +489,71 @@ class StatusCommand(Verb):
     context.print_out(result)
 
 
-class DiffCommand(Verb):
-
-  def __init__(self):
-    super(DiffCommand, self).__init__()
-    self.prettyprinter = pprint.PrettyPrinter(indent=2)
-
-  @property
-  def help(self):
-    return """usage: diff cluster/role/env/job config
-
-  Compares a job configuration against a running job.
-  By default the diff will be displayed using 'diff', though you may choose an alternate
-  diff program by setting the DIFF_VIEWER environment variable.
-  """
-
+class UpdateCommand(Verb):
   @property
   def name(self):
-    return 'diff'
+    return 'update'
 
   def setup_options_parser(self, parser):
+    self.add_option(parser, FORCE_OPTION)
     self.add_option(parser, BIND_OPTION)
-    self.add_option(parser, JSON_OPTION)
-    parser.add_argument('--from', dest='rename_from', type=AuroraJobKey.from_path, default=None,
-        help='If specified, the job key to diff against.')
+    self.add_option(parser, JSON_READ_OPTION)
+    self.add_option(parser, INSTANCES_OPTION)
+    self.add_option(parser, HEALTHCHECK_OPTION)
     self.add_option(parser, JOBSPEC_ARGUMENT)
     self.add_option(parser, CONFIG_ARGUMENT)
 
-  def pretty_print_task(self, task):
-    task.configuration = None
-    task.executorConfig = ExecutorConfig(
-        name=AURORA_EXECUTOR_NAME,
-        data=json.loads(task.executorConfig.data))
-    return self.prettyprinter.pformat(vars(task))
+  @property
+  def help(self):
+    return """Usage: update cluster/role/env/job config
 
-  def pretty_print_tasks(self, tasks):
-    return ',\n'.join(self.pretty_print_task(t) for t in tasks)
+  Performs a rolling upgrade on a running job, using the update configuration
+  within the config file as a control for update velocity and failure tolerance.
 
-  def dump_tasks(self, tasks, out_file):
-    out_file.write(self.pretty_print_tasks(tasks))
-    out_file.write('\n')
-    out_file.flush()
+  Updates are fully controlled client-side, so aborting an update halts the
+  update and leaves the job in a 'locked' state on the scheduler.
+  Subsequent update attempts will fail until the update is 'unlocked' using the
+  'cancel_update' command.
 
-  def execute(self, context):
-    try:
-      config = context.get_job_config(context.options.jobspec, context.options.config_file)
-    except Config.InvalidConfigError as e:
-      raise context.CommandError(EXIT_INVALID_CONFIGURATION,
-          'Error loading job configuration: %s' % e)
-    if context.options.rename_from is not None:
-      cluster = context.options.rename_from.cluster
-      role = context.options.rename_from.role
-      env = context.options.rename_from.environment
-      name = context.options.rename_from.name
-    else:
-      cluster = config.cluster()
-      role = config.role()
-      env = config.environment()
-      name = config.name()
-    api = context.get_api(cluster)
-    resp = api.query(api.build_query(role, name, statuses=ACTIVE_STATES, env=env))
+  The updater only takes action on shards in a job that have changed, meaning
+  that changing a single shard will only induce a restart on the changed shard.
+
+  You may want to consider using the 'diff' subcommand before updating,
+  to preview what changes will take effect.
+  """
+
+  def warn_if_dangerous_change(self, context, api, job_spec, config):
+    # Get the current job status, so that we can check if there's anything
+    # dangerous about this update.
+    resp = api.query(api.build_query(config.role(), config.name(),
+        statuses=ACTIVE_STATES, env=config.environment()))
     if resp.responseCode != ResponseCode.OK:
-      raise context.CommandError(EXIT_INVALID_PARAMETER, 'Could not find job to diff against')
+      # NOTE(mchucarroll): we assume here that updating a cron schedule and updating a
+      # running job are different operations; in client v1, they were both done with update.
+      raise context.CommandError(EXIT_COMMAND_FAILURE,
+          'Server could not find running job to update: %s' % resp.message)
     remote_tasks = [t.assignedTask.task for t in resp.result.scheduleStatusResult.tasks]
     resp = api.populate_job_config(config)
     if resp.responseCode != ResponseCode.OK:
-      raise context.CommandError(EXIT_INVALID_CONFIGURATION,
-          'Error loading configuration: %s' % resp.message)
-    local_tasks = resp.result.populateJobResult.populated
-    diff_program = os.environ.get('DIFF_VIEWER', 'diff')
-    with NamedTemporaryFile() as local:
-      self.dump_tasks(local_tasks, local)
-      with NamedTemporaryFile() as remote:
-        self.dump_tasks(remote_tasks, remote)
-        result = subprocess.call([diff_program, remote.name, local.name])
-        # Unlike most commands, diff doesn't return zero on success; it returns
-        # 1 when a successful diff is non-empty.
-        if result not in (0, 1):
-          raise context.CommandError(EXIT_COMMAND_FAILURE, 'Error running diff command')
-        else:
-          return EXIT_OK
-
-
-class InspectCommand(Verb):
-  @property
-  def help(self):
-    return """usage: inspect cluster/role/env/job config
-
-  Verifies that a job can be parsed from a configuration file, and displays
-  the parsed configuration.
-  """
-
-  @property
-  def name(self):
-    return 'inspect'
-
-  def setup_options_parser(self, parser):
-    self.add_option(parser, BIND_OPTION)
-    self.add_option(parser, JSON_OPTION)
-    parser.add_argument('--local', dest='local', default=False, action='store_true',
-        help='Inspect the configuration as would be created by the "spawn" command.')
-    parser.add_argument('--raw', dest='raw', default=False, action='store_true',
-        help='Show the raw configuration.')
-
-    self.add_option(parser, JOBSPEC_ARGUMENT)
-    self.add_option(parser, CONFIG_ARGUMENT)
+      raise context.CommandError(EXIT_COMMAND_FAILURE,
+          'Server could not populate job config for comparison: %s' % resp.message)
+    local_task_count = len(resp.result.populateJobResult.populated)
+    remote_task_count = len(remote_tasks)
+    if (local_task_count >= 4 * remote_task_count or
+        local_task_count <= 4 * remote_task_count or
+        local_task_count == 0):
+      context.print_out('Warning: this update is a large change. Press ^C within 5 seconds to abort')
+      time.sleep(5)
 
   def execute(self, context):
     config = context.get_job_config(context.options.jobspec, context.options.config_file)
-    if context.options.raw:
-      print(config.job())
-      return EXIT_OK
-    job_thrift = config.job()
-    job = config.raw()
-    job_thrift = config.job()
-    print('Job level information')
-    print('  name:       %s' % job.name())
-    print('  role:       %s' % job.role())
-    print('  contact:    %s' % job.contact())
-    print('  cluster:    %s' % job.cluster())
-    print('  instances:  %s' % job.instances())
-    if job.has_cron_schedule():
-      print('  cron:')
-      print('     schedule: %s' % job.cron_schedule())
-      print('     policy:   %s' % job.cron_collision_policy())
-    if job.has_constraints():
-      print('  constraints:')
-      for constraint, value in job.constraints().get().items():
-        print('    %s: %s' % (constraint, value))
-    print('  service:    %s' % job_thrift.taskConfig.isService)
-    print('  production: %s' % bool(job.production().get()))
-    print()
-
-    task = job.task()
-    print('Task level information')
-    print('  name: %s' % task.name())
-    if len(task.constraints().get()) > 0:
-      print('  constraints:')
-      for constraint in task.constraints():
-        print('    %s' % (' < '.join(st.get() for st in constraint.order() or [])))
-    print()
-
-    processes = task.processes()
-    for process in processes:
-      print('Process %s:' % process.name())
-      if process.daemon().get():
-        print('  daemon')
-      if process.ephemeral().get():
-        print('  ephemeral')
-      if process.final().get():
-        print('  final')
-      print('  cmdline:')
-      for line in process.cmdline().get().splitlines():
-        print('    ' + line)
-      print()
+    api = context.get_api(config.cluster())
+    if not context.options.force:
+      self.warn_if_dangerous_change(context, api, context.options.jobspec, config)
+    resp = api.update_job(config, context.options.healthcheck_interval_seconds,
+        context.options.instances)
+    if resp.responseCode != ResponseCode.OK:
+      raise context.CommandError(EXIT_COMMAND_FAILURE, 'Update failed: %s' % resp.message)
 
 
 class Job(Noun):
@@ -414,8 +571,12 @@ class Job(Noun):
 
   def __init__(self):
     super(Job, self).__init__()
+    self.register_verb(CancelUpdateCommand())
     self.register_verb(CreateJobCommand())
-    self.register_verb(KillJobCommand())
-    self.register_verb(StatusCommand())
     self.register_verb(DiffCommand())
     self.register_verb(InspectCommand())
+    self.register_verb(KillJobCommand())
+    self.register_verb(ListJobsCommand())
+    self.register_verb(RestartCommand())
+    self.register_verb(StatusCommand())
+    self.register_verb(UpdateCommand())

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/3a23a501/src/main/python/apache/aurora/client/cli/options.py
----------------------------------------------------------------------
diff --git a/src/main/python/apache/aurora/client/cli/options.py b/src/main/python/apache/aurora/client/cli/options.py
index 1b71554..b45af32 100644
--- a/src/main/python/apache/aurora/client/cli/options.py
+++ b/src/main/python/apache/aurora/client/cli/options.py
@@ -18,6 +18,26 @@ from apache.aurora.client.cli import CommandOption
 from apache.aurora.common.aurora_job_key import AuroraJobKey
 
 
+def parse_instances(instances):
+  """Parse lists of instances or instance ranges into a set().
+     Examples:
+       0-2
+       0,1-3,5
+       1,3,5
+  """
+  if instances is None or instances == '':
+    return None
+  result = set()
+  for part in instances.split(','):
+    x = part.split('-')
+    result.update(range(int(x[0]), int(x[-1]) + 1))
+  return sorted(result)
+
+
+BATCH_OPTION = CommandOption('--batch_size', type=int, default=5,
+        help='Number of instances to be operate on in one iteration')
+
+
 BIND_OPTION = CommandOption('--bind', type=str, default=[], dest='bindings',
     action='append',
     help='Bind a thermos mustache variable name to a value. '
@@ -33,9 +53,36 @@ CONFIG_ARGUMENT = CommandOption('config_file', type=str,
     help='pathname of the aurora configuration file contain the job specification')
 
 
+FORCE_OPTION = CommandOption('--force', default=False, action='store_true',
+    help='Force execution of the command even if there is a warning')
+
+
+HEALTHCHECK_OPTION = CommandOption('--healthcheck_interval_seconds', type=int,
+    default=3, dest='healthcheck_interval_seconds',
+    help='Number of seconds between healthchecks while monitoring update')
+
+
+INSTANCES_OPTION = CommandOption('--instances', type=parse_instances, dest='instances',
+    default=None,
+     help='A list of instance ids to act on. Can either be a comma-separated list (e.g. 0,1,2) '
+         'or a range (e.g. 0-2) or any combination of the two (e.g. 0-2,5,7-9). If not set, '
+         'all instances will be acted on.')
+
+
 JOBSPEC_ARGUMENT = CommandOption('jobspec', type=AuroraJobKey.from_path,
     help='Fully specified job key, in CLUSTER/ROLE/ENV/NAME format')
 
 
-JSON_OPTION = CommandOption('--json', default=False, dest='json', action='store_true',
+JSON_READ_OPTION = CommandOption('--read_json', default=False, dest='read_json',
+    action='store_true',
     help='Read job configuration in json format')
+
+
+JSON_WRITE_OPTION = CommandOption('--write_json', default=False, dest='write_json',
+    action='store_true',
+    help='Generate command output in JSON format')
+
+
+WATCH_OPTION = CommandOption('--watch_secs', type=int, default=30,
+    help='Minimum number of seconds a shard must remain in RUNNING state before considered a '
+         'success.')

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/3a23a501/src/main/python/apache/aurora/client/commands/core.py
----------------------------------------------------------------------
diff --git a/src/main/python/apache/aurora/client/commands/core.py b/src/main/python/apache/aurora/client/commands/core.py
index 13657c4..8dc4917 100644
--- a/src/main/python/apache/aurora/client/commands/core.py
+++ b/src/main/python/apache/aurora/client/commands/core.py
@@ -133,7 +133,8 @@ def create(job_spec, config_file):
   monitor = JobMonitor(api, config.role(), config.environment(), config.name())
   resp = api.create_job(config)
   check_and_log_response(resp)
-  handle_open(api.scheduler_proxy.scheduler_client().url, config.role(), config.environment(), config.name())
+  handle_open(api.scheduler_proxy.scheduler_client().url, config.role(), config.environment(),
+      config.name())
   if options.wait_until == 'RUNNING':
     monitor.wait_until(monitor.running_or_finished)
   elif options.wait_until == 'FINISHED':
@@ -229,7 +230,8 @@ def do_open(args, _):
   api = make_client(cluster_name)
 
   import webbrowser
-  webbrowser.open_new_tab(synthesize_url(api.scheduler_proxy.scheduler_client().url, role, env, job))
+  webbrowser.open_new_tab(
+      synthesize_url(api.scheduler_proxy.scheduler_client().url, role, env, job))
 
 
 @app.command

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/3a23a501/src/test/python/apache/aurora/client/cli/BUILD
----------------------------------------------------------------------
diff --git a/src/test/python/apache/aurora/client/cli/BUILD b/src/test/python/apache/aurora/client/cli/BUILD
index f9ebe0c..e619d22 100644
--- a/src/test/python/apache/aurora/client/cli/BUILD
+++ b/src/test/python/apache/aurora/client/cli/BUILD
@@ -30,7 +30,15 @@ python_library(
 
 python_tests(
   name = 'job',
-  sources = [ 'test_create.py', 'test_kill.py', 'test_status.py', 'test_diff.py' ],
+  sources = [
+    'test_cancel_update.py',
+    'test_create.py',
+    'test_diff.py',
+    'test_kill.py',
+    'test_restart.py',
+    'test_status.py',
+    'test_update.py',
+  ],
   dependencies = [
     pants(':util'),
     pants('src/main/python/apache/aurora/BUILD.thirdparty:mock'),

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/3a23a501/src/test/python/apache/aurora/client/cli/test_cancel_update.py
----------------------------------------------------------------------
diff --git a/src/test/python/apache/aurora/client/cli/test_cancel_update.py b/src/test/python/apache/aurora/client/cli/test_cancel_update.py
new file mode 100644
index 0000000..92de1b9
--- /dev/null
+++ b/src/test/python/apache/aurora/client/cli/test_cancel_update.py
@@ -0,0 +1,115 @@
+#
+# Copyright 2013 Apache Software Foundation
+#
+# 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 contextlib
+
+from apache.aurora.client.cli import AuroraCommandLine
+from apache.aurora.client.cli.util import AuroraClientCommandTest, FakeAuroraCommandContext
+from apache.aurora.common.aurora_job_key import AuroraJobKey
+from twitter.common.contextutil import temporary_file
+
+from gen.apache.aurora.ttypes import (
+    Identity,
+    JobKey,
+    ScheduleStatus,
+    ScheduleStatusResult,
+    TaskQuery,
+)
+
+from mock import Mock, patch
+
+
+class TestClientCancelUpdateCommand(AuroraClientCommandTest):
+
+  @classmethod
+  def setup_mock_api_factory(cls):
+    mock_api_factory, mock_api = cls.create_mock_api_factory()
+    mock_api_factory.return_value.cancel_update.return_value = cls.get_cancel_update_response()
+    return mock_api_factory
+
+  @classmethod
+  def create_mock_status_query_result(cls, scheduleStatus):
+    mock_query_result = cls.create_simple_success_response()
+    mock_query_result.result.scheduleStatusResult = Mock(spec=ScheduleStatusResult)
+    if scheduleStatus == ScheduleStatus.INIT:
+      # status query result for before job is launched.
+      mock_query_result.result.scheduleStatusResult.tasks = []
+    else:
+      mock_task_one = cls.create_mock_task('hello', 0, 1000, scheduleStatus)
+      mock_task_two = cls.create_mock_task('hello', 1, 1004, scheduleStatus)
+      mock_query_result.result.scheduleStatusResult.tasks = [mock_task_one, mock_task_two]
+    return mock_query_result
+
+  @classmethod
+  def create_mock_query(cls):
+    return TaskQuery(owner=Identity(role=cls.TEST_ROLE), environment=cls.TEST_ENV,
+        jobName=cls.TEST_JOB)
+
+  @classmethod
+  def get_cancel_update_response(cls):
+    return cls.create_simple_success_response()
+
+  @classmethod
+  def assert_cancel_update_called(cls, mock_api):
+    # Running cancel update should result in calling the API cancel_update
+    # method once, with an AuroraJobKey parameter.
+    assert mock_api.cancel_update.call_count == 1
+    assert mock_api.cancel_update.called_with(
+        AuroraJobKey(cls.TEST_CLUSTER, cls.TEST_ROLE, cls.TEST_ENV, cls.TEST_JOB),
+        config=None)
+
+  def test_simple_successful_cancel_update(self):
+    """Run a test of the "kill" command against a mocked-out API:
+    Verifies that the kill command sends the right API RPCs, and performs the correct
+    tests on the result."""
+    mock_context = FakeAuroraCommandContext()
+    mock_api = mock_context.get_api('west')
+    mock_api.cancel_update.return_value = self.create_simple_success_response()
+    with patch('apache.aurora.client.cli.jobs.Job.create_context', return_value=mock_context):
+      cmd = AuroraCommandLine()
+      cmd.execute(['job', 'cancel_update', 'west/bozo/test/hello'])
+      self.assert_cancel_update_called(mock_api)
+
+  @classmethod
+  def get_expected_task_query(cls, shards=None):
+    instance_ids = frozenset(shards) if shards is not None else None
+    # Helper to create the query that will be a parameter to job kill.
+    return TaskQuery(taskIds=None, jobName=cls.TEST_JOB, environment=cls.TEST_ENV,
+        instanceIds=instance_ids, owner=Identity(role=cls.TEST_ROLE, user=None))
+
+  @classmethod
+  def get_release_lock_response(cls):
+    """Set up the response to a startUpdate API call."""
+    return cls.create_simple_success_response()
+
+  def test_cancel_update_api_level(self):
+    """Test kill client-side API logic."""
+    (mock_api, mock_scheduler_proxy) = self.create_mock_api()
+    mock_scheduler_proxy.releaseLock.return_value = self.get_release_lock_response()
+    with contextlib.nested(
+        patch('apache.aurora.client.api.SchedulerProxy', return_value=mock_scheduler_proxy),
+        patch('apache.aurora.client.factory.CLUSTERS', new=self.TEST_CLUSTERS)):
+      with temporary_file() as fp:
+        fp.write(self.get_valid_config())
+        fp.flush()
+        cmd = AuroraCommandLine()
+        cmd.execute(['job', 'cancel_update', 'west/mchucarroll/test/hello'])
+
+      # All that cancel_update really does is release the update lock.
+      # So that's all we really need to check.
+      assert mock_scheduler_proxy.releaseLock.call_count == 1
+      assert mock_scheduler_proxy.releaseLock.call_args[0][0].key.job == JobKey(environment='test',
+          role='mchucarroll', name='hello')

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/3a23a501/src/test/python/apache/aurora/client/cli/test_create.py
----------------------------------------------------------------------
diff --git a/src/test/python/apache/aurora/client/cli/test_create.py b/src/test/python/apache/aurora/client/cli/test_create.py
index 64eb51b..330bde5 100644
--- a/src/test/python/apache/aurora/client/cli/test_create.py
+++ b/src/test/python/apache/aurora/client/cli/test_create.py
@@ -42,17 +42,6 @@ from mock import Mock, patch
 class TestClientCreateCommand(AuroraClientCommandTest):
 
   @classmethod
-  def setup_mock_options(cls):
-    """set up to get a mock options object."""
-    mock_options = Mock()
-    mock_options.json = False
-    mock_options.bindings = {}
-    mock_options.open_browser = False
-    mock_options.cluster = None
-    mock_options.wait_until = 'RUNNING'  # or 'FINISHED' for other tests
-    return mock_options
-
-  @classmethod
   def create_mock_task(cls, task_id, instance_id, initial_time, status):
     mock_task = Mock(spec=ScheduledTask)
     mock_task.assignedTask = Mock(spec=AssignedTask)

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/3a23a501/src/test/python/apache/aurora/client/cli/test_kill.py
----------------------------------------------------------------------
diff --git a/src/test/python/apache/aurora/client/cli/test_kill.py b/src/test/python/apache/aurora/client/cli/test_kill.py
index 714d5fb..9c593b9 100644
--- a/src/test/python/apache/aurora/client/cli/test_kill.py
+++ b/src/test/python/apache/aurora/client/cli/test_kill.py
@@ -21,7 +21,7 @@ from apache.aurora.client.cli import AuroraCommandLine
 from apache.aurora.client.hooks.hooked_api import HookedAuroraClientAPI
 from apache.aurora.common.aurora_job_key import AuroraJobKey
 from twitter.common.contextutil import temporary_file
-from apache.aurora.client.cli.jobs import parse_instances
+from apache.aurora.client.cli.options import parse_instances
 from apache.aurora.client.cli.util import AuroraClientCommandTest, FakeAuroraCommandContext
 
 from gen.apache.aurora.ttypes import (

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/3a23a501/src/test/python/apache/aurora/client/cli/test_restart.py
----------------------------------------------------------------------
diff --git a/src/test/python/apache/aurora/client/cli/test_restart.py b/src/test/python/apache/aurora/client/cli/test_restart.py
new file mode 100644
index 0000000..3c04433
--- /dev/null
+++ b/src/test/python/apache/aurora/client/cli/test_restart.py
@@ -0,0 +1,141 @@
+import contextlib
+import functools
+
+from apache.aurora.client.api.health_check import InstanceWatcherHealthCheck, Retriable
+from apache.aurora.client.cli import AuroraCommandLine, EXIT_API_ERROR
+from apache.aurora.client.cli.util import AuroraClientCommandTest
+
+from twitter.common.contextutil import temporary_file
+
+from gen.apache.aurora.ttypes import (
+    AssignedTask,
+    JobKey,
+    PopulateJobResult,
+    ScheduledTask,
+    ScheduleStatusResult,
+    TaskConfig,
+)
+
+from mock import Mock, patch
+
+
+class TestRestartCommand(AuroraClientCommandTest):
+
+  @classmethod
+  def setup_mock_scheduler_for_simple_restart(cls, api):
+    """Set up all of the API mocks for scheduler calls during a simple restart"""
+    sched_proxy = api.scheduler_proxy
+    cls.setup_get_tasks_status_calls(sched_proxy)
+    cls.setup_populate_job_config(sched_proxy)
+    sched_proxy.restartShards.return_value = cls.create_simple_success_response()
+
+  @classmethod
+  def setup_populate_job_config(cls, api):
+    populate = cls.create_simple_success_response()
+    populate.result.populateJobResult = Mock(spec=PopulateJobResult)
+    api.populateJobConfig.return_value = populate
+    configs = [Mock(spec=TaskConfig) for i in range(20)]
+    populate.result.populateJobResult.populated = set(configs)
+    return populate
+
+  @classmethod
+  def setup_get_tasks_status_calls(cls, scheduler):
+    status_response = cls.create_simple_success_response()
+    scheduler.getTasksStatus.return_value = status_response
+    schedule_status = Mock(spec=ScheduleStatusResult)
+    status_response.result.scheduleStatusResult = schedule_status
+    mock_task_config = Mock()
+    # This should be a list of ScheduledTask's.
+    schedule_status.tasks = []
+    for i in range(20):
+      task_status = Mock(spec=ScheduledTask)
+      task_status.assignedTask = Mock(spec=AssignedTask)
+      task_status.assignedTask.instanceId = i
+      task_status.assignedTask.taskId = "Task%s" % i
+      task_status.assignedTask.slaveId = "Slave%s" % i
+      task_status.slaveHost = "Slave%s" % i
+      task_status.assignedTask.task = mock_task_config
+      schedule_status.tasks.append(task_status)
+
+  @classmethod
+  def setup_health_checks(cls, mock_api):
+    mock_health_check = Mock(spec=InstanceWatcherHealthCheck)
+    mock_health_check.health.return_value = Retriable.alive()
+    return mock_health_check
+
+  def test_restart_simple(self):
+    # Test the client-side restart logic in its simplest case: everything succeeds
+    (mock_api, mock_scheduler_proxy) = self.create_mock_api()
+    mock_health_check = self.setup_health_checks(mock_api)
+    self.setup_mock_scheduler_for_simple_restart(mock_api)
+    with contextlib.nested(
+        patch('apache.aurora.client.api.SchedulerProxy', return_value=mock_scheduler_proxy),
+        patch('apache.aurora.client.factory.CLUSTERS', new=self.TEST_CLUSTERS),
+        patch('apache.aurora.client.api.instance_watcher.InstanceWatcherHealthCheck',
+            return_value=mock_health_check),
+        patch('time.time', side_effect=functools.partial(self.fake_time, self)),
+        patch('time.sleep', return_value=None)
+    ):
+      with temporary_file() as fp:
+        fp.write(self.get_valid_config())
+        fp.flush()
+        cmd = AuroraCommandLine()
+        cmd.execute(['job', 'restart', '--batch_size=5', 'west/bozo/test/hello', fp.name])
+
+        # Like the update test, the exact number of calls here doesn't matter.
+        # what matters is that it must have been called once before batching, plus
+        # at least once per batch, and there are 4 batches.
+        assert mock_scheduler_proxy.getTasksStatus.call_count >= 4
+        # called once per batch
+        assert mock_scheduler_proxy.restartShards.call_count == 4
+        # parameters for all calls are generated by the same code, so we just check one
+        mock_scheduler_proxy.restartShards.assert_called_with(JobKey(environment=self.TEST_ENV,
+            role=self.TEST_ROLE, name=self.TEST_JOB), [15, 16, 17, 18, 19], None)
+
+  def test_restart_failed_status(self):
+    # Test the client-side updater logic in its simplest case: everything succeeds, and no rolling
+    # updates.
+    (mock_api, mock_scheduler_proxy) = self.create_mock_api()
+    mock_health_check = self.setup_health_checks(mock_api)
+    self.setup_mock_scheduler_for_simple_restart(mock_api)
+    mock_scheduler_proxy.getTasksStatus.return_value = self.create_error_response()
+    with contextlib.nested(
+        patch('apache.aurora.client.api.SchedulerProxy', return_value=mock_scheduler_proxy),
+        patch('apache.aurora.client.factory.CLUSTERS', new=self.TEST_CLUSTERS),
+        patch('apache.aurora.client.api.instance_watcher.InstanceWatcherHealthCheck',
+            return_value=mock_health_check),
+        patch('time.time', side_effect=functools.partial(self.fake_time, self)),
+        patch('time.sleep', return_value=None)):
+      with temporary_file() as fp:
+        fp.write(self.get_valid_config())
+        fp.flush()
+        cmd = AuroraCommandLine()
+        result = cmd.execute(['job', 'restart', '--batch_size=5', 'west/bozo/test/hello', fp.name])
+        assert mock_scheduler_proxy.getTasksStatus.call_count == 1
+        assert mock_scheduler_proxy.restartShards.call_count == 0
+        assert result == EXIT_API_ERROR
+
+  def test_restart_failed_restart(self):
+    # Test the client-side updater logic in its simplest case: everything succeeds, and no rolling
+    # updates.
+    (mock_api, mock_scheduler_proxy) = self.create_mock_api()
+    mock_health_check = self.setup_health_checks(mock_api)
+    self.setup_mock_scheduler_for_simple_restart(mock_api)
+    mock_scheduler_proxy.restartShards.return_value = self.create_error_response()
+    with contextlib.nested(
+        patch('apache.aurora.client.api.SchedulerProxy', return_value=mock_scheduler_proxy),
+        patch('apache.aurora.client.factory.CLUSTERS', new=self.TEST_CLUSTERS),
+        patch('apache.aurora.client.api.instance_watcher.InstanceWatcherHealthCheck',
+            return_value=mock_health_check),
+        patch('time.time', side_effect=functools.partial(self.fake_time, self)),
+        patch('time.sleep', return_value=None)):
+      with temporary_file() as fp:
+        fp.write(self.get_valid_config())
+        fp.flush()
+        cmd = AuroraCommandLine()
+        result = cmd.execute(['job', 'restart', '--batch_size=5', 'west/bozo/test/hello', fp.name])
+        assert mock_scheduler_proxy.getTasksStatus.call_count == 1
+        assert mock_scheduler_proxy.restartShards.call_count == 1
+        mock_scheduler_proxy.restartShards.assert_called_with(JobKey(environment=self.TEST_ENV,
+            role=self.TEST_ROLE, name=self.TEST_JOB), [0, 1, 2, 3, 4], None)
+        assert result == EXIT_API_ERROR

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/3a23a501/src/test/python/apache/aurora/client/cli/test_status.py
----------------------------------------------------------------------
diff --git a/src/test/python/apache/aurora/client/cli/test_status.py b/src/test/python/apache/aurora/client/cli/test_status.py
index efcf164..38e14b1 100644
--- a/src/test/python/apache/aurora/client/cli/test_status.py
+++ b/src/test/python/apache/aurora/client/cli/test_status.py
@@ -150,7 +150,6 @@ class TestJobStatus(AuroraClientCommandTest):
     assert mock_api.check_status.call_args_list[1][0][0].env == 'test'
     assert mock_api.check_status.call_args_list[1][0][0].name == 'hello'
 
-
   def test_status_wildcard_two(self):
     """Test status using a wildcard. It should first call api.get_jobs, and then do a
     getTasksStatus on each job. This time, use a pattern that doesn't match all of the jobs."""

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/3a23a501/src/test/python/apache/aurora/client/cli/test_update.py
----------------------------------------------------------------------
diff --git a/src/test/python/apache/aurora/client/cli/test_update.py b/src/test/python/apache/aurora/client/cli/test_update.py
new file mode 100644
index 0000000..c469da4
--- /dev/null
+++ b/src/test/python/apache/aurora/client/cli/test_update.py
@@ -0,0 +1,246 @@
+import contextlib
+import functools
+
+from twitter.common.contextutil import temporary_file
+
+from apache.aurora.client.api.updater import Updater
+from apache.aurora.client.api.health_check import InstanceWatcherHealthCheck, Retriable
+from apache.aurora.client.api.quota_check import QuotaCheck
+from apache.aurora.client.cli import AuroraCommandLine, EXIT_INVALID_CONFIGURATION
+from apache.aurora.client.cli.util import AuroraClientCommandTest, FakeAuroraCommandContext
+from apache.aurora.config import AuroraConfig
+
+from gen.apache.aurora.constants import ACTIVE_STATES
+from gen.apache.aurora.ttypes import (
+    AcquireLockResult,
+    AddInstancesConfig,
+    AssignedTask,
+    Identity,
+    JobKey,
+    PopulateJobResult,
+    ResponseCode,
+    ScheduledTask,
+    ScheduleStatus,
+    ScheduleStatusResult,
+    TaskConfig,
+    TaskQuery
+)
+
+from mock import Mock, patch
+
+
+class TestUpdateCommand(AuroraClientCommandTest):
+
+  @classmethod
+  def setup_mock_options(cls):
+    """set up to get a mock options object."""
+    mock_options = Mock()
+    mock_options.json = False
+    mock_options.bindings = {}
+    mock_options.open_browser = False
+    mock_options.cluster = None
+    mock_options.force = True
+    mock_options.env = None
+    mock_options.shards = None
+    mock_options.health_check_interval_seconds = 3
+    return mock_options
+
+  @classmethod
+  def setup_mock_updater(cls):
+    return Mock(spec=Updater)
+
+  # First, we pretend that the updater isn't really client-side, and test
+  # that the client makes the right API call to the updated.
+  def test_update_command_line_succeeds(self):
+    mock_context = FakeAuroraCommandContext()
+    with contextlib.nested(
+        patch('apache.aurora.client.cli.jobs.Job.create_context', return_value=mock_context),
+        patch('apache.aurora.client.factory.CLUSTERS', new=self.TEST_CLUSTERS)):
+      mock_api = mock_context.get_api('west')
+      with temporary_file() as fp:
+        fp.write(self.get_valid_config())
+        fp.flush()
+        cmd = AuroraCommandLine()
+        cmd.execute(['job', 'update', '--force', self.TEST_JOBSPEC, fp.name])
+
+      assert mock_api.update_job.call_count == 1
+      args, kwargs = mock_api.update_job.call_args
+      assert isinstance(args[0], AuroraConfig)
+      assert args[1] == 3
+      assert args[2] is None
+
+  def test_update_invalid_config(self):
+    mock_context = FakeAuroraCommandContext()
+    with contextlib.nested(
+        patch('apache.aurora.client.cli.jobs.Job.create_context', return_value=mock_context),
+        patch('apache.aurora.client.factory.CLUSTERS', new=self.TEST_CLUSTERS)):
+      mock_api = mock_context.get_api('west')
+      with temporary_file() as fp:
+        fp.write(self.get_invalid_config('invalid_field=False,'))
+        fp.flush()
+        cmd = AuroraCommandLine()
+        result = cmd.execute(['job', 'update', '--force', self.TEST_JOBSPEC, fp.name])
+        assert result == EXIT_INVALID_CONFIGURATION
+        assert mock_api.update_job.call_count == 0
+
+  @classmethod
+  def setup_mock_scheduler_for_simple_update(cls, api):
+    """Set up all of the API mocks for scheduler calls during a simple update"""
+    sched_proxy = api.scheduler_proxy
+    # First, the updater acquires a lock
+    sched_proxy.acquireLock.return_value = cls.create_acquire_lock_response(ResponseCode.OK,
+         'OK', 'token', False)
+    # Then it gets the status of the tasks for the updating job.
+    cls.setup_get_tasks_status_calls(sched_proxy)
+    # Next, it needs to populate the update config.
+    cls.setup_populate_job_config(sched_proxy)
+    # Then it does the update, which kills and restarts jobs, and monitors their
+    # health with the status call.
+    cls.setup_kill_tasks(sched_proxy)
+    cls.setup_add_tasks(sched_proxy)
+    # Finally, after successful health checks, it releases the lock.
+    cls.setup_release_lock_response(sched_proxy)
+
+  @classmethod
+  def setup_add_tasks(cls, api):
+    add_response = cls.create_simple_success_response()
+    api.addInstances.return_value = add_response
+    return add_response
+
+  @classmethod
+  def setup_kill_tasks(cls, api):
+    kill_response = cls.create_simple_success_response()
+    api.killTasks.return_value = kill_response
+    return kill_response
+
+  @classmethod
+  def setup_populate_job_config(cls, api):
+    populate = cls.create_simple_success_response()
+    populate.result.populateJobResult = Mock(spec=PopulateJobResult)
+    api.populateJobConfig.return_value = populate
+    configs = [TaskConfig(numCpus=1.0, ramMb=1, diskMb=1) for i in range(20)]
+    populate.result.populateJobResult.populated = set(configs)
+    return populate
+
+  @classmethod
+  def create_acquire_lock_response(cls, code, msg, token, rolling):
+    """Set up the response to a startUpdate API call."""
+    start_update_response = cls.create_blank_response(code, msg)
+    start_update_response.result.acquireLockResult = Mock(spec=AcquireLockResult)
+    start_update_response.result.acquireLockResult.lock = "foo"
+    start_update_response.result.acquireLockResult.updateToken = 'token'
+    return start_update_response
+
+  @classmethod
+  def setup_release_lock_response(cls, api):
+    """Set up the response to a startUpdate API call."""
+    release_lock_response = cls.create_simple_success_response()
+    api.releaseLock.return_value = release_lock_response
+    return release_lock_response
+
+  @classmethod
+  def setup_get_tasks_status_calls(cls, scheduler):
+    status_response = cls.create_simple_success_response()
+    scheduler.getTasksStatus.return_value = status_response
+    schedule_status = Mock(spec=ScheduleStatusResult)
+    status_response.result.scheduleStatusResult = schedule_status
+    task_config = TaskConfig(numCpus=1.0, ramMb=10, diskMb=1)
+    # This should be a list of ScheduledTask's.
+    schedule_status.tasks = []
+    for i in range(20):
+      task_status = Mock(spec=ScheduledTask)
+      task_status.assignedTask = Mock(spec=AssignedTask)
+      task_status.assignedTask.instanceId = i
+      task_status.assignedTask.taskId = "Task%s" % i
+      task_status.assignedTask.slaveId = "Slave%s" % i
+      task_status.slaveHost = "Slave%s" % i
+      task_status.assignedTask.task = task_config
+      schedule_status.tasks.append(task_status)
+
+  @classmethod
+  def setup_health_checks(cls, mock_api):
+    mock_health_check = Mock(spec=InstanceWatcherHealthCheck)
+    mock_health_check.health.return_value = Retriable.alive()
+    return mock_health_check
+
+  @classmethod
+  def setup_quota_check(cls):
+    mock_quota_check = Mock(spec=QuotaCheck)
+    mock_quota_check.validate_quota_from_requested.return_value = \
+        cls.create_simple_success_response()
+
+  def test_updater_simple(self):
+    # Test the client-side updater logic in its simplest case: everything succeeds,
+    # and no rolling updates. (Rolling updates are covered by the updated tests.)
+    (mock_api, mock_scheduler_proxy) = self.create_mock_api()
+    mock_health_check = self.setup_health_checks(mock_api)
+    mock_quota_check = self.setup_quota_check()
+    self.setup_mock_scheduler_for_simple_update(mock_api)
+    # This doesn't work, because:
+    # - The mock_context stubs out the API.
+    # - the test relies on using live code in the API.
+    with contextlib.nested(
+        patch('apache.aurora.client.factory.CLUSTERS', new=self.TEST_CLUSTERS),
+        patch('apache.aurora.client.api.SchedulerProxy', return_value=mock_scheduler_proxy),
+        patch('apache.aurora.client.api.instance_watcher.InstanceWatcherHealthCheck',
+            return_value=mock_health_check),
+        patch('apache.aurora.client.api.quota_check.QuotaCheck', return_value=mock_quota_check),
+        patch('time.time', side_effect=functools.partial(self.fake_time, self)),
+        patch('time.sleep', return_value=None)):
+      with temporary_file() as fp:
+        fp.write(self.get_valid_config())
+        fp.flush()
+        cmd = AuroraCommandLine()
+        cmd.execute(['job', 'update', 'west/bozo/test/hello', fp.name])
+
+      # We don't check all calls. The updater should be able to change. What's important
+      # is that we verify the key parts of the update process, to have some confidence
+      # that update did the right things.
+      # Every update should:
+      # check its options, acquire an update lock, check status,
+      # kill the old tasks, start new ones, wait for the new ones to healthcheck,
+      # and finally release the lock.
+      # The kill/start should happen in rolling batches.
+      mock_scheduler_proxy = mock_api.scheduler_proxy
+      assert mock_scheduler_proxy.acquireLock.call_count == 1
+      self.assert_correct_killtask_calls(mock_scheduler_proxy)
+      self.assert_correct_addinstance_calls(mock_scheduler_proxy)
+      self.assert_correct_status_calls(mock_scheduler_proxy)
+      assert mock_scheduler_proxy.releaseLock.call_count == 1
+
+  @classmethod
+  def assert_correct_addinstance_calls(cls, api):
+    assert api.addInstances.call_count == 4
+    last_addinst = api.addInstances.call_args
+    assert isinstance(last_addinst[0][0], AddInstancesConfig)
+    assert last_addinst[0][0].instanceIds == frozenset([15, 16, 17, 18, 19])
+    assert last_addinst[0][0].key == JobKey(environment='test', role='bozo', name='hello')
+
+  @classmethod
+  def assert_correct_killtask_calls(cls, api):
+    assert api.killTasks.call_count == 4
+    # Check the last call's parameters.
+    api.killTasks.assert_called_with(
+        TaskQuery(taskIds=None, jobName='hello', environment='test',
+            instanceIds=frozenset([16, 17, 18, 19, 15]),
+            owner=Identity(role=u'bozo', user=None),
+           statuses=ACTIVE_STATES),
+        'foo')
+
+  @classmethod
+  def assert_correct_status_calls(cls, api):
+    # getTasksStatus gets called a lot of times. The exact number isn't fixed; it loops
+    # over the health checks until all of them pass for a configured period of time.
+    # The minumum number of calls is 5: once before the tasks are restarted, and then
+    # once for each batch of restarts (Since the batch size is set to 5, and the
+    # total number of jobs is 20, that's 4 batches.)
+    assert api.getTasksStatus.call_count >= 5
+    # In the first getStatus call, it uses an expansive query; in the rest, it only queries for
+    # status RUNNING.
+    status_calls = api.getTasksStatus.call_args_list
+    assert status_calls[0][0][0] == TaskQuery(taskIds=None, jobName='hello', environment='test',
+        owner=Identity(role=u'bozo', user=None),
+        statuses=ACTIVE_STATES)
+    for status_call in status_calls[1:]:
+      status_call[0][0] == TaskQuery(taskIds=None, jobName='hello', environment='test',
+          owner=Identity(role='bozo', user=None), statuses=set([ScheduleStatus.RUNNING]))

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/3a23a501/src/test/python/apache/aurora/client/cli/util.py
----------------------------------------------------------------------
diff --git a/src/test/python/apache/aurora/client/cli/util.py b/src/test/python/apache/aurora/client/cli/util.py
index 76f9543..cbd544d 100644
--- a/src/test/python/apache/aurora/client/cli/util.py
+++ b/src/test/python/apache/aurora/client/cli/util.py
@@ -80,7 +80,6 @@ class AuroraClientCommandTest(unittest.TestCase):
     response.result = Mock(spec=Result)
     return response
 
-
   @classmethod
   def create_simple_success_response(cls):
     return cls.create_blank_response(ResponseCode.OK, 'OK')
@@ -99,7 +98,7 @@ class AuroraClientCommandTest(unittest.TestCase):
     mock_scheduler_client.scheduler.return_value = mock_scheduler
     mock_scheduler_client.url = "http://something_or_other"
     mock_api = Mock(spec=HookedAuroraClientAPI)
-    mock_api.scheduler = mock_scheduler_client
+    mock_api.scheduler_proxy = mock_scheduler_client
     return (mock_api, mock_scheduler_client)
 
   @classmethod