#!/usr/bin/env python3 # # This implements the various APIs for the various CI components we # need. Very simplistic and prototype-y. All in one file for # simplicity - production version will need to be done more carefully. # # To use: run this program with one of the following command lines: # # ./this controller this.log key.pub # ./this vcsworker this.log key.pub gitlab.token # # where # # - this.log is the log file to use # - key.pub is a public key in ssh format for checking incoming access tokens # - gitlab.token is the name of a file containing a GitLab access token # # NOTE: The GitLab instance and other configuration details are # hardcoded in this version. This will be fixed, as well as the 418 # HTTP status code as a generic error code. # # NOTE: You should run this behind haproxy or smilar TLS provider, # which forwards requests to localhost. import logging import os import shutil import subprocess import sys import tempfile import urllib.parse import Crypto.PublicKey.RSA import bottle import jwt HOST = '127.0.0.1' PORT = 2222 class TokenParser: '''Parse an incoming access token (signed JWT)''' # Note that if we need to, for performance, we can cache the parse # results here. But there's no point in doing that unless it # becomes necessary. def __init__(self, pubkey): self._pubkey = pubkey def parse_token(self, token_text): return jwt.decode( token_text, key=self._pubkey.exportKey('OpenSSH'), audience=None, options={'verify_aud': False}) class AccessChecker: '''Given request headers and required scopes, is a request allowed?''' def __init__(self, pubkey): self._parser = TokenParser(pubkey) def access_is_allowed(self, headers, required_scopes): token = self._get_token(headers) logging.debug('Access token %r', token) if token is None and len(required_scopes) != 0: logging.error('No valid access token') return False if token: scopes = token.get('scope', '').split() missing = set(required_scopes).difference(scopes) if missing: logging.error( 'Required scopes that are missing from token: %r', missing) return False return True def _get_token(self, headers): '''Parse an access token or return None if it's bad''' token_text = self._get_token_text(headers) if token_text is None: return None return self._parser.parse_token(token_text) def _get_token_text(self, headers): '''Extract access token from request headers or None if not there''' v = headers.get('Authorization', '') if not v: logging.error('No Authorization header') return None words = v.split() if len(words) != 2: logging.error('Authorization header does not contain two words') return None keyword, token_text = words if keyword.lower() != 'bearer': logging.error('Authorization header does not contain a Bearer token') return None logging.debug( 'Got an access token from Authorization header: %s', token_text) return token_text class API: '''Base class for simple HTTP APIs Override the get_routes method to use this. Call setup() method to set up routes and such, before actually running. ''' def __init__(self): self._checker = None def setup(self, app, token_pubkey): self._checker = AccessChecker(token_pubkey) self._add_routes(app, self.get_routes()) def get_routes(self): raise NotImplementedError() def _add_routes(self, app, routes): for route in routes: func = route.pop('func') scopes = route.pop('scopes') assert isinstance(scopes, list) callback = lambda **kwargs: self.check(func, scopes, kwargs) route = dict(route) route['callback'] = callback app.route(**route) def check(self, func, required_scopes, kwargs): '''Call a callback function, if it's OK to do so''' try: r = bottle.request logging.debug('New request, checking access: %s %s', r.method, r.path) if self._checker.access_is_allowed(r.headers, required_scopes): logging.info('Access is allowed: %s %s', r.method, r.path) ret = func(**kwargs) logging.info('Result: %r', ret) return ret logging.error('Request denied %s %s', r.method, r.path) return bottle.HTTPError(400) except Exception as e: logging.warning('Caught exception: %s', str(e)) return bottle.HTTPError(500) class Controller(API): '''A dummy controller API''' def get_routes(self): return [ { 'method': 'GET', 'path': '/status', 'func': self._status, 'scopes': ['status'], }, { 'method': 'GET', 'path': '/hello/', 'func': self._hello, 'scopes': ['hello'], }, ] def _status(self): return { 'queue': [], 'running': [], 'finished': [], } def _hello(self, name=None): return 'hello {}\n'.format(name) class VCSWorker(API): '''A VCSWorker API''' MAX_CLONE_TIME = 1 MAX_REMOVE_TIME = 60 MAX_CREATE_REPO_TIME = 5 MAX_PUSH_TIME = 60 GITLAB_DOMAIN = 'wmf-gitlab3.vm.liw.fi' GITLAB_PROJECT = 'liw' def __init__(self, gitlab_token, artifact_token): self._gitlab_token = gitlab_token self._artifact_token = artifact_token self._tmpdir = tempfile.mkdtemp() logging.info('Workspace: %s', self._tmpdir) def get_routes(self): return [ { 'method': 'POST', 'path': '/updaterepo', 'func': self._update_repo, 'scopes': ['update-repo'], }, ] def _update_repo(self): spec = bottle.request.json logging.info('Updating repository: %r', spec) url = spec['git'] ref = spec['ref'] name = spec['gitlab'] dirname = os.path.join(self._tmpdir, name) T = self._gitlab_token D = self.GITLAB_DOMAIN P = self.GITLAB_PROJECT key = 'ARTIFACT_TOKEN' value = self._artifact_token def update(): return (self._clone(url, ref, dirname) and self._remove(T, D, P, name) and self._create_repo(T, D, name) and self._set_var(T, D, P, name, key, value) and self._push(dirname, D, P, ref, name)) if update(): return 'Repository copied successfully\n' logging.error('Something went wrong when copying repository') return bottle.HTTPError(418) def _clone(self, url, ref, dirname): if os.path.exists(dirname): logging.debug('Removing %s', dirname) shutil.rmtree(dirname) argv = ['git', 'clone', '-q', '-b', ref, url, dirname] return runcmd('.', argv, self.MAX_CLONE_TIME) def _remove(self, token, gitlab_domain, gitlab_project, name): snippet = urllib.parse.quote('%s/%s' % (gitlab_project, name), safe='') url = 'https://%s/api/v4/projects/%s' % (gitlab_domain, snippet) argv = ['curl', '-HPRIVATE-TOKEN: %s' % token, '-X', 'DELETE', url] return runcmd('.', argv, self.MAX_REMOVE_TIME) def _create_repo(self, token, gitlab_domain, name): logging.info('Creating repository %s', name) url = 'https://%s/api/v4/projects' % gitlab_domain argv = [ 'curl', url, '-sv', '-X' 'POST', '-d', 'name=%s' % name, '-HPRIVATE-TOKEN: %s' % token, ] return runcmd('.', argv, self.MAX_CREATE_REPO_TIME) def _set_var(self, token, gitlab_domain, gitlab_project, name, key, value): logging.info( 'Setting variable for %s/%s: %s=%s', gitlab_project, name, key, value) name = urllib.parse.quote('%s/%s' % (gitlab_project, name), safe='') url = 'https://%s/api/v4/projects/%s/variables' % (gitlab_domain, name) argv = [ 'curl', url, '-sv', '-X' 'POST', '-d', 'key=%s' % key, '-d', 'value=%s' % value, '-HPRIVATE-TOKEN: %s' % token, ] return runcmd('.', argv, self.MAX_CREATE_REPO_TIME) def _push(self, dirname, gitlab_domain, gitlab_project, ref, name): logging.info('Pushing %s to %s as %s', dirname, gitlab_domain, name) url = 'ssh://git@%s/%s/%s.git' % (gitlab_domain, gitlab_project, name) argv = ['git', 'push', url, '%s:master' % ref] return runcmd(dirname, argv, self.MAX_PUSH_TIME) def runcmd(cwd, argv, timeout): logging.info('Running command: %r', argv) try: p = subprocess.run( argv, cwd=cwd, timeout=timeout, stdout=subprocess.PIPE, stderr=subprocess.PIPE) except subprocess.TimeoutExpired: logging.error('Command took too long (timeout %r)', timeout) return False except Exception as e: logging.error('Error while running command: %s', str(e)) return False if p.returncode != 0: logging.error('Command failed: %r', argv) logging.error('exit code: %d', p.returncode) logging.error('stdout: %r', p.stdout) logging.error('stderr: %r', p.stderr) return False logging.info('Command succeeded') return True def setup_logging(): logging.basicConfig(level=logging.DEBUG, format='%(levelname)s %(message)s') logging.info('API server starting') def get_key_from_file(filename): with open(filename) as f: key_text = f.read() return Crypto.PublicKey.RSA.importKey(key_text) def get_token_from_file(filename): with open(filename) as f: return f.read().strip() def main(): args = sys.argv[1:] cmd = args.pop(0) pubkey_filename = args.pop(0) setup_logging() if cmd == 'controller': api = Controller() elif cmd == 'vcsworker': gitlab_token = get_token_from_file(args.pop(0)) artifact_token = get_token_from_file(args.pop(0)) api = VCSWorker(gitlab_token, artifact_token) else: sys.exit('Unknown command %s' % cmd) app = bottle.Bottle() pubkey = get_key_from_file(pubkey_filename) api.setup(app, pubkey) try: app.run(host=HOST, port=PORT, quiet=True) except SystemExit as e: logging.info('Terminating normally') except Exception as e: logging.error('Caught exception %s', str(e)) sys.exit(1) main()