diff --git a/doc/source/reference/jobs.rst b/doc/source/reference/jobs.rst index eb33c093c3..cc49bbb2ee 100644 --- a/doc/source/reference/jobs.rst +++ b/doc/source/reference/jobs.rst @@ -405,6 +405,20 @@ of item. This may be influenced by the branch or tag associated with the item as well as the job configuration. + .. var:: checkout_description + + A human-readable description of why Zuul chose this + particular branch or tag to be checked out. This is intended + as a debugging aid in the case of complex jobs. The specific + text is not defined and is subject to change. + + .. var:: commit + + The hex SHA of the commit checked out. This commit may + appear in the upstream repository, or if it the result of a + speculative merge, it may only exist during the run of this + job. + For example, to access the source directory of a single known project, you might use:: @@ -418,6 +432,95 @@ of item. msg: "Project {{ item.name }} is at {{ item.src_dir }} with_items: {{ zuul.projects.values() | list }} + .. var:: playbook_context + :type: dict + + This dictionary contains information about the execution of each + playbook in the job. This may be useful for understanding + exactly what playbooks and roles Zuul executed. + + All paths herein are located under the root of the build + directory (note that is one level higher than the workspace + directory accessible to jobs on the executor). + + .. var:: playbook_projects + :type: dict + + A dictionary of projects that have been checked out for + playbook execution. When used in the trusted execution + context, these will contain only merged commits in upstream + repositories. In the case of the untrusted context, they may + contain speculatively merged code. + + The key is the path and each value is another dictionary with + the following keys: + + .. var:: canonical_name + + The canonical name of the repository. + + .. var:: checkout + + The branch or tag checked out. + + .. var:: commit + + The hex SHA of the commit checked out. As above, this + commit may or may not exist in the upstream repository + depending on whether it was the result of a speculative + merge. + + .. var:: playbooks + :type: list + + An ordered list of playbooks executed for the job. Each item + is a dictionary with the following keys: + + .. var:: path + + The path to the playbook. + + .. var:: roles + :type: list + + Information about the roles available to the playbook. + The actual `role path` supplied to Ansible is the + concatenation of the ``role_path`` entry in each of the + following dictionaries. The rest of the information + describes what is in the role path. + + In order to deal with the many possible role layouts and + aliases, each element in the role path gets its own + directory. Depending on the contents and alias + configuration for that role repo, a symlink is added to + one of the repo checkouts in + :var:`zuul.playbook_context.playbook_projects` so that the + role may be supplied to Ansible with the correct name. + + .. var:: checkout + + The branch or tag checked out. + + .. var:: checkout_description + + A human-readable description of why Zuul chose this + particular branch or tag to be checked out. This is + intended as a debugging aid in the case of complex + jobs. The specific text is not defined and is subject + to change. + + .. var:: link_name + + The name of the symbolic link. + + .. var:: link_target + + The target of the symbolic_link. + + .. var:: role_path + + The role path passed to Ansible. + .. var:: tenant The name of the current Zuul tenant. diff --git a/releasenotes/notes/audit-vars-4dcb0d3c7bf87ef2.yaml b/releasenotes/notes/audit-vars-4dcb0d3c7bf87ef2.yaml new file mode 100644 index 0000000000..44e7c4968a --- /dev/null +++ b/releasenotes/notes/audit-vars-4dcb0d3c7bf87ef2.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + The new variable :var:`zuul.playbook_context` as well as new + variables under :var:`zuul.projects` have been added to help debug + or audit the playbooks and roles used by Ansible when running + jobs. + + These variables describe the repo configuration used for the + playbooks and roles of each Ansible execution. These repos may + have a different state than the workspace repos. diff --git a/tests/unit/test_v3.py b/tests/unit/test_v3.py index 2aa0b2aa63..2c3f8a8084 100644 --- a/tests/unit/test_v3.py +++ b/tests/unit/test_v3.py @@ -29,6 +29,7 @@ import git import paramiko import zuul.configloader +from zuul.lib import yamlutil as yaml from zuul.model import MergeRequest from tests.base import ( @@ -4425,10 +4426,76 @@ class TestRoleBranches(RoleTestCase): self._assertInFile(build.jobdir.pre_playbooks[1].path, 'parent-stable-role') + inventory = self.getBuildInventory('child-job-override') + zuul = inventory['all']['vars']['zuul'] + + expected = { + 'playbook_projects': { + 'trusted/project_0/review.example.com/common-config': { + 'canonical_name': 'review.example.com/common-config', + 'checkout': 'master', + 'commit': self.getCheckout( + build, + 'trusted/project_0/review.example.com/common-config')}, + 'untrusted/project_0/review.example.com/project1': { + 'canonical_name': 'review.example.com/project1', + 'checkout': 'stable', + 'commit': self.getCheckout( + build, + 'untrusted/project_0/review.example.com/project1')}, + 'untrusted/project_1/review.example.com/common-config': { + 'canonical_name': 'review.example.com/common-config', + 'checkout': 'master', + 'commit': self.getCheckout( + build, + 'untrusted/project_1/review.example.com/common-config' + )}, + 'untrusted/project_2/review.example.com/project2': { + 'canonical_name': 'review.example.com/project2', + 'checkout': 'master', + 'commit': self.getCheckout( + build, + 'untrusted/project_2/review.example.com/project2')}}, + 'playbooks': [ + {'path': 'untrusted/project_2/review.example.com/' + 'project2/playbooks/child-job.yaml', + 'roles': [ + {'checkout': 'stable', + 'checkout_description': 'job override ref', + 'link_name': 'ansible/playbook_0/role_1/project1', + 'link_target': 'untrusted/project_0/' + 'review.example.com/project1', + 'role_path': 'ansible/playbook_0/role_1/project1/roles' + }, + {'checkout': 'master', + 'checkout_description': 'zuul branch', + 'link_name': 'ansible/playbook_0/role_2/common-config', + 'link_target': 'untrusted/project_1/' + 'review.example.com/common-config', + 'role_path': 'ansible/playbook_0/role_2/' + 'common-config/roles' + } + ]} + ] + } + + self.assertEqual(expected, zuul['playbook_context']) + self.executor_server.hold_jobs_in_build = False self.executor_server.release() self.waitUntilSettled() + def getBuildInventory(self, name): + build = self.getBuildByName(name) + inv_path = os.path.join(build.jobdir.root, 'ansible', 'inventory.yaml') + inventory = yaml.safe_load(open(inv_path, 'r')) + return inventory + + def getCheckout(self, build, path): + root = os.path.join(build.jobdir.root, path) + repo = git.Repo(root) + return repo.head.commit.hexsha + class TestRoles(RoleTestCase): tenant_config_file = 'config/roles/main.yaml' diff --git a/zuul/executor/server.py b/zuul/executor/server.py index 127274bf26..95b3f838db 100644 --- a/zuul/executor/server.py +++ b/zuul/executor/server.py @@ -417,6 +417,30 @@ class KubeFwd(object): pass +class JobDirPlaybookRole(object): + def __init__(self, root): + self.root = root + self.link_src = None + self.link_target = None + self.role_path = None + self.checkout_description = None + self.checkout = None + + def toDict(self, jobdir_root=None): + # This is serialized to the zuul.playbook_context variable + if jobdir_root: + strip = len(jobdir_root) + 1 + else: + strip = 0 + return dict( + link_name=self.link_name[strip:], + link_target=self.link_target[strip:], + role_path=self.role_path[strip:], + checkout_description=self.checkout_description, + checkout=self.checkout, + ) + + class JobDirPlaybook(object): def __init__(self, root): self.root = root @@ -440,8 +464,25 @@ class JobDirPlaybook(object): count = len(self.roles) root = os.path.join(self.root, 'role_%i' % (count,)) os.makedirs(root) - self.roles.append(root) - return root + role_info = JobDirPlaybookRole(root) + self.roles.append(role_info) + return role_info + + +class JobDirProject(object): + def __init__(self, root): + self.root = root + self.canonical_name = None + self.checkout = None + self.commit = None + + def toDict(self): + # This is serialized to the zuul.playbook_context variable + return dict( + canonical_name=self.canonical_name, + checkout=self.checkout, + commit=self.commit, + ) class JobDir(object): @@ -589,10 +630,8 @@ class JobDir(object): job_output.write("{now} | Job console starting...\n".format( now=datetime.datetime.now() )) - self.trusted_projects = [] - self.trusted_project_index = {} - self.untrusted_projects = [] - self.untrusted_project_index = {} + self.trusted_projects = {} + self.untrusted_projects = {} # Create a JobDirPlaybook for the Ansible setup run. This # doesn't use an actual playbook, but it lets us use the same @@ -618,12 +657,14 @@ class JobDir(object): count = len(self.trusted_projects) root = os.path.join(self.trusted_root, 'project_%i' % (count,)) os.makedirs(root) - self.trusted_projects.append(root) - self.trusted_project_index[(canonical_name, branch)] = root - return root + project_info = JobDirProject(root) + project_info.canonical_name = canonical_name + project_info.checkout = branch + self.trusted_projects[(canonical_name, branch)] = project_info + return project_info def getTrustedProject(self, canonical_name, branch): - return self.trusted_project_index.get((canonical_name, branch)) + return self.trusted_projects.get((canonical_name, branch)) def addUntrustedProject(self, canonical_name, branch): # Similar to trusted projects, but these hold checkouts of @@ -635,12 +676,14 @@ class JobDir(object): count = len(self.untrusted_projects) root = os.path.join(self.untrusted_root, 'project_%i' % (count,)) os.makedirs(root) - self.untrusted_projects.append(root) - self.untrusted_project_index[(canonical_name, branch)] = root - return root + project_info = JobDirProject(root) + project_info.canonical_name = canonical_name + project_info.checkout = branch + self.untrusted_projects[(canonical_name, branch)] = project_info + return project_info def getUntrustedProject(self, canonical_name, branch): - return self.untrusted_project_index.get((canonical_name, branch)) + return self.untrusted_projects.get((canonical_name, branch)) def addPrePlaybook(self): count = len(self.pre_playbooks) @@ -1331,12 +1374,14 @@ class AnsibleJob(object): self.log.info("Checking out %s %s %s", project['canonical_name'], selected_desc, selected_ref) - repo.checkout(selected_ref) + commit = repo.checkout(selected_ref) # Update the inventory variables to indicate the ref we # checked out p = args['zuul']['projects'][project['canonical_name']] p['checkout'] = selected_ref + p['checkout_description'] = selected_desc + p['commit'] = commit.hexsha # Set the URL of the origin remote for each repo to a bogus # value. Keeping the remote allows tools to use it to determine @@ -2024,43 +2069,44 @@ class AnsibleJob(object): return ret def checkoutTrustedProject(self, project, branch, args): - root = self.jobdir.getTrustedProject(project.canonical_name, - branch) - if not root: - root = self.jobdir.addTrustedProject(project.canonical_name, - branch) + pi = self.jobdir.getTrustedProject(project.canonical_name, + branch) + if not pi: + pi = self.jobdir.addTrustedProject(project.canonical_name, + branch) self.log.debug("Cloning %s@%s into new trusted space %s", - project, branch, root) + project, branch, pi.root) # We always use the golang scheme for playbook checkouts # (so that the path indicates the canonical repo name for # easy debugging; there are no concerns with collisions # since we only have one repo in the working dir). merger = self.executor_server._getMerger( - root, + pi.root, self.executor_server.merge_root, logger=self.log, scheme=zuul.model.SCHEME_GOLANG) - merger.checkoutBranch( + commit = merger.checkoutBranch( project.connection_name, project.name, branch, repo_state=args['repo_state'], process_worker=self.executor_server.process_worker, zuul_event_id=self.zuul_event_id) + pi.commit = commit.hexsha else: self.log.debug("Using existing repo %s@%s in trusted space %s", - project, branch, root) + project, branch, pi.root) - path = os.path.join(root, + path = os.path.join(pi.root, project.canonical_hostname, project.name) return path def checkoutUntrustedProject(self, project, branch, args): - root = self.jobdir.getUntrustedProject(project.canonical_name, - branch) - if not root: - root = self.jobdir.addUntrustedProject(project.canonical_name, - branch) + pi = self.jobdir.getUntrustedProject(project.canonical_name, + branch) + if not pi: + pi = self.jobdir.addUntrustedProject(project.canonical_name, + branch) # If the project is in the dependency chain, clone from # there so we pick up any speculative changes, otherwise, # clone from the cache. @@ -2076,7 +2122,7 @@ class AnsibleJob(object): # We already have this repo prepared self.log.debug("Found workdir repo for untrusted project") merger = self.executor_server._getMerger( - root, + pi.root, self.jobdir.src_root, logger=self.log, scheme=zuul.model.SCHEME_GOLANG, @@ -2086,7 +2132,7 @@ class AnsibleJob(object): repo_state = None if merger is None: merger = self.executor_server._getMerger( - root, + pi.root, self.executor_server.merge_root, logger=self.log, scheme=zuul.model.SCHEME_GOLANG) @@ -2097,17 +2143,18 @@ class AnsibleJob(object): repo_state = args['repo_state'] self.log.debug("Cloning %s@%s into new untrusted space %s", - project, branch, root) - merger.checkoutBranch( + project, branch, pi.root) + commit = merger.checkoutBranch( project.connection_name, project.name, branch, repo_state=repo_state, process_worker=self.executor_server.process_worker, zuul_event_id=self.zuul_event_id) + pi.commit = commit.hexsha else: self.log.debug("Using existing repo %s@%s in trusted space %s", - project, branch, root) + project, branch, pi.root) - path = os.path.join(root, + path = os.path.join(pi.root, project.canonical_hostname, project.name) return path @@ -2149,8 +2196,8 @@ class AnsibleJob(object): def prepareRole(self, jobdir_playbook, role, args): if role['type'] == 'zuul': - root = jobdir_playbook.addRole() - self.prepareZuulRole(jobdir_playbook, role, args, root) + role_info = jobdir_playbook.addRole() + self.prepareZuulRole(jobdir_playbook, role, args, role_info) def findRole(self, path, trusted=False): d = os.path.join(path, 'tasks') @@ -2173,7 +2220,7 @@ class AnsibleJob(object): # It is neither a bare role, nor a collection of roles raise RoleNotFoundError("Unable to find role in %s" % (path,)) - def prepareZuulRole(self, jobdir_playbook, role, args, root): + def prepareZuulRole(self, jobdir_playbook, role, args, role_info): self.log.debug("Prepare zuul role for %s" % (role,)) # Check out the role repo if needed source = self.executor_server.connections.getSource( @@ -2190,6 +2237,8 @@ class AnsibleJob(object): branch = jobdir_playbook.branch self.log.debug("Role project is playbook project, " "using playbook branch %s", branch) + role_info.checkout_description = 'playbook branch' + role_info.checkout = branch else: # Find if the project is one of the job-specified projects. # If it is, we can honor the project checkout-override options. @@ -2209,6 +2258,8 @@ class AnsibleJob(object): args_project.get('override_checkout'), role['project_default_branch']) self.log.debug("Role using %s %s", selected_desc, branch) + role_info.checkout_description = selected_desc + role_info.checkout = branch if not jobdir_playbook.trusted: path = self.checkoutUntrustedProject(project, branch, args) @@ -2218,12 +2269,14 @@ class AnsibleJob(object): # The name of the symlink is the requested name of the role # (which may be the repo name or may be something else; this # can come into play if this is a bare role). - link = os.path.join(root, name) + link = os.path.join(role_info.root, name) link = os.path.realpath(link) - if not link.startswith(os.path.realpath(root)): + if not link.startswith(os.path.realpath(role_info.root)): raise ExecutorError("Invalid role name %s" % name) os.symlink(path, link) + role_info.link_name = link + role_info.link_target = path try: role_path = self.findRole(link, trusted=jobdir_playbook.trusted) except RoleNotFoundError: @@ -2239,7 +2292,8 @@ class AnsibleJob(object): raise if role_path is None: # In the case of a bare role, add the containing directory - role_path = root + role_path = role_info.root + role_info.role_path = role_path self.log.debug("Adding role path %s", role_path) jobdir_playbook.roles_path.append(role_path) @@ -2387,6 +2441,27 @@ class AnsibleJob(object): result_data_file=self.jobdir.result_data_file, inventory_file=self.jobdir.inventory) + # Add playbook_context info + zuul_vars['playbook_context'] = dict( + playbook_projects={}, + playbooks=[], + ) + strip = len(self.jobdir.root) + 1 + for pi in self.jobdir.trusted_projects.values(): + root = os.path.join(pi.root[strip:], pi.canonical_name) + zuul_vars['playbook_context']['playbook_projects'][ + root] = pi.toDict() + for pi in self.jobdir.untrusted_projects.values(): + root = os.path.join(pi.root[strip:], pi.canonical_name) + zuul_vars['playbook_context']['playbook_projects'][ + root] = pi.toDict() + for pb in self.jobdir.playbooks: + zuul_vars['playbook_context']['playbooks'].append(dict( + path=pb.path[strip:], + roles=[ri.toDict(self.jobdir.root) for ri in pb.roles + if ri.role_path is not None], + )) + with open(self.jobdir.zuul_vars, 'w') as zuul_vars_yaml: zuul_vars_yaml.write( yaml.safe_dump({'zuul': zuul_vars}, default_flow_style=False)) diff --git a/zuul/merger/merger.py b/zuul/merger/merger.py index 8f5a61aa2f..ad3a4f9501 100644 --- a/zuul/merger/merger.py +++ b/zuul/merger/merger.py @@ -930,7 +930,7 @@ class Merger(object): self._restoreRepoState(connection_name, project_name, repo, repo_state, zuul_event_id, process_worker=process_worker) - repo.checkout(branch, zuul_event_id=zuul_event_id) + return repo.checkout(branch, zuul_event_id=zuul_event_id) def _saveRepoState(self, connection_name, project_name, repo, repo_state, recent, branches):