← Back to team overview

yellow team mailing list archive

[Merge] lp:~bac/charms/oneiric/buildbot-master/wip into lp:~yellow/charms/oneiric/buildbot-master/trunk

 

Brad Crittenden has proposed merging lp:~bac/charms/oneiric/buildbot-master/wip into lp:~yellow/charms/oneiric/buildbot-master/trunk.

Requested reviews:
  Gary Poster (gary): code

For more details, see:
https://code.launchpad.net/~bac/charms/oneiric/buildbot-master/wip/+merge/91357

Fix config-changed to save state and modify configs only as actually needed.

Add more helper functions and tests.

Added example lpbuildot.yaml configuration file.

Can be completely deployed with:

juju deploy --config <charmname>/examples/lpbuildbot.yaml --repository=. local:buildbot-master
-- 
https://code.launchpad.net/~bac/charms/oneiric/buildbot-master/wip/+merge/91357
Your team Launchpad Yellow Squad is subscribed to branch lp:~yellow/charms/oneiric/buildbot-master/trunk.
=== modified file 'config.yaml'
--- config.yaml	2012-01-25 15:21:32 +0000
+++ config.yaml	2012-02-02 21:47:30 +0000
@@ -1,4 +1,11 @@
 options:
+  buildbot-pkg:
+    description: |
+      The package name, possibly with versioning information, to be
+      installed for buildbot.  Example values are 'buildbot',
+      'buildbot/lucid', or 'buildbot=0.7.9'.
+    type: string
+    default: buildbot
   installdir:
     description: |
       The directory where the Buildbot master will be installed.
@@ -28,9 +35,22 @@
     description: |
       The user for access to a restricted URL.
     type: string
-  config-private-key: 
+  config-private-key:
     description: |
       The private key for `config-user`.
     type: string
-
-
+  extra-repository:
+    description: |
+      The full line to be inserted into an apt sources.list for a repository.
+      If called multiple times the new repository will be added but
+      ones added previously will not be removed. An example would be:
+      deb http://us.archive.ubuntu.com/ubuntu/ lucid main universe
+    type: string
+  extra-packages:
+    description: |
+      A space-separated list of packages to be installed.  The
+      repositories to use in getting these packages should have been
+      set prior to setting this value.  Calling multiple times will
+      install the newly specified packages while leaving the previous
+      ones installed.
+    type: string

=== added directory 'examples'
=== added file 'examples/lpbuildbot.yaml'
--- examples/lpbuildbot.yaml	1970-01-01 00:00:00 +0000
+++ examples/lpbuildbot.yaml	2012-02-02 21:47:30 +0000
@@ -0,0 +1,7 @@
+buildbot-master:
+  config-transport: bzr
+  config-url: http://bazaar.launchpad.net/~launchpad/lpbuildbot/public
+  extra-repository: deb http://us.archive.ubuntu.com/ubuntu/ lucid main universe
+  buildbot-pkg: buildbot/lucid
+  installdir: /var/lib/buildbot/masters/lpbuildbot
+

=== modified file 'hooks/config-changed'
--- hooks/config-changed	2012-02-02 20:22:52 +0000
+++ hooks/config-changed	2012-02-02 21:47:30 +0000
@@ -1,42 +1,110 @@
 #!/usr/bin/python
 
-from helpers import command, Config, run
+from helpers import (
+    command,
+    DictDiffer,
+    get_config,
+    install_extra_repository,
+    load_pickle,
+    save_pickle,
+    run,
+    )
 from subprocess import CalledProcessError
 import base64
 import os
 import os.path
-
+import sys
 
 # config_file is changed via juju like:
 # juju set buildbot-master config-file=`uuencode master.cfg`
 
+CONFIG_PICKLE = "config.pkl"
+REQUIRED_KEYS = [
+    'buildbot-pkg',
+    'installdir',
+    ]
+SUPPORTED_TRANSPORTS = ['bzr']
+
+restart_required = False
+
 log = command('juju-log')
 bzr = command('bzr')
+apt_get_install = command('apt-get', 'install', '-y', '--force-yes')
 
 # Log the fact that we're about to begin the install step.
 log('--> config-changed')
 
-config = Config()
-buildbot_dir =  config['installdir']
-config_file = config['config-file']
-config_transport = config['config-transport']
-config_url = config['config-url']
+config = get_config()
+prev_config = load_pickle(CONFIG_PICKLE)
+
+diff = DictDiffer(config, prev_config)
+
+buildbot_pkg = config.get('buildbot-pkg')
+buildbot_dir =  config.get('installdir')
+config_file = config.get('config-file')
+config_transport = config.get('config-transport')
+config_url = config.get('config-url')
+extra_repo = config.get('extra-repository')
+extra_pkgs = config.get('extra-packages')
 
 log('Updating buildbot configuration.')
+log(str(config))
+
+if not diff.modified:
+    log("No configuration changes, exiting.")
+    sys.exit(0)
+
+# If all of the required keys are not present in the configuration then exit
+# with an error.
+if not all(config.get(k) for k in REQUIRED_KEYS):
+    log('All required items not configured: {}'.format(REQUIRED_KEYS))
+    log(str(config))
+    sys.exit(1)
+
+log('Configuration changes seen:')
+log(str(diff))
+
+# Add a new repository if it was just added.
+if extra_repo and 'extra-repository' in diff.added_or_changed:
+    install_extra_repository(extra_repo)
+    restart_required = True
+
+if extra_pkgs and 'extra_packages' not in diff.unchanged:
+    apt_get_install(extra_pkgs)
+    restart_required = True
+
+if 'buildbot-pkg' not in diff.unchanged:
+    apt_get_install(buildbot_pkg)
+    restart_required = True
+
+# Ensure the install directory exists.
+if not os.path.exists(buildbot_dir):
+    os.makedirs(buildbot_dir)
+
+# If asked to fetch the config file then it must be a transport we support.
+if config_url and config_transport not in SUPPORTED_TRANSPORTS:
+    log("{} is an unsupported transport".format(config_transport))
+    sys.exit(1)
+
 # Write the buildbot config to disk (fetching it if necessary).
-if config_file:
+if config_file and 'config-file' not in diff.unchanged:
+    if not os.path.exists(buildbot_dir):
+        os.makedirs(buildbot_dir)
     with open(os.path.join(buildbot_dir, 'master.cfg', 'w')) as f:
         f.write(base64.decode(config_file))
-    log('Config decoded and written.')
-elif config_transport == 'bzr' and config_url:
+    log('config_file decoded and written.')
+    restart_required = True
+elif (config_transport == 'bzr' and config_url and
+      'config-transport' not in diff.unchanged and
+      'config-url' not in diff.unchanged):
     # If the branch is private then more setup needs to be done.  The
     # gpg-agent needs to send the key over and the bzr launchpad-login
-    # needs to be set. 
-    lp_id = config['config-user']
+    # needs to be set.
+    lp_id = config.get('config-user')
     if lp_id:
         bzr('launchpad-login', lp_id)
 
-    private_key = config['config-private-key']
+    private_key = config.get('config-private-key')
     if private_key:
         # Set up the .ssh directory.
         ssh_dir = os.expanduser('~/.ssh')
@@ -44,27 +112,38 @@
         os.chmod(ssh_dir, 0700)
         with open(os.path.join(ssh_dir, 'lp_key', w)) as f:
             f.write(base64.decode(private_key))
-
     bzr('branch', '--use-existing-dir', config_url, buildbot_dir)
     run('chown', '-R', 'ubuntu:ubuntu', buildbot_dir)
+    log('configuration fetched from {}'.format(config_url))
+    restart_required = True
 else:
-    # XXX Is it an error to get to this point?
+    # Configuration file specifiers are unchanged or unrecognized.
     pass
 
 # Restart buildbot if it is running.
-pidfile = os.path.join(buildbot_dir, 'twistd.pid')
-if os.path.exists(pidfile):
-    buildbot_pid = open(pidfile).read().strip()
-    try:
-        # Is buildbot running?
-        run('kill', '-0', buildbot_pid)
-    except CalledProcessError:
-        # Buildbot isn't running, so no need to reconfigure it.
-        pass
+if restart_required:
+    pidfile = os.path.join(buildbot_dir, 'twistd.pid')
+    if os.path.exists(pidfile):
+        buildbot_pid = open(pidfile).read().strip()
+        try:
+            # Is buildbot running?
+            run('kill', '-0', buildbot_pid)
+        except CalledProcessError:
+            # Buildbot isn't running, so no need to reconfigure it.
+            pass
+        else:
+            # Buildbot is running, reconfigure it.
+            log('Reconfiguring buildbot')
+            run('buildbot', 'reconfig', buildbot_dir)
+            log('Buildbot reconfigured')
     else:
-        # Buildbot is running, reconfigure it.
-        log('Reconfiguring buildbot')
-        run('buildbot', 'reconfig', buildbot_dir)
-        log('Buildbot reconfigured')
+        # Buildbot isn't running so start afresh but only if properly
+        # configured.
+        if os.path.exists(os.path.join(buildbot_dir, 'master.cfg')):
+            run('buildbot', 'start', buildbot_dir)
+else:
+    log("Configuration changed but didn't require restarting.")
+
+save_pickle(config, CONFIG_PICKLE)
 
 log('<-- config-changed')

=== modified file 'hooks/helpers.py'
--- hooks/helpers.py	2012-02-02 18:35:04 +0000
+++ hooks/helpers.py	2012-02-02 21:47:30 +0000
@@ -1,17 +1,135 @@
+# Copyright 2012 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Helper functions for writing hooks in python."""
+
+__metaclass__ = type
+__all__ = [
+    'command',
+    'Config',
+    'DictDiffer',
+    'install_extra_repository',
+    'log',
+    'run',
+    ]
+
 from collections import defaultdict
+import json
+import os
+import pickle
 import subprocess
+from textwrap import dedent
 import yaml
 
+
 def command(*base_args):
     def callable_command(*args):
-        return subprocess.check_output(base_args+args, shell=False)
+        all_args = base_args + args
+        return subprocess.check_output(all_args, shell=False)
 
     return callable_command
 
 
+log = command('juju-log')
+
+
 def run(*args):
+    log('run: ' + str(args))
     return subprocess.check_output(args, shell=False)
 
+def get_config():
+    config_get = command('config-get', '--format=json')
+    return json.loads(config_get())
+
+
+def install_extra_repository(extra_repository):
+    try:
+        run('apt-add-repository', extra_repository)
+        run('apt-get', 'update')
+    except CalledProcessError as e:
+        log("Error adding repository %s" % extra_repository)
+        log(e)
+        sys.exit(-1)
+
+
+def load_pickle(filepath):
+    prev_config = {}
+    if os.path.exists(filepath):
+        with open(filepath) as fd:
+            prev_config = pickle.load(fd)
+    return prev_config
+
+
+def save_pickle(obj, filepath):
+    with open(filepath, 'w') as fd:
+        pickle.dump(obj, fd)
+
+
+class DictDiffer:
+    """
+    Calculate the difference between two dictionaries as:
+    (1) items added
+    (2) items removed
+    (3) keys same in both but changed values
+    (4) keys same in both and unchanged values
+    """
+
+    # Based on answer by hughdbrown at:
+    # http://stackoverflow.com/questions/1165352
+
+    def __init__(self, current_dict, past_dict):
+        self.current_dict = current_dict
+        self.past_dict = past_dict
+        self.set_current = set(current_dict)
+        self.set_past = set(past_dict)
+        self.intersect = self.set_current.intersection(self.set_past)
+
+    @property
+    def added(self):
+        return self.set_current - self.intersect
+
+    @property
+    def removed(self):
+        return self.set_past - self.intersect
+
+    @property
+    def changed(self):
+        return set(key for key in self.intersect
+                   if self.past_dict[key] != self.current_dict[key])
+    @property
+    def unchanged(self):
+        return set(key for key in self.intersect
+                   if self.past_dict[key] == self.current_dict[key])
+    @property
+    def modified(self):
+        return self.current_dict != self.past_dict
+
+    @property
+    def added_or_changed(self):
+        return self.added.union(self.changed)
+
+    def _changes(self, keys):
+        new = {}
+        old = {}
+        for k in keys:
+            new[k] = self.current_dict.get(k)
+            old[k] = self.past_dict.get(k)
+        return "%s -> %s" % (old, new)
+    def __str__(self):
+        if self.modified:
+            s = dedent("""\
+            added: %s
+            removed: %s
+            changed: %s
+            unchanged: %s""") % (
+                self._changes(self.added),
+                self._changes(self.removed),
+                self._changes(self.changed),
+                list(self.unchanged))
+        else:
+            s = "no changes"
+        return s
+
 
 def unit_info(service_name, item_name, data=None):
     if data is None:
@@ -20,16 +138,3 @@
     units = services['units']
     item = units.items()[0][1][item_name]
     return item
-
-
-class Config(defaultdict):
-
-    def __init__(self):
-        super(defaultdict, self).__init__()
-        self.config_get = command('config-get')
-
-    def __missing__(self, key):
-        return self.config_get(key).strip()
-
-    def __setitem__(self, key, value):
-        raise RuntimeError('configuration is read-only')

=== modified file 'hooks/install'
--- hooks/install	2012-02-02 20:17:22 +0000
+++ hooks/install	2012-02-02 21:47:30 +0000
@@ -1,9 +1,8 @@
 #!/usr/bin/python
 
-from helpers import command, Config, run
+from helpers import command, get_config, run, log
 from subprocess import CalledProcessError
 import os
-import os.path
 import shutil
 
 log = command('juju-log')
@@ -11,18 +10,16 @@
 # Log the fact that we're about to begin the install step.
 log('--> install')
 
-config = Config()
+config = get_config()
+log("config:")
+log(str(config))
 
 buildbot_dir =  config['installdir']
 run('apt-get', 'install', '-y', 'sharutils', 'bzr')
 
