diff options
author | Oliver Poignant <oliver@poignant.se> | 2016-03-13 23:07:50 +0100 |
---|---|---|
committer | Oliver Poignant <oliver@poignant.se> | 2016-03-13 23:07:50 +0100 |
commit | 2465cfc4a3669282523daf168e8c408880f3dd4d (patch) | |
tree | 18abeadf13559334abe17044f80dea3fa6f3ccbe | |
parent | 5a8caf8b9c9f4f283dffac33b461b5c43ad64eed (diff) | |
parent | 86ff375bf4c4b6c04ab44ea4be7dd90730f824c1 (diff) | |
download | Git-Auto-Deploy-2465cfc4a3669282523daf168e8c408880f3dd4d.zip Git-Auto-Deploy-2465cfc4a3669282523daf168e8c408880f3dd4d.tar.gz Git-Auto-Deploy-2465cfc4a3669282523daf168e8c408880f3dd4d.tar.bz2 |
Merge pull request #77 from olipo186/developmentv0.2.0
Development
43 files changed, 1718 insertions, 1030 deletions
@@ -64,3 +64,8 @@ GitAutoDeploy.conf.json .DS_Store ._.DS_Store .fuse* + +# Ignore +deb_dist +*.tar.gz +MANIFEST diff --git a/GitAutoDeploy.py b/GitAutoDeploy.py index 6013ec0..7d9ce7c 100755..100644 --- a/GitAutoDeploy.py +++ b/GitAutoDeploy.py @@ -1,963 +1,8 @@ #!/usr/bin/env python -from BaseHTTPServer import BaseHTTPRequestHandler - - -class LogInterface(object): - """Interface that functions as a stdout and stderr handler and directs the - output to the logging module, which in turn will output to either console, - file or both.""" - - def __init__(self, level=None): - import logging - self.level = (level if level else logging.getLogger().info) - - def write(self, msg): - for line in msg.strip().split("\n"): - self.level(line) - - -class Lock(): - """Simple implementation of a mutex lock using the file systems. Works on - *nix systems.""" - - path = None - _has_lock = False - - def __init__(self, path): - self.path = path - - def obtain(self): - import os - import logging - logger = logging.getLogger() - - try: - os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) - self._has_lock = True - logger.info("Successfully obtained lock: %s" % self.path) - except OSError: - return False - else: - return True - - def release(self): - import os - import logging - logger = logging.getLogger() - - if not self._has_lock: - raise Exception("Unable to release lock that is owned by another process") - try: - os.remove(self.path) - logger.info("Successfully released lock: %s" % self.path) - finally: - self._has_lock = False - - def has_lock(self): - return self._has_lock - - def clear(self): - import os - import logging - logger = logging.getLogger() - - try: - os.remove(self.path) - except OSError: - pass - finally: - logger.info("Successfully cleared lock: %s" % self.path) - self._has_lock = False - - -class ProcessWrapper(): - """Wraps the subprocess popen method and provides logging.""" - - def __init__(self): - pass - - @staticmethod - def call(*popenargs, **kwargs): - """Run command with arguments. Wait for command to complete. Sends - output to logging module. The arguments are the same as for the Popen - constructor.""" - - from subprocess import Popen, PIPE - import logging - logger = logging.getLogger() - - kwargs['stdout'] = PIPE - kwargs['stderr'] = PIPE - - p = Popen(*popenargs, **kwargs) - stdout, stderr = p.communicate() - - if stdout: - for line in stdout.strip().split("\n"): - logger.info(line) - - if stderr: - for line in stderr.strip().split("\n"): - logger.error(line) - - return p.returncode - - -class GitWrapper(): - """Wraps the git client. Currently uses git through shell command - invocations.""" - - def __init__(self): - pass - - @staticmethod - def pull(repo_config): - """Pulls the latest version of the repo from the git server""" - import logging - - logger = logging.getLogger() - logger.info("Post push request received") - - # Only pull if there is actually a local copy of the repository - if 'path' not in repo_config: - logger.info('No local repository path configured, no pull will occure') - return 0 - - logger.info('Updating ' + repo_config['path']) - -# cmd = 'cd "' + repo_config['path'] + '"' \ - cmd = 'unset GIT_DIR ' + \ - '&& git fetch ' + repo_config['remote'] + \ - '&& git reset --hard ' + repo_config['remote'] + '/' + repo_config['branch'] + ' ' + \ - '&& git submodule init ' + \ - '&& git submodule update' - - # '&& git update-index --refresh ' +\ - res = ProcessWrapper().call([cmd], cwd=repo_config['path'], shell=True) - logger.info('Pull result: ' + str(res)) - - return int(res) - - @staticmethod - def clone(url, branch, path): - ProcessWrapper().call(['git clone --recursive ' + url + ' -b ' + branch + ' ' + path], shell=True) - - @staticmethod - def deploy(repo_config): - """Executes any supplied post-pull deploy command""" - from subprocess import call - import logging - logger = logging.getLogger() - - if 'path' in repo_config: - path = repo_config['path'] - - logger.info('Executing deploy command(s)') - - # Use repository path as default cwd when executing deploy commands - cwd = (repo_config['path'] if 'path' in repo_config else None) - - for cmd in repo_config['deploy_commands']: - ProcessWrapper().call([cmd], cwd=cwd, shell=True) - - -class WebhookRequestHandler(BaseHTTPRequestHandler): - """Extends the BaseHTTPRequestHandler class and handles the incoming - HTTP requests.""" - - def do_POST(self): - """Invoked on incoming POST requests""" - from threading import Timer - - # Extract repository URL(s) from incoming request body - repo_urls, ref, action, data = self.get_repo_params_from_request() - self.send_response(200) - self.send_header('Content-type', 'text/plain') - self.end_headers() - - # Wait one second before we do git pull (why?) - Timer(1.0, GitAutoDeploy.process_repo_urls, (repo_urls, - ref, - action, - data)).start() - - def log_message(self, format, *args): - """Overloads the default message logging method to allow messages to - go through our custom logger instead.""" - import logging - logger = logging.getLogger() - logger.info("%s - - [%s] %s\n" % (self.client_address[0], - self.log_date_time_string(), - format%args)) - - def get_repo_params_from_request(self): - """Parses the incoming request and extracts all possible URLs to the - repository in question. Since repos can have both ssh://, git:// and - https:// URIs, and we don't know which of them is specified in the - config, we need to collect and compare them all.""" - import json - import logging - - logger = logging.getLogger() - - content_type = self.headers.getheader('content-type') - length = int(self.headers.getheader('content-length')) - body = self.rfile.read(length) - - data = json.loads(body) - - repo_urls = [] - ref = "" - action = "" - - gitlab_event = self.headers.getheader('X-Gitlab-Event') - github_event = self.headers.getheader('X-GitHub-Event') - user_agent = self.headers.getheader('User-Agent') - - # Assume GitLab if the X-Gitlab-Event HTTP header is set - if gitlab_event: - - logger.info("Received '%s' event from GitLab" % gitlab_event) - - if 'repository' not in data: - logger.error("ERROR - Unable to recognize data format") - return repo_urls, ref or "master", action - - # One repository may posses multiple URLs for different protocols - for k in ['url', 'git_http_url', 'git_ssh_url']: - if k in data['repository']: - repo_urls.append(data['repository'][k]) - - # extract the branch - if 'ref' in data: - ref = data['ref'] - - # set the action - if 'object_kind' in data: - action = data['object_kind'] - - # Assume GitHub if the X-GitHub-Event HTTP header is set - elif github_event: - - logger.info("Received '%s' event from GitHub" % github_event) - - if 'repository' not in data: - logger.error("ERROR - Unable to recognize data format") - return repo_urls, ref or "master", action - - # One repository may posses multiple URLs for different protocols - for k in ['url', 'git_url', 'clone_url', 'ssh_url']: - if k in data['repository']: - repo_urls.append(data['repository'][k]) - - if 'pull_request' in data: - if 'base' in data['pull_request']: - if 'ref' in data['pull_request']['base']: - ref = data['pull_request']['base']['ref'] - logger.info("Pull request to branch '%s' was fired" % ref) - elif 'ref' in data: - ref = data['ref'] - logger.info("Push to branch '%s' was fired" % ref) - - if 'action' in data: - action = data['action'] - logger.info("Action '%s' was fired" % action) - - # Assume BitBucket if the User-Agent HTTP header is set to 'Bitbucket-Webhooks/2.0' (or something similar) - elif user_agent and user_agent.lower().find('bitbucket') != -1: - - logger.info("Received event from BitBucket") - - if 'repository' not in data: - logger.error("ERROR - Unable to recognize data format") - return repo_urls, ref or "master", action - - # One repository may posses multiple URLs for different protocols - for k in ['url', 'git_url', 'clone_url', 'ssh_url']: - if k in data['repository']: - repo_urls.append(data['repository'][k]) - - if 'full_name' in data['repository']: - repo_urls.append('git@bitbucket.org:%s.git' % data['repository']['full_name']) - - # Add a simplified version of the bitbucket HTTPS URL - without the username@bitbucket.com part. This is - # needed since the configured repositories might be configured using a different username. - repo_urls.append('https://bitbucket.org/%s.git' % (data['repository']['full_name'])) - - # Special Case for Gitlab CI - elif content_type == "application/json" and "build_status" in data: - - logger.info('Received event from Gitlab CI') - - if 'push_data' not in data: - logger.error("ERROR - Unable to recognize data format") - return repo_urls, ref or "master", action - - # Only add repositories if the build is successful. Ignore it in other case. - if data['build_status'] == "success": - for k in ['url', 'git_http_url', 'git_ssh_url']: - if k in data['push_data']['repository']: - repo_urls.append(data['push_data']['repository'][k]) - else: - logger.warning("Gitlab CI build '%d' has status '%s'. Not pull will be done" % ( - data['build_id'], data['build_status'])) - - # Try to find the repository urls and add them as long as the content type is set to JSON at least. - # This handles old GitLab requests and Gogs requests for example. - elif content_type == "application/json": - - logger.info("Received event from unknown origin. Assume generic data format.") - - if 'repository' not in data: - logger.error("ERROR - Unable to recognize data format") - return repo_urls, ref or "master", action - - # One repository may posses multiple URLs for different protocols - for k in ['url', 'git_http_url', 'git_ssh_url', 'http_url', 'ssh_url']: - if k in data['repository']: - repo_urls.append(data['repository'][k]) - else: - logger.error("ERROR - Unable to recognize request origin. Don't know how to handle the request.") - - logger.info("Event details - ref: %s; action: %s" % (ref or "master", action)) - return repo_urls, ref or "master", action, data - - -# Used to describe when a filter does not match a request -class FilterMatchError(Exception): pass - - -class GitAutoDeploy(object): - _instance = None - _server = None - _config = None - - def __new__(cls, *args, **kwargs): - """Overload constructor to enable Singleton access""" - if not cls._instance: - cls._instance = super(GitAutoDeploy, cls).__new__( - cls, *args, **kwargs) - return cls._instance - - @staticmethod - def debug_diagnosis(port): - import logging - logger = logging.getLogger() - - pid = GitAutoDeploy.get_pid_on_port(port) - if pid is False: - logger.warning('I don\'t know the number of pid that is using my configured port') - return - - logger.info('Process with pid number %s is using port %s' % (pid, port)) - with open("/proc/%s/cmdline" % pid) as f: - cmdline = f.readlines() - logger.info('cmdline ->', cmdline[0].replace('\x00', ' ')) - - @staticmethod - def get_pid_on_port(port): - import os - - with open("/proc/net/tcp", 'r') as f: - file_content = f.readlines()[1:] - - pids = [int(x) for x in os.listdir('/proc') if x.isdigit()] - conf_port = str(port) - mpid = False - - for line in file_content: - if mpid is not False: - break - - _, laddr, _, _, _, _, _, _, _, inode = line.split()[:10] - decport = str(int(laddr.split(':')[1], 16)) - - if decport != conf_port: - continue - - for pid in pids: - try: - path = "/proc/%s/fd" % pid - if os.access(path, os.R_OK) is False: - continue - - for fd in os.listdir(path): - cinode = os.readlink("/proc/%s/fd/%s" % (pid, fd)) - minode = cinode.split(":") - - if len(minode) == 2 and minode[1][1:-1] == inode: - mpid = pid - - except Exception as e: - pass - - return mpid - - @staticmethod - def process_repo_urls(urls, ref, action, data): - import os - import time - import logging - logger = logging.getLogger() - - # Get a list of configured repositories that matches the incoming web hook reqeust - repo_configs = GitAutoDeploy().get_matching_repo_configs(urls) - - if len(repo_configs) == 0: - logger.warning('Unable to find any of the repository URLs in the config: %s' % ', '.join(urls)) - return - - # Process each matching repository - for repo_config in repo_configs: - - try: - # Verify that all filters matches the request (if any filters are specified) - if 'filters' in repo_config: - - # at least one filter must match - for filter in repo_config['filters']: - - # all options specified in the filter must match - for filter_key, filter_value in filter.iteritems(): - - # support for earlier version so it's non-breaking functionality - if filter_key == 'action' and filter_value == action: - continue - - if filter_key not in data or filter_value != data[filter_key]: - raise FilterMatchError() - - except FilterMatchError as e: - - # Filter does not match, do not process this repo config - continue - - # In case there is no path configured for the repository, no pull will - # be made. - if not 'path' in repo_config: - GitWrapper.deploy(repo_config) - continue - - running_lock = Lock(os.path.join(repo_config['path'], 'status_running')) - waiting_lock = Lock(os.path.join(repo_config['path'], 'status_waiting')) - try: - - # Attempt to obtain the status_running lock - while not running_lock.obtain(): - - # If we're unable, try once to obtain the status_waiting lock - if not waiting_lock.has_lock() and not waiting_lock.obtain(): - logger.error("Unable to obtain the status_running lock nor the status_waiting lock. Another process is " + - "already waiting, so we'll ignore the request.") - - # If we're unable to obtain the waiting lock, ignore the request - break - - # Keep on attempting to obtain the status_running lock until we succeed - time.sleep(5) - - n = 4 - while 0 < n and 0 != GitWrapper.pull(repo_config): - n -= 1 - - if 0 < n: - GitWrapper.deploy(repo_config) - - except Exception as e: - logger.error('Error during \'pull\' or \'deploy\' operation on path: %s' % repo_config['path']) - logger.error(e) - - finally: - - # Release the lock if it's ours - if running_lock.has_lock(): - running_lock.release() - - # Release the lock if it's ours - if waiting_lock.has_lock(): - waiting_lock.release() - - def find_config_file_path(self): - """Attempt to find a config file in cwd and script path.""" - - import os - import re - import logging - logger = logging.getLogger() - - # Look for a custom config file if no path is provided as argument - target_directories = [ - os.path.dirname(os.path.realpath(__file__)), # Script path - ] - - # Add current CWD if not identical to script path - if not os.getcwd() in target_directories: - target_directories.append(os.getcwd()) - - target_directories.reverse() - - # Look for a *conf.json or *config.json - for dir in target_directories: - for item in os.listdir(dir): - if re.match(r"conf(ig)?\.json$", item): - path = os.path.realpath(os.path.join(dir, item)) - logger.info("Using '%s' as config" % path) - return path - - return './GitAutoDeploy.conf.json' - - def read_json_file(self, file_path): - import json - import logging - logger = logging.getLogger() - - try: - json_string = open(file_path).read() - - except Exception as e: - logger.critical("Could not load %s file\n" % file_path) - raise e - - try: - data = json.loads(json_string) - - except Exception as e: - logger.critical("%s file is not valid JSON\n" % file_path) - raise e - - return data - - def read_repo_config_from_environment(self, config_data): - """Look for repository config in any defined environment variables. If - found, import to main config.""" - import logging - import os - - if 'GAD_REPO_URL' not in os.environ: - return config_data - - logger = logging.getLogger() - - repo_config = { - 'url': os.environ['GAD_REPO_URL'] - } - - logger.info("Added configuration for '%s' found environment variables" % os.environ['GAD_REPO_URL']) - - if 'GAD_REPO_BRANCH' in os.environ: - repo_config['branch'] = os.environ['GAD_REPO_BRANCH'] - - if 'GAD_REPO_REMOTE' in os.environ: - repo_config['remote'] = os.environ['GAD_REPO_REMOTE'] - - if 'GAD_REPO_PATH' in os.environ: - repo_config['path'] = os.environ['GAD_REPO_PATH'] - - if 'GAD_REPO_DEPLOY' in os.environ: - repo_config['deploy'] = os.environ['GAD_REPO_DEPLOY'] - - if not 'repositories' in config_data: - config_data['repositories'] = [] - - config_data['repositories'].append(repo_config) - - return config_data - - def init_config(self, config_data): - import os - import re - import logging - logger = logging.getLogger() - - self._config = config_data - - # Translate any ~ in the path into /home/<user> - if 'pidfilepath' in self._config: - self._config['pidfilepath'] = os.path.expanduser(self._config['pidfilepath']) - - for repo_config in self._config['repositories']: - - # Setup branch if missing - if 'branch' not in repo_config: - repo_config['branch'] = "master" - - # Setup remote if missing - if 'remote' not in repo_config: - repo_config['remote'] = "origin" - - # Setup deploy commands list if not present - if 'deploy_commands' not in repo_config: - repo_config['deploy_commands'] = [] - - # Check if any global pre deploy commands is specified - if len(self._config['global_deploy'][0]) is not 0: - repo_config['deploy_commands'].insert(0, self._config['global_deploy'][0]) - - # Check if any repo specific deploy command is specified - if 'deploy' in repo_config: - repo_config['deploy_commands'].append(repo_config['deploy']) - - # Check if any global post deploy command is specified - if len(self._config['global_deploy'][1]) is not 0: - repo_config['deploy_commands'].append(self._config['global_deploy'][1]) - - # If a Bitbucket repository is configured using the https:// URL, a username is usually - # specified in the beginning of the URL. To be able to compare configured Bitbucket - # repositories with incoming web hook events, this username needs to be stripped away in a - # copy of the URL. - if 'url' in repo_config and 'bitbucket_username' not in repo_config: - regexp = re.search(r"^(https?://)([^@]+)@(bitbucket\.org/)(.+)$", repo_config['url']) - if regexp: - repo_config['url_without_usernme'] = regexp.group(1) + regexp.group(3) + regexp.group(4) - - # Translate any ~ in the path into /home/<user> - if 'path' in repo_config: - repo_config['path'] = os.path.expanduser(repo_config['path']) - - return self._config - - def clone_all_repos(self): - """Iterates over all configured repositories and clones them to their - configured paths.""" - import os - import re - import logging - logger = logging.getLogger() - - # Iterate over all configured repositories - for repo_config in self._config['repositories']: - - # Only clone repositories with a configured path - if 'path' not in repo_config: - logger.info("Repository %s will not be cloned (no path configured)" % repo_config['url']) - continue - - if os.path.isdir(repo_config['path']) and os.path.isdir(repo_config['path']+'/.git'): - logger.info("Repository %s already present" % repo_config['url']) - continue - - # Clone repository - GitWrapper.clone(url=repo_config['url'], branch=repo_config['branch'], path=repo_config['path']) - - if os.path.isdir(repo_config['path']): - logger.info("Repository %s successfully cloned" % repo_config['url']) - else: - logger.error("Unable to clone %s branch of repository %s" % (repo_config['branch'], repo_config['url'])) - - - def get_matching_repo_configs(self, urls): - """Iterates over the various repo URLs provided as argument (git://, - ssh:// and https:// for the repo) and compare them to any repo URL - specified in the config""" - - configs = [] - for url in urls: - for repo_config in self._config['repositories']: - if repo_config in configs: - continue - if repo_config['url'] == url: - configs.append(repo_config) - elif 'url_without_usernme' in repo_config and repo_config['url_without_usernme'] == url: - configs.append(repo_config) - - return configs - - def ssh_key_scan(self): - import re - import logging - logger = logging.getLogger() - - for repository in self._config['repositories']: - - url = repository['url'] - logger.info("Scanning repository: %s" % url) - m = re.match('.*@(.*?):', url) - - if m is not None: - port = repository['port'] - port = '' if port is None else ('-p' + port) - ProcessWrapper().call(['ssh-keyscan -t ecdsa,rsa ' + - port + ' ' + - m.group(1) + - ' >> ' + - '$HOME/.ssh/known_hosts'], shell=True) - - else: - logger.error('Could not find regexp match in path: %s' % url) - - def kill_conflicting_processes(self): - import os - import logging - logger = logging.getLogger() - - pid = GitAutoDeploy.get_pid_on_port(self._config['port']) - - if pid is False: - logger.error('[KILLER MODE] I don\'t know the number of pid ' + - 'that is using my configured port\n[KILLER MODE] ' + - 'Maybe no one? Please, use --force option carefully') - return False - - os.kill(pid, signal.SIGKILL) - return True - - def create_pid_file(self): - import os - - with open(self._config['pidfilepath'], 'w') as f: - f.write(str(os.getpid())) - - def read_pid_file(self): - with open(self._config['pidfilepath'], 'r') as f: - return f.readlines() - - def remove_pid_file(self): - import os - os.remove(self._config['pidfilepath']) - - def exit(self): - import sys - import logging - logger = logging.getLogger() - logger.info('\nGoodbye') - self.remove_pid_file() - sys.exit(0) - - @staticmethod - def create_daemon(): - import os - - try: - # Spawn first child. Returns 0 in the child and pid in the parent. - pid = os.fork() - except OSError, e: - raise Exception("%s [%d]" % (e.strerror, e.errno)) - - # First child - if pid == 0: - os.setsid() - - try: - # Spawn second child - pid = os.fork() - - except OSError, e: - raise Exception("%s [%d]" % (e.strerror, e.errno)) - - if pid == 0: - os.chdir('/') - os.umask(0) - else: - # Kill first child - os._exit(0) - else: - # Kill parent of first child - os._exit(0) - - return 0 - - def run(self): - import sys - from BaseHTTPServer import HTTPServer - import socket - import os - import logging - import argparse - - # Attempt to retrieve default config values from environment variables - default_quiet_value = 'GAD_QUIET' in os.environ - default_daemon_mode_value = 'GAD_DAEMON_MODE' in os.environ - default_config_value = 'GAD_CONFIG' in os.environ and os.environ['GAD_CONFIG'] - default_ssh_keygen_value = 'GAD_SSH_KEYGEN' in os.environ - default_force_value = 'GAD_FORCE' in os.environ - default_pid_file_value = 'GAD_PID_FILE' in os.environ and os.environ['GAD_PID_FILE'] - default_log_file_value = 'GAD_LOG_FILE' in os.environ and os.environ['GAD_LOG_FILE'] - default_host_value = 'GAD_HOST' in os.environ and os.environ['GAD_HOST'] - default_port_value = 'GAD_PORT' in os.environ and int(os.environ['GAD_PORT']) - - parser = argparse.ArgumentParser() - - parser.add_argument("-d", "--daemon-mode", - help="run in background (daemon mode)", - default=default_daemon_mode_value, - action="store_true") - - parser.add_argument("-q", "--quiet", - help="supress console output", - default=default_quiet_value, - action="store_true") - - parser.add_argument("-c", "--config", - help="custom configuration file", - default=default_config_value, - type=str) - - parser.add_argument("--ssh-keygen", - help="scan repository hosts for ssh keys", - default=default_ssh_keygen_value, - action="store_true") - - parser.add_argument("--force", - help="kill any process using the configured port", - default=default_force_value, - action="store_true") - - parser.add_argument("--pid-file", - help="specify a custom pid file", - default=default_pid_file_value, - type=str) - - parser.add_argument("--log-file", - help="specify a log file", - default=default_log_file_value, - type=str) - - parser.add_argument("--host", - help="address to bind to", - default=default_host_value, - type=str) - - parser.add_argument("--port", - help="port to bind to", - default=default_port_value, - type=int) - - args = parser.parse_args() - - # Set up logging - logger = logging.getLogger() - logFormatter = logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s") - - # Enable console output? - if args.quiet: - logger.addHandler(logging.NullHandler()) - else: - consoleHandler = logging.StreamHandler() - consoleHandler.setFormatter(logFormatter) - logger.addHandler(consoleHandler) - - # All logs are recording - logger.setLevel(logging.NOTSET) - - # Look for log file path provided in argument - config_file_path = None - if args.config: - config_file_path = os.path.realpath(args.config) - logger.info('Using custom configuration file \'%s\'' % config_file_path) - - # Try to find a config file on the file system - if not config_file_path: - config_file_path = self.find_config_file_path() - - # Read config data from json file - config_data = self.read_json_file(config_file_path) - - # Configuration options coming from environment or command line will - # override those coming from config file - if args.pid_file: - config_data['pidfilepath'] = args.pid_file - - if args.log_file: - config_data['logfilepath'] = args.log_file - - if args.host: - config_data['host'] = args.host - - if args.port: - config_data['port'] = args.port - - # Extend config data with any repository defined by environment variables - config_data = self.read_repo_config_from_environment(config_data) - - # Initialize config using config file data - self.init_config(config_data) - - # Translate any ~ in the path into /home/<user> - if 'logfilepath' in self._config: - log_file_path = os.path.expanduser(self._config['logfilepath']) - fileHandler = logging.FileHandler(log_file_path) - fileHandler.setFormatter(logFormatter) - logger.addHandler(fileHandler) - - if args.ssh_keygen: - logger.info('Scanning repository hosts for ssh keys...') - self.ssh_key_scan() - - if args.force: - logger.info('Attempting to kill any other process currently occupying port %s' % self._config['port']) - self.kill_conflicting_processes() - - # Clone all repos once initially - self.clone_all_repos() - - # Set default stdout and stderr to our logging interface (that writes - # to file and console depending on user preference) - sys.stdout = LogInterface(logger.info) - sys.stderr = LogInterface(logger.error) - - if args.daemon_mode: - logger.info('Starting Git Auto Deploy in daemon mode') - GitAutoDeploy.create_daemon() - else: - logger.info('Git Auto Deploy started') - - self.create_pid_file() - - # Clear any existing lock files, with no regard to possible ongoing processes - for repo_config in self._config['repositories']: - - # Do we have a physical repository? - if 'path' in repo_config: - Lock(os.path.join(repo_config['path'], 'status_running')).clear() - Lock(os.path.join(repo_config['path'], 'status_waiting')).clear() - - try: - self._server = HTTPServer((self._config['host'], - self._config['port']), - WebhookRequestHandler) - sa = self._server.socket.getsockname() - logger.info("Listening on %s port %s", sa[0], sa[1]) - self._server.serve_forever() - - except socket.error, e: - - if not args.daemon_mode: - logger.critical("Error on socket: %s" % e) - GitAutoDeploy.debug_diagnosis(self._config['port']) - - sys.exit(1) - - def stop(self): - if self._server is not None: - self._server.socket.close() - - def signal_handler(self, signum, frame): - import logging - logger = logging.getLogger() - self.stop() - - if signum == 1: - self.run() - return - - elif signum == 2: - logger.info('\nRequested close by keyboard interrupt signal') - - elif signum == 6: - logger.info('Requested close by SIGABRT (process abort signal). Code 6.') - - self.exit() - - if __name__ == '__main__': - import signal - - app = GitAutoDeploy() - - signal.signal(signal.SIGHUP, app.signal_handler) - signal.signal(signal.SIGINT, app.signal_handler) - signal.signal(signal.SIGABRT, app.signal_handler) - signal.signal(signal.SIGPIPE, signal.SIG_IGN) - - app.run() + import sys + import os + import gitautodeploy + sys.stderr.write("\033[1;33m[WARNING]\033[0;33m GitAutoDeploy.py is deprecated. Please use \033[1;33m'python gitautodeploy%s'\033[0;33m instead.\033[0m\n" % (' ' + ' '.join(sys.argv[1:])).strip()) + gitautodeploy.main() @@ -1,6 +1,6 @@ .PHONY: all detect install initsystem -prefix = /opt/Gitlab_Auto_Deploy/ +prefix = /opt/Git-Auto-Deploy/ init_version := $(shell /sbin/init --version 2>&1) test_upstart := $(shell printf $(init_version) | grep -q upstart || grep -q upstart /proc/net/unix ; echo $$?) @@ -18,15 +18,15 @@ else ifeq ($(test_systemd),0) else @echo "InitV supposed" endif - @echo "Done!" + @echo "Init script not installed - not yet implemented" install: clean all @echo "Installing deploy script in $(prefix) ..." + @echo "Installing deploy script in $(init_version) ..." @sudo mkdir $(prefix) &> /dev/null || true - @sudo cp GitAutoDeploy.conf.json.example $(prefix)GitAutoDeploy.conf.json - @sudo cp GitAutoDeploy.py $(prefix) - @sudo chmod +x $(prefix)GitAutoDeploy.py + @sudo cp config.json.sample $(prefix)config.json + @sudo cp -r gitautodeploy $(prefix)/ @echo "Installing run-on-startup scripts according to your init system ..." - @make --silent initsystem + @make initsystem @@ -1,93 +1,88 @@ # What is it? -GitAutoDeploy.py consists of a small HTTP server that listens for Web hook requests sent from GitHub, GitLab or Bitbucket servers. This application allows you to continuously and automatically deploy you projects each time you push new commits to your repository.</p> +Git-Auto-Deploy consists of a small HTTP server that listens for Web hook requests sent from GitHub, GitLab or Bitbucket servers. This application allows you to continuously and automatically deploy you projects each time you push new commits to your repository.</p>  # How does it work? -When commits are pushed to your Git repository, the Git server will notify ```GitAutoDeploy.py``` by sending a HTTP POST request with a JSON body to a pre configured URL (your-host:8001). The JSON body contains detailed information about the repository and what event that triggered the request. GitAutoDeploy.py parses and validates the request, and if all goes well it issues a ```git pull```. +When commits are pushed to your Git repository, the Git server will notify ```Git-Auto-Deploy``` by sending a HTTP POST request with a JSON body to a pre configured URL (your-host:8001). The JSON body contains detailed information about the repository and what event that triggered the request. ```Git-Auto-Deploy``` parses and validates the request, and if all goes well it issues a ```git pull```. -Additionally, ```GitAutoDeploy.py``` can be configured to execute a shell command upon each successful ```git pull```, which can be used to trigger custom build actions or test scripts.</p> +Additionally, ```Git-Auto-Deploy``` can be configured to execute a shell command upon each successful ```git pull```, which can be used to trigger custom build actions or test scripts.</p> # Getting started + ## Dependencies * Git (tested on version 2.5.0) * Python (tested on version 2.7) -## Configuration +## Install from repository (recommended) + +### Download and install + + git clone https://github.com/olipo186/Git-Auto-Deploy.git + cd Git-Auto-Deploy + +### Configuration + +Make a copy of the sample configuration file and modify it to match your project setup. [Read more about the configuration options](./docs/Configuration.md). + + cp config.json.sample config.json + +*Tip:* Make sure that the path specified in ```pidfilepath``` is writable for the user running the script, as well as any other path configured for your repositories. + +### Running the application + +Run the application my invoking ```python``` and referencing the ```gitautodeploy``` module (the directory ```Git-Auto-Deploy/gitautodeploy```). + + python gitautodeploy -* Copy ```GitAutoDeploy.conf.json.example``` to ```GitAutoDeploy.conf.json``` -* Modify ```GitAutoDeploy.conf.json``` to match your project setup -* Make sure that the ```pidfilepath``` path is writable for the user running the script, as well as any other path configured for your repositories. -* If you don't want to execute ```git pull``` after webhook was fired, you can leave field ```"path"``` empty. +### Start automatically on boot -See the [Configuration](./docs/Configuration.md) documentation for more details. +The easiest way to configure your system to automatically start ```Git-Auto-Deploy``` after a reboot is using crontab. Open crontab in edit mode using ```crontab -e``` and add the entry below. -### Logging + @reboot /usr/bin/python /path/to/Git-Auto-Deploy/gitautodeploy --daemon-mode --quiet --config /path/to/git-auto-deploy.conf.json -To start logging you can define ```"logfilepath": "/home/hermes/gitautodeploy.log"```. Note that you can`t see triggered command output when log is defined, only script output. If you leave ```"logfilepath"``` empty - everething will work as usual (without log). +*Tip:* You can also configure ```Git-Auto-Deploy``` to start automatically using a init.d-script (for Debian and Sys-V like init systems) or a service for systemd. [Read more about starting Git-Auto-Deploy automatically using init.d or systemd](./docs/Start automatically on boot.md). -## Running the application -```python GitAutoDeploy.py``` +## Alternative installation methods + +* [Install as a python module (experimental)](./docs/Install as a python module.md) +* [Install as a debian package (experimental)](./docs/Install as a debian package.md) +* [Start automatically on boot (init.d and systemd)](./docs/Start automatically on boot.md) ## Command line options +Below is a summarized list of the most common command line options. For a full list of available command line options, invoke the application with the argument ```--help``` or read the documentation article about [all avaialble command line options, environment variables and config attributes](./docs/Configuration.md). + Command line option | Environment variable | Config attribute | Description ---------------------- | -------------------- | ---------------- | -------------------------- --daemon-mode (-d) | GAD_DAEMON_MODE | | Run in background (daemon mode) --quiet (-q) | GAD_QUIET | | Supress console output ---ssh-keygen | GAD_SSH_KEYGEN | | Scan repository hosts for ssh keys ---force | GAD_FORCE | | Kill any process using the configured port --config (-c) <path> | GAD_CONFIG | | Custom configuration file --pid-file <path> | GAD_PID_FILE | pidfilepath | Specify a custom pid file --log-file <path> | GAD_LOG_FILE | logfilepath | Specify a log file --host <host> | GAD_HOST | host | Address to bind to --port <port> | GAD_PORT | port | Port to bind to -## Start automatically on boot - -### Crontab -The easiest way to configure your system to automatically start ```GitAutoDeploy.py``` after a reboot is through crontab. Open crontab in edit mode using ```crontab -e``` and add the following: - -```@reboot /usr/bin/python /path/to/GitAutoDeploy.py --daemon-mode --quiet``` - -### Debian and Sys-V like init system. - -* Copy file ```initfiles/debianLSBInitScripts/gitautodeploy``` to ```/etc/init.d/``` -* Make it executable: ```chmod 755 /etc/init.d/gitautodeploy``` -* Also you need to make ```GitAutoDeploy.py``` executable (if it isn't already): ```chmod 755 GitAutoDeploy.py``` -* This init script assumes that you have ```GitAutoDeploy.py``` installed in ```/opt/Git-Auto-Deploy/``` and that the ```pidfilepath``` config option is set to ```/var/run/gitautodeploy.pid```. If this is not the case, edit the ```gitautodeploy``` init script and modify ```DAEMON```, ```PWD``` and ```PIDFILE```. -* Now you need to add the correct symbolic link to your specific runlevel dir to get the script executed on each start up. On Debian_Sys-V just do ```update-rc.d gitautodeploy defaults``` - -### Systemd - -* Copy file ```initfiles/systemd/gitautodeploy.service``` to ```/etc/systemd/system``` -* Also you need to make ```GitAutoDeploy.py``` executable (if it isn't already): ```chmod 755 GitAutoDeploy.py``` -* And also you need to create the user and the group ```www-data``` if those not exists ```useradd -U www-data``` -* This init script assumes that you have ```GitAutoDeploy.py``` installed in ```/opt/Git-Auto-Deploy/```. If this is not the case, edit the ```gitautodeploy.service``` service file and modify ```ExecStart``` and ```WorkingDirectory```. -* now reload daemons ```systemctl daemon-reload``` -* Fire it up ```systemctl start gitautodeploy``` -* Make is start on system boot ```systemctl enable gitautodeploy``` - -## Configure GitHub - -* Go to your repository -> Settings -> Webhooks and Services -> Add webhook</li> -* In "Payload URL", enter your hostname and port (your-host:8001) -* Hit "Add webhook" +## Getting webhooks from git +To make your git provider send notifications to ```Git-Auto-Deploy``` you will need to provide the hostname and port for your ```Git-Auto-Deploy``` instance. Instructions for the most common git providers is listed below. -## Configure GitLab -* Go to your repository -> Settings -> Web hooks -* In "URL", enter your hostname and port (your-host:8001) -* Hit "Add Web Hook" +**GitHub** +1. Go to your repository -> Settings -> Webhooks and Services -> Add webhook</li> +2. In "Payload URL", enter your hostname and port (your-host:8001) +3. Hit "Add webhook" -## Configure Bitbucket -* Go to your repository -> Settings -> Webhooks -> Add webhook -* In "URL", enter your hostname and port (your-host:8001) -* Hit "Save" +**GitLab** +1. Go to your repository -> Settings -> Web hooks +2. In "URL", enter your hostname and port (your-host:8001) +3. Hit "Add Web Hook" -# Example workflows +**Bitbucket** +1. Go to your repository -> Settings -> Webhooks -> Add webhook +2. In "URL", enter your hostname and port (your-host:8001) +3. Hit "Save" -## Continuous Delivery via Pull requests (GitHub only) +# More documentation -It's possible to configure Git-Auto-Deploy to trigger when pull requests are opened or closed on GitHub. To read more about this workflow and how to configure Git-Aut-Deploy here: [Continuous Delivery via Pull requests](./docs/Continuous Delivery via Pull requests.md) +[Have a look in the *docs* directory](./docs), where you'll find more detailed documentation on configurations, alternative installation methods and example workflows. diff --git a/GitAutoDeploy.conf.json.example b/config.json.sample index babdb98..babdb98 100755 --- a/GitAutoDeploy.conf.json.example +++ b/config.json.sample diff --git a/docs/Configuration.md b/docs/Configuration.md index b911048..953385d 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -1,5 +1,25 @@ -# Configuration -The configuration file is formatted in `JSON`. The possible root elements are +# Command line options and environment variables + +```Git-Auto-Deploy``` supports a number of configurable options. Some of them are available using command line options, where others are only configurable from the config file. Below is a list of the options made available from the command line. Every command line option has also a corresponding environemnt variable. In the cases where a corresponding config file attribute is available, that attribute name is listed. + +There is also support for supplying configuration options for up to one repository using environmetn variables. Variable names and descriptios are available in the section (Repository configuration using environment variables)[#eepository-configuration-using-environment-variables]. + +The list of available command line options can also be seen by invoke the application with the argument ```--help```. + +Command line option | Environment variable | Config attribute | Description +---------------------- | -------------------- | ---------------- | -------------------------- +--daemon-mode (-d) | GAD_DAEMON_MODE | | Run in background (daemon mode) +--quiet (-q) | GAD_QUIET | | Supress console output +--config (-c) <path> | GAD_CONFIG | | Custom configuration file +--pid-file <path> | GAD_PID_FILE | pidfilepath | Specify a custom pid file +--log-file <path> | GAD_LOG_FILE | logfilepath | Specify a log file +--host <host> | GAD_HOST | host | Address to bind to +--port <port> | GAD_PORT | port | Port to bind to +--force | GAD_FORCE | | Kill any process using the configured port +--ssh-keygen | GAD_SSH_KEYGEN | | Scan repository hosts for ssh keys + +# Configuration file options +The configuration file is formatted according to a `JSON` inspired format, with the additional feature of supporting inline comments. The possible root elements are as follow: - **pidfilepath**: The path where `pid` files are kept. @@ -129,4 +149,16 @@ Execute script upon GitLab CI successful build of `master` branch. } ] } -```
\ No newline at end of file +``` + +# Repository configuration using environment variables + +It's possible to configure up to one repository using environment variables. This can be useful in some specific use cases where a full config file is undesired. + +Environment variable | Description +-------------------- | -------------------------- +GAD_REPO_URL | Repository URL +GAD_REPO_BRANCH | +GAD_REPO_REMOTE | +GAD_REPO_PATH | Path to where ```Git-Auto-Deploy``` should clone and pull repository +GAD_REPO_DEPLOY | Deploy command diff --git a/docs/Install as a debian package.md b/docs/Install as a debian package.md new file mode 100644 index 0000000..7cce207 --- /dev/null +++ b/docs/Install as a debian package.md @@ -0,0 +1,40 @@ +# Install as a debian package (experimental) + +Below is instructions on how to create a debian (.deb) package using stdeb. You can follow the instructions below to build the .deb package, or use the prepared script (platforms/debian/scripts/create-debian-package.sh) that will do the same. Once the package is created, you can install it using ```dpkg -i```. A sample configuration file as well as a init.d start up script will be installed as part of the package. + +### Install dependencies + +Install stdeb and other dependencies + + apt-get install python-stdeb fakeroot python-all + +### Download and build + + git clone https://github.com/olipo186/Git-Auto-Deploy.git + cd Git-Auto-Deploy + + # Generate a Debian source package + python setup.py --command-packages=stdeb.command sdist_dsc -x platforms/debian/stdeb.cfg + + # Copy configuration files + cp -vr ./platforms/debian/stdeb/* ./deb_dist/git-auto-deploy-<version>/debian/ + + # Copile a Debian binary package + cd ./deb_dist/git-auto-deploy-<version> + dpkg-buildpackage -rfakeroot -uc -us + +### Install + +When installing the package, a sample configuration file and a init.d start up script will be created. + + dpkg -i git-auto-deploy-<version>.deb + +### Configuration + + nano /etc/git-auto-deploy.conf.json + +### Running the application + + service git-auto-deploy start + service git-auto-deploy status + diff --git a/docs/Install as a python module.md b/docs/Install as a python module.md new file mode 100644 index 0000000..245091d --- /dev/null +++ b/docs/Install as a python module.md @@ -0,0 +1,43 @@ +# Install as a python module (experimental) + +## Download and install + +Install using [pip](http://www.pip-installer.org/en/latest/), a package manager for Python, by running the following command. + + pip install git-auto-deploy + +If you don't have pip installed, try installing it by running this from the command +line: + + curl https://raw.github.com/pypa/pip/master/contrib/get-pip.py | python + +Or, you can [download the source code +(ZIP)](https://github.com/olipo186/Git-Auto-Deploy/zipball/master "Git-Auto-Deploy +source code") for `Git-Auto-Deploy` and then run: + + python setup.py install + +You may need to run the above commands with `sudo`. + +Once ```Git-Auto-Deploy``` has been installed as a python module, it can be started using the executable ```git-auto-deploy```. During installation with pip, the executable is usually installed in ```/usr/local/bin/git-auto-deploy```. This can vary depending on platform. + +## Configuration + +Copy the content of [config.json.sample](./config.json.sample) and save it anywhere you like, for example ```~/git-auto-deploy.conf.json```. Modify it to match your project setup. [Read more about the configuration options](./docs/Configuration.md). + [](./docs/Configuration.md) + +## Running the application + +Run the application using the executable ```git-auto-deploy``` which has been provided by pip. Provide the path to your configuration file as a command line argument. + referencing the ```gitautodeploy``` module (the directory ```Git-Auto-Deploy/gitautodeploy```). + + git-auto-deploy --config ~/git-auto-deploy.conf.json + +## Start automatically on boot using crontab + +The easiest way to configure your system to automatically start ```Git-Auto-Deploy``` after a reboot is using crontab. Open crontab in edit mode using ```crontab -e``` and add the entry below. + +When installing with pip, the executable ```git-auto-deploy``` is usually installed in ```/usr/local/bin/git-auto-deploy```. It is a good idea to verify the path to ```git-auto-deploy``` before adding the entry below. + + @reboot /usr/local/bin/git-auto-deploy --daemon-mode --quiet --config /path/to/git-auto-deploy.conf.json + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..51d629d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +# Git-Auto-Deploy documentation + +# Documents + +* [Configuration options](./Configuration.md) +* [Install as a python module (experimental)](./Install as a python module.md) +* [Install as a debian package (experimental)](./Install as a debian package.md) +* [Start automatically on boot (init.d and systemd)](./Start automatically on boot.md) + +# Example workflows + +## Continuous Delivery via Pull requests (GitHub only) + +It's possible to configure Git-Auto-Deploy to trigger when pull requests are opened or closed on GitHub. To read more about this workflow and how to configure Git-Aut-Deploy here: [Continuous Delivery via Pull requests](./Continuous Delivery via Pull requests.md) diff --git a/docs/Start automatically on boot.md b/docs/Start automatically on boot.md new file mode 100644 index 0000000..48977a3 --- /dev/null +++ b/docs/Start automatically on boot.md @@ -0,0 +1,53 @@ +# Start automatically on boot + +```Git-Auto-Deploy``` can be automatically started at boot time using various techniques. Below you'll find a couple of suggested approaches with instructions. + +The following instructions assumes that you are running ```Git-Auto-Deploy``` from a clone of this repository. In such a case, ```Git-Auto-Deploy``` is started by invoking ```python``` and referencing the ```gitautodeploy``` python module which is found in the cloned repository. Such a command can look like ```python /path/to/Git-Auto-Deploy/gitautodeploy --daemon-mode```. + +If you have used any of the alternative installation methods (install with pip or as a debian package), you will instead start ```Git-Auto-Deploy``` using a installed executable. ```Git-Auto-Deploy``` would then be started using a command like ```git-auto-deploy --daemon-mode``` instead. If you have installed ```Git-Auto-Deploy``` in this way, you will need to modify the paths and commands used in the instructions below. + +## Crontab +The easiest way to configure your system to automatically start ```Git-Auto-Deploy``` after a reboot is using crontab. Open crontab in edit mode using ```crontab -e``` and add the following: + + @reboot /usr/bin/python /path/to/Git-Auto-Deploy/gitautodeploy --daemon-mode --quiet + +## Debian and Sys-V like init system. + +Copy the sample init script into ```/etc/init.d/``` and make it executable. + + cp platforms/linux/initfiles/debianLSBInitScripts/git-auto-deploy /etc/init.d/ + chmod 755 /etc/init.d/git-auto-deploy + +**Important:** The init script assumes that you have ```Git-Auto-Deploy``` installed in ```/opt/Git-Auto-Deploy/``` and that the ```pidfilepath``` config option is set to ```/var/run/git-auto-deploy.pid```. If this is not the case, edit the ```git-auto-deploy``` init script and modify ```DAEMON```, ```PWD``` and ```PIDFILE```. + +Now you need to add the correct symbolic link to your specific runlevel dir to get the script executed on each start up. On Debian_Sys-V just do; + + update-rc.d git-auto-deploy defaults + +Fire it up and verify; + + service git-auto-deploy start + service git-auto-deploy status + +## Systemd + +Copy the sample systemd service file ```git-auto-deploy.service``` into ```/etc/systemd/system```; + + cp platforms/linux/initfiles/systemd/git-auto-deploy.service /etc/systemd/system + +Create the user and group specified in git-auto-deploy.service (```www-data```) if those do not exist already. + + useradd -U www-data + +This init script assumes that you have ```Git-Auto-Deploy``` installed in ```/opt/Git-Auto-Deploy/```. If this is not the case, edit the ```git-auto-deploy.service``` service file and modify ```ExecStart``` and ```WorkingDirectory```. + +Now, reload daemons and fire ut up; + + systemctl daemon-reload + systemctl start git-auto-deploy + +Make is start automatically on system boot; + + systemctl enable gitautodeploy + + diff --git a/docs/Useful commands.md b/docs/Useful commands.md new file mode 100644 index 0000000..fb25613 --- /dev/null +++ b/docs/Useful commands.md @@ -0,0 +1,11 @@ + +# Create debian package +apt-get install python-stdeb fakeroot python-all + +https://pypi.python.org/pypi/stdeb/0.8.5 +python setup.py --command-packages=stdeb.command –package=git-auto-deploy bdist_deb +python setup.py --command-packages=stdeb.command sdist_dsc bdist_deb + + +# Debianize +python setup.py --command-packages=stdeb.command debianize diff --git a/gitautodeploy/__init__.py b/gitautodeploy/__init__.py new file mode 100644 index 0000000..c8a461c --- /dev/null +++ b/gitautodeploy/__init__.py @@ -0,0 +1,5 @@ +from wrappers import * +from lock import * +from parsers import * +from gitautodeploy import * +from cli import *
\ No newline at end of file diff --git a/gitautodeploy/__main__.py b/gitautodeploy/__main__.py new file mode 100644 index 0000000..f97e44f --- /dev/null +++ b/gitautodeploy/__main__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +from gitautodeploy import main + +if __name__ == '__main__': + main() diff --git a/gitautodeploy/cli/__init__.py b/gitautodeploy/cli/__init__.py new file mode 100644 index 0000000..1725ebe --- /dev/null +++ b/gitautodeploy/cli/__init__.py @@ -0,0 +1 @@ +from config import * diff --git a/gitautodeploy/cli/config.py b/gitautodeploy/cli/config.py new file mode 100644 index 0000000..332b03e --- /dev/null +++ b/gitautodeploy/cli/config.py @@ -0,0 +1,4 @@ + + +def generate_config(): + print "Generating config"
\ No newline at end of file diff --git a/gitautodeploy/gitautodeploy.py b/gitautodeploy/gitautodeploy.py new file mode 100644 index 0000000..a618d40 --- /dev/null +++ b/gitautodeploy/gitautodeploy.py @@ -0,0 +1,573 @@ +class LogInterface(object): + """Interface that functions as a stdout and stderr handler and directs the + output to the logging module, which in turn will output to either console, + file or both.""" + + def __init__(self, level=None): + import logging + self.level = (level if level else logging.getLogger().info) + + def write(self, msg): + for line in msg.strip().split("\n"): + self.level(line) + + +class GitAutoDeploy(object): + _instance = None + _server = None + _config = None + + def __new__(cls, *args, **kwargs): + """Overload constructor to enable Singleton access""" + if not cls._instance: + cls._instance = super(GitAutoDeploy, cls).__new__( + cls, *args, **kwargs) + return cls._instance + + @staticmethod + def debug_diagnosis(port): + import logging + logger = logging.getLogger() + + pid = GitAutoDeploy.get_pid_on_port(port) + if pid is False: + logger.warning('I don\'t know the number of pid that is using my configured port') + return + + logger.info('Process with pid number %s is using port %s' % (pid, port)) + with open("/proc/%s/cmdline" % pid) as f: + cmdline = f.readlines() + logger.info('cmdline ->', cmdline[0].replace('\x00', ' ')) + + @staticmethod + def get_pid_on_port(port): + import os + + with open("/proc/net/tcp", 'r') as f: + file_content = f.readlines()[1:] + + pids = [int(x) for x in os.listdir('/proc') if x.isdigit()] + conf_port = str(port) + mpid = False + + for line in file_content: + if mpid is not False: + break + + _, laddr, _, _, _, _, _, _, _, inode = line.split()[:10] + decport = str(int(laddr.split(':')[1], 16)) + + if decport != conf_port: + continue + + for pid in pids: + try: + path = "/proc/%s/fd" % pid + if os.access(path, os.R_OK) is False: + continue + + for fd in os.listdir(path): + cinode = os.readlink("/proc/%s/fd/%s" % (pid, fd)) + minode = cinode.split(":") + + if len(minode) == 2 and minode[1][1:-1] == inode: + mpid = pid + + except Exception as e: + pass + + return mpid + + def find_config_file_path(self): + """Attempt to find a config file in cwd and script path.""" + + import os + import re + import logging + logger = logging.getLogger() + + # Look for a custom config file if no path is provided as argument + target_directories = [ + os.path.dirname(os.path.realpath(__file__)), # Script path + ] + + # Add current CWD if not identical to script path + if not os.getcwd() in target_directories: + target_directories.append(os.getcwd()) + + target_directories.reverse() + + # Look for a *conf.json or *config.json + for dir in target_directories: + if not os.access(dir, os.R_OK): + continue + for item in os.listdir(dir): + if re.match(r".*conf(ig)?\.json$", item): + path = os.path.realpath(os.path.join(dir, item)) + logger.info("Using '%s' as config" % path) + return path + + return + + def read_json_file(self, file_path): + import json + import logging + import re + logger = logging.getLogger() + + try: + json_string = open(file_path).read() + + except Exception as e: + logger.critical("Could not load %s file\n" % file_path) + raise e + + try: + # Remove commens from JSON (makes sample config options easier) + regex = r'\s*(#|\/{2}).*$' + regex_inline = r'(:?(?:\s)*([A-Za-z\d\.{}]*)|((?<=\").*\"),?)(?:\s)*(((#|(\/{2})).*)|)$' + lines = json_string.split('\n') + + for index, line in enumerate(lines): + if re.search(regex, line): + if re.search(r'^' + regex, line, re.IGNORECASE): + lines[index] = "" + elif re.search(regex_inline, line): + lines[index] = re.sub(regex_inline, r'\1', line) + + data = json.loads('\n'.join(lines)) + + except Exception as e: + logger.critical("%s file is not valid JSON\n" % file_path) + raise e + + return data + + def read_repo_config_from_environment(self, config_data): + """Look for repository config in any defined environment variables. If + found, import to main config.""" + import logging + import os + + if 'GAD_REPO_URL' not in os.environ: + return config_data + + logger = logging.getLogger() + + repo_config = { + 'url': os.environ['GAD_REPO_URL'] + } + + logger.info("Added configuration for '%s' found in environment variables" % os.environ['GAD_REPO_URL']) + + if 'GAD_REPO_BRANCH' in os.environ: + repo_config['branch'] = os.environ['GAD_REPO_BRANCH'] + + if 'GAD_REPO_REMOTE' in os.environ: + repo_config['remote'] = os.environ['GAD_REPO_REMOTE'] + + if 'GAD_REPO_PATH' in os.environ: + repo_config['path'] = os.environ['GAD_REPO_PATH'] + + if 'GAD_REPO_DEPLOY' in os.environ: + repo_config['deploy'] = os.environ['GAD_REPO_DEPLOY'] + + if not 'repositories' in config_data: + config_data['repositories'] = [] + + config_data['repositories'].append(repo_config) + + return config_data + + def init_config(self, config_data): + import os + import re + import logging + logger = logging.getLogger() + + self._config = config_data + + # Translate any ~ in the path into /home/<user> + if 'pidfilepath' in self._config: + self._config['pidfilepath'] = os.path.expanduser(self._config['pidfilepath']) + + if 'repositories' not in self._config: + self._config['repositories'] = [] + + for repo_config in self._config['repositories']: + + # Setup branch if missing + if 'branch' not in repo_config: + repo_config['branch'] = "master" + + # Setup remote if missing + if 'remote' not in repo_config: + repo_config['remote'] = "origin" + + # Setup deploy commands list if not present + if 'deploy_commands' not in repo_config: + repo_config['deploy_commands'] = [] + + # Check if any global pre deploy commands is specified + if 'global_deploy' in self._config and len(self._config['global_deploy'][0]) is not 0: + repo_config['deploy_commands'].insert(0, self._config['global_deploy'][0]) + + # Check if any repo specific deploy command is specified + if 'deploy' in repo_config: + repo_config['deploy_commands'].append(repo_config['deploy']) + + # Check if any global post deploy command is specified + if 'global_deploy' in self._config and len(self._config['global_deploy'][1]) is not 0: + repo_config['deploy_commands'].append(self._config['global_deploy'][1]) + + # If a Bitbucket repository is configured using the https:// URL, a username is usually + # specified in the beginning of the URL. To be able to compare configured Bitbucket + # repositories with incoming web hook events, this username needs to be stripped away in a + # copy of the URL. + if 'url' in repo_config and 'bitbucket_username' not in repo_config: + regexp = re.search(r"^(https?://)([^@]+)@(bitbucket\.org/)(.+)$", repo_config['url']) + if regexp: + repo_config['url_without_usernme'] = regexp.group(1) + regexp.group(3) + regexp.group(4) + + # Translate any ~ in the path into /home/<user> + if 'path' in repo_config: + repo_config['path'] = os.path.expanduser(repo_config['path']) + + return self._config + + def clone_all_repos(self): + """Iterates over all configured repositories and clones them to their + configured paths.""" + import os + import re + import logging + from wrappers import GitWrapper + logger = logging.getLogger() + + # Iterate over all configured repositories + for repo_config in self._config['repositories']: + + # Only clone repositories with a configured path + if 'path' not in repo_config: + logger.info("Repository %s will not be cloned (no path configured)" % repo_config['url']) + continue + + if os.path.isdir(repo_config['path']) and os.path.isdir(repo_config['path']+'/.git'): + logger.info("Repository %s already present" % repo_config['url']) + continue + + # Clone repository + GitWrapper.clone(url=repo_config['url'], branch=repo_config['branch'], path=repo_config['path']) + + if os.path.isdir(repo_config['path']): + logger.info("Repository %s successfully cloned" % repo_config['url']) + else: + logger.error("Unable to clone %s branch of repository %s" % (repo_config['branch'], repo_config['url'])) + + def ssh_key_scan(self): + import re + import logging + from wrappers import ProcessWrapper + logger = logging.getLogger() + + for repository in self._config['repositories']: + + url = repository['url'] + logger.info("Scanning repository: %s" % url) + m = re.match('.*@(.*?):', url) + + if m is not None: + port = repository['port'] + port = '' if port is None else ('-p' + port) + ProcessWrapper().call(['ssh-keyscan -t ecdsa,rsa ' + + port + ' ' + + m.group(1) + + ' >> ' + + '$HOME/.ssh/known_hosts'], shell=True) + + else: + logger.error('Could not find regexp match in path: %s' % url) + + def kill_conflicting_processes(self): + import os + import logging + logger = logging.getLogger() + + pid = GitAutoDeploy.get_pid_on_port(self._config['port']) + + if pid is False: + logger.error('[KILLER MODE] I don\'t know the number of pid ' + + 'that is using my configured port\n[KILLER MODE] ' + + 'Maybe no one? Please, use --force option carefully') + return False + + os.kill(pid, signal.SIGKILL) + return True + + def create_pid_file(self): + import os + + with open(self._config['pidfilepath'], 'w') as f: + f.write(str(os.getpid())) + + def read_pid_file(self): + with open(self._config['pidfilepath'], 'r') as f: + return f.readlines() + + def remove_pid_file(self): + import os + os.remove(self._config['pidfilepath']) + + def exit(self): + import sys + import logging + logger = logging.getLogger() + logger.info('Goodbye') + self.remove_pid_file() + sys.exit(0) + + @staticmethod + def create_daemon(): + import os + + try: + # Spawn first child. Returns 0 in the child and pid in the parent. + pid = os.fork() + except OSError, e: + raise Exception("%s [%d]" % (e.strerror, e.errno)) + + # First child + if pid == 0: + os.setsid() + + try: + # Spawn second child + pid = os.fork() + + except OSError, e: + raise Exception("%s [%d]" % (e.strerror, e.errno)) + + if pid == 0: + os.chdir('/') + os.umask(0) + else: + # Kill first child + os._exit(0) + else: + # Kill parent of first child + os._exit(0) + + return 0 + + def run(self): + import sys + from BaseHTTPServer import HTTPServer + import socket + import os + import logging + import argparse + from lock import Lock + from httpserver import WebhookRequestHandler + + # Attempt to retrieve default config values from environment variables + default_quiet_value = 'GAD_QUIET' in os.environ or False + default_daemon_mode_value = 'GAD_DAEMON_MODE' in os.environ or False + default_config_value = 'GAD_CONFIG' in os.environ and os.environ['GAD_CONFIG'] or None + default_ssh_keygen_value = 'GAD_SSH_KEYGEN' in os.environ or False + default_force_value = 'GAD_FORCE' in os.environ or False + default_pid_file_value = 'GAD_PID_FILE' in os.environ and os.environ['GAD_PID_FILE'] or '~/.gitautodeploy.pid' + default_log_file_value = 'GAD_LOG_FILE' in os.environ and os.environ['GAD_LOG_FILE'] or None + default_host_value = 'GAD_HOST' in os.environ and os.environ['GAD_HOST'] or '0.0.0.0' + default_port_value = 'GAD_PORT' in os.environ and int(os.environ['GAD_PORT']) or 8001 + + parser = argparse.ArgumentParser() + + parser.add_argument("-d", "--daemon-mode", + help="run in background (daemon mode)", + default=default_daemon_mode_value, + action="store_true") + + parser.add_argument("-q", "--quiet", + help="supress console output", + default=default_quiet_value, + action="store_true") + + parser.add_argument("-c", "--config", + help="custom configuration file", + default=default_config_value, + type=str) + + parser.add_argument("--ssh-keygen", + help="scan repository hosts for ssh keys", + default=default_ssh_keygen_value, + action="store_true") + + parser.add_argument("--force", + help="kill any process using the configured port", + default=default_force_value, + action="store_true") + + parser.add_argument("--pid-file", + help="specify a custom pid file", + default=default_pid_file_value, + type=str) + + parser.add_argument("--log-file", + help="specify a log file", + default=default_log_file_value, + type=str) + + parser.add_argument("--host", + help="address to bind to", + default=default_host_value, + type=str) + + parser.add_argument("--port", + help="port to bind to", + default=default_port_value, + type=int) + + args = parser.parse_args() + + # Set up logging + logger = logging.getLogger() + logFormatter = logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s") + + # Enable console output? + if args.quiet: + logger.addHandler(logging.NullHandler()) + else: + consoleHandler = logging.StreamHandler() + consoleHandler.setFormatter(logFormatter) + logger.addHandler(consoleHandler) + + # All logs are recording + logger.setLevel(logging.NOTSET) + + # Look for log file path provided in argument + config_file_path = None + if args.config: + config_file_path = os.path.realpath(args.config) + logger.info('Using custom configuration file \'%s\'' % config_file_path) + + # Try to find a config file on the file system + if not config_file_path: + config_file_path = self.find_config_file_path() + + # Read config data from json file + if config_file_path: + config_data = self.read_json_file(config_file_path) + else: + logger.info('No configuration file found or specified. Using default values.') + config_data = {} + + # Configuration options coming from environment or command line will + # override those coming from config file + if args.pid_file: + config_data['pidfilepath'] = args.pid_file + + if args.log_file: + config_data['logfilepath'] = args.log_file + + if args.host: + config_data['host'] = args.host + + if args.port: + config_data['port'] = args.port + + # Extend config data with any repository defined by environment variables + config_data = self.read_repo_config_from_environment(config_data) + + # Initialize config using config file data + self.init_config(config_data) + + # Translate any ~ in the path into /home/<user> + if 'logfilepath' in self._config: + log_file_path = os.path.expanduser(self._config['logfilepath']) + fileHandler = logging.FileHandler(log_file_path) + fileHandler.setFormatter(logFormatter) + logger.addHandler(fileHandler) + + if args.ssh_keygen: + logger.info('Scanning repository hosts for ssh keys...') + self.ssh_key_scan() + + if args.force: + logger.info('Attempting to kill any other process currently occupying port %s' % self._config['port']) + self.kill_conflicting_processes() + + # Clone all repos once initially + self.clone_all_repos() + + # Set default stdout and stderr to our logging interface (that writes + # to file and console depending on user preference) + sys.stdout = LogInterface(logger.info) + sys.stderr = LogInterface(logger.error) + + if args.daemon_mode: + logger.info('Starting Git Auto Deploy in daemon mode') + GitAutoDeploy.create_daemon() + else: + logger.info('Git Auto Deploy started') + + self.create_pid_file() + + # Clear any existing lock files, with no regard to possible ongoing processes + for repo_config in self._config['repositories']: + + # Do we have a physical repository? + if 'path' in repo_config: + Lock(os.path.join(repo_config['path'], 'status_running')).clear() + Lock(os.path.join(repo_config['path'], 'status_waiting')).clear() + + try: + WebhookRequestHandler._config = self._config + self._server = HTTPServer((self._config['host'], + self._config['port']), + WebhookRequestHandler) + sa = self._server.socket.getsockname() + logger.info("Listening on %s port %s", sa[0], sa[1]) + self._server.serve_forever() + + except socket.error, e: + + if not args.daemon_mode: + logger.critical("Error on socket: %s" % e) + GitAutoDeploy.debug_diagnosis(self._config['port']) + + sys.exit(1) + + def stop(self): + if self._server is not None: + self._server.socket.close() + + def signal_handler(self, signum, frame): + import logging + logger = logging.getLogger() + self.stop() + + if signum == 1: + self.run() + return + + elif signum == 2: + logger.info('Requested close by keyboard interrupt signal') + + elif signum == 6: + logger.info('Requested close by SIGABRT (process abort signal). Code 6.') + + self.exit() + + +def main(): + import signal + from gitautodeploy import GitAutoDeploy + + app = GitAutoDeploy() + + signal.signal(signal.SIGHUP, app.signal_handler) + signal.signal(signal.SIGINT, app.signal_handler) + signal.signal(signal.SIGABRT, app.signal_handler) + signal.signal(signal.SIGPIPE, signal.SIG_IGN) + + app.run()
\ No newline at end of file diff --git a/gitautodeploy/httpserver.py b/gitautodeploy/httpserver.py new file mode 100644 index 0000000..0760067 --- /dev/null +++ b/gitautodeploy/httpserver.py @@ -0,0 +1,193 @@ +from BaseHTTPServer import BaseHTTPRequestHandler + + +class FilterMatchError(Exception): + """Used to describe when a filter does not match a request.""" + pass + + +class WebhookRequestHandler(BaseHTTPRequestHandler): + """Extends the BaseHTTPRequestHandler class and handles the incoming + HTTP requests.""" + + def do_POST(self): + """Invoked on incoming POST requests""" + from threading import Timer + import logging + + logger = logging.getLogger() + + content_type = self.headers.getheader('content-type') + content_length = int(self.headers.getheader('content-length')) + request_body = self.rfile.read(content_length) + + # Extract request headers and make all keys to lowercase (makes them easier to compare) + request_headers = dict(self.headers) + request_headers = dict((k.lower(), v) for k, v in request_headers.iteritems()) + + ServiceRequestParser = self.figure_out_service_from_request(request_headers, request_body) + + self.send_response(200) + self.send_header('Content-type', 'text/plain') + self.end_headers() + + # Unable to identify the source of the request + if not ServiceRequestParser: + logger.error('Unable to find appropriate handler for request. The source service is not supported.') + return + + # Could be GitHubParser, GitLabParser or other + repo_configs, ref, action = ServiceRequestParser(self._config).get_repo_params_from_request(request_headers, request_body) + + logger.info("Event details - ref: %s; action: %s" % (ref or "master", action)) + + #if success: + # print "Successfullt handled request using %s" % ServiceHandler.__name__ + #else: + # print "Unable to handle request using %s" % ServiceHandler.__name__ + + if len(repo_configs) == 0: + logger.warning('Unable to find any of the repository URLs in the config: %s' % ', '.join(repo_urls)) + return + + # Wait one second before we do git pull (why?) + Timer(1.0, self.process_repositories, (repo_configs, + ref, + action, request_body)).start() + + def log_message(self, format, *args): + """Overloads the default message logging method to allow messages to + go through our custom logger instead.""" + import logging + logger = logging.getLogger() + logger.info("%s - - [%s] %s\n" % (self.client_address[0], + self.log_date_time_string(), + format%args)) + + def figure_out_service_from_request(self, request_headers, request_body): + """Parses the incoming request and attempts to determine whether + it originates from GitHub, GitLab or any other known service.""" + import json + import logging + import parsers + + logger = logging.getLogger() + data = json.loads(request_body) + + user_agent = 'user-agent' in request_headers and request_headers['user-agent'] + content_type = 'content-type' in request_headers and request_headers['content-type'] + + # Assume GitLab if the X-Gitlab-Event HTTP header is set + if 'x-gitlab-event' in request_headers: + + logger.info("Received event from GitLab") + return parsers.GitLabRequestParser + + # Assume GitHub if the X-GitHub-Event HTTP header is set + elif 'x-github-event' in request_headers: + + logger.info("Received event from GitHub") + return parsers.GitHubRequestParser + + # Assume BitBucket if the User-Agent HTTP header is set to + # 'Bitbucket-Webhooks/2.0' (or something similar) + elif user_agent and user_agent.lower().find('bitbucket') != -1: + + logger.info("Received event from BitBucket") + return parsers.BitBucketRequestParser + + # Special Case for Gitlab CI + elif content_type == "application/json" and "build_status" in data: + + logger.info('Received event from Gitlab CI') + return parsers.GitLabCIRequestParser + + # This handles old GitLab requests and Gogs requests for example. + elif content_type == "application/json": + + logger.info("Received event from unknown origin.") + return parsers.GenericRequestParser + + logger.error("Unable to recognize request origin. Don't know how to handle the request.") + return + + + def process_repositories(self, repo_configs, ref, action, request_body): + import os + import time + import logging + from wrappers import GitWrapper + from lock import Lock + + logger = logging.getLogger() + data = json.loads(request_body) + + # Process each matching repository + for repo_config in repo_configs: + + try: + # Verify that all filters matches the request (if any filters are specified) + if 'filters' in repo_config: + + # at least one filter must match + for filter in repo_config['filters']: + + # all options specified in the filter must match + for filter_key, filter_value in filter.iteritems(): + + # support for earlier version so it's non-breaking functionality + if filter_key == 'action' and filter_value == action: + continue + + if filter_key not in data or filter_value != data[filter_key]: + raise FilterMatchError() + + except FilterMatchError as e: + + # Filter does not match, do not process this repo config + continue + + # In case there is no path configured for the repository, no pull will + # be made. + if not 'path' in repo_config: + GitWrapper.deploy(repo_config) + continue + + running_lock = Lock(os.path.join(repo_config['path'], 'status_running')) + waiting_lock = Lock(os.path.join(repo_config['path'], 'status_waiting')) + try: + + # Attempt to obtain the status_running lock + while not running_lock.obtain(): + + # If we're unable, try once to obtain the status_waiting lock + if not waiting_lock.has_lock() and not waiting_lock.obtain(): + logger.error("Unable to obtain the status_running lock nor the status_waiting lock. Another process is " + + "already waiting, so we'll ignore the request.") + + # If we're unable to obtain the waiting lock, ignore the request + break + + # Keep on attempting to obtain the status_running lock until we succeed + time.sleep(5) + + n = 4 + while 0 < n and 0 != GitWrapper.pull(repo_config): + n -= 1 + + if 0 < n: + GitWrapper.deploy(repo_config) + + except Exception as e: + logger.error('Error during \'pull\' or \'deploy\' operation on path: %s' % repo_config['path']) + logger.error(e) + + finally: + + # Release the lock if it's ours + if running_lock.has_lock(): + running_lock.release() + + # Release the lock if it's ours + if waiting_lock.has_lock(): + waiting_lock.release()
\ No newline at end of file diff --git a/gitautodeploy/lock.py b/gitautodeploy/lock.py new file mode 100644 index 0000000..8bd8b32 --- /dev/null +++ b/gitautodeploy/lock.py @@ -0,0 +1,52 @@ +class Lock(): + """Simple implementation of a mutex lock using the file systems. Works on + *nix systems.""" + + path = None + _has_lock = False + + def __init__(self, path): + self.path = path + + def obtain(self): + import os + import logging + logger = logging.getLogger() + + try: + os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + self._has_lock = True + logger.info("Successfully obtained lock: %s" % self.path) + except OSError: + return False + else: + return True + + def release(self): + import os + import logging + logger = logging.getLogger() + + if not self._has_lock: + raise Exception("Unable to release lock that is owned by another process") + try: + os.remove(self.path) + logger.info("Successfully released lock: %s" % self.path) + finally: + self._has_lock = False + + def has_lock(self): + return self._has_lock + + def clear(self): + import os + import logging + logger = logging.getLogger() + + try: + os.remove(self.path) + except OSError: + pass + finally: + logger.info("Successfully cleared lock: %s" % self.path) + self._has_lock = False
\ No newline at end of file diff --git a/gitautodeploy/parsers/__init__.py b/gitautodeploy/parsers/__init__.py new file mode 100644 index 0000000..59d2b50 --- /dev/null +++ b/gitautodeploy/parsers/__init__.py @@ -0,0 +1,4 @@ +from bitbucket import BitBucketRequestParser +from github import GitHubRequestParser +from gitlab import GitLabRequestParser, GitLabCIRequestParser +from generic import GenericRequestParser
\ No newline at end of file diff --git a/gitautodeploy/parsers/bitbucket.py b/gitautodeploy/parsers/bitbucket.py new file mode 100644 index 0000000..791b491 --- /dev/null +++ b/gitautodeploy/parsers/bitbucket.py @@ -0,0 +1,37 @@ +from common import WebhookRequestParser + +class BitBucketRequestParser(WebhookRequestParser): + + def get_repo_params_from_request(self, request_headers, request_body): + import json + import logging + + logger = logging.getLogger() + data = json.loads(request_body) + + repo_urls = [] + ref = "" + action = "" + + logger.info("Received event from BitBucket") + + if 'repository' not in data: + logger.error("Unable to recognize data format") + return [], ref or "master", action + + # One repository may posses multiple URLs for different protocols + for k in ['url', 'git_url', 'clone_url', 'ssh_url']: + if k in data['repository']: + repo_urls.append(data['repository'][k]) + + if 'full_name' in data['repository']: + repo_urls.append('git@bitbucket.org:%s.git' % data['repository']['full_name']) + + # Add a simplified version of the bitbucket HTTPS URL - without the username@bitbucket.com part. This is + # needed since the configured repositories might be configured using a different username. + repo_urls.append('https://bitbucket.org/%s.git' % (data['repository']['full_name'])) + + # Get a list of configured repositories that matches the incoming web hook reqeust + repo_configs = self.get_matching_repo_configs(repo_urls) + + return repo_configs, ref or "master", action
\ No newline at end of file diff --git a/gitautodeploy/parsers/common.py b/gitautodeploy/parsers/common.py new file mode 100644 index 0000000..42cbbdd --- /dev/null +++ b/gitautodeploy/parsers/common.py @@ -0,0 +1,24 @@ + +class WebhookRequestParser(object): + """Abstract parent class for git service parsers. Contains helper + methods.""" + + def __init__(self, config): + self._config = config + + def get_matching_repo_configs(self, urls): + """Iterates over the various repo URLs provided as argument (git://, + ssh:// and https:// for the repo) and compare them to any repo URL + specified in the config""" + + configs = [] + for url in urls: + for repo_config in self._config['repositories']: + if repo_config in configs: + continue + if repo_config['url'] == url: + configs.append(repo_config) + elif 'url_without_usernme' in repo_config and repo_config['url_without_usernme'] == url: + configs.append(repo_config) + + return configs
\ No newline at end of file diff --git a/gitautodeploy/parsers/generic.py b/gitautodeploy/parsers/generic.py new file mode 100644 index 0000000..7c7c7f5 --- /dev/null +++ b/gitautodeploy/parsers/generic.py @@ -0,0 +1,30 @@ +from common import WebhookRequestParser + +class GenericRequestParser(WebhookRequestParser): + + def get_repo_params_from_request(self, request_headers, request_body): + import json + import logging + + logger = logging.getLogger() + data = json.loads(request_body) + + repo_urls = [] + ref = "" + action = "" + + logger.info("Received event from unknown origin. Assume generic data format.") + + if 'repository' not in data: + logger.error("Unable to recognize data format") + return [], ref or "master", action + + # One repository may posses multiple URLs for different protocols + for k in ['url', 'git_http_url', 'git_ssh_url', 'http_url', 'ssh_url']: + if k in data['repository']: + repo_urls.append(data['repository'][k]) + + # Get a list of configured repositories that matches the incoming web hook reqeust + repo_configs = self.get_matching_repo_configs(repo_urls) + + return repo_configs, ref or "master", action
\ No newline at end of file diff --git a/gitautodeploy/parsers/github.py b/gitautodeploy/parsers/github.py new file mode 100644 index 0000000..028663d --- /dev/null +++ b/gitautodeploy/parsers/github.py @@ -0,0 +1,45 @@ +from common import WebhookRequestParser + +class GitHubRequestParser(WebhookRequestParser): + + def get_repo_params_from_request(self, request_headers, request_body): + import json + import logging + + logger = logging.getLogger() + data = json.loads(request_body) + + repo_urls = [] + ref = "" + action = "" + + github_event = 'x-github-event' in request_headers and request_headers['x-github-event'] + + logger.info("Received '%s' event from GitHub" % github_event) + + if 'repository' not in data: + logger.error("Unable to recognize data format") + return [], ref or "master", action + + # One repository may posses multiple URLs for different protocols + for k in ['url', 'git_url', 'clone_url', 'ssh_url']: + if k in data['repository']: + repo_urls.append(data['repository'][k]) + + if 'pull_request' in data: + if 'base' in data['pull_request']: + if 'ref' in data['pull_request']['base']: + ref = data['pull_request']['base']['ref'] + logger.info("Pull request to branch '%s' was fired" % ref) + elif 'ref' in data: + ref = data['ref'] + logger.info("Push to branch '%s' was fired" % ref) + + if 'action' in data: + action = data['action'] + logger.info("Action '%s' was fired" % action) + + # Get a list of configured repositories that matches the incoming web hook reqeust + repo_configs = self.get_matching_repo_configs(repo_urls) + + return repo_configs, ref or "master", action
\ No newline at end of file diff --git a/gitautodeploy/parsers/gitlab.py b/gitautodeploy/parsers/gitlab.py new file mode 100644 index 0000000..580fed2 --- /dev/null +++ b/gitautodeploy/parsers/gitlab.py @@ -0,0 +1,74 @@ +from common import WebhookRequestParser + +class GitLabRequestParser(WebhookRequestParser): + + def get_repo_params_from_request(self, request_headers, request_body): + import json + import logging + + logger = logging.getLogger() + data = json.loads(request_body) + + repo_urls = [] + ref = "" + action = "" + + gitlab_event = 'x-gitlab-event' in request_headers and request_headers['x-gitlab-event'] + + logger.info("Received '%s' event from GitLab" % gitlab_event) + + if 'repository' not in data: + logger.error("Unable to recognize data format") + return [], ref or "master", action + + # One repository may posses multiple URLs for different protocols + for k in ['url', 'git_http_url', 'git_ssh_url']: + if k in data['repository']: + repo_urls.append(data['repository'][k]) + + # extract the branch + if 'ref' in data: + ref = data['ref'] + + # set the action + if 'object_kind' in data: + action = data['object_kind'] + + # Get a list of configured repositories that matches the incoming web hook reqeust + repo_configs = self.get_matching_repo_configs(repo_urls) + + return repo_configs, ref or "master", action + + +class GitLabCIRequestParser(WebhookRequestParser): + + def get_repo_params_from_request(self, request_headers, request_body): + import json + import logging + + logger = logging.getLogger() + data = json.loads(request_body) + + repo_urls = [] + ref = "" + action = "" + + logger.info('Received event from Gitlab CI') + + if 'push_data' not in data: + logger.error("Unable to recognize data format") + return [], ref or "master", action + + # Only add repositories if the build is successful. Ignore it in other case. + if data['build_status'] == "success": + for k in ['url', 'git_http_url', 'git_ssh_url']: + if k in data['push_data']['repository']: + repo_urls.append(data['push_data']['repository'][k]) + else: + logger.warning("Gitlab CI build '%d' has status '%s'. Not pull will be done" % ( + data['build_id'], data['build_status'])) + + # Get a list of configured repositories that matches the incoming web hook reqeust + repo_configs = self.get_matching_repo_configs(repo_urls) + + return repo_configs, ref or "master", action
\ No newline at end of file diff --git a/gitautodeploy/parsers/gitlabci.py b/gitautodeploy/parsers/gitlabci.py new file mode 100644 index 0000000..6b5e3ca --- /dev/null +++ b/gitautodeploy/parsers/gitlabci.py @@ -0,0 +1 @@ +from common import WebhookRequestParser diff --git a/gitautodeploy/wrappers/__init__.py b/gitautodeploy/wrappers/__init__.py new file mode 100644 index 0000000..d7df44b --- /dev/null +++ b/gitautodeploy/wrappers/__init__.py @@ -0,0 +1,2 @@ +from git import * +from process import *
\ No newline at end of file diff --git a/gitautodeploy/wrappers/git.py b/gitautodeploy/wrappers/git.py new file mode 100644 index 0000000..98a7361 --- /dev/null +++ b/gitautodeploy/wrappers/git.py @@ -0,0 +1,57 @@ +class GitWrapper(): + """Wraps the git client. Currently uses git through shell command + invocations.""" + + def __init__(self): + pass + + @staticmethod + def pull(repo_config): + """Pulls the latest version of the repo from the git server""" + import logging + from process import ProcessWrapper + + logger = logging.getLogger() + logger.info("Post push request received") + + # Only pull if there is actually a local copy of the repository + if 'path' not in repo_config: + logger.info('No local repository path configured, no pull will occure') + return 0 + + logger.info('Updating ' + repo_config['path']) + + cmd = 'unset GIT_DIR ' + \ + '&& git fetch ' + repo_config['remote'] + \ + '&& git reset --hard ' + repo_config['remote'] + '/' + repo_config['branch'] + ' ' + \ + '&& git submodule init ' + \ + '&& git submodule update' + + # '&& git update-index --refresh ' +\ + res = ProcessWrapper().call([cmd], cwd=repo_config['path'], shell=True) + logger.info('Pull result: ' + str(res)) + + return int(res) + + @staticmethod + def clone(url, branch, path): + from process import ProcessWrapper + ProcessWrapper().call(['git clone --recursive ' + url + ' -b ' + branch + ' ' + path], shell=True) + + @staticmethod + def deploy(repo_config): + """Executes any supplied post-pull deploy command""" + from process import ProcessWrapper + import logging + logger = logging.getLogger() + + if 'path' in repo_config: + path = repo_config['path'] + + logger.info('Executing deploy command(s)') + + # Use repository path as default cwd when executing deploy commands + cwd = (repo_config['path'] if 'path' in repo_config else None) + + for cmd in repo_config['deploy_commands']: + ProcessWrapper().call([cmd], cwd=cwd, shell=True)
\ No newline at end of file diff --git a/gitautodeploy/wrappers/process.py b/gitautodeploy/wrappers/process.py new file mode 100644 index 0000000..30adc36 --- /dev/null +++ b/gitautodeploy/wrappers/process.py @@ -0,0 +1,31 @@ +class ProcessWrapper(): + """Wraps the subprocess popen method and provides logging.""" + + def __init__(self): + pass + + @staticmethod + def call(*popenargs, **kwargs): + """Run command with arguments. Wait for command to complete. Sends + output to logging module. The arguments are the same as for the Popen + constructor.""" + + from subprocess import Popen, PIPE + import logging + logger = logging.getLogger() + + kwargs['stdout'] = PIPE + kwargs['stderr'] = PIPE + + p = Popen(*popenargs, **kwargs) + stdout, stderr = p.communicate() + + if stdout: + for line in stdout.strip().split("\n"): + logger.info(line) + + if stderr: + for line in stderr.strip().split("\n"): + logger.error(line) + + return p.returncode
\ No newline at end of file diff --git a/platforms/debian/scripts/create-debian-package.sh b/platforms/debian/scripts/create-debian-package.sh new file mode 100755 index 0000000..d103416 --- /dev/null +++ b/platforms/debian/scripts/create-debian-package.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# This script compiles a binary Debian package (.deb) +# + +# Get current path +ORIGINAL_CWD=`pwd -P` + +# Get script path (<path>/Git-Auto-Deploy/platforms/debian/scripts) +pushd `dirname $0` > /dev/null +SCRIPT_PATH=`pwd -P` +popd > /dev/null + +# Path to Git-Auto-Deploy project directory +PROJECT_PATH=`readlink -f $SCRIPT_PATH/../../../` +cd $PROJECT_PATH + +# Get package name and version +PACKAGE_NAME=`python setup.py --name` +PACKAGE_VERSION=`python setup.py --version` + +# Generate a Debian source package +echo +echo "** Generating a Debian source package **" +python setup.py --command-packages=stdeb.command sdist_dsc -x platforms/debian/stdeb.cfg + +# Path to newly generated deb_dist directory +TARGET=`readlink -f "$PROJECT_PATH/deb_dist/$PACKAGE_NAME-$PACKAGE_VERSION"` + +# Copy configuration files +echo +echo "** Copying configuration files **" +cp -vr "$PROJECT_PATH/platforms/debian/stdeb"/* "$PROJECT_PATH/deb_dist/$PACKAGE_NAME-$PACKAGE_VERSION/debian/" +#cp -vrp "$PROJECT_PATH/platforms/debian/etc"/* "$PROJECT_PATH/deb_dist/$PACKAGE_NAME-$PACKAGE_VERSION/debian/" +#cp -vrp "$PROJECT_PATH/platforms/debian/etc"/* "$PROJECT_PATH/deb_dist/$PACKAGE_NAME-$PACKAGE_VERSION/debian/source" +#mkdir "$PROJECT_PATH/deb_dist/$PACKAGE_NAME-$PACKAGE_VERSION/debian/gitautodeploy" +#mkdir "$PROJECT_PATH/deb_dist/$PACKAGE_NAME-$PACKAGE_VERSION/debian/git-auto-deploy" +#mkdir "$PROJECT_PATH/deb_dist/$PACKAGE_NAME-$PACKAGE_VERSION/debian/tmp" +#cp -vrp "$PROJECT_PATH/platforms/debian/etc"/* "$PROJECT_PATH/deb_dist/$PACKAGE_NAME-$PACKAGE_VERSION/debian/gitautodeploy" +#cp -vrp "$PROJECT_PATH/platforms/debian/etc"/* "$PROJECT_PATH/deb_dist/$PACKAGE_NAME-$PACKAGE_VERSION/debian/tmp" +#cp -vrp "$PROJECT_PATH/platforms/debian/etc"/* "$PROJECT_PATH/deb_dist/$PACKAGE_NAME-$PACKAGE_VERSION/gitautodeploy" + +# Copile a Debian binary package +echo +echo "** Compiling a Debian binary package **" +cd "$PROJECT_PATH/deb_dist/"* + +#dpkg-source --commit + +dpkg-buildpackage -rfakeroot -uc -us + +# Restore cwd +cd $ORIGINAL_CWD
\ No newline at end of file diff --git a/platforms/debian/stdeb.cfg b/platforms/debian/stdeb.cfg new file mode 100644 index 0000000..10ce100 --- /dev/null +++ b/platforms/debian/stdeb.cfg @@ -0,0 +1,2 @@ +[DEFAULT] +Package: git-auto-deploy diff --git a/platforms/debian/stdeb/compat b/platforms/debian/stdeb/compat new file mode 100644 index 0000000..7f8f011 --- /dev/null +++ b/platforms/debian/stdeb/compat @@ -0,0 +1 @@ +7 diff --git a/platforms/debian/stdeb/conffiles b/platforms/debian/stdeb/conffiles new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/platforms/debian/stdeb/conffiles diff --git a/platforms/debian/stdeb/control b/platforms/debian/stdeb/control new file mode 100644 index 0000000..e436648 --- /dev/null +++ b/platforms/debian/stdeb/control @@ -0,0 +1,17 @@ +Source: git-auto-deploy +Maintainer: Oliver Poignant <oliver@poignant.se> +Section: python +Priority: optional +Build-Depends: python-all (>= 2.6.6-3), debhelper (>= 7) +Standards-Version: 3.9.1 + + + +Package: git-auto-deploy +Architecture: all +Depends: ${misc:Depends}, ${python:Depends} +Description: Deploy your GitHub, GitLab or Bitbucket projects automatical + GitAutoDeploy consists of a HTTP server that listens for Web hook requests sent from GitHub, GitLab or Bitbucket servers. This application allows you to continuously and automatically deploy you projects each time you push new commits to your repository. + + + diff --git a/platforms/debian/stdeb/git-auto-deploy.init b/platforms/debian/stdeb/git-auto-deploy.init new file mode 100755 index 0000000..9a0a84c --- /dev/null +++ b/platforms/debian/stdeb/git-auto-deploy.init @@ -0,0 +1,152 @@ +#! /bin/sh +### BEGIN INIT INFO +# Provides: gitautodeploy +# Required-Start: $remote_fs $syslog $network +# Required-Stop: $remote_fs $syslog $network +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Script to start Autodeploy Git +# Description: Autodeploy script for Gitlab +### END INIT INFO + +# Author: JA Nache <nache.nache@gmail.com> + +NAME="git-auto-deploy" +PATH=/sbin:/usr/sbin:/bin:/usr/bin +DESC="GitAutodeploy" +DAEMON=$(which $NAME) +DAEMON_UID="git-auto-deploy" +DAEMON_GID="git-auto-deploy" +RUNDIR=/var/run/$NAME +PIDFILE=/var/run/$NAME/$NAME.pid +#PWD=/opt/Git-Auto-Deploy/ +OPTIONS="--daemon-mode --pid-file $PIDFILE --config /etc/$NAME.conf.json" +SCRIPTNAME="/etc/init.d/$NAME" + +# Exit if the package is not installed +#[ -x $DAEMON ] || echo "$NAME is not installed" && exit 0 + +# Load the VERBOSE setting and other rcS variables +. /lib/init/vars.sh + +# Define LSB log_* functions. +. /lib/lsb/init-functions + +# Read configuration variable file if it is present +[ -r /etc/default/$NAME ] && . /etc/default/$NAME || ENABLE_GITAUTODEPLOY=yes + +# +# Check whether daemon starting is enabled +# +check_start_daemon() { + if [ ! "$ENABLE_GITAUTODEPLOY" = "yes" ]; then + [ "$VERBOSE" != no ] && \ + log_warning_msg "Not starting gitautodeploy, disabled via /etc/default/gitautodeploy" + return 1 + else + return 0 + fi +} + +# +# Function that starts the daemon/service +# +do_start() +{ + echo "Starting.." + # Return + # 0 if daemon has been started + # 1 if daemon was already running + # 2 if daemon could not be started + + if [ ! -d $RUNDIR ]; then + mkdir $RUNDIR + fi + + if ! dpkg-statoverride --list $dir >/dev/null 2>&1; then + chown $DAEMON_UID:$DAEMON_GID $RUNDIR + chmod g-w,o-rwx $RUNDIR + fi + + start-stop-daemon --start --quiet --pidfile $PIDFILE --startas $DAEMON \ + --name $NAME --test > /dev/null \ + || return 1 + start-stop-daemon --start --quiet --pidfile $PIDFILE --startas $DAEMON \ + --name $NAME --umask 0027 --chuid $DAEMON_UID:$DAEMON_GID -- $OPTIONS \ + || return 2 +} + +# +# Function that stops the daemon/service +# +do_stop() +{ + echo "Stopping.." + # Return + # 0 if daemon has been stopped + # 1 if daemon was already stopped + # 2 if daemon could not be stopped + # other if a failure occurred + start-stop-daemon --stop --signal 6 --retry 30 --pidfile $PIDFILE +} + +# +# Function that reload the daemon/service +# +do_reload() +{ + echo "Reloading.." + start-stop-daemon --stop -s 1 --pidfile $PIDFILE +} + +case "$1" in + start) + check_start_daemon || exit 0 + [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC " "$NAME" + do_start + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + stop) + [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" + do_stop + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + status) + status_of_proc -p $PIDFILE "python" "$DAEMON" && exit 0 || exit $? + + ;; + reload) + do_reload + ;; + restart|force-reload) + log_daemon_msg "Restarting $DESC" "$NAME" + do_stop + case "$?" in + 0|1) + do_start + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1 ;; # Old process is still running + *) log_end_msg 1 ;; # Failed to start + esac + ;; + *) + # Failed to stop + log_end_msg 1 + ;; + esac + ;; + *) + echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 + exit 3 + ;; +esac + +: + diff --git a/platforms/debian/stdeb/git-auto-deploy.install b/platforms/debian/stdeb/git-auto-deploy.install new file mode 100644 index 0000000..cbe91a4 --- /dev/null +++ b/platforms/debian/stdeb/git-auto-deploy.install @@ -0,0 +1 @@ +gitautodeploy/data/git-auto-deploy.conf.json etc diff --git a/platforms/debian/stdeb/git-auto-deploy.postinst b/platforms/debian/stdeb/git-auto-deploy.postinst new file mode 100755 index 0000000..c5a2af0 --- /dev/null +++ b/platforms/debian/stdeb/git-auto-deploy.postinst @@ -0,0 +1,39 @@ +#!/bin/bash + +NAME="git-auto-deploy" +GAD_UID="git-auto-deploy" +GAD_GID="git-auto-deploy" + +CONF_DIR="/etc/$NAME" +DATA_DIR="/var/lib/$NAME" +PID_DIR="/var/run/$NAME" + +# Add user and group +adduser --quiet --system --home $CONF_DIR --no-create-home --ingroup nogroup --disabled-password $GAD_UID +addgroup --system $GAD_GID +adduser $GAD_UID $GAD_GID + +# Create config dir +mkdir -p $CONF_DIR +chown -R $GAD_UID:$GAD_GID $CONF_DIR +chmod 750 $CONF_DIR + +# Create log file +touch /var/log/$NAME.log +chown $GAD_UID:$GAD_GID /var/log/$NAME.log +chmod 750 /var/log/$NAME.log + +# Create data directory +mkdir -p $DATA_DIR +chown -R $GAD_UID:$GAD_GID $DATA_DIR +chmod 750 $DATA_DIR + +# Create pid file +mkdir -p $PID_DIR +chown -R $GAD_UID:$GAD_GID $PID_DIR +chmod 750 $PID_DIR +touch $PID_DIR/$NAME.pid +chown $GAD_UID:$GAD_GID $PID_DIR/$NAME.pid +chmod 750 $PID_DIR/$NAME.pid + +update-rc.d $NAME defaults diff --git a/platforms/debian/stdeb/git-auto-deploy.prerm b/platforms/debian/stdeb/git-auto-deploy.prerm new file mode 100755 index 0000000..a9bf588 --- /dev/null +++ b/platforms/debian/stdeb/git-auto-deploy.prerm @@ -0,0 +1 @@ +#!/bin/bash diff --git a/platforms/debian/stdeb/rules b/platforms/debian/stdeb/rules new file mode 100755 index 0000000..27cda30 --- /dev/null +++ b/platforms/debian/stdeb/rules @@ -0,0 +1,27 @@ +#!/usr/bin/make -f + +# This file was automatically generated by stdeb 0.8.5 at +# Thu, 10 Mar 2016 19:52:51 +0100 + +%: + dh $@ --with python2 --buildsystem=python_distutils + + +override_dh_auto_clean: + python setup.py clean -a + find . -name \*.pyc -exec rm {} \; + + + +override_dh_auto_build: + python setup.py build --force + + + +override_dh_auto_install: + python setup.py install --force --root=debian/git-auto-deploy --no-compile -O0 --install-layout=deb + + + +override_dh_python2: + dh_python2 --no-guessing-versions diff --git a/platforms/debian/stdeb/source/format b/platforms/debian/stdeb/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/platforms/debian/stdeb/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/platforms/debian/stdeb/source/options b/platforms/debian/stdeb/source/options new file mode 100644 index 0000000..bcc4bbb --- /dev/null +++ b/platforms/debian/stdeb/source/options @@ -0,0 +1 @@ +extend-diff-ignore="\.egg-info$"
\ No newline at end of file diff --git a/initfiles/debianLSBInitScripts/gitautodeploy b/platforms/linux/initfiles/debianLSBInitScripts/git-auto-deploy index 01642fe..9edf87b 100755 --- a/initfiles/debianLSBInitScripts/gitautodeploy +++ b/platforms/linux/initfiles/debianLSBInitScripts/git-auto-deploy @@ -11,21 +11,21 @@ # Author: JA Nache <nache.nache@gmail.com> -NAME=gitautodeploy +NAME="git-auto-deploy" PATH=/sbin:/usr/sbin:/bin:/usr/bin DESC="GitAutodeploy" -DAEMON=/opt/Git-Auto-Deploy/GitAutoDeploy.py +DAEMON="/usr/bin/env python /opt/Git-Auto-Deploy/gitautodeploy" DAEMON_UID=root DAEMON_GID=root RUNDIR=/var/run/$NAME PIDFILE=/var/run/$NAME.pid PWD=/opt/Git-Auto-Deploy/ -OPTIONS="--daemon-mode" +OPTIONS="--daemon-mode --quiet --pid-file /var/run/git-auto-deploy.pid --log-file /var/log/git-auto-deploy.log" USER=root SCRIPTNAME=/etc/init.d/$NAME # Exit if the package is not installed -[ -x $DAEMON ] || exit 0 +#[ -x $DAEMON ] || echo "$NAME is not installed" && exit 0 # Load the VERBOSE setting and other rcS variables . /lib/init/vars.sh diff --git a/initfiles/systemd/gitautodeploy.service b/platforms/linux/initfiles/systemd/git-auto-deploy.service index 71cb1c5..c73ff6c 100644 --- a/initfiles/systemd/gitautodeploy.service +++ b/platforms/linux/initfiles/systemd/git-auto-deploy.service @@ -5,7 +5,7 @@ Description=GitAutoDeploy User=www-data Group=www-data WorkingDirectory=/opt/Git-Auto-Deploy/ -ExecStart=/opt/Git-Auto-Deploy/GitAutoDeploy.py +ExecStart=/usr/bin/python /opt/Git-Auto-Deploy/gitautodeploy --daemon-mode [Install] WantedBy=multi-user.target diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..dd33072 --- /dev/null +++ b/setup.py @@ -0,0 +1,17 @@ +from setuptools import setup, find_packages + +setup(name='git-auto-deploy', + version='0.2.0', + url='https://github.com/olipo186/Git-Auto-Deploy', + author='Oliver Poignant', + author_email='oliver@poignant.se', + packages = find_packages(), + package_data={'gitautodeploy': ['data/*']}, + entry_points={ + 'console_scripts': [ + 'git-auto-deploy = gitautodeploy.__main__:main' + ] + }, + description = "Deploy your GitHub, GitLab or Bitbucket projects automatically on Git push events or webhooks.", + long_description = "GitAutoDeploy consists of a HTTP server that listens for Web hook requests sent from GitHub, GitLab or Bitbucket servers. This application allows you to continuously and automatically deploy you projects each time you push new commits to your repository." +) |