Merge lp:~bac/charms/oneiric/buildbot-master/history-s3 into lp:~yellow/charms/oneiric/buildbot-master/trunk

Proposed by Brad Crittenden
Status: Merged
Approved by: Gary Poster
Approved revision: 34
Merged at revision: 33
Proposed branch: lp:~bac/charms/oneiric/buildbot-master/history-s3
Merge into: lp:~yellow/charms/oneiric/buildbot-master/trunk
Diff against target: 590 lines (+372/-15)
9 files modified
HACKING.txt (+3/-0)
config.yaml (+22/-0)
examples/pyflakes.yaml (+1/-1)
hooks/config-changed (+22/-7)
hooks/install (+1/-1)
hooks/local.py (+121/-0)
hooks/start (+0/-1)
hooks/stop (+5/-2)
hooks/tests.py (+197/-3)
To merge this branch: bzr merge lp:~bac/charms/oneiric/buildbot-master/history-s3
Reviewer Review Type Date Requested Status
Gary Poster (community) Approve
Yellow Squad code Pending
Review via email: mp+94262@code.launchpad.net

Description of the change

Add ability to store buildbot data files to S3 and retrieve them the next time the master is started.

The configuration settings for access-key and secret-key need to be passed, like so:

juju set buildbot-master access-key='<your AWS key>' secret-key='<your AWS secret key>'

Unfortunately the 'stop' hook is not being called (see bug 872264) so automatically saving the history when bringing down the juju service does not work. As a work-around a new config value is provided which causes the files to get pushed. Setting that config variable to a different value will cause it to save again, e.g.

juju set buildbot-master save-history-now='`date`'

To post a comment you must log in.
Revision history for this message
Gary Poster (gary) wrote :

Brad, this looks great! Nicely done, and nice to have helpers that can do this for us with future charms.

As I mentioned on IRC, please clean up the log messages in handle_config_changes one way or another, so that we no longer have a colon and two messages when one would do.

Making a chdir context manager would be nice. We have one or two hanging around. You mentioned you had found one in setuplxc.

I asked whether tar xvf will have the expanded file or the existing file win: you said the expanded one, which is good.

You pointed out there are no tests. If there's a sane way to write them then that would be great.

Thank you!

Gary

review: Approve
34. By Brad Crittenden

Added unit tests of history management functions

Revision history for this message
Gary Poster (gary) wrote :

Hi Brad. That looks good.

You asked about the amount of code needed to stub boto. I am very happy we have it, and I wonder if it ought to be put somewhere else to make it reusable--later, perhaps.

For now, though, the only idea I have is that it might be nice to put the boto setup/teardown stuff in a separate file, just to make the test file easier to read. Just an idea.

