-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathdeployments.py
289 lines (251 loc) · 11.8 KB
/
deployments.py
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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
import datetime
import json
import os
import subprocess # nosec
from typing import (
Any,
Dict,
Tuple,
)
import woodchips
import yaml
from config import Config
from containers import Container
from git import Git
from messages import Message
from repos.deployments import store_deployment_details
from repos.locks import (
lookup_project_lock,
update_project_lock,
)
from utils.deployments import (
kill_deployment,
succeed_deployment,
)
from utils.utils import get_utc_timestamp
from webhooks import Webhook
class Deployment:
@staticmethod
def initialize_deployment(webhook: Dict[str, Any]) -> Tuple[Dict[str, Any], str, datetime.datetime]:
"""Initialize the setup for a deployment by cloning or pulling the project
and setting up standard logging info.
"""
logger = woodchips.get(Config.logger_name)
try:
# Kill the deployment if the project is locked
if lookup_project_lock(Webhook.repo_full_name(webhook))['locked'] is True:
kill_deployment(
f'{Webhook.repo_full_name(webhook)} deployments are locked.'
' Please try again later or unlock deployments.',
webhook,
)
except Exception:
logger.error('Could not determine project lock status!')
start_time = get_utc_timestamp()
_ = update_project_lock(
project_name=Webhook.repo_full_name(webhook),
locked=True,
system_lock=True,
)
store_deployment_details(webhook)
# Run git operation first to ensure the config is present and up-to-date
git = Git.update_git_repo(webhook)
webhook_data_key = webhook.get('data')
if webhook_data_key:
logger.debug(f'Pulling Harvey config for {Webhook.repo_full_name(webhook)} from webhook...')
config = webhook_data_key
else:
logger.debug(f'Pulling Harvey config for {Webhook.repo_full_name(webhook)} from config file...')
config = Deployment.open_project_config(webhook)
deployment_type = config.get('deployment_type', Config.default_deployment)
if deployment_type not in Config.supported_deployments:
kill_deployment(
message='Harvey could not run since there was no acceptable deployment specified.',
webhook=webhook,
)
deployment_started_message = (
f'{Message.work_emoji} Harvey started a {deployment_type} for `{Webhook.repo_full_name(webhook)}`.'
)
if Config.use_slack:
Message.send_slack_message(deployment_started_message)
preamble = (
f'{Webhook.repo_full_name(webhook)} {deployment_type.title()}\n'
f'Harvey: v{Config.harvey_version}\n'
f'Deployment Started: {start_time}\n'
f'Deployment ID: {Webhook.repo_commit_id(webhook)}'
)
logger.info(preamble)
configuration = f'\n\nConfiguration:\n{json.dumps(config, indent=4)}' if Config.log_level == 'DEBUG' else ''
commit_details = f'Commit author: {Webhook.repo_commit_author(webhook)}'
if Config.log_level == 'DEBUG':
commit_details += f'\nCommit Details: {Webhook.repo_commit_message(webhook)}'
git_output = f'\n\n{git}' if Config.log_level == 'DEBUG' else ''
execution_time = f'Startup execution time: {get_utc_timestamp() - start_time}'
output = f'{preamble}{configuration}\n\n{commit_details}{git_output}\n\n{execution_time}'
logger.debug(f'{Webhook.repo_full_name(webhook)} {execution_time}')
return config, output, start_time
@staticmethod
def run_deployment(webhook: Dict[str, Any]):
"""After receiving a webhook, spin up a deployment based on the config.
If a Deployment fails, it fails early in the individual functions being called.
"""
try:
logger = woodchips.get(Config.logger_name)
webhook_config, webhook_output, start_time = Deployment.initialize_deployment(webhook)
deployment_type = webhook_config.get('deployment_type', Config.default_deployment).lower()
if deployment_type == 'deploy':
deploy_output = Deployment.deploy(webhook_config, webhook, webhook_output)
healthcheck = webhook_config.get('healthcheck')
healthcheck_messages = ''
docker_client = Container.create_client()
if healthcheck:
container_healthcheck_statuses = {}
for container in healthcheck:
container_healthcheck = Container.run_container_healthcheck(docker_client, container, webhook)
container_healthcheck_statuses[container] = container_healthcheck
if container_healthcheck is True:
healthcheck_message = f'\n{container} Healthcheck: {Message.success_emoji}'
else:
healthcheck_message = f'\n{container} Healthcheck: {Message.failure_emoji}'
healthcheck_messages += healthcheck_message
healthcheck_values = container_healthcheck_statuses.values()
all_healthchecks_passed = any(healthcheck_values) and list(healthcheck_values)[0] is True
else:
all_healthchecks_passed = True # Set to true here since we cannot determine, won't kill the deploy
end_time = get_utc_timestamp()
execution_time = f'Deployment execution time: {end_time - start_time}'
logger.debug(f'{Webhook.repo_full_name(webhook)} {execution_time}')
final_output = f'{webhook_output}\n{deploy_output}\n{execution_time}\n{healthcheck_messages}\n'
if all_healthchecks_passed or not healthcheck:
succeed_deployment(final_output, webhook)
else:
kill_deployment(
message=final_output,
webhook=webhook,
)
elif deployment_type == 'pull':
# We simply assign the final message because if we got this far, the repo has already been pulled
pull_success_message = (
f'Harvey pulled {Webhook.repo_full_name(webhook)} successfully. {Message.success_emoji}\n'
)
logger.info(pull_success_message)
final_output = f'{webhook_output}\n{pull_success_message}'
succeed_deployment(final_output, webhook)
else:
kill_deployment(f'deployment_type invalid, must be one of {Config.supported_deployments}', webhook)
except Exception as error:
# We wrap this entire block in a try/catch in an attempt to catch anything that bubbles to the
# top before hitting sentry as this function is the top-level function called when a thread has
# been spawned.
kill_deployment(str(error), webhook)
@staticmethod
def open_project_config(webhook: Dict[str, Any]):
"""Open the project's config file to assign deployment variables.
Project configs look like the following:
{
"deployment_type": "deploy",
"prod_compose": true,
"healthcheck": [
"container_name_1",
"container_name_2"
]
}
"""
logger = woodchips.get(Config.logger_name)
try:
yml_filepath = os.path.join(Config.projects_path, Webhook.repo_full_name(webhook), '.harvey.yml')
yaml_filepath = os.path.join(Config.projects_path, Webhook.repo_full_name(webhook), '.harvey.yaml')
if os.path.isfile(yml_filepath):
filepath = yml_filepath
elif os.path.isfile(yaml_filepath):
filepath = yaml_filepath
else:
raise FileNotFoundError
with open(filepath, 'r') as config_file:
config = yaml.safe_load(config_file.read())
logger.debug(json.dumps(config, indent=4))
return config
except FileNotFoundError:
kill_deployment(
message='Harvey could not find a ".harvey.yaml" file!',
webhook=webhook,
)
@staticmethod
def deploy(config: Dict[str, Any], webhook: Dict[str, Any], output: str) -> str:
"""Build Stage, used for `deploy` deployments.
This flow doesn't use the Docker API but instead runs `docker compose` commands.
"""
logger = woodchips.get(Config.logger_name)
start_time = get_utc_timestamp()
repo_path = os.path.join(Config.projects_path, Webhook.repo_full_name(webhook))
docker_compose_yml = 'docker-compose.yml'
docker_compose_yaml = 'docker-compose.yaml'
docker_compose_prod_yml = 'docker-compose-prod.yml'
docker_compose_prod_yaml = 'docker-compose-prod.yaml'
# Setup the `docker-compose.yml` file for all deployments based on file spelling
if os.path.exists(os.path.join(repo_path, docker_compose_yml)):
default_compose_filepath = os.path.join(repo_path, docker_compose_yml)
elif os.path.exists(os.path.join(repo_path, docker_compose_yaml)):
default_compose_filepath = os.path.join(repo_path, docker_compose_yaml)
else:
kill_deployment(
message='Harvey could not find a "docker-compose.yaml" file!',
webhook=webhook,
)
if config.get('prod_compose'):
# If this is a prod deployment, setup the `docker-compose-prod.yml` file
# in addition to the base `docker-compose.yml` file
if os.path.exists(os.path.join(repo_path, docker_compose_prod_yml)):
prod_compose_filepath = os.path.join(repo_path, docker_compose_prod_yml)
elif os.path.exists(os.path.join(repo_path, docker_compose_prod_yaml)):
prod_compose_filepath = os.path.join(repo_path, docker_compose_prod_yaml)
else:
kill_deployment(
message='Harvey could not find a "docker-compose-prod.yml" file!',
webhook=webhook,
)
# fmt: off
compose_command = [
'docker',
'compose',
'-f', default_compose_filepath,
'-f', prod_compose_filepath,
'up', '-d',
'--build',
'--force-recreate',
]
# fmt: on
else:
# fmt: off
compose_command = [
'docker',
'compose',
'-f', default_compose_filepath,
'up', '-d',
'--build',
'--force-recreate',
]
# fmt: on
try:
compose_output = subprocess.check_output( # nosec
compose_command,
stderr=subprocess.STDOUT,
text=True,
timeout=Config.operation_timeout,
)
execution_time = f'Deploy stage execution time: {get_utc_timestamp() - start_time}'
final_output = f'{compose_output}\n{execution_time}'
logger.info(final_output)
except subprocess.TimeoutExpired:
final_output = 'Harvey timed out deploying!'
kill_deployment(
message=final_output,
webhook=webhook,
)
except subprocess.CalledProcessError as error:
final_output = f'{output}\nHarvey could not finish the deploy: {error.output}'
kill_deployment(
message=final_output,
webhook=webhook,
)
return final_output