Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(289)

Side by Side Diff: third_party/buildbot_7_12/buildbot/test/test_status_push.py

Issue 12207158: Bye bye buildbot 0.7.12. (Closed) Base URL: svn://svn.chromium.org/chrome/trunk/tools/build
Patch Set: Created 7 years, 10 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
OLDNEW
(Empty)
1 # -*- test-case-name: buildbot.test.test_status_push -*-
2
3 import re
4 import os
5
6 try:
7 import simplejson as json
8 except ImportError:
9 import json
10
11 from twisted.internet import defer, reactor
12 from twisted.python import log
13 from twisted.trial import unittest
14 from twisted.web import server, resource
15 from twisted.web.error import Error
16 from zope.interface import implements, Interface
17
18 from buildbot import master
19 from buildbot.changes import changes
20 from buildbot.slave import bot
21 from buildbot.status import status_push
22 from buildbot.status.persistent_queue import IQueue, ReadFile
23 from buildbot.test.runutils import RunMixin
24 from buildbot.test.status_push_server import EventsHandler
25
26
27 config_base = """
28 from buildbot.process import factory
29 from buildbot.buildslave import BuildSlave
30 from buildbot.config import BuilderConfig
31 from buildbot.scheduler import Scheduler
32 from buildbot.status.persistent_queue import IQueue
33 from buildbot.status.status_push import StatusPush, HttpStatusPush
34 from buildbot.steps import dummy
35
36 BuildmasterConfig = c = {}
37
38 c['slaves'] = [BuildSlave('bot1', 'sekrit')]
39 c['schedulers'] = [Scheduler('dummy', None, 120, ['dummy'])]
40
41 f1 = factory.QuickBuildFactory('fakerep', 'cvsmodule', configure=None)
42 c['builders'] = [
43 BuilderConfig(name='dummy', slavename='bot1', factory=f1,
44 builddir='quickdir', slavebuilddir='slavequickdir'),
45 ]
46 c['slavePortnum'] = 0
47 c['projectUrl'] = 'example.com/yay'
48 c['projectName'] = 'Pouet'
49 c['buildbotURL'] = 'build.example.com/yo'
50
51 def doNothing(self):
52 # Creates self.fake_queue to store the object.
53 assert IQueue.providedBy(self.queue)
54 if not hasattr(self, 'fake_queue'):
55 self.fake_queue = []
56 items = self.queue.popChunk()
57 self.fake_queue.extend(items)
58 self.queueNextServerPush()
59 """
60
61 config_no_http = (config_base + """
62 c['status'] = [StatusPush(serverPushCb=doNothing)]
63 """)
64
65 config_http = (config_base + """
66 c['status'] = [HttpStatusPush('http://127.0.0.1:<PORT>/receiver')]
67 """)
68
69 config_no_http_no_filter = (config_base + """
70 c['status'] = [StatusPush(serverPushCb=doNothing, filter=False)]
71 """)
72
73 config_http_no_filter = (config_base + """
74 c['status'] = [HttpStatusPush('http://127.0.0.1:<PORT>/receiver', filter=False)]
75 """)
76
77 EXPECTED = [
78 {
79 'event': 'builderAdded',
80 'payload': {
81 'builder': {
82 "category": None,
83 "cachedBuilds": [],
84 "basedir": "quickdir",
85 "pendingBuilds": [],
86 "state": "offline",
87 "slaves": ["bot1"],
88 "currentBuilds": []
89 },
90 'builderName': 'dummy',
91 }
92 },
93 {
94 "event": "builderChangedState",
95 "payload": {
96 'state': 'offline',
97 'builderName': 'dummy'
98 }
99 },
100 {
101 "event": "start",
102 "payload": {
103 'status': {
104 "buildbotURL": 'build.example.com/yo',
105 "projectName": 'Pouet',
106 'projectURL': None,
107 }
108 }
109 },
110 {
111 'event': 'slaveConnected',
112 'payload': {
113 'slave': {
114 'access_uri': None,
115 'admin': 'one',
116 'connected': True,
117 'host': None,
118 'name': 'bot1',
119 'runningBuilds': [],
120 'version': '0.7.12'
121 }
122 }
123 },
124 {
125 'event': 'builderChangedState',
126 'payload': {
127 'state': 'idle',
128 'builderName': 'dummy'
129 },
130 },
131 {
132 "event": "changeAdded",
133 "payload": {
134 'change': {
135 "category": None,
136 "files": ["Makefile", "foo/bar.c"],
137 "who": "bob",
138 "when": "n0w",
139 "number": 1,
140 "comments": "changed stuff",
141 "branch": None,
142 "revlink": "",
143 "properties": [],
144 "revision": None
145 }
146 }
147 },
148 {
149 'event': 'requestSubmitted',
150 'payload': {
151 'request': {
152 'builderName': 'test_builder',
153 'builds': [],
154 'source': {
155 'branch': None,
156 'changes': [],
157 'hasPatch': False,
158 'revision': None
159 },
160 'submittedAt': 'yesterday',
161 }
162 }
163 },
164 {
165 'event': 'builderChangedState',
166 'payload': {
167 'state': 'building',
168 'builderName': 'dummy'
169 }
170 },
171 {
172 'event': 'buildStarted',
173 'payload': {
174 'build': {
175 'blame': [],
176 'builderName': 'dummy',
177 'changes': [],
178 'currentStep': None,
179 'eta': None,
180 'number': 0,
181 'properties': [
182 ['branch', None, 'Build'],
183 ['buildername', 'dummy', 'Build'],
184 ['buildnumber', 0, 'Build'],
185 ['revision', None, 'Build'],
186 ['slavename', 'bot1', 'BuildSlave']
187 ],
188 'reason': 'forced build',
189 'requests': [
190 {
191 'builderName': 'test_builder',
192 'builds': [],
193 'source': {
194 'branch': None,
195 'changes': [],
196 'hasPatch': False,
197 'revision': None
198 },
199 'submittedAt': 'yesterday'
200 }
201 ],
202 'results': None,
203 'slave': 'bot1',
204 'sourceStamp': {
205 'branch': None,
206 'hasPatch': False,
207 'changes': [],
208 'revision': None
209 },
210 'steps': [
211 {
212 'eta': None,
213 'expectations': [],
214 'isFinished': False,
215 'isStarted': False,
216 'name': 'cvs',
217 'results': [[None, []], []],
218 'statistics': {},
219 'text': ['updating'],
220 'times': [None, None],
221 'urls': {}
222 },
223 {
224 'eta': None,
225 'expectations': [],
226 'isFinished': False,
227 'isStarted': False,
228 'name': 'compile',
229 'results': [[None, []], []],
230 'statistics': {},
231 'text': ['compiling'],
232 'times': [None, None],
233 'urls': {}
234 },
235 {
236 'eta': None,
237 'expectations': [],
238 'isFinished': False,
239 'isStarted': False,
240 'name': 'test',
241 'results': [[None, []], []],
242 'statistics': {},
243 'text': ['testing'],
244 'times': [None, None],
245 'urls': {}
246 }
247 ],
248 'text': [],
249 'times': [123, None]
250 }
251 }
252 },
253 {
254 'event': 'stepStarted',
255 'payload': {
256 'step': {
257 'eta': None,
258 'expectations': [],
259 'isFinished': False,
260 'isStarted': True,
261 'name': 'cvs',
262 'results': [[None, []], []],
263 'statistics': {},
264 'text': ['updating'],
265 'times': [123, None],
266 'urls': {}
267 },
268 'properties': [
269 ['branch', None, 'Build'],
270 ['buildername', 'dummy', 'Build'],
271 ['buildnumber', 0, 'Build'],
272 ['revision', None, 'Build'],
273 ['slavename', 'bot1', 'BuildSlave']
274 ],
275 }
276 },
277 {
278 'event': 'stepFinished',
279 'payload': {
280 'step': {
281 'eta': None,
282 'expectations': [],
283 'isFinished': True,
284 'isStarted': True,
285 'name': 'cvs',
286 'results': [2, ['cvs']],
287 'statistics': {},
288 'text': ['update', 'failed'],
289 'times': [123, None],
290 'urls': {}
291 },
292 'properties': [
293 ['branch', None, 'Build'],
294 ['buildername', 'dummy', 'Build'],
295 ['buildnumber', 0, 'Build'],
296 ['revision', None, 'Build'],
297 ['slavename', 'bot1', 'BuildSlave']
298 ],
299 }
300 },
301 {
302 'event': 'buildFinished',
303 'payload': {
304 'build': {
305 'blame': [],
306 'builderName': 'dummy',
307 'changes': [],
308 'currentStep': None,
309 'eta': None,
310 'number': 0,
311 'properties': [
312 ['branch', None, 'Build'],
313 ['buildername', 'dummy', 'Build'],
314 ['buildnumber', 0, 'Build'],
315 ['revision', None, 'Build'],
316 ['slavename', 'bot1', 'BuildSlave']
317 ],
318 'reason': 'forced build',
319 'requests': [
320 {
321 'builderName': 'test_builder',
322 'builds': [0],
323 'source': {
324 'branch': None,
325 'hasPatch': False,
326 'changes': [],
327 'revision': None},
328 'submittedAt': 'yesterday'
329 }
330 ],
331 'results': 2,
332 'slave': 'bot1',
333 'sourceStamp': {
334 'branch': None,
335 'changes': [],
336 'hasPatch': False,
337 'revision': None
338 },
339 'steps': [
340 {
341 'eta': None,
342 'expectations': [],
343 'isFinished': True,
344 'isStarted': True,
345 'name': 'cvs',
346 'results': [2, ['cvs']],
347 'statistics': {},
348 'text': ['update', 'failed'],
349 'times': [345, None],
350 'urls': {}
351 },
352 {
353 'eta': None,
354 'expectations': [],
355 'isFinished': False,
356 'isStarted': False,
357 'name': 'compile',
358 'results': [[None, []], []],
359 'statistics': {},
360 'text': ['compiling'],
361 'times': [345, None],
362 'urls': {}
363 },
364 {
365 'eta': None,
366 'expectations': [],
367 'isFinished': False,
368 'isStarted': False,
369 'name': 'test',
370 'results': [[None, []], []],
371 'statistics': {},
372 'text': ['testing'],
373 'times': [345, None],
374 'urls': {}
375 }
376 ],
377 'text': ['failed', 'cvs'],
378 'times': [123, None]
379 },
380 }
381 },
382 {
383 'event': 'builderChangedState',
384 'payload': {
385 'state': 'idle',
386 'builderName': 'dummy'
387 }
388 },
389 {
390 'event': 'slaveDisconnected',
391 'payload': {
392 'slavename': 'bot1'
393 }
394 },
395 {
396 'event': 'builderChangedState',
397 'payload': {
398 'state': 'offline',
399 'builderName': 'dummy',
400 }
401 },
402 {
403 "event": "shutdown",
404 "payload": {
405 'status': {
406 "buildbotURL": 'build.example.com/yo',
407 "projectName": 'Pouet',
408 'projectURL': None,
409 }
410 }
411 },
412 ]
413
414 EXPECTED_SHORT = [
415 {
416 'event': 'builderAdded',
417 'payload': {
418 'builder': {
419 "basedir": "quickdir",
420 "state": "offline",
421 "slaves": ["bot1"],
422 },
423 'builderName': 'dummy',
424 }
425 },
426 {
427 "event": "builderChangedState",
428 "payload": {
429 'state': 'offline',
430 'builderName': 'dummy'
431 }
432 },
433 {
434 "event": "start",
435 "payload": {
436 'status': {
437 "buildbotURL": 'build.example.com/yo',
438 "projectName": 'Pouet',
439 }
440 }
441 },
442 {
443 'event': 'slaveConnected',
444 'payload': {
445 'slave': {
446 'admin': 'one',
447 'connected': True,
448 'name': 'bot1',
449 'version': '0.7.12'
450 }
451 }
452 },
453 {
454 'event': 'builderChangedState',
455 'payload': {
456 'state': 'idle',
457 'builderName': 'dummy'
458 },
459 },
460 {
461 "event": "changeAdded",
462 "payload": {
463 'change': {
464 "files": ["Makefile", "foo/bar.c"],
465 "who": "bob",
466 "when": "n0w",
467 "number": 1,
468 "comments": "changed stuff",
469 }
470 }
471 },
472 {
473 'event': 'requestSubmitted',
474 'payload': {
475 'request': {
476 'builderName': 'test_builder',
477 'submittedAt': 'yesterday',
478 }
479 }
480 },
481 {
482 'event': 'builderChangedState',
483 'payload': {
484 'state': 'building',
485 'builderName': 'dummy'
486 }
487 },
488 {
489 'event': 'buildStarted',
490 'payload': {
491 'build': {
492 'builderName': 'dummy',
493 'properties': [
494 ['branch', None, 'Build'],
495 ['buildername', 'dummy', 'Build'],
496 ['buildnumber', 0, 'Build'],
497 ['revision', None, 'Build'],
498 ['slavename', 'bot1', 'BuildSlave']
499 ],
500 'reason': 'forced build',
501 'requests': [
502 {
503 'builderName': 'test_builder',
504 'submittedAt': 'yesterday'
505 }
506 ],
507 'slave': 'bot1',
508 'steps': [
509 {
510 'name': 'cvs',
511 'text': ['updating'],
512 },
513 {
514 'name': 'compile',
515 'text': ['compiling'],
516 },
517 {
518 'name': 'test',
519 'text': ['testing'],
520 }
521 ],
522 'times': [123, None]
523 }
524 }
525 },
526 {
527 'event': 'stepStarted',
528 'payload': {
529 'step': {
530 'isStarted': True,
531 'name': 'cvs',
532 'text': ['updating'],
533 'times': [123, None],
534 },
535 'properties': [
536 ['branch', None, 'Build'],
537 ['buildername', 'dummy', 'Build'],
538 ['buildnumber', 0, 'Build'],
539 ['revision', None, 'Build'],
540 ['slavename', 'bot1', 'BuildSlave']
541 ],
542 }
543 },
544 {
545 'event': 'stepFinished',
546 'payload': {
547 'step': {
548 'isFinished': True,
549 'isStarted': True,
550 'name': 'cvs',
551 'results': [2, ['cvs']],
552 'text': ['update', 'failed'],
553 'times': [123, None],
554 },
555 'properties': [
556 ['branch', None, 'Build'],
557 ['buildername', 'dummy', 'Build'],
558 ['buildnumber', 0, 'Build'],
559 ['revision', None, 'Build'],
560 ['slavename', 'bot1', 'BuildSlave']
561 ],
562 }
563 },
564 {
565 'event': 'buildFinished',
566 'payload': {
567 'build': {
568 'builderName': 'dummy',
569 'properties': [
570 ['branch', None, 'Build'],
571 ['buildername', 'dummy', 'Build'],
572 ['buildnumber', 0, 'Build'],
573 ['revision', None, 'Build'],
574 ['slavename', 'bot1', 'BuildSlave']
575 ],
576 'reason': 'forced build',
577 'requests': [
578 {
579 'builderName': 'test_builder',
580 'submittedAt': 'yesterday'
581 }
582 ],
583 'results': 2,
584 'slave': 'bot1',
585 'steps': [
586 {
587 'isFinished': True,
588 'isStarted': True,
589 'name': 'cvs',
590 'results': [2, ['cvs']],
591 'text': ['update', 'failed'],
592 'times': [345, None],
593 },
594 {
595 'name': 'compile',
596 'text': ['compiling'],
597 },
598 {
599 'name': 'test',
600 'text': ['testing'],
601 }
602 ],
603 'text': ['failed', 'cvs'],
604 'times': [123, None]
605 },
606 }
607 },
608 {
609 'event': 'builderChangedState',
610 'payload': {
611 'state': 'idle',
612 'builderName': 'dummy'
613 }
614 },
615 {
616 'event': 'slaveDisconnected',
617 'payload': {
618 'slavename': 'bot1'
619 }
620 },
621 {
622 'event': 'builderChangedState',
623 'payload': {
624 'state': 'offline',
625 'builderName': 'dummy',
626 }
627 },
628 {
629 "event": "shutdown",
630 "payload": {
631 'status': {
632 "buildbotURL": 'build.example.com/yo',
633 "projectName": 'Pouet',
634 }
635 }
636 },
637 ]
638
639 class Receiver(resource.Resource):
640 isLeaf = True
641 def __init__(self):
642 self.packets = []
643
644 def render_POST(self, request):
645 for packet in request.args['packets']:
646 data = json.loads(packet)
647 for p in data:
648 self.packets.append(p)
649 return "ok"
650
651
652 class StatusPushTestBase(RunMixin, unittest.TestCase):
653 def getStatusPush(self):
654 for i in self.master.services:
655 if isinstance(i, status_push.StatusPush):
656 return i
657
658 def init(self, config):
659 # The master.
660 self.master.loadConfig(config)
661 self.master.readConfig = True
662 self.assertTrue(self.getStatusPush())
663 self.master.startService()
664
665 def tearDown(self):
666 """Similar to RunMixin.tearDown but skip over if self.master is None
667 since we do test stopService."""
668 log.msg("doing tearDown")
669 if self.master:
670 d = self.shutdownAllSlaves()
671 d.addCallback(self._tearDown_1)
672 d.addCallback(self._tearDown_2)
673 return d
674 else:
675 return defer.succeed(None)
676
677 def verifyItems(self, items, expected):
678 def QuickFix(item, *args):
679 """Strips time-specific values.
680
681 None means an array.
682 Anything else is a key to a dict."""
683 args = list(args)
684 value = args.pop()
685
686 def Loop(item, value, *args):
687 args = list(args)
688 arg = args.pop(0)
689 if isinstance(item, list) and arg is None:
690 for i in item:
691 Loop(i, value, *args)
692 elif isinstance(item, dict):
693 if len(args) == 0 and arg in item:
694 item[arg] = value
695 elif arg in item:
696 Loop(item[arg], value, *args)
697
698 Loop(item, value, *args)
699
700 def FindItem(items, event, *args):
701 for i in items:
702 if i['event'] == event:
703 QuickFix(i, *args)
704
705 # Cleanup time dependent values. It'd be nice to mock datetime instead.
706 for i in range(len(items)):
707 item = items[i]
708 del item['started']
709 del item['timestamp']
710 self.assertEqual('Pouet', item.pop('project'))
711 self.assertEqual(i + 1, item.pop('id'))
712
713 FindItem(items, 'changeAdded', 'payload', 'change', 'when',
714 'n0w')
715 FindItem(items, 'requestSubmitted', 'payload', 'request',
716 'submittedAt', 'yesterday')
717
718 FindItem(items, 'buildStarted', 'payload', 'build', 'requests',
719 None, 'submittedAt', 'yesterday')
720 FindItem(items, 'stepStarted', 'payload', 'build', 'requests',
721 None, 'submittedAt', 'yesterday')
722 FindItem(items, 'stepFinished', 'payload', 'build', 'requests',
723 None, 'submittedAt', 'yesterday')
724 FindItem(items, 'buildFinished', 'payload', 'build', 'requests',
725 None, 'submittedAt', 'yesterday')
726
727 FindItem(items, 'buildStarted', 'payload', 'build', 'times',
728 [123, None])
729 FindItem(items, 'stepStarted', 'payload', 'build', 'times',
730 [123, None])
731 FindItem(items, 'stepStarted', 'payload', 'step', 'times',
732 [123, None])
733 FindItem(items, 'stepFinished', 'payload', 'build', 'times',
734 [123, None])
735 FindItem(items, 'stepFinished', 'payload', 'step', 'times',
736 [123, None])
737 FindItem(items, 'buildFinished', 'payload', 'build', 'times',
738 [123, None])
739
740 FindItem(items, 'stepStarted', 'payload', 'build',
741 'current_step', 'times', [234, None])
742 FindItem(items, 'stepFinished', 'payload', 'build',
743 'current_step', 'times', [234, None])
744 FindItem(items, 'buildFinished', 'payload', 'build',
745 'current_step', 'times', [234, None])
746
747 FindItem(items, 'stepStarted', 'payload', 'build', 'steps', None,
748 'times', [345, None])
749 FindItem(items, 'stepFinished', 'payload', 'build', 'steps',
750 None, 'times', [345, None])
751 FindItem(items, 'buildFinished', 'payload', 'build', 'steps',
752 None, 'times', [345, None])
753
754 for i in range(min(len(expected), len(items))):
755 self.assertEqual(expected[i], items[i], str(i))
756 self.assertEqual(len(expected), len(items))
757
758
759 class StatusPushTest(StatusPushTestBase):
760 def testNotFiltered(self):
761 self.expected = EXPECTED
762 self.init(config_no_http_no_filter)
763 d = self.connectSlave()
764 d.addCallbacks(self._testPhase1)
765 return d
766
767 def testFiltered(self):
768 self.expected = EXPECTED_SHORT
769 self.init(config_no_http)
770 d = self.connectSlave()
771 d.addCallbacks(self._testPhase1)
772 return d
773
774 def _testPhase1(self, d):
775 # Now the slave is connected, trigger a change.
776 cm = self.master.change_svc
777 c = changes.Change("bob", ["Makefile", "foo/bar.c"], "changed stuff")
778 cm.addChange(c)
779 d = self.requestBuild("dummy")
780 d.addCallback(self._testPhase2)
781 return d
782
783 def _testPhase2(self, d):
784 d = self.shutdownAllSlaves()
785 d.addCallback(lambda x: self.master.stopService())
786 d.addCallback(self._testPhase3)
787 return d
788
789 def _testPhase3(self, d):
790 def TupleToList(items):
791 if isinstance(items, (list, tuple)):
792 return [TupleToList(i) for i in items]
793 if isinstance(items, dict):
794 return dict([(k, TupleToList(v))
795 for (k, v) in items.iteritems()])
796 else:
797 return items
798 self.assertEqual(0, self.getStatusPush().queue.nbItems())
799 # Grabs fake_queue created in DoNothing().
800 self.verifyItems(TupleToList(self.getStatusPush().fake_queue),
801 self.expected)
802 self.master = None
803
804
805 class HttpStatusPushTest(StatusPushTestBase):
806 def setUp(self):
807 StatusPushTestBase.setUp(self)
808 self.server = None
809
810 def tearDown(self):
811 StatusPushTestBase.tearDown(self)
812 state_path = os.path.join(self.path, 'state')
813 state = json.loads(ReadFile(state_path))
814 del state['started']
815 self.assertEqual({"last_id_pushed": 0, "next_id": 17, }, state)
816 os.remove(state_path)
817 self.assertEqual([], os.listdir(self.path))
818
819 def testNotFiltered(self):
820 self.expected = EXPECTED
821 path = os.path.join(os.path.dirname(__file__), 'status_push_server.py')
822 self.site = server.Site(Receiver())
823 self.server = reactor.listenTCP(0, self.site)
824 self.port = self.server.getHost().port
825 self.init(config_http_no_filter.replace('<PORT>', str(self.port)))
826 d = self.connectSlave()
827 d.addCallbacks(self._testPhase1)
828 return d
829
830 def testFiltered(self):
831 self.expected = EXPECTED_SHORT
832 path = os.path.join(os.path.dirname(__file__), 'status_push_server.py')
833 self.site = server.Site(Receiver())
834 self.server = reactor.listenTCP(0, self.site)
835 self.port = self.server.getHost().port
836 self.init(config_http.replace('<PORT>', str(self.port)))
837 d = self.connectSlave()
838 d.addCallbacks(self._testPhase1)
839 return d
840
841 def _testPhase1(self, d):
842 g = self.getStatusPush()
843 self.path = g.path
844 # Now the slave is connected, trigger a change.
845 cm = self.master.change_svc
846 c = changes.Change("bob", ["Makefile", "foo/bar.c"], "changed stuff")
847 cm.addChange(c)
848 d = self.requestBuild("dummy")
849 d.addCallback(self._testPhase2)
850 return d
851
852 def _testPhase2(self, d):
853 d = self.shutdownAllSlaves()
854 d.addCallback(lambda x: self.master.stopService())
855 d.addCallback(self._testPhase3)
856 d.addCallback(lambda x: self.server.stopListening())
857 return d
858
859 def _testPhase3(self, d):
860 g = self.getStatusPush()
861 # Assert all the items were pushed.
862 self.assertEqual(0, g.queue.nbItems())
863 self.verifyItems(self.site.resource.packets, self.expected)
864 self.master = None
865
866 # vim: set ts=4 sts=4 sw=4 et:
OLDNEW
« no previous file with comments | « third_party/buildbot_7_12/buildbot/test/test_status.py ('k') | third_party/buildbot_7_12/buildbot/test/test_steps.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698