-# Get the lucid version of buildbot.
-run('apt-add-repository',
-    'deb http://us.archive.ubuntu.com/ubuntu/ lucid main universe')
-run('apt-get', 'update')
-run('apt-get', 'install', '-y', 'buildbot/lucid')
+# Install the extra repository
+# Install the initially configured version of buildbot.
 
-log('Creating master in {}.'.format(buildbot_dir))
 # Since we may be installing into a pre-existing service, ensure the
 # buildbot directory is removed.
 if os.path.exists(buildbot_dir):
@@ -32,8 +29,10 @@
         # It probably wasn't running; just ignore the error.
         pass
     shutil.rmtree(buildbot_dir)
-lpbuildbot_checkout = os.path.join(os.environ['CHARM_DIR'], 'lpbuildbot')
-shutil.copytree(lpbuildbot_checkout, buildbot_dir)
+
+## run('buildbot', 'create-master', buildbot_dir)
+## shutil.copyfile(os.path.join(buildbot_dir, 'master.cfg.sample'),
+##                 os.path.join(buildbot_dir, 'master.cfg'))
 
 # Log the fact that the install step is done.
 log('<-- install')

=== modified file 'hooks/start'
--- hooks/start	2012-02-02 20:17:22 +0000
+++ hooks/start	2012-02-02 21:47:30 +0000
@@ -1,15 +1,18 @@
 #!/usr/bin/python
 
-from helpers import command, Config, run
-
-log = command('juju-log')
+from helpers import (
+    command,
+    Config,
+    log,
+    run,
+    )
 
 # Log the fact that we're about to begin the start step.
 log('--> start')
 
 config = Config()
 buildbot_dir =  config['installdir']
-run('buildbot', 'start', buildbot_dir)
+# Actually starting the buildbot happens in config-changed.
 run('open-port', '8010/TCP')
 
 # Log the fact that the start step is done.

=== modified file 'hooks/tests.py'
--- hooks/tests.py	2012-02-02 18:35:04 +0000
+++ hooks/tests.py	2012-02-02 21:47:30 +0000
@@ -1,9 +1,19 @@
+from subprocess import CalledProcessError
+import tempfile
 import unittest
+
+from helpers import (
+    command,
+    DictDiffer,
+    load_pickle,
+    save_pickle,
+    unit_info,
+    )
+
 from subprocess import CalledProcessError
-from helpers import command, unit_info
-
-
-class testCommand(unittest.TestCase):
+
+
+class TestCommand(unittest.TestCase):
 
     def testSimpleCommand(self):
         # Creating a simple command (ls) works and running the command
@@ -42,8 +52,55 @@
         ls = command('/bin/ls')
         ls('--help', '>')
 
-
-class testUnit_info(unittest.TestCase):
+    def testOneLongArgument(self):
+        ls = command('/bin/ls')
+        with self.assertRaises(CalledProcessError):
+            ls('tests.py install')
+
+    def testSplitStringWorks(self):
+        ls = command('/bin/ls')
+        ls(*'tests.py install'.split())
+
+
+class TestDictDiffer(unittest.TestCase):
+
+    def testStr(self):
+        a = dict(cow='moo', pig='oink')
+        b = dict(cow='moo', pig='oinkoink', horse='nay')
+        diff = DictDiffer(b, a)
+        s = str(diff)
+        self.assertIn("added: {'horse': None} -> {'horse': 'nay'}", s)
+        self.assertIn("removed: {} -> {}", s)
+        self.assertIn("changed: {'pig': 'oink'} -> {'pig': 'oinkoink'}", s)
+        self.assertIn("unchanged: ['cow']", s)
+
+    def testStrUnmodified(self):
+        a = dict(cow='moo', pig='oink')
+        diff = DictDiffer(a, a)
+        s = str(diff)
+        self.assertEquals('no changes', s)
+
+    def testAddedOrChanged(self):
+        a = dict(cow='moo', pig='oink')
+        b = dict(cow='moo', pig='oinkoink', horse='nay')
+        diff = DictDiffer(b, a)
+        expected = set(['horse', 'pig'])
+        self.assertEquals(expected, diff.added_or_changed)
+
+
+class TestPicklers(unittest.TestCase):
+
+    def testSaveAndLoad(self):
+        fd = tempfile.NamedTemporaryFile()
+        fn = fd.name
+        fd.close()
+        orig = dict(a=1, b=2)
+        save_pickle(orig, fn)
+        retrieved = load_pickle(fn)
+        self.assertEquals(orig, retrieved)
+
+
+class TestUnit_info(unittest.TestCase):
 
     def make_data(self, state='started'):
         return {


Follow ups