Skip to content

Commit 51cab9d

Browse files
author
Matthew Fisher
authored
feat(api): add deploy hooks (#1168)
1 parent f132b25 commit 51cab9d

7 files changed

Lines changed: 191 additions & 35 deletions

File tree

charts/controller/templates/controller-deployment.yaml

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,16 @@ spec:
4242
ports:
4343
- containerPort: 8000
4444
name: http
45-
{{- if or (.Values.limits_cpu) (.Values.limits_memory)}}
45+
{{- if or (.Values.limits_cpu) (.Values.limits_memory) }}
4646
resources:
4747
limits:
4848
{{- if (.Values.limits_cpu) }}
4949
cpu: {{.Values.limits_cpu}}
50-
{{- end}}
50+
{{- end }}
5151
{{- if (.Values.limits_memory) }}
5252
memory: {{.Values.limits_memory}}
53-
{{- end}}
54-
{{- end}}
53+
{{- end }}
54+
{{- end }}
5555
env:
5656
- name: REGISTRATION_MODE
5757
value: {{ .Values.registration_mode }}
@@ -75,6 +75,15 @@ spec:
7575
key: image
7676
- name: "IMAGE_PULL_POLICY"
7777
value: "{{ .Values.app_pull_policy }}"
78+
{{- if (.Values.deploy_hook_urls) }}
79+
- name: DEIS_DEPLOY_HOOK_URLS
80+
value: "{{ .Values.deploy_hook_urls }}"
81+
- name: DEIS_DEPLOY_HOOK_SECRET_KEY
82+
valueFrom:
83+
secretKeyRef:
84+
name: deploy-hook-key
85+
key: secret-key
86+
{{- end }}
7887
- name: DEIS_SECRET_KEY
7988
valueFrom:
8089
secretKeyRef:
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
apiVersion: v1
2+
kind: Secret
3+
metadata:
4+
name: deploy-hook-key
5+
labels:
6+
heritage: deis
7+
annotations:
8+
"helm.sh/hook": pre-install
9+
type: Opaque
10+
data:
11+
secret-key: {{ randAscii 64 | b64enc }}

charts/controller/values.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ org: "deisci"
22
pull_policy: "Always"
33
docker_tag: canary
44
app_pull_policy: "Always"
5+
# A comma-separated list of URLs to send app release information to
6+
# See https://deis.com/docs/workflow/managing-workflow/deploy-hooks
7+
deploy_hook_urls: ""
58
# limits_cpu: "100m"
69
# limits_memory: "50Mi"
710
# Possible values are:

rootfs/api/models/__init__.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,49 @@
33
"""
44
Data models for the Deis API.
55
"""
6+
import hashlib
7+
import hmac
68
import importlib
79
import logging
8-
import uuid
910
import morph
1011
import re
12+
import urllib.parse
13+
import uuid
1114

1215
from django.conf import settings
1316
from django.db import models
1417
from django.db.models.signals import post_delete, post_save
1518
from django.dispatch import receiver
16-
1719
from rest_framework.exceptions import ValidationError
1820
from rest_framework.authtoken.models import Token
21+
import requests
22+
from requests_toolbelt import user_agent
1923

24+
from api import __version__ as deis_version
2025
from api.exceptions import DeisException, AlreadyExists, ServiceUnavailable, UnprocessableEntity # noqa
2126
from api.utils import dict_merge
2227
from scheduler import KubeException
2328

29+
2430
logger = logging.getLogger(__name__)
2531

32+
session = None
33+
34+
35+
def get_session():
36+
global session
37+
if session is None:
38+
session = requests.Session()
39+
session.headers = {
40+
# https://toolbelt.readthedocs.org/en/latest/user-agent.html#user-agent-constructor
41+
'User-Agent': user_agent('Deis Controller', deis_version),
42+
}
43+
# `mount` a custom adapter that retries failed connections for HTTP and HTTPS requests.
44+
# http://docs.python-requests.org/en/latest/api/#requests.adapters.HTTPAdapter
45+
session.mount('http://', requests.adapters.HTTPAdapter(max_retries=10))
46+
session.mount('https://', requests.adapters.HTTPAdapter(max_retries=10))
47+
return session
48+
2649

2750
def validate_label(value):
2851
"""
@@ -165,16 +188,48 @@ def _log_instance_removed(**kwargs):
165188
logger.info(message)
166189

167190

168-
# special case: log the release summary
169-
def _log_release_created(**kwargs):
191+
# special case: log the release summary and send release info to each deploy hook
192+
def _hook_release_created(**kwargs):
170193
if kwargs.get('created'):
171194
release = kwargs['instance']
172195
# append release lifecycle logs to the app
173196
release.app.log(release.summary)
174197

198+
for deploy_hook in settings.DEIS_DEPLOY_HOOK_URLS:
199+
url = deploy_hook
200+
params = {
201+
'app': release.app,
202+
'release': 'v{}'.format(release.version),
203+
'release_summary': release.summary,
204+
'sha': '',
205+
'user': release.owner,
206+
}
207+
if release.build is not None:
208+
params['sha'] = release.build.sha
209+
210+
# order of the query arguments is important when computing the HMAC auth secret
211+
params = sorted(params.items())
212+
url += '?{}'.format(urllib.parse.urlencode(params))
213+
214+
headers = {}
215+
if settings.DEIS_DEPLOY_HOOK_SECRET_KEY is not None:
216+
headers['Authorization'] = hmac.new(
217+
settings.DEIS_DEPLOY_HOOK_SECRET_KEY.encode('utf-8'),
218+
url.encode('utf-8'),
219+
hashlib.sha1
220+
).hexdigest()
221+
222+
try:
223+
get_session().post(url, headers=headers)
224+
# just notify with the base URL, disregard the added URL query
225+
release.app.log('Deploy hook sent to {}'.format(deploy_hook))
226+
except requests.RequestException as e:
227+
release.app.log('An error occurred while sending the deploy hook to {}: {}'.format(
228+
deploy_hook, e), logging.ERROR)
229+
175230

176231
# Log significant app-related events
177-
post_save.connect(_log_release_created, sender=Release, dispatch_uid='api.models.log')
232+
post_save.connect(_hook_release_created, sender=Release, dispatch_uid='api.models.log')
178233

179234
post_save.connect(_log_instance_created, sender=Build, dispatch_uid='api.models.log')
180235
post_save.connect(_log_instance_added, sender=Certificate, dispatch_uid='api.models.log')

rootfs/api/models/app.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import random
1010
import re
1111
import requests
12-
from requests_toolbelt import user_agent
1312
import string
1413
import time
1514
from urllib.parse import urljoin
@@ -19,7 +18,7 @@
1918
from rest_framework.exceptions import ValidationError, NotFound
2019
from jsonfield import JSONField
2120

22-
from api import __version__ as deis_version
21+
from api.models import get_session
2322
from api.models import UuidAuditedModel, AlreadyExists, DeisException, ServiceUnavailable
2423

2524
from api.utils import generate_app_name, async_run
@@ -33,23 +32,6 @@
3332

3433
logger = logging.getLogger(__name__)
3534

36-
session = None
37-
38-
39-
def get_session():
40-
global session
41-
if session is None:
42-
session = requests.Session()
43-
session.headers = {
44-
# https://toolbelt.readthedocs.org/en/latest/user-agent.html#user-agent-constructor
45-
'User-Agent': user_agent('Deis Controller', deis_version),
46-
}
47-
# `mount` a custom adapter that retries failed connections for HTTP and HTTPS requests.
48-
# http://docs.python-requests.org/en/latest/api/#requests.adapters.HTTPAdapter
49-
session.mount('http://', requests.adapters.HTTPAdapter(max_retries=10))
50-
session.mount('https://', requests.adapters.HTTPAdapter(max_retries=10))
51-
return session
52-
5335

5436
# http://kubernetes.io/v1.1/docs/design/identifiers.html
5537
def validate_app_id(value):

rootfs/api/settings/production.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,13 @@
294294
# where it roughly goes BATCHES * TIMEOUT = global timeout
295295
DEIS_DEPLOY_TIMEOUT = int(os.environ.get('DEIS_DEPLOY_TIMEOUT', 120))
296296

297+
try:
298+
DEIS_DEPLOY_HOOK_URLS = os.environ['DEIS_DEPLOY_HOOK_URLS'].split(',')
299+
except KeyError:
300+
DEIS_DEPLOY_HOOK_URLS = []
301+
302+
DEIS_DEPLOY_HOOK_SECRET_KEY = os.environ.get('DEIS_DEPLOY_HOOK_SECRET_KEY', None)
303+
297304
KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT = os.environ.get('KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT', None) # noqa
298305

299306
# How long k8s waits for a pod to finish work after a SIGTERM before sending SIGKILL

rootfs/api/tests/test_release.py

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
"""
2-
Unit tests for the Deis api app.
3-
4-
Run the tests with "./manage.py test api"
5-
"""
6-
7-
1+
import hashlib
2+
import hmac
83
import json
4+
import logging
5+
import requests
96
import uuid
107

118
from django.contrib.auth.models import User
@@ -390,6 +387,98 @@ def test_release_get_port(self, mock_requests):
390387

391388
# TODO(bacongobbler): test dockerfile ports
392389

390+
@override_settings(DEIS_DEPLOY_HOOK_URLS=['http://deis.rocks'])
391+
@mock.patch('api.models.logger')
392+
def test_deploy_hooks_logged(self, mock_requests, mock_logger):
393+
"""
394+
Verifies that a configured deploy hook is dumped into the logs when a release is created.
395+
"""
396+
app_id = 'foo'
397+
body = {'sha': '123456', 'image': 'autotest/example'}
398+
399+
mr_rocks = mock_requests.post('http://deis.rocks?app={app_id}&user={self.user.username}&sha=&release=v1&release_summary={self.user.username}+created+initial+release'.format(**locals())) # noqa
400+
self.create_app(app_id)
401+
# check app logs
402+
exp_msg = "[{app_id}]: Sent deploy hook to http://deis.rocks".format(**locals())
403+
mock_logger.log.has_calls(logging.INFO, exp_msg)
404+
self.assertTrue(mr_rocks.called)
405+
self.assertEqual(mr_rocks.call_count, 1)
406+
407+
# override DEIS_DEPLOY_HOOK_URLS again, ensuring that the new deploy hooks get the same
408+
# treatment
409+
url = '/v2/apps/{app_id}/builds'.format(**locals())
410+
with self.settings(DEIS_DEPLOY_HOOK_URLS=['http://deis.ninja', 'http://cat.dog']):
411+
mr_ninja = mock_requests.post("http://deis.ninja?app={app_id}&user={self.user.username}&sha=123456&release=v2&release_summary={self.user.username}+deployed+123456".format(**locals())) # noqa
412+
mr_catdog = mock_requests.post("http://cat.dog?app={app_id}&user={self.user.username}&sha=123456&release=v2&release_summary={self.user.username}+deployed+123456".format(**locals())) # noqa
413+
response = self.client.post(url, body)
414+
self.assertEqual(response.status_code, 201, response.data)
415+
416+
# check app logs
417+
exp_msg = "[{app_id}]: Sent deploy hook to http://deis.ninja".format(**locals())
418+
mock_logger.log.has_calls(logging.INFO, exp_msg)
419+
self.assertTrue(mr_ninja.called)
420+
self.assertEqual(mr_ninja.call_count, 1)
421+
exp_msg = "[{app_id}]: Sent deploy hook to http://cat.dog".format(**locals())
422+
mock_logger.log.has_calls(logging.INFO, exp_msg)
423+
self.assertTrue(mr_catdog.called)
424+
self.assertEqual(mr_catdog.call_count, 1)
425+
sha = '2345678'
426+
body['sha'] = sha
427+
# Ensure that when requests.Exception is raised, the error is noted and life carries on.
428+
with self.settings(DEIS_DEPLOY_HOOK_URLS=['http://cat.ninja', 'http://deis.dog']):
429+
def raise_callback(request, context):
430+
raise requests.ConnectionError('poop')
431+
mr_ninja = mock_requests.post("http://cat.ninja?app={app_id}&user={self.user.username}&sha={sha}&release=v3&release_summary={self.user.username}+deployed+{sha}". format(**locals()), text=raise_callback) # noqa
432+
mr_catdog = mock_requests.post("http://deis.dog?app={app_id}&user={self.user.username}&sha={sha}&release=v3&release_summary={self.user.username}+deployed+{sha}". format(**locals())) # noqa
433+
response = self.client.post(url, body)
434+
self.assertEqual(response.status_code, 201, response.data)
435+
436+
# check app logs
437+
exp_msg = "[{app_id}]: An error occurred while sending the deploy hook to http://cat.ninja: poop".format(**locals()) # noqa
438+
mock_logger.log.has_calls(logging.ERROR, exp_msg)
439+
self.assertTrue(mr_ninja.called)
440+
self.assertEqual(mr_ninja.call_count, 1)
441+
exp_msg = "[{app_id}]: Sent deploy hook to http://deis.dog".format(**locals())
442+
mock_logger.log.has_calls(logging.INFO, exp_msg)
443+
self.assertTrue(mr_catdog.called)
444+
self.assertEqual(mr_catdog.call_count, 1)
445+
446+
# ensure that when a secret key is used, a Deis-Signature header is present
447+
# which was generated by using HMAC-SHA1 against the target URL
448+
secret = 'Hasta la vista, baby.'
449+
hook_url = 'http://deis.com'
450+
sha = '3456789'
451+
body['sha'] = sha
452+
# target URL MUST be in the exact alphabetized order when calculating the HMAC signature.
453+
target_url = '{}?app={}&release=v4&release_summary={}+deployed+{}&sha={}&user={}'.format(
454+
hook_url,
455+
app_id,
456+
self.user.username,
457+
sha,
458+
sha,
459+
self.user.username,
460+
)
461+
signature = hmac.new(
462+
secret.encode('utf-8'),
463+
target_url.encode('utf-8'),
464+
hashlib.sha1,
465+
).hexdigest()
466+
request_headers = {'Authorization': signature}
467+
468+
with self.settings(DEIS_DEPLOY_HOOK_SECRET_KEY=secret, DEIS_DEPLOY_HOOK_URLS=[hook_url]):
469+
mr_terminator = mock_requests.post(
470+
target_url,
471+
request_headers=request_headers,
472+
)
473+
response = self.client.post(url, body)
474+
self.assertEqual(response.status_code, 201, response.data)
475+
476+
# check app logs
477+
exp_msg = "[{app_id}]: Sent deploy hook to {hook_url}".format(**locals())
478+
mock_logger.log.has_calls(logging.INFO, exp_msg)
479+
self.assertTrue(mr_terminator.called)
480+
self.assertEqual(mr_terminator.call_count, 1)
481+
393482
@override_settings(REGISTRY_LOCATION="off-cluster")
394483
def test_release_external_registry(self, mock_requests):
395484
"""

0 commit comments

Comments
 (0)