Skip to content

Commit 3691de3

Browse files
committed
Moving from quick hack to public release, first commit
0 parents  commit 3691de3

File tree

10 files changed

+401
-0
lines changed

10 files changed

+401
-0
lines changed

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Deployment virtualenv
2+
/venv
3+
4+
# Compiled Python
5+
*.pyc
6+
__pycache__
7+
build/
8+
dist/
9+
webhooks_git_automata.egg-info/

LICENSE

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Copyright (c) 2018 The Python Packaging Authority
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy
4+
of this software and associated documentation files (the "Software"), to deal
5+
in the Software without restriction, including without limitation the rights
6+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
copies of the Software, and to permit persons to whom the Software is
8+
furnished to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice shall be included in all
11+
copies or substantial portions of the Software.
12+
13+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
SOFTWARE.

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
2+
Webhook receiver for Git deployments
3+
====================================
4+
5+
This project started as a dirty & quick hack to perform some deployment actions
6+
triggered by a Git webhook.
7+
8+
I looked a little bit at other projects and didn't found any one that suited my
9+
needs, so I started this project... and then it grew a little bit and become
10+
something more versatile than the quick hack originally intended.
11+
12+
This project is aimed at DevOps or sysadmin that have git repositories, typically
13+
with a VIP-branch, and automatic deployment. You may want to have different branches
14+
for stage and production or set up push permissions differently to the different
15+
branches. This utility, when a webhook is received, will update the local Git repository
16+
and perform the commands in the settings.
17+
18+
Quickstart
19+
----------
20+
21+
- Create and activate a Virtual Environment:
22+
```
23+
virtualenv --python=/usr/bin/python3 /path/to/venv
24+
source /path/to/venv/bin/activate
25+
```
26+
- Install the package: `pip install webhooks_git_automata`
27+
- Create a `settings.yaml`
28+
- Set up a service (e.g. a systemd _service_ file) that does something along:
29+
```
30+
/path/to/venv/bin/wh-gitd /path/to/settings.yaml
31+
```
32+
33+
Settings
34+
--------
35+
36+
37+
Implementation details
38+
----------------------
39+
40+
This project contains a minimal Flask server that answers the POST webhooks sent
41+
by a Git server like GitLab, GitHub or Gogs. The server is started through the
42+
Flask's `app.run` method.
43+
44+
Not a lot of traffic is expected, but you may want to set up a reverse proxy in front
45+
of the Flask server, or add some fancier method like a WSGI or uWSGI or similar layer.
46+
47+
Typical git servers expect the webhook to send a quick reply (in general, HTTP
48+
connections are intended to be short lived) so there is a worker/tasks approach. There
49+
is a very simple implementation base on `Threading` and a shared `Queue`. More complex
50+
implementations may be added in the future (pull requests welcome).

setup.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import setuptools
2+
3+
with open("README.md", "r") as fh:
4+
long_description = fh.read()
5+
6+
setuptools.setup(
7+
name="webhooks_git_automata",
8+
version="0.0.1",
9+
author="Alex Barcelo",
10+
author_email="[email protected]",
11+
description="Webhook receiver for Git deployments",
12+
long_description=long_description,
13+
long_description_content_type="text/markdown",
14+
install_requires=["Flask", "PyYAML"],
15+
url="https://github.com/alexbarcelo/webhooks-git-automata",
16+
packages=setuptools.find_packages(),
17+
entry_points={
18+
'console_scripts': [
19+
'wh-gitd = webhooks_git_automata.webhooks:main_func',
20+
],
21+
},
22+
classifiers=[
23+
"Programming Language :: Python :: 3",
24+
"License :: OSI Approved :: MIT License",
25+
"Operating System :: OS Independent",
26+
],
27+
)

webhooks_git_automata/__init__.py

Whitespace-only changes.

