flask-gogs-maker/gogsmaker.py

205 lines
5.9 KiB
Python
Raw Normal View History

2018-03-03 00:47:28 +01:00
""" GogsMaker
A webhook-handler for Gogs running `make` when needed. """
2018-03-03 01:45:54 +01:00
import os
2018-03-03 02:13:35 +01:00
import sys
2018-03-03 01:45:54 +01:00
import subprocess
2018-03-03 02:31:54 +01:00
import hmac
2018-03-03 14:24:03 +01:00
import logging
import coloredlogs
2018-03-03 02:31:54 +01:00
from hashlib import sha256
2018-03-03 02:13:35 +01:00
from threading import Thread
from functools import wraps
2018-03-03 01:09:24 +01:00
from flask import Flask, request
2018-03-03 01:45:54 +01:00
from . import settings
2018-03-03 01:09:24 +01:00
2018-03-03 14:24:03 +01:00
LOGGER_NAME = __name__
2018-03-03 01:09:24 +01:00
app = Flask(__name__)
2018-03-03 01:45:54 +01:00
class UnmonitoredRepository(Exception):
pass
class GitError(Exception):
def __init__(self, what):
super().__init__()
2018-03-03 01:45:54 +01:00
self.what = what
def __str__(self):
return self.what
def get_hook(url):
''' Get the hook matching an URL, or raise UnmonitoredRepository '''
for hook in settings.HOOKS:
if hook['url'] == url:
return hook
raise UnmonitoredRepository
def repo_path(hook):
''' Get the path at which the hook's repo is cloned '''
return os.path.join(settings.CLONE_ROOT, hook['name'])
2018-03-03 14:24:03 +01:00
def subprocess_run(command, **kwargs):
''' Run subprocess with default arguments '''
args = {
'check': True,
'stdout': subprocess.DEVNULL,
'stderr': subprocess.PIPE,
}
args.update(kwargs)
return subprocess.run(command, **args)
2018-03-03 02:13:35 +01:00
class MakeWorker(Thread):
''' A make job '''
def __init__(self, hook):
super().__init__()
self.hook = hook
self.name = 'makeworker-{}'.format(hook['name'])
self.path = repo_path(hook)
def run(self):
''' Run the make job '''
try:
2018-03-03 14:24:03 +01:00
subprocess_run(['make', '-C', self.path, '--']
2018-03-03 02:13:35 +01:00
+ self.hook['targets'])
2018-03-03 14:24:03 +01:00
except subprocess.CalledProcessError as error:
logging.error(
("Hook %s: make failed with status %s. "
"Error output:\n%s\n"),
self.hook['name'],
error.returncode,
error.stderr.decode('utf-8'))
2018-03-03 02:13:35 +01:00
2018-03-03 01:45:54 +01:00
def update_repo(hook, clone_url):
''' Update (or clone) the given repository. May raise GitError. '''
path = repo_path(hook)
if os.path.isdir(os.path.join(path, '.git')): # Repo is already cloned
try:
2018-03-03 14:24:03 +01:00
subprocess_run(['git', '-C', path, 'reset', '--hard']
) # Just in case.
subprocess_run(['git', '-C', path, 'pull'])
except subprocess.CalledProcessError as error:
logging.error(
("Hook %s: git failed with status %s. "
"Error output:\n%s\n"),
hook['name'],
error.returncode,
error.stderr.decode('utf-8'))
2018-03-03 01:45:54 +01:00
raise GitError("Cannot pull {}".format(hook['name']))
2018-03-03 14:24:03 +01:00
else: # Repo is to be cloned
try:
2018-03-03 14:24:03 +01:00
subprocess_run(['mkdir', '-p', path])
subprocess_run(['git', 'clone', clone_url, path], check=True)
except subprocess.CalledProcessError as error:
logging.error(
("Hook %s: git failed cloning with status %s. "
"Error output:\n%s"),
hook['name'],
error.returncode,
error.stderr.decode('utf-8'))
raise GitError("Cannot clone {}".format(clone_url))
2018-03-03 01:45:54 +01:00
2018-03-03 02:31:54 +01:00
def check_signature(received_sig, hook, payload):
''' Check Gogs signature '''
2018-03-03 13:30:05 +01:00
digest = hmac.new(hook['secret'].encode('utf-8'),
2018-03-03 02:31:54 +01:00
msg=payload,
2018-03-03 13:30:05 +01:00
digestmod=sha256).hexdigest()
2018-03-03 02:31:54 +01:00
return hmac.compare_digest(digest, received_sig)
2018-03-03 01:45:54 +01:00
def gogs_payload(required):
def wrapper(fct):
@wraps(fct)
def wrapped(*args, **kwargs):
payload = request.json
if payload is None:
return 'Expected json\n', 415
for field in required + ['repository/html_url']:
path = field.split('/')
explore = payload
for section in path:
if section not in explore:
return (
'Invalid json: missing {}\n'.format(
'/'.join(path)),
400)
explore = explore[section]
try:
hook = get_hook(payload['repository']['html_url'])
except UnmonitoredRepository:
return 'Unmonitored repository\n', 403
2018-03-03 02:31:54 +01:00
if not settings.DEBUG:
received_sig = request.headers['X-Gogs-Signature']
payload_raw = request.data
if not check_signature(received_sig, hook, payload_raw):
return 'Invaild signature\n', 403
return fct(payload, hook, *args, **kwargs)
return wrapped
2018-03-03 01:45:54 +01:00
return wrapper
2018-03-03 01:09:24 +01:00
@app.route('/', methods=['POST'])
2018-03-03 01:45:54 +01:00
@gogs_payload(['repository/clone_url'])
def view_root(payload, hook):
clone_url = payload['repository']['clone_url']
try:
update_repo(hook, clone_url)
except GitError as error:
return 'Git error: {}\n'.format(error), 500
2018-03-03 02:13:35 +01:00
worker = MakeWorker(hook)
worker.start()
2018-03-03 01:45:54 +01:00
2018-03-03 01:09:24 +01:00
return 'OK\n', 200
2018-03-03 02:13:35 +01:00
2018-03-03 02:48:09 +01:00
@app.before_first_request # FIXME this should be run on startup...
2018-03-03 14:24:03 +01:00
def startup_actions():
setup_logger()
check_settings()
def setup_logger():
''' Setup the default logger '''
coloredlogs.install(
fmt="%(asctime)s [%(levelname)s] %(message)s",
)
2018-03-03 02:48:09 +01:00
def check_settings():
''' Check the supplied settings '''
if settings.DEBUG:
2018-03-03 14:24:03 +01:00
logging.warning('GogsMaker is running in DEBUG MODE, this is '
'unsuitable for production environments!')
2018-03-03 02:48:09 +01:00
required_keys = ['name', 'url', 'targets', 'secret']
for hook_id, hook in enumerate(settings.HOOKS):
for key in required_keys:
if key not in hook:
if key == 'name':
descr = '#{}'.format(hook_id)
else:
descr = '{} (#{})'.format(hook['name'], hook_id)
2018-03-03 14:24:03 +01:00
logging.critical(('Configuration error: hook %s lacks '
'attribute %s.'),
descr, key)
2018-03-03 02:48:09 +01:00
sys.exit(1)