aurora-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From mchucarr...@apache.org
Subject git commit: Command hooks: stage 2.
Date Tue, 06 May 2014 19:19:23 GMT
Repository: incubator-aurora
Updated Branches:
  refs/heads/master 239747ec0 -> fb5027b55


Command hooks: stage 2.

The second half of command hooks:
- Dynamically registered hook exceptions are provided, by fetching a hooks skip rules file
  from a URL.
- Hooks are loaded and activated by the noun/verb framework.
- Hook selection and dispatch has been substantially updated.

Also did some long overdue cleanup of string quoting consistency.

Bugs closed: aurora-270

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


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

Branch: refs/heads/master
Commit: fb5027b55db12aa5e6a64036fca0b3fa0ecc0488
Parents: 239747e
Author: Mark Chu-Carroll <mchucarroll@twopensource.com>
Authored: Tue May 6 15:15:24 2014 -0400
Committer: Mark Chu-Carroll <mchucarroll@twitter.com>
Committed: Tue May 6 15:15:24 2014 -0400

----------------------------------------------------------------------
 3rdparty/python/BUILD                           |   1 +
 docs/design/command-hooks.md                    | 116 ++++++---
 src/main/python/apache/aurora/client/cli/BUILD  |   1 +
 .../python/apache/aurora/client/cli/__init__.py | 175 +++++++-------
 .../apache/aurora/client/cli/command_hooks.py   | 194 ++++++++++++++-
 .../aurora/client/cli/test_command_hooks.py     | 238 +++++++++++++++++--
 6 files changed, 577 insertions(+), 148 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/fb5027b5/3rdparty/python/BUILD
----------------------------------------------------------------------
diff --git a/3rdparty/python/BUILD b/3rdparty/python/BUILD
index 122f71d..fb154ca 100644
--- a/3rdparty/python/BUILD
+++ b/3rdparty/python/BUILD
@@ -33,6 +33,7 @@ python_requirement('mox==0.5.3')
 python_requirement('psutil==1.1.2')
 python_requirement('pystachio==0.7.2')
 python_requirement('pyyaml==3.10')
+python_requirement('requests==2.0.0')
 python_requirement('thrift==0.9.1')
 python_requirement('twitter.common.app==%s' % COMMONS_VERSION)
 python_requirement('twitter.common.collections==%s' % COMMONS_VERSION)

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/fb5027b5/docs/design/command-hooks.md
----------------------------------------------------------------------
diff --git a/docs/design/command-hooks.md b/docs/design/command-hooks.md
index ee320ed..cc56218 100644
--- a/docs/design/command-hooks.md
+++ b/docs/design/command-hooks.md
@@ -137,50 +137,98 @@ command(s) they're allowed to run.  All of that is specified in a
 special system file located in `/etc/sudoers` on a typical unix
 machine.
 
+In a world of distributed systems, this approach has one grave
+weakness. An aurora client can be located on any machine that has
+network access to a Mesos/Aurora cluster. It can be run by a user in
+pretty much any way they want - from a machine they control, from a
+special chroot they created, etc. Relying an a file being in a special
+location on their machine isn't sufficient - it's too easy to
+maliciously or erroneously run a command in an environment with an
+invalid hooks exceptions file.
+
+Instead, we've got two basic choices: hook exception rules can be
+baked into the client executable, or they can be provided in a
+network location.
+
 ### Specifying when hooks can be skipped
 
-The sudoers file has a terrible syntax, so I'm not going to try to
-adopt it; instead, I'm going to stick with the Pystachio-based
-configuration syntax that we use in Aurora. A rule that permits a
-group of users to skip hooks is defined using a Pystachio struct:
+#### Hooks File
+
+The module `apache.aurora.client.cli` contains a variable named
+`GLOBAL_HOOK_SKIP_RULES_URL`. In the default distribution of Aurora, tihs variable contains
+`None`. Users can modify this value for their local environments, providing
+a site specific URL. If users attempt to bypass command hooks, and this
+URL is not `None`, then the client will fetch the contents of that URL, and
+attempt to interpret it as a hooks exception file.
+
+The hooks exception file is written in JSON, with the following structure:
 
-    class HookRule(Struct):
-      roles = List(String)
-      commands = Map(String, List(String))
-      arg_patterns = List(String)
-	  hooks = List(String)
+    { "rulename":
+      {
+      "hooks": [ "hook-name ", ... ],
+      "users": [ string, ...],
+      "commands": { "job": ["kill", "killall", ...], ... },
+      "arg-patterns": [ "regexp-str", ... ]
+      },
+	  ...
+    }
 
-* `roles` is a list of role names, or regular expressions that range over role
-  names. This rule gives permission to those users to skip hooks.
+* `hooks` is a list of hook identifiers which can be skipped by a user
+  that satisfies this rule. If omitted, then this rule applies to _all hooks_.
+  (Omitting the `hooks` field is equivalent to giving it the value `['*']`.)
+* `users` is a list of user names, or glob expressions that range over user
+  names. This rule gives permission to those users to skip hooks. If omitted,
+  then this rule allows _any user_ to skip hooks that satisfy the rest of this rule.
+  Note that this is _user_ names, not
+  _role_ names: the rules specify users that are allowed to skip commands.
+  Some users that are allowed to work with a role account may be allowed to
+  skip, while others cannot.
 * `commands` is a map from nouns to lists of verbs. If a command `aurora n v`
   is being executed, this rule allows the hooks to be skipped if
-  `v` is in `commands[n]`. If this is empty, then all commands can be skipped.
-* `arg_patterns` is a list of regular expressions ranging over parameters.
+  `v` is in `commands[n]`. If this is omitted, then it allows hooks to be skipped for all
+  commands that satisfy the rest of the rule.
+* `arg_patterns` is a list of glob patterns ranging over parameters.
   If any of the parameters of the command match the parameters in this list,
-  the hook can be skipped.
-* `hooks` is a list of hook identifiers which can be skipped by a user
-  that satisfies this rule.
-
-The hooks file defines a global variable `hook_rules`, which is a list of
-`HookRule` objects. If any of the hook rules matches, then the command
-can be run with hooks skipped.
+  the hook can be skipped. If ommitted, then this applies regardless of arguments.
 
 For example, the following is a hook rules file which allows:
-* The admin (role admin) to skip any hook.
+* The user "root" to skip any hook.
 * Any user to skip hooks for test jobs.
 * A specific group of users to skip hooks for jobs in cluster `east`
 * Another group of users to skip hooks for `job kill` in cluster `west`.
 
-    allow_admin = HookRule(roles=['admin'])
-    allow_test = HookRule(roles=['.*'],  arg_patterns=['.*/.*/test/.*'])
-    allow_east_users = HookRule(roles=['john', 'mary', 'mike', 'sue'],
-        arg_patterns=['east/.*/.*./*'])
-    allow_west_kills = HookRule(roles=['anne', 'bill', 'chris'],
-      commands = { 'job': ['kill']}, arg_patterns = ['west/.*/.*./*'])
-
-    hook_rules = [allow_admin, allow_test, allow_east_users, allow_west_kills]
-
-## Skipping Hooks
+    {
+      "allow-admin": { "users": ["root"] },
+	  "allow-test": { "users": ["\*"], "arg-patterns": ["\*/\*/test/\*"] },
+	  "allow-east-users": { "users"=['john', 'mary', 'mike', 'sue'],
+          "arg-patterns": ["east/\*/\*/\*"] },
+	  "allow-west-kills": { "users": ["anne", "bill", "chris"],
+          "commands": { "job": ["kill"]}, "arg-patterns" = ["west/\*/\*/\*"] }
+    }
+
+#### Programmatic Hooks Exceptions
+
+The `GlobalHooksRegistry` contains the method `add_hooks_exception`, which allows
+users to register local hooks exceptions using the `ConfigurationPlugin` mechanism.
+A hooks exception object implements the following interface:
+
+    class HooksException(object):
+	  def allow_exception(self, hooks, role, noun, verb, args):
+	    """Params:
+        - hooks: a list of hook-names that the user wants to skip. If this
+		  is ommitted, then this applies to all hooks.
+		- role: the role requesting that hooks be skipped.
+		- noun, verb: the noun and verb being executed.
+		- the other command-line arguments.
+		Returns: True if the user should be allowed to skip the requested hooks.
+		"""
+	    return False
+
+When a user supplies the `--skip-hooks` argument, `allow_exception` is invoked on
+each of the `HooksException` arguments. If _any_ of the hooks exception objects
+returns `True`, then the user will be permitted to skip the hooks.
+
+### Skipping Hooks
 
 To skip a hook, a user uses a command-line option, `--skip-hooks`. The option can either
 specify specific hooks to skip, or "all":
@@ -193,6 +241,12 @@ specify specific hooks to skip, or "all":
 
 ## Changes
 
