Run pre and post playbooks

This allows jobs to specify pre and post playbooks.  Jobs which inherit
from parents or variants add their pre and post playbooks to their
parents in onion fashion -- the outermost pre playbooks run first and post
playbooks run last.

Change-Id: Ic844dcac77d87481534745a220664d72be2ffa7c
diff --git a/tests/fixtures/config/ansible/git/common-config/playbooks/post.yaml b/tests/fixtures/config/ansible/git/common-config/playbooks/post.yaml
new file mode 100644
index 0000000..2e512b1
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/common-config/playbooks/post.yaml
@@ -0,0 +1,5 @@
+- hosts: all
+  tasks:
+    - file:
+        path: "{{zuul._test.test_root}}/{{zuul.uuid}}.post.flag"
+        state: touch
diff --git a/tests/fixtures/config/ansible/git/common-config/playbooks/pre.yaml b/tests/fixtures/config/ansible/git/common-config/playbooks/pre.yaml
new file mode 100644
index 0000000..f4222ff
--- /dev/null
+++ b/tests/fixtures/config/ansible/git/common-config/playbooks/pre.yaml
@@ -0,0 +1,5 @@
+- hosts: all
+  tasks:
+    - file:
+        path: "{{zuul._test.test_root}}/{{zuul.uuid}}.pre.flag"
+        state: touch
diff --git a/tests/fixtures/config/ansible/git/common-config/zuul.yaml b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
index f00eab7..7964243 100644
--- a/tests/fixtures/config/ansible/git/common-config/zuul.yaml
+++ b/tests/fixtures/config/ansible/git/common-config/zuul.yaml
@@ -38,3 +38,5 @@
 
 - job:
     name: python27
