Skip to content
This repository was archived by the owner on May 28, 2025. It is now read-only.

Commit 350baf4

Browse files
committed
v0.7.0 - refactored webhook module
1 parent c12657c commit 350baf4

File tree

13 files changed

+390
-97
lines changed

13 files changed

+390
-97
lines changed

.coveragerc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@
22
exclude_lines =
33
if __name__ == '__main__':
44
main()
5+
6+
# TODO: TEMPORARY, Remove when replaced with a long-term solution
7+
retrieve_pipeline

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# CHANGELOG
22

3+
## v0.7.0 (2020-10-26)
4+
5+
* Refactored the `webhook` module and added unit tests
6+
* Refactored webhook logic to return appropriate JSON messages and HTTP status codes
7+
* Fixed bugs where Harvey would blow up when no JSON, malformed JSON, or other webhook details weren't correct
8+
* Fixed a bug that would not catch when a bad pipeline name would be given
9+
* Various other bug fixes and improvements
10+
* Added basic tests to API routes
11+
312
## v0.6.0 (2020-10-25)
413

514
* Added unit tests for the `container` module and refactored various code there

README.md

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@ Harvey was born because Rancher has too much overhead and GitLab is too RAM hung
1919

2020
## How it Works
2121

22-
Harvey receives a webhook from GitHub, pulls in the changes, tests them, builds them, then deploys them. When all is said and done - we'll even provide you a message to say "job's done".
22+
Harvey receives a webhook from GitHub, pulls in the changes, tests them, builds them, then deploys them. If you have Slack enabled, Harvey will send you the pipeline summary.
2323

24-
1. GitHub webhook fires to your self-hosted endpoint stating that a new commit hit an enabled repo
25-
- Your server pulls in the new changes for that repo via Git
26-
1. Next we test your code based on the criteria provided
27-
1. Then we build your docker image locally
28-
1. Next we spin up the new docker container and tear down the old one once it's up and running, making downtime barely a blip
29-
1. Finally we shoot off a message to notify users the build was a success or not
24+
1. GitHub webhook fires and is received by Harvey stating that a new commit hit an enabled repo
25+
* Harvey pulls in the new changes for that repo via Git
26+
1. Next Harvey tests your code based on the criteria provided
27+
1. Then Harvey builds your docker image locally
28+
1. Next Harvey spins up the new docker container and tears down the old one once it's up and running
29+
1. Then Harvey will run a container healthcheck to ensure your container is up and running and didn't exit on startup
30+
1. Finally, (if enabled) Harvey will shoot off a message to notify users the build was a success or not
3031

3132
Harvey has lightweight testing functionality which is configurable via shell scripts. Harvey builds a unique self-isolated Docker container to test your code and returns the logs from your tests when finished.
3233

@@ -48,11 +49,9 @@ make help
4849

4950
1. Install Docker & login
5051
1. Ensure you've added your ssh key to the ssh agent: `ssh-add` followed by your password
51-
1. Enable logging (see below)
52-
1. Setup enviornment variables in `.env`
53-
1. Add webhooks for all your repositories you want to use Harvey with (point them to `http://example.com:5000/pipelines/start`, send the payload as JSON)
54-
55-
**NOTE:** It is not recommended to use Harvey alongside other CI/CD or Docker orchestration platforms on the same machine.
52+
1. Enable logging (see `Logs` below)
53+
1. Setup enviornment variables as needed
54+
1. Enable GitHub webhooks for all your repositories you want to use Harvey with (point them to `http://example.com:5000/pipelines/start`, send the payload as JSON)
5655

5756
### Logs
5857

