Coverage for nova/virt/disk/api.py: 79%
291 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-17 15:08 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-17 15:08 +0000
1# Copyright 2010 United States Government as represented by the
2# Administrator of the National Aeronautics and Space Administration.
3#
4# Copyright 2011, Piston Cloud Computing, Inc.
5#
6# All Rights Reserved.
7#
8# Licensed under the Apache License, Version 2.0 (the "License"); you may
9# not use this file except in compliance with the License. You may obtain
10# a copy of the License at
11#
12# http://www.apache.org/licenses/LICENSE-2.0
13#
14# Unless required by applicable law or agreed to in writing, software
15# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
17# License for the specific language governing permissions and limitations
18# under the License.
19"""
20Utility methods to resize, repartition, and modify disk images.
22Includes injection of SSH PGP keys into authorized_keys file.
24"""
26import os
27import random
28import tempfile
30from oslo_concurrency import processutils
31from oslo_log import log as logging
32from oslo_serialization import jsonutils
33from oslo_utils import secretutils
35import nova.conf
36from nova import exception
37from nova.i18n import _
38import nova.privsep.fs
39import nova.privsep.libvirt
40from nova.virt.disk.mount import api as mount
41from nova.virt.disk.vfs import api as vfs
42from nova.virt.image import model as imgmodel
43from nova.virt import images
46LOG = logging.getLogger(__name__)
48CONF = nova.conf.CONF
51# NOTE(mikal): Here as a transition step
52SUPPORTED_FS_TO_EXTEND = nova.privsep.fs.SUPPORTED_FS_TO_EXTEND
55for s in CONF.virt_mkfs: 55 ↛ 58line 55 didn't jump to line 58 because the loop on line 55 never started
56 # NOTE(yamahata): mkfs command may includes '=' for its options.
57 # So item.partition('=') doesn't work here
58 os_type, mkfs_command = s.split('=', 1)
59 if os_type:
60 nova.privsep.fs.load_mkfs_command(os_type, mkfs_command)
63def mkfs(os_type, fs_label, target, run_as_root=True, specified_fs=None):
64 nova.privsep.fs.configurable_mkfs(
65 os_type, fs_label, target, run_as_root,
66 CONF.default_ephemeral_format, specified_fs)
69def resize2fs(image, check_exit_code=False, run_as_root=False):
70 # NOTE(mikal): note that the check_exit_code kwarg here only refers to
71 # resize2fs, not the precursor e2fsck. Yes, I agree it's confusing.
72 try:
73 if run_as_root:
74 nova.privsep.fs.e2fsck(image)
75 else:
76 nova.privsep.fs.unprivileged_e2fsck(image)
78 except processutils.ProcessExecutionError as exc:
79 LOG.debug("Checking the file system with e2fsck has failed, "
80 "the resize will be aborted. (%s)", exc)
82 else:
83 if run_as_root:
84 nova.privsep.fs.resize2fs(image, check_exit_code)
85 else:
86 nova.privsep.fs.unprivileged_resize2fs(image, check_exit_code)
89def get_disk_info(path):
90 """Get QEMU info of a disk image
92 :param path: Path to the disk image
93 :returns: oslo_utils.imageutils.QemuImgInfo object for the image.
94 """
95 return images.qemu_img_info(path)
98def get_disk_size(path):
99 """Get the (virtual) size of a disk image
101 :param path: Path to the disk image
102 :returns: Size (in bytes) of the given disk image as it would be seen
103 by a virtual machine.
104 """
105 return images.qemu_img_info(path).virtual_size
108def extend(image, size):
109 """Increase image to size.
111 :param image: instance of nova.virt.image.model.Image
112 :param size: image size in bytes
113 """
115 # Currently can only resize FS in local images
116 if not isinstance(image, imgmodel.LocalImage): 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true
117 return
119 if not can_resize_image(image.path, size): 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true
120 return
122 if (image.format == imgmodel.FORMAT_PLOOP):
123 nova.privsep.libvirt.ploop_resize(image.path, size)
124 return
126 processutils.execute('qemu-img', 'resize', image.path, size)
128 if (image.format != imgmodel.FORMAT_RAW and
129 not CONF.resize_fs_using_block_device):
130 return
132 # if we can't access the filesystem, we can't do anything more
133 if not is_image_extendable(image): 133 ↛ 134line 133 didn't jump to line 134 because the condition on line 133 was never true
134 return
136 def safe_resize2fs(dev, run_as_root=False, finally_call=lambda: None):
137 try:
138 resize2fs(dev, run_as_root=run_as_root, check_exit_code=[0])
139 except processutils.ProcessExecutionError as exc:
140 LOG.debug("Resizing the file system with resize2fs "
141 "has failed with error: %s", exc)
142 finally:
143 finally_call()
145 # NOTE(vish): attempts to resize filesystem
146 if image.format != imgmodel.FORMAT_RAW:
147 # in case of non-raw disks we can't just resize the image, but
148 # rather the mounted device instead
149 mounter = mount.Mount.instance_for_format(
150 image, None, None)
151 if mounter.get_dev(): 151 ↛ exitline 151 didn't return from function 'extend' because the condition on line 151 was always true
152 safe_resize2fs(mounter.device,
153 run_as_root=True,
154 finally_call=mounter.unget_dev)
155 else:
156 safe_resize2fs(image.path)
159def can_resize_image(image, size):
160 """Check whether we can resize the container image file.
161 :param image: path to local image file
162 :param size: the image size in bytes
163 """
164 LOG.debug('Checking if we can resize image %(image)s. '
165 'size=%(size)s', {'image': image, 'size': size})
167 # Check that we're increasing the size
168 virt_size = get_disk_size(image)
169 if virt_size >= size:
170 LOG.debug('Cannot resize image %s to a smaller size.',
171 image)
172 return False
173 return True
176def is_image_extendable(image):
177 """Check whether we can extend the image."""
178 LOG.debug('Checking if we can extend filesystem inside %(image)s.',
179 {'image': image})
181 # For anything except a local raw file we must
182 # go via the VFS layer
183 if (not isinstance(image, imgmodel.LocalImage) or
184 image.format != imgmodel.FORMAT_RAW):
185 fs = None
186 try:
187 fs = vfs.VFS.instance_for_image(image, None)
188 fs.setup(mount=False)
189 if fs.get_image_fs() in SUPPORTED_FS_TO_EXTEND: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true
190 return True
191 except exception.NovaException as e:
192 # FIXME(sahid): At this step we probably want to break the
193 # process if something wrong happens however our CI
194 # provides a bad configuration for libguestfs reported in
195 # the bug lp#1413142. When resolved we should remove this
196 # except to let the error to be propagated.
197 LOG.warning('Unable to mount image %(image)s with '
198 'error %(error)s. Cannot resize.',
199 {'image': image, 'error': e})
200 finally:
201 if fs is not None: 201 ↛ 204line 201 didn't jump to line 204 because the condition on line 201 was always true
202 fs.teardown()
204 return False
205 else:
206 # For raw, we can directly inspect the file system
207 try:
208 processutils.execute('e2label', image.path)
209 except processutils.ProcessExecutionError as e:
210 LOG.debug('Unable to determine label for image %(image)s with '
211 'error %(error)s. Cannot resize.',
212 {'image': image,
213 'error': e})
214 return False
216 return True
219class _DiskImage(object):
220 """Provide operations on a disk image file."""
222 tmp_prefix = 'openstack-disk-mount-tmp'
224 def __init__(self, image, partition=None, mount_dir=None):
225 """Create a new _DiskImage object instance
227 :param image: instance of nova.virt.image.model.Image
228 :param partition: the partition number within the image
229 :param mount_dir: the directory to mount the image on
230 """
232 # These passed to each mounter
233 self.partition = partition
234 self.mount_dir = mount_dir
235 self.image = image
237 # Internal
238 self._mkdir = False
239 self._mounter = None
240 self._errors = []
242 if mount_dir: 242 ↛ exitline 242 didn't return from function '__init__' because the condition on line 242 was always true
243 device = self._device_for_path(mount_dir)
244 if device:
245 self._reset(device)
246 else:
247 LOG.debug('No device found for path: %s', mount_dir)
249 @staticmethod
250 def _device_for_path(path):
251 device = None
252 path = os.path.realpath(path)
253 with open("/proc/mounts", 'r') as ifp:
254 for line in ifp:
255 fields = line.split()
256 if fields[1] == path: 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true
257 device = fields[0]
258 break
259 return device
261 def _reset(self, device):
262 """Reset internal state for a previously mounted directory."""
263 self._mounter = mount.Mount.instance_for_device(self.image,
264 self.mount_dir,
265 self.partition,
266 device)
268 mount_name = os.path.basename(self.mount_dir or '')
269 self._mkdir = mount_name.startswith(self.tmp_prefix)
271 @property
272 def errors(self):
273 """Return the collated errors from all operations."""
274 return '\n--\n'.join([''] + self._errors)
276 def mount(self):
277 """Mount a disk image, using the object attributes.
279 The first supported means provided by the mount classes is used.
281 True, or False is returned and the 'errors' attribute
282 contains any diagnostics.
283 """
284 if self._mounter: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true
285 raise exception.NovaException(_('image already mounted'))
287 if not self.mount_dir: 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true
288 self.mount_dir = tempfile.mkdtemp(prefix=self.tmp_prefix)
289 self._mkdir = True
291 mounter = mount.Mount.instance_for_format(self.image,
292 self.mount_dir,
293 self.partition)
295 if mounter.do_mount(): 295 ↛ 299line 295 didn't jump to line 299 because the condition on line 295 was always true
296 self._mounter = mounter
297 return self._mounter.device
298 else:
299 LOG.debug(mounter.error)
300 self._errors.append(mounter.error)
301 return None
303 def umount(self):
304 """Umount a mount point from the filesystem."""
305 if self._mounter:
306 self._mounter.do_umount()
307 self._mounter = None
309 def teardown(self):
310 """Remove a disk image from the file system."""
311 try:
312 if self._mounter:
313 self._mounter.do_teardown()
314 self._mounter = None
315 finally:
316 if self._mkdir: 316 ↛ 317line 316 didn't jump to line 317 because the condition on line 316 was never true
317 os.rmdir(self.mount_dir)
320# Public module functions
322def inject_data(image, key=None, net=None, metadata=None, admin_password=None,
323 files=None, partition=None, mandatory=()):
324 """Inject the specified items into a disk image.
326 :param image: instance of nova.virt.image.model.Image
327 :param key: the SSH public key to inject
328 :param net: the network configuration to inject
329 :param metadata: the user metadata to inject
330 :param admin_password: the root password to set
331 :param files: the files to copy into the image
332 :param partition: the partition number to access
333 :param mandatory: the list of parameters which must not fail to inject
335 If an item name is not specified in the MANDATORY iterable, then a warning
336 is logged on failure to inject that item, rather than raising an exception.
338 it will mount the image as a fully partitioned disk and attempt to inject
339 into the specified partition number.
341 If PARTITION is not specified the image is mounted as a single partition.
343 Returns True if all requested operations completed without issue.
344 Raises an exception if a mandatory item can't be injected.
345 """
346 items = {'image': image, 'key': key, 'net': net, 'metadata': metadata,
347 'files': files, 'partition': partition}
348 LOG.debug("Inject data image=%(image)s key=%(key)s net=%(net)s "
349 "metadata=%(metadata)s admin_password=<SANITIZED> "
350 "files=%(files)s partition=%(partition)s", items)
351 try:
352 fs = vfs.VFS.instance_for_image(image, partition)
353 fs.setup()
354 except Exception as e:
355 # If a mandatory item is passed to this function,
356 # then reraise the exception to indicate the error.
357 for inject in mandatory: 357 ↛ 358line 357 didn't jump to line 358 because the loop on line 357 never started
358 inject_val = items[inject]
359 if inject_val:
360 raise
361 LOG.warning('Ignoring error injecting data into image %(image)s '
362 '(%(e)s)', {'image': image, 'e': e})
363 return False
365 try:
366 return inject_data_into_fs(fs, key, net, metadata, admin_password,
367 files, mandatory)
368 finally:
369 fs.teardown()
372def setup_container(image, container_dir):
373 """Setup the LXC container.
375 :param image: instance of nova.virt.image.model.Image
376 :param container_dir: directory to mount the image at
378 It will mount the loopback image to the container directory in order
379 to create the root filesystem for the container.
381 Returns path of image device which is mounted to the container directory.
382 """
383 img = _DiskImage(image=image, mount_dir=container_dir)
384 dev = img.mount()
385 if dev is None: 385 ↛ 386line 385 didn't jump to line 386 because the condition on line 385 was never true
386 LOG.error("Failed to mount container filesystem '%(image)s' "
387 "on '%(target)s': %(errors)s",
388 {"image": img, "target": container_dir,
389 "errors": img.errors})
390 raise exception.NovaException(img.errors)
392 return dev
395def teardown_container(container_dir, container_root_device=None):
396 """Teardown the container rootfs mounting once it is spawned.
398 It will umount the container that is mounted,
399 and delete any linked devices.
400 """
401 try:
402 img = _DiskImage(image=None, mount_dir=container_dir)
403 img.teardown()
405 # Make sure container_root_device is released when teardown container.
406 if container_root_device:
407 if 'loop' in container_root_device:
408 LOG.debug("Release loop device %s", container_root_device)
409 nova.privsep.fs.loopremove(container_root_device)
410 elif 'nbd' in container_root_device: 410 ↛ 414line 410 didn't jump to line 414 because the condition on line 410 was always true
411 LOG.debug('Release nbd device %s', container_root_device)
412 nova.privsep.fs.nbd_disconnect(container_root_device)
413 else:
414 LOG.debug('No release necessary for block device %s',
415 container_root_device)
416 except Exception:
417 LOG.exception('Failed to teardown container filesystem')
420def clean_lxc_namespace(container_dir):
421 """Clean up the container namespace rootfs mounting one spawned.
423 It will umount the mounted names that are mounted
424 but leave the linked devices alone.
425 """
426 try:
427 img = _DiskImage(image=None, mount_dir=container_dir)
428 img.umount()
429 except Exception:
430 LOG.exception('Failed to umount container filesystem')
433def inject_data_into_fs(fs, key, net, metadata, admin_password, files,
434 mandatory=()):
435 """Injects data into a filesystem already mounted by the caller.
436 Virt connections can call this directly if they mount their fs
437 in a different way to inject_data.
439 If an item name is not specified in the MANDATORY iterable, then a warning
440 is logged on failure to inject that item, rather than raising an exception.
442 Returns True if all requested operations completed without issue.
443 Raises an exception if a mandatory item can't be injected.
444 """
445 items = {'key': key, 'net': net, 'metadata': metadata,
446 'admin_password': admin_password, 'files': files}
447 functions = {
448 'key': _inject_key_into_fs,
449 'net': _inject_net_into_fs,
450 'metadata': _inject_metadata_into_fs,
451 'admin_password': _inject_admin_password_into_fs,
452 'files': _inject_files_into_fs,
453 }
454 status = True
455 for inject, inject_val in items.items():
456 if inject_val:
457 try:
458 inject_func = functions[inject]
459 inject_func(inject_val, fs)
460 except Exception as e:
461 if inject in mandatory:
462 raise
463 LOG.warning('Ignoring error injecting %(inject)s into '
464 'image (%(e)s)', {'inject': inject, 'e': e})
465 status = False
466 return status
469def _inject_files_into_fs(files, fs):
470 for (path, contents) in files:
471 # NOTE(wangpan): Ensure the parent dir of injecting file exists
472 parent_dir = os.path.dirname(path)
473 if (len(parent_dir) > 0 and parent_dir != "/" and
474 not fs.has_file(parent_dir)):
475 fs.make_path(parent_dir)
476 fs.set_ownership(parent_dir, "root", "root")
477 fs.set_permissions(parent_dir, 0o744)
478 _inject_file_into_fs(fs, path, contents)
481def _inject_file_into_fs(fs, path, contents, append=False):
482 LOG.debug("Inject file fs=%(fs)s path=%(path)s append=%(append)s",
483 {'fs': fs, 'path': path, 'append': append})
484 if append:
485 fs.append_file(path, contents)
486 else:
487 fs.replace_file(path, contents)
490def _inject_metadata_into_fs(metadata, fs):
491 LOG.debug("Inject metadata fs=%(fs)s metadata=%(metadata)s",
492 {'fs': fs, 'metadata': metadata})
493 _inject_file_into_fs(fs, 'meta.js', jsonutils.dumps(metadata))
496def _setup_selinux_for_keys(fs, sshdir):
497 """Get selinux guests to ensure correct context on injected keys."""
499 if not fs.has_file(os.path.join("etc", "selinux")):
500 return
502 rclocal = os.path.join('etc', 'rc.local')
503 rc_d = os.path.join('etc', 'rc.d')
505 if not fs.has_file(rclocal) and fs.has_file(rc_d): 505 ↛ 511line 505 didn't jump to line 511 because the condition on line 505 was always true
506 rclocal = os.path.join(rc_d, 'rc.local')
508 # Note some systems end rc.local with "exit 0"
509 # and so to append there you'd need something like:
510 # utils.execute('sed', '-i', '${/^exit 0$/d}' rclocal, run_as_root=True)
511 restorecon = [
512 '\n',
513 '# Added by Nova to ensure injected ssh keys have the right context\n',
514 'restorecon -RF %s 2>/dev/null || :\n' % sshdir,
515 ]
517 if not fs.has_file(rclocal):
518 restorecon.insert(0, '#!/bin/sh')
520 _inject_file_into_fs(fs, rclocal, ''.join(restorecon), append=True)
521 fs.set_permissions(rclocal, 0o700)
524def _inject_key_into_fs(key, fs):
525 """Add the given public ssh key to root's authorized_keys.
527 key is an ssh key string.
528 fs is the path to the base of the filesystem into which to inject the key.
529 """
531 LOG.debug("Inject key fs=%(fs)s key=%(key)s", {'fs': fs, 'key': key})
532 sshdir = os.path.join('root', '.ssh')
533 fs.make_path(sshdir)
534 fs.set_ownership(sshdir, "root", "root")
535 fs.set_permissions(sshdir, 0o700)
537 keyfile = os.path.join(sshdir, 'authorized_keys')
539 key_data = ''.join([
540 '\n',
541 '# The following ssh key was injected by Nova',
542 '\n',
543 key.strip(),
544 '\n',
545 ])
547 _inject_file_into_fs(fs, keyfile, key_data, append=True)
548 fs.set_permissions(keyfile, 0o600)
550 _setup_selinux_for_keys(fs, sshdir)
553def _inject_net_into_fs(net, fs):
554 """Inject /etc/network/interfaces into the filesystem rooted at fs.
556 net is the contents of /etc/network/interfaces.
557 """
559 LOG.debug("Inject key fs=%(fs)s net=%(net)s", {'fs': fs, 'net': net})
560 netdir = os.path.join('etc', 'network')
561 fs.make_path(netdir)
562 fs.set_ownership(netdir, "root", "root")
563 fs.set_permissions(netdir, 0o744)
565 netfile = os.path.join('etc', 'network', 'interfaces')
566 _inject_file_into_fs(fs, netfile, net)
569def _inject_admin_password_into_fs(admin_passwd, fs):
570 """Set the root password to admin_passwd
572 admin_password is a root password
573 fs is the path to the base of the filesystem into which to inject
574 the key.
576 This method modifies the instance filesystem directly,
577 and does not require a guest agent running in the instance.
579 """
580 # The approach used here is to copy the password and shadow
581 # files from the instance filesystem to local files, make any
582 # necessary changes, and then copy them back.
584 LOG.debug("Inject admin password fs=%(fs)s "
585 "admin_passwd=<SANITIZED>", {'fs': fs})
586 admin_user = 'root'
588 passwd_path = os.path.join('etc', 'passwd')
589 shadow_path = os.path.join('etc', 'shadow')
591 passwd_data = fs.read_file(passwd_path)
592 shadow_data = fs.read_file(shadow_path)
594 new_shadow_data = _set_passwd(admin_user, admin_passwd,
595 passwd_data, shadow_data)
597 fs.replace_file(shadow_path, new_shadow_data)
600def _generate_salt():
601 salt_set = ('abcdefghijklmnopqrstuvwxyz'
602 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
603 '0123456789./')
604 salt = 16 * ' '
605 return ''.join([random.choice(salt_set) for c in salt])
608def _set_passwd(username, admin_passwd, passwd_data, shadow_data):
609 """set the password for username to admin_passwd
611 The passwd_file is not modified. The shadow_file is updated.
612 if the username is not found in both files, an exception is raised.
614 :param username: the username
615 :param admin_passwd: the admin password
616 :param passwd_data: Data from the passwd file decoded as a string
617 :param shadow_data: Data from the shadow file decoded as a string
618 :returns: nothing
619 :raises: exception.NovaException(), IOError()
621 """
622 # encryption algo - id pairs for crypt()
623 algos = {'SHA-512': '$6$', 'SHA-256': '$5$', 'MD5': '$1$', 'DES': ''}
625 salt = _generate_salt()
627 # crypt() depends on the underlying libc, and may not support all
628 # forms of hash. We try md5 first. If we get only 13 characters back,
629 # then the underlying crypt() didn't understand the '$n$salt' magic,
630 # so we fall back to DES.
631 # md5 is the default because it's widely supported. Although the
632 # local crypt() might support stronger SHA, the target instance
633 # might not.
634 encrypted_passwd = secretutils.crypt_password(
635 admin_passwd, algos['MD5'] + salt
636 )
637 if len(encrypted_passwd) == 13: 637 ↛ 638line 637 didn't jump to line 638 because the condition on line 637 was never true
638 encrypted_passwd = secretutils.crypt_password(
639 admin_passwd, algos['DES'] + salt
640 )
642 p_file = passwd_data.split("\n")
643 s_file = shadow_data.split("\n")
645 # username MUST exist in passwd file or it's an error
646 for entry in p_file: 646 ↛ 651line 646 didn't jump to line 651 because the loop on line 646 didn't complete
647 split_entry = entry.split(':')
648 if split_entry[0] == username: 648 ↛ 646line 648 didn't jump to line 646 because the condition on line 648 was always true
649 break
650 else:
651 msg = _('User %(username)s not found in password file.')
652 raise exception.NovaException(msg % username)
654 # update password in the shadow file. It's an error if the
655 # user doesn't exist.
656 new_shadow = list()
657 found = False
658 for entry in s_file:
659 split_entry = entry.split(':')
660 if split_entry[0] == username:
661 split_entry[1] = encrypted_passwd
662 found = True
663 new_entry = ':'.join(split_entry)
664 new_shadow.append(new_entry)
666 if not found: 666 ↛ 667line 666 didn't jump to line 667 because the condition on line 666 was never true
667 msg = _('User %(username)s not found in shadow file.')
668 raise exception.NovaException(msg % username)
670 return "\n".join(new_shadow)