~openstack-charmers-archive/charms/precise/swift-storage/trunk

« back to all changes in this revision

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

  • Committer: James Page
  • Date: 2015-10-22 13:25:26 UTC
  • Revision ID: james.page@ubuntu.com-20151022132526-47habq9rp2eeivlv
Tags: 15.10
15.10 Charm release

Show diffs side-by-side

added added

removed removed

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