12
12
# See the License for the specific language governing permissions and
13
13
# limitations under the License.
15
from collections import OrderedDict
20
from tempfile import NamedTemporaryFile
21
from charmhelpers.core.host import (
23
from charmhelpers.core.host import get_distrib_codename
25
from charmhelpers.core.hookenv import (
24
from charmhelpers.core.hookenv import log
25
from charmhelpers.fetch import SourceConfigError
31
from charmhelpers.fetch import SourceConfigError, GPGKeyError
35
"deb http://archive.ubuntu.com/ubuntu {}-proposed main universe "
36
"multiverse restricted\n")
37
PROPOSED_PORTS_POCKET = (
39
"deb http://ports.ubuntu.com/ubuntu-ports {}-proposed main universe "
40
"multiverse restricted\n")
41
# Only supports 64bit and ppc64 at the moment.
42
ARCH_TO_PROPOSED_POCKET = {
43
'x86_64': PROPOSED_POCKET,
44
'ppc64le': PROPOSED_PORTS_POCKET,
45
'aarch64': PROPOSED_PORTS_POCKET,
46
's390x': PROPOSED_PORTS_POCKET,
48
CLOUD_ARCHIVE_URL = "http://ubuntu-cloud.archive.canonical.com/ubuntu"
49
CLOUD_ARCHIVE_KEY_ID = '5EDB1B62EC4926EA'
27
50
CLOUD_ARCHIVE = """# Ubuntu Cloud Archive
28
51
deb http://ubuntu-cloud.archive.canonical.com/ubuntu {} main
31
PROPOSED_POCKET = """# Proposed
32
deb http://archive.ubuntu.com/ubuntu {}-proposed main universe multiverse restricted
35
53
CLOUD_ARCHIVE_POCKETS = {
37
55
'folsom': 'precise-updates/folsom',
56
'folsom/updates': 'precise-updates/folsom',
38
57
'precise-folsom': 'precise-updates/folsom',
39
58
'precise-folsom/updates': 'precise-updates/folsom',
40
59
'precise-updates/folsom': 'precise-updates/folsom',
107
134
'xenial-proposed/newton': 'xenial-proposed/newton',
109
136
'ocata': 'xenial-updates/ocata',
137
'ocata/updates': 'xenial-updates/ocata',
110
138
'xenial-ocata': 'xenial-updates/ocata',
111
139
'xenial-ocata/updates': 'xenial-updates/ocata',
112
140
'xenial-updates/ocata': 'xenial-updates/ocata',
113
141
'ocata/proposed': 'xenial-proposed/ocata',
114
142
'xenial-ocata/proposed': 'xenial-proposed/ocata',
115
'xenial-ocata/newton': 'xenial-proposed/ocata',
143
'xenial-proposed/ocata': 'xenial-proposed/ocata',
145
'pike': 'xenial-updates/pike',
146
'xenial-pike': 'xenial-updates/pike',
147
'xenial-pike/updates': 'xenial-updates/pike',
148
'xenial-updates/pike': 'xenial-updates/pike',
149
'pike/proposed': 'xenial-proposed/pike',
150
'xenial-pike/proposed': 'xenial-proposed/pike',
151
'xenial-proposed/pike': 'xenial-proposed/pike',
153
'queens': 'xenial-updates/queens',
154
'xenial-queens': 'xenial-updates/queens',
155
'xenial-queens/updates': 'xenial-updates/queens',
156
'xenial-updates/queens': 'xenial-updates/queens',
157
'queens/proposed': 'xenial-proposed/queens',
158
'xenial-queens/proposed': 'xenial-proposed/queens',
159
'xenial-proposed/queens': 'xenial-proposed/queens',
161
'rocky': 'bionic-updates/rocky',
162
'bionic-rocky': 'bionic-updates/rocky',
163
'bionic-rocky/updates': 'bionic-updates/rocky',
164
'bionic-updates/rocky': 'bionic-updates/rocky',
165
'rocky/proposed': 'bionic-proposed/rocky',
166
'bionic-rocky/proposed': 'bionic-proposed/rocky',
167
'bionic-proposed/rocky': 'bionic-proposed/rocky',
169
'stein': 'bionic-updates/stein',
170
'bionic-stein': 'bionic-updates/stein',
171
'bionic-stein/updates': 'bionic-updates/stein',
172
'bionic-updates/stein': 'bionic-updates/stein',
173
'stein/proposed': 'bionic-proposed/stein',
174
'bionic-stein/proposed': 'bionic-proposed/stein',
175
'bionic-proposed/stein': 'bionic-proposed/stein',
118
179
APT_NO_LOCK = 100 # The return code for "couldn't acquire lock" in APT.
119
180
CMD_RETRY_DELAY = 10 # Wait 10 seconds between command retries.
120
CMD_RETRY_COUNT = 30 # Retry a failing fatal command X times.
181
CMD_RETRY_COUNT = 3 # Retry a failing fatal command X times.
123
184
def filter_installed_packages(packages):
217
298
return apt_mark(packages, 'unhold', fatal=fatal)
220
def add_source(source, key=None):
302
"""Import an ASCII Armor key.
304
A Radix64 format keyid is also supported for backwards
305
compatibility. In this case Ubuntu keyserver will be
306
queried for a key via HTTPS by its keyid. This method
307
is less preferrable because https proxy servers may
308
require traffic decryption which is equivalent to a
309
man-in-the-middle attack (a proxy server impersonates
310
keyserver TLS certificates and has to be explicitly
311
trusted by the system).
313
:param key: A GPG key in ASCII armor format,
314
including BEGIN and END markers or a keyid.
315
:type key: (bytes, str)
316
:raises: GPGKeyError if the key could not be imported
319
if '-' in key or '\n' in key:
320
# Send everything not obviously a keyid to GPG to import, as
321
# we trust its validation better than our own. eg. handling
322
# comments before the key.
323
log("PGP key found (looks like ASCII Armor format)", level=DEBUG)
324
if ('-----BEGIN PGP PUBLIC KEY BLOCK-----' in key and
325
'-----END PGP PUBLIC KEY BLOCK-----' in key):
326
log("Writing provided PGP key in the binary format", level=DEBUG)
328
key_bytes = key.encode('utf-8')
331
key_name = _get_keyid_by_gpg_key(key_bytes)
332
key_gpg = _dearmor_gpg_key(key_bytes)
333
_write_apt_gpg_keyfile(key_name=key_name, key_material=key_gpg)
335
raise GPGKeyError("ASCII armor markers missing from GPG key")
337
log("PGP key found (looks like Radix64 format)", level=WARNING)
338
log("SECURELY importing PGP key from keyserver; "
339
"full key not provided.", level=WARNING)
340
# as of bionic add-apt-repository uses curl with an HTTPS keyserver URL
341
# to retrieve GPG keys. `apt-key adv` command is deprecated as is
342
# apt-key in general as noted in its manpage. See lp:1433761 for more
343
# history. Instead, /etc/apt/trusted.gpg.d is used directly to drop
345
key_asc = _get_key_by_keyid(key)
346
# write the key in GPG format so that apt-key list shows it
347
key_gpg = _dearmor_gpg_key(key_asc)
348
_write_apt_gpg_keyfile(key_name=key, key_material=key_gpg)
351
def _get_keyid_by_gpg_key(key_material):
352
"""Get a GPG key fingerprint by GPG key material.
353
Gets a GPG key fingerprint (40-digit, 160-bit) by the ASCII armor-encoded
354
or binary GPG key material. Can be used, for example, to generate file
355
names for keys passed via charm options.
357
:param key_material: ASCII armor-encoded or binary GPG key material
358
:type key_material: bytes
359
:raises: GPGKeyError if invalid key material has been provided
360
:returns: A GPG key fingerprint
363
# Use the same gpg command for both Xenial and Bionic
364
cmd = 'gpg --with-colons --with-fingerprint'
365
ps = subprocess.Popen(cmd.split(),
366
stdout=subprocess.PIPE,
367
stderr=subprocess.PIPE,
368
stdin=subprocess.PIPE)
369
out, err = ps.communicate(input=key_material)
371
out = out.decode('utf-8')
372
err = err.decode('utf-8')
373
if 'gpg: no valid OpenPGP data found.' in err:
374
raise GPGKeyError('Invalid GPG key material provided')
375
# from gnupg2 docs: fpr :: Fingerprint (fingerprint is in field 10)
376
return re.search(r"^fpr:{9}([0-9A-F]{40}):$", out, re.MULTILINE).group(1)
379
def _get_key_by_keyid(keyid):
380
"""Get a key via HTTPS from the Ubuntu keyserver.
381
Different key ID formats are supported by SKS keyservers (the longer ones
382
are more secure, see "dead beef attack" and https://evil32.com/). Since
383
HTTPS is used, if SSLBump-like HTTPS proxies are in place, they will
384
impersonate keyserver.ubuntu.com and generate a certificate with
385
keyserver.ubuntu.com in the CN field or in SubjAltName fields of a
386
certificate. If such proxy behavior is expected it is necessary to add the
387
CA certificate chain containing the intermediate CA of the SSLBump proxy to
388
every machine that this code runs on via ca-certs cloud-init directive (via
389
cloudinit-userdata model-config) or via other means (such as through a
390
custom charm option). Also note that DNS resolution for the hostname in a
391
URL is done at a proxy server - not at the client side.
393
8-digit (32 bit) key ID
394
https://keyserver.ubuntu.com/pks/lookup?search=0x4652B4E6
395
16-digit (64 bit) key ID
396
https://keyserver.ubuntu.com/pks/lookup?search=0x6E85A86E4652B4E6
398
https://keyserver.ubuntu.com/pks/lookup?search=0x35F77D63B5CEC106C577ED856E85A86E4652B4E6
400
:param keyid: An 8, 16 or 40 hex digit keyid to find a key for
401
:type keyid: (bytes, str)
402
:returns: A key material for the specified GPG key id
404
:raises: subprocess.CalledProcessError
406
# options=mr - machine-readable output (disables html wrappers)
407
keyserver_url = ('https://keyserver.ubuntu.com'
408
'/pks/lookup?op=get&options=mr&exact=on&search=0x{}')
409
curl_cmd = ['curl', keyserver_url.format(keyid)]
410
# use proxy server settings in order to retrieve the key
411
return subprocess.check_output(curl_cmd,
412
env=env_proxy_settings(['https']))
415
def _dearmor_gpg_key(key_asc):
416
"""Converts a GPG key in the ASCII armor format to the binary format.
418
:param key_asc: A GPG key in ASCII armor format.
419
:type key_asc: (str, bytes)
420
:returns: A GPG key in binary format
424
ps = subprocess.Popen(['gpg', '--dearmor'],
425
stdout=subprocess.PIPE,
426
stderr=subprocess.PIPE,
427
stdin=subprocess.PIPE)
428
out, err = ps.communicate(input=key_asc)
429
# no need to decode output as it is binary (invalid utf-8), only error
431
err = err.decode('utf-8')
432
if 'gpg: no valid OpenPGP data found.' in err:
433
raise GPGKeyError('Invalid GPG key material. Check your network setup'
434
' (MTU, routing, DNS) and/or proxy server settings'
435
' as well as destination keyserver status.')
440
def _write_apt_gpg_keyfile(key_name, key_material):
441
"""Writes GPG key material into a file at a provided path.
443
:param key_name: A key name to use for a key file (could be a fingerprint)
445
:param key_material: A GPG key material (binary)
446
:type key_material: (str, bytes)
448
with open('/etc/apt/trusted.gpg.d/{}.gpg'.format(key_name),
450
keyf.write(key_material)
453
def add_source(source, key=None, fail_invalid=False):
221
454
"""Add a package source to this system.
223
456
@param source: a URL or sources.list entry, as supported by
233
466
such as 'cloud:icehouse'
234
467
'distro' may be used as a noop
469
Full list of source specifications supported by the function are:
471
'distro': A NOP; i.e. it has no effect.
472
'proposed': the proposed deb spec [2] is wrtten to
473
/etc/apt/sources.list/proposed
474
'distro-proposed': adds <version>-proposed to the debs [2]
475
'ppa:<ppa-name>': add-apt-repository --yes <ppa_name>
476
'deb <deb-spec>': add-apt-repository --yes deb <deb-spec>
477
'http://....': add-apt-repository --yes http://...
478
'cloud-archive:<spec>': add-apt-repository -yes cloud-archive:<spec>
479
'cloud:<release>[-staging]': specify a Cloud Archive pocket <release> with
480
optional staging version. If staging is used then the staging PPA [2]
481
with be used. If staging is NOT used then the cloud archive [3] will be
482
added, and the 'ubuntu-cloud-keyring' package will be added for the
485
Otherwise the source is not recognised and this is logged to the juju log.
486
However, no error is raised, unless sys_error_on_exit is True.
488
[1] deb http://ubuntu-cloud.archive.canonical.com/ubuntu {} main
489
where {} is replaced with the derived pocket name.
490
[2] deb http://archive.ubuntu.com/ubuntu {}-proposed \
491
main universe multiverse restricted
492
where {} is replaced with the lsb_release codename (e.g. xenial)
493
[3] deb http://ubuntu-cloud.archive.canonical.com/ubuntu <pocket>
494
to /etc/apt/sources.list.d/cloud-archive-list
236
496
@param key: A key to be added to the system's APT keyring and used
237
497
to verify the signatures on packages. Ideally, this should be an
238
498
ASCII format GPG public key including the block headers. A GPG key
240
500
available to retrieve the actual public key from a public keyserver
241
501
placing your Juju environment at risk. ppa and cloud archive keys
242
502
are securely added automtically, so sould not be provided.
504
@param fail_invalid: (boolean) if True, then the function raises a
505
SourceConfigError is there is no matching installation source.
507
@raises SourceConfigError() if for cloud:<pocket>, the <pocket> is not a
508
valid pocket in CLOUD_ARCHIVE_POCKETS
510
_mapping = OrderedDict([
511
(r"^distro$", lambda: None), # This is a NOP
512
(r"^(?:proposed|distro-proposed)$", _add_proposed),
513
(r"^cloud-archive:(.*)$", _add_apt_repository),
514
(r"^((?:deb |http:|https:|ppa:).*)$", _add_apt_repository),
515
(r"^cloud:(.*)-(.*)\/staging$", _add_cloud_staging),
516
(r"^cloud:(.*)-(.*)$", _add_cloud_distro_check),
517
(r"^cloud:(.*)$", _add_cloud_pocket),
518
(r"^snap:.*-(.*)-(.*)$", _add_cloud_distro_check),
244
520
if source is None:
245
log('Source is not present. Skipping')
248
if (source.startswith('ppa:') or
249
source.startswith('http') or
250
source.startswith('deb ') or
251
source.startswith('cloud-archive:')):
252
cmd = ['add-apt-repository', '--yes', source]
253
_run_with_retries(cmd)
254
elif source.startswith('cloud:'):
255
install(filter_installed_packages(['ubuntu-cloud-keyring']),
522
for r, fn in six.iteritems(_mapping):
523
m = re.match(r, source)
525
# call the assoicated function with the captured groups
526
# raises SourceConfigError on error.
531
except GPGKeyError as e:
532
raise SourceConfigError(str(e))
535
# nothing matched. log an error and maybe sys.exit
536
err = "Unknown source: {!r}".format(source)
539
raise SourceConfigError(err)
543
"""Add the PROPOSED_POCKET as /etc/apt/source.list.d/proposed.list
545
Uses get_distrib_codename to determine the correct stanza for
548
For intel architecutres PROPOSED_POCKET is used for the release, but for
549
other architectures PROPOSED_PORTS_POCKET is used for the release.
551
release = get_distrib_codename()
552
arch = platform.machine()
553
if arch not in six.iterkeys(ARCH_TO_PROPOSED_POCKET):
554
raise SourceConfigError("Arch {} not supported for (distro-)proposed"
556
with open('/etc/apt/sources.list.d/proposed.list', 'w') as apt:
557
apt.write(ARCH_TO_PROPOSED_POCKET[arch].format(release))
560
def _add_apt_repository(spec):
561
"""Add the spec using add_apt_repository
563
:param spec: the parameter to pass to add_apt_repository
566
if '{series}' in spec:
567
series = get_distrib_codename()
568
spec = spec.replace('{series}', series)
569
# software-properties package for bionic properly reacts to proxy settings
570
# passed as environment variables (See lp:1433761). This is not the case
571
# LTS and non-LTS releases below bionic.
572
_run_with_retries(['add-apt-repository', '--yes', spec],
573
cmd_env=env_proxy_settings(['https']))
576
def _add_cloud_pocket(pocket):
577
"""Add a cloud pocket as /etc/apt/sources.d/cloud-archive.list
579
Note that this overwrites the existing file if there is one.
581
This function also converts the simple pocket in to the actual pocket using
582
the CLOUD_ARCHIVE_POCKETS mapping.
584
:param pocket: string representing the pocket to add a deb spec for.
585
:raises: SourceConfigError if the cloud pocket doesn't exist or the
586
requested release doesn't match the current distro version.
588
apt_install(filter_installed_packages(['ubuntu-cloud-keyring']),
257
pocket = source.split(':')[-1]
258
if pocket not in CLOUD_ARCHIVE_POCKETS:
259
raise SourceConfigError(
260
'Unsupported cloud: source option %s' %
262
actual_pocket = CLOUD_ARCHIVE_POCKETS[pocket]
263
with open('/etc/apt/sources.list.d/cloud-archive.list', 'w') as apt:
264
apt.write(CLOUD_ARCHIVE.format(actual_pocket))
265
elif source == 'proposed':
266
release = lsb_release()['DISTRIB_CODENAME']
267
with open('/etc/apt/sources.list.d/proposed.list', 'w') as apt:
268
apt.write(PROPOSED_POCKET.format(release))
269
elif source == 'distro':
272
log("Unknown source: {!r}".format(source))
275
if '-----BEGIN PGP PUBLIC KEY BLOCK-----' in key:
276
with NamedTemporaryFile('w+') as key_file:
280
subprocess.check_call(['apt-key', 'add', '-'], stdin=key_file)
282
# Note that hkp: is in no way a secure protocol. Using a
283
# GPG key id is pointless from a security POV unless you
284
# absolutely trust your network and DNS.
285
subprocess.check_call(['apt-key', 'adv', '--keyserver',
286
'hkp://keyserver.ubuntu.com:80', '--recv',
590
if pocket not in CLOUD_ARCHIVE_POCKETS:
591
raise SourceConfigError(
592
'Unsupported cloud: source option %s' %
594
actual_pocket = CLOUD_ARCHIVE_POCKETS[pocket]
595
with open('/etc/apt/sources.list.d/cloud-archive.list', 'w') as apt:
596
apt.write(CLOUD_ARCHIVE.format(actual_pocket))
599
def _add_cloud_staging(cloud_archive_release, openstack_release):
600
"""Add the cloud staging repository which is in
601
ppa:ubuntu-cloud-archive/<openstack_release>-staging
603
This function checks that the cloud_archive_release matches the current
604
codename for the distro that charm is being installed on.
606
:param cloud_archive_release: string, codename for the release.
607
:param openstack_release: String, codename for the openstack release.
608
:raises: SourceConfigError if the cloud_archive_release doesn't match the
609
current version of the os.
611
_verify_is_ubuntu_rel(cloud_archive_release, openstack_release)
612
ppa = 'ppa:ubuntu-cloud-archive/{}-staging'.format(openstack_release)
613
cmd = 'add-apt-repository -y {}'.format(ppa)
614
_run_with_retries(cmd.split(' '))
617
def _add_cloud_distro_check(cloud_archive_release, openstack_release):
618
"""Add the cloud pocket, but also check the cloud_archive_release against
619
the current distro, and use the openstack_release as the full lookup.
621
This just calls _add_cloud_pocket() with the openstack_release as pocket
622
to get the correct cloud-archive.list for dpkg to work with.
624
:param cloud_archive_release:String, codename for the distro release.
625
:param openstack_release: String, spec for the release to look up in the
626
CLOUD_ARCHIVE_POCKETS
627
:raises: SourceConfigError if this is the wrong distro, or the pocket spec
630
_verify_is_ubuntu_rel(cloud_archive_release, openstack_release)
631
_add_cloud_pocket("{}-{}".format(cloud_archive_release, openstack_release))
634
def _verify_is_ubuntu_rel(release, os_release):
635
"""Verify that the release is in the same as the current ubuntu release.
637
:param release: String, lowercase for the release.
638
:param os_release: String, the os_release being asked for
639
:raises: SourceConfigError if the release is not the same as the ubuntu
642
ubuntu_rel = get_distrib_codename()
643
if release != ubuntu_rel:
644
raise SourceConfigError(
645
'Invalid Cloud Archive release specified: {}-{} on this Ubuntu'
646
'version ({})'.format(release, os_release, ubuntu_rel))
290
649
def _run_with_retries(cmd, max_retries=CMD_RETRY_COUNT, retry_exitcodes=(1,),