Meanwhile though, I approve it again!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'HACKING.txt'
--- HACKING.txt 2012-02-07 17:20:47 +0000
+++ HACKING.txt 2012-02-23 16:49:43 +0000
@@ -21,6 +21,9 @@
214) Run the tests: RESOLVE_TEST_CHARMS=1 tests/buildbot-master.test214) Run the tests: RESOLVE_TEST_CHARMS=1 tests/buildbot-master.test
2222
2323
24XXX bac 2012-02-23: The tests do not run locally using precise. Set
25default-series: oneiric to get them to pass.
26
24Running the charm helper tests27Running the charm helper tests
25==============================28==============================
2629
2730
=== modified file 'config.yaml'
--- config.yaml 2012-02-10 21:19:58 +0000
+++ config.yaml 2012-02-23 16:49:43 +0000
@@ -52,3 +52,25 @@
52 install the newly specified packages while leaving the previous52 install the newly specified packages while leaving the previous
53 ones installed.53 ones installed.
54 type: string54 type: string
55 access-key:
56 description: |
57 Access key for EC2.
58 type: string
59 secret-key:
60 description: |
61 Secret key for EC2.
62 type: string
63 bucket-name:
64 description: |
65 The bucket used to store buildbot history. If not provided a
66 default based on the access-key will be used.
67 type: string
68 save-history-now:
69 description: |
70 Configuration hack to fire off the saving of the buildbot master
71 history. Normally this would be done in the stop hook but due to
72 Bug 872264 that hook is not firing properly. The value of the
73 setting is not important but it must change between invocations
74 or the event will not be recogized. Monotonically increasing
75 integer values would be a good choice. Or a time string.
76 type: string
5577
=== modified file 'examples/pyflakes.yaml'
--- examples/pyflakes.yaml 2012-02-10 21:19:58 +0000
+++ examples/pyflakes.yaml 2012-02-23 16:49:43 +0000
@@ -1,5 +1,5 @@
1buildbot-master:1buildbot-master:
2 extra-packages: git2 extra-packages: git python-sqlalchemy python-migrate
3 installdir: /tmp/buildbot3 installdir: /tmp/buildbot
4 config-file: |4 config-file: |
5 # -*- python -*-5 # -*- python -*-
66
=== modified file 'hooks/config-changed'
--- hooks/config-changed 2012-02-14 15:51:49 +0000
+++ hooks/config-changed 2012-02-23 16:49:43 +0000
@@ -3,14 +3,17 @@
3# Copyright 2012 Canonical Ltd. This software is licensed under the3# Copyright 2012 Canonical Ltd. This software is licensed under the
4# GNU Affero General Public License version 3 (see the file LICENSE).4# GNU Affero General Public License version 3 (see the file LICENSE).
55
6import base64
6import json7import json
7import os8import os
8import os.path9import os.path
9import shutil10import shutil
11import subprocess
10import sys12import sys
1113
12from helpers import (14from helpers import (
13 apt_get_install,15 apt_get_install,
16 cd,
14 command,17 command,
15 DictDiffer,18 DictDiffer,
16 get_config,19 get_config,
@@ -26,7 +29,11 @@
26 buildbot_create,29 buildbot_create,
27 buildbot_reconfig,30 buildbot_reconfig,
28 config_json,31 config_json,
32 fetch_history,
29 generate_string,33 generate_string,
34 get_bucket,
35 get_key,
36 put_history,
30 slave_json,37 slave_json,
31 )38 )
3239
@@ -36,7 +43,6 @@
36 ]43 ]
37SUPPORTED_TRANSPORTS = ['bzr']44SUPPORTED_TRANSPORTS = ['bzr']
3845
39
40bzr = command('bzr')46bzr = command('bzr')
4147
4248
@@ -57,8 +63,6 @@
5763
58def handle_config_changes(config, diff):64def handle_config_changes(config, diff):
59 log('Updating buildbot configuration.')65 log('Updating buildbot configuration.')
60 log('Configuration changes seen:')
61 log(str(diff))
6266
63 buildbot_pkg = config.get('buildbot-pkg')67 buildbot_pkg = config.get('buildbot-pkg')
64 extra_repo = config.get('extra-repository')68 extra_repo = config.get('extra-repository')
@@ -91,7 +95,6 @@
9195
92 # Write the buildbot config to disk (fetching it if necessary).96 # Write the buildbot config to disk (fetching it if necessary).
93 added_or_changed = diff.added_or_changed97 added_or_changed = diff.added_or_changed
94 log("CONFIG FILE: {}".format(config_file))
95 log("ADDED OR CHANGED: {}".format(added_or_changed))98 log("ADDED OR CHANGED: {}".format(added_or_changed))
96 if config_file and 'config-file' in added_or_changed:99 if config_file and 'config-file' in added_or_changed:
97 buildbot_create(buildbot_dir)100 buildbot_create(buildbot_dir)
@@ -108,7 +111,7 @@
108 # gpg-agent needs to send the key over and the bzr launchpad-login111 # gpg-agent needs to send the key over and the bzr launchpad-login
109 # needs to be set.112 # needs to be set.
110 with su('buildbot'):113 with su('buildbot'):
111 lp_id = config.get('config-usr')114 lp_id = config.get('config-user')
112 if lp_id:115 if lp_id:
113 bzr('launchpad-login', lp_id)116 bzr('launchpad-login', lp_id)
114 private_key = config.get('config-private-key')117 private_key = config.get('config-private-key')
@@ -153,12 +156,21 @@
153 if not os.path.exists(placeholder_path):156 if not os.path.exists(placeholder_path):
154 with open(placeholder_path, 'w') as f:157 with open(placeholder_path, 'w') as f:
155 json.dump(generate_string("temporary-placeholder-"), f)158 json.dump(generate_string("temporary-placeholder-"), f)
156 buildbot_reconfig()159 try:
160 buildbot_reconfig()
161 except subprocess.CalledProcessError as e:
162 print e
163 print e.output
164 raise
165
166
167def conditionally_save_history(config, diff):
168 if 'save-history-now' in diff.added_or_changed:
169 put_history(config)
157170
158171
159def main():172def main():
160 config = get_config()173 config = get_config()
161 log(str(config))
162 if not check_config(config):174 if not check_config(config):
163 log("Configuration not valid.")175 log("Configuration not valid.")
164 sys.exit(1)176 sys.exit(1)
@@ -178,6 +190,7 @@
178 os.makedirs(buildbot_dir)190 os.makedirs(buildbot_dir)
179191
180 restart_required |= configure_buildbot(config, diff)192 restart_required |= configure_buildbot(config, diff)
193 restart_required |= fetch_history(config, diff)
181194
182 master_cfg_path = os.path.join(buildbot_dir, 'master.cfg')195 master_cfg_path = os.path.join(buildbot_dir, 'master.cfg')
183 if restart_required and os.path.exists(master_cfg_path):196 if restart_required and os.path.exists(master_cfg_path):
@@ -185,6 +198,8 @@
185 else:198 else:
186 log("Configuration changed but didn't require restarting.")199 log("Configuration changed but didn't require restarting.")
187200
201 conditionally_save_history(config, diff)
202
188 config_json.set(config)203 config_json.set(config)
189204
190205
191206
=== modified file 'hooks/install'
--- hooks/install 2012-02-14 16:54:21 +0000
+++ hooks/install 2012-02-23 16:49:43 +0000
@@ -22,7 +22,7 @@
2222
2323
24def cleanup(buildbot_dir):24def cleanup(buildbot_dir):
25 apt_get_install('bzr')25 apt_get_install('bzr', 'python-boto')
26 # Since we may be installing into a pre-existing service, ensure the26 # Since we may be installing into a pre-existing service, ensure the
27 # buildbot directory is removed.27 # buildbot directory is removed.
28 if os.path.exists(buildbot_dir):28 if os.path.exists(buildbot_dir):
2929
=== modified file 'hooks/local.py'
--- hooks/local.py 2012-02-10 01:05:09 +0000
+++ hooks/local.py 2012-02-23 16:49:43 +0000
@@ -10,16 +10,22 @@
10 'buildslave_stop',10 'buildslave_stop',
11 'config_json',11 'config_json',
12 'create_slave',12 'create_slave',
13 'fetch_history',
13 'generate_string',14 'generate_string',
15 'get_bucket',
16 'get_key',
14 'HTTP_PORT_PROTOCOL',17 'HTTP_PORT_PROTOCOL',
18 'put_history',
15 'slave_json',19 'slave_json',
16 ]20 ]
1721
18import os22import os
23import re
19import subprocess24import subprocess
20import uuid25import uuid
2126
22from helpers import (27from helpers import (
28 cd,
23 get_config,29 get_config,
24 log,30 log,
25 run,31 run,
@@ -157,3 +163,118 @@
157163
158slave_json = Serializer('/tmp/slave_info.json')164slave_json = Serializer('/tmp/slave_info.json')
159config_json = Serializer('/tmp/config.json')165config_json = Serializer('/tmp/config.json')
166
167
168def get_bucket(config, bucket_name=None):
169 """Return an S3 bucket or None."""
170 # Late import to ensure python-boto package has been installed.
171 import boto
172 access_key = config.get('access-key')
173 secret_key = config.get('secret-key')
174 bucket = None
175 if access_key and secret_key:
176 if bucket_name is None:
177 bucket_name = str(access_key + '-buildbot-history').lower()
178 conn = boto.connect_s3(access_key, secret_key)
179 bucket = conn.create_bucket(bucket_name)
180 log("Using bucket: " + bucket.name)
181 return bucket
182
183
184def get_key(bucket):
185 """Return an S3 key for the bucket."""
186 # Late import to ensure python-boto package has been installed.
187 from boto.s3.key import Key
188 key = Key(bucket)
189 value = os.environ['JUJU_UNIT_NAME']
190 key.key = value
191 log("Using key: " + key.key)
192 return key
193
194
195VERSION_TO_STORE = {
196 '0.7': "*/builder",
197 '0.8': "state.sqlite",
198 }
199
200
201def get_buildbot_version():
202 """Get the major version (x.y) of buildbot and return as a string.
203
204 Return None if the output from buildbot cannot be parsed.
205 """
206 version = None
207 output = run('buildbot', '--version')
208 match = re.search('Buildbot version: (\d+\.\d+)(\.\d+)*\n', output)
209 if match and len(match.groups()) > 0:
210 version = match.group(1)
211 return version
212
213
214def put_history(config):
215 """Put the buildbot history to an external store, if set up."""
216 log("put_history called")
217 bucket_name = config.get('bucket-name')
218 bucket = get_bucket(config, bucket_name)
219 success = False
220 if bucket:
221 key = get_key(bucket)
222 target = '/tmp/history-put.tgz'
223 version = get_buildbot_version()
224 store_pattern = VERSION_TO_STORE.get(version)
225 assert store_pattern is not None, (
226 "Buildbot version not supported: {}".format(version))
227 with cd(config['installdir']):
228 with su('buildbot'):
229 try:
230 run('tar', 'czf', target, store_pattern)
231 key.set_contents_from_filename(target)
232 # If would be natural to just log the success here, but we
233 # are su-ed to the buildbot user and that causes permission
234 # problems, so instead set a flag.
235 success = True
236 except subprocess.CalledProcessError as e:
237 print e
238 print e.output
239 raise
240 os.unlink(target)
241 else:
242 log("Bucket not found: " + bucket_name)
243 if success:
244 log("History stored to S3.")
245
246
247def fetch_history(config, diff):
248 """Fetch the buildbot history from an external store."""
249 # Currently only S3 is supported.
250 restart_required = False
251
252 log("fetching history")
253 if 'secret-key' not in diff.added_or_changed:
254 log("skipping fetch of history")
255 return restart_required
256
257 bucket_name = config.get('bucket-name')
258 bucket = get_bucket(config, bucket_name)
259
260 if bucket:
261 key = get_key(bucket)
262 if key.exists():
263 target = '/tmp/history-fetched.tgz'
264 key.get_contents_to_filename(target)
265 with cd(config['installdir']):
266 with su('buildbot'):
267 try:
268 run('tar', 'xzf', target)
269 except subprocess.CalledProcessError as e:
270 print e
271 print e.output
272 raise
273 os.unlink(target)
274 log("History fetched from S3.")
275 restart_required = True
276 else:
277 log("Key does not exist: " + key.key)
278 else:
279 log("Bucket not found: " + bucket_name)
280 return restart_required
160281
=== modified file 'hooks/start'
--- hooks/start 2012-02-08 14:26:58 +0000
+++ hooks/start 2012-02-23 16:49:43 +0000
@@ -4,7 +4,6 @@
4# GNU Affero General Public License version 3 (see the file LICENSE).4# GNU Affero General Public License version 3 (see the file LICENSE).
55
6from helpers import (6from helpers import (
7 log,
8 log_entry,7 log_entry,
9 log_exit,8 log_exit,
10 run,9 run,
1110
=== modified file 'hooks/stop'
--- hooks/stop 2012-02-08 14:26:58 +0000
+++ hooks/stop 2012-02-23 16:49:43 +0000
@@ -10,14 +10,17 @@
10 log_exit,10 log_exit,
11 run,11 run,
12 )12 )
13from local import HTTP_PORT_PROTOCOL13from local import (
14 HTTP_PORT_PROTOCOL,
15 put_history,
16 )
1417
1518
16def main():19def main():
17 config = get_config()20 config = get_config()
18 log('Stopping buildbot in {}'.format(config['installdir']))21 log('Stopping buildbot in {}'.format(config['installdir']))
22 put_history(config)
19 run('close-port', HTTP_PORT_PROTOCOL)23 run('close-port', HTTP_PORT_PROTOCOL)
20
21 log('Finished stopping buildbot')24 log('Finished stopping buildbot')
2225
2326
2427
=== modified file 'hooks/tests.py'
--- hooks/tests.py 2012-02-10 19:43:46 +0000
+++ hooks/tests.py 2012-02-23 16:49:43 +0000
@@ -1,16 +1,32 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Helper functions for writing hooks in python."""
5
6__metaclass__ = type
7
8
9from contextlib import contextmanager
1import os10import os
2from subprocess import CalledProcessError11from subprocess import CalledProcessError
12import tempfile
3import unittest13import unittest
414
5from helpers import (15from helpers import (
16 cd,
17 command,
6 DictDiffer,18 DictDiffer,
7 command,
8 run,19 run,
9 su,20 su,
10 unit_info,21 unit_info,
11 )22 )
23from local import (
24 get_bucket,
25 get_key,
26 fetch_history,
27 put_history,
28 )
1229
13from subprocess import CalledProcessError
1430
15class TestRun(unittest.TestCase):31class TestRun(unittest.TestCase):
1632
@@ -24,7 +40,7 @@
24 # produces a string.40 # produces a string.
25 self.assertIn('Usage:', run('/bin/ls', '--help'))41 self.assertIn('Usage:', run('/bin/ls', '--help'))
2642
27 def testSimpleCommand(self):43 def testCalledProcessErrorRaised(self):
28 # If an error occurs a CalledProcessError is raised with the return44 # If an error occurs a CalledProcessError is raised with the return
29 # code, command executed, and the output of the command.45 # code, command executed, and the output of the command.
30 with self.assertRaises(CalledProcessError) as info:46 with self.assertRaises(CalledProcessError) as info:
@@ -205,5 +221,183 @@
205 self.assertEqual(current_home, os.environ['HOME'])221 self.assertEqual(current_home, os.environ['HOME'])
206222
207223
224class TestCdContextManager(unittest.TestCase):
225 def test_cd(self):
226 curdir = os.getcwd()
227 self.assertNotEqual('/var', curdir)
228 with cd('/var'):
229 self.assertEqual('/var', os.getcwd())
230 self.assertEqual(curdir, os.getcwd())
231
232
233class StubBucket:
234 """Stub S3 Bucket."""
235 def __init__(self, name):
236 self.name = name
237 self.keys = {}
238
239
240class StubKey:
241 """Stub S3 Key."""
242 def __init__(self, bucket):
243 self.bucket = bucket
244 self._name = None
245
246 def _get_key(self):
247 return self._name
248 def _set_key(self, name):
249 if name not in self.bucket.keys:
250 self.bucket.keys[name] = None
251 self._name = name
252 key = property(_get_key, _set_key)
253
254 def _get_value(self):
255 return self.bucket.keys.get(self._name)
256 def _set_value(self, v):
257 self.bucket.keys[self._name] = v
258 value = property(_get_value, _set_value)
259
260 def exists(self):
261 return self.value is not None
262
263 def get_contents_to_filename(self, fn):
264 with open(fn, 'w') as f:
265 f.write(self.value)
266
267 def set_contents_from_filename(self, fn):
268 with open(fn, 'r') as f:
269 self.value = f.read()
270
271
272class StubConnection:
273 """A stub S3 connection."""
274 def __init__(self, access_key, secret_key):
275 self.buckets = {}
276
277 def create_bucket(self, name):
278 if name in self.buckets:
279 bucket = self.buckets[name]
280 else:
281 bucket = StubBucket(name)
282 self.buckets[name] = bucket
283 return bucket
284
285
286connection = None
287
288
289def stub_connect_s3(access_key, secret_key):
290 # A stub boto connection factory.
291 global connection
292 if connection is None:
293 connection = StubConnection(access_key, secret_key)
294 return connection
295
296
297def stub_log(msg):
298 pass
299
300
301@contextmanager
302def stub_su(user):
303 yield
304
305
306class TestHistoryManagement(unittest.TestCase):
307
308 def setUp(self):
309 self.config = {'access-key': 'fake-access-key',
310 'secret-key': 'fake-secret-key',
311 'bucket-name': 'fake-bucket-name',
312 'installdir': '/tmp',
313 }
314 import local
315 import boto
316 import boto.s3.key
317 self.connect_s3 = boto.connect_s3
318 self.Key = boto.s3.key.Key
319 self.log = local.log
320 self.get_buildbot_version = local.get_buildbot_version
321 self.su = local.su
322 boto.connect_s3 = stub_connect_s3
323 boto.s3.key.Key = StubKey
324 local.log = stub_log
325 local.get_buildbot_version = lambda: '0.8'
326 local.su = stub_su
327 self.tempfile = tempfile.NamedTemporaryFile(delete=False)
328 self.tempfilename = self.tempfile.name
329 self.tempfile.close()
330 os.environ['JUJU_UNIT_NAME'] = 'fake-unit-name'
331
332 def tearDown(self):
333 import local
334 import boto
335 import boto.s3.key
336 boto.connect_s3 = self.connect_s3
337 boto.s3.key.Key = self.Key
338 local.log = self.log
339 local.get_buildbot_version = self.get_buildbot_version
340 local.su = self.su
341 os.unlink(self.tempfilename)
342
343 def test_get_bucket(self):
344 bucket = get_bucket(self.config, 'fake-bucket')
345 self.assertEqual('fake-bucket', bucket.name)
346
347 def test_get_key(self):
348 bucket = get_bucket(self.config, 'fake-bucket')
349 key = get_key(bucket)
350 self.assertEqual('fake-bucket', key.bucket.name)
351 self.assertEqual('fake-unit-name', key.key)
352 self.assertFalse(key.exists())
353
354 def test_key_content_file(self):
355 contents = "Fake file contents, yo."
356 with open(self.tempfilename, 'w') as f:
357 f.write(contents)
358 bucket = get_bucket(self.config, 'fake-bucket')
359 key = get_key(bucket)
360 key.set_contents_from_filename(self.tempfilename)
361 self.assertTrue(key.exists())
362 self.assertEqual(contents, key.value)
363 os.unlink(self.tempfilename)
364 key.get_contents_to_filename(self.tempfilename)
365 with open(self.tempfilename, 'r') as f:
366 value = f.read()
367 self.assertEqual(contents, value)
368
369 def test_put_fetch_history(self):
370 # Tests putting the history file into the fake bucket and then
371 # retrieving it.
372 fake_contents = "Fake state.sqlite"
373 history_fn = os.path.join(self.config['installdir'], 'state.sqlite')
374 with open(history_fn, 'w') as f:
375 f.write(fake_contents)
376 put_history(self.config)
377 os.unlink(history_fn)
378 diff = DictDiffer(self.config, {})
379 restart = fetch_history(self.config, diff)
380 self.assertTrue(restart)
381 with open(history_fn, 'r') as f:
382 contents = f.read()
383 self.assertEqual(fake_contents, contents)
384
385 def test_fetch_history_no_restart(self):
386 diff = DictDiffer(self.config, self.config)
387 restart = fetch_history(self.config, diff)
388 self.assertFalse(restart)
389
390 def test_fetch_history_no_credentials(self):
391 self.config['access-key'] = None
392 diff = DictDiffer(self.config, {})
393 restart = fetch_history(self.config, diff)
394 self.assertFalse(restart)
395
396 def test_fetch_history_no_key(self):
397 diff = DictDiffer(self.config, {})
398 restart = fetch_history(self.config, diff)
399 self.assertFalse(restart)
400
401
208if __name__ == '__main__':402if __name__ == '__main__':
209 unittest.main()403 unittest.main()

Subscribers

People subscribed via source and target branches

to all changes: