Merge lp:~sinzui/charmworld/heartbeat into lp:~juju-jitsu/charmworld/trunk

Proposed by Curtis Hovey
Status: Merged
Approved by: Curtis Hovey
Approved revision: 338
Merged at revision: 323
Proposed branch: lp:~sinzui/charmworld/heartbeat
Merge into: lp:~juju-jitsu/charmworld/trunk
Diff against target: 328 lines (+258/-12)
6 files modified
charmworld/health.py (+64/-0)
charmworld/routes.py (+2/-11)
charmworld/templates/heartbeat.pt (+28/-0)
charmworld/tests/test_health.py (+94/-0)
charmworld/views/misc.py (+18/-0)
charmworld/views/tests/test_misc.py (+52/-1)
To merge this branch: bzr merge lp:~sinzui/charmworld/heartbeat
Reviewer Review Type Date Requested Status
Richard Harding (community) Approve
Review via email: mp+176530@code.launchpad.net

Commit message

Add /heartbeat to verify the app is operational.

Description of the change

RULES

    * A review of API3 noted that the "latest" routes were updated and
      that they were moved. The routes were added for webops who were confused
      when APIs were removed. The 'latest' hack does not server webops well
      because they don't care about the API. They care about the app's
      operational health
    * We want a page that allows webops to see that the site is up
      * The page can list the checks it performed with pass and fail
        marks.
      * Developers can add checks as the site grows features
      * Webops can adapt the nagios checks to verify the site is operational.

QA

    * Visit http://staging.charmworld.com/heartbeat
      * Verify all the checks pass.
        * They might not pass if there are no featured charms!
      * Verify the response headers state the data is not cacheable.

    * See http://people.canonical.com/~curtis/heartbeat.png

To post a comment you must log in.
Revision history for this message
Richard Harding (rharding) wrote :

A couple of notes. First, thanks for this. I think it's a great step.

This actually has me wondering if we can't move the logic into a charmworld/lib/health.py and keep the logic of adding/checking there make it more reusable. I'm actually thinking I'd love to provide a cli tool to sanity check your charmworld install for devs as well.

In the failing cases where we have on charm, for #IS I'd think that's a failing condition. So in #97 and the other checks like it should that set the status to fail?

Finally, if we're going to FAIL for no featured charms, should we adjust our bootstrap to auto select at least one charm to be featured automatically. This would help with the 'new dev setup' story as well as they could be sure to have featured already and be able to point the GUI at the dev instance without issue.

Sorry, this grew.

tl;dr
1. Looks good!
2. I think it'd be nice if the health checks were a module vs in the view.
3. I think we should auto select a single featured charm during install/setup?
4. Should 0 charms be failing for IS purposes?

review: Approve
lp:~sinzui/charmworld/heartbeat updated
332. By Curtis Hovey

Incomplete data is a fail condition.

333. By Curtis Hovey

DRY code.

334. By Curtis Hovey

Extract the three checks to the health module so that they are portable.

335. By Curtis Hovey

Duplicated the heartbeat tests as health tests. Next step is to make
both test modules test only the concerns.

336. By Curtis Hovey

Updated health tests to directly check the health functions.

337. By Curtis Hovey

Simplify the heartbeat tests to focus of the list of checks.

338. By Curtis Hovey

