flask-gogs-maker/gogsmaker.py

209 lines
5.9 KiB
Python

""" GogsMaker
A webhook-handler for Gogs running `make` when needed. """
import os
import sys
import subprocess
import hmac
import logging
from hashlib import sha256
from threading import Thread
from functools import wraps
import coloredlogs
from flask import Flask, request
import settings
LOGGER_NAME = __name__
app = Flask(__name__)
class UnmonitoredRepository(Exception):
pass
class GitError(Exception):
def __init__(self, what):
super().__init__()
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"])
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)
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:
subprocess_run(["make", "-C", self.path, "--"] + self.hook["targets"])
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"),
)
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:
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"),
)
raise GitError("Cannot pull {}".format(hook["name"]))
else: # Repo is to be cloned
try:
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))
def check_signature(received_sig, hook, payload):
""" Check Gogs signature """
digest = hmac.new(
hook["secret"].encode("utf-8"), msg=payload, digestmod=sha256
).hexdigest()
return hmac.compare_digest(digest, received_sig)
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
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
return wrapper
@app.route("/", methods=["POST"])
@gogs_payload(["repository/clone_url"])
def view_root(payload, hook):
clone_url = payload["repository"]["clone_url"]
if "clone_url" in hook:
clone_url = hook["clone_url"]
try:
update_repo(hook, clone_url)
except GitError as error:
return "Git error: {}\n".format(error), 500
worker = MakeWorker(hook)
worker.start()
return "OK\n", 200
@app.before_first_request # FIXME this should be run on startup...
def startup_actions():
setup_logger()
check_settings()
def setup_logger():
""" Setup the default logger """
coloredlogs.install(
fmt="%(asctime)s [%(levelname)s] %(message)s",
)
def check_settings():
""" Check the supplied settings """
if settings.DEBUG:
logging.warning(
"GogsMaker is running in DEBUG MODE, this is "
"unsuitable for production environments!"
)
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)
logging.critical(
("Configuration error: hook %s lacks " "attribute %s."), descr, key
)
sys.exit(1)