diff --git a/gitlab_docker_autodep/hooks.py b/gitlab_docker_autodep/hooks.py index dc99626b786ef3e1dc7353514b05149dc13e2194..5f53b2113e83473cc8a4f24da4e75ea9f2421c97 100644 --- a/gitlab_docker_autodep/hooks.py +++ b/gitlab_docker_autodep/hooks.py @@ -1,6 +1,7 @@ +import logging -def check_hook(gl, hook_url, webhook_token, project_path): +def check_hook(gl, hook_url, webhook_token, project_path, dry_run): project = gl.projects.get(project_path) found = False for h in project.hooks.list(): @@ -9,8 +10,10 @@ def check_hook(gl, hook_url, webhook_token, project_path): break if found: return - project.hooks.add( - url=hook_url, - pipeline_events=True, - token=webhook_token, - ) + logging.info('adding pipeline_events hook to %s', project_path) + if not dry_run: + project.hooks.add( + url=hook_url, + pipeline_events=True, + token=webhook_token, + ) diff --git a/gitlab_docker_autodep/main.py b/gitlab_docker_autodep/main.py index 9a14950473c516205d22a042177e2b5eb48c2c5a..6fec413f3b57d880d1fdd05b26d6428351193b63 100644 --- a/gitlab_docker_autodep/main.py +++ b/gitlab_docker_autodep/main.py @@ -3,7 +3,7 @@ import gitlab import logging import os import sys -import urllib.parse as urlparse +from urllib.parse import urlsplit from .deps import get_branches, list_projects, list_deps, \ split_project_branch, read_deps @@ -18,12 +18,14 @@ def _fmtdesc(s): def main(): parser = argparse.ArgumentParser( - description='Rebuild Docker images on a Gitlab instance.') + description='Manage Gitlab project dependencies and trigger pipelines.') subparsers = parser.add_subparsers(dest='subparser') # Common options. common_parser = argparse.ArgumentParser(add_help=False) - common_parser.add_argument('--debug', action='store_true') + common_parser.add_argument( + '--debug', action='store_true', + help='increase logging level') common_parser.add_argument( '-n', '--dry-run', action='store_true', dest='dry_run', help='only show what would be done') @@ -35,16 +37,11 @@ def main(): '--token-file', metavar='FILE', type=argparse.FileType('r'), default=os.getenv('GITLAB_AUTH_TOKEN_FILE'), - help='Load Gitlab authentication token from this file') + help='file containing the Gitlab authentication token') gitlab_opts_group.add_argument( '--token', metavar='TOKEN', default=os.getenv('GITLAB_AUTH_TOKEN'), help='Gitlab authentication token') - gitlab_opts_group.add_argument( - '--registry', metavar='NAME', - default=os.getenv('GITLAB_REGISTRY'), - help='Docker registry hostname (if empty, it will be ' - 'automatically derived from --url)') # List projects. list_projects_parser = subparsers.add_parser( @@ -69,7 +66,7 @@ or ''')) list_projects_parser.add_argument( '--search', - help='Search query used to filter project list on the server side') + help='search query used to filter project list on the server side') # Compute deps. deps_parser = subparsers.add_parser( @@ -81,9 +78,9 @@ or Generate a map of dependencies between projects on a Gitlab instance. -The input (on standard input) must consist of a list of projects along -with their branches, separated by a colon, one per line. If the branch -is unspecified, 'master' is assumed. +The input must consist of a list of projects along with their +branches, separated by a colon, one per line. If the branch is +unspecified, 'master' is assumed. The output consists of pairs of project / dependency (so, these are 'forward' dependencies), for all projects/branches specified in the @@ -94,51 +91,108 @@ columns in the output, e.g.: gitlab-deps deps < project.list | awk '{print $2, $1}' +'''), epilog=_fmtdesc(''' +Input can be read from a file (if passed as an argument), or +from standard input if a filename is omitted or specified as '-'. ''')) + deps_parser.add_argument( + '--registry', metavar='NAME', + default=os.getenv('GITLAB_REGISTRY'), + help='Docker registry hostname (if empty, it will be ' + 'automatically derived from --url)') + deps_parser.add_argument( + 'projects_list', + type=argparse.FileType('r'), + nargs='?', default=sys.stdin) # Setup pipeline hooks on the specified projects. set_hooks_parser = subparsers.add_parser( 'set-hooks', parents=[common_parser], help='set pipeline hooks on projects', - description='Set the pipeline hooks on the specified projects ' - '(usually points at our own server)') + formatter_class=argparse.RawDescriptionHelpFormatter, + description=_fmtdesc(''' +Set a HTTP hook for pipeline_events on the specified projects. + +Takes a list of projects (optional branch specifiers will be ignored) +as input. Pipeline hooks are required by 'gitlab-deps server' to +trigger dependent builds, so a common way to use this command is to +feed it the right-hand side of the 'gitlab-deps deps' output, e.g.: + + gitlab-deps deps < project.list \\ + | awk '{print $2}' \\ + | gitlab-deps set-hooks --hook-url=... + +using --hook-url to point at the URL of 'gitlab-deps server'. + +'''), epilog=_fmtdesc(''' +Input can be read from a file (if passed as an argument), or +from standard input if a filename is omitted or specified as '-'. +''')) set_hooks_parser.add_argument( '--hook-url', metavar='URL', help='URL for the pipeline HTTP hook') set_hooks_parser.add_argument( '--webhook-auth-token', metavar='TOKEN', - help='Secret X-Gitlab-Token for request authentication') + help='secret X-Gitlab-Token for request authentication') + set_hooks_parser.add_argument( + 'projects_list', + type=argparse.FileType('r'), + nargs='?', default=sys.stdin) # Trigger rebuilds of reverse deps. rebuild_image_parser = subparsers.add_parser( 'rebuild', parents=[common_parser], help='rebuild dependencies of a project', - description='Rebuild all projects that depend on the specified ' - 'project.') - rebuild_image_parser.add_argument( - '--deps', metavar='FILE', - help='file with project dependencies') + formatter_class=argparse.RawDescriptionHelpFormatter, + description=_fmtdesc(''' +Rebuild all projects that depend on the specified project. + +Takes a single project path as argument, and triggers a rebuild of its +direct dependencies. Useful for one-off rebuilds. + +If the --recurse option is provided, the tool will wait for completion +of the pipeline and recursively trigger its dependencies too, +navigating the entire dependency tree. + +'''), epilog=_fmtdesc(''' +Project dependencies can be read from a file (if passed as an +argument), or from standard input if a filename is omitted or +specified as '-'. +''')) rebuild_image_parser.add_argument( '--recurse', action='store_true', - help='Include all dependencies recursively ' + help='include all dependencies recursively ' 'and wait for completion of the pipelines') rebuild_image_parser.add_argument( 'project_path', - help='Project name (relative path, with optional branch)') + help='project name (relative path, with optional branch)') + rebuild_image_parser.add_argument( + 'dependencies_list', + type=argparse.FileType('r'), + nargs='?', default=sys.stdin) # Server. server_parser = subparsers.add_parser( 'server', parents=[common_parser], - help='start a HTTP server', - description='Start a HTTP server that listens for Gitlab webhooks. ' - 'Configure Gitlab to send Pipeline events for your projects to this ' - 'server to auto-rebuild first-level dependencies.') - server_parser.add_argument( - '--deps', metavar='FILE', - help='file with project dependencies') + help='start the HTTP server', + formatter_class=argparse.RawDescriptionHelpFormatter, + description=_fmtdesc(''' +Start an HTTP server that listens for Gitlab webhooks. + +When the server receives a pipeline event from Gitlab, it will trigger +new builds for the direct dependencies of the project. The server is +meant to be associated with a single Gitlab instance. + +You must provide the server with the list of project dependencies. + +'''), epilog=_fmtdesc(''' +Project dependencies can be read from a file (if passed as an +argument), or from standard input if a filename is omitted or +specified as '-'. +''')) server_parser.add_argument( '--port', metavar='PORT', type=int, default='5404', dest='bind_port', help='port to listen on') @@ -147,7 +201,11 @@ columns in the output, e.g.: dest='bind_host', help='address to listen on') server_parser.add_argument( '--webhook-auth-token', metavar='TOKEN', - help='Secret X-Gitlab-Token for request authentication') + help='secret X-Gitlab-Token for request authentication') + server_parser.add_argument( + 'dependencies_list', + type=argparse.FileType('r'), + nargs='?', default=sys.stdin) args = parser.parse_args() cmd = args.subparser @@ -160,13 +218,7 @@ columns in the output, e.g.: level=logging.DEBUG if args.debug else logging.INFO, ) - # If --registry is not specified, make an educated guess. - registry_hostname = args.registry - if not registry_hostname: - registry_hostname = 'registry.' + urlparse.urlsplit(args.url).netloc - logging.warning('guessed %s for the Docker registry hostname', - registry_hostname) - + # Connect to the Gitlab API. gitlab_token = args.token if not gitlab_token and args.token_file: gitlab_token = args.token_file.read().strip().encode('utf-8') @@ -179,25 +231,36 @@ columns in the output, e.g.: if cmd == 'list-projects': for p, b in get_branches(gl, list_projects(gl, args.search)): print(f'{p}:{b}') - if cmd == 'deps': - projects = [split_project_branch(x.strip()) for x in sys.stdin] + + elif cmd == 'deps': + # If --registry is not specified, make an educated guess. + registry_hostname = args.registry + if not registry_hostname: + registry_hostname = 'registry.' + urlsplit(args.url).netloc + logging.warning('guessed %s for the Docker registry hostname', + registry_hostname) + projects = [split_project_branch(x.strip()) + for x in args.projects_list] list_deps(gl, args.url, registry_hostname, projects) + elif cmd == 'rebuild': - deps = read_deps(sys.stdin) + deps = read_deps(args.dependencies_list) project_path, branch_name = split_project_branch(args.project_path) rebuild_deps(gl, deps, project_path, branch_name, args.dry_run, args.recurse) + elif cmd == 'set-hooks': if not args.hook_url: parser.error('Must specify --hook-url') # Need a project list on input, ignore branches. projects = set(y[0] for y in ( - split_project_branch(x.strip()) for x in sys.stdin)) + split_project_branch(x.strip()) for x in args.projects_list)) for project_path in projects: check_hook(gl, args.hook_url, args.webhook_auth_token, - project_path) + project_path, args.dry_run) + elif cmd == 'server': - deps = read_deps(sys.stdin) + deps = read_deps(args.dependencies_list) run_app(gl, deps, args.bind_host, args.bind_port, args.webhook_auth_token) diff --git a/gitlab_docker_autodep/rebuild.py b/gitlab_docker_autodep/rebuild.py index b9cbf4cb40b10251caacd20440d8e643c04fab45..96ebc69d0b2d3b20fc3e3a5a5acaeb4aac644c12 100644 --- a/gitlab_docker_autodep/rebuild.py +++ b/gitlab_docker_autodep/rebuild.py @@ -2,13 +2,14 @@ import logging import time -def rebuild(gl, project_path, wait=False): +def rebuild(gl, project_path, branch_name, wait=False): """Trigger a rebuild of a project.""" project = gl.projects.get(project_path) if not project: return None - pipeline = project.pipelines.create({'ref': 'master'}) + pipeline = project.pipelines.create({'ref': branch_name}) + logging.info('started pipeline %s', pipeline.web_url) if wait: while pipeline.finished_at is None: pipeline.refresh() @@ -20,9 +21,9 @@ def rebuild_deps(gl, project_deps, project_path, branch_name, dry_run, wait_and_recurse): stack = project_deps.get((project_path, branch_name), []) while stack: - path = stack.pop(0) - logging.info('rebuilding %s', path) + path, branch = stack.pop(0) + logging.info('rebuilding %s:%s', path, branch) if not dry_run: - rebuild(gl, path, wait_and_recurse) + rebuild(gl, path, branch, wait_and_recurse) if wait_and_recurse: - stack.extend(project_deps.get(path, [])) + stack.extend(project_deps.get((path, branch), [])) diff --git a/gitlab_docker_autodep/server.py b/gitlab_docker_autodep/server.py index ffc0351d307dfad601472bb535d97b04e8202de4..2f5ae406eaf239aba236ca0e2217df1e0251a850 100644 --- a/gitlab_docker_autodep/server.py +++ b/gitlab_docker_autodep/server.py @@ -16,25 +16,25 @@ queue = Queue.Queue() def _process_request(gl, project_deps, data): pipeline_status = data['object_attributes']['status'] - branch = data['object_attributes']['ref'] - path_with_namespace = data['project']['path_with_namespace'] + branch_name = data['object_attributes']['ref'] + project_path = data['project']['path_with_namespace'] action = 'none' if pipeline_status == 'success': - deps = project_deps.get_contents().get(path_with_namespace, []) + deps = project_deps.get((project_path, branch_name), []) built_projects = [] - for dep_path in deps: + for dep_path, dep_branch in deps: try: - p = rebuild(gl, dep_path) - logging.info('started pipeline %s', p) + rebuild(gl, dep_path, dep_branch) + built_projects.append(f'{dep_path}:{dep_branch}') except Exception as e: - logging.error('error rebuilding project %s: %s' % ( - path_with_namespace, str(e))) + logging.error('error rebuilding project %s:%s: %s' % ( + dep_path, dep_branch, str(e))) action = 'rebuilt %s' % (', '.join(built_projects),) logging.info('pipeline for %s@%s: %s, action=%s', - path_with_namespace, branch, pipeline_status, action) + project_path, branch_name, pipeline_status, action) def worker_thread(gl, project_deps):