diff --git a/items/git_deploy.py b/items/git_deploy.py new file mode 100644 index 0000000..020009c --- /dev/null +++ b/items/git_deploy.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from atexit import register as at_exit +from os import remove, setpgrp +from os.path import isfile, join +from pipes import quote +from shutil import rmtree +from subprocess import PIPE, Popen +from tempfile import mkdtemp, NamedTemporaryFile + +from bundlewrap.exceptions import RepositoryError +from bundlewrap.items import Item +from bundlewrap.utils import cached_property +from bundlewrap.utils.text import mark_for_translation as _, randstr +from bundlewrap.utils.ui import io + + +REPO_MAP_FILENAME = "git_deploy_repos" +REMOTE_STATE_FILENAME = ".bundlewrap_git_deploy" + + +def is_ref(rev): + """ + Braindead check to see if our rev is a branch or tag name. False + negatives are OK since this is only used for optimization. + """ + for char in rev: + if char not in "0123456789abcdef": + return True + return False + + +def clone_to_dir(remote_url, rev): + """ + Clones the given URL to a temporary directory, using a shallow clone + if the given revision is definitely not a commit hash. + + Returns the path to the directory. + """ + tmpdir = mkdtemp() + if is_ref(rev): + git_cmdline = ["clone", "--bare", "--depth", "1", "--no-single-branch", remote_url, "."] + else: + git_cmdline = ["clone", "--bare", remote_url, "."] + git_command(git_cmdline, tmpdir) + return tmpdir + + +def get_local_repo_path(bw_repo_path, repo_name): + """ + From the given BundleWrap repo, get the filesystem path to the git + repo associated with the given internal repo name. + """ + repo_map_path = join(bw_repo_path, REPO_MAP_FILENAME) + if not isfile(repo_map_path): + io.stderr(_("missing repo map for git_deploy at {}").format(repo_map_path)) + io.stderr(_("you must create this file with the following format:")) + io.stderr(_(" : " + "")) + io.stderr(_("since the path is local, you should also add the " + "{} file to your gitignore").format(REPO_MAP_FILENAME)) + raise RepositoryError(_("missing repo map for git_deploy")) + + with open(join(bw_repo_path, REPO_MAP_FILENAME)) as f: + repo_map = f.readlines() + + for line in repo_map: + if not line.strip() or line.startswith("#"): + continue + try: + repo, path = line.split(":", 1) + except: + raise RepositoryError(_("unable to parse line from {path}: '{line}'").format( + line=line, + path=repo_map_path, + )) + if repo_name == repo: + return path.strip() + + raise RepositoryError(_("no path found for repo '{repo}' in {path}").format( + path=repo_map_path, + repo=repo_name, + )) + + +def git_command(cmdline, repo_dir): + """ + Runs the given git command line in the given directory. + + Returns stdout of the command. + """ + cmdline = ["git"] + cmdline + io.debug(_("running '{}' in {}").format( + " ".join(cmdline), + repo_dir, + )) + git_process = Popen( + cmdline, + cwd=repo_dir, + preexec_fn=setpgrp, + stderr=PIPE, + stdout=PIPE, + ) + stdout, stderr = git_process.communicate() + if git_process.returncode != 0: + io.stderr(_("failed command: {}").format(" ".join(cmdline))) + io.stderr(_("stdout:\n{}").format(stdout)) + io.stderr(_("stderr:\n{}").format(stderr)) + raise RuntimeError(_("`git {command}` failed in {dir}").format( + command=cmdline[1], + dir=repo_dir, + )) + return stdout.decode('utf-8').strip() + + +class GitDeploy(Item): + """ + Facilitates deployment of a given rev from a local git repo to a + node. + """ + BUNDLE_ATTRIBUTE_NAME = "git_deploy" + ITEM_ATTRIBUTES = { + 'repo': None, + 'rev': None, + 'use_xattrs': False, + } + ITEM_TYPE_NAME = "git_deploy" + REQUIRED_ATTRIBUTES = ['repo', 'rev'] + + def __repr__(self): + return "".format( + self.name, + self.attributes['repo'], + self.attributes['rev'], + ) + + @cached_property + def _expanded_rev(self): + git_cmdline = ["rev-parse", self.attributes['rev']] + return git_command( + git_cmdline, + self._repo_dir, + ) + + @cached_property + def _repo_dir(self): + if "://" in self.attributes['repo']: + repo_dir = clone_to_dir(self.attributes['repo'], self.attributes['rev']) + io.debug(_("registering {} for deletion on exit").format(repo_dir)) + at_exit(rmtree, repo_dir) + else: + repo_dir = get_local_repo_path(self.node.repo.path, self.attributes['repo']) + return repo_dir + + def cdict(self): + return {'rev': self._expanded_rev} + + def fix(self, status): + archive_local = NamedTemporaryFile(delete=False) + try: + archive_local.close() + git_command( + ["archive", "-o", archive_local.name, self._expanded_rev], + self._repo_dir, + ) + temp_filename = ".bundlewrap_tmp_git_deploy_" + randstr() + + try: + self.node.upload( + archive_local.name, + temp_filename, + ) + self.node.run("find {} -mindepth 1 -delete".format(quote(self.name))) + self.node.run("tar -xf {} -C {}".format(temp_filename, quote(self.name))) + if self.attributes['use_xattrs']: + self.node.run("attr -q -s bw_git_deploy_rev -V {} {}".format( + self._expanded_rev, + quote(self.name), + )) + else: + self.node.run("echo {} > {}".format( + self._expanded_rev, + quote(join(self.name, REMOTE_STATE_FILENAME)), + )) + self.node.run("chmod 400 {}".format( + quote(join(self.name, REMOTE_STATE_FILENAME)), + )) + finally: + self.node.run("rm -f {}".format(temp_filename)) + finally: + remove(archive_local.name) + + def sdict(self): + if self.attributes['use_xattrs']: + status_result = self.node.run( + "attr -q -g bw_git_deploy_rev {}".format(quote(self.name)), + may_fail=True, + ) + else: + status_result = self.node.run( + "cat {}".format(quote(join(self.name, REMOTE_STATE_FILENAME))), + may_fail=True, + ) + if status_result.return_code != 0: + return None + else: + return {'rev': status_result.stdout.decode('utf-8').strip()} diff --git a/plugins.json b/plugins.json new file mode 100644 index 0000000..39e6bc0 --- /dev/null +++ b/plugins.json @@ -0,0 +1,8 @@ +{ + "item_git_deploy": { + "files": { + "items/git_deploy.py": "111c81fe88c3e97168251fccf59e1aeb36af3d02" + }, + "version": 6 + } +}