-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
171 lines (142 loc) · 6.22 KB
/
main.py
File metadata and controls
171 lines (142 loc) · 6.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
import hashlib
import hmac
import os
import subprocess
import time
from datetime import datetime
from uuid import uuid4
import flask
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from config import Config, load_config
from logger import logger_from_config
from utils import request_to_sanitized_json
app = flask.Flask(__name__)
config = load_config()
logger = logger_from_config(config)
# Attach rate-limiter
limiter = Limiter(
get_remote_address, # identify clients by IP
app=app,
default_limits=["200 per day", "50 per hour"],
)
def now() -> datetime:
return datetime.now().astimezone()
# Custom error handler for all HTTP exceptions.
@app.errorhandler(Exception)
def handle_exception(e):
"""
Transform all exceptions from 'flask.abort' to JSON error responses.
"""
# If it's an HTTPException, use its code; otherwise 500
code = getattr(e, "code", 500)
return flask.jsonify(error=str(e), code=code, success=False), code
def _abort_if_payload_too_large() -> None:
"""
Abort request if the content is too large.
"""
max_bytes = config.max_email_payload_bytes
content_length = flask.request.content_length if flask.request.content_length else 0
if content_length > max_bytes:
logger.warning(
"Rejected request with payload = {} KB > {} KB: {}.".format(
content_length / 1000,
max_bytes / 1000,
request_to_sanitized_json(flask.request),
)
)
flask.abort(413, description="Payload too large.")
def _abort_if_invalid_signature() -> None:
"""
Abort if the request is missing auth headers, it's timestamp is too old, or it has an invalid signature.
"""
signature = flask.request.headers.get("X-Signature")
timestamp = flask.request.headers.get("X-Timestamp")
body = flask.request.get_data(as_text=True)
if not signature or not timestamp:
logger.warning(
"Rejected request because it was missing 'X-Signature': '{}', or 'X-Timestamp': '{}'. Request: {}".format(
signature,
timestamp,
request_to_sanitized_json(flask.request),
)
)
flask.abort(403, "Missing auth header(s). Make sure you're sending 'X-Signature' and 'X-Timestamp'.")
# Check timestamp is from the last 5 minutes
if abs(time.time() - int(timestamp)) > 300:
logger.warning(
"Rejected request with timestamp '{}' because it's too old. Request: {}.".format(
timestamp,
request_to_sanitized_json(flask.request),
)
)
flask.abort(403, "Too old.")
# Compute the expected request signature and compare
message = timestamp + body
secret_bytes = config.api_secret.encode("utf-8")
expected_sig = hmac.new(secret_bytes, message.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected_sig):
logger.warning(
"Rejected request with invalid signature. Request: {}.".format(
request_to_sanitized_json(flask.request),
)
)
flask.abort(403, "Invalid Signature.")
@app.route("/", methods=["GET"])
def index():
return flask.Response("Python-Deployer v1.0.", status=200)
for deploy_app in config.apps:
def make_route(_deploy_app: Config.App):
def handler():
_abort_if_payload_too_large()
_abort_if_invalid_signature()
start = now()
try:
logger.info(
"Starting deploy for '%s' using '%s' in '%s'...",
_deploy_app.name,
" ".join(_deploy_app.run_args),
_deploy_app.cwd,
)
working_dir = os.chdir(_deploy_app.cwd) if os.chdir(_deploy_app.cwd) != "." else os.getcwd()
with subprocess.Popen(
_deploy_app.run_args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=working_dir,
) as proc:
if proc.stdout:
for line in proc.stdout:
logger.info(f"<{_deploy_app.name}> {line.strip()}")
if proc.stderr:
for line in proc.stderr:
# some processes use stderr for normal logging. Try to interpret what
# they are saying and also rely on exit_code for failure condition.
if "error" in line.lower() or "failed" in line.lower():
logger.error(f"<{_deploy_app.name}> {line.strip()}")
else:
logger.info(f"<{_deploy_app.name}> {line.strip()}")
exit_code = proc.wait()
if exit_code == -15:
logger.warning(
"Subprocess killed by SIGTERM — likely due to service restart. Did python-deployer just deploy itself?"
)
elif exit_code != 0:
raise Exception(f"Subprocess exited with code: {exit_code}!")
elapsed = now().timestamp() - start.timestamp()
message = f"Deployment for {_deploy_app.name} succeeded in {elapsed:.2f} seconds."
return flask.jsonify({"success": True, "message": message}), 200
except Exception as e:
elapsed = now().timestamp() - start.timestamp()
logger.error(f"Failed to deploy app: {_deploy_app.name}.", exc_info=e)
message = f"Deployment failed after {elapsed:.2f} seconds."
return flask.jsonify({"success": False, "message": message}), 500
# give the function a unique name
handler.__name__ = f"handler_{uuid4().hex}"
app.route(_deploy_app.endpoint, methods=["POST"])(handler)
make_route(deploy_app)
logger.debug(f"Created API route for '{deploy_app.name} @ {deploy_app.endpoint} --> {deploy_app.run_args}'")
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=True)
app.run(host="127.0.0.1", port=5000, debug=True)