+    pre-run: pre
+    post-run: post
diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py
index ae40416..a71dc28 100644
--- a/tests/unit/test_model.py
+++ b/tests/unit/test_model.py
@@ -94,6 +94,9 @@
         layout.addJob(python27diablo)
 
         project_config = configloader.ProjectParser.fromYaml(layout, {
+            '_source_project': project,
+            '_source_branch': 'master',
+            '_source_configrepo': True,
             'name': 'project',
             'gate': {
                 'jobs': [
@@ -247,6 +250,9 @@
         layout.addJob(python27diablo)
 
         project_config = configloader.ProjectParser.fromYaml(layout, {
+            '_source_project': project,
+            '_source_branch': 'master',
+            '_source_configrepo': True,
             'name': 'project',
             'gate': {
                 'jobs': [
@@ -309,6 +315,9 @@
         layout.addJob(python27)
 
         project_config = configloader.ProjectParser.fromYaml(layout, {
+            '_source_project': project,
+            '_source_branch': 'master',
+            '_source_configrepo': True,
             'name': 'project',
             'gate': {
                 'jobs': [
diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py
index 31410fb..0ba5ff8 100644
--- a/tests/unit/test_v3.py
+++ b/tests/unit/test_v3.py
@@ -129,3 +129,9 @@
         self.assertEqual(build.result, 'SUCCESS')
         flag_path = os.path.join(self.test_root, build.uuid + '.flag')
         self.assertTrue(os.path.exists(flag_path))
+        pre_flag_path = os.path.join(self.test_root, build.uuid +
+                                     '.pre.flag')
+        self.assertTrue(os.path.exists(pre_flag_path))
+        post_flag_path = os.path.join(self.test_root, build.uuid +
+                                      '.post.flag')
+        self.assertTrue(os.path.exists(post_flag_path))
diff --git a/zuul/configloader.py b/zuul/configloader.py
index 40ebda0..8e112d0 100644
--- a/zuul/configloader.py
+++ b/zuul/configloader.py
@@ -101,6 +101,8 @@
                'nodes': vs.Any([node], str),
                'timeout': int,
                'attempts': int,
+               'pre-run': to_list(str),
+               'post-run': to_list(str),
                '_source_project': model.Project,
                '_source_branch': vs.Any(str, None),
                '_source_configrepo': bool,
@@ -111,6 +113,7 @@
     @staticmethod
     def fromYaml(layout, conf):
         JobParser.getSchema()(conf)
+
         job = model.Job(conf['name'])
         if 'auth' in conf:
             job.auth = conf.get('auth')
@@ -119,8 +122,6 @@
             job.inheritFrom(parent)
         job.timeout = conf.get('timeout', job.timeout)
         job.workspace = conf.get('workspace', job.workspace)
-        job.pre_run = as_list(conf.get('pre-run', job.pre_run))
-        job.post_run = as_list(conf.get('post-run', job.post_run))
         job.voting = conf.get('voting', True)
         job.hold_following_changes = conf.get('hold-following-changes', False)
         job.mutex = conf.get('mutex', None)
@@ -144,14 +145,38 @@
             # accumulate onto any previously applied tags from
             # metajobs.
             job.tags = job.tags.union(set(tags))
-        # The source attributes and playbook may not be overridden --
-        # they are always supplied by the config loader.  They
-        # correspond to the Project instance of the repo where it
+        # The source attributes and playbook info may not be
+        # overridden -- they are always supplied by the config loader.
+        # They correspond to the Project instance of the repo where it
         # originated, and the branch name.
         job.source_project = conf.get('_source_project')
         job.source_branch = conf.get('_source_branch')
         job.source_configrepo = conf.get('_source_configrepo')
-        job.playbook = os.path.join('playbooks', job.name)
+        pre_run_name = conf.get('pre-run')
+        # Append the pre-run command
+        if pre_run_name:
+            pre_run_name = os.path.join('playbooks', pre_run_name)
+            pre_run = model.PlaybookContext(job.source_project,
+                                            job.source_branch,
+                                            pre_run_name,
+                                            job.source_configrepo)
+            job.pre_run.append(pre_run)
+        # Prepend the post-run command
+        post_run_name = conf.get('post-run')
+        if post_run_name:
+            post_run_name = os.path.join('playbooks', post_run_name)
+            post_run = model.PlaybookContext(job.source_project,
+                                             job.source_branch,
+                                             post_run_name,
+                                             job.source_configrepo)
+            job.post_run.insert(0, post_run)
+        # Set the run command
+        run_name = job.name
+        run_name = os.path.join('playbooks', run_name)
+        run = model.PlaybookContext(job.source_project,
+                                    job.source_branch, run_name,
+                                    job.source_configrepo)
+        job.run = run
         job.failure_message = conf.get('failure-message', job.failure_message)
         job.success_message = conf.get('success-message', job.success_message)
         job.failure_url = conf.get('failure-url', job.failure_url)
@@ -193,7 +218,11 @@
             vs.Required('name'): str,
             'merge-mode': vs.Any(
                 'merge', 'merge-resolve',
-                'cherry-pick')}
+                'cherry-pick'),
+            '_source_project': model.Project,
+            '_source_branch': vs.Any(str, None),
+            '_source_configrepo': bool,
+        }
 
         for p in layout.pipelines.values():
             project_template[p.name] = {'queue': str,
@@ -204,6 +233,9 @@
     def fromYaml(layout, conf):
         ProjectTemplateParser.getSchema(layout)(conf)
         project_template = model.ProjectConfig(conf['name'])
+        source_project = conf['_source_project']
+        source_branch = conf['_source_branch']
+        source_configrepo = conf['_source_configrepo']
         for pipeline in layout.pipelines.values():
             conf_pipeline = conf.get(pipeline.name)
             if not conf_pipeline:
@@ -212,11 +244,13 @@
             project_template.pipelines[pipeline.name] = project_pipeline
             project_pipeline.queue_name = conf_pipeline.get('queue')
             project_pipeline.job_tree = ProjectTemplateParser._parseJobTree(
-                layout, conf_pipeline.get('jobs', []))
+                layout, conf_pipeline.get('jobs', []),
+                source_project, source_branch, source_configrepo)
         return project_template
 
     @staticmethod
-    def _parseJobTree(layout, conf, tree=None):
+    def _parseJobTree(layout, conf, source_project, source_branch,
+                      source_configrepo, tree=None):
         if not tree:
             tree = model.JobTree(None)
         for conf_job in conf:
@@ -230,6 +264,9 @@
                 if attrs:
                     # We are overriding params, so make a new job def
                     attrs['name'] = jobname
+                    attrs['_source_project'] = source_project
+                    attrs['_source_branch'] = source_branch
+                    attrs['_source_configrepo'] = source_configrepo
                     subtree = tree.addJob(JobParser.fromYaml(layout, attrs))
                 else:
                     # Not overriding, so get existing job
@@ -237,7 +274,11 @@
 
                 if jobs:
                     # This is the root of a sub tree
-                    ProjectTemplateParser._parseJobTree(layout, jobs, subtree)
+                    ProjectTemplateParser._parseJobTree(layout, jobs,
+                                                        source_project,
+                                                        source_branch,
+                                                        source_configrepo,
+                                                        subtree)
             else:
                 raise Exception("Job must be a string or dictionary")
         return tree
@@ -248,10 +289,16 @@
 
     @staticmethod
     def getSchema(layout):
-        project = {vs.Required('name'): str,
-                   'templates': [str],
-                   'merge-mode': vs.Any('merge', 'merge-resolve',
-                                        'cherry-pick')}
+        project = {
+            vs.Required('name'): str,
+            'templates': [str],
+            'merge-mode': vs.Any('merge', 'merge-resolve',
+                                 'cherry-pick'),
+            '_source_project': model.Project,
+            '_source_branch': vs.Any(str, None),
+            '_source_configrepo': bool,
+        }
+
         for p in layout.pipelines.values():
             project[p.name] = {'queue': str,
                                'jobs': [vs.Any(str, dict)]}
diff --git a/zuul/launcher/client.py b/zuul/launcher/client.py
index f7bb73b..5ab1977 100644
--- a/zuul/launcher/client.py
+++ b/zuul/launcher/client.py
@@ -373,15 +373,11 @@
         params['items'] = merger_items
         params['projects'] = []
 
-        config_repos = set([x[1] for x in
-                            item.pipeline.layout.tenant.config_repos])
         if job.name != 'noop':
-            params['playbook'] = dict(
-                connection=job.source_project.connection_name,
-                config_repo=job.source_project in config_repos,
-                project=job.source_project.name,
-                branch=job.source_branch,
-                path=job.playbook)
+            params['playbook'] = job.run.toDict()
+            params['pre_playbooks'] = [x.toDict() for x in job.pre_run]
+            params['post_playbooks'] = [x.toDict() for x in job.post_run]
+
         nodes = []
         for node in item.current_build_set.getJobNodeSet(job.name).getNodes():
             nodes.append(dict(name=node.name, image=node.image))
diff --git a/zuul/launcher/server.py b/zuul/launcher/server.py
index 9c0bdf1..73f5e27 100644
--- a/zuul/launcher/server.py
+++ b/zuul/launcher/server.py
@@ -66,6 +66,13 @@
 # repos end up in git.openstack.org.
 
 
+class JobDirPlaybook(object):
+    def __init__(self, root):
+        self.root = root
+        self.secure = None
+        self.path = None
+
+
 class JobDir(object):
     def __init__(self, keep=False):
         self.keep = keep
@@ -77,20 +84,30 @@
         self.known_hosts = os.path.join(self.ansible_root, 'known_hosts')
         self.inventory = os.path.join(self.ansible_root, 'inventory')
         self.vars = os.path.join(self.ansible_root, 'vars.yaml')
-        self.playbook = None
         self.playbook_root = os.path.join(self.ansible_root, 'playbook')
         os.makedirs(self.playbook_root)
-        self.pre_playbook = None
-        self.pre_playbook_root = os.path.join(self.ansible_root,
-                                              'pre_playbook')
-        os.makedirs(self.pre_playbook_root)
-        self.post_playbook = None
-        self.post_playbook_root = os.path.join(self.ansible_root,
-                                               'post_playbook')
-        os.makedirs(self.post_playbook_root)
+        self.playbook = JobDirPlaybook(self.playbook_root)
+        self.pre_playbooks = []
+        self.post_playbooks = []
         self.config = os.path.join(self.ansible_root, 'ansible.cfg')
         self.ansible_log = os.path.join(self.ansible_root, 'ansible_log.txt')
 
+    def addPrePlaybook(self):
+        count = len(self.pre_playbooks)
+        root = os.path.join(self.ansible_root, 'pre_playbook_%i' % (count,))
+        os.makedirs(root)
+        playbook = JobDirPlaybook(root)
+        self.pre_playbooks.append(playbook)
+        return playbook
+
+    def addPostPlaybook(self):
+        count = len(self.post_playbooks)
+        root = os.path.join(self.ansible_root, 'post_playbook_%i' % (count,))
+        os.makedirs(root)
+        playbook = JobDirPlaybook(root)
+        self.post_playbooks.append(playbook)
+        return playbook
+
     def cleanup(self):
         if not self.keep:
             shutil.rmtree(self.root)
@@ -463,7 +480,7 @@
             commit = args['items'][-1]['newrev']  # noqa
 
         # is the playbook in a repo that we have already prepared?
-        self.jobdir.playbook = self.preparePlaybookRepo(args)
+        self.preparePlaybookRepos(args)
 
         # TODOv3: Ansible the ansible thing here.
         self.prepareAnsibleFiles(args)
@@ -499,13 +516,14 @@
     def runPlaybooks(self):
         result = None
 
-        pre_status, pre_code = self.runAnsiblePrePlaybook()
-        if pre_status != self.RESULT_NORMAL or pre_code != 0:
-            # These should really never fail, so return None and have
-            # zuul try again
-            return result
+        for playbook in self.jobdir.pre_playbooks:
+            pre_status, pre_code = self.runAnsiblePlaybook(playbook)
+            if pre_status != self.RESULT_NORMAL or pre_code != 0:
+                # These should really never fail, so return None and have
+                # zuul try again
+                return result
 
-        job_status, job_code = self.runAnsiblePlaybook()
+        job_status, job_code = self.runAnsiblePlaybook(self.jobdir.playbook)
         if job_status == self.RESULT_TIMED_OUT:
             return 'TIMED_OUT'
         if job_status == self.RESULT_ABORTED:
@@ -515,14 +533,17 @@
             # run it again.
             return result
 
-        post_status, post_code = self.runAnsiblePostPlaybook(
-            job_code == 0)
-        if post_status != self.RESULT_NORMAL or post_code != 0:
-            result = 'POST_FAILURE'
-        elif job_code == 0:
+        success = (job_code == 0)
+        if success:
             result = 'SUCCESS'
         else:
             result = 'FAILURE'
+
+        for playbook in self.jobdir.post_playbooks:
+            post_status, post_code = self.runAnsiblePlaybook(
+                playbook, success)
+            if post_status != self.RESULT_NORMAL or post_code != 0:
+                result = 'POST_FAILURE'
         return result
 
     def getHostList(self, args):
@@ -542,16 +563,28 @@
                 return fn
         raise Exception("Unable to find playbook %s" % path)
 
-    def preparePlaybookRepo(self, args):
-        # Check out the playbook repo if needed and return the path to
+    def preparePlaybookRepos(self, args):
+        for playbook in args['pre_playbooks']:
+            jobdir_playbook = self.jobdir.addPrePlaybook()
+            self.preparePlaybookRepo(jobdir_playbook, playbook, args)
+
+        jobdir_playbook = self.jobdir.playbook
+        self.preparePlaybookRepo(jobdir_playbook, args['playbook'], args)
+
+        for playbook in args['post_playbooks']:
+            jobdir_playbook = self.jobdir.addPostPlaybook()
+            self.preparePlaybookRepo(jobdir_playbook, playbook, args)
+
+    def preparePlaybookRepo(self, jobdir_playbook, playbook, args):
+        # Check out the playbook repo if needed and set the path to
         # the playbook that should be run.
-        playbook = args['playbook']
+        jobdir_playbook.secure = playbook['secure']
         source = self.launcher_server.connections.getSource(
             playbook['connection'])
         project = source.getProject(playbook['project'])
         # TODO(jeblair): construct the url in the merger itself
         url = source.getGitUrl(project)
-        if not playbook['config_repo']:
+        if not playbook['secure']:
             # This is a project repo, so it is safe to use the already
             # checked out version (from speculative merging) of the
             # playbook
@@ -562,18 +595,19 @@
                     path = os.path.join(self.jobdir.git_root,
                                         project.name,
                                         playbook['path'])
-                    return self.findPlaybook(path)
+                    jobdir_playbook.path = self.findPlaybook(path)
+                    return
         # The playbook repo is either a config repo, or it isn't in
         # the stack of changes we are testing, so check out the branch
         # tip into a dedicated space.
 
-        merger = self.launcher_server._getMerger(self.jobdir.playbook_root)
+        merger = self.launcher_server._getMerger(jobdir_playbook.root)
         merger.checkoutBranch(project.name, url, playbook['branch'])
 
-        path = os.path.join(self.jobdir.playbook_root,
+        path = os.path.join(jobdir_playbook.root,
                             project.name,
                             playbook['path'])
-        return self.findPlaybook(path)
+        jobdir_playbook.path = self.findPlaybook(path)
 
     def prepareAnsibleFiles(self, args):
         with open(self.jobdir.inventory, 'w') as inventory:
@@ -682,10 +716,7 @@
 
         return (self.RESULT_NORMAL, ret)
 
-    def runAnsiblePrePlaybook(self):
-        # TODOv3(jeblair): remove return statement
-        return (self.RESULT_NORMAL, 0)
-
+    def runAnsiblePlaybook(self, playbook, success=None):
         env_copy = os.environ.copy()
         env_copy['LOGNAME'] = 'zuul'
 
@@ -694,44 +725,13 @@
         else:
             verbose = '-v'
 
-        cmd = ['ansible-playbook', self.jobdir.pre_playbook,
-               '-e@%s' % self.jobdir.vars, verbose]
-        # TODOv3: get this from the job
-        timeout = 60
+        cmd = ['ansible-playbook', playbook.path]
 
-        return self.runAnsible(cmd, timeout)
+        if success is not None:
+            cmd.extend(['-e', 'success=%s' % str(bool(success))])
 
-    def runAnsiblePlaybook(self):
-        env_copy = os.environ.copy()
-        env_copy['LOGNAME'] = 'zuul'
+        cmd.extend(['-e@%s' % self.jobdir.vars, verbose])
 
-        if False:  # TODOv3: self.options['verbose']:
-            verbose = '-vvv'
-        else:
-            verbose = '-v'
-
-        cmd = ['ansible-playbook', self.jobdir.playbook,
-               '-e@%s' % self.jobdir.vars, verbose]
-        # TODOv3: get this from the job
-        timeout = 60
-
-        return self.runAnsible(cmd, timeout)
-
-    def runAnsiblePostPlaybook(self, success):
-        # TODOv3(jeblair): remove return statement
-        return (self.RESULT_NORMAL, 0)
-
-        env_copy = os.environ.copy()
-        env_copy['LOGNAME'] = 'zuul'
-
-        if False:  # TODOv3: self.options['verbose']:
-            verbose = '-vvv'
-        else:
-            verbose = '-v'
-
-        cmd = ['ansible-playbook', self.jobdir.post_playbook,
-               '-e', 'success=%s' % success,
-               '-e@%s' % self.jobdir.vars, verbose]
         # TODOv3: get this from the job
         timeout = 60
 
diff --git a/zuul/model.py b/zuul/model.py
index 189244a..e467c00 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -515,7 +515,48 @@
         self.state_time = data['state_time']
 
 
+class PlaybookContext(object):
+    """A reference to a playbook in the context of a project.
+
+    Jobs refer to objects of this class for their main, pre, and post
+    playbooks so that we can keep track of which repos and security
+    contexts are needed in order to run them."""
+
+    def __init__(self, project, branch, path, secure):
+        self.project = project
+        self.branch = branch
+        self.path = path
+        self.secure = secure
+
+    def __repr__(self):
+        return '<PlaybookContext %s:%s %s secure:%s>' % (self.project,
+                                                         self.branch,
+                                                         self.path,
+                                                         self.secure)
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __eq__(self, other):
+        if not isinstance(other, PlaybookContext):
+            return False
+        return (self.project == other.project and
+                self.branch == other.branch and
+                self.path == other.path and
+                self.secure == other.secure)
+
+    def toDict(self):
+        # Render to a dict to use in passing json to the launcher
+        return dict(
+            connection=self.project.connection_name,
+            project=self.project.name,
+            branch=self.branch,
+            path=self.path,
+            secure=self.secure)
+
+
 class Job(object):
+
     """A Job represents the defintion of actions to perform."""
 
     def __init__(self, name):
@@ -527,6 +568,7 @@
             workspace=None,
             pre_run=[],
             post_run=[],
+            run=None,
             voting=None,
             hold_following_changes=None,
             failure_message=None,
@@ -544,13 +586,15 @@
             source_project=None,
             source_branch=None,
             source_configrepo=None,
-            playbook=None,
         )
 
         self.name = name
         for k, v in self.attributes.items():
             setattr(self, k, v)
 
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
     def __eq__(self, other):
         # Compare the name and all inheritable attributes to determine
         # whether two jobs with the same name are identically
@@ -577,11 +621,15 @@
         if not isinstance(other, Job):
             raise Exception("Job unable to inherit from %s" % (other,))
         for k, v in self.attributes.items():
-            if getattr(other, k) != v and k != 'auth':
+            if (getattr(other, k) != v and k not in
+                set(['auth', 'pre_run', 'post_run'])):
                 setattr(self, k, getattr(other, k))
         # Inherit auth only if explicitly allowed
         if other.auth and 'inherit' in other.auth and other.auth['inherit']:
             setattr(self, 'auth', getattr(other, 'auth'))
+        # Pre and post run are lists; make a copy
+        self.pre_run = other.pre_run + self.pre_run
+        self.post_run = self.post_run + other.post_run
 
     def changeMatches(self, change):
         if self.branch_matcher and not self.branch_matcher.matches(change):
@@ -1816,15 +1864,16 @@
                                 "a single key (when parsing %s)" %
                                 (conf,))
             key, value = item.items()[0]
-            if key == 'project':
-                self.projects.append(value)
-            elif key == 'job':
+            if key in ['project', 'project-template', 'job']:
                 if source_project is not None:
                     value['_source_project'] = source_project
                 if source_branch is not None:
                     value['_source_branch'] = source_branch
                 if source_configrepo is not None:
                     value['_source_configrepo'] = source_configrepo
+            if key == 'project':
+                self.projects.append(value)
+            elif key == 'job':
                 self.jobs.append(value)
             elif key == 'project-template':
                 self.project_templates.append(value)