@@ -70,9 +69,17 @@ The [following](https://docs.docker.com/config/containers/logging/json-file/#usa
7069

7170
## Usage
7271

72+
```bash
73+
# Run locally
74+
make run
75+
76+
# Run in production
77+
harvey-ci
78+
```
79+
7380
Find the full [docs here](docs/README.md).
7481

75-
Harvey's entrypoint is a webhook (eg: `127.0.0.1:5000/pipelines/start`). Pass GitHub data to Harvey and let it do the rest. If you'd like to simulate a GitHub webhook, simply pass a JSON file like the following example to the Harvey webhook endpoint (ensure you have an environment variable `MODE=test` to bypass the need for a webhook secret):
82+
Harvey's entrypoint (eg: `127.0.0.1:5000/pipelines/start`) accepts a webhook from GitHub. If you'd like to simulate a GitHub webhook, simply pass a JSON file like the following example to the Harvey webhook endpoint (ensure you have an environment variable `MODE=test` to bypass the need for a webhook secret and GitHub headers):
7683

7784
```javascript
7885
{
@@ -101,12 +108,6 @@ Environment Variables:
101108
DEBUG Whether the Flask API will run in debug mode or not
102109
```
103110

104-
### Start API Server (for Webhook)
105-
106-
```bash
107-
make run
108-
```
109-
110111
### Example Python Functions
111112

112113
See `examples.py` for all available methods of each class. Almost every usage example is contained in this file.

examples/examples.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,10 @@
9090

9191
"""API Entrypoint (Webhook)"""
9292
with open('examples/git_webhook.json', 'r') as file:
93+
data = json.load(file)
9394
request = requests.post(
9495
'http://127.0.0.1:5000/pipelines/start',
95-
data=file,
96+
json=data,
9697
headers=harvey.Global.JSON_HEADERS
9798
)
9899
print(request.json())

harvey/app.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,14 @@
1313
PORT = os.getenv('PORT', '5000')
1414
DEBUG = os.getenv('DEBUG', 'True')
1515

16-
17-
# TODO: Move all of the logic out of this file and into Harvey itself
18-
# This file should only route requests to the proper functions
16+
# TODO: Add authentication to each endpoint
1917

2018

2119
@API.route('/pipelines/start', methods=['POST'])
2220
def start_pipeline():
2321
"""Start a pipeline based on webhook data
2422
"""
25-
return Webhook.parse_webhook(request=request, target=Webhook.start_pipeline)
23+
return Webhook.parse_webhook(request=request, use_compose=False)
2624

2725

2826
# @API.route('/pipelines/stop', methods=['POST'])
@@ -35,7 +33,7 @@ def start_pipeline_compose():
3533
"""Start a pipeline based on webhook data
3634
But build from compose file.
3735
"""
38-
return Webhook.parse_webhook(request=request, target=Webhook.start_pipeline_compose)
36+
return Webhook.parse_webhook(request=request, use_compose=True)
3937

4038

4139
# @API.route('/pipelines/stop/compose', methods=['POST'])

harvey/git.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class Git():
99
def update_git_repo(cls, webhook):
1010
"""Clone or pull repo using Git depending on if it exists or not
1111
"""
12+
# TODO: Fail fast if a repo doesn't exist
1213
project_path = os.path.join(
1314
Global.PROJECTS_PATH, Global.repo_full_name(webhook)
1415
)

harvey/globals.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ class Global():
33
"""
44
DOCKER_VERSION = 'v1.40' # Docker API version
55
# TODO: Figure out how to sync this version number with the one in `setup.py`
6-
HARVEY_VERSION = '0.5.0' # Harvey release
6+
HARVEY_VERSION = '0.7.0' # Harvey release
77
PROJECTS_PATH = 'projects'
88
PROJECTS_LOG_PATH = 'logs/projects'
99
HARVEY_LOG_PATH = 'logs/harvey'

harvey/utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def kill(cls, final_output, webhook):
1414
if os.getenv('SLACK'):
1515
Message.send_slack_message(final_output)
1616
sys.exit()
17+
return True
1718

1819
@classmethod
1920
def success(cls, final_output, webhook):
@@ -22,6 +23,7 @@ def success(cls, final_output, webhook):
2223
Logs.generate_logs(final_output, webhook)
2324
if os.getenv('SLACK'):
2425
Message.send_slack_message(final_output)
26+
return True
2527

2628

2729
class Logs():

harvey/webhook.py

Lines changed: 76 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -3,112 +3,120 @@
33
from datetime import datetime
44
import hmac
55
import hashlib
6-
from flask import abort
76
from threading import Thread
87
from harvey.pipeline import Pipeline
98
from harvey.git import Git
109
from harvey.globals import Global
1110
from harvey.utils import Utils
1211

1312

13+
WEBHOOK_SECRET = os.getenv('WEBHOOK_SECRET')
14+
APP_MODE = os.getenv('MODE')
15+
16+
1417
class Webhook():
1518
@classmethod
16-
def init(cls, webhook):
19+
def initialize_pipeline(cls, webhook):
1720
"""Initiate the logic for webhooks and pull the project
1821
"""
1922
start_time = datetime.now()
20-
preamble = f'Running Harvey v{Global.HARVEY_VERSION}\n' + \
21-
f'Pipeline Started: {start_time}'
23+
preamble = f'Running Harvey v{Global.HARVEY_VERSION}\nPipeline Started: {start_time}'
2224
pipeline_id = f'Pipeline ID: {Global.repo_commit_id(webhook)}\n'
2325
print(preamble)
24-
git_message = (f'New commit by: {Global.repo_commit_author(webhook)}. \
25-
\nCommit made on repo: {Global.repo_full_name(webhook)}.')
26+
git_message = (f'New commit by: {Global.repo_commit_author(webhook)}.'
27+
f'\nCommit made on repo: {Global.repo_full_name(webhook)}.')
2628
git = Git.update_git_repo(webhook)
29+
config = cls.open_project_config(webhook)
30+
execution_time = f'Startup execution time: {datetime.now() - start_time}\n'
31+
output = (f'{preamble}\n{pipeline_id}Configuration:\n{json.dumps(config, indent=4)}'
32+
f'\n\n{git_message}\n{git}\n{execution_time}')
33+
print(execution_time)
34+
return config, output
2735

28-
# Open the project's config file to assign pipeline variables
36+
@classmethod
37+
def open_project_config(cls, webhook):
38+
"""Open the project's config file to assign pipeline variables
39+
"""
40+
# TODO: Add the ability to configure projects on the Harvey side
41+
# instead of only from within a JSON file in the repo
2942
try:
30-
filename = os.path.join(Global.PROJECTS_PATH, Global.repo_full_name(webhook),
31-
'harvey.json')
43+
filename = os.path.join(
44+
Global.PROJECTS_PATH, Global.repo_full_name(webhook), 'harvey.json'
45+
)
3246
with open(filename, 'r') as file:
3347
config = json.loads(file.read())
3448
print(json.dumps(config, indent=4))
35-
except FileNotFoundError as fnf_error:
36-
final_output = f'Error: Harvey could not find "harvey.json" file in \
37-
{Global.repo_full_name(webhook)}.'
38-
print(fnf_error)
49+
return config
50+
except FileNotFoundError:
51+
final_output = f'Error: Harvey could not find a "harvey.json" file in {Global.repo_full_name(webhook)}.'
52+
print(final_output)
3953
Utils.kill(final_output, webhook)
4054

41-
execution_time = f'Startup execution time: {datetime.now() - start_time}\n'
42-
output = f'{preamble}\n{pipeline_id}Configuration:\n{json.dumps(config, indent=4)}' + \
43-
f'\n\n{git_message}\n{git}\n{execution_time}'
44-
print(execution_time)
45-
46-
return config, output
47-
4855
@classmethod
49-
def parse_webhook(cls, request, target):
56+
def parse_webhook(cls, request, use_compose):
5057
"""Initiate details to receive a webhook
5158
"""
52-
data = request.data
59+
success = False
60+
message = 'Server-side error.'
61+
status_code = 500
62+
data = request.json if request.json else None
5363
signature = request.headers.get('X-Hub-Signature')
54-
parsed_data = json.loads(data) # TODO: Is this necessary?
55-
# TODO: Allow the user to configure whatever branch they'd like to pull from
56-
if parsed_data['ref'] in ['refs/heads/master', 'refs/heads/main']:
57-
if os.getenv('MODE') == 'test':
58-
Thread(target=target, args=(parsed_data,)).start()
59-
return "200"
60-
if cls.decode_webhook(data, signature):
61-
Thread(target=target, args=(parsed_data,)).start()
62-
return "200"
63-
return abort(403)
64-
return abort(500, 'Harvey can only pull from the "master" or "main" branch.')
64+
65+
if request.data and data:
66+
# TODO: Allow the user to configure whatever branch they'd like to pull from
67+
if data['ref'] in ['refs/heads/master', 'refs/heads/main']:
68+
if APP_MODE == 'test' or cls.decode_webhook(data, signature):
69+
Thread(target=cls.start_pipeline, args=(data, use_compose,)).start()
70+
message = f'Started pipeline for {data["repository"]["name"]}'
71+
status_code = 200
72+
success = True
73+
if APP_MODE != 'test' and not cls.decode_webhook(data, signature):
74+
message = 'The X-Hub-Signature did not match the WEBHOOK_SECRET.'
75+
status_code = 403
76+
else:
77+
message = 'Harvey can only pull from the "master" or "main" branch of a repo.'
78+
status_code = 422
79+
else:
80+
message = 'Malformed or missing JSON data in webhook.'
81+
status_code = 422
82+
response = {
83+
'success': success,
84+
'message': message,
85+
}, status_code
86+
return response
6587

6688
@classmethod
6789
def decode_webhook(cls, data, signature):
6890
"""Decode a webhook's secret key
6991
"""
70-
secret = bytes(os.getenv('WEBHOOK_SECRET'), 'UTF-8')
71-
mac = hmac.new(secret, msg=data, digestmod=hashlib.sha1)
72-
return hmac.compare_digest('sha1=' + mac.hexdigest(), signature)
92+
if signature:
93+
secret = bytes(WEBHOOK_SECRET, 'UTF-8')
94+
mac = hmac.new(secret, msg=data, digestmod=hashlib.sha1)
95+
return hmac.compare_digest('sha1=' + mac.hexdigest(), signature)
96+
else:
97+
return False
7398

7499
@classmethod
75-
def start_pipeline(cls, webhook):
100+
def start_pipeline(cls, webhook, use_compose=False):
76101
"""Receive a webhook and spin up a pipeline based on the config
77102
"""
78-
webhook_config, webhook_output = Webhook.init(webhook)
103+
webhook_config, webhook_output = cls.initialize_pipeline(webhook)
104+
webhook_pipeline = webhook_config['pipeline']
79105

80-
if webhook_config['pipeline'] == 'test':
81-
pipeline = Pipeline.test(webhook_config, webhook, webhook_output)
82-
elif webhook_config['pipeline'] == 'deploy':
83-
pipeline = Pipeline.deploy(webhook_config, webhook, webhook_output)
84-
elif webhook_config['pipeline'] == 'full':
85-
pipeline = Pipeline.full(webhook_config, webhook, webhook_output)
86-
elif webhook_config['pipeline'] == 'pull':
106+
if webhook_pipeline == 'pull':
87107
pipeline = Utils.success(webhook_output, webhook)
88-
elif not webhook_config['pipeline']:
89-
final_output = webhook_output + '\nError: Harvey could not run, \
90-
there was no pipeline specified.'
91-
Utils.kill(final_output, webhook)
92-
93-
return pipeline
94-
95-
@classmethod
96-
def start_pipeline_compose(cls, webhook):
97-
"""Receive a webhook and spin up a pipeline based on the config
98-
"""
99-
webhook_config, webhook_output = Webhook.init(webhook)
100-
101-
if webhook_config['pipeline'] == 'test':
108+
elif webhook_pipeline == 'test':
102109
pipeline = Pipeline.test(webhook_config, webhook, webhook_output)
103-
elif webhook_config['pipeline'] == 'deploy':
104-
pipeline = Pipeline.deploy_compose(webhook_config, webhook, webhook_output)
105-
elif webhook_config['pipeline'] == 'full':
110+
elif webhook_pipeline == 'deploy' and use_compose is False:
111+
pipeline = Pipeline.deploy(webhook_config, webhook, webhook_output)
112+
elif webhook_pipeline == 'full' and use_compose is True:
106113
pipeline = Pipeline.full_compose(webhook_config, webhook, webhook_output)
107-
elif webhook_config['pipeline'] == 'pull':
108-
pipeline = Utils.success(webhook_output, webhook)
109-
elif not webhook_config['pipeline']:
110-
final_output = webhook_output + '\nError: Harvey could not run, \
111-
there was no pipeline specified.'
112-
Utils.kill(final_output, webhook)
114+
elif webhook_pipeline == 'deploy' and use_compose is True:
115+
pipeline = Pipeline.deploy_compose(webhook_config, webhook, webhook_output)
116+
elif webhook_pipeline == 'full' and use_compose is False:
117+
pipeline = Pipeline.full(webhook_config, webhook, webhook_output)
118+
else:
119+
final_output = webhook_output + '\nError: Harvey could not run, there was no acceptable pipeline specified.'
120+
pipeline = Utils.kill(final_output, webhook)
113121

114122
return pipeline

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
setuptools.setup(
1515
name='harvey-ci',
16-
version='0.6.0',
16+
version='0.7.0',
1717
description='Your personal CI/CD and Docker orchestration platform.',
1818
long_description=long_description,
1919
long_description_content_type="text/markdown",

0 commit comments

Comments
 (0)