+4/30:
+* Rule exceptions are defined in JSON, and they are specified to be loaded from
+  a URL, not from a local file.
+* Rule exceptions specify users, not roles.
+
+4/27:
 Major changes between this and the last version of this proposal.
 * Command hooks can't be declared in a configuration file. There's a simple
   reason why: hooks run before a command's implementation is invoked.

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/fb5027b5/src/main/python/apache/aurora/client/cli/BUILD
----------------------------------------------------------------------
diff --git a/src/main/python/apache/aurora/client/cli/BUILD b/src/main/python/apache/aurora/client/cli/BUILD
index 1bd565e..8dac98e 100644
--- a/src/main/python/apache/aurora/client/cli/BUILD
+++ b/src/main/python/apache/aurora/client/cli/BUILD
@@ -52,6 +52,7 @@ python_library(
   ],
   dependencies = [
     pants('3rdparty/python:argparse'),
+    pants('3rdparty/python:requests'),
     pants('3rdparty/python:twitter.common.python'),
     pants('3rdparty/python:twitter.common.quantity'),
     pants('src/main/python/apache/aurora/client/api:command_runner'),

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/fb5027b5/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 1e396b2..efdc545 100644
--- a/src/main/python/apache/aurora/client/cli/__init__.py
+++ b/src/main/python/apache/aurora/client/cli/__init__.py
@@ -61,23 +61,29 @@ EXIT_UNKNOWN_ERROR = 20
 # invocation, and "user", which contains the username of the user who invoked
 # the client.
 
-logger = logging.getLogger('aurora_client')
+logger = logging.getLogger("aurora_client")
 CLIENT_ID = uuid1()
 
 
+# A location where you can find a site-specific file containing
+# global hook skip rules. This can be something like a link into a file stored in a git
+# repos.
+GLOBAL_HOOK_SKIP_RULES_URL = None
+
+
 def print_aurora_log(sev, msg, *args, **kwargs):
-  extra = kwargs.get('extra', {})
-  extra['clientid'] = CLIENT_ID
-  extra['user'] = getpass.getuser()
-  kwargs['extra'] = extra
+  extra = kwargs.get("extra", {})
+  extra["clientid"] = CLIENT_ID
+  extra["user"] = getpass.getuser()
+  kwargs["extra"] = extra
   logger.log(sev, msg, *args, **kwargs)
 
 def get_client_version():
   try:
     pexpath = sys.argv[0]
     pex_info = PexInfo.from_pex(pexpath)
-    return ("%s@%s" % (pex_info.build_properties.get('sha', 'unknown'),
-        pex_info.build_properties.get('date', 'unknown')))
+    return ("%s@%s" % (pex_info.build_properties.get("sha", "unknown"),
+        pex_info.build_properties.get("date", "unknown")))
   except (IOError, OSError):
     return "VersionUnknown"
 
@@ -104,21 +110,25 @@ class Context(object):
     """
     self.options = options
 
+  def set_args(self, args):
+    """Add the raw argument list to a context."""
+    self.args = args
+
   def print_out(self, msg, indent=0):
     """Prints output to standard out with indent.
     For debugging purposes, it's nice to be able to patch this and capture output.
     """
-    indent_str = ' ' * indent
-    lines = msg.split('\n')
+    indent_str = " " * indent
+    lines = msg.split("\n")
     for line in lines:
-      print('%s%s' % (indent_str, line))
+      print("%s%s" % (indent_str, line))
 
   def print_err(self, msg, indent=0):
     """Prints output to standard error, with an indent."""
-    indent_str = ' ' * indent
-    lines = msg.split('\n')
+    indent_str = " " * indent
+    lines = msg.split("\n")
     for line in lines:
-      print('%s%s' % (indent_str, line), file=sys.stderr)
+      print("%s%s" % (indent_str, line), file=sys.stderr)
 
   def print_log(self, severity, msg, *args, **kwargs):
     """Print a message to a log.
@@ -153,15 +163,14 @@ class ConfigurationPlugin(object):
   @abstractmethod
   def before_dispatch(self, raw_args):
     """Run some code before dispatching to the client.
-    Returns a potentially modified version of the command line arguments.
-    If a ConfigurationPlugin.Error exception is thrown, it aborts command execution.
+    If a ConfigurationPlugin.Error exception is thrown, aborts the command execution.
     """
     return raw_args
 
   @abstractmethod
   def before_execution(self, context):
     """Run the context/command line initialization code for this plugin before
-    invoking the command verb.
+    invoking the verb.
     The before_execution method behaves as if it's part of the implementation of the
     verb being invoked. It has access to the same context that will be used by the command.
     Any errors that occur during the execution should be signalled using ConfigurationPlugin.Error.
@@ -226,7 +235,7 @@ class CommandLine(object):
     if self.nouns is None:
       self.nouns = {}
     if not isinstance(noun, Noun):
-      raise TypeError('register_noun requires a Noun argument')
+      raise TypeError("register_noun requires a Noun argument")
     self.nouns[noun.name] = noun
     noun.set_commandline(self)
 
@@ -236,7 +245,7 @@ class CommandLine(object):
   def setup_options_parser(self):
     """ Builds the options parsing for the application."""
     self.parser = argparse.ArgumentParser()
-    subparser = self.parser.add_subparsers(dest='noun')
+    subparser = self.parser.add_subparsers(dest="noun")
     for (name, noun) in self.nouns.items():
       noun_parser = subparser.add_parser(name, help=noun.help)
       noun.internal_setup_options_parser(noun_parser)
@@ -256,7 +265,7 @@ class CommandLine(object):
         return EXIT_OK
       else:
         self.print_err('Unknown noun "%s"' % args[0])
-        self.print_err('Valid nouns are: %s' % [k for k in self.nouns])
+        self.print_err("Valid nouns are: %s" % [k for k in self.nouns])
         return EXIT_INVALID_PARAMETER
     elif len(args) == 2:
       if args[0] in self.nouns:
@@ -269,10 +278,10 @@ class CommandLine(object):
           self.print_err('Valid verbs for "%s" are: %s' % (args[0], verbs))
           return EXIT_INVALID_PARAMETER
       else:
-        self.print_err('Unknown noun %s' % args[0])
+        self.print_err("Unknown noun %s" % args[0])
         return EXIT_INVALID_PARAMETER
     else:
-      self.print_err('Unknown help command: %s' % (' '.join(args)))
+      self.print_err("Unknown help command: %s" % (" ".join(args)))
       self.print_err(self.composed_help)
       return EXIT_INVALID_PARAMETER
 
@@ -309,81 +318,80 @@ class CommandLine(object):
       self.register_nouns()
     return self.nouns.keys()
 
-  def run_pre_hooks(self, context, noun, verb, args, command_hooks):
-    try:
-      for hook in command_hooks:
-        result = hook.pre_command(noun, verb, context, args)
-        if result != 0:
-          print_aurora_log(logging.INFO, 'Command hook %s aborted operation with error code
%s' %
-              (hook.name, result))
-          self.print_out('Command aborted by command hook %s' % hook.name)
-          return result
-      return EXIT_OK
-    except (Context.CommandError, ConfigurationPlugin.Error) as c:
-      print_aurora_log(logging.INFO, 'Error executing command hook %s: %s' % (hook.name,
c))
-      self.print_err('Error executing command hook %s: %s; aborting' % hook.name, c.msg)
-      return c.code
-
-  def run_post_hooks(self, context, noun, verb, args, result, command_hooks):
-    try:
-      for hook in command_hooks:
-        hook.post_command(noun, verb, context, args, result)
-    except (Context.CommandError, ConfigurationPlugin.Error) as c:
-      print_aurora_log(logging.INFO, 'Error executing post-command hook %s: %s' % (hook.name,
c))
-      self.print_err('Error executing command hook %s: %s; aborting' % hook.name, c.msg)
-      return c.code
-
-  def execute(self, args):
-    """Execute a command.
-    :param args: the command-line arguments for the command. This only includes arguments
-        that should be parsed by the application; it does not include sys.argv[0].
-    """
-    print_aurora_log(logging.INFO, 'Command=(%s)', args)
+  def _setup(self, args):
+    GlobalCommandHookRegistry.setup(GLOBAL_HOOK_SKIP_RULES_URL)
     nouns = self.registered_nouns
-    try:
-      for plugin in self.plugins:
-        args = plugin.before_dispatch(args)
-    except ConfigurationPlugin.Error as e:
-      print('Error in configuration plugin before dispatch: %s' % e.msg, file=sys.stderr)
-      return e.code
+    for plugin in self.plugins:
+      args = plugin.before_dispatch(args)
+    return args
 
-    if args[0] == 'help':
-      return self.help_cmd(args[1:])
+  def _parse_args(self, args):
     self.setup_options_parser()
     options = self.parser.parse_args(args)
-    if options.noun not in nouns:
-      raise ValueError('Unknown command: %s' % options.noun)
+    if options.noun not in self.nouns:
+      raise ValueError("Unknown command: %s" % options.noun)
     noun = self.nouns[options.noun]
     context = noun.create_context()
     context.set_options(options)
+    context.set_args(args)
+    return (noun, context)
+
+  def _run_pre_hooks_and_plugins(self, context, args):
+    try:
+      context.selected_hooks = GlobalCommandHookRegistry.get_required_hooks(context,
+          context.options.skip_hooks, context.options.noun, context.options.verb)
+    except Context.CommandError as c:
+      return c.code
     try:
       for plugin in self.plugins:
         plugin.before_execution(context)
     except ConfigurationPlugin.Error as e:
-      print('Error in configuration plugin before execution: %s' % c.msg, file=sys.stderr)
+      print("Error in configuration plugin before execution: %s" % c.msg, file=sys.stderr)
       return c.code
-    command_hooks = GlobalCommandHookRegistry.get_command_hooks_for(options.noun, options.verb)
-    plugin_result = self.run_pre_hooks(context, options.noun, options.verb, args, command_hooks)
-    if plugin_result != 0:
+    plugin_result = GlobalCommandHookRegistry.run_pre_hooks(context, context.options.noun,
+        context.options.verb)
+    if plugin_result != EXIT_OK:
       return plugin_result
+    else:
+      return EXIT_OK
 
+  def _run_post_plugins(self, context, result):
+    for plugin in self.plugins:
+      try:
+        plugin.after_execution(context, result)
+      except ConfigurationPlugin.Error as e:
+        print_aurora_log(logging.INFO, "Error executing post-execution plugin: %s", e.msg)
+
+  def execute(self, args):
+    """Execute a command.
+    :param args: the command-line arguments for the command. This only includes arguments
+        that should be parsed by the application; it does not include sys.argv[0].
+    """
+    try:
+      args = self._setup(args)
+    except ConfigurationPlugin.Error as e:
+      print("Error in configuration plugin before dispatch: %s" % e.msg, file=sys.stderr)
+      return e.code
+    if args[0] == "help":
+      return self.help_cmd(args[1:])
+    noun, context = self._parse_args(args)
+    print_aurora_log(logging.INFO, "Command=(%s)", args)
+    pre_result = self._run_pre_hooks_and_plugins(context, args)
+    if pre_result is not EXIT_OK:
+      return pre_result
     try:
       result = noun.execute(context)
       if result == EXIT_OK:
-        print_aurora_log(logging.INFO, 'Command terminated successfully')
-        self.run_post_hooks(options.noun, options.verb, context, args, result, command_hooks)
+        print_aurora_log(logging.INFO, "Command terminated successfully")
+        GlobalCommandHookRegistry.run_post_hooks(context, context.options.noun, context.options.verb,
+            result)
       else:
-        print_aurora_log(logging.INFO, 'Commmand terminated with error code %s', result)
-
-      for plugin in self.plugins:
-        try:
-          plugin.after_execution(context, result)
-        except ConfigurationPlugin.Error as e:
-          print_aurora_log(logging.INFO, 'Error executing post-execution plugin: %s', e.msg)
+        print_aurora_log(logging.INFO, "Commmand terminated with error code %s", result)
+      self._run_post_plugins(context, result)
       return result
     except Context.CommandError as c:
-      print_aurora_log(logging.INFO, 'Error executing command: %s', c.msg)
-      self.print_err('Error executing command: %s' % c.msg)
+      print_aurora_log(logging.INFO, "Error executing command: %s", c.msg)
+      self.print_err("Error executing command: %s" % c.msg)
       return c.code
 
 
@@ -402,14 +410,17 @@ class Noun(AuroraCommand):
   def register_verb(self, verb):
     """Add an operation supported for this noun."""
     if not isinstance(verb, Verb):
-      raise TypeError('register_verb requires a Verb argument')
+      raise TypeError("register_verb requires a Verb argument")
     self.verbs[verb.name] = verb
     verb._register(self)
 
   def internal_setup_options_parser(self, argparser):
-    """Internal driver for the options processing framework."""
+    """Internal driver for the options processing framework.
+    This gets the options from all of the verb for this noun, and assembles them
+    into a python argparse subparser for this noun.
+    """
     self.setup_options_parser(argparser)
-    subparser = argparser.add_subparsers(dest='verb')
+    subparser = argparser.add_subparsers(dest="verb")
     for (name, verb) in self.verbs.items():
       vparser = subparser.add_parser(name, help=verb.help)
       for opt in verb.get_options():
@@ -417,6 +428,8 @@ class Noun(AuroraCommand):
       for plugin in self.commandline.plugins:
         for opt in plugin.get_options():
           opt.add_to_parser(vparser)
+      for opt in GlobalCommandHookRegistry.get_options():
+        opt.add_to_parser(vparser)
 
   @property
   def usage(self):
@@ -434,11 +447,11 @@ class Noun(AuroraCommand):
     result = ['Usage for noun "%s":' % self.name]
     result += ["    %s %s" % (self.name, self.verbs[verb].usage) for verb in self.verbs]
     result += [self.help]
-    return '\n'.join(result)
+    return "\n".join(result)
 
   def execute(self, context):
     if context.options.verb not in self.verbs:
-      raise self.InvalidVerbException('Noun %s does not have a verb %s' %
+      raise self.InvalidVerbException("Noun %s does not have a verb %s" %
           (self.name, context.options.verb))
     return self.verbs[context.options.verb].execute(context)
 

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/fb5027b5/src/main/python/apache/aurora/client/cli/command_hooks.py
----------------------------------------------------------------------
diff --git a/src/main/python/apache/aurora/client/cli/command_hooks.py b/src/main/python/apache/aurora/client/cli/command_hooks.py
index 2d20068..196a0c1 100644
--- a/src/main/python/apache/aurora/client/cli/command_hooks.py
+++ b/src/main/python/apache/aurora/client/cli/command_hooks.py
@@ -14,15 +14,84 @@
 # limitations under the License.
 #
 
+# The implementation of command hooks. See the design doc in docs/design/command-hooks.md
for
+# details on what hooks are and how they work.
+
 from __future__ import print_function
 
 from abc import abstractmethod
+from fnmatch import fnmatch
+import getpass
 import logging
 import os
 import sys
 
+from apache.aurora.client.cli.options import CommandOption
+
+import requests
 from twitter.common.lang import Compatibility
 
+# Ugly workaround to avoid cyclic dependency.
+EXIT_PERMISSION_VIOLATION = 8
+
+
+ALL_HOOKS = "all"
+
+
+class SkipHooksRule(object):
+  """A rule that specifies when a user may skip a command hook."""
+
+  @property
+  def name(self):
+    pass
+
+  def allow_hook_skip(self, hook, user, noun, verb, args):
+    """ Check if this rule allows a user to skip a specific hook.
+    Params:
+    - hook: the name of the hook that the user wants to skip.
+    - user: the user requesting that hooks be skipped. (Note: user, not role!)
+    - noun, verb: the noun and verb being executed.
+    - args: the other command-line arguments.
+    Returns: True if the user should be allowed to skip the requested hooks.
+    """
+    return False
+
+
+class JsonSkipHooksRule(SkipHooksRule):
+  """An implementation for skip rules that are loaded from a json file.
+  See the design doc for details on the format of the rules."""
+
+  def __init__(self, name, json_rule):
+    self.rulename = name
+    self.hooks = json_rule.get("hooks", ["*"])
+    self.users = json_rule.get("users", ["*"])
+    self.commands = json_rule.get("commands", {})
+    self.arg_patterns = json_rule.get("arg-patterns", None)
+
+  @property
+  def name(self):
+    return self.rulename
+
+  def allow_hook_skip(self, hook, user, noun, verb, args):
+    def _hooks_match(hook):
+      return any(fnmatch(hook, hook_pattern) for hook_pattern in self.hooks)
+
+    def _commands_match(noun, verb):
+      return noun in self.commands and verb in self.commands[noun]
+
+    def _users_match(user):
+      return any(fnmatch(user, user_pattern) for user_pattern in self.users)
+
+    def _args_match(args):
+      if self.arg_patterns is None:
+        return True
+      else:
+        return any(fnmatch(arg, arg_pattern) for arg_pattern in self.arg_patterns
+            for arg in args)
+
+    return (_hooks_match(hook) and _commands_match(noun, verb) and _users_match(user) and
+        _args_match(args))
+
 
 class GlobalCommandHookRegistry(object):
   """Registry for command hooks.
@@ -31,7 +100,45 @@ class GlobalCommandHookRegistry(object):
   """
 
   COMMAND_HOOKS = []
-  HOOKS_FILE_NAME = 'AuroraHooks'
+  HOOKS_FILE_NAME = "AuroraHooks"
+  SKIP_HOOK_RULES = {}
+
+  @classmethod
+  def setup(cls, rules_url):
+    """Initializes the hook registry, loading the project hooks and the skip rules."""
+    if rules_url is not None:
+      cls.load_global_hook_skip_rules(rules_url)
+    cls.load_project_hooks()
+
+  @classmethod
+  def get_options(cls):
+    """Returns the options that should be added to option parsers for the hooks registry."""
+    return [CommandOption("--skip-hooks", default=None,
+        metavar="hook,hook,...",
+        help=("A comma-separated list of command hook names that should be skipped. If the
hooks"
+              " cannot be skipped, then the command will be aborted"))]
+
+  @classmethod
+  def register_hook_skip_rule(cls, rule):
+    """Registers a rule to allow users to skip certain hooks"""
+    cls.SKIP_HOOK_RULES[rule.name] = rule
+
+  @classmethod
+  def register_json_hook_skip_rules(cls, json_rules):
+    """Register a set of skip rules that are structured as JSON dictionaries."""
+    for (name, rule) in json_rules.items():
+      cls.register_hook_skip_rule(JsonSkipHooksRule(name, rule))
+
+  @classmethod
+  def load_global_hook_skip_rules(cls, url):
+    """If the system uses a master skip rules file, loads the master skip rules JSON."""
+    resp = requests.get(url)
+    try:
+      rules = resp.json()
+      cls.register_json_hook_skip_rules(rules)
+    except ValueError as e:
+      logging.error("Client could not decode hook skip rules: %s" % e)
+
 
   @classmethod
   def load_hooks_file(cls, path):
@@ -39,7 +146,6 @@ class GlobalCommandHookRegistry(object):
     the errors will be logged, the hooks from the file will be skipped, but the execution
of
     the command will continue.
     """
-    #  To load a hooks file, we compile and exec the file.
     with open(path, "r") as hooks_file:
       hooks_data = hooks_file.read()
       hooks_code = None
@@ -54,15 +160,15 @@ class GlobalCommandHookRegistry(object):
         Compatibility.exec_function(hooks_code, hooks_environment)
       except Exception as e:
         # Unfortunately, exec could throw *anything* at all.
-        logging.warn('Warning: error loading hooks file %s: %s' % (path, e))
-        print('Warning: error loading hooks file %s: %s' % (path, e), file=sys.stderr)
+        logging.warn("Warning: error loading hooks file %s: %s" % (path, e))
+        print("Warning: error loading hooks file %s: %s" % (path, e), file=sys.stderr)
         return {}
-      for hook in hooks_environment.get('hooks', []):
+      for hook in hooks_environment.get("hooks", []):
         cls.register_command_hook(hook)
       return hooks_environment
 
   @classmethod
-  def find_project_hooks_file(self, dir):
+  def find_project_hooks_file(cls, dir):
     """Search for a file named "AuroraHooks" in  current directory or
     one of its parents, up to the closest repository root. Only one
     file will be loaded, so creating an AuroraHooks file in a subdirecory will
@@ -71,9 +177,9 @@ class GlobalCommandHookRegistry(object):
     def is_repos_root(dir):
       # a directory is a git root if it contains a directory named ".git".
       # it's an HG root if it contains a directory named ".hg"
-      return any(os.path.isdir(os.path.join(dir, rootname)) for rootname in ['.git', '.hg'])
+      return any(os.path.isdir(os.path.join(dir, rootname)) for rootname in [".git", ".hg"])
 
-    filepath =  os.path.join(dir, self.HOOKS_FILE_NAME)
+    filepath =  os.path.join(dir, cls.HOOKS_FILE_NAME)
     if os.path.exists(filepath):
       return filepath
     elif is_repos_root(dir):
@@ -85,7 +191,7 @@ class GlobalCommandHookRegistry(object):
       if parent == dir:
         return None
       else:
-        return find_project_hooks_file(parent)
+        return cls.find_project_hooks_file(parent)
       return None
 
   @classmethod
@@ -102,6 +208,7 @@ class GlobalCommandHookRegistry(object):
   @classmethod
   def reset(cls):
     """For testing purposes, reset the list of registered hooks"""
+    cls.SKIP_HOOK_RULES = {}
     cls.COMMAND_HOOKS = []
 
   @classmethod
@@ -112,11 +219,79 @@ class GlobalCommandHookRegistry(object):
         verb in hook.get_verbs(noun)]
 
   @classmethod
+  def get_required_hooks(cls, context, skip_opt, noun, verb, user=None):
+    """Given a set of hooks that match a command, find the set of hooks that
+    must be run. If the user asked to skip a hook that cannot be skipped,
+    raise an exception.
+    """
+    if user is None:
+      user = getpass.getuser()
+    selected_hooks = cls.get_command_hooks_for(noun, verb)
+    # The real set of required hooks is the set of hooks that match the command
+    # being executed, minus the set of hooks that the user both wants to skip,
+    # and is allowed to skip.
+    if skip_opt is None:
+      return selected_hooks
+    selected_hook_names = [hook.name for hook in selected_hooks]
+    desired_skips = set(selected_hook_names if skip_opt == ALL_HOOKS else skip_opt.split(","))
+    desired_skips = desired_skips & set(selected_hook_names)
+    for desired_skip in desired_skips:
+      if not any(rule.allow_hook_skip(desired_skip, user, noun, verb, context.args)
+          for name, rule in cls.SKIP_HOOK_RULES.items()):
+        context.print_log(logging.INFO, "Hook %s cannot be skipped by user %s" %
+            (desired_skip, user))
+        raise context.CommandError(EXIT_PERMISSION_VIOLATION,
+            "Hook %s cannot be skipped by user %s" % (desired_skip, user))
+    selected_hook_names = set(selected_hook_names) - desired_skips
+    return [hook for hook in selected_hooks if hook.name in selected_hook_names]
+
+  @classmethod
   def register_command_hook(cls, hook):
+    # "all" is a reserved name used to indicate that the user wants to skip all hooks, not
just
+    # a specific set.
+    if hook.name == ALL_HOOKS:
+      raise ValueError("Invalid hook name 'all'")
     cls.COMMAND_HOOKS.append(hook)
 
+  @classmethod
+  def run_pre_hooks(cls, context, noun, verb):
+    """Run all of the non-skipped hooks that apply to this command."""
+    pre_hooks = context.selected_hooks
+    try:
+      for hook in pre_hooks:
+        result = hook.pre_command(noun, verb, context, context.args)
+        if result != 0:
+          context.print_log(logging.INFO, "Command hook %s aborted operation with error code
%s" %
+              (hook.name, result))
+          context.print_out("Command aborted by command hook %s" % hook.name)
+          return result
+      return 0
+    except CommandHook.Error as c:
+      context.print_log(logging.INFO, "Error executing command hook %s: %s" % (hook.name,
c))
+      context.print_err("Error executing command hook %s: %s; aborting" % hook.name, c.msg)
+      return c.code
+
+  @classmethod
+  def run_post_hooks(cls, context, noun, verb, result):
+    """Run all of the non-skipped post-command hooks that apply to this command"""
+    selected_hooks = context.selected_hooks
+    try:
+      for hook in selected_hooks:
+        hook.post_command(noun, verb, context, context.args, result)
+        return 0
+    except CommandHook.Error as c:
+      context.print_log(logging.INFO, "Error executing post-command hook %s: %s" % (hook.name,
c))
+      context.print_err("Error executing command hook %s: %s; aborting" % hook.name, c.msg)
+      return c.code
 
 class CommandHook(object):
+  """A hook which contains code that should be run before certain commands."""
+  class Error(Exception):
+    def __init__(self, code, msg):
+      super(CommandHook.Error, self).__init__(msg)
+      self.code = code
+      self.msg = msg
+
   @property
   def name(self):
     return None
@@ -151,4 +326,3 @@ class CommandHook(object):
     * result: the result code returned by the verb.
     Returns: nothing
     """
-

http://git-wip-us.apache.org/repos/asf/incubator-aurora/blob/fb5027b5/src/test/python/apache/aurora/client/cli/test_command_hooks.py
----------------------------------------------------------------------
diff --git a/src/test/python/apache/aurora/client/cli/test_command_hooks.py b/src/test/python/apache/aurora/client/cli/test_command_hooks.py
index 6c6f6f5..8835d82 100644
--- a/src/test/python/apache/aurora/client/cli/test_command_hooks.py
+++ b/src/test/python/apache/aurora/client/cli/test_command_hooks.py
@@ -14,7 +14,7 @@
 # limitations under the License.
 #
 
-from twitter.common.contextutil import temporary_file
+import contextlib
 
 from gen.apache.aurora.api.ttypes import (
     AssignedTask,
@@ -26,12 +26,14 @@ from gen.apache.aurora.api.ttypes import (
     TaskQuery,
 )
 
+from apache.aurora.client.cli import EXIT_PERMISSION_VIOLATION
 from apache.aurora.client.cli.client import AuroraCommandLine
 from apache.aurora.client.cli.command_hooks import CommandHook, GlobalCommandHookRegistry
 from apache.aurora.client.cli.util import AuroraClientCommandTest, FakeAuroraCommandContext
 from apache.aurora.config import AuroraConfig
 
 from mock import Mock, patch
+from twitter.common.contextutil import temporary_file
 
 
 class HookForTesting(CommandHook):
@@ -40,12 +42,46 @@ class HookForTesting(CommandHook):
     self.ran_pre = False
     self.ran_post = False
 
+  @property
+  def name(self):
+    return "test_hook"
+
   def get_nouns(self):
-    return ['job']
+    return ["job"]
 
   def get_verbs(self, noun):
-    if noun == 'job':
-      return ['create', 'status']
+    if noun == "job":
+      return ["create", "status"]
+    else:
+      return []
+
+  def pre_command(self, noun, verb, context, commandline):
+    self.ran_pre = True
+    if self.succeed:
+      return 0
+    else:
+      return 1
+
+  def post_command(self, noun, verb, context, commandline, result):
+    self.ran_post = True
+
+
+class SecondHookForTesting(CommandHook):
+  def __init__(self, succeed):
+    self.succeed = succeed
+    self.ran_pre = False
+    self.ran_post = False
+
+  @property
+  def name(self):
+    return "second"
+
+  def get_nouns(self):
+    return ["job"]
+
+  def get_verbs(self, noun):
+    if noun == "job":
+      return ["kill", "killall", "create"]
     else:
       return []
 
@@ -82,8 +118,8 @@ class TestClientCreateCommand(AuroraClientCommandTest):
       # 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_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
 
@@ -120,20 +156,20 @@ class TestClientCreateCommand(AuroraClientCommandTest):
 
   def generic_test_successful_hook(self, command_hook):
     mock_context = FakeAuroraCommandContext()
-    with patch('apache.aurora.client.cli.jobs.Job.create_context', return_value=mock_context):
+    with patch("apache.aurora.client.cli.jobs.Job.create_context", return_value=mock_context):
       mock_query = self.create_mock_query()
       mock_context.add_expected_status_query_result(
         self.create_mock_status_query_result(ScheduleStatus.INIT))
       mock_context.add_expected_status_query_result(
         self.create_mock_status_query_result(ScheduleStatus.RUNNING))
-      api = mock_context.get_api('west')
+      api = mock_context.get_api("west")
       api.create_job.return_value = self.get_createjob_response()
 
       with temporary_file() as fp:
         fp.write(self.get_valid_config())
         fp.flush()
         cmd = AuroraCommandLine()
-        cmd.execute(['job', 'create', '--wait-until=RUNNING', 'west/bozo/test/hello',
+        cmd.execute(["job", "create", "--wait-until=RUNNING", "west/bozo/test/hello",
             fp.name])
 
       self.assert_create_job_called(api)
@@ -146,19 +182,19 @@ class TestClientCreateCommand(AuroraClientCommandTest):
     command_hook = HookForTesting(False)
     GlobalCommandHookRegistry.register_command_hook(command_hook)
     mock_context = FakeAuroraCommandContext()
-    with patch('apache.aurora.client.cli.jobs.Job.create_context', return_value=mock_context):
+    with patch("apache.aurora.client.cli.jobs.Job.create_context", return_value=mock_context):
       mock_context.add_expected_status_query_result(
         self.create_mock_status_query_result(ScheduleStatus.INIT))
       mock_context.add_expected_status_query_result(
         self.create_mock_status_query_result(ScheduleStatus.RUNNING))
-      api = mock_context.get_api('west')
+      api = mock_context.get_api("west")
       api.create_job.return_value = self.get_createjob_response()
 
       with temporary_file() as fp:
         fp.write(self.get_valid_config())
         fp.flush()
         cmd = AuroraCommandLine()
-        result = cmd.execute(['job', 'create', '--wait-until=RUNNING', 'west/bozo/test/hello',
+        result = cmd.execute(["job", "create", "--wait-until=RUNNING", "west/bozo/test/hello",
             fp.name])
 
         assert result == 1
@@ -169,30 +205,180 @@ class TestClientCreateCommand(AuroraClientCommandTest):
   def test_load_dynamic_hooks(self):
     GlobalCommandHookRegistry.reset()
     hook_locals = GlobalCommandHookRegistry.load_project_hooks(
-        './src/test/python/apache/aurora/client/cli')
-    assert hook_locals['hooks'][0] in GlobalCommandHookRegistry.COMMAND_HOOKS
+        "./src/test/python/apache/aurora/client/cli")
+    assert hook_locals["hooks"][0] in GlobalCommandHookRegistry.COMMAND_HOOKS
 
   def test_successful_dynamic_hook(self):
     GlobalCommandHookRegistry.reset()
     hook_locals = GlobalCommandHookRegistry.load_project_hooks(
-        './src/test/python/apache/aurora/client/cli')
-    self.generic_test_successful_hook(hook_locals['hooks'][0])
+        "./src/test/python/apache/aurora/client/cli")
+    self.generic_test_successful_hook(hook_locals["hooks"][0])
 
   def test_dynamic_hook_syntax_error(self):
-    with patch('logging.warn') as log_patch:
+    with patch("logging.warn") as log_patch:
       GlobalCommandHookRegistry.reset()
       hook_locals = GlobalCommandHookRegistry.load_project_hooks(
-        './src/test/python/apache/aurora/client/cli/hook_test_data/bad_syntax')
-      log_patch.assert_called_with('Error compiling hooks file '
-          './src/test/python/apache/aurora/client/cli/hook_test_data/bad_syntax/AuroraHooks:
'
-          'invalid syntax (AuroraHooks, line 1)')
+        "./src/test/python/apache/aurora/client/cli/hook_test_data/bad_syntax")
+      log_patch.assert_called_with("Error compiling hooks file "
+          "./src/test/python/apache/aurora/client/cli/hook_test_data/bad_syntax/AuroraHooks:
"
+          "invalid syntax (AuroraHooks, line 1)")
 
   def test_dynamic_hook_exec_error(self):
-    with patch('logging.warn') as log_patch:
+    with patch("logging.warn") as log_patch:
       GlobalCommandHookRegistry.reset()
       hook_locals = GlobalCommandHookRegistry.load_project_hooks(
-        './src/test/python/apache/aurora/client/cli/hook_test_data/exec_error')
-      log_patch.assert_called_with('Warning: error loading hooks file '
-          './src/test/python/apache/aurora/client/cli/hook_test_data/exec_error/AuroraHooks:
'
-          'integer division or modulo by zero')
+        "./src/test/python/apache/aurora/client/cli/hook_test_data/exec_error")
+      log_patch.assert_called_with("Warning: error loading hooks file "
+          "./src/test/python/apache/aurora/client/cli/hook_test_data/exec_error/AuroraHooks:
"
+          "integer division or modulo by zero")
+
+  def assert_skip_allowed(self, context, skip_opt, user, noun, verb, args):
+    """Checks that a hook would be allowed to be skipped in a command invocation"""
+    required_hooks = GlobalCommandHookRegistry.get_required_hooks(context, skip_opt, noun,
+        verb, user)
 
+  def assert_skip_forbidden(self, context, skip_opt, user, noun, verb, args):
+    """Checks that a hook would NOT be allowed to be skipped in a command invocation"""
+    try:
+      # assertRaises doesn't work with classmethods.
+      context.args = args
+      GlobalCommandHookRegistry.get_required_hooks(context, skip_opt, noun, verb, user)
+      self.fail("Should have thrown an error.")
+    except context.CommandError:
+      pass
+
+  def test_json_skip_rules(self):
+    """Load up a set of skips, specified in JSON, and then check a bunch of different
+    cases to see that the skip rules work correctly.
+    """
+    mock_response = Mock()
+    mock_context = FakeAuroraCommandContext()
+    with patch("requests.get", return_value=mock_response):
+      mock_response.json.return_value = {
+        "a": {
+          "users": ["bozo", "clown"],
+          "commands": {"job": ["killall"]},
+          "hooks": ["test_hook", "second"]
+        },
+        "b": {
+          "commands": {"user": ["kick"]},
+        }
+      }
+      GlobalCommandHookRegistry.reset()
+      GlobalCommandHookRegistry.setup("http://foo.bar")
+      command_hook_one = HookForTesting(False)
+      GlobalCommandHookRegistry.register_command_hook(command_hook_one)
+      command_hook_two = SecondHookForTesting(True)
+      GlobalCommandHookRegistry.register_command_hook(command_hook_two)
+      assert len(GlobalCommandHookRegistry.SKIP_HOOK_RULES) == 2
+      # Should not be allowed: no skip rule permitting skipping hooks on job kill
+      self.assert_skip_forbidden(mock_context, "all", "beezus", "job", "kill", ["a", "b",
"c"])
+      # Should not be allowed: there are hooks on "job killall", and beezus doesn't satisfy
+      # their exception rules
+      self.assert_skip_forbidden(mock_context, "all", "beezus", "job", "kill", ["a", "b",
"c"])
+      # Should be allowed: there's a rule allowing bozo to skip test_hook on job killall.
+      self.assert_skip_allowed(mock_context, "test_hook", "bozo", "job", "killall", ["a",
"b", "c"])
+      # Should be allowed: since there's only one hook on killall, this is equivalent to
+      # the previous.
+      self.assert_skip_allowed(mock_context, "all", "bozo", "job", "killall", ["a", "b",
"c"])
+      # Should be allowed: there's a rule allowing anyone to skip any hook on "user kick".
+      self.assert_skip_allowed(mock_context, "test_hook,something_else", "nobody", "user",
+          "kick", ["a", "b", "c"])
+      # should be allowed: there are no hooks in place for "user kick", so all is acceptable.
+      self.assert_skip_allowed(mock_context, "all", "nobody", "user", "kick", ["a", "b",
"c"])
+
+  def test_skip_hooks_in_create(self):
+    """Run a test of create, with a hook that should forbid running create, but
+    with a user who's covered by one of the hook skip exception rules - so it
+    should succeed.
+    """
+
+    GlobalCommandHookRegistry.reset()
+    command_hook = HookForTesting(True)
+    GlobalCommandHookRegistry.register_command_hook(command_hook)
+    command_hook_two = SecondHookForTesting(False)
+    GlobalCommandHookRegistry.register_command_hook(command_hook_two)
+    mock_response = Mock()
+    mock_context = FakeAuroraCommandContext()
+
+    with contextlib.nested(
+        patch("apache.aurora.client.cli.jobs.Job.create_context", return_value=mock_context),
+        patch("requests.get", return_value=mock_response),
+        patch("getpass.getuser", return_value="bozo")):
+      mock_response.json.return_value = {
+        "a": {
+          "users": ["bozo", "clown"],
+          "commands": {"job": ["killall", "create"]},
+          "hooks": ["test_hook", "second"]
+        },
+        "b": {
+          "commands": {"user": ["kick"]},
+        }
+      }
+
+      mock_query = self.create_mock_query()
+      mock_context.add_expected_status_query_result(
+        self.create_mock_status_query_result(ScheduleStatus.INIT))
+      mock_context.add_expected_status_query_result(
+        self.create_mock_status_query_result(ScheduleStatus.RUNNING))
+      api = mock_context.get_api("west")
+      api.create_job.return_value = self.get_createjob_response()
+      GlobalCommandHookRegistry.setup("http://foo.bar")
+
+      with temporary_file() as fp:
+        fp.write(self.get_valid_config())
+        fp.flush()
+        cmd = AuroraCommandLine()
+        result = cmd.execute(["job", "create", "--skip-hooks=second", "--wait-until=RUNNING",
+            "west/bozo/test/hello", fp.name])
+        assert result == 0
+        self.assert_create_job_called(api)
+        self.assert_scheduler_called(api, mock_query, 1)
+        assert command_hook.ran_pre
+        assert command_hook.ran_post
+
+  def test_cannot_skip_hooks_in_create(self):
+    """This time, the hook shouldn't be skippable, because we use a username
+    who isn't allowed by the hook exception rule.
+    """
+    GlobalCommandHookRegistry.reset()
+    command_hook = HookForTesting(True)
+    GlobalCommandHookRegistry.register_command_hook(command_hook)
+    command_hook_two = SecondHookForTesting(False)
+    GlobalCommandHookRegistry.register_command_hook(command_hook_two)
+    mock_response = Mock()
+    mock_context = FakeAuroraCommandContext()
+
+    with contextlib.nested(
+        patch("apache.aurora.client.cli.jobs.Job.create_context", return_value=mock_context),
+        patch("requests.get", return_value=mock_response),
+        patch("getpass.getuser", return_value="beezus")):
+      mock_response.json.return_value = {
+        "a": {
+          "users": ["bozo", "clown"],
+          "commands": {"job": ["killall", "create"]},
+          "hooks": ["test_hook", "second"]
+        },
+        "b": {
+          "commands": {"user": ["kick"]},
+        }
+      }
+
+      mock_query = self.create_mock_query()
+      mock_context.add_expected_status_query_result(
+        self.create_mock_status_query_result(ScheduleStatus.INIT))
+      mock_context.add_expected_status_query_result(
+        self.create_mock_status_query_result(ScheduleStatus.RUNNING))
+      api = mock_context.get_api("west")
+      api.create_job.return_value = self.get_createjob_response()
+      GlobalCommandHookRegistry.setup("http://foo.bar")
+
+      with temporary_file() as fp:
+        fp.write(self.get_valid_config())
+        fp.flush()
+        cmd = AuroraCommandLine()
+        result = cmd.execute(["job", "create", "--skip-hooks=second", "--wait-until=RUNNING",
+            "west/bozo/test/hello", fp.name])
+        # Check that it returns the right error code, and that create_job didn't get called.
+        assert result == EXIT_PERMISSION_VIOLATION
+        assert api.create_job.call_count == 0


Mime
View raw message