DRY code.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'charmworld/health.py'
2--- charmworld/health.py 1970-01-01 00:00:00 +0000
3+++ charmworld/health.py 2013-07-24 16:11:42 +0000
4@@ -0,0 +1,64 @@
5+# Copyright 2013 Canonical Ltd. This software is licensed under the
6+# GNU Affero General Public License version 3 (see the file LICENSE).
7+
8+from collections import namedtuple
9+
10+
11+Check = namedtuple('check', 'name status remark')
12+PASS = 'Pass'
13+FAIL = 'Fail'
14+
15+
16+def check_mongodb(request):
17+ """Return a Check of the mongodb charms collection."""
18+ status = FAIL
19+ try:
20+ count = request.db.charms.count()
21+ if count > 0:
22+ remark = '{0} charms found'.format(count)
23+ status = PASS
24+ else:
25+ remark = 'There are no charms. Is ingest running?'
26+ except:
27+ remark = 'The mongodb charm collection is not available.'
28+ finally:
29+ check = Check('charms collection', status, remark)
30+ return check
31+
32+
33+def check_elasticsearch(request):
34+ """Return a Check of the elasticsearch charms index."""
35+ status = FAIL
36+ try:
37+ results = request.index_client.search(terms='')
38+ if results['charm_total'] > 0:
39+ remark = '{0} matches found'.format(results['charm_total'])
40+ status = PASS
41+ else:
42+ remark = 'There are no matches. Is ingest running?'
43+ except:
44+ remark = 'The elasticsearch charm index is not available.'
45+ finally:
46+ check = Check('charms index', status, remark)
47+ return check
48+
49+
50+def check_api(request):
51+ """Return a Check of the API charms interesting end point."""
52+ status = FAIL
53+ try:
54+ from charmworld.views.api import API2
55+ api = API2(request)
56+ interesting = api.charms(path=['interesting'])['result']
57+ if (interesting['new']
58+ and interesting['popular']
59+ and interesting['featured']):
60+ status = PASS
61+ remark = 'Interesting has new, popular, and featured.'
62+ else:
63+ remark = 'Interesting is missing new, popular, or featured.'
64+ except:
65+ remark = 'The API is not available.'
66+ finally:
67+ check = Check('API interesting', status, remark)
68+ return check
69
70=== modified file 'charmworld/routes.py'
71--- charmworld/routes.py 2013-07-19 13:28:20 +0000
72+++ charmworld/routes.py 2013-07-24 16:11:42 +0000
73@@ -83,20 +83,11 @@
74 config.add_route('api-obsolete-0', '/api/0/*remaining')
75 config.add_route('api-obsolete-1', '/api/1/*remaining')
76
77- # Provided for MONITORING purposes only! Not documented in the api docs so
78- # that people don't try to use this as the calls/results could change over
79- # time. Please be aware that IS is using this to verify that services are
80- # up and running. They may need to be consulted when this is changed to a
81- # later API version.
82- config.add_route('api_latest_single', '/api/latest/{endpoint}')
83- config.add_view('charmworld.views.api.API2',
84- route_name='api_latest_single')
85- config.add_route('api_latest', '/api/latest/{endpoint}/*remainder')
86- config.add_view('charmworld.views.api.API2', route_name='api_latest')
87-
88 config.add_route('api_2_single', '/api/2/{endpoint}')
89 config.add_view('charmworld.views.api.API2', route_name='api_2_single')
90 config.add_route('api_2', '/api/2/{endpoint}/*remainder')
91 config.add_view('charmworld.views.api.API2', route_name='api_2')
92
93+ # Monitoring support.
94+ config.add_route('heartbeat', '/heartbeat')
95 return config
96
97=== added file 'charmworld/templates/heartbeat.pt'
98--- charmworld/templates/heartbeat.pt 1970-01-01 00:00:00 +0000
99+++ charmworld/templates/heartbeat.pt 2013-07-24 16:11:42 +0000
100@@ -0,0 +1,28 @@
101+<html tal:define="main load: main.pt" metal:use-macro="main.macros['page']">
102+<metal:block fill-slot="css_include">
103+ <style type="text/css">
104+ .checks th {
105+ text-align: left;
106+ }
107+ </style>
108+</metal:block>
109+
110+<metal:block fill-slot="content">
111+ <h1>Heartbeat application checks</h1>
112+
113+ <p>
114+ This app is operational if all checks pass,
115+ but that does not mean the site is fully operational.
116+ This app, and clients like juju-gui, are impaired though usable,
117+ when data is missing.
118+ </p>
119+
120+ <table class="checks table table-striped">
121+ <tr tal:repeat="check checks">
122+ <th>${check.name}</th>
123+ <td>${check.status}</td>
124+ <td>${check.remark}</td>
125+ </tr>
126+ </table>
127+</metal:block>
128+</html>
129
130=== added file 'charmworld/tests/test_health.py'
131--- charmworld/tests/test_health.py 1970-01-01 00:00:00 +0000
132+++ charmworld/tests/test_health.py 2013-07-24 16:11:42 +0000
133@@ -0,0 +1,94 @@
134+# Copyright 2013 Canonical Ltd. This software is licensed under the
135+# GNU Affero General Public License version 3 (see the file LICENSE).
136+
137+from mock import patch
138+
139+from charmworld.health import (
140+ check_api,
141+ check_elasticsearch,
142+ check_mongodb,
143+)
144+from charmworld.testing import (
145+ factory,
146+ ViewTestBase,
147+)
148+
149+
150+class TestCheckMixin:
151+
152+ def assertCheck(self, check, name, status, remark=None):
153+ self.assertEqual(name, check.name)
154+ self.assertEqual(status, check.status)
155+ if remark is not None:
156+ self.assertEqual(remark, check.remark)
157+
158+ @staticmethod
159+ def makeFailCallable():
160+ def failing(*args, **kwargs):
161+ raise ValueError
162+ return failing
163+
164+
165+class HealthTestCase(ViewTestBase, TestCheckMixin):
166+
167+ def test_check_mongodb_pass(self):
168+ factory.makeCharm(self.db)
169+ check = check_mongodb(self.getRequest())
170+ remark = '1 charms found'
171+ self.assertCheck(check, 'charms collection', 'Pass', remark)
172+
173+ def test_check_mongodb_missing_data_fail(self):
174+ check = check_mongodb(self.getRequest())
175+ remark = 'There are no charms. Is ingest running?'
176+ self.assertCheck(check, 'charms collection', 'Fail', remark)
177+
178+ def test_check_mongodb_no_collection_fail(self):
179+ request = self.getRequest()
180+ with patch.object(request, 'db', new_callable=self.makeFailCallable):
181+ check = check_mongodb(request)
182+ self.assertCheck(check, 'charms collection', 'Fail')
183+
184+ def test_check_elasticsearch_pass(self):
185+ ignore, charm_data = factory.makeCharm(self.db)
186+ self.use_index_client()
187+ self.index_client.index_charm(charm_data)
188+ check = check_elasticsearch(self.getRequest())
189+ remark = '1 matches found'
190+ self.assertCheck(check, 'charms index', 'Pass', remark)
191+
192+ def test_check_elasticsearch_missing_data_fail(self):
193+ self.use_index_client()
194+ request = self.getRequest()
195+ check = check_elasticsearch(request)
196+ remark = 'There are no matches. Is ingest running?'
197+ self.assertCheck(check, 'charms index', 'Fail', remark)
198+
199+ def test_check_elasticsearch_no_index_fail(self):
200+ request = self.getRequest()
201+ # The index is not setup.
202+ check = check_elasticsearch(request)
203+ self.assertCheck(check, 'charms index', 'Fail')
204+
205+ def test_check_api_pass(self):
206+ ignore, charm_data = factory.makeCharm(
207+ self.db, downloads=5, is_featured=True)
208+ self.use_index_client()
209+ self.index_client.index_charm(charm_data)
210+ check = check_api(self.getRequest())
211+ remark = 'Interesting has new, popular, and featured.'
212+ self.assertCheck(check, 'API interesting', 'Pass', remark)
213+
214+ def test_check_api_missing_data_fail(self):
215+ ignore, charm_data = factory.makeCharm(self.db)
216+ self.use_index_client()
217+ self.index_client.index_charm(charm_data)
218+ # There are no featured or downloaded charms.
219+ check = check_api(self.getRequest())
220+ remark = 'Interesting is missing new, popular, or featured.'
221+ self.assertCheck(check, 'API interesting', 'Fail', remark)
222+
223+ def test_check_api_no_matches_fail(self):
224+ request = self.getRequest()
225+ # The index is not setup for the interesting search.
226+ check = check_api(request)
227+ self.assertCheck(check, 'API interesting', 'Fail')
228
229=== modified file 'charmworld/views/misc.py'
230--- charmworld/views/misc.py 2013-02-12 11:36:14 +0000
231+++ charmworld/views/misc.py 2013-07-24 16:11:42 +0000
232@@ -4,6 +4,11 @@
233 from pyramid.view import view_config
234
235 from charmworld import cached_view_config
236+from charmworld.health import (
237+ check_api,
238+ check_elasticsearch,
239+ check_mongodb,
240+)
241
242
243 # Basic app views
244@@ -36,3 +41,16 @@
245 owners.sort(lambda x, y: cmp(y[1], x[1]))
246 owners.pop(0)
247 return {"owners": owners}
248+
249+
250+@view_config(
251+ route_name="heartbeat",
252+ http_cache=(0, {'public': True}),
253+ renderer="charmworld:templates/heartbeat.pt")
254+def heartbeat(request):
255+ """Return a dict summarising the operational state of charmworld."""
256+ checks = []
257+ checks.append(check_mongodb(request))
258+ checks.append(check_elasticsearch(request))
259+ checks.append(check_api(request))
260+ return {'checks': checks}
261
262=== modified file 'charmworld/views/tests/test_misc.py'
263--- charmworld/views/tests/test_misc.py 2013-02-15 17:37:23 +0000
264+++ charmworld/views/tests/test_misc.py 2013-07-24 16:11:42 +0000
265@@ -10,7 +10,13 @@
266 from paste.deploy import loadapp
267 from paste.deploy.config import PrefixMiddleware
268
269-from charmworld.testing import WebTestBase
270+from charmworld.testing import (
271+ factory,
272+ ViewTestBase,
273+ WebTestBase,
274+)
275+from charmworld.views.misc import heartbeat
276+from charmworld.tests.test_health import TestCheckMixin
277
278
279 class TestAppConfig(unittest.TestCase):
280@@ -81,3 +87,48 @@
281 http_count += 1
282 self.assertEqual('http', match.group(1))
283 self.assertTrue(https_count == http_count)
284+
285+
286+class HeartbeatWebTestCase(WebTestBase):
287+
288+ def test_heartbeat_cache_control(self):
289+ # Never cache heartbeats.
290+ response = self.app.get('/heartbeat', status=200)
291+ body = response.body
292+ # No data was setup, so heartbeat knows the site is not operational.
293+ self.assertIn("Fail", body)
294+ self.assertIn(
295+ ('Cache-Control',
296+ 'max-age=0, must-revalidate, no-cache, no-store, public'),
297+ response.headerlist)
298+
299+
300+class HeartbeatViewTestCase(ViewTestBase, TestCheckMixin):
301+
302+ def test_all_checks_pass(self):
303+ # All checks pass when the services are available and data is returned.
304+ ignore, charm_data = factory.makeCharm(
305+ self.db, downloads=5, is_featured=True)
306+ self.use_index_client()
307+ self.index_client.index_charm(charm_data)
308+ response = heartbeat(self.getRequest())
309+ checks = response['checks']
310+ remark = '1 charms found'
311+ self.assertCheck(checks[0], 'charms collection', 'Pass', remark)
312+ remark = '1 matches found'
313+ self.assertCheck(checks[1], 'charms index', 'Pass', remark)
314+ remark = 'Interesting has new, popular, and featured.'
315+ self.assertCheck(checks[2], 'API interesting', 'Pass', remark)
316+
317+ def test_checks_fail(self):
318+ # When services or data are not available, the checks fail.
319+ # No setup is performed, creating a case where services and data
320+ # are not available
321+ response = heartbeat(self.getRequest())
322+ checks = response['checks']
323+ remark = 'There are no charms. Is ingest running?'
324+ self.assertCheck(checks[0], 'charms collection', 'Fail', remark)
325+ remark = 'The elasticsearch charm index is not available.'
326+ self.assertCheck(checks[1], 'charms index', 'Fail', remark)
327+ remark = 'The API is not available.'
328+ self.assertCheck(checks[2], 'API interesting', 'Fail', remark)

Subscribers

People subscribed via source and target branches