You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@aurora.apache.org by wi...@apache.org on 2014/01/14 18:41:57 UTC
git commit: client v2: added implementations of job diff, job inspect.
Updated Branches:
refs/heads/master 343613a5c -> e1aee67b7
client v2: added implementations of job diff, job inspect.
Implemented client v2 versions of the diff and inspect verbs.
Testing Done:
Added new unit tests for "diff"; inspect doesn't do any API calls that aren't already tested.
Reviewed at https://reviews.apache.org/r/16423/
Project: http://git-wip-us.apache.org/repos/asf/incubator-aurora/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-aurora/commit/e1aee67b
Tree: http://git-wip-us.apache.org/repos/asf/incubator-aurora/tree/e1aee67b
Diff: http://git-wip-us.apache.org/repos/asf/incubator-aurora/diff/e1aee67b
Branch: refs/heads/master
Commit: e1aee67b7f792cd0ebc00e5816388e567df70c0d
Parents: 343613a
Author: Mark Chu-Carroll <mc...@twopensource.com>
Authored: Tue Jan 14 09:41:33 2014 -0800
Committer: Brian Wickman <wi...@twitter.com>
Committed: Tue Jan 14 09:41:33 2014 -0800
----------------------------------------------------------------------
.../python/apache/aurora/client/cli/__init__.py | 9 +-
.../python/apache/aurora/client/cli/context.py | 132 +++++++-
.../python/apache/aurora/client/cli/jobs.py | 308 ++++++++++++++++++-
.../python/apache/aurora/client/cli/options.py | 4 +-
src/main/python/apache/aurora/client/options.py | 2 +
src/main/python/twitter/aurora/client/cli/BUILD | 26 ++
src/test/python/apache/aurora/client/cli/BUILD | 2 +-
.../apache/aurora/client/cli/test_create.py | 26 +-
.../apache/aurora/client/cli/test_kill.py | 29 +-
.../apache/aurora/client/cli/test_status.py | 161 ++++++++++
.../python/apache/aurora/client/cli/util.py | 45 ++-
src/test/python/twitter/aurora/client/cli/BUILD | 30 ++
.../twitter/aurora/client/cli/test_diff.py | 182 +++++++++++
13 files changed, 880 insertions(+), 76 deletions(-)
----------------------------------------------------------------------
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/src/main/python/apache/aurora/client/cli/__init__.py
----------------------------------------------------------------------
diff --git a/src/main/python/apache/aurora/client/cli/__init__.py b/src/main/python/apache/aurora/client/cli/__init__.py
index 2c08cf9..1df0d9b 100644
--- a/src/main/python/apache/aurora/client/cli/__init__.py
+++ b/src/main/python/apache/aurora/client/cli/__init__.py
@@ -9,7 +9,6 @@ arguments needed by the verb.
For example:
- To create a job, the noun is "job", the verb is "create":
$ aurora job create us-west/www/prod/server server.aurora
-
- To find out the resource quota for a specific user, the noun is "user" and the verb is
"get_quota":
$ aurora user get_quota mchucarroll
@@ -31,6 +30,7 @@ EXIT_INVALID_PARAMETER = 6
EXIT_NETWORK_ERROR = 7
EXIT_PERMISSION_VIOLATION = 8
EXIT_TIMEOUT = 9
+EXIT_API_ERROR = 10
EXIT_UNKNOWN_ERROR = 20
@@ -45,6 +45,10 @@ class Context(object):
self.msg = msg
self.code = code
+ @classmethod
+ def exit(cls, code, msg):
+ raise cls.CommandError(code, msg)
+
def set_options(self, options):
"""Add the options object to a context.
This is separated from the constructor to make patching tests easier.
@@ -54,7 +58,8 @@ class Context(object):
class CommandOption(object):
"""A lightweight encapsulation of an argparse option specification, which can be used to
- define options that can be reused by multiple commands."""
+ define options that can be reused by multiple commands.
+ """
def __init__(self, *args, **kwargs):
self.args = args
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/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 c50f1a1..be156bb 100644
--- a/src/main/python/apache/aurora/client/cli/context.py
+++ b/src/main/python/apache/aurora/client/cli/context.py
@@ -1,6 +1,17 @@
+from __future__ import print_function
+from collections import namedtuple
+from fnmatch import fnmatch
+import sys
+from apache.aurora.common.clusters import CLUSTERS
+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
+from apache.aurora.client.cli import (
+ Context,
+ EXIT_NETWORK_ERROR,
+ EXIT_INVALID_PARAMETER,
+ EXIT_NETWORK_ERROR
+)
from apache.aurora.client.config import get_config
from apache.aurora.client.factory import make_client
from twitter.common import log
@@ -8,42 +19,129 @@ from twitter.common import log
from gen.apache.aurora.ttypes import ResponseCode
+# Utility type, representing job keys with wildcards.
+PartialJobKey = namedtuple('PartialJobKey', ['cluster', 'role', 'env', 'name'])
+
+
class AuroraCommandContext(Context):
"""A context object used by Aurora commands to manage command processing state
and common operations.
"""
+ def __init__(self):
+ super(AuroraCommandContext, self).__init__()
+ self.apis = {}
+
def get_api(self, cluster):
- """Creates an API object for a specified cluster"""
- return make_client(cluster)
+ """Gets an API object for a specified cluster
+ Keeps the API handle cached, so that only one handle for each cluster will be created in a
+ session.
+ """
+ if cluster not in self.apis:
+ api = make_client(cluster)
+ self.apis[cluster] = api
+ return self.apis[cluster]
- def get_job_config(self, job_key, config_file):
- """Loads a job configuration from a config file"""
- jobname = job_key.name
+ 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=job_key.cluster,
- select_role=job_key.role,
- select_env=job_key.env)
+ select_cluster=jobkey.cluster,
+ select_role=jobkey.role,
+ select_env=jobkey.env)
+
+ def print_out(self, str):
+ """Prints output. For debugging purposes, it's nice to be able to patch this
+ and capture output.
+ """
+ print(str)
+
+ def print_err(self, str):
+ """Prints output to standard error."""
+ print(str, file=sys.stderr)
def open_page(self, url):
import webbrowser
webbrowser.open_new_tab(url)
- def open_job_page(self, api, config):
- self.open_page(synthesize_url(api.scheduler.scheduler().url, config.role(),
- config.environment(), config.name()))
-
- def handle_open(self, api):
- if self.options.open_browser:
- self.open_page(synthesize_url(api.scheduler.scheduler().url,
- self.options.jobspec.role, self.options.jobspec.env, self.options.jobspec.name))
+ 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,
+ 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)
+
+ @classmethod
+ def parse_partial_jobkey(cls, key):
+ """Given a partial jobkey, where parts can be wildcards, parse it.
+ Slots that are wildcards will be replaced by "*".
+ """
+ parts = []
+ for part in key.split('/'):
+ parts.append(part)
+ if len(parts) > 4:
+ raise cls.CommandError(EXIT_INVALID_PARAMETER, 'Job key must have no more than 4 segments')
+ while len(parts) < 4:
+ parts.append('*')
+ return PartialJobKey(*parts)
+
+ def get_job_list(self, clusters, role=None):
+ """Get a list of all 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.
+ """
+ result = []
+ if '*' in role:
+ role = None
+ for cluster in clusters:
+ api = self.get_api(cluster)
+ resp = api.get_jobs(role)
+ if resp.responseCode is not ResponseCode.OK:
+ raise self.CommandError(EXIT_COMMAND_FAILURE, resp.message)
+ result.extend([AuroraJobKey(cluster, job.key.role, job.key.environment, job.key.name)
+ for job in resp.result.getJobsResult.configs])
+ return result
+
+ 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!
+ """
+
+ def is_fully_bound(key):
+ """Helper that checks if a key contains wildcards."""
+ return not any('*' in component for component in [key.cluster, key.role, key.env, key.name])
+
+ def filter_job_list(jobs, role, env, name):
+ """Filter a list of jobs to get just the jobs that match the pattern from a key"""
+ return [job for job in jobs if fnmatch(job.role, role) and fnmatch(job.env, env)
+ and fnmatch(job.name, name)]
+
+ # For cluster, we can expand the list of things we're looking for directly.
+ # For other key elements, we need to just get a list of the jobs on the clusters, and filter
+ # it for things that match.
+ if key.cluster == '*':
+ clusters_to_search = CLUSTERS
+ else:
+ clusters_to_search = [key.cluster]
+ if is_fully_bound(key):
+ return [AuroraJobKey(key.cluster, key.role, key.env, key.name)]
+ else:
+ jobs = filter_job_list(self.get_job_list(clusters_to_search, key.role),
+ key.role, key.env, key.name)
+ return jobs
+
+ def get_job_status(self, key):
+ api = self.get_api(key.cluster)
+ resp = api.check_status(key)
+ if resp.responseCode is not ResponseCode.OK:
+ raise self.CommandError(EXIT_INVALID_PARAMETER, resp.message)
+ return resp.result.scheduleStatusResult.tasks or None
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/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 b789361..1d0f37e 100644
--- a/src/main/python/apache/aurora/client/cli/jobs.py
+++ b/src/main/python/apache/aurora/client/cli/jobs.py
@@ -1,6 +1,23 @@
+from __future__ import print_function
+from datetime import datetime
+import json
+import os
+import pprint
+import subprocess
+import sys
+from tempfile import NamedTemporaryFile
+
+from apache.aurora.client.api.job_monitor import JobMonitor
+
+from gen.apache.aurora.constants import ACTIVE_STATES
+from gen.apache.aurora.ttypes import ResponseCode, ScheduleStatus
+
from apache.aurora.client.api.job_monitor import JobMonitor
from apache.aurora.client.cli import (
+ EXIT_COMMAND_FAILURE,
EXIT_INVALID_CONFIGURATION,
+ EXIT_INVALID_PARAMETER,
+ EXIT_OK,
Noun,
Verb
)
@@ -8,18 +25,19 @@ from apache.aurora.client.cli.context import AuroraCommandContext
from apache.aurora.client.cli.options import (
BIND_OPTION,
BROWSER_OPTION,
- CONFIG_OPTION,
- JOBSPEC_OPTION,
+ CONFIG_ARGUMENT,
+ JOBSPEC_ARGUMENT,
JSON_OPTION
)
from apache.aurora.common.aurora_job_key import AuroraJobKey
from pystachio.config import Config
+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
@@ -34,6 +52,10 @@ def parse_instances(instances):
return sorted(result)
+def arg_type_jobkey(key):
+ return AuroraCommandContext.parse_partial_jobkey(key)
+
+
class CreateJobCommand(Verb):
@property
def name(self):
@@ -41,7 +63,10 @@ class CreateJobCommand(Verb):
@property
def help(self):
- return 'Create a job using aurora'
+ return '''Usage: aurora create cluster/role/env/job config.aurora
+
+ Create a job using aurora
+ '''
CREATE_STATES = ('PENDING', 'RUNNING', 'FINISHED')
@@ -53,8 +78,8 @@ class CreateJobCommand(Verb):
default='PENDING',
help=('Block the client until all the tasks have transitioned into the requested state. '
'Default: PENDING'))
- self.add_option(parser, JOBSPEC_OPTION)
- self.add_option(parser, CONFIG_OPTION)
+ self.add_option(parser, JOBSPEC_ARGUMENT)
+ self.add_option(parser, CONFIG_ARGUMENT)
def execute(self, context):
try:
@@ -65,13 +90,17 @@ class CreateJobCommand(Verb):
api = context.get_api(config.cluster())
monitor = JobMonitor(api, config.role(), config.environment(), config.name())
resp = api.create_job(config)
- context.check_and_log_response(resp)
+ if resp.responseCode == ResponseCode.INVALID_REQUEST:
+ raise context.CommandError(EXIT_INVALID_PARAMETER, 'Job not found')
+ elif resp.responseCode == ResponseCode.ERROR:
+ raise context.CommandError(EXIT_COMMAND_FAILURE, resp.message)
if context.options.open_browser:
context.open_job_page(api, config)
if context.options.wait_until == 'RUNNING':
monitor.wait_until(monitor.running_or_finished)
elif context.options.wait_until == 'FINISHED':
monitor.wait_until(monitor.terminal)
+ return EXIT_OK
class KillJobCommand(Verb):
@@ -79,6 +108,13 @@ class KillJobCommand(Verb):
def name(self):
return 'kill'
+ @property
+ def help(self):
+ 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,
@@ -87,13 +123,262 @@ class KillJobCommand(Verb):
'all instances will be acted on.')
parser.add_argument('--config', type=str, default=None, dest='config',
help='Config file for the job, possibly containing hooks')
- self.add_option(parser, JOBSPEC_OPTION)
+ self.add_option(parser, JOBSPEC_ARGUMENT)
def execute(self, context):
+ # TODO: 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)
- context.check_and_log_response(resp)
- context.handle_open(api)
+ if resp.responseCode != ResponseCode.OK:
+ context.print_err('Job %s not found' % context.options.jobspec, file=sys.stderr)
+ return EXIT_INVALID_PARAMETER
+ 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
+
+ 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')
+ parser.add_argument('jobspec', type=arg_type_jobkey)
+
+ def render_tasks_json(self, jobkey, active_tasks, inactive_tasks):
+ """Render the tasks running for a job in machine-processable JSON format."""
+ def render_task_json(scheduled_task):
+ """Render a single task into json. This is baroque, but it uses thrift to
+ give us all of the job status data, while allowing us to compose it with
+ other stuff and pretty-print it.
+ """
+ return json.loads(serialize(scheduled_task,
+ protocol_factory=TJSONProtocol.TSimpleJSONProtocolFactory()))
+
+ return {'job': str(jobkey),
+ 'active': [render_task_json(task) for task in active_tasks],
+ 'inactive': [render_task_json(task) for task in inactive_tasks]}
+
+ def render_tasks_pretty(self, jobkey, active_tasks, inactive_tasks):
+ """Render the tasks for a job in human-friendly format"""
+ def render_task_pretty(scheduled_task):
+ assigned_task = scheduled_task.assignedTask
+ task_info = assigned_task.task
+ task_strings = []
+ if task_info:
+ task_strings.append('''\tcpus: %s, ram: %s MB, disk: %s MB''' % (
+ 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!
+ task_strings.append('failure count: %s (max %s)' % (scheduled_task.failureCount,
+ task_info.maxTaskFailures))
+ task_strings.append('events:')
+ for event in scheduled_task.taskEvents:
+ task_strings.append('\t %s %s: %s' % (datetime.fromtimestamp(event.timestamp / 1000),
+ ScheduleStatus._VALUES_TO_NAMES[event.status], event.message))
+ task_strings.append('packages:')
+ for pkg in assigned_task.task.packages:
+ task_strings.append('\trole: %s, package: %s, version: %s' %
+ (pkg.role, pkg.name, pkg.version))
+ return '\n\t'.join(task_strings)
+
+ result = ["Active tasks (%s):\n" % len(active_tasks)]
+ for t in active_tasks:
+ result.append(render_task_pretty(t))
+ result.append("Inactive tasks (%s):\n" % len(inactive_tasks))
+ for t in inactive_tasks:
+ result.append(render_task_pretty(t))
+ return ''.join(result)
+
+ def get_status_for_jobs(self, jobkeys, context):
+ """Retrieve and render the status information for a collection of jobs"""
+ def is_active(task):
+ return task.status in ACTIVE_STATES
+
+ result = []
+ for jk in jobkeys:
+ 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:
+ 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:
+ return json.dumps(result, indent=2, separators=[',', ': '], sort_keys=False)
+ else:
+ return ''.join(result)
+
+ def execute(self, context):
+ jobs = context.get_jobs_matching_key(context.options.jobspec)
+ result = self.get_status_for_jobs(jobs, context)
+ 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.
+ """
+
+ @property
+ def name(self):
+ return 'diff'
+
+ def setup_options_parser(self, parser):
+ 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, 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):
+ 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))
+ 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_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:
+ 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()
class Job(Noun):
@@ -113,3 +398,6 @@ class Job(Noun):
super(Job, self).__init__()
self.register_verb(CreateJobCommand())
self.register_verb(KillJobCommand())
+ self.register_verb(StatusCommand())
+ self.register_verb(DiffCommand())
+ self.register_verb(InspectCommand())
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/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 80fabb9..0aaf880 100644
--- a/src/main/python/apache/aurora/client/cli/options.py
+++ b/src/main/python/apache/aurora/client/cli/options.py
@@ -13,11 +13,11 @@ BROWSER_OPTION = CommandOption('--open-browser', default=False, dest='open_brows
help='open browser to view job page after job is created')
-CONFIG_OPTION = CommandOption('config_file', type='str', dest='config_file',
+CONFIG_ARGUMENT = CommandOption('config_file', type=str,
help='pathname of the aurora configuration file contain the job specification')
-JOBSPEC_OPTION = CommandOption('jobspec', type=AuroraJobKey.from_path,
+JOBSPEC_ARGUMENT = CommandOption('jobspec', type=AuroraJobKey.from_path,
help='Fully specified job key, in CLUSTER/ROLE/ENV/NAME format')
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/src/main/python/apache/aurora/client/options.py
----------------------------------------------------------------------
diff --git a/src/main/python/apache/aurora/client/options.py b/src/main/python/apache/aurora/client/options.py
index af56351..f398748 100644
--- a/src/main/python/apache/aurora/client/options.py
+++ b/src/main/python/apache/aurora/client/options.py
@@ -82,6 +82,8 @@ def make_env_option(explanation):
default=None,
help=explanation)
+# Note: in these predefined options, "OPTION" is used in names of optional arguments,
+# and "PARAMETER" is used in names of required ones.
OPEN_BROWSER_OPTION = optparse.Option(
'-o',
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/src/main/python/twitter/aurora/client/cli/BUILD
----------------------------------------------------------------------
diff --git a/src/main/python/twitter/aurora/client/cli/BUILD b/src/main/python/twitter/aurora/client/cli/BUILD
new file mode 100644
index 0000000..9ef5c84
--- /dev/null
+++ b/src/main/python/twitter/aurora/client/cli/BUILD
@@ -0,0 +1,26 @@
+
+python_binary(
+ name='client',
+ entry_point = 'twitter.aurora.client.cli:main',
+ dependencies = [ pants(':cli') ],
+ )
+
+python_library(
+ name='cli',
+ sources = [ '__init__.py', 'context.py', 'jobs.py' ],
+ dependencies = [
+ pants('aurora/twitterdeps/src/python/twitter/common/python'),
+ pants('src/main/python/twitter/aurora/client/api:command_runner'),
+ pants('src/main/python/twitter/aurora/client/api:disambiguator'),
+ pants('src/main/python/twitter/aurora/client/api:job_monitor'),
+ pants('src/main/python/twitter/aurora/client/api:updater'),
+ pants('src/main/python/twitter/aurora/client/hooks'),
+ pants('src/main/python/twitter/aurora/client:base'),
+ pants('src/main/python/twitter/aurora/client:config'),
+ pants('src/main/python/twitter/aurora/client:factory'),
+ pants('src/main/python/twitter/aurora/client:options'),
+ pants('src/main/python/twitter/aurora/common'),
+ pants('src/main/thrift/com/twitter/aurora/gen:py-thrift'),
+ pants('src/main/python/twitter/aurora:argparse')
+ ]
+ )
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/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 9528eed..bf21590 100644
--- a/src/test/python/apache/aurora/client/cli/BUILD
+++ b/src/test/python/apache/aurora/client/cli/BUILD
@@ -14,7 +14,7 @@ python_library(
python_tests(
name = 'job',
- sources = [ 'test_create.py', 'test_kill.py' ],
+ sources = [ 'test_create.py', 'test_kill.py', 'test_status.py' ],
dependencies = [
pants(':util'),
pants('src/main/python/apache/aurora/BUILD.thirdparty:mock'),
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/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 df2386d..2b0c504 100644
--- a/src/test/python/apache/aurora/client/cli/test_create.py
+++ b/src/test/python/apache/aurora/client/cli/test_create.py
@@ -1,13 +1,5 @@
import contextlib
-from apache.aurora.client.cli import (
- AuroraCommandLine,
- EXIT_INVALID_CONFIGURATION,
- EXIT_NETWORK_ERROR
-)
-from apache.aurora.client.cli.util import AuroraClientCommandTest, FakeAuroraCommandContext
-from apache.aurora.client.hooks.hooked_api import HookedAuroraClientAPI
-from apache.aurora.config import AuroraConfig
from twitter.common.contextutil import temporary_file
from gen.apache.aurora.ttypes import (
@@ -20,6 +12,14 @@ from gen.apache.aurora.ttypes import (
TaskQuery,
)
+from apache.aurora.client.cli import (
+ AuroraCommandLine,
+ EXIT_COMMAND_FAILURE,
+ EXIT_INVALID_CONFIGURATION
+)
+from apache.aurora.client.cli.util import AuroraClientCommandTest, FakeAuroraCommandContext
+from apache.aurora.client.hooks.hooked_api import HookedAuroraClientAPI
+from apache.aurora.config import AuroraConfig
from mock import Mock, patch
@@ -121,7 +121,7 @@ class TestClientCreateCommand(AuroraClientCommandTest):
fp.write(self.get_valid_config())
fp.flush()
cmd = AuroraCommandLine()
- cmd.execute(['job', 'create', '--wait_until=RUNNING', 'west/mchucarroll/test/hello',
+ cmd.execute(['job', 'create', '--wait_until=RUNNING', 'west/bozo/test/hello',
fp.name])
# Now check that the right API calls got made.
@@ -147,7 +147,7 @@ class TestClientCreateCommand(AuroraClientCommandTest):
fp.write(self.get_valid_config())
fp.flush()
cmd = AuroraCommandLine()
- cmd.execute(['job', 'create', '--wait_until=RUNNING', 'west/mchucarroll/test/hello',
+ cmd.execute(['job', 'create', '--wait_until=RUNNING', 'west/bozo/test/hello',
fp.name])
# Now check that the right API calls got made.
# Check that create_job was called exactly once, with an AuroraConfig parameter.
@@ -170,8 +170,8 @@ class TestClientCreateCommand(AuroraClientCommandTest):
fp.flush()
cmd = AuroraCommandLine()
result = cmd.execute(['job', 'create', '--wait_until=RUNNING',
- 'west/mchucarroll/test/hello', fp.name])
- assert result == EXIT_NETWORK_ERROR
+ 'west/bozo/test/hello', fp.name])
+ assert result == EXIT_COMMAND_FAILURE
# Now check that the right API calls got made.
# Check that create_job was called exactly once, with an AuroraConfig parameter.
@@ -190,7 +190,7 @@ class TestClientCreateCommand(AuroraClientCommandTest):
fp.flush()
cmd = AuroraCommandLine()
result = cmd.execute(['job', 'create', '--wait_until=RUNNING',
- 'west/mchucarroll/test/hello', fp.name])
+ 'west/bozo/test/hello', fp.name])
assert result == EXIT_INVALID_CONFIGURATION
# Now check that the right API calls got made.
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/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 75fb6b6..66d729d 100644
--- a/src/test/python/apache/aurora/client/cli/test_kill.py
+++ b/src/test/python/apache/aurora/client/cli/test_kill.py
@@ -29,20 +29,6 @@ class TestInstancesParser(unittest.TestCase):
class TestClientKillCommand(AuroraClientCommandTest):
@classmethod
- def setup_mock_api(cls):
- """Builds up a mock API object, with a mock SchedulerProxy.
- Returns the API and the proxy"""
-
- mock_scheduler = Mock()
- mock_scheduler.url = "http://something_or_other"
- mock_scheduler_client = Mock()
- 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
- return (mock_api, mock_scheduler_client)
-
- @classmethod
def get_kill_job_response(cls):
return cls.create_simple_success_response()
@@ -65,11 +51,11 @@ class TestClientKillCommand(AuroraClientCommandTest):
fp.write(self.get_valid_config())
fp.flush()
cmd = AuroraCommandLine()
- cmd.execute(['job', 'kill', '--config=%s' % fp.name, 'west/mchucarroll/test/hello'])
+ cmd.execute(['job', 'kill', '--config=%s' % fp.name, 'west/bozo/test/hello'])
# Now check that the right API calls got made.
assert api.kill_job.call_count == 1
- api.kill_job.assert_called_with(AuroraJobKey.from_path('west/mchucarroll/test/hello'), None)
+ api.kill_job.assert_called_with(AuroraJobKey.from_path('west/bozo/test/hello'), None)
def test_kill_job_with_instances(self):
"""Test kill client-side API logic."""
@@ -84,16 +70,15 @@ class TestClientKillCommand(AuroraClientCommandTest):
fp.flush()
cmd = AuroraCommandLine()
cmd.execute(['job', 'kill', '--config=%s' % fp.name, '--instances=0,2,4-6',
- 'west/mchucarroll/test/hello'])
+ 'west/bozo/test/hello'])
# Now check that the right API calls got made.
assert api.kill_job.call_count == 1
- api.kill_job.assert_called_with(AuroraJobKey.from_path('west/mchucarroll/test/hello'),
+ api.kill_job.assert_called_with(AuroraJobKey.from_path('west/bozo/test/hello'),
[0, 2, 4, 5, 6])
def test_kill_job_with_instances_deep_api(self):
"""Test kill client-side API logic."""
- mock_context = FakeAuroraCommandContext()
(mock_api, mock_scheduler) = self.setup_mock_api()
with contextlib.nested(
patch('apache.aurora.client.api.SchedulerProxy', return_value=mock_scheduler),
@@ -104,11 +89,9 @@ class TestClientKillCommand(AuroraClientCommandTest):
fp.flush()
cmd = AuroraCommandLine()
cmd.execute(['job', 'kill', '--config=%s' % fp.name, '--instances=0,2,4-6',
- 'west/mchucarroll/test/hello'])
+ 'west/bozo/test/hello'])
# Now check that the right API calls got made.
assert mock_scheduler.killTasks.call_count == 1
mock_scheduler.killTasks.assert_called_with(
TaskQuery(jobName='hello', environment='test', instanceIds=frozenset([0, 2, 4, 5, 6]),
- owner=Identity(role='mchucarroll')), None)
-
-
+ owner=Identity(role='bozo')), None)
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/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
new file mode 100644
index 0000000..38f3018
--- /dev/null
+++ b/src/test/python/apache/aurora/client/cli/test_status.py
@@ -0,0 +1,161 @@
+import contextlib
+
+from gen.apache.aurora.ttypes import (
+ AssignedTask,
+ Identity,
+ JobKey,
+ ResponseCode,
+ ScheduleStatus,
+ ScheduleStatusResult,
+ TaskConfig,
+ TaskEvent,
+ TaskQuery,
+)
+
+from apache.aurora.client.cli import (
+ AuroraCommandLine,
+ EXIT_INVALID_PARAMETER
+)
+from apache.aurora.common.aurora_job_key import AuroraJobKey
+from apache.aurora.client.cli.util import AuroraClientCommandTest, FakeAuroraCommandContext
+
+from mock import call, Mock, patch
+
+
+class TestJobStatus(AuroraClientCommandTest):
+
+ @classmethod
+ def create_mock_scheduled_tasks(cls):
+ jobs = []
+ for name in ['foo', 'bar', 'baz']:
+ job = Mock()
+ job.key = JobKey(role=cls.TEST_ROLE, environment=cls.TEST_ENV, name=name)
+ job.failure_count = 0
+ job.assignedTask = Mock(spec=AssignedTask)
+ job.assignedTask.slaveHost = 'slavehost'
+ job.assignedTask.task = Mock(spec=TaskConfig)
+ job.assignedTask.task.maxTaskFailures = 1
+ job.assignedTask.task.packages = []
+ job.assignedTask.task.owner = Identity(role='bozo')
+ job.assignedTask.task.environment = 'test'
+ job.assignedTask.task.jobName = 'woops'
+ job.assignedTask.task.numCpus = 2
+ job.assignedTask.task.ramMb = 2
+ job.assignedTask.task.diskMb = 2
+ job.assignedTask.instanceId = 4237894
+ job.assignedTask.assignedPorts = None
+ job.status = ScheduleStatus.RUNNING
+ mockEvent = Mock(spec=TaskEvent)
+ mockEvent.timestamp = 28234726395
+ mockEvent.status = ScheduleStatus.RUNNING
+ mockEvent.message = "Hi there"
+ job.taskEvents = [mockEvent]
+ jobs.append(job)
+ return jobs
+
+ @classmethod
+ def create_getjobs_response(cls):
+ result = Mock()
+ result.responseCode = ResponseCode.OK
+ result.result = Mock()
+ result.result.getJobsResult = Mock()
+ mock_job_one = Mock()
+ mock_job_one.key = Mock()
+ mock_job_one.key.role = 'RoleA'
+ mock_job_one.key.environment = 'test'
+ mock_job_one.key.name = 'hithere'
+ mock_job_two = Mock()
+ mock_job_two.key = Mock()
+ mock_job_two.key.role = 'bozo'
+ mock_job_two.key.environment = 'test'
+ mock_job_two.key.name = 'hello'
+ result.result.getJobsResult.configs = [mock_job_one, mock_job_two]
+ return result
+
+ @classmethod
+ def create_status_response(cls):
+ resp = cls.create_simple_success_response()
+ resp.result.scheduleStatusResult = Mock(spec=ScheduleStatusResult)
+ resp.result.scheduleStatusResult.tasks = set(cls.create_mock_scheduled_tasks())
+ return resp
+
+ @classmethod
+ def create_failed_status_response(cls):
+ return cls.create_blank_response(ResponseCode.INVALID_REQUEST, 'No tasks found for query')
+
+ def test_successful_status_shallow(self):
+ """Test the status command at the shallowest level: calling status should end up invoking
+ the local APIs get_status method."""
+ mock_context = FakeAuroraCommandContext()
+ mock_api = mock_context.get_api('west')
+ mock_api.check_status.return_value = self.create_status_response()
+ with contextlib.nested(
+ patch('apache.aurora.client.cli.jobs.Job.create_context', return_value=mock_context)):
+ cmd = AuroraCommandLine()
+ cmd.execute(['job', 'status', 'west/bozo/test/hello'])
+ mock_api.check_status.assert_called_with(AuroraJobKey('west', 'bozo', 'test', 'hello'))
+
+ def test_successful_status_deep(self):
+ """Test the status command more deeply: in a request with a fully specified
+ job, it should end up doing a query using getTasksStatus."""
+ (mock_api, mock_scheduler) = self.setup_mock_api()
+ mock_scheduler.query.return_value = self.create_status_response()
+ with contextlib.nested(
+ patch('apache.aurora.client.api.SchedulerProxy', return_value=mock_scheduler),
+ patch('apache.aurora.client.factory.CLUSTERS', new=self.TEST_CLUSTERS)):
+ cmd = AuroraCommandLine()
+ cmd.execute(['job', 'status', 'west/bozo/test/hello'])
+ mock_scheduler.getTasksStatus.assert_called_with(TaskQuery(jobName='hello',
+ environment='test', owner=Identity(role='bozo')))
+
+ def test_status_wildcard(self):
+ """Test status using a wildcard. It should first call api.get_jobs, and then do a
+ getTasksStatus on each job."""
+ mock_context = FakeAuroraCommandContext()
+ mock_api = mock_context.get_api('west')
+ mock_api.check_status.return_value = self.create_status_response()
+ mock_api.get_jobs.return_value = self.create_getjobs_response()
+ with contextlib.nested(
+ patch('apache.aurora.client.cli.jobs.Job.create_context', return_value=mock_context)):
+ cmd = AuroraCommandLine()
+ cmd.execute(['job', 'status', '*'])
+
+ # Wildcard should have expanded to two jobs, so there should be two calls
+ # to check_status.
+ assert mock_api.check_status.call_count == 2
+ assert (call(AuroraJobKey('example', 'RoleA', 'test', 'hithere')) in
+ mock_api.check_status.call_args_list)
+ assert (call(AuroraJobKey('example', 'bozo', 'test', 'hello')) in
+ mock_api.check_status.call_args_list)
+
+ 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."""
+ mock_context = FakeAuroraCommandContext()
+ mock_api = mock_context.get_api('west')
+ mock_api.check_status.return_value = self.create_status_response()
+ mock_api.get_jobs.return_value = self.create_getjobs_response()
+ with contextlib.nested(
+ patch('apache.aurora.client.cli.jobs.Job.create_context', return_value=mock_context)):
+ cmd = AuroraCommandLine()
+ cmd.execute(['job', 'status', 'example/*/*/hello'])
+
+ # Wildcard should have expanded to two jobs, but only matched one,
+ # so there should be one call to check_status.
+ assert mock_api.check_status.call_count == 1
+ mock_api.check_status.assert_called_with(
+ AuroraJobKey('example', 'bozo', 'test', 'hello'))
+
+ def test_unsuccessful_status_shallow(self):
+ """Test the status command at the shallowest level: calling status should end up invoking
+ the local APIs get_status method."""
+ # Calls api.check_status, which calls scheduler.getJobs
+ mock_context = FakeAuroraCommandContext()
+ mock_api = mock_context.get_api('west')
+ mock_api.check_status.return_value = self.create_failed_status_response()
+ # mock_api.scheduler.getTasksStatus.return_value =
+ with contextlib.nested(
+ patch('apache.aurora.client.cli.jobs.Job.create_context', return_value=mock_context)):
+ cmd = AuroraCommandLine()
+ result = cmd.execute(['job', 'status', 'west/bozo/test/hello'])
+ assert result == EXIT_INVALID_PARAMETER
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/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 c6d3830..2985865 100644
--- a/src/test/python/apache/aurora/client/cli/util.py
+++ b/src/test/python/apache/aurora/client/cli/util.py
@@ -1,16 +1,16 @@
import unittest
-from apache.aurora.client.cli.context import AuroraCommandContext
-from apache.aurora.client.hooks.hooked_api import HookedAuroraClientAPI
-from apache.aurora.common.cluster import Cluster
-from apache.aurora.common.clusters import Clusters
-
from gen.apache.aurora.ttypes import (
Response,
ResponseCode,
Result,
)
+from apache.aurora.client.cli.context import AuroraCommandContext
+from apache.aurora.client.hooks.hooked_api import HookedAuroraClientAPI
+from apache.aurora.common.cluster import Cluster
+from apache.aurora.common.clusters import Clusters
+
from mock import Mock
@@ -26,6 +26,20 @@ class FakeAuroraCommandContext(AuroraCommandContext):
def get_api(self, cluster):
return self.fake_api
+ @classmethod
+ def setup_mock_api(cls):
+ """Builds up a mock API object, with a mock SchedulerProxy.
+ Returns the API and the proxy"""
+
+ mock_scheduler = Mock()
+ mock_scheduler.url = "http://something_or_other"
+ mock_scheduler_client = Mock()
+ 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
+ return (mock_api, mock_scheduler_client)
+
def setup_fake_api(self):
# In here, we'd like to get it mocked so that the HookedAuroraClientAPI
# object, and its underlying AuroraClientAPI objects are not
@@ -33,7 +47,7 @@ class FakeAuroraCommandContext(AuroraCommandContext):
new_fake = Mock(spec=HookedAuroraClientAPI)
new_fake.scheduler = Mock()
new_fake.scheduler.url = 'http://something_or_other'
- new_fake.scheduler.getTasksStatus.side_effect = []
+# new_fake.scheduler.getTasksStatus.side_effect = []
self.fake_api = new_fake
return self.fake_api
@@ -59,6 +73,21 @@ class AuroraClientCommandTest(unittest.TestCase):
return response
@classmethod
+ def setup_mock_api(cls):
+ """Builds up a mock API object, with a mock SchedulerProxy.
+ Returns the API and the proxy"""
+ # TODO: merge this with setup_fake_api (MESOS-4861)
+ mock_scheduler = Mock()
+ mock_scheduler.url = "http://something_or_other"
+ mock_scheduler_client = Mock()
+ 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
+ return (mock_api, mock_scheduler_client)
+
+
+ @classmethod
def create_simple_success_response(cls):
return cls.create_blank_response(ResponseCode.OK, 'OK')
@@ -119,7 +148,7 @@ HELLO_WORLD = Job(
jobs = [HELLO_WORLD]
"""
- TEST_ROLE = 'mchucarroll'
+ TEST_ROLE = 'bozo'
TEST_ENV = 'test'
@@ -127,7 +156,7 @@ jobs = [HELLO_WORLD]
TEST_CLUSTER = 'west'
- TEST_JOBSPEC = 'west/mchucarroll/test/hello'
+ TEST_JOBSPEC = 'west/bozo/test/hello'
TEST_CLUSTERS = Clusters([Cluster(
name='west',
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/src/test/python/twitter/aurora/client/cli/BUILD
----------------------------------------------------------------------
diff --git a/src/test/python/twitter/aurora/client/cli/BUILD b/src/test/python/twitter/aurora/client/cli/BUILD
new file mode 100644
index 0000000..e4ea5da
--- /dev/null
+++ b/src/test/python/twitter/aurora/client/cli/BUILD
@@ -0,0 +1,30 @@
+python_test_suite(
+ name = 'all',
+ dependencies = [ pants(':job') ]
+)
+
+python_library(
+ name = 'util',
+ sources = [ 'util.py' ],
+ dependencies = [
+ pants('src/main/python/apache/aurora/BUILD.thirdparty:mock'),
+ pants('src/main/python/apache/aurora/client/cli'),
+ ]
+)
+
+python_tests(
+ name = 'job',
+<<<<<<< HEAD
+ sources = [ 'test_create.py', 'test_kill.py', 'test_status.py', 'test_diff.py' ],
+=======
+ sources = [ 'test_create.py', 'test_kill.py', 'test_status.py' ],
+>>>>>>> v2.2
+ dependencies = [
+ pants(':util'),
+ pants('src/main/python/apache/aurora/BUILD.thirdparty:mock'),
+ pants('aurora/twitterdeps/src/python/twitter/common/contextutil'),
+ pants('src/main/python/apache/aurora/client/cli'),
+ pants('src/main/python/apache/aurora/client/cli'),
+ pants('src/test/python/apache/aurora/client/commands:util')
+ ]
+)
http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/e1aee67b/src/test/python/twitter/aurora/client/cli/test_diff.py
----------------------------------------------------------------------
diff --git a/src/test/python/twitter/aurora/client/cli/test_diff.py b/src/test/python/twitter/aurora/client/cli/test_diff.py
new file mode 100644
index 0000000..363352f
--- /dev/null
+++ b/src/test/python/twitter/aurora/client/cli/test_diff.py
@@ -0,0 +1,182 @@
+import contextlib
+
+from twitter.aurora.client.cli import (
+ AuroraCommandLine,
+ EXIT_INVALID_CONFIGURATION,
+ EXIT_INVALID_PARAMETER
+)
+from twitter.aurora.client.cli.util import AuroraClientCommandTest
+from twitter.common.contextutil import temporary_file
+
+from gen.twitter.aurora.ttypes import (
+ AssignedTask,
+ ExecutorConfig,
+ Identity,
+ JobConfiguration,
+ JobKey,
+ PopulateJobResult,
+ ResponseCode,
+ ScheduleStatus,
+ ScheduleStatusResult,
+ TaskConfig,
+ TaskEvent,
+ TaskQuery,
+)
+
+from mock import Mock, patch
+
+
+class TestDiffCommand(AuroraClientCommandTest):
+ @classmethod
+ def setup_mock_options(cls):
+ """set up to get a mock options object."""
+ mock_options = Mock()
+ mock_options = Mock()
+ mock_options.env = None
+ mock_options.json = False
+ mock_options.bindings = {}
+ mock_options.open_browser = False
+ mock_options.rename_from = None
+ mock_options.cluster = None
+ return mock_options
+
+ @classmethod
+ def create_mock_scheduled_tasks(cls):
+ jobs = []
+ for name in ['foo', 'bar', 'baz']:
+ job = Mock()
+ job.key = JobKey(role=cls.TEST_ROLE, environment=cls.TEST_ENV, name=name)
+ job.failure_count = 0
+ job.assignedTask = Mock(spec=AssignedTask)
+ job.assignedTask.slaveHost = 'slavehost'
+ job.assignedTask.task = Mock(spec=TaskConfig)
+ job.assignedTask.task.maxTaskFailures = 1
+ job.assignedTask.task.executorConfig = Mock(spec=ExecutorConfig)
+ job.assignedTask.task.executorConfig.data = Mock()
+ job.assignedTask.task.packages = []
+ job.assignedTask.task.owner = Identity(role='mchucarroll')
+ job.assignedTask.task.environment = 'test'
+ job.assignedTask.task.jobName = 'woops'
+ job.assignedTask.task.numCpus = 2
+ job.assignedTask.task.ramMb = 2
+ job.assignedTask.task.diskMb = 2
+ job.assignedTask.instanceId = 4237894
+ job.assignedTask.assignedPorts = None
+ job.status = ScheduleStatus.RUNNING
+ mockEvent = Mock(spec=TaskEvent)
+ mockEvent.timestamp = 28234726395
+ mockEvent.status = ScheduleStatus.RUNNING
+ mockEvent.message = "Hi there"
+ job.taskEvents = [mockEvent]
+ jobs.append(job)
+ return jobs
+
+ @classmethod
+ def create_status_response(cls):
+ resp = cls.create_simple_success_response()
+ resp.result.scheduleStatusResult = Mock(spec=ScheduleStatusResult)
+ resp.result.scheduleStatusResult.tasks = set(cls.create_mock_scheduled_tasks())
+ return resp
+
+ @classmethod
+ def create_failed_status_response(cls):
+ return cls.create_blank_response(ResponseCode.INVALID_REQUEST, 'No tasks found for query')
+
+ @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
+ populate.result.populateJobResult.populated = cls.create_mock_scheduled_tasks()
+ return populate
+
+ def test_successful_diff(self):
+ """Test the diff command."""
+ (mock_api, mock_scheduler) = self.setup_mock_api()
+ with contextlib.nested(
+ patch('twitter.aurora.client.api.SchedulerProxy', return_value=mock_scheduler),
+ patch('twitter.aurora.client.factory.CLUSTERS', new=self.TEST_CLUSTERS),
+ patch('subprocess.call', return_value=0),
+ patch('json.loads', return_value=Mock())) as (_, _, subprocess_patch, _):
+ mock_scheduler.getTasksStatus.return_value = self.create_status_response()
+ self.setup_populate_job_config(mock_scheduler)
+ with temporary_file() as fp:
+ fp.write(self.get_valid_config())
+ fp.flush()
+ cmd = AuroraCommandLine()
+ cmd.execute(['job', 'diff', 'west/mchucarroll/test/hello', fp.name])
+
+ # Diff should get the task status, populate a config, and run diff.
+ mock_scheduler.getTasksStatus.assert_called_with(
+ TaskQuery(jobName='hello', environment='test', owner=Identity(role='mchucarroll'),
+ statuses=set([ScheduleStatus.PENDING, ScheduleStatus.STARTING,
+ ScheduleStatus.RUNNING, ScheduleStatus.KILLING, ScheduleStatus.ASSIGNED,
+ ScheduleStatus.RESTARTING, ScheduleStatus.PREEMPTING])))
+ assert mock_scheduler.populateJobConfig.call_count == 1
+ assert isinstance(mock_scheduler.populateJobConfig.call_args[0][0], JobConfiguration)
+ assert (mock_scheduler.populateJobConfig.call_args[0][0].key ==
+ JobKey(environment=u'test', role=u'mchucarroll', name=u'hello'))
+ # Subprocess should have been used to invoke diff with two parameters.
+ assert subprocess_patch.call_count == 1
+ assert len(subprocess_patch.call_args[0][0]) == 3
+ assert subprocess_patch.call_args[0][0][0] == 'diff'
+
+ def test_diff_invalid_config(self):
+ """Test the diff command if the user passes a config with an error in it."""
+ mock_options = self.setup_mock_options()
+ (mock_api, mock_scheduler) = self.create_mock_api()
+ mock_scheduler.getTasksStatus.return_value = self.create_status_response()
+ self.setup_populate_job_config(mock_scheduler)
+ with contextlib.nested(
+ patch('twitter.aurora.client.api.SchedulerProxy', return_value=mock_scheduler),
+ patch('twitter.aurora.client.factory.CLUSTERS', new=self.TEST_CLUSTERS),
+ patch('twitter.common.app.get_options', return_value=mock_options),
+ patch('subprocess.call', return_value=0),
+ patch('json.loads', return_value=Mock())) as (
+ mock_scheduler_proxy_class,
+ mock_clusters,
+ options,
+ subprocess_patch,
+ json_patch):
+ with temporary_file() as fp:
+ fp.write(self.get_invalid_config('stupid="me"',))
+ fp.flush()
+ cmd = AuroraCommandLine()
+ result = cmd.execute(['job', 'diff', 'west/mchucarroll/test/hello', fp.name])
+ assert result == EXIT_INVALID_CONFIGURATION
+ assert mock_scheduler.getTasksStatus.call_count == 0
+ assert mock_scheduler.populateJobConfig.call_count == 0
+ assert subprocess_patch.call_count == 0
+
+ def test_diff_server_error(self):
+ """Test the diff command if the user passes a config with an error in it."""
+ mock_options = self.setup_mock_options()
+ (mock_api, mock_scheduler) = self.create_mock_api()
+ mock_scheduler.getTasksStatus.return_value = self.create_failed_status_response()
+ self.setup_populate_job_config(mock_scheduler)
+ with contextlib.nested(
+ patch('twitter.aurora.client.api.SchedulerProxy', return_value=mock_scheduler),
+ patch('twitter.aurora.client.factory.CLUSTERS', new=self.TEST_CLUSTERS),
+ patch('twitter.common.app.get_options', return_value=mock_options),
+ patch('subprocess.call', return_value=0),
+ patch('json.loads', return_value=Mock())) as (
+ mock_scheduler_proxy_class,
+ mock_clusters,
+ options,
+ subprocess_patch,
+ json_patch):
+ with temporary_file() as fp:
+ fp.write(self.get_valid_config())
+ fp.flush()
+ cmd = AuroraCommandLine()
+ result = cmd.execute(['job', 'diff', 'west/mchucarroll/test/hello', fp.name])
+ assert result == EXIT_INVALID_PARAMETER
+ # In this error case, we should have called the server getTasksStatus;
+ # but since it fails, we shouldn't call populateJobConfig or subprocess.
+ mock_scheduler.getTasksStatus.assert_called_with(
+ TaskQuery(jobName='hello', environment='test', owner=Identity(role='mchucarroll'),
+ statuses=set([ScheduleStatus.PENDING, ScheduleStatus.STARTING,
+ ScheduleStatus.RUNNING, ScheduleStatus.KILLING, ScheduleStatus.ASSIGNED,
+ ScheduleStatus.RESTARTING, ScheduleStatus.PREEMPTING])))
+ assert mock_scheduler.populateJobConfig.call_count == 0
+ assert subprocess_patch.call_count == 0