webhooks_git_automata/automata.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import os.path
2+
import subprocess
3+
from logging import getLogger, DEBUG
4+
5+
logger = getLogger("webhooks.actions")
6+
7+
8+
class MetaAutomata(type):
9+
"""Metaclass required to provide a `__getitem__` method to Automata class.
10+
11+
When Automata[<repo>, <branch>] is called, the corresponding Automaton
12+
instance is returned ready to be used by the worker.
13+
"""
14+
actions = dict()
15+
16+
def __getitem__(cls, item):
17+
"""Given a (repo, branch) tuple, return its automaton."""
18+
return cls.actions[item]
19+
20+
def load_config(cls, actions):
21+
"""Given its configuration section, create and store the automaton instances."""
22+
for name, automaton in actions.items():
23+
repo = automaton.pop("repo", name)
24+
branch = automaton.pop("branch", "master")
25+
26+
cls.actions[repo, branch] = cls(automaton, repo, branch)
27+
28+
29+
class Automata(object, metaclass=MetaAutomata):
30+
"""Manage the set of automaton instances.
31+
32+
With the method helpers of its metaclass MetaAutomata, manage a set of
33+
automaton (one per repository and branch).
34+
"""
35+
def __init__(self, action, repo, branch):
36+
self._action = action
37+
self._repo = repo
38+
self._branch = branch
39+
40+
def pull_sources(self):
41+
os.chdir(self._action["repodir"])
42+
submodules = self._action.get("submodules", True)
43+
logger.info("Proceeding to pull changes (fetch + checkout)")
44+
45+
git_fetch = ["/usr/bin/git", "fetch"]
46+
if submodules:
47+
git_fetch.append("--recurse-submodules=yes")
48+
49+
git_checkout = ["/usr/bin/git", "checkout", "-f", "origin/%s" % self._branch]
50+
51+
git_submodule_update = ["/usr/bin/git", "submodule", "update"]
52+
53+
ret = subprocess.call(git_fetch)
54+
if ret != 0:
55+
raise RuntimeError("git fetch process failed with exitcode %d" % ret)
56+
57+
ret = subprocess.call(git_checkout)
58+
if ret != 0:
59+
raise RuntimeError("git checkout process failed with exitcode %d" % ret)
60+
61+
if submodules:
62+
ret = subprocess.call(git_submodule_update)
63+
if ret != 0:
64+
raise RuntimeError("git submodule update failed with exitcode %d" % ret)
65+
66+
def perform_commands(self):
67+
os.chdir(self._action["workdir"])
68+
for command in self._action["commands"]:
69+
ret = subprocess.call(command)
70+
if ret != 0:
71+
logger.error("Could not perform the following command: `%s`",
72+
" ".join(command))
73+
raise RuntimeError("A subprocess command failed")
74+
logger.debug("Command `%s` executed", " ".join(command))
75+
76+
logger.info("All commands executed")

webhooks_git_automata/providers.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from logging import getLogger
2+
import hmac
3+
4+
from flask import request
5+
from werkzeug.exceptions import Forbidden, BadRequest
6+
7+
from .worker import event_queue
8+
9+
logger = getLogger(__name__)
10+
11+
12+
def _schedule_data_from_request():
13+
"""Given an ongoing request, schedule it to the worker.
14+
15+
This can be called from an ongoing request, and it will get the data (the
16+
JSON payload) and put it in the worker event_queue for later processing.
17+
"""
18+
data = request.get_json(force=True, cache=False)
19+
if not data:
20+
raise BadRequest("Unconsistent JSON received")
21+
event_queue.put(data)
22+
23+
24+
def webhook_github(secret):
25+
"""GitHub implementation of the webhook (requires secret)."""
26+
logger.debug("Received a POST from: %s",
27+
request.remote_addr)
28+
29+
agent = request.headers.get("User-Agent")
30+
31+
if not agent.startswith("GitHub-Hookshot"):
32+
raise Forbidden("Only GitHub is allowed to POST to us")
33+
34+
signature = request.headers.get("X-Hub-Signature")
35+
event_type = request.headers.get("X-GitHub-Event")
36+
37+
if not signature or not event_type:
38+
raise Forbidden("Webhooks must include the signature "
39+
"(add `secret` to the webhook settings in GitHub)")
40+
41+
if not secret:
42+
raise SystemError("No `secret` configured, refusing to accept petition")
43+
44+
try:
45+
digest = hmac.digest(secret, request.get_data(), "sha1")
46+
except AttributeError: # Python < 3.7, using older & slower approach
47+
h = hmac.new(secret, request.get_data(), "sha1")
48+
digest = h.digest()
49+
50+
# Strip the first five characters: 'sha1=xxxxxxx' and get it in binary form
51+
sent_digest = bytes.fromhex(signature[5:])
52+
if digest != sent_digest:
53+
logger.debug("Expected signature %s, received %s" % (digest, sent_digest))
54+
raise Forbidden("The HMAC digest do not match. You are not allowed.")
55+
56+
if event_type == "ping":
57+
logger.info("Ignoring ping event")
58+
else:
59+
_schedule_data_from_request()
60+
61+
return ""
62+
63+
64+
def webhook_gogs(secret):
65+
"""Gogs implementation of the webhook (requires secret)."""
66+
logger.debug("Received a POST from: %s",
67+
request.remote_addr)
68+
69+
data = request.get_json(force=True, cache=False)
70+
if not data:
71+
raise BadRequest("Unconsistent JSON received")
72+
73+
try:
74+
if data["secret"] != secret:
75+
logger.debug("Received secret: %s, which did not match the expected", data["secret"])
76+
raise Forbidden("Invalid `secret` provided in HTTP method payload")
77+
except KeyError:
78+
raise Forbidden("The request should include the `secret`. "
79+
"Have you configured correctly the gogs sever webhook?")
80+
81+
event_queue.put(data)
82+
return ""
83+
84+
85+
def webhook_plain():
86+
"""Trivial implementation."""
87+
logger.debug("Received a POST from: %s",
88+
request.remote_addr)
89+
90+
_schedule_data_from_request()
91+
92+
return ""

