1
0
mirror of https://github.com/projectatomic/atomic.git synced 2026-02-06 03:45:28 +01:00
Files
atomic/Atomic/mount.py
Brent Baude be0b56c616 Unify error messages for no docker daemon (BZ #1300187)
In the case where the docker daemon is not running, each
Atomic subcommand was returning a different error message. This
PR unifies the errors messages for each subcommand.

This work was done for Bugzilla #1300187
2016-02-23 15:45:40 -06:00

576 lines
21 KiB
Python

# Copyright (C) 2015 Red Hat, All rights reserved.
# AUTHORS: William Temple <wtemple@redhat.com>
# Brent Baude <bbaude@redhat.com>
#
# This library is a component of Project Atomic.
#
# Project Atomic is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License as
# published by the Free Software Foundation; either version 2 of
# the License, or (at your option) any later version.
#
# Project Atomic is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Project Atomic; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301 USA.
#
import os
import sys
import json
from fnmatch import fnmatch as matches
import time
import docker
from .client import get_docker_client
from . import util
import requests
from .util import NoDockerDaemon
""" Module for mounting and unmounting containerized applications. """
class MountError(Exception):
"""Generic error mounting a candidate container."""
def __init__(self, val):
self.val = val
def __str__(self):
return str(self.val)
class SelectionMatchError(MountError):
"""Input identifier matched multiple mount candidates."""
def __init__(self, i, matches):
self.val = ('"{0}" matched multiple items. Try one of the following:\n'
'{1}'.format(i, '\n'.join(['\t' + m for m in matches])))
class Mount:
"""
A class which contains backend-independent methods useful for mounting and
unmounting containers.
"""
def __init__(self, mountpoint, live=False):
"""
Constructs the Mount class with a mountpoint.
Optional: mount a running container live (read/write)
"""
self.mountpoint = mountpoint
self.live = live
def mount(self, identifier, options=[]):
raise NotImplementedError('Mount subclass does not implement mount() '
'method.')
def unmount(self):
raise NotImplementedError('Mount subclass does not implement unmount()'
' method.')
# LVM DeviceMapper Utility Methods
@staticmethod
def _activate_thin_device(name, dm_id, size, pool):
"""
Provisions an LVM device-mapper thin device reflecting,
DM device id 'dm_id' in the docker pool.
"""
table = '0 %d thin /dev/mapper/%s %s' % (int(size)//512, pool, dm_id)
cmd = ['dmsetup', 'create', name, '--table', table]
r = util.subp(cmd)
if r.return_code != 0:
raise MountError('Failed to create thin device: %s' %
r.stderr.decode(sys.getdefaultencoding()))
@staticmethod
def _remove_thin_device(name):
"""
Destroys a thin device via subprocess call.
"""
r = util.subp(['dmsetup', 'remove', '--retry', name])
if r.return_code != 0:
raise MountError('Could not remove thin device:\n%s' %
r.stderr.decode(sys.getdefaultencoding()).split("\n")[0])
@staticmethod
def _is_device_active(device):
"""
Checks dmsetup to see if a device is already active
"""
cmd = ['dmsetup', 'info', device]
dmsetup_info = util.subp(cmd)
for dm_line in dmsetup_info.stdout.split("\n"):
line = dm_line.split(':')
if ('State' in line[0].strip()) and ('ACTIVE' in line[1].strip()):
return True
return False
@staticmethod
def _get_fs(thin_pathname):
"""
Returns the file system type (xfs, ext4) of a given device
"""
cmd = ['lsblk', '-o', 'FSTYPE', '-n', thin_pathname]
fs_return = util.subp(cmd)
return fs_return.stdout.strip()
@staticmethod
def mount_path(source, target, optstring='', bind=False):
"""
Subprocess call to mount dev at path.
"""
cmd = ['mount']
if bind:
cmd.append('--bind')
if optstring:
cmd.append('-o')
cmd.append(optstring)
cmd.append(source)
cmd.append(target)
r = util.subp(cmd)
if r.return_code != 0:
raise MountError('Could not mount docker container:\n' +
' '.join(cmd) + '\n%s' %
r.stderr.decode(sys.getdefaultencoding()))
@staticmethod
def get_dev_at_mountpoint(mntpoint):
"""
Retrieves the device mounted at mntpoint, or raises
MountError if none.
"""
results = util.subp(['findmnt', '-o', 'SOURCE', mntpoint])
if results.return_code != 0:
raise MountError('No device mounted at %s' % mntpoint)
stdout = results.stdout.decode(sys.getdefaultencoding())
return stdout.replace('SOURCE\n', '').strip().split('\n')[-1]
@staticmethod
def unmount_path(path, timeout=10):
"""
Unmounts the directory specified by path.
"""
# Added this timeout loop because it seems openscap/openscap-daemon
# still has a left over process running that causes the mount path
# to be busy and therefore causes the umount to fail.
#
# When that is fixed, this can revert to a simple command executed
# by subp.
for x in range(0, timeout, 1):
rc, result_stdout, result_stderr = util.subp(['umount', path])
if rc == 0:
return rc, result_stdout, result_stderr
sys.stderr.write("Warning: {}\nRetrying {}/{} to unmount {}\n"
.format(result_stderr, x+1, timeout, path))
time.sleep(1)
raise ValueError("Unable to unmount {0} due to {1}".format(path, result_stderr))
class DockerMount(Mount):
"""
A class which can be used to mount and unmount docker containers and
images on a filesystem location.
mnt_mkdir = Create temporary directories based on the cid at mountpoint
for mounting containers
"""
def __init__(self, mountpoint, live=False, mnt_mkdir=False):
Mount.__init__(self, mountpoint, live)
self.client = get_docker_client()
self.mnt_mkdir = mnt_mkdir
self.tmp_image = None
def _create_temp_container(self, iid):
"""
Create a temporary container from a given iid.
Temporary containers are marked with a sentinel environment
variable so that they can be cleaned on unmount.
"""
try:
return self.client.create_container(
image=iid, command='/bin/true',
environment=['_ATOMIC_TEMP_CONTAINER'],
detach=True, network_disabled=True)['Id']
except docker.errors.APIError as ex:
raise MountError('Error creating temporary container:\n%s' % str(ex))
def _clone(self, cid, image_only=False):
"""
Create a temporary image snapshot from a given cid and then
create temporary container from the temporary image.
Temporary image snapshots are marked with a sentinel label
so that they can be cleaned on unmount.
image_only: Create the image from the container only
Return: the id of the temporary container unless image_only=True
in which case it returns the image cloned image id.
"""
try:
iid = self.client.commit(
container=cid,
conf={
'Labels': {
'io.projectatomic.Temporary': 'true'
}
}
)['Id']
except docker.errors.APIError as ex:
raise MountError(str(ex))
self.tmp_image = iid
if image_only:
return iid
else:
return self._create_temp_container(iid)
def _is_container_running(self, cid):
cinfo = self.client.inspect_container(cid)
return cinfo['State']['Running']
def _identifier_as_cid(self, identifier):
"""
Returns a container uuid for identifier.
If identifier is an image UUID or image tag, create a temporary
container and return its uuid.
"""
def __cname_matches(container, identifier):
return any([n for n in (container['Names'] or [])
if matches(n, '/' + identifier)])
# Determine if identifier is a container
containers = [c['Id'] for c in self.client.containers(all=True)
if (__cname_matches(c, identifier) or
matches(c['Id'], identifier + '*'))]
if len(containers) > 1:
raise SelectionMatchError(identifier, containers)
elif len(containers) == 1:
c = containers[0]
return c if self.live else self._clone(c)
# Determine if identifier is an image UUID
images = [i for i in set(self.client.images(all=True, quiet=True))
if i.startswith(identifier)]
if len(images) > 1:
raise SelectionMatchError(identifier, images)
elif len(images) == 1:
return self._create_temp_container(images[0])
# Match image tag.
images = util.image_by_name(identifier)
if len(images) > 1:
tags = [t for i in images for t in i['RepoTags']]
raise SelectionMatchError(identifier, tags)
elif len(images) == 1:
return self._create_temp_container(images[0]['Id'].replace("sha256:", ""))
raise MountError('{} did not match any image or container.'
''.format(identifier))
@staticmethod
def _no_gd_api_dm(cid):
# TODO: Deprecated
desc_file = os.path.join('/var/lib/docker/devicemapper/metadata', cid)
desc = json.loads(open(desc_file).read())
return desc['device_id'], desc['size']
@staticmethod
def _no_gd_api_overlay(cid):
# TODO: Deprecated
prefix = os.path.join('/var/lib/docker/overlay/', cid)
ld_metafile = open(os.path.join(prefix, 'lower-id'))
ld_loc = os.path.join('/var/lib/docker/overlay/', ld_metafile.read())
return (os.path.join(ld_loc, 'root'), os.path.join(prefix, 'upper'),
os.path.join(prefix, 'work'))
def mount(self, identifier, options=[]):
"""
Mounts a container or image referred to by identifier to
the host filesystem.
"""
try:
# Check if a container/image is already mounted at the
# desired mount point.
cid, dev_name = self._get_cid_from_mountpoint(self.mountpoint)
if cid:
raise ValueError("container/image '{0}' already mounted at '{1}'"
.format(cid, self.mountpoint))
except MountError:
pass
try:
driver = self.client.info()['Driver']
except requests.exceptions.ConnectionError:
raise NoDockerDaemon()
driver_mount_fn = getattr(self, "_mount_" + driver,
self._unsupported_backend)
driver_mount_fn(identifier, options)
# Return mount path so it can be later unmounted by path
return self.mountpoint
def _unsupported_backend(self, identifier='', options=[]):
raise MountError('Atomic mount is not supported on the {} docker '
'storage backend.'
''.format(self.client.info()['Driver']))
def _default_options(self, options, default_con=None, default_options=[]):
"""
Merges user options with default options and determines security
context.
"""
if not options:
options = default_options
# Determines default context.
if all([o.find('context=') == -1 for o in options]):
options.append('context="' +
(default_con if default_con else
util.default_container_context()) + '"')
return options
def _mount_devicemapper(self, identifier, options):
"""
Devicemapper mount backend.
"""
if self.live and options:
raise MountError('Cannot set mount options for live container '
'mount.')
info = self.client.info()
cid = self._identifier_as_cid(identifier)
if self.mnt_mkdir:
# If the given mount_path is just a parent dir for where
# to mount things by cid, then the new mountpoint is the
# mount_path plus the first 20 chars of the cid
self.mountpoint = os.path.join(self.mountpoint, cid[:20])
try:
if not os.path.exists(self.mountpoint) :
os.mkdir(self.mountpoint)
except Exception as e:
raise MountError(e)
cinfo = self.client.inspect_container(cid)
if self.live and not cinfo['State']['Running']:
self._cleanup_container(cinfo)
raise MountError('Cannot live mount non-running container.')
options = self._default_options(
options, default_con=cinfo['MountLabel'],
default_options=[] if self.live else ['ro', 'nosuid', 'nodev'])
dm_dev_name, dm_dev_id, dm_dev_size = '', '', ''
dm_pool = info['DriverStatus'][0][1]
try:
dm_dev_name = cinfo['GraphDriver']['Data']['DeviceName']
dm_dev_id = cinfo['GraphDriver']['Data']['DeviceId']
dm_dev_size = cinfo['GraphDriver']['Data']['DeviceSize']
except:
# TODO: deprecated when GraphDriver patch makes it upstream
dm_dev_id, dm_dev_size = DockerMount._no_gd_api_dm(cid)
dm_dev_name = dm_pool.replace('pool', cid)
dm_dev_path = os.path.join('/dev/mapper', dm_dev_name)
# If the device isn't already there, activate it.
if not os.path.exists(dm_dev_path):
if self.live:
raise MountError('Error: Attempted to live-mount unactivated '
'device.')
Mount._activate_thin_device(dm_dev_name, dm_dev_id, dm_dev_size,
dm_pool)
# XFS should get nouuid
fstype = Mount._get_fs(dm_dev_path).decode(sys.getdefaultencoding())
if fstype.upper() == 'XFS' and 'nouuid' not in options:
if 'nouuid' not in options:
options.append('nouuid')
try:
Mount.mount_path(dm_dev_path, self.mountpoint,
optstring=(','.join(options)))
except MountError as de:
self._cleanup_container(cinfo)
if not self.live:
Mount._remove_thin_device(dm_dev_name)
raise de
def _mount_overlay(self, identifier, options):
"""
OverlayFS mount backend.
"""
if self.live:
raise MountError('The OverlayFS backend does not support live '
'mounts.')
elif 'rw' in options:
raise MountError('The OverlayFS backend does not support '
'writeable mounts.')
cid = self._identifier_as_cid(identifier)
cinfo = self.client.inspect_container(cid)
ld, ud, wd = '', '', ''
try:
ld = cinfo['GraphDriver']['Data']['lowerDir']
ud = cinfo['GraphDriver']['Data']['upperDir']
wd = cinfo['GraphDriver']['Data']['workDir']
except:
ld, ud, wd = DockerMount._no_gd_api_overlay(cid)
options += ['ro', 'lowerdir=' + ld, 'upperdir=' + ud, 'workdir=' + wd]
optstring = ','.join(options)
cmd = ['mount', '-t', 'overlay', '-o', optstring, 'overlay',
self.mountpoint]
status = util.subp(cmd)
if status.return_code != 0:
self._cleanup_container(cinfo)
raise MountError('Failed to mount OverlayFS device.\n%s' %
status.stderr.decode(sys.getdefaultencoding()))
def _cleanup_container(self, cinfo):
"""
Remove a container and clean up its image if necessary.
"""
# I'm not a fan of doing this again here.
env = cinfo['Config']['Env']
if (env and '_ATOMIC_TEMP_CONTAINER' not in env) or not env:
return
iid = cinfo['Image']
self.client.remove_container(cinfo['Id'])
try:
labels = self.client.inspect_image(iid)['Config']['Labels']
except TypeError:
labels = {}
if labels and 'io.projectatomic.Temporary' in labels:
if labels['io.projectatomic.Temporary'] == 'true':
self.client.remove_image(iid)
# If we are creating temporary dirs for mount points
# based on the cid, then we should rmdir them while
# cleaning up.
if self.mnt_mkdir:
try:
os.rmdir(self.mountpoint)
except Exception as e:
raise MountError(e)
def _clean_tmp_image(self):
# If a temporary image is created with commit,
# clean up that too
if self.tmp_image is not None:
self.client.remove_image(self.tmp_image, noprune=True)
def unmount(self, path=None):
"""
Unmounts and cleans-up after a previous mount().
"""
driver = self.client.info()['Driver']
driver_unmount_fn = getattr(self, "_unmount_" + driver,
self._unsupported_backend)
if path is not None:
driver_unmount_fn(path=path)
else:
driver_unmount_fn()
def _get_all_cids(self):
'''
Simple function that returns a list of the container
IDs.
'''
return [x['Id'] for x in self.client.containers(all=True)]
def _get_cid_from_mountpoint(self, mountpoint):
dev = Mount.get_dev_at_mountpoint(mountpoint)
dev_name = dev.replace('/dev/mapper/', '').replace('[/rootfs]', '')
cid = None
for c in self._get_all_cids():
graph = self.client.inspect_container(c)["GraphDriver"]
if graph["Name"] != "devicemapper":
continue
if dev_name == graph["Data"]["DeviceName"]:
cid=c
return cid, dev_name
def _unmount_devicemapper(self, path=None):
"""
Devicemapper unmount backend.
"""
mountpoint = self.mountpoint if path is None else path
cid, dev_name = self._get_cid_from_mountpoint(mountpoint)
if not cid:
raise MountError('Device mounted at {} is not a docker container.'
''.format(mountpoint))
Mount.unmount_path(mountpoint)
cinfo = self.client.inspect_container(cid)
# Was the container live mounted? If so, done.
# TODO: Container.Config.Env should be {} (iterable) not None.
# Fix in docker-py.
env = cinfo['Config']['Env']
if (env and '_ATOMIC_TEMP_CONTAINER' not in env) or not env:
return
Mount._remove_thin_device(dev_name)
self._cleanup_container(cinfo)
def _get_overlay_mount_cid(self):
"""
Returns the cid of the container mounted at mountpoint.
"""
cmd = ['findmnt', '-o', 'OPTIONS', '-n', self.mountpoint]
r = util.subp(cmd)
if r.return_code != 0:
raise MountError('No devices mounted at that location.')
stdout = r.stdout.decode(sys.getdefaultencoding())
optstring = stdout.strip().split('\n')[-1]
upperdir = [o.replace('upperdir=', '') for o in optstring.split(',')
if o.startswith('upperdir=')][0]
cdir = upperdir.rsplit('/', 1)[0]
if not cdir.startswith('/var/lib/docker/overlay/'):
raise MountError('The device mounted at that location is not a '
'docker container.')
return cdir.replace('/var/lib/docker/overlay/', '')
def _unmount_overlay(self, path=None):
"""
OverlayFS unmount backend.
"""
mountpoint = self.mountpoint if path is None else path
if Mount.get_dev_at_mountpoint(mountpoint) != 'overlay':
raise MountError('Device mounted at {} is not an atomic mount.'.format(mountpoint))
cid = self._get_overlay_mount_cid()
Mount.unmount_path(mountpoint)
self._cleanup_container(self.client.inspect_container(cid))
def _clean_temp_container_by_path(self, path):
short_cid = os.path.basename(path)
if not self.live:
self.client.remove_container(short_cid)
self._clean_tmp_image()