Merge lp:~tvansteenburgh/charms/precise/meteor/fix-npm-install into lp:charms/meteor

Proposed by Tim Van Steenburgh
Status: Merged
Merged at revision: 6
Proposed branch: lp:~tvansteenburgh/charms/precise/meteor/fix-npm-install
Merge into: lp:charms/meteor
Diff against target: 1662 lines (+1040/-206)
11 files modified
hooks/hooks.py (+56/-104)
lib/charmhelpers/core/fstab.py (+116/-0)
lib/charmhelpers/core/hookenv.py (+129/-6)
lib/charmhelpers/core/host.py (+72/-9)
lib/charmhelpers/core/services/__init__.py (+2/-0)
lib/charmhelpers/core/services/base.py (+313/-0)
lib/charmhelpers/core/services/helpers.py (+125/-0)
lib/charmhelpers/core/templating.py (+51/-0)
lib/charmhelpers/fetch/__init__.py (+173/-85)
lib/charmhelpers/fetch/bzrurl.py (+2/-1)
tests/10-deploy (+1/-1)
To merge this branch: bzr merge lp:~tvansteenburgh/charms/precise/meteor/fix-npm-install
Reviewer Review Type Date Requested Status
Matt Bruzek (community) Approve
Review via email: mp+233973@code.launchpad.net

Description of the change

This update does 3 things:

1. Fixes meteor app installation by adding `npm install` to ensure dependencies are installed
2. Removes Config class from the charm since it has been merged up into charmhelpers
3. Provides an upgrade-charm hook that can convert from the old Config to the new

To post a comment you must log in.
Revision history for this message
Matt Bruzek (mbruzek) wrote :

I have taken a look at this merge proposal.

The meteor charm deploys on local and on amazon and the code change looks good to me.

