~robert-ayres/charms/trusty/contrail-configuration/trunk

« back to all changes in this revision

Viewing changes to hooks/charmhelpers/fetch/__init__.py

  • Committer: Robert Ayres
  • Date: 2014-09-10 14:03:02 UTC
  • Revision ID: robert.ayres@canonical.com-20140910140302-bqu0wb61an4nhgfa
Initial charm

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
import importlib
 
2
from tempfile import NamedTemporaryFile
 
3
import time
 
4
from yaml import safe_load
 
5
from charmhelpers.core.host import (
 
6
    lsb_release
 
7
)
 
8
from urlparse import (
 
9
    urlparse,
 
10
    urlunparse,
 
11
)
 
12
import subprocess
 
13
from charmhelpers.core.hookenv import (
 
14
    config,
 
15
    log,
 
16
)
 
17
import os
 
18
 
 
19
 
 
20
CLOUD_ARCHIVE = """# Ubuntu Cloud Archive
 
21
deb http://ubuntu-cloud.archive.canonical.com/ubuntu {} main
 
22
"""
 
23
PROPOSED_POCKET = """# Proposed
 
24
deb http://archive.ubuntu.com/ubuntu {}-proposed main universe multiverse restricted
 
25
"""
 
26
CLOUD_ARCHIVE_POCKETS = {
 
27
    # Folsom
 
28
    'folsom': 'precise-updates/folsom',
 
29
    'precise-folsom': 'precise-updates/folsom',
 
30
    'precise-folsom/updates': 'precise-updates/folsom',
 
31
    'precise-updates/folsom': 'precise-updates/folsom',
 
32
    'folsom/proposed': 'precise-proposed/folsom',
 
33
    'precise-folsom/proposed': 'precise-proposed/folsom',
 
34
    'precise-proposed/folsom': 'precise-proposed/folsom',
 
35
    # Grizzly
 
36
    'grizzly': 'precise-updates/grizzly',
 
37
    'precise-grizzly': 'precise-updates/grizzly',
 
38
    'precise-grizzly/updates': 'precise-updates/grizzly',
 
39
    'precise-updates/grizzly': 'precise-updates/grizzly',
 
40
    'grizzly/proposed': 'precise-proposed/grizzly',
 
41
    'precise-grizzly/proposed': 'precise-proposed/grizzly',
 
42
    'precise-proposed/grizzly': 'precise-proposed/grizzly',
 
43
    # Havana
 
44
    'havana': 'precise-updates/havana',
 
45
    'precise-havana': 'precise-updates/havana',
 
46
    'precise-havana/updates': 'precise-updates/havana',
 
47
    'precise-updates/havana': 'precise-updates/havana',
 
48
    'havana/proposed': 'precise-proposed/havana',
 
49
    'precise-havana/proposed': 'precise-proposed/havana',
 
50
    'precise-proposed/havana': 'precise-proposed/havana',
 
51
    # Icehouse
 
52
    'icehouse': 'precise-updates/icehouse',
 
53
    'precise-icehouse': 'precise-updates/icehouse',
 
54
    'precise-icehouse/updates': 'precise-updates/icehouse',
 
55
    'precise-updates/icehouse': 'precise-updates/icehouse',
 
56
    'icehouse/proposed': 'precise-proposed/icehouse',
 
57
    'precise-icehouse/proposed': 'precise-proposed/icehouse',
 
58
    'precise-proposed/icehouse': 'precise-proposed/icehouse',
 
59
    # Juno
 
60
    'juno': 'trusty-updates/juno',
 
61
    'trusty-juno': 'trusty-updates/juno',
 
62
    'trusty-juno/updates': 'trusty-updates/juno',
 
63
    'trusty-updates/juno': 'trusty-updates/juno',
 
64
    'juno/proposed': 'trusty-proposed/juno',
 
65
    'juno/proposed': 'trusty-proposed/juno',
 
66
    'trusty-juno/proposed': 'trusty-proposed/juno',
 
67
    'trusty-proposed/juno': 'trusty-proposed/juno',
 
68
}
 
69
 
 
70
# The order of this list is very important. Handlers should be listed in from
 
71
# least- to most-specific URL matching.
 
72
FETCH_HANDLERS = (
 
73
    'charmhelpers.fetch.archiveurl.ArchiveUrlFetchHandler',
 
74
    'charmhelpers.fetch.bzrurl.BzrUrlFetchHandler',
 
75
)
 
76
 
 
77
APT_NO_LOCK = 100  # The return code for "couldn't acquire lock" in APT.
 
78
APT_NO_LOCK_RETRY_DELAY = 10  # Wait 10 seconds between apt lock checks.
 
79
APT_NO_LOCK_RETRY_COUNT = 30  # Retry to acquire the lock X times.
 
80
 
 
81
 
 
82
class SourceConfigError(Exception):
 
83
    pass
 
84
 
 
85
 
 
86
class UnhandledSource(Exception):
 
87
    pass
 
88
 
 
89
 
 
90
class AptLockError(Exception):
 
91
    pass
 
92
 
 
93
 
 
94
class BaseFetchHandler(object):
 
95
 
 
96
    """Base class for FetchHandler implementations in fetch plugins"""
 
97
 
 
98
    def can_handle(self, source):
 
99
        """Returns True if the source can be handled. Otherwise returns
 
100
        a string explaining why it cannot"""
 
101
        return "Wrong source type"
 
102
 
 
103
    def install(self, source):
 
104
        """Try to download and unpack the source. Return the path to the
 
105
        unpacked files or raise UnhandledSource."""
 
106
        raise UnhandledSource("Wrong source type {}".format(source))
 
107
 
 
108
    def parse_url(self, url):
 
109
        return urlparse(url)
 
110
 
 
111
    def base_url(self, url):
 
112
        """Return url without querystring or fragment"""
 
113
        parts = list(self.parse_url(url))
 
114
        parts[4:] = ['' for i in parts[4:]]
 
115
        return urlunparse(parts)
 
116
 
 
117
 
 
118
def filter_installed_packages(packages):
 
119
    """Returns a list of packages that require installation"""
 
120
    cache = apt_cache()
 
121
    _pkgs = []
 
122
    for package in packages:
 
123
        try:
 
124
            p = cache[package]
 
125
            p.current_ver or _pkgs.append(package)
 
126
        except KeyError:
 
127
            log('Package {} has no installation candidate.'.format(package),
 
128
                level='WARNING')
 
129
            _pkgs.append(package)
 
130
    return _pkgs
 
131
 
 
132
 
 
133
def apt_cache(in_memory=True):
 
134
    """Build and return an apt cache"""
 
135
    import apt_pkg
 
136
    apt_pkg.init()
 
137
    if in_memory:
 
138
        apt_pkg.config.set("Dir::Cache::pkgcache", "")
 
139
        apt_pkg.config.set("Dir::Cache::srcpkgcache", "")
 
140
    return apt_pkg.Cache()
 
141
 
 
142
 
 
143
def apt_install(packages, options=None, fatal=False):
 
144
    """Install one or more packages"""
 
145
    if options is None:
 
146
        options = ['--option=Dpkg::Options::=--force-confold']
 
147
 
 
148
    cmd = ['apt-get', '--assume-yes']
 
149
    cmd.extend(options)
 
150
    cmd.append('install')
 
151
    if isinstance(packages, basestring):
 
152
        cmd.append(packages)
 
153
    else:
 
154
        cmd.extend(packages)
 
155
    log("Installing {} with options: {}".format(packages,
 
156
                                                options))
 
157
    _run_apt_command(cmd, fatal)
 
158
 
 
159
 
 
160
def apt_upgrade(options=None, fatal=False, dist=False):
 
161
    """Upgrade all packages"""
 
162
    if options is None:
 
163
        options = ['--option=Dpkg::Options::=--force-confold']
 
164
 
 
165
    cmd = ['apt-get', '--assume-yes']
 
166
    cmd.extend(options)
 
167
    if dist:
 
168
        cmd.append('dist-upgrade')
 
169
    else:
 
170
        cmd.append('upgrade')
 
171
    log("Upgrading with options: {}".format(options))
 
172
    _run_apt_command(cmd, fatal)
 
173
 
 
174
 
 
175
def apt_update(fatal=False):
 
176
    """Update local apt cache"""
 
177
    cmd = ['apt-get', 'update']
 
178
    _run_apt_command(cmd, fatal)
 
179
 
 
180
 
 
181
def apt_purge(packages, fatal=False):
 
182
    """Purge one or more packages"""
 
183
    cmd = ['apt-get', '--assume-yes', 'purge']
 
184
    if isinstance(packages, basestring):
 
185
        cmd.append(packages)
 
186
    else:
 
187
        cmd.extend(packages)
 
188
    log("Purging {}".format(packages))
 
189
    _run_apt_command(cmd, fatal)
 
190
 
 
191
 
 
192
def apt_hold(packages, fatal=False):
 
193
    """Hold one or more packages"""
 
194
    cmd = ['apt-mark', 'hold']
 
195
    if isinstance(packages, basestring):
 
196
        cmd.append(packages)
 
197
    else:
 
198
        cmd.extend(packages)
 
199
    log("Holding {}".format(packages))
 
200
 
 
201
    if fatal:
 
202
        subprocess.check_call(cmd)
 
203
    else:
 
204
        subprocess.call(cmd)
 
205
 
 
206
 
 
207
def add_source(source, key=None):
 
208
    """Add a package source to this system.
 
209
 
 
210
    @param source: a URL or sources.list entry, as supported by
 
211
    add-apt-repository(1). Examples:
 
212
        ppa:charmers/example
 
213
        deb https://stub:key@private.example.com/ubuntu trusty main
 
214
 
 
215
    In addition:
 
216
        'proposed:' may be used to enable the standard 'proposed'
 
217
        pocket for the release.
 
218
        'cloud:' may be used to activate official cloud archive pockets,
 
219
        such as 'cloud:icehouse'
 
220
 
 
221
    @param key: A key to be added to the system's APT keyring and used
 
222
    to verify the signatures on packages. Ideally, this should be an
 
223
    ASCII format GPG public key including the block headers. A GPG key
 
224
    id may also be used, but be aware that only insecure protocols are
 
225
    available to retrieve the actual public key from a public keyserver
 
226
    placing your Juju environment at risk. ppa and cloud archive keys
 
227
    are securely added automtically, so sould not be provided.
 
228
    """
 
229
    if source is None:
 
230
        log('Source is not present. Skipping')
 
231
        return
 
232
 
 
233
    if (source.startswith('ppa:') or
 
234
        source.startswith('http') or
 
235
        source.startswith('deb ') or
 
236
            source.startswith('cloud-archive:')):
 
237
        subprocess.check_call(['add-apt-repository', '--yes', source])
 
238
    elif source.startswith('cloud:'):
 
239
        apt_install(filter_installed_packages(['ubuntu-cloud-keyring']),
 
240
                    fatal=True)
 
241
        pocket = source.split(':')[-1]
 
242
        if pocket not in CLOUD_ARCHIVE_POCKETS:
 
243
            raise SourceConfigError(
 
244
                'Unsupported cloud: source option %s' %
 
245
                pocket)
 
246
        actual_pocket = CLOUD_ARCHIVE_POCKETS[pocket]
 
247
        with open('/etc/apt/sources.list.d/cloud-archive.list', 'w') as apt:
 
248
            apt.write(CLOUD_ARCHIVE.format(actual_pocket))
 
249
    elif source == 'proposed':
 
250
        release = lsb_release()['DISTRIB_CODENAME']
 
251
        with open('/etc/apt/sources.list.d/proposed.list', 'w') as apt:
 
252
            apt.write(PROPOSED_POCKET.format(release))
 
253
    else:
 
254
        raise SourceConfigError("Unknown source: {!r}".format(source))
 
255
 
 
256
    if key:
 
257
        if '-----BEGIN PGP PUBLIC KEY BLOCK-----' in key:
 
258
            with NamedTemporaryFile() as key_file:
 
259
                key_file.write(key)
 
260
                key_file.flush()
 
261
                key_file.seek(0)
 
262
                subprocess.check_call(['apt-key', 'add', '-'], stdin=key_file)
 
263
        else:
 
264
            # Note that hkp: is in no way a secure protocol. Using a
 
265
            # GPG key id is pointless from a security POV unless you
 
266
            # absolutely trust your network and DNS.
 
267
            subprocess.check_call(['apt-key', 'adv', '--keyserver',
 
268
                                   'hkp://keyserver.ubuntu.com:80', '--recv',
 
269
                                   key])
 
270
 
 
271
 
 
272
def configure_sources(update=False,
 
273
                      sources_var='install_sources',
 
274
                      keys_var='install_keys'):
 
275
    """
 
276
    Configure multiple sources from charm configuration.
 
277
 
 
278
    The lists are encoded as yaml fragments in the configuration.
 
279
    The frament needs to be included as a string. Sources and their
 
280
    corresponding keys are of the types supported by add_source().
 
281
 
 
282
    Example config:
 
283
        install_sources: |
 
284
          - "ppa:foo"
 
285
          - "http://example.com/repo precise main"
 
286
        install_keys: |
 
287
          - null
 
288
          - "a1b2c3d4"
 
289
 
 
290
    Note that 'null' (a.k.a. None) should not be quoted.
 
291
    """
 
292
    sources = safe_load((config(sources_var) or '').strip()) or []
 
293
    keys = safe_load((config(keys_var) or '').strip()) or None
 
294
 
 
295
    if isinstance(sources, basestring):
 
296
        sources = [sources]
 
297
 
 
298
    if keys is None:
 
299
        for source in sources:
 
300
            add_source(source, None)
 
301
    else:
 
302
        if isinstance(keys, basestring):
 
303
            keys = [keys]
 
304
 
 
305
        if len(sources) != len(keys):
 
306
            raise SourceConfigError(
 
307
                'Install sources and keys lists are different lengths')
 
308
        for source, key in zip(sources, keys):
 
309
            add_source(source, key)
 
310
    if update:
 
311
        apt_update(fatal=True)
 
312
 
 
313
 
 
314
def install_remote(source):
 
315
    """
 
316
    Install a file tree from a remote source
 
317
 
 
318
    The specified source should be a url of the form:
 
319
        scheme://[host]/path[#[option=value][&...]]
 
320
 
 
321
    Schemes supported are based on this modules submodules
 
322
    Options supported are submodule-specific"""
 
323
    # We ONLY check for True here because can_handle may return a string
 
324
    # explaining why it can't handle a given source.
 
325
    handlers = [h for h in plugins() if h.can_handle(source) is True]
 
326
    installed_to = None
 
327
    for handler in handlers:
 
328
        try:
 
329
            installed_to = handler.install(source)
 
330
        except UnhandledSource:
 
331
            pass
 
332
    if not installed_to:
 
333
        raise UnhandledSource("No handler found for source {}".format(source))
 
334
    return installed_to
 
335
 
 
336
 
 
337
def install_from_config(config_var_name):
 
338
    charm_config = config()
 
339
    source = charm_config[config_var_name]
 
340
    return install_remote(source)
 
341
 
 
342
 
 
343
def plugins(fetch_handlers=None):
 
344
    if not fetch_handlers:
 
345
        fetch_handlers = FETCH_HANDLERS
 
346
    plugin_list = []
 
347
    for handler_name in fetch_handlers:
 
348
        package, classname = handler_name.rsplit('.', 1)
 
349
        try:
 
350
            handler_class = getattr(
 
351
                importlib.import_module(package),
 
352
                classname)
 
353
            plugin_list.append(handler_class())
 
354
        except (ImportError, AttributeError):
 
355
            # Skip missing plugins so that they can be ommitted from
 
356
            # installation if desired
 
357
            log("FetchHandler {} not found, skipping plugin".format(
 
358
                handler_name))
 
359
    return plugin_list
 
360
 
 
361
 
 
362
def _run_apt_command(cmd, fatal=False):
 
363
    """
 
364
    Run an APT command, checking output and retrying if the fatal flag is set
 
365
    to True.
 
366
 
 
367
    :param: cmd: str: The apt command to run.
 
368
    :param: fatal: bool: Whether the command's output should be checked and
 
369
        retried.
 
370
    """
 
371
    env = os.environ.copy()
 
372
 
 
373
    if 'DEBIAN_FRONTEND' not in env:
 
374
        env['DEBIAN_FRONTEND'] = 'noninteractive'
 
375
 
 
376
    if fatal:
 
377
        retry_count = 0
 
378
        result = None
 
379
 
 
380
        # If the command is considered "fatal", we need to retry if the apt
 
381
        # lock was not acquired.
 
382
 
 
383
        while result is None or result == APT_NO_LOCK:
 
384
            try:
 
385
                result = subprocess.check_call(cmd, env=env)
 
386
            except subprocess.CalledProcessError, e:
 
387
                retry_count = retry_count + 1
 
388
                if retry_count > APT_NO_LOCK_RETRY_COUNT:
 
389
                    raise
 
390
                result = e.returncode
 
391
                log("Couldn't acquire DPKG lock. Will retry in {} seconds."
 
392
                    "".format(APT_NO_LOCK_RETRY_DELAY))
 
393
                time.sleep(APT_NO_LOCK_RETRY_DELAY)
 
394
 
 
395
    else:
 
396
        subprocess.call(cmd, env=env)