webhooks_git_automata/settings.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import logging
2+
3+
import yaml
4+
5+
from .automata import Automata
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
class Configuration(object):
11+
@classmethod
12+
def load(cls, path):
13+
with open(path, 'r') as f:
14+
cls._settings = yaml.load(f)
15+
16+
# Logging settings
17+
logging.basicConfig(level=cls._settings.get("log_level", "INFO"))
18+
logger.info("Initialized with configuration at %s", path)
19+
logger.debug("Debug level is activated")
20+
21+
# Provider settings
22+
provider = cls._settings.get("provider", "plain")
23+
24+
if isinstance(provider, dict):
25+
# If it is a dictionary, the provider is the key name
26+
cls.provider = provider.pop("name")
27+
# and all other entries are their options
28+
cls.provider_options = provider
29+
else:
30+
# typically "plain", which has no extra configuration
31+
cls.provider = provider
32+
cls.provider_options = dict()
33+
34+
# URL path for webhooks
35+
cls.webhook_url_path = cls._settings.get("webhook_url_path", "/webhook")
36+
37+
# IP to listen to
38+
cls.listen_ip = cls._settings.get("listen_ip", "127.0.0.1")
39+
40+
# Port to listen to
41+
cls.listen_port = cls._settings.get("listen_port", 5000)
42+
43+
# Repositories required entry, (managed by the `Actions` class)
44+
Automata.load_config(cls._settings["repositories"])

webhooks_git_automata/webhooks.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import sys
2+
import logging
3+
from functools import partial
4+
from threading import Thread
5+
6+
from flask import Flask
7+
8+
from .settings import Configuration
9+
from .worker import async_worker
10+
from . import providers
11+
12+
13+
logging.basicConfig(level=logging.DEBUG)
14+
logger = logging.getLogger("webhooks")
15+
app = Flask(__name__)
16+
17+
18+
def main_func():
19+
if len(sys.argv) != 2:
20+
raise RuntimeError("You should provide the path to the settings YAML file as first argument")
21+
22+
Configuration.load(sys.argv[1])
23+
24+
# Future work: check the settings and allow multiple worker implementations (e.g. Celery)
25+
Thread(target=async_worker,
26+
name="webhook worker").start()
27+
28+
try:
29+
webhook_func = getattr(providers, "webhook_%s" % Configuration.provider)
30+
except AttributeError:
31+
raise RuntimeError("Could not prepare the webhook for provider %s. "
32+
"Maybe it is not supported?" % Configuration.provider)
33+
34+
webhook = partial(
35+
webhook_func,
36+
**Configuration.provider_options
37+
)
38+
39+
app.add_url_rule('/webhook', 'webhook', webhook, methods=['POST'])
40+
app.run(host=Configuration.listen_ip,
41+
port=Configuration.listen_port)

0 commit comments

Comments
 (0)