Merge lp:~tribaal/charms/trusty/landscape-client/resync-charm-helpers-landscape-trunk into lp:charms/trusty/landscape-client

Proposed by Chris Glass
Status: Superseded
Proposed branch: lp:~tribaal/charms/trusty/landscape-client/resync-charm-helpers-landscape-trunk
Merge into: lp:charms/trusty/landscape-client
Diff against target: 1570 lines (+1131/-51)
14 files modified
hooks/charmhelpers/core/fstab.py (+116/-0)
hooks/charmhelpers/core/hookenv.py (+132/-7)
hooks/charmhelpers/core/host.py (+100/-12)
hooks/charmhelpers/core/services/__init__.py (+2/-0)
hooks/charmhelpers/core/services/base.py (+313/-0)
hooks/charmhelpers/core/services/helpers.py (+239/-0)
hooks/charmhelpers/core/templating.py (+51/-0)
hooks/charmhelpers/fetch/__init__.py (+102/-25)
hooks/charmhelpers/fetch/archiveurl.py (+49/-4)
hooks/charmhelpers/fetch/bzrurl.py (+2/-1)
hooks/common.py (+13/-0)
hooks/hooks.py (+1/-0)
hooks/install.py (+6/-2)
hooks/test_hooks.py (+5/-0)
To merge this branch: bzr merge lp:~tribaal/charms/trusty/landscape-client/resync-charm-helpers-landscape-trunk
Reviewer Review Type Date Requested Status
Adam Collard Needs Resubmitting
Landscape Pending
Review via email: mp+236085@code.launchpad.net

This proposal has been superseded by a proposal from 2014-09-26.

Description of the change

This branch resyncs charm-helpers to get the in-memory apt cache fixes.

Produced mechanically by:
bzr co lp:~landscape/charms/trusty/landscape-client/trunk
bzr cd trunk
make sync
bzr add .
bzr commit

To post a comment you must log in.
Revision history for this message
Adam Collard (adam-collard) wrote :

Err... this is proposed to land in trunk. Not in ~landscape. Happy Friday?

review: Needs Resubmitting

Unmerged revisions

Preview Diff

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

Subscribers

People subscribed via source and target branches