Thanks for the update Tim!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'hooks/hooks.py'
2--- hooks/hooks.py 2014-04-02 04:52:35 +0000
3+++ hooks/hooks.py 2014-09-09 18:07:26 +0000
4@@ -1,7 +1,6 @@
5-#!/usr/bin/python
6+#!/usr/bin/env python
7
8 from contextlib import contextmanager
9-import json
10 import os
11 import shutil
12 import subprocess
13@@ -12,9 +11,9 @@
14
15 from charmhelpers import fetch
16 from charmhelpers.core import (
17- hookenv,
18- host,
19- )
20+ hookenv,
21+ host,
22+)
23
24 hooks = hookenv.Hooks()
25
26@@ -24,87 +23,12 @@
27 CODE_DIR = BASE_DIR + '/code'
28 BUNDLE_DIR = BASE_DIR + '/bundle'
29 BUNDLE_ARCHIVE = BASE_DIR + '/bundle.tgz'
30-METEOR_CONFIG = BASE_DIR + '/.juju-config'
31 NODEJS_REPO = 'ppa:chris-lea/node.js'
32 PACKAGES = ['nodejs', 'build-essential', 'curl']
33 DOWNLOAD_CMD = 'curl http://install.meteor.com -o /tmp/meteor-install'
34 INSTALL_CMD = '/bin/sh /tmp/meteor-install'
35
36
37-class Config(object):
38- """A Juju config dictionary that can write itself to
39- disk (json) and track which values have changed since
40- the previous write.
41-
42- """
43- def __init__(self, path=None):
44- """Initialize a `hookenv.config(), with an optional
45- "previous" copy loaded from `path`.
46-
47- """
48- self.cur_dict = hookenv.config()
49- self.prev_dict = None
50- self.path = path
51- if path:
52- self.load_previous(path)
53-
54- def load_previous(self, path):
55- """Load a previous copy of config so that current values
56- can be compares to previous values.
57-
58- """
59- try:
60- with open(path) as f:
61- self.prev_dict = json.load(f)
62- except IOError:
63- pass
64-
65- def changed(self, key):
66- """Return true if the value for this key has changed since
67- the last save.
68-
69- """
70- if self.prev_dict is None:
71- return True
72- return self.prev_dict.get(key) != self.cur_dict.get(key)
73-
74- def previous(self, key):
75- """Return previous value for this key, or None if there
76- is no "previous" value.
77-
78- """
79- if self.prev_dict:
80- return self.prev_dict.get(key)
81- return None
82-
83- def save(self, path=None):
84- """Save this config to `path`, or to the path from which
85- it was loaded. This is a no-op of path is None or this
86- config was not loaded from a file.
87-
88- Preserves items in prev_dict that do not exist in cur_dict.
89-
90- """
91- path = path or self.path
92- if not path:
93- return
94- if self.prev_dict:
95- for k, v in self.prev_dict.iteritems():
96- if k not in self.cur_dict:
97- self.cur_dict[k] = v
98- with open(path, 'w') as f:
99- json.dump(self.cur_dict, f)
100-
101- def get(self, key, default=None):
102- return self.cur_dict.get(key, default)
103-
104- def __getitem__(self, key):
105- return self.cur_dict[key]
106-
107- def __setitem__(self, key, val):
108- self.cur_dict[key] = val
109-
110-
111 def write_upstart(config):
112 upstart = textwrap.dedent("""\
113 description "Meteor app '{app_name}'"
114@@ -206,11 +130,11 @@
115 }[config['repo-type']]
116 fetch.apt_install(pkg)
117 clone(config['repo-type'], config['repo-url'], CODE_DIR,
118- config['repo-revision'])
119+ config['repo-revision'])
120 else:
121 hookenv.log('Creating demo app')
122- subprocess.check_call(['mrt', 'create', '--example',
123- config['demo-app'], CODE_DIR])
124+ subprocess.check_call(
125+ ['mrt', 'create', '--example', config['demo-app'], CODE_DIR])
126
127
128 def init_bundle(config):
129@@ -230,9 +154,19 @@
130 shutil.copytree(os.path.join(CODE_DIR, 'bundle'), BUNDLE_DIR)
131
132
133+def init_dependencies(config):
134+ """Install dependencies for installed meteor app.
135+
136+ """
137+ depends_dir = os.path.join(BUNDLE_DIR, 'programs', 'server')
138+ if os.path.exists(depends_dir):
139+ with chdir(depends_dir):
140+ subprocess.call(['npm', 'install'])
141+
142+
143 @hooks.hook('install')
144 def install():
145- config = Config(METEOR_CONFIG)
146+ config = hookenv.config()
147
148 host.adduser(USER, password='')
149 host.mkdir(BASE_DIR, owner=USER, group=USER)
150@@ -252,20 +186,20 @@
151
152 init_code(config)
153 init_bundle(config)
154+ init_dependencies(config)
155
156 hookenv.open_port(config['port'])
157- subprocess.check_call(['chown', '-R', '{user}:{user}'.format(user=USER),
158- BASE_DIR])
159+ subprocess.check_call(
160+ ['chown', '-R', '{user}:{user}'.format(user=USER), BASE_DIR])
161
162 config['mongo_url'] = ''
163 write_upstart(config)
164- config.save()
165 # wait for mongodb to join before starting
166
167
168 @hooks.hook('config-changed')
169 def config_changed():
170- config = Config(METEOR_CONFIG)
171+ config = hookenv.config()
172 os.environ['HOME'] = os.path.expanduser('~' + USER)
173
174 if config.changed('repo-url') or config.changed('demo-app'):
175@@ -274,8 +208,8 @@
176 init_code(config)
177 elif config.changed('repo-revision') and config['repo-url']:
178 with chdir(CODE_DIR):
179- subprocess.check_call([config['repo-type'], 'pull',
180- config['repo-url']])
181+ subprocess.check_call(
182+ [config['repo-type'], 'pull', config['repo-url']])
183 checkout(config['repo-type'], CODE_DIR, config['repo-revision'])
184
185 if (config.changed('repo-url') or
186@@ -283,6 +217,7 @@
187 config.changed('bundled') or
188 config.changed('demo-app')):
189 init_bundle(config)
190+ init_dependencies(config)
191
192 if config.changed('port'):
193 hookenv.open_port(config['port'])
194@@ -290,13 +225,13 @@
195 hookenv.close_port(config.previous('port'))
196 if hookenv.is_relation_made('website'):
197 hookenv.relation_set(
198- relation_id=config.previous('website-relation-id'),
199+ relation_id=config.previous('website-relation-id'),
200 relation_settings={
201 'port': config['port'],
202 'hostname': hookenv.unit_private_ip()})
203
204- subprocess.check_call(['chown', '-R', '{user}:{user}'.format(user=USER),
205- BASE_DIR])
206+ subprocess.check_call(
207+ ['chown', '-R', '{user}:{user}'.format(user=USER), BASE_DIR])
208
209 if config.previous('mongo_url'):
210 mongo_host = config.previous('mongo_url').rsplit('/', 1)[0]
211@@ -304,21 +239,19 @@
212 config['mongo_url'] = mongo_url
213
214 write_upstart(config)
215- config.save(METEOR_CONFIG)
216 start()
217
218
219 @hooks.hook('mongodb-relation-changed')
220 def mongodb_relation_changed():
221- config = Config(METEOR_CONFIG)
222+ config = hookenv.config()
223 db_host = hookenv.relation_get('private-address')
224 if not db_host:
225 return
226 db_port = hookenv.relation_get('port') or '27017'
227 config['mongo_url'] = 'mongodb://{host}:{port}/{app_name}'.format(
228- host=db_host, port=db_port, app_name=config['app-name'])
229+ host=db_host, port=db_port, app_name=config['app-name'])
230 write_upstart(config)
231- config.save()
232 start()
233
234
235@@ -330,11 +263,31 @@
236 @hooks.hook('upgrade-charm')
237 def upgrade_charm():
238 hookenv.log('Upgrading Meteor')
239- config = Config(METEOR_CONFIG)
240-
241+
242+ OLD_METEOR_CONFIG = BASE_DIR + '/.juju-config'
243+ NEW_METEOR_CONFIG = os.path.join(hookenv.charm_dir(),
244+ hookenv.Config.CONFIG_FILE_NAME)
245+
246+ if (os.path.exists(OLD_METEOR_CONFIG) and not
247+ os.path.exists(NEW_METEOR_CONFIG)):
248+ hookenv.log('Moving config from {} to {}'.format(
249+ OLD_METEOR_CONFIG, NEW_METEOR_CONFIG))
250+ shutil.move(OLD_METEOR_CONFIG, NEW_METEOR_CONFIG)
251+
252+ config = hookenv.config()
253+ os.environ['HOME'] = os.path.expanduser('~' + USER)
254+
255+ hookenv.log('Upgrading nodejs')
256+ fetch.apt_update()
257+ fetch.apt_install(PACKAGES)
258+
259+ hookenv.log('Upgrading meteor/meteorite')
260 subprocess.check_call(DOWNLOAD_CMD.split())
261 subprocess.check_call(INSTALL_CMD.split())
262- init_bundle(config)
263+ subprocess.check_call('npm install -g meteorite'.split())
264+
265+ init_dependencies(config)
266+
267 if host.service_running(SERVICE):
268 start()
269
270@@ -354,12 +307,11 @@
271
272 @hooks.hook('website-relation-joined')
273 def website_relation_joined():
274- config = Config(METEOR_CONFIG)
275- hookenv.relation_set(port=config['port'],
276- hostname=hookenv.unit_private_ip())
277+ config = hookenv.config()
278+ hookenv.relation_set(
279+ port=config['port'], hostname=hookenv.unit_private_ip())
280 # save relation id so we can inform the remote side if we change ports
281 config['website-relation-id'] = os.environ['JUJU_RELATION_ID']
282- config.save(METEOR_CONFIG)
283
284
285 if __name__ == "__main__":
286
287=== added file 'lib/charmhelpers/core/fstab.py'
288--- lib/charmhelpers/core/fstab.py 1970-01-01 00:00:00 +0000
289+++ lib/charmhelpers/core/fstab.py 2014-09-09 18:07:26 +0000
290@@ -0,0 +1,116 @@
291+#!/usr/bin/env python
292+# -*- coding: utf-8 -*-
293+
294+__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
295+
296+import os
297+
298+
299+class Fstab(file):
300+ """This class extends file in order to implement a file reader/writer
301+ for file `/etc/fstab`
302+ """
303+
304+ class Entry(object):
305+ """Entry class represents a non-comment line on the `/etc/fstab` file
306+ """
307+ def __init__(self, device, mountpoint, filesystem,
308+ options, d=0, p=0):
309+ self.device = device
310+ self.mountpoint = mountpoint
311+ self.filesystem = filesystem
312+
313+ if not options:
314+ options = "defaults"
315+
316+ self.options = options
317+ self.d = d
318+ self.p = p
319+
320+ def __eq__(self, o):
321+ return str(self) == str(o)
322+
323+ def __str__(self):
324+ return "{} {} {} {} {} {}".format(self.device,
325+ self.mountpoint,
326+ self.filesystem,
327+ self.options,
328+ self.d,
329+ self.p)
330+
331+ DEFAULT_PATH = os.path.join(os.path.sep, 'etc', 'fstab')
332+
333+ def __init__(self, path=None):
334+ if path:
335+ self._path = path
336+ else:
337+ self._path = self.DEFAULT_PATH
338+ file.__init__(self, self._path, 'r+')
339+
340+ def _hydrate_entry(self, line):
341+ # NOTE: use split with no arguments to split on any
342+ # whitespace including tabs
343+ return Fstab.Entry(*filter(
344+ lambda x: x not in ('', None),
345+ line.strip("\n").split()))
346+
347+ @property
348+ def entries(self):
349+ self.seek(0)
350+ for line in self.readlines():
351+ try:
352+ if not line.startswith("#"):
353+ yield self._hydrate_entry(line)
354+ except ValueError:
355+ pass
356+
357+ def get_entry_by_attr(self, attr, value):
358+ for entry in self.entries:
359+ e_attr = getattr(entry, attr)
360+ if e_attr == value:
361+ return entry
362+ return None
363+
364+ def add_entry(self, entry):
365+ if self.get_entry_by_attr('device', entry.device):
366+ return False
367+
368+ self.write(str(entry) + '\n')
369+ self.truncate()
370+ return entry
371+
372+ def remove_entry(self, entry):
373+ self.seek(0)
374+
375+ lines = self.readlines()
376+
377+ found = False
378+ for index, line in enumerate(lines):
379+ if not line.startswith("#"):
380+ if self._hydrate_entry(line) == entry:
381+ found = True
382+ break
383+
384+ if not found:
385+ return False
386+
387+ lines.remove(line)
388+
389+ self.seek(0)
390+ self.write(''.join(lines))
391+ self.truncate()
392+ return True
393+
394+ @classmethod
395+ def remove_by_mountpoint(cls, mountpoint, path=None):
396+ fstab = cls(path=path)
397+ entry = fstab.get_entry_by_attr('mountpoint', mountpoint)
398+ if entry:
399+ return fstab.remove_entry(entry)
400+ return False
401+
402+ @classmethod
403+ def add(cls, device, mountpoint, filesystem, options=None, path=None):
404+ return cls(path=path).add_entry(Fstab.Entry(device,
405+ mountpoint, filesystem,
406+ options=options))
407
408=== modified file 'lib/charmhelpers/core/hookenv.py'
409--- lib/charmhelpers/core/hookenv.py 2014-04-02 04:52:35 +0000
410+++ lib/charmhelpers/core/hookenv.py 2014-09-09 18:07:26 +0000
411@@ -25,7 +25,7 @@
412 def cached(func):
413 """Cache return values for multiple executions of func + args
414
415- For example:
416+ For example::
417
418 @cached
419 def unit_get(attribute):
420@@ -155,6 +155,121 @@
421 return os.path.basename(sys.argv[0])
422
423
424+class Config(dict):
425+ """A dictionary representation of the charm's config.yaml, with some
426+ extra features:
427+
428+ - See which values in the dictionary have changed since the previous hook.
429+ - For values that have changed, see what the previous value was.
430+ - Store arbitrary data for use in a later hook.
431+
432+ NOTE: Do not instantiate this object directly - instead call
433+ ``hookenv.config()``, which will return an instance of :class:`Config`.
434+
435+ Example usage::
436+
437+ >>> # inside a hook
438+ >>> from charmhelpers.core import hookenv
439+ >>> config = hookenv.config()
440+ >>> config['foo']
441+ 'bar'
442+ >>> # store a new key/value for later use
443+ >>> config['mykey'] = 'myval'
444+
445+
446+ >>> # user runs `juju set mycharm foo=baz`
447+ >>> # now we're inside subsequent config-changed hook
448+ >>> config = hookenv.config()
449+ >>> config['foo']
450+ 'baz'
451+ >>> # test to see if this val has changed since last hook
452+ >>> config.changed('foo')
453+ True
454+ >>> # what was the previous value?
455+ >>> config.previous('foo')
456+ 'bar'
457+ >>> # keys/values that we add are preserved across hooks
458+ >>> config['mykey']
459+ 'myval'
460+
461+ """
462+ CONFIG_FILE_NAME = '.juju-persistent-config'
463+
464+ def __init__(self, *args, **kw):
465+ super(Config, self).__init__(*args, **kw)
466+ self.implicit_save = True
467+ self._prev_dict = None
468+ self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)
469+ if os.path.exists(self.path):
470+ self.load_previous()
471+
472+ def __getitem__(self, key):
473+ """For regular dict lookups, check the current juju config first,
474+ then the previous (saved) copy. This ensures that user-saved values
475+ will be returned by a dict lookup.
476+
477+ """
478+ try:
479+ return dict.__getitem__(self, key)
480+ except KeyError:
481+ return (self._prev_dict or {})[key]
482+
483+ def load_previous(self, path=None):
484+ """Load previous copy of config from disk.
485+
486+ In normal usage you don't need to call this method directly - it
487+ is called automatically at object initialization.
488+
489+ :param path:
490+
491+ File path from which to load the previous config. If `None`,
492+ config is loaded from the default location. If `path` is
493+ specified, subsequent `save()` calls will write to the same
494+ path.
495+
496+ """
497+ self.path = path or self.path
498+ with open(self.path) as f:
499+ self._prev_dict = json.load(f)
500+
501+ def changed(self, key):
502+ """Return True if the current value for this key is different from
503+ the previous value.
504+
505+ """
506+ if self._prev_dict is None:
507+ return True
508+ return self.previous(key) != self.get(key)
509+
510+ def previous(self, key):
511+ """Return previous value for this key, or None if there
512+ is no previous value.
513+
514+ """
515+ if self._prev_dict:
516+ return self._prev_dict.get(key)
517+ return None
518+
519+ def save(self):
520+ """Save this config to disk.
521+
522+ If the charm is using the :mod:`Services Framework <services.base>`
523+ or :meth:'@hook <Hooks.hook>' decorator, this
524+ is called automatically at the end of successful hook execution.
525+ Otherwise, it should be called directly by user code.
526+
527+ To disable automatic saves, set ``implicit_save=False`` on this
528+ instance.
529+
530+ """
531+ if self._prev_dict:
532+ for k, v in self._prev_dict.iteritems():
533+ if k not in self:
534+ self[k] = v
535+ with open(self.path, 'w') as f:
536+ json.dump(self, f)
537+
538+
539 @cached
540 def config(scope=None):
541 """Juju charm configuration"""
542@@ -163,7 +278,10 @@
543 config_cmd_line.append(scope)
544 config_cmd_line.append('--format=json')
545 try:
546- return json.loads(subprocess.check_output(config_cmd_line))
547+ config_data = json.loads(subprocess.check_output(config_cmd_line))
548+ if scope is not None:
549+ return config_data
550+ return Config(config_data)
551 except ValueError:
552 return None
553
554@@ -188,8 +306,9 @@
555 raise
556
557
558-def relation_set(relation_id=None, relation_settings={}, **kwargs):
559+def relation_set(relation_id=None, relation_settings=None, **kwargs):
560 """Set relation information for the current unit"""
561+ relation_settings = relation_settings if relation_settings else {}
562 relation_cmd_line = ['relation-set']
563 if relation_id is not None:
564 relation_cmd_line.extend(('-r', relation_id))
565@@ -348,18 +467,19 @@
566 class Hooks(object):
567 """A convenient handler for hook functions.
568
569- Example:
570+ Example::
571+
572 hooks = Hooks()
573
574 # register a hook, taking its name from the function name
575 @hooks.hook()
576 def install():
577- ...
578+ pass # your code here
579
580 # register a hook, providing a custom hook name
581 @hooks.hook("config-changed")
582 def config_changed():
583- ...
584+ pass # your code here
585
586 if __name__ == "__main__":
587 # execute a hook based on the name the program is called by
588@@ -379,6 +499,9 @@
589 hook_name = os.path.basename(args[0])
590 if hook_name in self._hooks:
591 self._hooks[hook_name]()
592+ cfg = config()
593+ if cfg.implicit_save:
594+ cfg.save()
595 else:
596 raise UnregisteredHookError(hook_name)
597
598
599=== modified file 'lib/charmhelpers/core/host.py'
600--- lib/charmhelpers/core/host.py 2014-04-02 04:52:35 +0000
601+++ lib/charmhelpers/core/host.py 2014-09-09 18:07:26 +0000
602@@ -12,10 +12,13 @@
603 import string
604 import subprocess
605 import hashlib
606+import shutil
607+from contextlib import contextmanager
608
609 from collections import OrderedDict
610
611 from hookenv import log
612+from fstab import Fstab
613
614
615 def service_start(service_name):
616@@ -34,7 +37,8 @@
617
618
619 def service_reload(service_name, restart_on_failure=False):
620- """Reload a system service, optionally falling back to restart if reload fails"""
621+ """Reload a system service, optionally falling back to restart if
622+ reload fails"""
623 service_result = service('reload', service_name)
624 if not service_result and restart_on_failure:
625 service_result = service('restart', service_name)
626@@ -50,7 +54,7 @@
627 def service_running(service):
628 """Determine whether a system service is running"""
629 try:
630- output = subprocess.check_output(['service', service, 'status'])
631+ output = subprocess.check_output(['service', service, 'status'], stderr=subprocess.STDOUT)
632 except subprocess.CalledProcessError:
633 return False
634 else:
635@@ -60,6 +64,16 @@
636 return False
637
638
639+def service_available(service_name):
640+ """Determine whether a system service is available"""
641+ try:
642+ subprocess.check_output(['service', service_name, 'status'], stderr=subprocess.STDOUT)
643+ except subprocess.CalledProcessError:
644+ return False
645+ else:
646+ return True
647+
648+
649 def adduser(username, password=None, shell='/bin/bash', system_user=False):
650 """Add a user to the system"""
651 try:
652@@ -143,7 +157,19 @@
653 target.write(content)
654
655
656-def mount(device, mountpoint, options=None, persist=False):
657+def fstab_remove(mp):
658+ """Remove the given mountpoint entry from /etc/fstab
659+ """
660+ return Fstab.remove_by_mountpoint(mp)
661+
662+
663+def fstab_add(dev, mp, fs, options=None):
664+ """Adds the given device entry to the /etc/fstab file
665+ """
666+ return Fstab.add(dev, mp, fs, options=options)
667+
668+
669+def mount(device, mountpoint, options=None, persist=False, filesystem="ext3"):
670 """Mount a filesystem at a particular mountpoint"""
671 cmd_args = ['mount']
672 if options is not None:
673@@ -154,9 +180,9 @@
674 except subprocess.CalledProcessError, e:
675 log('Error mounting {} at {}\n{}'.format(device, mountpoint, e.output))
676 return False
677+
678 if persist:
679- # TODO: update fstab
680- pass
681+ return fstab_add(device, mountpoint, filesystem, options=options)
682 return True
683
684
685@@ -168,9 +194,9 @@
686 except subprocess.CalledProcessError, e:
687 log('Error unmounting {}\n{}'.format(mountpoint, e.output))
688 return False
689+
690 if persist:
691- # TODO: update fstab
692- pass
693+ return fstab_remove(mountpoint)
694 return True
695
696
697@@ -197,13 +223,13 @@
698 def restart_on_change(restart_map, stopstart=False):
699 """Restart services based on configuration files changing
700
701- This function is used a decorator, for example
702+ This function is used a decorator, for example::
703
704 @restart_on_change({
705 '/etc/ceph/ceph.conf': [ 'cinder-api', 'cinder-volume' ]
706 })
707 def ceph_client_changed():
708- ...
709+ pass # your code here
710
711 In this example, the cinder-api and cinder-volume services
712 would be restarted if /etc/ceph/ceph.conf is changed by the
713@@ -295,3 +321,40 @@
714 if 'link/ether' in words:
715 hwaddr = words[words.index('link/ether') + 1]
716 return hwaddr
717+
718+
719+def cmp_pkgrevno(package, revno, pkgcache=None):
720+ '''Compare supplied revno with the revno of the installed package
721+
722+ * 1 => Installed revno is greater than supplied arg
723+ * 0 => Installed revno is the same as supplied arg
724+ * -1 => Installed revno is less than supplied arg
725+
726+ '''
727+ import apt_pkg
728+ from charmhelpers.fetch import apt_cache
729+ if not pkgcache:
730+ pkgcache = apt_cache()
731+ pkg = pkgcache[package]
732+ return apt_pkg.version_compare(pkg.current_ver.ver_str, revno)
733+
734+
735+@contextmanager
736+def chdir(d):
737+ cur = os.getcwd()
738+ try:
739+ yield os.chdir(d)
740+ finally:
741+ os.chdir(cur)
742+
743+
744+def chownr(path, owner, group):
745+ uid = pwd.getpwnam(owner).pw_uid
746+ gid = grp.getgrnam(group).gr_gid
747+
748+ for root, dirs, files in os.walk(path):
749+ for name in dirs + files:
750+ full = os.path.join(root, name)
751+ broken_symlink = os.path.lexists(full) and not os.path.exists(full)
752+ if not broken_symlink:
753+ os.chown(full, uid, gid)
754
755=== added directory 'lib/charmhelpers/core/services'
756=== added file 'lib/charmhelpers/core/services/__init__.py'
757--- lib/charmhelpers/core/services/__init__.py 1970-01-01 00:00:00 +0000
758+++ lib/charmhelpers/core/services/__init__.py 2014-09-09 18:07:26 +0000
759@@ -0,0 +1,2 @@
760+from .base import *
761+from .helpers import *
762
763=== added file 'lib/charmhelpers/core/services/base.py'
764--- lib/charmhelpers/core/services/base.py 1970-01-01 00:00:00 +0000
765+++ lib/charmhelpers/core/services/base.py 2014-09-09 18:07:26 +0000
766@@ -0,0 +1,313 @@
767+import os
768+import re
769+import json
770+from collections import Iterable
771+
772+from charmhelpers.core import host
773+from charmhelpers.core import hookenv
774+
775+
776+__all__ = ['ServiceManager', 'ManagerCallback',
777+ 'PortManagerCallback', 'open_ports', 'close_ports', 'manage_ports',
778+ 'service_restart', 'service_stop']
779+
780+
781+class ServiceManager(object):
782+ def __init__(self, services=None):
783+ """
784+ Register a list of services, given their definitions.
785+
786+ Service definitions are dicts in the following formats (all keys except
787+ 'service' are optional)::
788+
789+ {
790+ "service": <service name>,
791+ "required_data": <list of required data contexts>,
792+ "provided_data": <list of provided data contexts>,
793+ "data_ready": <one or more callbacks>,
794+ "data_lost": <one or more callbacks>,
795+ "start": <one or more callbacks>,
796+ "stop": <one or more callbacks>,
797+ "ports": <list of ports to manage>,
798+ }
799+
800+ The 'required_data' list should contain dicts of required data (or
801+ dependency managers that act like dicts and know how to collect the data).
802+ Only when all items in the 'required_data' list are populated are the list
803+ of 'data_ready' and 'start' callbacks executed. See `is_ready()` for more
804+ information.
805+
806+ The 'provided_data' list should contain relation data providers, most likely
807+ a subclass of :class:`charmhelpers.core.services.helpers.RelationContext`,
808+ that will indicate a set of data to set on a given relation.
809+
810+ The 'data_ready' value should be either a single callback, or a list of
811+ callbacks, to be called when all items in 'required_data' pass `is_ready()`.
812+ Each callback will be called with the service name as the only parameter.
813+ After all of the 'data_ready' callbacks are called, the 'start' callbacks
814+ are fired.
815+
816+ The 'data_lost' value should be either a single callback, or a list of
817+ callbacks, to be called when a 'required_data' item no longer passes
818+ `is_ready()`. Each callback will be called with the service name as the
819+ only parameter. After all of the 'data_lost' callbacks are called,
820+ the 'stop' callbacks are fired.
821+
822+ The 'start' value should be either a single callback, or a list of
823+ callbacks, to be called when starting the service, after the 'data_ready'
824+ callbacks are complete. Each callback will be called with the service
825+ name as the only parameter. This defaults to
826+ `[host.service_start, services.open_ports]`.
827+
828+ The 'stop' value should be either a single callback, or a list of
829+ callbacks, to be called when stopping the service. If the service is
830+ being stopped because it no longer has all of its 'required_data', this
831+ will be called after all of the 'data_lost' callbacks are complete.
832+ Each callback will be called with the service name as the only parameter.
833+ This defaults to `[services.close_ports, host.service_stop]`.
834+
835+ The 'ports' value should be a list of ports to manage. The default
836+ 'start' handler will open the ports after the service is started,
837+ and the default 'stop' handler will close the ports prior to stopping
838+ the service.
839+
840+
841+ Examples:
842+
843+ The following registers an Upstart service called bingod that depends on
844+ a mongodb relation and which runs a custom `db_migrate` function prior to
845+ restarting the service, and a Runit service called spadesd::
846+
847+ manager = services.ServiceManager([
848+ {
849+ 'service': 'bingod',
850+ 'ports': [80, 443],
851+ 'required_data': [MongoRelation(), config(), {'my': 'data'}],
852+ 'data_ready': [
853+ services.template(source='bingod.conf'),
854+ services.template(source='bingod.ini',
855+ target='/etc/bingod.ini',
856+ owner='bingo', perms=0400),
857+ ],
858+ },
859+ {
860+ 'service': 'spadesd',
861+ 'data_ready': services.template(source='spadesd_run.j2',
862+ target='/etc/sv/spadesd/run',
863+ perms=0555),
864+ 'start': runit_start,
865+ 'stop': runit_stop,
866+ },
867+ ])
868+ manager.manage()
869+ """
870+ self._ready_file = os.path.join(hookenv.charm_dir(), 'READY-SERVICES.json')
871+ self._ready = None
872+ self.services = {}
873+ for service in services or []:
874+ service_name = service['service']
875+ self.services[service_name] = service
876+
877+ def manage(self):
878+ """
879+ Handle the current hook by doing The Right Thing with the registered services.
880+ """
881+ hook_name = hookenv.hook_name()
882+ if hook_name == 'stop':
883+ self.stop_services()
884+ else:
885+ self.provide_data()
886+ self.reconfigure_services()
887+ cfg = hookenv.config()
888+ if cfg.implicit_save:
889+ cfg.save()
890+
891+ def provide_data(self):
892+ """
893+ Set the relation data for each provider in the ``provided_data`` list.
894+
895+ A provider must have a `name` attribute, which indicates which relation
896+ to set data on, and a `provide_data()` method, which returns a dict of
897+ data to set.
898+ """
899+ hook_name = hookenv.hook_name()
900+ for service in self.services.values():
901+ for provider in service.get('provided_data', []):
902+ if re.match(r'{}-relation-(joined|changed)'.format(provider.name), hook_name):
903+ data = provider.provide_data()
904+ _ready = provider._is_ready(data) if hasattr(provider, '_is_ready') else data
905+ if _ready:
906+ hookenv.relation_set(None, data)
907+
908+ def reconfigure_services(self, *service_names):
909+ """
910+ Update all files for one or more registered services, and,
911+ if ready, optionally restart them.
912+
913+ If no service names are given, reconfigures all registered services.
914+ """
915+ for service_name in service_names or self.services.keys():
916+ if self.is_ready(service_name):
917+ self.fire_event('data_ready', service_name)
918+ self.fire_event('start', service_name, default=[
919+ service_restart,
920+ manage_ports])
921+ self.save_ready(service_name)
922+ else:
923+ if self.was_ready(service_name):
924+ self.fire_event('data_lost', service_name)
925+ self.fire_event('stop', service_name, default=[
926+ manage_ports,
927+ service_stop])
928+ self.save_lost(service_name)
929+
930+ def stop_services(self, *service_names):
931+ """
932+ Stop one or more registered services, by name.
933+
934+ If no service names are given, stops all registered services.
935+ """
936+ for service_name in service_names or self.services.keys():
937+ self.fire_event('stop', service_name, default=[
938+ manage_ports,
939+ service_stop])
940+
941+ def get_service(self, service_name):
942+ """
943+ Given the name of a registered service, return its service definition.
944+ """
945+ service = self.services.get(service_name)
946+ if not service:
947+ raise KeyError('Service not registered: %s' % service_name)
948+ return service
949+
950+ def fire_event(self, event_name, service_name, default=None):
951+ """
952+ Fire a data_ready, data_lost, start, or stop event on a given service.
953+ """
954+ service = self.get_service(service_name)
955+ callbacks = service.get(event_name, default)
956+ if not callbacks:
957+ return
958+ if not isinstance(callbacks, Iterable):
959+ callbacks = [callbacks]
960+ for callback in callbacks:
961+ if isinstance(callback, ManagerCallback):
962+ callback(self, service_name, event_name)
963+ else:
964+ callback(service_name)
965+
966+ def is_ready(self, service_name):
967+ """
968+ Determine if a registered service is ready, by checking its 'required_data'.
969+
970+ A 'required_data' item can be any mapping type, and is considered ready
971+ if `bool(item)` evaluates as True.
972+ """
973+ service = self.get_service(service_name)
974+ reqs = service.get('required_data', [])
975+ return all(bool(req) for req in reqs)
976+
977+ def _load_ready_file(self):
978+ if self._ready is not None:
979+ return
980+ if os.path.exists(self._ready_file):
981+ with open(self._ready_file) as fp:
982+ self._ready = set(json.load(fp))
983+ else:
984+ self._ready = set()
985+
986+ def _save_ready_file(self):
987+ if self._ready is None:
988+ return
989+ with open(self._ready_file, 'w') as fp:
990+ json.dump(list(self._ready), fp)
991+
992+ def save_ready(self, service_name):
993+ """
994+ Save an indicator that the given service is now data_ready.
995+ """
996+ self._load_ready_file()
997+ self._ready.add(service_name)
998+ self._save_ready_file()
999+
1000+ def save_lost(self, service_name):
1001+ """
1002+ Save an indicator that the given service is no longer data_ready.
1003+ """
1004+ self._load_ready_file()
1005+ self._ready.discard(service_name)
1006+ self._save_ready_file()
1007+
1008+ def was_ready(self, service_name):
1009+ """
1010+ Determine if the given service was previously data_ready.
1011+ """
1012+ self._load_ready_file()
1013+ return service_name in self._ready
1014+
1015+
1016+class ManagerCallback(object):
1017+ """
1018+ Special case of a callback that takes the `ServiceManager` instance
1019+ in addition to the service name.
1020+
1021+ Subclasses should implement `__call__` which should accept three parameters:
1022+
1023+ * `manager` The `ServiceManager` instance
1024+ * `service_name` The name of the service it's being triggered for
1025+ * `event_name` The name of the event that this callback is handling
1026+ """
1027+ def __call__(self, manager, service_name, event_name):
1028+ raise NotImplementedError()
1029+
1030+
1031+class PortManagerCallback(ManagerCallback):
1032+ """
1033+ Callback class that will open or close ports, for use as either
1034+ a start or stop action.
1035+ """
1036+ def __call__(self, manager, service_name, event_name):
1037+ service = manager.get_service(service_name)
1038+ new_ports = service.get('ports', [])
1039+ port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name))
1040+ if os.path.exists(port_file):
1041+ with open(port_file) as fp:
1042+ old_ports = fp.read().split(',')
1043+ for old_port in old_ports:
1044+ if bool(old_port):
1045+ old_port = int(old_port)
1046+ if old_port not in new_ports:
1047+ hookenv.close_port(old_port)
1048+ with open(port_file, 'w') as fp:
1049+ fp.write(','.join(str(port) for port in new_ports))
1050+ for port in new_ports:
1051+ if event_name == 'start':
1052+ hookenv.open_port(port)
1053+ elif event_name == 'stop':
1054+ hookenv.close_port(port)
1055+
1056+
1057+def service_stop(service_name):
1058+ """
1059+ Wrapper around host.service_stop to prevent spurious "unknown service"
1060+ messages in the logs.
1061+ """
1062+ if host.service_running(service_name):
1063+ host.service_stop(service_name)
1064+
1065+
1066+def service_restart(service_name):
1067+ """
1068+ Wrapper around host.service_restart to prevent spurious "unknown service"
1069+ messages in the logs.
1070+ """
1071+ if host.service_available(service_name):
1072+ if host.service_running(service_name):
1073+ host.service_restart(service_name)
1074+ else:
1075+ host.service_start(service_name)
1076+
1077+
1078+# Convenience aliases
1079+open_ports = close_ports = manage_ports = PortManagerCallback()
1080
1081=== added file 'lib/charmhelpers/core/services/helpers.py'
1082--- lib/charmhelpers/core/services/helpers.py 1970-01-01 00:00:00 +0000
1083+++ lib/charmhelpers/core/services/helpers.py 2014-09-09 18:07:26 +0000
1084@@ -0,0 +1,125 @@
1085+from charmhelpers.core import hookenv
1086+from charmhelpers.core import templating
1087+
1088+from charmhelpers.core.services.base import ManagerCallback
1089+
1090+
1091+__all__ = ['RelationContext', 'TemplateCallback',
1092+ 'render_template', 'template']
1093+
1094+
1095+class RelationContext(dict):
1096+ """
1097+ Base class for a context generator that gets relation data from juju.
1098+
1099+ Subclasses must provide the attributes `name`, which is the name of the
1100+ interface of interest, `interface`, which is the type of the interface of
1101+ interest, and `required_keys`, which is the set of keys required for the
1102+ relation to be considered complete. The data for all interfaces matching
1103+ the `name` attribute that are complete will used to populate the dictionary
1104+ values (see `get_data`, below).
1105+
1106+ The generated context will be namespaced under the interface type, to prevent
1107+ potential naming conflicts.
1108+ """
1109+ name = None
1110+ interface = None
1111+ required_keys = []
1112+
1113+ def __init__(self, *args, **kwargs):
1114+ super(RelationContext, self).__init__(*args, **kwargs)
1115+ self.get_data()
1116+
1117+ def __bool__(self):
1118+ """
1119+ Returns True if all of the required_keys are available.
1120+ """
1121+ return self.is_ready()
1122+
1123+ __nonzero__ = __bool__
1124+
1125+ def __repr__(self):
1126+ return super(RelationContext, self).__repr__()
1127+
1128+ def is_ready(self):
1129+ """
1130+ Returns True if all of the `required_keys` are available from any units.
1131+ """
1132+ ready = len(self.get(self.name, [])) > 0
1133+ if not ready:
1134+ hookenv.log('Incomplete relation: {}'.format(self.__class__.__name__), hookenv.DEBUG)
1135+ return ready
1136+
1137+ def _is_ready(self, unit_data):
1138+ """
1139+ Helper method that tests a set of relation data and returns True if
1140+ all of the `required_keys` are present.
1141+ """
1142+ return set(unit_data.keys()).issuperset(set(self.required_keys))
1143+
1144+ def get_data(self):
1145+ """
1146+ Retrieve the relation data for each unit involved in a relation and,
1147+ if complete, store it in a list under `self[self.name]`. This
1148+ is automatically called when the RelationContext is instantiated.
1149+
1150+ The units are sorted lexographically first by the service ID, then by
1151+ the unit ID. Thus, if an interface has two other services, 'db:1'
1152+ and 'db:2', with 'db:1' having two units, 'wordpress/0' and 'wordpress/1',
1153+ and 'db:2' having one unit, 'mediawiki/0', all of which have a complete
1154+ set of data, the relation data for the units will be stored in the
1155+ order: 'wordpress/0', 'wordpress/1', 'mediawiki/0'.
1156+
1157+ If you only care about a single unit on the relation, you can just
1158+ access it as `{{ interface[0]['key'] }}`. However, if you can at all
1159+ support multiple units on a relation, you should iterate over the list,
1160+ like::
1161+
1162+ {% for unit in interface -%}
1163+ {{ unit['key'] }}{% if not loop.last %},{% endif %}
1164+ {%- endfor %}
1165+
1166+ Note that since all sets of relation data from all related services and
1167+ units are in a single list, if you need to know which service or unit a
1168+ set of data came from, you'll need to extend this class to preserve
1169+ that information.
1170+ """
1171+ if not hookenv.relation_ids(self.name):
1172+ return
1173+
1174+ ns = self.setdefault(self.name, [])
1175+ for rid in sorted(hookenv.relation_ids(self.name)):
1176+ for unit in sorted(hookenv.related_units(rid)):
1177+ reldata = hookenv.relation_get(rid=rid, unit=unit)
1178+ if self._is_ready(reldata):
1179+ ns.append(reldata)
1180+
1181+ def provide_data(self):
1182+ """
1183+ Return data to be relation_set for this interface.
1184+ """
1185+ return {}
1186+
1187+
1188+class TemplateCallback(ManagerCallback):
1189+ """
1190+ Callback class that will render a template, for use as a ready action.
1191+ """
1192+ def __init__(self, source, target, owner='root', group='root', perms=0444):
1193+ self.source = source
1194+ self.target = target
1195+ self.owner = owner
1196+ self.group = group
1197+ self.perms = perms
1198+
1199+ def __call__(self, manager, service_name, event_name):
1200+ service = manager.get_service(service_name)
1201+ context = {}
1202+ for ctx in service.get('required_data', []):
1203+ context.update(ctx)
1204+ templating.render(self.source, self.target, context,
1205+ self.owner, self.group, self.perms)
1206+
1207+
1208+# Convenience aliases for templates
1209+render_template = template = TemplateCallback
1210
1211=== added file 'lib/charmhelpers/core/templating.py'
1212--- lib/charmhelpers/core/templating.py 1970-01-01 00:00:00 +0000
1213+++ lib/charmhelpers/core/templating.py 2014-09-09 18:07:26 +0000
1214@@ -0,0 +1,51 @@
1215+import os
1216+
1217+from charmhelpers.core import host
1218+from charmhelpers.core import hookenv
1219+
1220+
1221+def render(source, target, context, owner='root', group='root', perms=0444, templates_dir=None):
1222+ """
1223+ Render a template.
1224+
1225+ The `source` path, if not absolute, is relative to the `templates_dir`.
1226+
1227+ The `target` path should be absolute.
1228+
1229+ The context should be a dict containing the values to be replaced in the
1230+ template.
1231+
1232+ The `owner`, `group`, and `perms` options will be passed to `write_file`.
1233+
1234+ If omitted, `templates_dir` defaults to the `templates` folder in the charm.
1235+
1236+ Note: Using this requires python-jinja2; if it is not installed, calling
1237+ this will attempt to use charmhelpers.fetch.apt_install to install it.
1238+ """
1239+ try:
1240+ from jinja2 import FileSystemLoader, Environment, exceptions
1241+ except ImportError:
1242+ try:
1243+ from charmhelpers.fetch import apt_install
1244+ except ImportError:
1245+ hookenv.log('Could not import jinja2, and could not import '
1246+ 'charmhelpers.fetch to install it',
1247+ level=hookenv.ERROR)
1248+ raise
1249+ apt_install('python-jinja2', fatal=True)
1250+ from jinja2 import FileSystemLoader, Environment, exceptions
1251+
1252+ if templates_dir is None:
1253+ templates_dir = os.path.join(hookenv.charm_dir(), 'templates')
1254+ loader = Environment(loader=FileSystemLoader(templates_dir))
1255+ try:
1256+ source = source
1257+ template = loader.get_template(source)
1258+ except exceptions.TemplateNotFound as e:
1259+ hookenv.log('Could not load template %s from %s.' %
1260+ (source, templates_dir),
1261+ level=hookenv.ERROR)
1262+ raise e
1263+ content = template.render(context)
1264+ host.mkdir(os.path.dirname(target))
1265+ host.write_file(target, content, owner, group, perms)
1266
1267=== modified file 'lib/charmhelpers/fetch/__init__.py'
1268--- lib/charmhelpers/fetch/__init__.py 2014-04-02 04:52:35 +0000
1269+++ lib/charmhelpers/fetch/__init__.py 2014-09-09 18:07:26 +0000
1270@@ -1,4 +1,6 @@
1271 import importlib
1272+from tempfile import NamedTemporaryFile
1273+import time
1274 from yaml import safe_load
1275 from charmhelpers.core.host import (
1276 lsb_release
1277@@ -12,9 +14,9 @@
1278 config,
1279 log,
1280 )
1281-import apt_pkg
1282 import os
1283
1284+
1285 CLOUD_ARCHIVE = """# Ubuntu Cloud Archive
1286 deb http://ubuntu-cloud.archive.canonical.com/ubuntu {} main
1287 """
1288@@ -54,13 +56,68 @@
1289 'icehouse/proposed': 'precise-proposed/icehouse',
1290 'precise-icehouse/proposed': 'precise-proposed/icehouse',
1291 'precise-proposed/icehouse': 'precise-proposed/icehouse',
1292+ # Juno
1293+ 'juno': 'trusty-updates/juno',
1294+ 'trusty-juno': 'trusty-updates/juno',
1295+ 'trusty-juno/updates': 'trusty-updates/juno',
1296+ 'trusty-updates/juno': 'trusty-updates/juno',
1297+ 'juno/proposed': 'trusty-proposed/juno',
1298+ 'juno/proposed': 'trusty-proposed/juno',
1299+ 'trusty-juno/proposed': 'trusty-proposed/juno',
1300+ 'trusty-proposed/juno': 'trusty-proposed/juno',
1301 }
1302
1303+# The order of this list is very important. Handlers should be listed in from
1304+# least- to most-specific URL matching.
1305+FETCH_HANDLERS = (
1306+ 'charmhelpers.fetch.archiveurl.ArchiveUrlFetchHandler',
1307+ 'charmhelpers.fetch.bzrurl.BzrUrlFetchHandler',
1308+)
1309+
1310+APT_NO_LOCK = 100 # The return code for "couldn't acquire lock" in APT.
1311+APT_NO_LOCK_RETRY_DELAY = 10 # Wait 10 seconds between apt lock checks.
1312+APT_NO_LOCK_RETRY_COUNT = 30 # Retry to acquire the lock X times.
1313+
1314+
1315+class SourceConfigError(Exception):
1316+ pass
1317+
1318+
1319+class UnhandledSource(Exception):
1320+ pass
1321+
1322+
1323+class AptLockError(Exception):
1324+ pass
1325+
1326+
1327+class BaseFetchHandler(object):
1328+
1329+ """Base class for FetchHandler implementations in fetch plugins"""
1330+
1331+ def can_handle(self, source):
1332+ """Returns True if the source can be handled. Otherwise returns
1333+ a string explaining why it cannot"""
1334+ return "Wrong source type"
1335+
1336+ def install(self, source):
1337+ """Try to download and unpack the source. Return the path to the
1338+ unpacked files or raise UnhandledSource."""
1339+ raise UnhandledSource("Wrong source type {}".format(source))
1340+
1341+ def parse_url(self, url):
1342+ return urlparse(url)
1343+
1344+ def base_url(self, url):
1345+ """Return url without querystring or fragment"""
1346+ parts = list(self.parse_url(url))
1347+ parts[4:] = ['' for i in parts[4:]]
1348+ return urlunparse(parts)
1349+
1350
1351 def filter_installed_packages(packages):
1352 """Returns a list of packages that require installation"""
1353- apt_pkg.init()
1354- cache = apt_pkg.Cache()
1355+ cache = apt_cache()
1356 _pkgs = []
1357 for package in packages:
1358 try:
1359@@ -73,6 +130,16 @@
1360 return _pkgs
1361
1362
1363+def apt_cache(in_memory=True):
1364+ """Build and return an apt cache"""
1365+ import apt_pkg
1366+ apt_pkg.init()
1367+ if in_memory:
1368+ apt_pkg.config.set("Dir::Cache::pkgcache", "")
1369+ apt_pkg.config.set("Dir::Cache::srcpkgcache", "")
1370+ return apt_pkg.Cache()
1371+
1372+
1373 def apt_install(packages, options=None, fatal=False):
1374 """Install one or more packages"""
1375 if options is None:
1376@@ -87,14 +154,7 @@
1377 cmd.extend(packages)
1378 log("Installing {} with options: {}".format(packages,
1379 options))
1380- env = os.environ.copy()
1381- if 'DEBIAN_FRONTEND' not in env:
1382- env['DEBIAN_FRONTEND'] = 'noninteractive'
1383-
1384- if fatal:
1385- subprocess.check_call(cmd, env=env)
1386- else:
1387- subprocess.call(cmd, env=env)
1388+ _run_apt_command(cmd, fatal)
1389
1390
1391 def apt_upgrade(options=None, fatal=False, dist=False):
1392@@ -109,24 +169,13 @@
1393 else:
1394 cmd.append('upgrade')
1395 log("Upgrading with options: {}".format(options))
1396-
1397- env = os.environ.copy()
1398- if 'DEBIAN_FRONTEND' not in env:
1399- env['DEBIAN_FRONTEND'] = 'noninteractive'
1400-
1401- if fatal:
1402- subprocess.check_call(cmd, env=env)
1403- else:
1404- subprocess.call(cmd, env=env)
1405+ _run_apt_command(cmd, fatal)
1406
1407
1408 def apt_update(fatal=False):
1409 """Update local apt cache"""
1410 cmd = ['apt-get', 'update']
1411- if fatal:
1412- subprocess.check_call(cmd)
1413- else:
1414- subprocess.call(cmd)
1415+ _run_apt_command(cmd, fatal)
1416
1417
1418 def apt_purge(packages, fatal=False):
1419@@ -137,10 +186,7 @@
1420 else:
1421 cmd.extend(packages)
1422 log("Purging {}".format(packages))
1423- if fatal:
1424- subprocess.check_call(cmd)
1425- else:
1426- subprocess.call(cmd)
1427+ _run_apt_command(cmd, fatal)
1428
1429
1430 def apt_hold(packages, fatal=False):
1431@@ -151,6 +197,7 @@
1432 else:
1433 cmd.extend(packages)
1434 log("Holding {}".format(packages))
1435+
1436 if fatal:
1437 subprocess.check_call(cmd)
1438 else:
1439@@ -158,6 +205,27 @@
1440
1441
1442 def add_source(source, key=None):
1443+ """Add a package source to this system.
1444+
1445+ @param source: a URL or sources.list entry, as supported by
1446+ add-apt-repository(1). Examples:
1447+ ppa:charmers/example
1448+ deb https://stub:key@private.example.com/ubuntu trusty main
1449+
1450+ In addition:
1451+ 'proposed:' may be used to enable the standard 'proposed'
1452+ pocket for the release.
1453+ 'cloud:' may be used to activate official cloud archive pockets,
1454+ such as 'cloud:icehouse'
1455+
1456+ @param key: A key to be added to the system's APT keyring and used
1457+ to verify the signatures on packages. Ideally, this should be an
1458+ ASCII format GPG public key including the block headers. A GPG key
1459+ id may also be used, but be aware that only insecure protocols are
1460+ available to retrieve the actual public key from a public keyserver
1461+ placing your Juju environment at risk. ppa and cloud archive keys
1462+ are securely added automtically, so sould not be provided.
1463+ """
1464 if source is None:
1465 log('Source is not present. Skipping')
1466 return
1467@@ -182,59 +250,66 @@
1468 release = lsb_release()['DISTRIB_CODENAME']
1469 with open('/etc/apt/sources.list.d/proposed.list', 'w') as apt:
1470 apt.write(PROPOSED_POCKET.format(release))
1471+ else:
1472+ raise SourceConfigError("Unknown source: {!r}".format(source))
1473+
1474 if key:
1475- subprocess.check_call(['apt-key', 'adv', '--keyserver',
1476- 'keyserver.ubuntu.com', '--recv',
1477- key])
1478-
1479-
1480-class SourceConfigError(Exception):
1481- pass
1482+ if '-----BEGIN PGP PUBLIC KEY BLOCK-----' in key:
1483+ with NamedTemporaryFile() as key_file:
1484+ key_file.write(key)
1485+ key_file.flush()
1486+ key_file.seek(0)
1487+ subprocess.check_call(['apt-key', 'add', '-'], stdin=key_file)
1488+ else:
1489+ # Note that hkp: is in no way a secure protocol. Using a
1490+ # GPG key id is pointless from a security POV unless you
1491+ # absolutely trust your network and DNS.
1492+ subprocess.check_call(['apt-key', 'adv', '--keyserver',
1493+ 'hkp://keyserver.ubuntu.com:80', '--recv',
1494+ key])
1495
1496
1497 def configure_sources(update=False,
1498 sources_var='install_sources',
1499 keys_var='install_keys'):
1500 """
1501- Configure multiple sources from charm configuration
1502+ Configure multiple sources from charm configuration.
1503+
1504+ The lists are encoded as yaml fragments in the configuration.
1505+ The frament needs to be included as a string. Sources and their
1506+ corresponding keys are of the types supported by add_source().
1507
1508 Example config:
1509- install_sources:
1510+ install_sources: |
1511 - "ppa:foo"
1512 - "http://example.com/repo precise main"
1513- install_keys:
1514+ install_keys: |
1515 - null
1516 - "a1b2c3d4"
1517
1518 Note that 'null' (a.k.a. None) should not be quoted.
1519 """
1520- sources = safe_load(config(sources_var))
1521- keys = config(keys_var)
1522- if keys is not None:
1523- keys = safe_load(keys)
1524- if isinstance(sources, basestring) and (
1525- keys is None or isinstance(keys, basestring)):
1526- add_source(sources, keys)
1527+ sources = safe_load((config(sources_var) or '').strip()) or []
1528+ keys = safe_load((config(keys_var) or '').strip()) or None
1529+
1530+ if isinstance(sources, basestring):
1531+ sources = [sources]
1532+
1533+ if keys is None:
1534+ for source in sources:
1535+ add_source(source, None)
1536 else:
1537- if not len(sources) == len(keys):
1538- msg = 'Install sources and keys lists are different lengths'
1539- raise SourceConfigError(msg)
1540- for src_num in range(len(sources)):
1541- add_source(sources[src_num], keys[src_num])
1542+ if isinstance(keys, basestring):
1543+ keys = [keys]
1544+
1545+ if len(sources) != len(keys):
1546+ raise SourceConfigError(
1547+ 'Install sources and keys lists are different lengths')
1548+ for source, key in zip(sources, keys):
1549+ add_source(source, key)
1550 if update:
1551 apt_update(fatal=True)
1552
1553-# The order of this list is very important. Handlers should be listed in from
1554-# least- to most-specific URL matching.
1555-FETCH_HANDLERS = (
1556- 'charmhelpers.fetch.archiveurl.ArchiveUrlFetchHandler',
1557- 'charmhelpers.fetch.bzrurl.BzrUrlFetchHandler',
1558-)
1559-
1560-
1561-class UnhandledSource(Exception):
1562- pass
1563-
1564
1565 def install_remote(source):
1566 """
1567@@ -265,30 +340,6 @@
1568 return install_remote(source)
1569
1570
1571-class BaseFetchHandler(object):
1572-
1573- """Base class for FetchHandler implementations in fetch plugins"""
1574-
1575- def can_handle(self, source):
1576- """Returns True if the source can be handled. Otherwise returns
1577- a string explaining why it cannot"""
1578- return "Wrong source type"
1579-
1580- def install(self, source):
1581- """Try to download and unpack the source. Return the path to the
1582- unpacked files or raise UnhandledSource."""
1583- raise UnhandledSource("Wrong source type {}".format(source))
1584-
1585- def parse_url(self, url):
1586- return urlparse(url)
1587-
1588- def base_url(self, url):
1589- """Return url without querystring or fragment"""
1590- parts = list(self.parse_url(url))
1591- parts[4:] = ['' for i in parts[4:]]
1592- return urlunparse(parts)
1593-
1594-
1595 def plugins(fetch_handlers=None):
1596 if not fetch_handlers:
1597 fetch_handlers = FETCH_HANDLERS
1598@@ -306,3 +357,40 @@
1599 log("FetchHandler {} not found, skipping plugin".format(
1600 handler_name))
1601 return plugin_list
1602+
1603+
1604+def _run_apt_command(cmd, fatal=False):
1605+ """
1606+ Run an APT command, checking output and retrying if the fatal flag is set
1607+ to True.
1608+
1609+ :param: cmd: str: The apt command to run.
1610+ :param: fatal: bool: Whether the command's output should be checked and
1611+ retried.
1612+ """
1613+ env = os.environ.copy()
1614+
1615+ if 'DEBIAN_FRONTEND' not in env:
1616+ env['DEBIAN_FRONTEND'] = 'noninteractive'
1617+
1618+ if fatal:
1619+ retry_count = 0
1620+ result = None
1621+
1622+ # If the command is considered "fatal", we need to retry if the apt
1623+ # lock was not acquired.
1624+
1625+ while result is None or result == APT_NO_LOCK:
1626+ try:
1627+ result = subprocess.check_call(cmd, env=env)
1628+ except subprocess.CalledProcessError, e:
1629+ retry_count = retry_count + 1
1630+ if retry_count > APT_NO_LOCK_RETRY_COUNT:
1631+ raise
1632+ result = e.returncode
1633+ log("Couldn't acquire DPKG lock. Will retry in {} seconds."
1634+ "".format(APT_NO_LOCK_RETRY_DELAY))
1635+ time.sleep(APT_NO_LOCK_RETRY_DELAY)
1636+
1637+ else:
1638+ subprocess.call(cmd, env=env)
1639
1640=== modified file 'lib/charmhelpers/fetch/bzrurl.py'
1641--- lib/charmhelpers/fetch/bzrurl.py 2014-04-02 04:52:35 +0000
1642+++ lib/charmhelpers/fetch/bzrurl.py 2014-09-09 18:07:26 +0000
1643@@ -39,7 +39,8 @@
1644 def install(self, source):
1645 url_parts = self.parse_url(source)
1646 branch_name = url_parts.path.strip("/").split("/")[-1]
1647- dest_dir = os.path.join(os.environ.get('CHARM_DIR'), "fetched", branch_name)
1648+ dest_dir = os.path.join(os.environ.get('CHARM_DIR'), "fetched",
1649+ branch_name)
1650 if not os.path.exists(dest_dir):
1651 mkdir(dest_dir, perms=0755)
1652 try:
1653
1654=== modified file 'tests/10-deploy'
1655--- tests/10-deploy 2014-04-02 15:50:07 +0000
1656+++ tests/10-deploy 2014-09-09 18:07:26 +0000
1657@@ -1,4 +1,4 @@
1658-#!/usr/bin/python3
1659+#!/usr/bin/env python3
1660
1661 import amulet
1662 import requests

Subscribers

People subscribed via source and target branches