# Copyright (C) 2017 Red Hat, Inc., Bryn M. Reeves <bmr@redhat.com>
#
# command.py - Boom BLS bootloader command interface
#
# This file is part of the boom project.
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions
# of the GNU General Public License v.2.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""The ``boom.command`` module provides both the Boom command line
interface infrastructure, and a simple procedural interface to the
``boom`` library modules.

The procedural interface is used by the ``boom`` command line tool,
and may be used by application programs, or interactively in the
Python shell by users who do not require all the features present
in the Boom object API.

In addition the module contains definitions for ``BoomReport``
object types and fields that may be of use in implementing custom
reports using the ``boom.report`` module.
"""
from __future__ import print_function

from boom import *
from boom.osprofile import *
from boom.report import *
from boom.bootloader import *
from boom.hostprofile import *
from boom.legacy import *
from boom.config import *
from boom.cache import *

from os import environ, uname, getcwd
from os.path import basename, exists as path_exists, isabs, join, sep
from argparse import ArgumentParser
import platform
import logging

#: The environment variable from which to take the location of the
#: ``/boot`` file system.
BOOM_BOOT_PATH_ENV = "BOOM_BOOT_PATH"

#: Path to the system machine-id file
_MACHINE_ID = "/etc/machine-id"
#: Path to the legacy system machine-id file
_DBUS_MACHINE_ID = "/var/lib/dbus/machine-id"

# Module logging configuration
_log = logging.getLogger(__name__)
_log.set_debug_mask(BOOM_DEBUG_COMMAND)

_log_debug = _log.debug
_log_debug_cmd = _log.debug_masked
_log_info = _log.info
_log_warn = _log.warning
_log_error = _log.error

_default_log_level = logging.WARNING
_console_handler = None


#
# Reporting object types
#

class BoomReportObj(object):
    """BoomReportObj()
        The universal object type used for all reports generated by
        the Boom CLI. Individual fields map to one of the contained
        objects via the ``BoomReportObjType`` object's ``data_fn``
        method. It is an error to attempt to report an object that
        is undefined: the BoomReportObj used for a report must
        contain values for each object type that the specified list
        of fields will attempt to access.

        This allows a single report to include fields from both a
        ``BootEntry`` object and an attached ``OsProfile``.
    """
    be = None
    osp = None
    hp = None
    ce = None

    def __init__(self, boot_entry=None, os_profile=None, host_profile=None,
                 cache_entry=None):
        """Initialise new BoomReportObj objects.

            Construct a new BoomReportObj object containing the
            specified BootEntry and or OsProfile objects.

            :returns: a new BoomReportObj.
            :rtype: ``BoomReportObj``
        """
        self.be = boot_entry
        self.osp = os_profile
        self.hp = host_profile
        self.ce = cache_entry


#: BootEntry report object type
BR_ENTRY = 1
#: OsProfile report object type
BR_PROFILE = 2
#: BootParams report object type
BR_PARAMS = 4
#: HostProfile report object type
BR_HOST = 8
#: CacheEntry report object type
BR_CACHE = 16

#: Report object type table for ``boom.command`` reports.
_report_obj_types = [
    BoomReportObjType(
        BR_ENTRY, "Boot loader entries", "entry_", lambda o: o.be),
    BoomReportObjType(
        BR_PROFILE, "OS profiles", "profile_", lambda o: o.osp),
    BoomReportObjType(
        BR_PARAMS, "Boot parameters", "param_", lambda o: o.be.bp),
    BoomReportObjType(
        BR_HOST, "Host profiles", "host_", lambda o: o.hp),
    BoomReportObjType(
        BR_CACHE, "Cache entries", "cache_", lambda o: o.ce)
]

#
# Reporting field definitions
#

#: Fields derived from OsProfile data.
_profile_fields = [
    BoomFieldType(
        BR_PROFILE, "osid", "OsID", "OS identifier", 7,
        REP_SHA, lambda f, d: f.report_sha(d.os_id)),
    BoomFieldType(
        BR_PROFILE, "osname", "Name", "OS name", 24,
        REP_STR, lambda f, d: f.report_str(d.os_name)),
    BoomFieldType(
        BR_PROFILE, "osshortname", "OsShortName", "OS short name", 12,
        REP_STR, lambda f, d: f.report_str(d.os_short_name)),
    BoomFieldType(
        BR_PROFILE, "osversion", "OsVersion", "OS version", 10,
        REP_STR, lambda f, d: f.report_str(d.os_version)),
    BoomFieldType(
        BR_PROFILE, "osversion_id", "VersionID", "Version identifier", 10,
        REP_STR, lambda f, d: f.report_str(d.os_version_id)),
    BoomFieldType(
        BR_PROFILE, "unamepattern", "UnamePattern", "UTS name pattern", 12,
        REP_STR, lambda f, d: f.report_str(d.uname_pattern)),
    BoomFieldType(
        BR_PROFILE, "kernelpattern", "KernPattern", "Kernel image pattern", 13,
        REP_STR, lambda f, d: f.report_str(d.kernel_pattern)),
    BoomFieldType(
        BR_PROFILE, "initrdpattern", "InitrdPattern", "Initrd pattern", 13,
        REP_STR, lambda f, d: f.report_str(d.initramfs_pattern)),
    BoomFieldType(
        BR_PROFILE, "lvm2opts", "LVM2Opts", "LVM2 options", 12,
        REP_STR, lambda f, d: f.report_str(d.root_opts_lvm2)),
    BoomFieldType(
        BR_PROFILE, "btrfsopts", "BTRFSOpts", "BTRFS options", 13,
        REP_STR, lambda f, d: f.report_str(d.root_opts_btrfs)),
    BoomFieldType(
        BR_PROFILE, "options", "Options", "Kernel options", 24,
        REP_STR, lambda f, d: f.report_str(d.options)),
    BoomFieldType(
        BR_PROFILE, "profilepath", "ProfilePath", "On-disk profile path", 12,
        REP_STR, lambda f, d: f.report_str(d._profile_path()))
]

_default_profile_fields = "osid,osname,osversion"
_verbose_profile_fields = _default_profile_fields + ",unamepattern,options"

_host_fields = [
    BoomFieldType(
        BR_HOST, "hostid", "HostID", "Host identifier", 7,
        REP_SHA, lambda f, d: f.report_sha(d.host_id)),
    BoomFieldType(
        BR_HOST, "machineid", "MachineID", "Machine identifier", 10,
        REP_SHA, lambda f, d: f.report_sha(d.disp_machine_id)),
    BoomFieldType(
        BR_HOST, "osid", "OsID", "OS identifier", 7,
        REP_SHA, lambda f, d: f.report_sha(d.os_id)),
    BoomFieldType(
        BR_HOST, "hostname", "HostName", "Host name", 28,
        REP_STR, lambda f, d: f.report_str(d.host_name)),
    BoomFieldType(
        BR_HOST, "label", "Label", "Host label", 12,
        REP_STR, lambda f, d: f.report_str(d.label)),
    BoomFieldType(
        BR_HOST, "kernelpattern", "KernPattern", "Kernel image pattern", 13,
        REP_STR, lambda f, d: f.report_str(d.kernel_pattern)),
    BoomFieldType(
        BR_HOST, "initrdpattern", "InitrdPattern", "Initrd pattern", 13,
        REP_STR, lambda f, d: f.report_str(d.initramfs_pattern)),
    BoomFieldType(
        BR_HOST, "lvm2opts", "LVM2Opts", "LVM2 options", 12,
        REP_STR, lambda f, d: f.report_str(d.root_opts_lvm2)),
    BoomFieldType(
        BR_HOST, "btrfsopts", "BTRFSOpts", "BTRFS options", 13,
        REP_STR, lambda f, d: f.report_str(d.root_opts_btrfs)),
    BoomFieldType(
        BR_HOST, "options", "Options", "Kernel options", 24,
        REP_STR, lambda f, d: f.report_str(d.options)),
    BoomFieldType(
        BR_HOST, "profilepath", "ProfilePath", "On-disk profile path", 12,
        REP_STR, lambda f, d: f.report_str(d._profile_path())),
    BoomFieldType(
        BR_HOST, "addopts", "AddOptions", "Added Options", 12,
        REP_STR, lambda f, d: f.report_str(d.add_opts)),
    BoomFieldType(
        BR_HOST, "delopts", "DelOptions", "Deleted Options", 12,
        REP_STR, lambda f, d: f.report_str(d.del_opts))
]

_default_host_fields = "hostid,hostname,machineid,osid"
_verbose_host_fields = _default_host_fields + ",options,addopts,delopts"


def _int_if_val(val):
    """Return an int if val is defined or None otherwise.

        A TypeError exception is raised if val is defined but does
        not contain a parsable integer value.

        :param val: The value to convert
        :returns: None if val is None or an integer representation of
                  the string val
        :raises: TypeError is val cannot be converted to an int
    """
    return int(val) if val is not None else None


def _bool_to_yes_no(bval):
    """Return the string 'yes' if ``bval`` is ``True`` or 'no' otherwise.
    """
    return "yes" if bval else "no"


#: Fields derived from BootEntry data.
_entry_fields = [
    BoomFieldType(
        BR_ENTRY, "bootid", "BootID", "Boot identifier", 7,
        REP_SHA, lambda f, d: f.report_sha(d.boot_id)),
    BoomFieldType(
        BR_ENTRY, "title", "Title", "Entry title", 24,
        REP_STR, lambda f, d: f.report_str(d.title)),
    BoomFieldType(
        BR_ENTRY, "options", "Options", "Kernel options", 24,
        REP_STR, lambda f, d: f.report_str(d.options)),
    BoomFieldType(
        BR_ENTRY, "kernel", "Kernel", "Kernel image", 32,
        REP_STR, lambda f, d: f.report_str(d.linux)),
    BoomFieldType(
        BR_ENTRY, "initramfs", "Initramfs", "Initramfs image", 40,
        REP_STR, lambda f, d: f.report_str(d.initrd)),
    BoomFieldType(
        BR_ENTRY, "machineid", "MachineID", "Machine identifier", 10,
        REP_SHA, lambda f, d: f.report_sha(d.machine_id)),
    BoomFieldType(
        BR_ENTRY, "entrypath", "EntryPath", "On-disk entry path", 12,
        REP_STR, lambda f, d: f.report_str(d.entry_path)),
    BoomFieldType(
        BR_ENTRY, "entryfile", "EntryFile", "On-disk entry file name", 12,
        REP_STR, lambda f, d: f.report_str(basename(d.entry_path))),
    BoomFieldType(
        BR_ENTRY, "readonly", "ReadOnly", "Entry is read-only", 9,
        REP_STR, lambda f, d: f.report_str(_bool_to_yes_no(d.read_only)))
]

#: Fields derived from BootEntry data, with bootloader variables expanded.
_expand_entry_fields = [
    BoomFieldType(
        BR_ENTRY, "bootid", "BootID", "Boot identifier", 7,
        REP_SHA, lambda f, d: f.report_sha(d.boot_id)),
    BoomFieldType(
        BR_ENTRY, "title", "Title", "Entry title", 24,
        REP_STR, lambda f, d: f.report_str(d.title)),
    BoomFieldType(
        BR_ENTRY, "options", "Options", "Kernel options", 24,
        REP_STR, lambda f, d: f.report_str(d.expand_options)),
    BoomFieldType(
        BR_ENTRY, "kernel", "Kernel", "Kernel image", 32,
        REP_STR, lambda f, d: f.report_str(d.linux)),
    BoomFieldType(
        BR_ENTRY, "initramfs", "Initramfs", "Initramfs image", 40,
        REP_STR, lambda f, d: f.report_str(d.initrd)),
    BoomFieldType(
        BR_ENTRY, "machineid", "MachineID", "Machine identifier", 10,
        REP_SHA, lambda f, d: f.report_sha(d.machine_id)),
    BoomFieldType(
        BR_ENTRY, "entrypath", "EntryPath", "On-disk entry path", 12,
        REP_STR, lambda f, d: f.report_str(d.entry_path)),
    BoomFieldType(
        BR_ENTRY, "entryfile", "EntryFile", "On-disk entry file name", 12,
        REP_STR, lambda f, d: f.report_str(basename(d.entry_path))),
    BoomFieldType(
        BR_ENTRY, "readonly", "ReadOnly", "Entry is read-only", 9,
        REP_STR, lambda f, d: f.report_str(_bool_to_yes_no(d.read_only)))
]

#: Fields derived from BootParams data
_params_fields = [
    BoomFieldType(
        BR_PARAMS, "version", "Version", "Kernel version", 24,
        REP_STR, lambda f, d: f.report_str(d.version)),
    BoomFieldType(
        BR_PARAMS, "rootdev", "RootDevice", "Root device", 10,
        REP_STR, lambda f, d: f.report_str(d.root_device)),
    BoomFieldType(
        BR_PARAMS, "rootlv", "RootLV", "Root logical volume", 6,
        REP_STR, lambda f, d: f.report_str(d.lvm_root_lv or "")),
    BoomFieldType(
        BR_PARAMS, "subvolpath", "SubvolPath", "BTRFS subvolume path", 10,
        REP_STR, lambda f, d: f.report_str(d.btrfs_subvol_path or "")),
    BoomFieldType(
        BR_PARAMS, "subvolid", "SubvolID", "BTRFS subvolume ID", 8,
        REP_NUM, lambda f, d: f.report_num(_int_if_val(d.btrfs_subvol_id)))
]

_default_entry_fields = "bootid,version,osname,rootdev"
_verbose_entry_fields = (_default_entry_fields + ",options,machineid")


#: Fields derived from CacheEntry data
_cache_fields = [
    BoomFieldType(
        BR_CACHE, "imgid", "ImageID", "Image identifier", 7,
        REP_SHA, lambda f, d: f.report_sha(d.img_id)),
    BoomFieldType(
        BR_CACHE, "path", "Path", "Image path", 24,
        REP_STR, lambda f, d: f.report_str(d.path)),
    BoomFieldType(
        BR_CACHE, "mode", "Mode", "Path mode", 8,
        REP_STR, lambda f, d: f.report_str(boom_filemode(d.mode))),
    BoomFieldType(
        BR_CACHE, "uid", "User", "User ID", 5,
        REP_NUM, lambda f, d: f.report_num(d.uid)),
    BoomFieldType(
        BR_CACHE, "gid", "Group", "Group ID", 5,
        REP_NUM, lambda f, d: f.report_num(d.gid)),
    BoomFieldType(
        BR_CACHE, "ts", "Timestamp", "Timestamp", 10,
        REP_NUM, lambda f, d: f.report_num(d.timestamp)),
    BoomFieldType(
        BR_CACHE, "state", "State", "State", 8,
        REP_STR, lambda f, d: f.report_str(d.state)),
    BoomFieldType(
        BR_CACHE, "count", "Count", "Use Count", 5,
        REP_NUM, lambda f, d: f.report_num(d.count))
]

_default_cache_fields = "path,imgid,ts,state"
_verbose_cache_fields = "path,imgid,ts,mode,uid,gid,state,count"


def _get_machine_id():
    """Return the current host's machine-id.

        Get the machine-id value for the running system by reading from
        ``/etc/machine-id`` and return it as a string.

        :returns: The ``machine_id`` as a string
        :rtype: str
    """
    if path_exists(_MACHINE_ID):
        path = _MACHINE_ID
    elif path_exists(_DBUS_MACHINE_ID):
        path = _DBUS_MACHINE_ID
    else:
        return None

    with open(path, "r") as f:
        try:
            machine_id = f.read().strip()
        except Exception as e:
            _log_error("Could not read machine-id from '%s': %s" %
                       (_MACHINE_ID, e))
            machine_id = None
    return machine_id


def _subvol_from_arg(subvol):
    """Parse a BTRFS subvolume from a string argument.

        Parse a BTRFS subvolume path or identifier from a command line
        argument string. Numeric values are assumed to be a subvolume ID
        and values beginning with a '/' character are assumed to be a
        subvolume path.

        :param subvol: A subvolume path or ID string
        :returns: (path, id) tuple or (None, None) if neither is found
        :rtype: (str, str)
    """
    if not subvol:
        return (None, None)
    subvol = parse_btrfs_subvol(subvol)
    if subvol.startswith('/'):
        btrfs_subvol_path = subvol
        btrfs_subvol_id = None
    else:
        btrfs_subvol_path = None
        btrfs_subvol_id = subvol
    return (btrfs_subvol_path, btrfs_subvol_id)


def _str_indent(string, indent):
    """Indent all lines of a multi-line string.

        Indent each line of the multi line string ``string`` to the
        specified indentation level.

        :param string: The string to be indented
        :param indent: The number of characters to indent by
        :returns: str
    """
    outstr = ""
    for line in string.splitlines():
        outstr += indent * ' ' + line + '\n'
    return outstr.rstrip('\n')


def _canonicalize_lv_name(lvname):
    """Canonicalize an LVM2 logical volume name as "VG/LV", removing any
        "/dev/" prefix and return the result as a string.

        The use of "/dev/mapper/VG-LV" names is not supported.
    """
    dev_prefix = DEV_PATTERN % ""
    if lvname.startswith(dev_prefix + "mapper/"):
        raise ValueError("Logical volume names in /dev/mapper/VG-LV format "
                         "are not supported.")
    if lvname.startswith(dev_prefix):
        lvname = lvname[len(dev_prefix):]
    if '/' not in lvname or lvname.count('/') != 1:
        raise ValueError("Root logical volume name must be in VG/LV format.")
    return lvname


def __write_legacy():
    """Synchronise boom boot entries with the configured legacy
        bootloader format.
    """
    config = get_boom_config()
    if config.legacy_enable and config.legacy_sync:
        clear_legacy_loader()
        write_legacy_loader(selection=Selection(), loader=config.legacy_format)


def _do_print_type(report_fields, selected, output_fields=None,
                   opts=None, sort_keys=None):
    """Print an object type report (entry, osprofile, hostprofile).

        Helper for list function that generate BoomReports.

        Format a set of entry or profile objects matching the given
        criteria and format them as a report, returning the output
        as a string.

        Selection criteria may be expressed via a Selection object
        passed to the call using the ``selection`` parameter.

        :param selection: A Selection object giving selection
                          criteria for the operation
        :param output_fields: a comma-separated list of output fields
        :param opts: output formatting and control options
        :param sort_keys: a comma-separated list of sort keys
        :rtype: str
    """
    opts = opts if opts is not None else BoomReportOpts()

    br = BoomReport(_report_obj_types, report_fields, output_fields,
                    opts, sort_keys, None)

    for obj in selected:
        # Fixme: handle bes with embedded hp (class test)
        br.report_object(obj)

    return br.report_output()


def _merge_add_del_opts(bp, add_opts, del_opts):
    """Merge a set of existing bootparams option alterations with
        a set of command-line provided values to produce a single
        set of options to add or remove from a cloned or edited
        ``BootEntry``.

        The sets are merged giving precedence to alterations on the
        current command line: i.e. if an option is present in both
        ``bp.del_opts`` and ``add_opts`` (or vice versa) then the
        option taken from the current command line will be effective.

        :param bp: A ``BootParams`` object with the original ``add_opts``
                   and ``del_opts`` values.
        :param add_opts: A space-separated string containing a list of
                         additional options taken from the current
                         command line.
        :param del_opts: A space-separated string containing a list of
                         options to delete taken from the current
                         command line.
        :returns: A tuple ``(effective_add_opts, effective_del_opts)``
                  giving the final effective values as a list of
                  strings, one per option word.
    """
    def _merge_opts(orig_opts, opts, r_opts):
        # Merge new and cloned kernel options
        all_opts = []
        if orig_opts:
            for opt in orig_opts:
                if opt not in all_opts:
                    all_opts.append(opt)
        if opts:
            for opt in opts:
                if opt not in all_opts:
                    all_opts.append(opt)
        return [o for o in all_opts if o not in r_opts]

    _log_debug_cmd("Add opts: %s" % add_opts)
    _log_debug_cmd("Del opts: %s" % del_opts)
    _log_debug_cmd("Original add_opts: %s" % bp.add_opts)
    _log_debug_cmd("Original del_opts: %s" % bp.del_opts)

    r_del_opts = []
    r_add_opts = []

    add_opts = add_opts.split() if add_opts else []
    del_opts = del_opts.split() if del_opts else []

    for add_opt in list(add_opts):
        # Do not allow conflicting command line add/del opts
        if add_opt in del_opts:
            raise ValueError("Conflicting --add-opts %s and --del-opts %s" %
                             (add_opt, add_opt))

        if add_opt in bp.del_opts:
            r_del_opts.append(add_opt)
            add_opts.remove(add_opt)

    for del_opt in list(del_opts):
        if del_opt in bp.add_opts:
            r_add_opts.append(del_opt)
            del_opts.remove(del_opt)

    add_opts = _merge_opts(bp.add_opts, add_opts, r_add_opts)
    del_opts = _merge_opts(bp.del_opts, del_opts, r_del_opts)

    _log_debug_cmd("Effective add options: %s" % add_opts)
    _log_debug_cmd("Effective del options: %s" % del_opts)

    return (add_opts, del_opts)


#
# Command driven API: BootEntry and OsProfile management and reporting.
#

# Boot image cache modes

#: Use original image (no caching)
I_NONE = None
I_CACHE = "cache"
I_BACKUP = "backup"


#
# BootEntry manipulation
#

def _find_backup_name(img_path):
    """Generate a new, unique backup pathname.
    """
    img_backup = ("%s.boom" % img_path)[1:] + "%d"

    def _backup_img(backup_nr):
        return sep + img_backup % backup_nr

    def _backup_path(backup_nr):
        return join(get_boot_path(), img_backup[1:] % backup_nr)

    backup_nr = 0
    while path_exists(_backup_path(backup_nr)):
        if find_cache_paths(Selection(path=_backup_img(backup_nr))):
            break
        backup_nr += 1
    return sep + img_backup % backup_nr


def _cache_image(img_path, backup):
    """Cache the image found at ``img_path`` and optionally create
        a backup copy.
    """
    if "." in img_path:
        ext = img_path.rsplit(".", 1)[1]
        if ext.startswith("boom") and ext[4:].isdigit():
            if find_cache_paths(Selection(path=img_path)):
                return img_path
    try:
        if backup:
            img_backup = _find_backup_name(img_path)
            _log_debug("backing up '%s' as '%s'" % (img_path, img_backup))
            ce = backup_path(img_path, img_backup)
            return img_backup
        else:
            ce = cache_path(img_path)
    except (OSError, ValueError) as e:
        _log_error("Could not cache path %s: %s" % (img_path, e))
        raise e
    return img_path


def _find_one_entry(select):
    """Find exactly one entry, and raise ValueError if zero or more
        than one entry is found.

        :param: An instance of ``Selection`` specificying match criteria.
        :returns: A single instance of ``BootEntry``
        :raises: ValueError if selection results are empty or non-unique
    """
    bes = find_entries(select)
    if not bes:
        raise ValueError("No matching entry found for boot ID %s" %
                         select.boot_id)
    if len(bes) > 1:
        raise ValueError("Selection criteria must match exactly one entry")
    return bes[0]


def create_entry(title, version, machine_id, root_device, lvm_root_lv=None,
                 btrfs_subvol_path=None, btrfs_subvol_id=None, profile=None,
                 add_opts=None, del_opts=None, write=True, architecture=None,
                 expand=False, allow_no_dev=False, images=I_NONE):
    """Create new boot loader entry.

        Create the specified boot entry in the configured loader directory.
        An error is raised if a matching entry already exists.

        :param title: the title of the new entry.
        :param version: the version string for the new entry.
        :param machine_id: the machine id for the new entry.
        :param root_device: the root device path for the new entry.
        :param lvm_root_lv: an optional LVM2 root logical volume.
        :param btrfs_subvol_path: an optional BTRFS subvolume path.
        :param btrfs_subvol_id: an optional BTRFS subvolume id.
        :param profile: A profile to use for this entry.
        :param add_opts: A list of additional kernel options to append.
        :param del_opts: A list of template-supplied options to drop.
        :param write: ``True`` if the entry should be written to disk,
                      or ``False`` otherwise.
        :param architecture: An optional BLS architecture string.
        :param expand: Expand bootloader environment variables.
        :param allow_no_dev: Accept a non-existent or invalid root dev.
        :param images: Whether to cache or backup boot images in the new
                       entry.
        :returns: a ``BootEntry`` object corresponding to the new entry.
        :rtype: ``BootEntry``
        :raises: ``ValueError`` if either required values are missing or
                 a duplicate entry exists, or``OsError`` if an error
                 occurs while writing the entry file.
    """
    if not title and not profile.title:
        raise ValueError("Entry title cannot be empty.")

    if not version:
        raise ValueError("Entry version cannot be empty.")

    if not machine_id:
        raise ValueError("Entry machine_id cannot be empty.")

    if not root_device:
        raise ValueError("Entry requires a root_device.")

    if not profile:
        raise ValueError("Cannot create entry without OsProfile.")

    bc = get_boom_config()
    if images is not I_NONE and not bc.cache_enable:
        raise BoomConfigError("Cannot use images=%s with image cache disabled"
                              " (config.cache_enable=False)" % images)

    add_opts = add_opts.split() if add_opts else []
    del_opts = del_opts.split() if del_opts else []

    _log_debug_cmd("Effective add options: %s" % add_opts)
    _log_debug_cmd("Effective del options: %s" % del_opts)

    bp = BootParams(version, root_device, lvm_root_lv=lvm_root_lv,
                    btrfs_subvol_path=btrfs_subvol_path,
                    btrfs_subvol_id=btrfs_subvol_id,
                    add_opts=add_opts, del_opts=del_opts)

    be = BootEntry(title=title, machine_id=machine_id,
                   osprofile=profile, boot_params=bp,
                   architecture=architecture, allow_no_dev=allow_no_dev)

    if images in (I_BACKUP, I_CACHE):
        be.initrd = _cache_image(be.initrd, images == I_BACKUP)
        be.linux = _cache_image(be.linux, images == I_BACKUP)

    if find_entries(Selection(boot_id=be.boot_id)):
        raise ValueError("Entry already exists (boot_id=%s)." %
                         be.disp_boot_id)

    if write:
        be.write_entry(expand=expand)
        __write_legacy()

    return be


def delete_entries(selection=None):
    """Delete entries matching selection criteria.

        Delete the specified boot entry or entries from the configured
        loader directory. If ``boot_id`` is used, or if the criteria
        specified match exactly one entry, a single entry is removed.
        If ``boot_id`` is not used, and more than one matching entry
        is present, all matching entries will be removed.

        Selection criteria may also be expressed via a Selection
        object passed to the call using the ``selection`` parameter.

        On success the number of entries removed is returned.

        :param selection: A Selection object giving selection
                          criteria for the operation.
        :returns: the number of entries removed.
        :rtype: ``int``
    """
    bes = find_entries(selection=selection)

    if not bes:
        raise IndexError("No matching entry found.")

    deleted = 0
    for be in bes:
        be.delete_entry()
        deleted += 1

    __write_legacy()

    return deleted


def clone_entry(selection=None, title=None, version=None, machine_id=None,
                root_device=None, lvm_root_lv=None, btrfs_subvol_path=None,
                btrfs_subvol_id=None, profile=None, architecture=None,
                add_opts=None, del_opts=None,
                write=True, expand=False, allow_no_dev=False, images=I_NONE):
    """Clone an existing boot loader entry.

        Create the specified boot entry in the configured loader directory
        by cloning all un-set parameters from the boot entry selected by
        the ``selection`` argument.

        An error is raised if a matching entry already exists.

        :param selection: criteria matching the entry to clone.
        :param title: the title of the new entry.
        :param version: the version string for the new entry.
        :param machine_id: the machine id for the new entry.
        :param root_device: the root device path for the new entry.
        :param lvm_root_lv: an optional LVM2 root logical volume.
        :param btrfs_subvol_path: an optional BTRFS subvolume path.
        :param btrfs_subvol_id: an optional BTRFS subvolume id.
        :param profile: A profile to use for this entry.
        :param architecture: An optional BLS architecture string.
        :param add_opts: A list of additional kernel options to append.
        :param del_opts: A list of template-supplied options to drop.
        :param write: ``True`` if the entry should be written to disk,
                      or ``False`` otherwise.
        :param expand: Expand bootloader environment variables.
        :param allow_no_dev: Allow the block device to not exist.
        :returns: a ``BootEntry`` object corresponding to the new entry.
        :rtype: ``BootEntry``
        :raises: ``ValueError`` if either required values are missing or
                 a duplicate entry exists, or``OsError`` if an error
                 occurs while writing the entry file.
    """
    if not selection.boot_id or selection.boot_id is None:
        raise ValueError("clone requires boot_id")

    all_args = (title, version, machine_id, root_device, lvm_root_lv,
                btrfs_subvol_path, btrfs_subvol_id, profile)

    if not any(all_args):
        raise ValueError("clone requires one or more of:\ntitle, version, "
                         "machine_id, root_device, lvm_root_lv, "
                         "btrfs_subvol_path, btrfs_subvol_id, profile")

    bc = get_boom_config()
    if images is not I_NONE and not bc.cache_enable:
        raise BoomConfigError("Cannot use images=%s with image cache disabled"
                              " (config.cache_enable=False)" % images)

    be = _find_one_entry(selection)

    _log_debug("Cloning entry with boot_id='%s'" % be.disp_boot_id)

    title = title if title else be.title
    version = version if version else be.version
    machine_id = machine_id if machine_id else be.machine_id
    profile = profile if profile else be._osp

    bp = BootParams.from_entry(be, expand=expand)
    (add_opts, del_opts) = _merge_add_del_opts(bp, add_opts, del_opts)

    bp.root_device = root_device if root_device else bp.root_device
    bp.lvm_root_lv = lvm_root_lv if lvm_root_lv else bp.lvm_root_lv

    if btrfs_subvol_path and btrfs_subvol_id:
        raise ValueError("cannot set btrfs_subvol_path and btrfs_subvol_id")

    if btrfs_subvol_path:
        bp.btrfs_subvol_path = btrfs_subvol_path
    elif btrfs_subvol_id:
        bp.btrfs_subvol_id = btrfs_subvol_id

    clone_be = BootEntry(title=title, machine_id=machine_id,
                         osprofile=profile, boot_params=bp,
                         architecture=architecture,
                         allow_no_dev=allow_no_dev)

    if be.options != be.expand_options and not expand:
        clone_be.options = be.options
    else:
        clone_be.bp.add_opts = add_opts
        clone_be.bp.del_opts = del_opts

    # Clone optional keys allowed by profile
    for optional_key in be._osp.optional_keys.split():
        if optional_key in clone_be._osp.optional_keys:
            if hasattr(be, optional_key):
                setattr(clone_be, optional_key,
                        getattr(be, optional_key))

    # Boot image overrides?
    if be.initrd != clone_be.initrd:
        clone_be.initrd = be.initrd
    if be.linux != clone_be.linux:
        clone_be.linux = be.linux

    if images in (I_BACKUP, I_CACHE):
        clone_be.initrd = _cache_image(clone_be.initrd, images == I_BACKUP)
        clone_be.linux = _cache_image(clone_be.linux, images == I_BACKUP)

    if find_entries(Selection(boot_id=clone_be.boot_id)):
        raise ValueError("Entry already exists (boot_id=%s)." %
                         clone_be.disp_boot_id)

    if write:
        clone_be.write_entry()
        __write_legacy()

    return clone_be


def edit_entry(selection=None, title=None, version=None, machine_id=None,
               root_device=None, lvm_root_lv=None, btrfs_subvol_path=None,
               btrfs_subvol_id=None, profile=None, architecture=None,
               add_opts=None, del_opts=None, expand=False, images=I_NONE):
    """Edit an existing boot loader entry.

        Modify an existing BootEntry by changing one or more of the
        entry values or boot parameters.

        The modified BootEntry is written to disk and returned on
        success.

        Modifying a BootEntry causes the entry's boot_id to change,
        since the ID is based on the values of all configured boot
        keys.

        :param selection: A Selection specifying the boot_id to edit
        :param title: The new entry title
        :param version: The new entry version
        :param machine_id: The new machine_id
        :param root_device: The new root device
        :param lvm_root_lv: The new LVM root LV
        :param btrfs_subvol_path: The new BTRFS subvolume path
        :param btrfs_subvol_id: The new BTRFS subvolme ID
        :param profile: The host or OS profile for the edited entry
        :param architecture: An optional BLS architecture string.
        :param add_opts: A list of additional kernel options to append.
        :param del_opts: A list of template-supplied options to drop.
        :param expand: Expand bootloader environment variables.

        :returns: The modified ``BootEntry``
        :rtype: ``BootEntry``
    """
    all_args = (title, version, machine_id, root_device, lvm_root_lv,
                btrfs_subvol_path, btrfs_subvol_id, profile)

    if not any(all_args):
        raise ValueError("edit requires one or more of:\ntitle, version, "
                         "machine_id, root_device, lvm_root_lv, "
                         "btrfs_subvol_path, btrfs_subvol_id, profile")

    bc = get_boom_config()
    if images is not I_NONE and not bc.cache_enable:
        raise BoomConfigError("Cannot use images=%s with image cache disabled"
                              " (config.cache_enable=False)" % images)

    # Discard all selection criteria but boot_id.
    selection = Selection(boot_id=selection.boot_id)

    be = _find_one_entry(selection)

    _log_debug("Editing entry with boot_id='%s'" % be.disp_boot_id)

    # Use a matching HostProfile is one exists, or the command line
    # OsProfile argument if set.
    machine_id = machine_id or be.machine_id
    version = version or be.version

    (add_opts, del_opts) = _merge_add_del_opts(be.bp, add_opts, del_opts)

    be._osp = profile or be._osp
    be.title = title or be.title
    be.machine_id = machine_id or be.machine_id
    be.architecture = architecture or be.architecture
    be.bp.version = version
    be.bp.root_device = root_device or be.bp.root_device
    be.bp.lvm_root_lv = lvm_root_lv or be.bp.lvm_root_lv
    be.bp.btrfs_subvol_path = btrfs_subvol_path or be.bp.btrfs_subvol_path
    be.bp.btrfs_subvol_id = btrfs_subvol_id or be.bp.btrfs_subvol_id
    be.bp.add_opts = add_opts
    be.bp.del_opts = del_opts

    if images in (I_BACKUP, I_CACHE):
        be.initrd = _cache_image(be.initrd, images == I_BACKUP)
        be.linux = _cache_image(be.linux, images == I_BACKUP)

    # Is the entry now identical to an existing entry?
    if len(find_entries(Selection(boot_id=be.boot_id))) > 1:
        raise ValueError("Entry already exists (boot_id=%s)." %
                         be.disp_boot_id)

    be.update_entry(expand=expand)
    __write_legacy()

    return be


def list_entries(selection=None):
    """List entries matching selection criteria.

        Return a list of ``boom.bootloader.BootEntry`` objects matching
        the given criteria.

        Selection criteria may be expressed via a Selection object
        passed to the call using the ``selection`` parameter.

        :param selection: A Selection object giving selection
                          criteria for the operation.
        :returns: A list of matching BootEntry objects.
        :rtype: list
    """
    bes = find_entries(selection=selection)

    return bes


def _expand_fields(default_fields, output_fields):
    """Expand output fields list from command line arguments.
    """

    if not output_fields:
        output_fields = default_fields
    elif output_fields.startswith('+'):
        output_fields = default_fields + ',' + output_fields[1:]
    return output_fields


def print_entries(selection=None, output_fields=None, opts=None,
                  sort_keys=None, expand=None):
    """Print boot loader entries matching selection criteria.

        Format a set of ``boom.bootloader.BootEntry`` objects matching
        the given criteria, and output them as a report to the file
        given in ``out_file``, or ``sys.stdout`` if ``out_file`` is
        unset.

        Selection criteria may be expressed via a Selection object
        passed to the call using the ``selection`` parameter.

        :param selection: A Selection object giving selection
                          criteria for the operation
        :param output_fields: a comma-separated list of output fields
        :param opts: output formatting and control options
        :param sort_keys: a comma-separated list of sort keys
        :param expand: Expand bootloader environment variables
        :returns: the ``boot_id`` of the new entry
        :rtype: str
    """
    output_fields = _expand_fields(_default_entry_fields, output_fields)

    bes = find_entries(selection=selection)
    selected = [BoomReportObj(be, be._osp, None, None) for be in bes]

    entry_fields = _expand_entry_fields if expand else _entry_fields
    report_fields = entry_fields + _profile_fields + _params_fields

    return _do_print_type(report_fields, selected, output_fields=output_fields,
                          opts=opts, sort_keys=sort_keys)


#
# OsProfile manipulation
#

def _find_profile(cmd_args, version, machine_id, command, optional=True):
    """Find a matching profile (HostProfile or OsProfile) for this
        combination of version, machine_id, label and command line
        profile arguments

        :param cmd_args: The command argument namespace
        :param version: A version string to match
        :machine_id: The machine identifier to match
        :command: The command name to use in error messages
        :returns: A matching ``OsProfile``, ``HostProfile``, or ``None``
                  if no match is found.
    """
    if not cmd_args.profile:
        # Attempt to find a matching OsProfile by version string
        osp = match_os_profile_by_version(version)
        os_id = osp.os_id if osp else None
        if not osp:
            print("No matching OsProfile found for version '%s'" % version)
    else:
        os_id = cmd_args.profile

    osps = find_profiles(Selection(os_id=os_id)) if os_id else None

    # Fail if an explicit profile was given and it is not found.
    if not osps and os_id is not None and os_id == cmd_args.profile:
        print("OsProfile not found: %s" % os_id)
        return None

    if osps and len(osps) > 1:
        print("OsProfile ID '%s' is ambiguous" % os_id)
        return None

    osp = osps[0] if osps else None

    if osp:
        _log_debug("Found OsProfile: %s" % osp.os_id)

    # Attempt to match a host profile to the running host
    label = cmd_args.label or ""
    host_select = Selection(machine_id=machine_id, host_label=label)
    hps = find_host_profiles(host_select)
    hp = hps[0] if hps else None
    if len(hps) > 1:
        # This can only occur if host profiles have been edited outside
        # boom's control, such that there are one or more profiles with
        # matching machine_id and label.
        _log_error("Ambiguous host profile selection")
        return None
    elif len(hps) == 1:
        _log_debug("Found HostProfile: %s" % hps[0].host_id)
        if (hp and osp) and not osp.os_id.startswith(hp.os_id):
            _log_error("Active host profile (host_id=%s, os_id=%s) "
                       "conflicts with --profile=%s" %
                       (hp.disp_host_id, hp.disp_os_id, osp.disp_os_id))
            return None
    elif not osp and not hps:
        if not optional:
            _log_error("%s requires --profile or a matching OsProfile "
                       "or HostProfile" % command)
        return None

    return hp or osp


def _uname_heuristic(name, version_id):
    """Attempt to guess a uname pattern for a given OS name and
        version_id value.

        This is currently supported for Red Hat Enterprise Linux and
        Fedora since both distributions provide a fixed string in the
        UTS release string that can be used to match candidate kernel
        versions against.

        :returns: ``True`` if uname pattern heuristics should be used
                  for this OS or ``False`` otherwise.
    """
    el_uname = "el"
    fc_uname = "fc"
    _name_to_uname = {
        "Red Hat Enterprise Linux": el_uname,
        "Red Hat Enterprise Linux Server": el_uname,
        "Red Hat Enterprise Linux Workstation": el_uname,
        "Fedora": fc_uname
    }

    # Strip trailing minor version ident from elX_Y
    if "_" in version_id:
        version_id = version_id[0:version_id.find("_")]
    if "." in version_id:
        version_id = version_id[0:version_id.find(".")]

    if name in _name_to_uname:
        return "%s%s" % (_name_to_uname[name], version_id)
    return None


def _default_optional_keys(osp):
    """Set default optional keys for OsProfile

        Attempt to set default optional keys for a given OsProfile
        if the distribution is known to support the Red Hat BLS
        extensions.
    """
    all_optional_keys = "grub_users grub_arg grub_class id"
    _default_optional_keys = [
        "Red Hat Enterprise Linux Server",
        "Red Hat Enterprise Linux Workstation",
        "CentOS Linux",
        "Fedora"
    ]
    if osp.os_name in _default_optional_keys:
        return all_optional_keys
    return ""


def _os_profile_from_file(os_release, uname_pattern, profile_data=None):
    """Create OsProfile from os-release file.

        Construct a new ``OsProfile`` object from the specified path,
        substituting each set kwarg parameter with the supplied value
        in the resulting object.

        :param os_release: The os-release file to read
        :param uname_pattern: A replacement uname_pattern value
        :param kernel_pattern: A replacement kernel_pattern value
        :param initramfs_pattern: A replacement initramfs_pattern value
        :param root_opts_lvm2: Replacement LVM2 root options
        :param root_opts_btrfs: Replacement BTRFS root options
        :param options: Replacement options string template
        :returns: A new OsProfile
        :rtype: OsProfile
    """
    profile_data[BOOM_OS_UNAME_PATTERN] = uname_pattern
    osp = OsProfile.from_os_release_file(os_release, profile_data=profile_data)

    # When creating an OsProfile from an os-release file we cannot
    # guess the uname_pattern until after the file has been read and
    # the os_name and os_version_id values have been set.
    if uname_pattern:
        osp.uname_pattern = uname_pattern
    else:
        # Attempt to guess a uname_pattern for operating systems
        # that have predictable UTS release patterns.
        osp.uname_pattern = _uname_heuristic(osp.os_name, osp.os_version_id)

    if not osp.uname_pattern:
        raise ValueError("Could not determine uname pattern for '%s'" %
                         osp.os_name)
    if not osp.optional_keys:
        osp.optional_keys = _default_optional_keys(osp)

    osp.write_profile()
    return osp


def create_profile(name, short_name, version, version_id,
                   uname_pattern=None, kernel_pattern=None,
                   initramfs_pattern=None, root_opts_lvm2=None,
                   root_opts_btrfs=None, options=None,
                   optional_keys=None, profile_data=None,
                   profile_file=None):
    """Create new operating system profile.

        Create the specified OsProfile in the configured profiles
        directory.

        OsProfile key values may be specified either by passing
        individual keyword arguments, or by passing a dictionary
        of OsProfile key name to value pairs as the ``profile_data``
        argument. If a key is present as both a keyword argument
        and in the ``profile_data`` dictionary, the argument will
        take precedence.

        An error is raised if a matching profile already exists.

        :param name: The name of the new OsProfile
        :param short_name: The short name of the new OsProfile
        :param version: The version string of the new OsProfile
        :param version_id: The version ID string of the new OsProfile
        :param uname_pattern: A uname pattern to match for this profile
        :param kernel_pattern: Pattern to generate kernel paths
        :param initramfs_pattern: Pattern to generate initramfs paths
        :param root_opts_lvm2: Template options for LVM2 entries
        :param root_opts_btrfs: Template options for BTRFS entries
        :param options: Template kernel command line options
        :param profile_data: Dictionary of profile key:value pairs
        :param profile_file: File to be used for profile

        :returns: an ``OsProfile`` object for the new profile
        :rtype: ``OsProfile``
        :raises: ``ValueError`` if either required values are missing or
                 a duplicate profile exists, or``OsError`` if an error
                 occurs while writing the profile file.
    """
    def _have_key(pd, arg, key):
        return arg or pd and key in pd

    if not profile_data:
        profile_data = {}

    if not profile_file:
        if not _have_key(profile_data, name, BOOM_OS_NAME):
            raise ValueError("Profile name cannot be empty.")

        if not _have_key(profile_data, short_name, BOOM_OS_SHORT_NAME):
            raise ValueError("Profile short name cannot be empty.")

        if not _have_key(profile_data, version, BOOM_OS_VERSION):
            raise ValueError("Profile version cannot be empty.")

        if not _have_key(profile_data, version_id, BOOM_OS_VERSION_ID):
            raise ValueError("Profile version ID cannot be empty.")

        # Allow keyword arguments to override
        if name:
            profile_data[BOOM_OS_NAME] = name
        if short_name:
            profile_data[BOOM_OS_SHORT_NAME] = short_name
        if version:
            profile_data[BOOM_OS_VERSION] = version
        if version_id:
            profile_data[BOOM_OS_VERSION_ID] = version_id

        if uname_pattern:
            profile_data[BOOM_OS_UNAME_PATTERN] = uname_pattern
        elif BOOM_OS_UNAME_PATTERN not in profile_data:
            # Attempt to guess a uname_pattern for operating systems
            # that have predictable UTS release patterns.
            pattern = _uname_heuristic(
                profile_data[BOOM_OS_NAME],
                profile_data[BOOM_OS_VERSION_ID]
            )
            if pattern:
                profile_data[BOOM_OS_UNAME_PATTERN] = pattern
            else:
                raise ValueError("Could not determine uname pattern for '%s'" %
                                 profile_data[BOOM_OS_NAME])

    if kernel_pattern:
        profile_data[BOOM_OS_KERNEL_PATTERN] = kernel_pattern
    if initramfs_pattern:
        profile_data[BOOM_OS_INITRAMFS_PATTERN] = initramfs_pattern
    if root_opts_lvm2:
        profile_data[BOOM_OS_ROOT_OPTS_LVM2] = root_opts_lvm2
    if root_opts_btrfs:
        profile_data[BOOM_OS_ROOT_OPTS_BTRFS] = root_opts_btrfs
    if options:
        profile_data[BOOM_OS_OPTIONS] = options
    if optional_keys:
        profile_data[BOOM_OS_OPTIONAL_KEYS] = optional_keys

    if profile_file:
        return _os_profile_from_file(profile_file, uname_pattern,
                                     profile_data=profile_data)

    osp = OsProfile(name, short_name, version, version_id,
                    profile_data=profile_data)

    if not osp.optional_keys:
        osp.optional_keys = _default_optional_keys(osp)

    osp.write_profile()
    return osp


def delete_profiles(selection=None):
    """Delete profiles matching selection criteria.

        Delete the specified OsProfile or profiles from the configured
        profile directory. If ``os_id`` is used, or if the criteria
        specified match exactly one profile, a single entry is removed.
        If ``os_id`` is not used, and more than one matching profile
        is present, all matching profiles will be removed.

        Selection criteria are expressed via a Selection object
        passed to the call using the ``selection`` parameter.

        On success the number of profiles removed is returned.

        :param selection: A Selection object giving selection
                          criteria for the operation.
        :returns: the number of entries removed.
        :rtype: ``int``
    """
    osps = find_profiles(selection=selection)

    if not osps:
        raise IndexError("No matching profiles found.")

    deleted = 0
    for osp in osps:
        osp.delete_profile()
        deleted += 1

    return deleted


def clone_profile(selection=None, name=None, short_name=None, version=None,
                  version_id=None, uname_pattern=None, kernel_pattern=None,
                  initramfs_pattern=None, root_opts_lvm2=None,
                  root_opts_btrfs=None, options=None):
    """Clone an existing operating system profile.

        Create the specified profile in the configured profile directory
        by cloning all un-set parameters from the profile selected by
        the ``selection`` argument.

        An error is raised if a matching profile already exists, or if
        the selection criteria match more than one profile.

        :param selection: criteria matching the profile to clone.
        :param name: the name of the new profile.
        :param short_name: the short name of the new profile.
        :param version: the version string for the new profile.
        :param version_id: the version ID string for the new profile.
        :param uname_pattern: a uname pattern to match this profile.
        :param kernel_pattern: a kernel pattern to match this profile.
        :param initramfs_pattern: a initramfs pattern to match this profile.
        :param root_opts_lvm2: LVM2 root options template.
        :param root_opts_btrfs: BTRFS root options template.
        :param options: Kernel options template.

        :returns: a new ``OsProfile`` object.
        :rtype: ``OsProfile``
        :raises: ``ValueError`` if either required values are missing or
                 a duplicate profile exists, or``OsError`` if an error
                 occurs while writing the profile file.
    """
    if not selection.os_id:
        raise ValueError("clone requires os_id")

    all_args = (
        name, short_name, version, version_id, uname_pattern,
        kernel_pattern, initramfs_pattern, root_opts_lvm2,
        root_opts_btrfs, options
    )

    if not any(all_args):
        raise ValueError(
            'clone requires one or more of:\nname, '
            'short_name, version, version_id, uname_pattern,'
            'kernel_pattern, initramfs_pattern, root_opts_lvm2, '
            'root_opts_btrfs, options'
        )

    osps = find_profiles(selection)
    if not(osps):
        raise ValueError("No matching profile found: %s" % selection.os_id)

    if len(osps) > 1:
        raise ValueError("Clone criteria must match exactly one profile")

    osp = osps.pop()

    # Clone unset keys
    name = name or osp.os_name
    short_name = short_name or osp.os_short_name
    version = version or osp.os_version
    version_id = version_id or osp.os_version_id
    uname_pattern = uname_pattern or osp.uname_pattern
    kernel_pattern = kernel_pattern or osp.kernel_pattern
    initramfs_pattern = initramfs_pattern or osp.initramfs_pattern
    root_opts_lvm2 = root_opts_lvm2 or osp.root_opts_lvm2
    root_opts_btrfs = root_opts_btrfs or osp.root_opts_btrfs
    options = options or osp.options

    clone_osp = OsProfile(name, short_name, version, version_id,
                          uname_pattern=uname_pattern,
                          kernel_pattern=kernel_pattern,
                          initramfs_pattern=initramfs_pattern,
                          root_opts_lvm2=root_opts_lvm2,
                          root_opts_btrfs=root_opts_btrfs, options=options)

    clone_osp.write_profile()

    return clone_osp


def edit_profile(selection=None, uname_pattern=None, kernel_pattern=None,
                 initramfs_pattern=None, root_opts_lvm2=None,
                 root_opts_btrfs=None, options=None, optional_keys=None):
    """Edit an existing operating system profile.

        Modify an existing OsProfile by changing one or more of the
        profile values.

        The modified OsProfile is written to disk and returned on
        success.

        :param selection: A Selection specifying the boot_id to edit
        :param uname_pattern: The new uname pattern
        :param kernel_pattern: The new kernel pattern
        :param initramfs_pattern: The new initramfs pattern
        :param root_opts_lvm2: The new LVM2 root options
        :param root_opts_btrfs: The new BTRFS root options
        :param options: The new kernel options template
        :returns: The modified ``OsProfile``
        :rtype: ``OsProfile``
    """
    # Discard all selection criteria but os_id.
    selection = Selection(os_id=selection.os_id)

    osp = None
    osps = find_profiles(Selection(os_id=selection.os_id))
    if not osps:
        raise ValueError("No matching profile found: %s" % selection.os_id)
    if len(osps) > 1:
        raise ValueError("OS profile identifier '%s' is ambiguous" %
                         selection.os_id)

    osp = osps.pop()
    osp.uname_pattern = uname_pattern or osp.uname_pattern
    osp.kernel_pattern = kernel_pattern or osp.kernel_pattern
    osp.initramfs_pattern = initramfs_pattern or osp.initramfs_pattern
    osp.root_opts_lvm2 = root_opts_lvm2 or osp.root_opts_lvm2
    osp.root_opts_btrfs = root_opts_btrfs or osp.root_opts_btrfs
    osp.options = options or osp.options
    osp.optional_keys = optional_keys or osp.optional_keys
    osp.write_profile()
    return osp


def list_profiles(selection=None):
    """List operating system profiles matching selection criteria.

        Return a list of ``boom.osprofile.OsProfile`` objects matching
        the given criteria.

        Selection criteria may be expressed via a Selection object
        passed to the call using the ``selection`` parameter.

        :param selection: A Selection object giving selection
                          criteria for the operation.
        :returns: a list of ``OsProfile`` objects.
        :rtype: list
    """
    osps = find_profiles(selection=selection)

    return osps


def print_profiles(selection=None, opts=None, output_fields=None,
                   sort_keys=None, expand=False):
    """Print operating system profiles matching selection criteria.

        Selection criteria may be expressed via a Selection object
        passed to the call using the ``selection`` parameter.

        :param selection: A Selection object giving selection
                          criteria for the operation
        :param output_fields: a comma-separated list of output fields
        :param opts: output formatting and control options
        :param sort_keys: a comma-separated list of sort keys
        :param expand: unused
        :returns: the number of matching profiles output.
        :rtype: int
    """
    output_fields = _expand_fields(_default_profile_fields, output_fields)

    osps = find_profiles(selection=selection)
    selected = [BoomReportObj(None, osp, None, None) for osp in osps]

    report_fields = _profile_fields
    return _do_print_type(report_fields, selected, output_fields=output_fields,
                          opts=opts, sort_keys=sort_keys)


def create_host(machine_id=None, host_name=None, os_id=None, label=None,
                kernel_pattern=None, initramfs_pattern=None,
                root_opts_lvm2=None, root_opts_btrfs=None,
                options=None, add_opts=None, del_opts=None,
                host_data=None):
    """Create new host profile.

        Create the specified HostProfile in the configured profiles
        directory.

        HostProfile key values may be specified either by passing
        individual keyword arguments, or by passing a dictionary
        of HostProfile key name to value pairs as the ``host_data``
        argument. If a key is present as both a keyword argument
        and in the ``host_data`` dictionary, the argument will
        take precedence.

        An error is raised if a matching profile already exists.

        :param machine_id: The machine_id of the host
        :param host_name: The full name of the new HostProfile
        :param label: An optional host label
        :param os_id: The os_id for the new host
        :param kernel_pattern: Pattern to generate kernel paths
        :param initramfs_pattern: Pattern to generate initramfs paths
        :param root_opts_lvm2: Template options for LVM2 entries
        :param root_opts_btrfs: Template options for BTRFS entries
        :param options: Template kernel command line options
        :param add_opts: Additional boot options for this profile
        :param del_opts: Boot options to delete for this profile
        :param host_data: Dictionary of profile key:value pairs

        :returns: a ``HostProfile`` object for the new profile
        :rtype: ``HostProfile``
        :raises: ``ValueError`` if either required values are missing or
                 a duplicate profile exists, or``OsError`` if an error
                 occurs while writing the profile file.
    """
    def _have_key(hd, arg, key):
        return arg or hd and key in hd

    if not _have_key(host_data, host_name, BOOM_OS_NAME):
        raise ValueError("Host name cannot be empty.")

    if not _have_key(host_data, machine_id, BOOM_OS_VERSION):
        raise ValueError("Host machine_id cannot be empty.")

    if not _have_key(host_data, os_id, BOOM_OS_ID):
        raise ValueError("Host OS ID cannot be empty.")

    label = label or ""

    if not host_data:
        host_data = {}

    # FIXME use kwarg style

    # Allow keyword arguments to override
    if machine_id:
        host_data[BOOM_ENTRY_MACHINE_ID] = machine_id
    if host_name:
        host_data[BOOM_HOST_NAME] = host_name
    if label:
        host_data[BOOM_HOST_LABEL] = label
    if os_id:
        host_data[BOOM_OS_ID] = os_id
    if kernel_pattern:
        host_data[BOOM_OS_KERNEL_PATTERN] = kernel_pattern
    if initramfs_pattern:
        host_data[BOOM_OS_INITRAMFS_PATTERN] = initramfs_pattern
    if root_opts_lvm2:
        host_data[BOOM_OS_ROOT_OPTS_LVM2] = root_opts_lvm2
    if root_opts_btrfs:
        host_data[BOOM_OS_ROOT_OPTS_BTRFS] = root_opts_btrfs
    if options:
        host_data[BOOM_OS_OPTIONS] = options
    if add_opts:
        host_data[BOOM_HOST_ADD_OPTS] = add_opts
    if del_opts:
        host_data[BOOM_HOST_DEL_OPTS] = del_opts

    hp = HostProfile(machine_id=machine_id, profile_data=host_data)

    hp.write_profile()
    return hp


def delete_hosts(selection=None):
    """Delete host profiles matching selection criteria.

        Delete the specified ``HostProfile`` or profiles from the
        configured profile directory. If ``os_id`` is used, or if the
        criteria specified match exactly one profile, a single entry is
        removed. If ``host_id`` is not used, and more than one matching
        profile is present, all matching profiles will be removed.

        Selection criteria are expressed via a Selection object
        passed to the call using the ``selection`` parameter.

        On success the number of profiles removed is returned.

        :param selection: A Selection object giving selection
                          criteria for the operation.
        :returns: the number of entries removed.
        :rtype: ``int``
    """
    hps = find_host_profiles(selection=selection)

    if not hps:
        raise IndexError("No matching host profiles found.")

    deleted = 0
    for hp in hps:
        hp.delete_profile()
        deleted += 1

    return deleted


def clone_host(selection=None, machine_id=None, host_name=None, label=None,
               os_id=None, kernel_pattern=None, initramfs_pattern=None,
               root_opts_lvm2=None, root_opts_btrfs=None,
               add_opts=None, del_opts=None, options=None):
    """Clone an existing host profile.

        Create the specified profile in the configured profile directory
        by cloning all un-set parameters from the profile selected by
        the ``selection`` argument.

        An error is raised if a matching profile already exists, or if
        the selection criteria match more than one profile.

        :param selection: criteria matching the profile to clone.
        :param machine_id: the machine_id of the new host profile.
        :param host_name: the hostname of the new host profile.
        :param label: an optional host label.
        :param os_id: the operating system identifier for the host.
        :param kernel_pattern: The kernel pattern for the host.
        :param initramfs_pattern: The initramfs pattern for the host.
        :param root_opts_lvm2: LVM2 root options template.
        :param root_opts_btrfs: BTRFS root options template.
        :param add_opts: Additional boot options for this profile.
        :param del_opts: Boot options to delete for this profile.
        :param options: Kernel options template.

        :returns: a new ``HostProfile`` object.
        :rtype: ``HostProfile``
        :raises: ``ValueError`` if either required values are missing or
                 a duplicate profile exists, or``OsError`` if an error
                 occurs while writing the profile file.
    """
    if not selection.host_id:
        raise ValueError("clone requires host_id")

    all_args = (
        machine_id, label, host_name, os_id,
        kernel_pattern, initramfs_pattern,
        root_opts_lvm2, root_opts_btrfs,
        add_opts, del_opts, options
    )

    if not any(all_args):
        raise ValueError(
            'clone requires one or more of:\n'
            '--machine-id, --label, --name, --os-id, '
            '--kernel-pattern, --initramfs_pattern, '
            '--root-opts-lvm2, --root_opts-btrfs, '
            '--add-opts, --del-opts, --options'
        )

    hps = find_host_profiles(selection)
    if not(hps):
        raise ValueError("No matching host profile found: %s" %
                         selection.host_id)

    if len(hps) > 1:
        raise ValueError("Clone criteria must match exactly one profile")

    hp = hps.pop()

    # Clone unset keys
    machine_id = machine_id or hp.machine_id
    host_name = host_name or hp.host_name
    label = label or ""
    os_id = os_id or hp.os_id
    initramfs_pattern = initramfs_pattern or hp.initramfs_pattern
    kernel_pattern = kernel_pattern or hp.kernel_pattern
    root_opts_lvm2 = root_opts_lvm2 or hp.root_opts_lvm2
    root_opts_btrfs = root_opts_btrfs or hp.root_opts_btrfs
    add_opts = add_opts or hp.add_opts
    del_opts = del_opts or hp.del_opts
    options = options or hp.options

    clone_hp = HostProfile(machine_id=machine_id, host_name=host_name,
                           label=label, os_id=os_id,
                           kernel_pattern=kernel_pattern,
                           initramfs_pattern=initramfs_pattern,
                           root_opts_lvm2=root_opts_lvm2,
                           root_opts_btrfs=root_opts_btrfs,
                           add_opts=add_opts, del_opts=del_opts,
                           options=options)

    clone_hp.write_profile()

    return clone_hp


def edit_host(selection=None, machine_id=None, os_id=None, host_name=None,
              label=None, kernel_pattern=None, initramfs_pattern=None,
              root_opts_lvm2=None, root_opts_btrfs=None,
              add_opts=None, del_opts=None, options=None):
    """Edit an existing host profile.

        Modify an existing HostProfile by changing one or more of the
        profile values.

        The modified HostProfile is written to disk and returned on
        success.

        :param selection: A Selection specifying the boot_id to edit
        :param machine_id: The machine id for the edited host profile
        :param os_id: The OS id for the edited host profile
        :param host_name: The host name for the edited host profile
        :param label: an optional host label
        :param kernel_pattern: The new kernel pattern
        :param initramfs_pattern: The new initramfs pattern
        :param root_opts_lvm2: The new LVM2 root options
        :param root_opts_btrfs: The new BTRFS root options
        :param add_opts: Additional boot options for this profile.
        :param del_opts: Boot options to delete for this profile.
        :param options: The new kernel options template

        :returns: The modified ``HostProfile``
        :rtype: ``HostProfile``
    """
    # Discard all selection criteria but host_id.
    selection = Selection(host_id=selection.host_id)

    hps = None
    hps = find_host_profiles(selection)
    if not hps:
        raise ValueError("No matching profile found: %s" % selection.host_id)
    if len(hps) > 1:
        raise ValueError("OS profile identifier '%s' is ambiguous" %
                         selection.os_id)

    hp = hps.pop()
    hp.delete_profile()
    hp.machine_id = machine_id or hp.os_id
    hp.host_name = host_name or hp.host_name
    hp.label = label or hp.label
    hp.os_id = os_id or hp.os_id
    hp.kernel_pattern = kernel_pattern or hp.kernel_pattern
    hp.initramfs_pattern = initramfs_pattern or hp.initramfs_pattern
    hp.root_opts_lvm2 = root_opts_lvm2 or hp.root_opts_lvm2
    hp.root_opts_btrfs = root_opts_btrfs or hp.root_opts_btrfs
    hp.options = options or hp.options
    hp.write_profile()
    return hp


def list_hosts(selection=None):
    """List host profiles matching selection criteria.

        Return a list of ``boom.hostprofile.HostProfile`` objects
        matching the given criteria.

        Selection criteria may be expressed via a Selection object
        passed to the call using the ``selection`` parameter.

        :param selection: A Selection object giving selection
                          criteria for the operation.
        :returns: a list of ``HostProfile`` objects.
        :rtype: list
    """
    hps = find_host_profiles(selection=selection)

    return hps


def print_hosts(selection=None, opts=None, output_fields=None,
                sort_keys=None, expand=False):
    """Print host profiles matching selection criteria.

        Selection criteria may be expressed via a Selection object
        passed to the call using the ``selection`` parameter.

        :param selection: A Selection object giving selection
                          criteria for the operation
        :param output_fields: a comma-separated list of output fields
        :param opts: output formatting and control options
        :param sort_keys: a comma-separated list of sort keys
        :param expand: unused
        :returns: the number of matching profiles output
        :rtype: int
    """
    output_fields = _expand_fields(_default_host_fields, output_fields)

    hps = find_host_profiles(selection=selection)
    selected = [BoomReportObj(None, None, hp, None) for hp in hps]
    report_fields = _host_fields
    return _do_print_type(report_fields, selected, output_fields=output_fields,
                          opts=opts, sort_keys=sort_keys)


def _print_cache(find_fn, selection=None, opts=None, output_fields=None,
                 sort_keys=None, expand=False):
    """Print cache entries (with or without images) matching selection
        criteria.

        Selection criteria may be expressed via a ``Selection`` object
        passed to the call using the ``Selection`` parameter.

        :param selection: A Selection object giving selection
                          criteria for the operation
        :param output_fields: a comma-separated list of output fields
        :param opts: output formatting and control options
        :param sort_keys: a comma-separated list of sort keys
        :param expand: unused
        :returns: the number of matching profiles output
        :rtype: int
    """
    output_fields = _expand_fields(_default_cache_fields, output_fields)

    ces = find_fn(selection=selection)
    selected = [BoomReportObj(None, None, None, ce) for ce in ces]
    report_fields = _cache_fields
    return _do_print_type(report_fields, selected, output_fields=output_fields,
                          opts=opts, sort_keys=sort_keys)


def print_cache(selection=None, opts=None, output_fields=None,
                sort_keys=None, expand=False):
    """Print cache entries matching selection criteria.

        Selection criteria may be expressed via a ``Selection`` object
        passed to the call using the ``Selection`` parameter.

        :param selection: A Selection object giving selection
                          criteria for the operation
        :param output_fields: a comma-separated list of output fields
        :param opts: output formatting and control options
        :param sort_keys: a comma-separated list of sort keys
        :param expand: unused
        :returns: the number of matching profiles output
        :rtype: int
    """
    return _print_cache(find_cache_paths, selection=selection, opts=opts,
                        output_fields=output_fields, sort_keys=sort_keys)


def print_cache_images(selection=None, opts=None, output_fields=None,
                       sort_keys=None, expand=False):
    """Print cache entries and images matching selection criteria.

        Selection criteria may be expressed via a ``Selection`` object
        passed to the call using the ``Selection`` parameter.

        :param selection: A Selection object giving selection
                          criteria for the operation
        :param output_fields: a comma-separated list of output fields
        :param opts: output formatting and control options
        :param sort_keys: a comma-separated list of sort keys
        :param expand: unused
        :returns: the number of matching profiles output
        :rtype: int
    """
    return _print_cache(find_cache_images, selection=selection, opts=opts,
                        output_fields=output_fields, sort_keys=sort_keys)


def show_legacy(selection=None, loader=BOOM_LOADER_GRUB1):
    """Print boot entries in legacy boot loader formats.

        :param selection: A Selection object giving selection criteria
                          for the operation
        :param loader: Which boot loader to use
    """
    (name, decorator, path) = find_legacy_loader(loader, None)
    bes = find_entries(selection=selection)
    [print(decorator(be)) for be in bes]


#
# boom command line tool
#

def _apply_profile_overrides(boot_entry, cmd_args):
    if cmd_args.linux:
        boot_entry.linux = cmd_args.linux

    if cmd_args.initrd:
        boot_entry.initrd = cmd_args.initrd


def _optional_key_to_arg(optional_key):
    """Map a Boom optional key name constant to the boom command line
        argument it corresponds to.

        Returns the argument name in long option style, or None if no
        matching optional key exists.
    """
    _key_map = {
        BOOM_ENTRY_GRUB_USERS: "--grub-users",
        BOOM_ENTRY_GRUB_ARG: "--grub-arg",
        BOOM_ENTRY_GRUB_CLASS: "--grub-class"
    }
    return _key_map[optional_key] if optional_key in _key_map else None


def _apply_optional_keys(be, cmd_args):
    """Set the optional key values defined by ``cmd_args`` in the
        ``BootEntry`` ``be``. This function assumes that the caller
        has already checked that the active ``OsProfile`` accepts these
        optional keys, or will handle exceptions raised by setting an
        invalid optional key.
    """
    if cmd_args.id:
        be.id = cmd_args.id.strip()
    if cmd_args.grub_arg:
        be.grub_arg = cmd_args.grub_arg.strip()
    if cmd_args.grub_class:
        be.grub_class = cmd_args.grub_class.strip()
    if cmd_args.grub_users:
        be.grub_users = cmd_args.grub_users.strip()


def _set_optional_key_defaults(profile, cmd_args):
    """Apply default values for all optional keys supported by
        ``profile`` to command line arguments ``cmd_args``.
    """
    for opt_key in OPTIONAL_KEYS:
        bls_key = key_to_bls_name(opt_key)
        if bls_key not in profile.optional_keys:
            if getattr(cmd_args, bls_key) is not None:
                print("Profile with os_id='%s' does not support %s" %
                      (profile.disp_os_id, _optional_key_to_arg(bls_key)))
                return 1
        else:
            if getattr(cmd_args, bls_key) is None:
                setattr(cmd_args, bls_key, optional_key_default(opt_key))


def _create_cmd(cmd_args, select, opts, identifier):
    """Create entry command handler.

        Attempt to create a new boot entry using the arguments
        supplied in ``cmd_args`` and return the command status
        as an integer.

        :param cmd_args: Command line arguments for the command
        :param select: Unused
        :returns: integer status code returned from ``main()``
    """
    if not check_bootloader():
        _log_warn("Boom configuration not found in grub.cfg")
        _log_warn("Run 'grub2-mkconfig > /boot/grub2/grub.cfg' to enable")

    if identifier is not None:
        print("entry create does not accept <identifier>")
        return 1

    if not cmd_args.version:
        version = get_uts_release()
        if not version:
            print("create requires --version")
            return 1
    else:
        version = cmd_args.version

    if not cmd_args.machine_id:
        # Use host machine-id by default
        machine_id = _get_machine_id()
        if not machine_id:
            print("Could not determine machine_id")
            return 1
    else:
        machine_id = cmd_args.machine_id

    if not cmd_args.root_device:
        print("create requires --root-device")
        return 1
    else:
        root_device = cmd_args.root_device

    lvm_root_lv = cmd_args.root_lv if cmd_args.root_lv else None
    subvol = cmd_args.btrfs_subvolume
    (btrfs_subvol_path, btrfs_subvol_id) = _subvol_from_arg(subvol)

    no_dev = cmd_args.no_dev

    profile = _find_profile(cmd_args, version, machine_id,
                            "create", optional=False)

    if not profile:
        return 1

    _set_optional_key_defaults(profile, cmd_args)

    if not cmd_args.title and not profile.title:
        print("create requires --title")
        return 1
    else:
        # Empty title will be filled out by profile
        title = cmd_args.title

    add_opts = cmd_args.add_opts
    del_opts = cmd_args.del_opts

    arch = cmd_args.architecture

    images = I_BACKUP if cmd_args.backup else I_NONE

    try:
        be = create_entry(title, version, machine_id,
                          root_device, lvm_root_lv=lvm_root_lv,
                          btrfs_subvol_path=btrfs_subvol_path,
                          btrfs_subvol_id=btrfs_subvol_id, profile=profile,
                          add_opts=add_opts, del_opts=del_opts,
                          architecture=arch, write=False,
                          expand=cmd_args.expand_variables,
                          allow_no_dev=no_dev, images=images)

    except BoomRootDeviceError as brde:
        print(brde)
        print("Creating an entry with no valid root device requires --no-dev")
        return 1
    except ValueError as e:
        print(e)
        return 1

    _apply_profile_overrides(be, cmd_args)
    _apply_optional_keys(be, cmd_args)

    try:
        be.write_entry(expand=cmd_args.expand_variables)
        __write_legacy()
    except Exception as e:
        if cmd_args.debug:
            raise
        print(e)
        return 1

    print("Created entry with boot_id %s:" % be.disp_boot_id)
    print(_str_indent(str(be), 2))
    return 0


def _delete_cmd(cmd_args, select, opts, identifier):
    """Delete entry command handler.

        Attempt to delete boot entries matching the selection criteria
        given in ``select``.

        :param cmd_args: Command line arguments for the command
        :param select: Selection criteria for the entries to remove
        :returns: integer status code returned from ``main()``
    """
    # If a boot_id is given as a command line argument treat it as
    # a single boot entry to delete and ignore any other criteria.
    identifier = identifier or cmd_args.boot_id
    if identifier is not None:
        select = Selection(boot_id=identifier)

    if not select or select.is_null():
        print("delete requires selection criteria")
        return 1

    if cmd_args.options:
        fields = cmd_args.options
    elif cmd_args.verbose:
        fields = _verbose_entry_fields
    else:
        fields = None
    try:
        if cmd_args.verbose:
            print_entries(selection=select, output_fields=fields,
                          opts=opts, sort_keys=cmd_args.sort)
        nr = delete_entries(select)
    except (ValueError, IndexError) as e:
        print(e)
        return 1

    print("Deleted %d entr%s" % (nr, "ies" if nr > 1 else "y"))
    return 0


def _clone_cmd(cmd_args, select, opts, identifier):
    """Clone entry command handler.

        Attempt to create a new boot entry by cloning an existing
        entry. The ``boot_id`` of the supplied ``Selection`` object
        is used to select the entry to clone. Any set entry values
        supplied in ``cmd_args`` will be used to modify the newly
        cloned entry.

        :param cmd_args: Command line arguments for the command
        :param select: The ``boot_id`` to clone
        :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.boot_id
    if identifier is not None:
        select = Selection(boot_id=identifier)

    if not select or select.is_null():
        print("clone requires selection criteria")
        return 1

    title = cmd_args.title
    root_device = cmd_args.root_device
    lvm_root_lv = cmd_args.root_lv
    subvol = cmd_args.btrfs_subvolume
    (btrfs_subvol_path, btrfs_subvol_id) = _subvol_from_arg(subvol)

    # Discard all selection criteria but boot_id.
    select = Selection(boot_id=select.boot_id)
    try:
        be = _find_one_entry(select)
    except ValueError as e:
        print(e)
        return 1

    version = cmd_args.version or be.version
    machine_id = cmd_args.machine_id or be.machine_id

    profile = _find_profile(cmd_args, version, machine_id, "clone")

    add_opts = cmd_args.add_opts
    del_opts = cmd_args.del_opts

    arch = cmd_args.architecture

    images = I_BACKUP if cmd_args.backup else I_NONE

    try:
        be = clone_entry(select, title=title, version=version,
                         machine_id=machine_id, root_device=root_device,
                         lvm_root_lv=lvm_root_lv,
                         btrfs_subvol_path=btrfs_subvol_path,
                         btrfs_subvol_id=btrfs_subvol_id, profile=profile,
                         add_opts=add_opts, del_opts=del_opts,
                         architecture=arch, expand=cmd_args.expand_variables,
                         allow_no_dev=cmd_args.no_dev, images=images)

    except ValueError as e:
        print(e)
        return 1

    # Command-line overrides take precedence over any overridden values
    # in the cloned BootEntry.
    _apply_profile_overrides(be, cmd_args)
    _apply_optional_keys(be, cmd_args)

    try:
        be.write_entry(expand=cmd_args.expand_variables)
        __write_legacy()
    except Exception as e:
        if cmd_args.debug:
            raise
        print(e)
        return 1

    print("Cloned entry with boot_id %s as boot_id %s:" %
          (select.boot_id, be.disp_boot_id))
    print(_str_indent(str(be), 2))

    return 0


def _show_cmd(cmd_args, select, opts, identifier):
    """Show entry command handler.

        Show the boot entries that match the given selection criteria in
        BLS boot entry notation: one key per line, with keys and values
        separated by a single space character.

        :param cmd_args: Command line arguments for the command
        :param select: Selection criteria for the entries to show.
        :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.boot_id
    if identifier is not None:
        select = Selection(boot_id=identifier)

    if identifier or cmd_args.all:
        if select:
            select.allow_null_profile = True
        else:
            select = Selection(allow_null=True)

    try:
        bes = find_entries(selection=select)
    except ValueError as e:
        print(e)
        return 1
    first = True
    for be in bes:
        ws = "" if first else "\n"
        be_str = be.expanded() if cmd_args.expand_variables else str(be)
        be_str = _str_indent(be_str, 2)
        print("%sBoot Entry (boot_id=%s)\n%s" % (ws, be.disp_boot_id, be_str))
        first = False
    return 0


def _generic_list_cmd(cmd_args, select, opts, verbose_fields, print_fn):
    """Generic list command implementation.

        Implements a simple list command that applies selection criteria
        and calls a print_*() API function to display results.

        Callers should initialise identifier and select appropriately
        for the specific command arguments.

        :param cmd_args: the command arguments
        :param select: selection criteria
        :param opts: reporting options object
        :param print_fn: the API call to display results. The function
                         must accept the selection, output_fields,
                         opts, and sort_keys keyword arguments
        :returns: None
    """
    if cmd_args.options:
        fields = cmd_args.options
    elif cmd_args.verbose:
        fields = verbose_fields
    else:
        fields = None

    try:
        print_fn(selection=select, output_fields=fields,
                 opts=opts, sort_keys=cmd_args.sort,
                 expand=cmd_args.expand_variables)
    except ValueError as e:
        print(e)
        return 1
    return 0


def _list_cmd(cmd_args, select, opts, identifier):
    """List entry command handler.
        List the boot entries that match the given selection criteria as
        a tabular report, with one boot entry per row.

        :param cmd_args: Command line arguments for the command
        :param select: Selection criteria fore the entries to list
        :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.boot_id
    if identifier is not None:
        select = Selection(boot_id=identifier)

    if cmd_args.all:
        if select:
            select.allow_null_profile = True
        else:
            select = Selection(allow_null=True)

    return _generic_list_cmd(cmd_args, select, opts, _verbose_entry_fields,
                             print_entries)


def _edit_cmd(cmd_args, select, opts, identifier):
    """Edit entry command handler.

        Attempt to edit an existing boot entry. The ``boot_id`` of
        the supplied ``Selection`` object is used to select the entry
        to edit. Any set entry values supplied in ``cmd_args`` will be
        used to modify the edited entry.

        :param cmd_args: Command line arguments for the command
        :param select: The ``boot_id`` of the entry to edit
        :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.boot_id
    if identifier is not None:
        select = Selection(boot_id=identifier)

    if not select or select.is_null():
        print("edit requires selection criteria")
        return 1

    title = cmd_args.title
    root_device = cmd_args.root_device
    lvm_root_lv = cmd_args.root_lv
    subvol = cmd_args.btrfs_subvolume
    (btrfs_subvol_path, btrfs_subvol_id) = _subvol_from_arg(subvol)

    # Discard all selection criteria but boot_id.
    select = Selection(boot_id=select.boot_id)
    try:
        be = _find_one_entry(select)
    except ValueError as e:
        print(e)
        return 1

    version = cmd_args.version or be.version
    machine_id = cmd_args.machine_id or be.machine_id

    profile = _find_profile(cmd_args, version, machine_id, "edit")

    add_opts = cmd_args.add_opts
    del_opts = cmd_args.del_opts

    arch = cmd_args.architecture

    try:
        be = edit_entry(selection=select, title=title, version=version,
                        machine_id=machine_id, root_device=root_device,
                        lvm_root_lv=lvm_root_lv,
                        btrfs_subvol_path=btrfs_subvol_path,
                        btrfs_subvol_id=btrfs_subvol_id, profile=profile,
                        add_opts=add_opts, del_opts=del_opts,
                        architecture=arch, expand=cmd_args.expand_variables)
    except ValueError as e:
        print(e)
        return 1

    # Command-line overrides take precedence over any overridden values
    # in the edited BootEntry.
    _apply_profile_overrides(be, cmd_args)

    try:
        be.write_entry(expand=cmd_args.expand_variables)
        __write_legacy()
    except Exception as e:
        if cmd_args.debug:
            raise
        print(e)
        return 1

    print("Edited entry, boot_id now: %s" % be.disp_boot_id)
    print(_str_indent(str(be), 2))
    return 0


def _create_profile_cmd(cmd_args, select, opts, identifier):
    """Create profile command handler.
        Attempt to create a new OS profile using the arguments
        supplied in ``cmd_args`` and return the command status
        as an integer.

        :param cmd_args: Command line arguments for the command
        :param select: Unused
        :returns: integer status code returned from ``main()``
    """
    if identifier is not None:
        print("profile create does not accept <identifier>")
        return 1

    if cmd_args.options:
        print("Invalid argument for profile create: --options")
        return 1

    if cmd_args.os_release or cmd_args.from_host:
        name = None
        short_name = None
        version = None
        version_id = None
        release = cmd_args.os_release or "/etc/os-release"
    else:
        if not cmd_args.name:
            print("profile create requires --name")
            return 1
        else:
            name = cmd_args.name

        if not cmd_args.short_name:
            print("profile create requires --short-name")
            return 1
        else:
            short_name = cmd_args.short_name

        if not cmd_args.os_version:
            print("profile create requires --os-version")
            return 1
        else:
            version = cmd_args.os_version

        if not cmd_args.os_version_id:
            print("profile create requires --os-version-id")
            return 1
        else:
            version_id = cmd_args.os_version_id
        release = None

    try:
        osp = create_profile(name, short_name, version, version_id,
                             uname_pattern=cmd_args.uname_pattern,
                             kernel_pattern=cmd_args.kernel_pattern,
                             initramfs_pattern=cmd_args.initramfs_pattern,
                             root_opts_lvm2=cmd_args.lvm_opts,
                             root_opts_btrfs=cmd_args.btrfs_opts,
                             options=cmd_args.os_options,
                             optional_keys=cmd_args.optional_keys,
                             profile_file=release)
    except ValueError as e:
        print(e)
        return 1
    print("Created profile with os_id %s:" % osp.disp_os_id)
    print(_str_indent(str(osp), 2))
    return 0


def _delete_profile_cmd(cmd_args, select, opts, identifier):
    """Delete profile command handler.

        Attempt to delete OS profiles matching the selection criteria
        given in ``select``.

        :param cmd_args: Command line arguments for the command
        :param select: Selection criteria for the profiles to remove
        :returns: integer status code returned from ``main()``
    """
    # If an os_id is given as a command line argument treat it as
    # a single OsProfile to delete and ignore any other criteria.
    identifier = identifier or cmd_args.profile
    if identifier is not None:
        select = Selection(os_id=identifier)

    if not select or select.is_null():
        print("profile delete requires selection criteria")
        return 1

    if cmd_args.options:
        fields = cmd_args.options
    elif cmd_args.verbose:
        fields = _verbose_profile_fields
    else:
        fields = None

    try:
        if cmd_args.verbose:
            print_profiles(select, output_fields=fields,
                           sort_keys=cmd_args.sort)
        nr = delete_profiles(select)
    except (ValueError, IndexError) as e:
        print(e)
        return 1
    print("Deleted %d profile%s" % (nr, "s" if nr > 1 else ""))
    return 0


def _clone_profile_cmd(cmd_args, select, opts, identifier):
    """Clone profile command handler.

        Attempt to create a new OS profile by cloning an existing
        profile. The ``os_id`` of the supplied ``Selection`` object
        is used to select the profile to clone. Any set profile values
        supplied in ``cmd_args`` will be used to modify the newly
        cloned profile.

        :param cmd_args: Command line arguments for the command
        :param select: The ``os_id`` to clone
        :returns: integer status code returned from ``main()``
    """
    name = cmd_args.name
    short_name = cmd_args.short_name
    version = cmd_args.os_version
    version_id = cmd_args.os_version_id
    uname_pattern = cmd_args.uname_pattern
    kernel_pattern = cmd_args.kernel_pattern
    initramfs_pattern = cmd_args.initramfs_pattern
    root_opts_lvm2 = cmd_args.lvm_opts
    root_opts_btrfs = cmd_args.btrfs_opts
    options = cmd_args.os_options

    identifier = identifier or cmd_args.profile
    if identifier is not None:
        select = Selection(os_id=identifier)

    if not select or select.is_null():
        print("profile delete requires selection criteria")
        return 1

    # Discard all selection criteria but os_id.
    select = Selection(os_id=select.os_id)

    try:
        osp = clone_profile(selection=select, name=name, short_name=short_name,
                            version=version, version_id=version_id,
                            uname_pattern=uname_pattern,
                            kernel_pattern=kernel_pattern,
                            initramfs_pattern=initramfs_pattern,
                            root_opts_lvm2=root_opts_lvm2,
                            root_opts_btrfs=root_opts_btrfs, options=options)

    except ValueError as e:
        print(e)
        return 1
    print("Cloned profile with os_id %s as %s:" %
          (select.os_id, osp.disp_os_id))
    print(_str_indent(str(osp), 2))
    return 0


def _generic_show_cmd(select, find_fn, fmt, get_data):
    """Generic show command handler.

        Show the objects returned by calling `find_fn` with selection
        criteria `select`, using the format string `fmt`, and the data
        tuple returned by calling `get_data` for each object.

        :param select: Selection() object with search criteria.
        :param find_fn: A find_*() function accepting Selection.
        :param fmt: A Python format string.
        :param get_data: A function returning a tuple of data values
                         satisfying the format string `fmt`.
    """
    try:
        objs = find_fn(select)
    except ValueError as e:
        print(e)
        return 1

    first = True
    for obj in objs:
        ws = "" if first else "\n"
        print(ws + fmt % get_data(obj))
        first = False
    return 0


def _show_profile_cmd(cmd_args, select, opts, identifier):
    """Show profile command handler.

        Show the OS profiles that match the given selection criteria in
        human readable form. Each matching profile is printed as a
        multi-line record, with like attributes grouped together on a
        line.

        :param cmd_args: Command line arguments for the command
        :param select: Selection criteria for the profiles to show.
        :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.profile
    if identifier is not None:
        select = Selection(os_id=identifier)

    def _profile_get_data(osp):
        return (osp.disp_os_id, _str_indent(str(osp), 2))

    fmt = "OS Profile (os_id=%s)\n%s"
    return _generic_show_cmd(select, find_profiles, fmt, _profile_get_data)


def _list_profile_cmd(cmd_args, select, opts, identifier):
    """List profile command handler.

        List the OS profiles that match the given selection criteria as
        a tabular report, with one profile per row.

        :param cmd_args: Command line arguments for the command
        :param select: Selection criteria fore the profiles to list
        :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.profile
    if identifier is not None:
        select = Selection(os_id=identifier)

    return _generic_list_cmd(cmd_args, select, opts, _verbose_profile_fields,
                             print_profiles)


def _edit_profile_cmd(cmd_args, select, opts, identifier):
    """Edit profile command handler.

        Attempt to edit an existing OS profile. The ``os_id`` of the
        supplied ``Selection`` object is used to select the profile to
        edit. Any set entry values supplied in ``cmd_args`` will be used
        to modify the edited profile.

        :param cmd_args: Command line arguments for the command
        :param select: The ``os_id`` of the profile to edit
        :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.profile
    if identifier is not None:
        select = Selection(os_id=identifier)

    id_keys = (cmd_args.name, cmd_args.short_name,
               cmd_args.version, cmd_args.os_version_id)

    if cmd_args.options:
        print("Invalid argument for profile edit: --options")
        return 1

    if any(id_keys):
        print("Cannot edit name, short_name, version, or version_id:\n"
              "Use 'clone --profile OS_ID'.")
        return 1

    uname_pattern = cmd_args.uname_pattern
    kernel_pattern = cmd_args.kernel_pattern
    initramfs_pattern = cmd_args.initramfs_pattern
    root_opts_lvm2 = cmd_args.lvm_opts
    root_opts_btrfs = cmd_args.btrfs_opts
    options = cmd_args.os_options
    optional_keys = cmd_args.optional_keys

    try:
        osp = edit_profile(selection=select, uname_pattern=uname_pattern,
                           kernel_pattern=kernel_pattern,
                           initramfs_pattern=initramfs_pattern,
                           root_opts_lvm2=root_opts_lvm2,
                           root_opts_btrfs=root_opts_btrfs, options=options,
                           optional_keys=optional_keys)
    except ValueError as e:
        print(e)
        return 1

    print("Edited profile:")
    print(_str_indent(str(osp), 2))
    return 0


def _create_host_cmd(cmd_args, select, opts, identifier):
    """Create host profile command handler.

        Attempt to create a new host profile using the arguments
        supplied in ``cmd_args`` and return the command status
        as an integer.

        :param cmd_args: Command line arguments for the command
        :param select: Unused
        :returns: integer status code returned from ``main()``
    """
    if identifier is not None:
        print("host profile create does not accept <identifier>")
        return 1

    host_name = cmd_args.host_name or platform.node()

    if not host_name:
        print("host profile create requires a valid host name to be set"
              "or --host-name")
        return 1

    if not cmd_args.machine_id:
        # Use host machine-id by default
        machine_id = _get_machine_id()
        if not machine_id:
            print("Could not determine machine_id")
            return 1
    else:
        machine_id = cmd_args.machine_id

    if not cmd_args.profile:
        print("host profile create requires --profile")
        return 1
    else:
        os_id = cmd_args.profile

    try:
        hp = create_host(machine_id=machine_id, os_id=os_id,
                         host_name=host_name, label=cmd_args.label,
                         kernel_pattern=cmd_args.kernel_pattern,
                         initramfs_pattern=cmd_args.initramfs_pattern,
                         root_opts_lvm2=cmd_args.lvm_opts,
                         root_opts_btrfs=cmd_args.btrfs_opts,
                         add_opts=cmd_args.add_opts,
                         del_opts=cmd_args.del_opts,
                         options=cmd_args.os_options)
    except ValueError as e:
        print(e)
        return 1
    print("Created host profile with host_id %s:" % hp.disp_host_id)
    print(_str_indent(str(hp), 2))
    return 0


def _delete_host_cmd(cmd_args, select, opts, identifier):
    """Delete host profile command handler.

        Attempt to delete host profiles matching the selection criteria
        given in ``select``.

        :param cmd_args: Command line arguments for the command
        :param select: Selection criteria for the profiles to remove
        :returns: integer status code returned from ``main()``
    """
    # If a host_id is given as a command line argument treat it as
    # a single HostProfile to delete and ignore any other criteria.
    identifier = identifier or cmd_args.host_id
    if identifier:
        select = Selection(host_id=identifier)

    if not select or select.is_null():
        print("host profile delete requires selection criteria")
        return 1

    if cmd_args.options:
        fields = cmd_args.options
    elif cmd_args.verbose:
        fields = _verbose_host_fields
    else:
        fields = _default_host_fields

    try:
        if cmd_args.verbose:
            print_hosts(select, output_fields=fields,
                        sort_keys=cmd_args.sort)
        nr = delete_hosts(select)
    except (ValueError, IndexError) as e:
        print(e)
        return 1
    print("Deleted %d profile%s" % (nr, "s" if nr > 1 else ""))
    return 0


def _clone_host_cmd(cmd_args, select, opts, identifier):
    """Clone host profile command handler.

        Attempt to create a new host profile by cloning an existing
        profile. The ``host_id`` of the supplied ``Selection`` object
        is used to select the profile to clone. Any set profile values
        supplied in ``cmd_args`` will be used to modify the newly
        cloned profile.

        :param cmd_args: Command line arguments for the command
        :param select: The ``host_id`` to clone
        :returns: integer status code returned from ``main()``
    """
    host_name = cmd_args.host_name
    os_id = cmd_args.profile

    identifier = identifier or cmd_args.host_id
    if identifier is not None:
        select = Selection(host_id=identifier)

    # For clone allow the machine_id to be inherited from the original
    # HostProfile unless the user has given an explicit argument.
    machine_id = cmd_args.machine_id

    # Cloning to modify only the host label is permitted
    label = cmd_args.label

    # Discard all selection criteria but host_id.
    select = Selection(host_id=select.host_id)

    try:
        hp = clone_host(selection=select, machine_id=machine_id,
                        label=label, os_id=os_id, host_name=host_name,
                        kernel_pattern=cmd_args.kernel_pattern,
                        initramfs_pattern=cmd_args.initramfs_pattern,
                        root_opts_lvm2=cmd_args.lvm_opts,
                        root_opts_btrfs=cmd_args.btrfs_opts,
                        add_opts=cmd_args.add_opts,
                        del_opts=cmd_args.del_opts,
                        options=cmd_args.os_options)
    except ValueError as e:
        print(e)
        return 1
    print("Cloned profile with host_id %s as %s:" %
          (select.host_id, hp.disp_host_id))
    print(_str_indent(str(hp), 2))
    return 0


def _show_host_cmd(cmd_args, select, opts, identifier):
    """Show host profile command handler.

        Show the host profiles that match the given selection criteria
        in human readable form. Each matching profile is printed as a
        multi-line record, with like attributes grouped together on a
        line.

        :param cmd_args: Command line arguments for the command
        :param select: Selection criteria for the profiles to show.
        :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.host_id
    if identifier is not None:
        select = Selection(host_id=identifier)

    def _host_get_data(hp):
        return (hp.disp_host_id, _str_indent(str(hp), 2))

    fmt = "Host Profile (host_id=%s)\n%s"

    return _generic_show_cmd(select, find_host_profiles, fmt, _host_get_data)


def _list_host_cmd(cmd_args, select, opts, identifier):
    """List host profile command handler.

        List the host profiles that match the given selection criteria
        as a tabular report, with one profile per row.

        :param cmd_args: Command line arguments for the command
        :param select: Selection criteria fore the profiles to list
        :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.host_id
    if identifier is not None:
        select = Selection(host_id=identifier)

    return _generic_list_cmd(cmd_args, select, opts, _verbose_host_fields,
                             print_hosts)


def _edit_host_cmd(cmd_args, select, opts, identifier):
    """Edit profile command handler.

        Attempt to edit an existing host profile. The ``host_id`` of the
        supplied ``Selection`` object is used to select the profile to
        edit. Any set entry values supplied in ``cmd_args`` will be used
        to modify the edited profile.

        :param cmd_args: Command line arguments for the command
        :param select: The ``host_id`` of the profile to edit
        :returns: integer status code returned from ``main()``
    """
    identifier = identifier or cmd_args.host_id
    if identifier is not None:
        select = Selection(host_id=identifier)

    if cmd_args.options:
        print("Invalid argument for 'host edit': --options\n"
              "To modify profile options template use --os-options")
        return 1

    machine_id = cmd_args.machine_id
    os_id = cmd_args.profile
    host_name = cmd_args.host_name
    kernel_pattern = cmd_args.kernel_pattern
    initramfs_pattern = cmd_args.initramfs_pattern
    root_opts_lvm2 = cmd_args.lvm_opts
    root_opts_btrfs = cmd_args.btrfs_opts
    add_opts = cmd_args.add_opts
    del_opts = cmd_args.del_opts
    options = cmd_args.os_options

    try:
        hp = edit_host(selection=select,
                       machine_id=machine_id, os_id=os_id,
                       host_name=host_name, kernel_pattern=kernel_pattern,
                       initramfs_pattern=initramfs_pattern,
                       root_opts_lvm2=root_opts_lvm2,
                       root_opts_btrfs=root_opts_btrfs,
                       add_opts=add_opts, del_opts=del_opts, options=options)
    except ValueError as e:
        print(e)
        return 1

    print("Edited profile:")
    print(_str_indent(str(hp), 2))
    return 0


def _show_cache_cmd(cmd_args, select, opts, identifier):
    """Show cache command handler.

        Show the cache entries that match the given selection criteria
        in human readable form. Each matching entry is printed as a
        multi-line record.

        :param cmd_args: Command line arguments for the command
        :param select: Selection criteria for the profiles to show.
        :returns: integer status code returned from ``main()``
    """
    if identifier is not None:
        select = Selection(path=identifier)

    try:
        find_fn = find_cache_images if cmd_args.verbose else find_cache_paths
        ces = find_fn(selection=select)
    except ValueError as e:
        print(e)
        return 1
    first = True
    for ce in ces:
        ws = "" if first else "\n"
        ce_str = str(ce)
        ce_str = _str_indent(ce_str, 2)
        print("%sCache Entry (img_id=%s)\n%s" % (ws, ce.disp_img_id, ce_str))
        first = False
    return 0


def _list_cache_cmd(cmd_args, select, opts, identifier):
    """List cache command handler.

        List the cache entries that match the given selection criteria
        as a tabular report, with one entry per row.

        :param cmd_args: Command line arguments for the command
        :param select: Selection criteria fore the profiles to list
        :returns: integer status code returned from ``main()``
    """
    print_fn = print_cache if not cmd_args.verbose else print_cache_images
    return _generic_list_cmd(cmd_args, select, opts, _verbose_cache_fields,
                             print_fn)


def _write_legacy_cmd(cmd_args, select, opts, identifier):
    if identifier:
        print("write legacy does not accept a boot_id")
        return 1
    config = get_boom_config()
    try:
        clear_legacy_loader()
        write_legacy_loader(selection=select, loader=config.legacy_format)
    except Exception as e:
        print(e)
        return 1


def _clear_legacy_cmd(cmd_args, select, opts, identifier):
    """Remove all boom entries from the legacy bootloader configuration.

        :param cmd_args: Command line arguments for the command
        :returns: integer status code returned from ``main()``
    """
    if identifier:
        print("write legacy does not accept a boot_id")
        return 1

    try:
        clear_legacy_loader()
    except BoomLegacyFormatError as e:
        print(e)
        return 1


def _show_legacy_cmd(cmd_args, select, opts, identifier):
    # FIXME: args
    config = get_boom_config()
    show_legacy(selection=select, loader=config.legacy_format)


CREATE_CMD = "create"
DELETE_CMD = "delete"
CLONE_CMD = "clone"
CLEAR_CMD = "clear"
SHOW_CMD = "show"
LIST_CMD = "list"
EDIT_CMD = "edit"

WRITE_CMD = "write"

ENTRY_TYPE = "entry"
PROFILE_TYPE = "profile"
HOST_TYPE = "host"
CACHE_TYPE = "cache"
LEGACY_TYPE = "legacy"

_boom_entry_commands = [
    (CREATE_CMD, _create_cmd),
    (DELETE_CMD, _delete_cmd),
    (CLONE_CMD, _clone_cmd),
    (SHOW_CMD, _show_cmd),
    (LIST_CMD, _list_cmd),
    (EDIT_CMD, _edit_cmd)
]

_boom_profile_commands = [
    (CREATE_CMD, _create_profile_cmd),
    (DELETE_CMD, _delete_profile_cmd),
    (CLONE_CMD, _clone_profile_cmd),
    (SHOW_CMD, _show_profile_cmd),
    (LIST_CMD, _list_profile_cmd),
    (EDIT_CMD, _edit_profile_cmd)
]

_boom_host_commands = [
    (CREATE_CMD, _create_host_cmd),
    (DELETE_CMD, _delete_host_cmd),
    (CLONE_CMD, _clone_host_cmd),
    (SHOW_CMD, _show_host_cmd),
    (LIST_CMD, _list_host_cmd),
    (EDIT_CMD, _edit_host_cmd)
]

_boom_cache_commands = [
    (SHOW_CMD, _show_cache_cmd),
    (LIST_CMD, _list_cache_cmd)
]

_boom_legacy_commands = [
    (WRITE_CMD, _write_legacy_cmd),
    (CLEAR_CMD, _clear_legacy_cmd),
    (SHOW_CMD, _show_legacy_cmd)
]

_boom_command_types = [
    (ENTRY_TYPE, _boom_entry_commands),
    (PROFILE_TYPE, _boom_profile_commands),
    (HOST_TYPE, _boom_host_commands),
    (CACHE_TYPE, _boom_cache_commands),
    (LEGACY_TYPE, _boom_legacy_commands)
]


def _get_command_verbs():
    """Return the set of command verbs known to boom.
    """
    verbs = set()
    all_cmds = [_boom_entry_commands, _boom_profile_commands,
                _boom_host_commands, _boom_cache_commands,
                _boom_legacy_commands]
    for cmd_list in all_cmds:
        verbs.update([cmd[0] for cmd in cmd_list])
    return verbs


def _id_from_arg(cmd_args, cmdtype, cmd):
    if cmd == CREATE_CMD:
        if cmdtype == ENTRY_TYPE:
            return cmd_args.boot_id
        if cmdtype == PROFILE_TYPE:
            return cmd_args.profile
    else:
        if cmd_args.identifier:
            return cmd_args.identifier
        if cmdtype == ENTRY_TYPE:
            return cmd_args.boot_id
        if cmdtype == PROFILE_TYPE:
            return cmd_args.profile
    return None


def _match_cmd_type(cmdtype):
    for t in _boom_command_types:
        if t[0].startswith(cmdtype):
            return t
    return None


def _match_command(cmd, cmds):
    for c in cmds:
        if cmd == c[0]:
            return c
    return None


def _report_opts_from_args(cmd_args):
    opts = BoomReportOpts()

    if not cmd_args:
        return opts

    if cmd_args.rows:
        opts.columns_as_rows = True

    if cmd_args.separator:
        opts.separator = cmd_args.separator

    if cmd_args.name_prefixes:
        opts.field_name_prefix = "BOOM_"
        opts.unquoted = False
        opts.aligned = False

    if cmd_args.no_headings:
        opts.headings = False

    return opts


def get_uts_release():
    return uname()[2]


def setup_logging(cmd_args):
    global _console_handler
    level = _default_log_level
    if cmd_args.verbose and cmd_args.verbose > 1:
        level = logging.DEBUG
    elif cmd_args.verbose and cmd_args.verbose > 0:
        level = logging.INFO
    # Configure the package-level logger
    boom_log = logging.getLogger("boom")
    formatter = logging.Formatter('%(levelname)s - %(message)s')
    boom_log.setLevel(level)
    _console_handler = logging.StreamHandler()
    _console_handler.setLevel(level)
    _console_handler.setFormatter(formatter)
    boom_log.addHandler(_console_handler)


def shutdown_logging():
    logging.shutdown()


def set_debug(debug_arg):
    if not debug_arg:
        return

    mask_map = {
        "profile": BOOM_DEBUG_PROFILE,
        "entry": BOOM_DEBUG_ENTRY,
        "report": BOOM_DEBUG_REPORT,
        "command": BOOM_DEBUG_COMMAND,
        "all": BOOM_DEBUG_ALL
    }

    mask = 0
    for name in debug_arg.split(','):
        if name not in mask_map:
            raise ValueError("Unknown debug mask: %s" % name)
        mask |= mask_map[name]
    set_debug_mask(mask)


def main(args):
    global _boom_entry_commands, _boom_profile_commands, _boom_command_types
    parser = ArgumentParser(prog=basename(args[0]),
                            description="Boom Boot Manager")

    # Default type is boot entry.
    if len(args) > 1 and _match_command(args[1], _boom_entry_commands):
        args.insert(1, "entry")

    parser.add_argument("type", metavar="[TYPE]", type=str,
                        help="The command type to run: profile or entry",
                        action="store")
    parser.add_argument("command", metavar="COMMAND", type=str, action="store",
                        help="The command to run: create, delete, list, edit, "
                        "clone, show")
    parser.add_argument("identifier", metavar="ID", type=str, action="store",
                        help="An optional profile or boot identifier to "
                        "operate on", nargs="?", default=None)
    parser.add_argument("-a", "--add-opts", "--addopts", metavar="OPTIONS",
                        help="Additional kernel options to append", type=str)
    parser.add_argument("--all", action="store_true",
                        help="Include entries with no valid profile")
    parser.add_argument("--architecture", metavar="ARCH", default=None,
                        help="An optional BLS architecture string", type=str)
    parser.add_argument("--backup", action="store_true",
                        help="Back up boot images used by entry")
    parser.add_argument("-b", "--boot-id", "--bootid", metavar="BOOT_ID",
                        type=str, help="The BOOT_ID of a boom boot entry")
    parser.add_argument("--boot-dir", "--bootdir", metavar="PATH", type=str,
                        help="The path to the /boot file system")
    parser.add_argument("-B", "--btrfs-subvolume", "--btrfssubvolume",
                        metavar="SUBVOL", type=str,
                        help="The path or ID of a BTRFS subvolume")
    parser.add_argument("--btrfs-opts", "--btrfsopts", metavar="OPTS",
                        type=str, help="A template option string for BTRFS "
                        "devices")
    parser.add_argument("-c", "--config", metavar="FILE", type=str,
                        help="Path to a boom configuration file", default=None)
    parser.add_argument("-d", "--del-opts", "--delopts", metavar="OPTIONS",
                        help="List of kernel options to be dropped", type=str)
    parser.add_argument("--debug", metavar="DEBUGOPTS", type=str,
                        help="A list of debug options to enable")
    parser.add_argument("-e", "--efi", metavar="IMG", type=str,
                        help="An executable EFI application image")
    parser.add_argument("-E", "--expand-variables", action="store_true",
                        help="Expand bootloader environment variables")
    parser.add_argument("--grub-arg", metavar="ARGS", type=str,
                        help="Pass additional arguments to the Grub2 loader")
    parser.add_argument("--grub-class", metavar="CLASS", type=str,
                        help="Specify a Grub2 class for this entry")
    parser.add_argument("--grub-users", metavar="USERS", type=str,
                        help="Grub user list for password protection")
    parser.add_argument("--grub-id", metavar="ID", type=str, dest="id",
                        help="Grub menu identifier string")
    parser.add_argument("-H", "--from-host", "--fromhost",
                        help="Take os-release values from the running host",
                        action="store_true")
    parser.add_argument("-P", "--host-profile", metavar="PROFILE", type=str,
                        help="A boom host profile identifier", dest="host_id")
    parser.add_argument("--host-name", metavar="HOSTNAME", type=str,
                        help="The host name associated with a host profile")
    parser.add_argument("-i", "--initrd", metavar="IMG", type=str,
                        help="A linux initrd image path")
    parser.add_argument("-k", "--kernel-pattern", "--kernelpattern",
                        metavar="PATTERN", type=str,
                        help="A pattern for generating kernel paths")
    parser.add_argument("--label", metavar="LABEL", type=str,
                        help="Host profile label")
    parser.add_argument("-l", "--linux", metavar="IMG", type=str,
                        help="A linux kernel image path")
    parser.add_argument("-L", "--root-lv", "--rootlv", metavar="LV", type=str,
                        help="An LVM2 root logical volume")
    parser.add_argument("--lvm-opts", "--lvmopts", metavar="OPTS", type=str,
                        help="A template option string for LVM2 devices")
    parser.add_argument("-m", "--machine-id", "--machineid",
                        metavar="MACHINE_ID", type=str,
                        help="The machine_id value to use")
    parser.add_argument("-n", "--name", metavar="OSNAME", type=str,
                        help="The name of a Boom OsProfile")
    parser.add_argument("--name-prefixes", "--nameprefixes",
                        help="Add a prefix to report field names",
                        action='store_true'),
    parser.add_argument("--no-headings", "--noheadings", action='store_true',
                        help="Suppress output of report headings"),
    parser.add_argument("--no-dev", "--nodev", action='store_true',
                        help="Disable checks for a valid root device")
    parser.add_argument("--optional-keys", metavar="KEYS", type=str,
                        help="Optional keys allows by this operating system "
                        "profile")
    parser.add_argument("-o", "--options", metavar="FIELDS", type=str,
                        help="Specify which fields to display")
    parser.add_argument("--os-version", "--osversion", metavar="OSVERSION",
                        help="A Boom OsProfile version", type=str)
    parser.add_argument("-O", "--sort", metavar="SORTFIELDS", type=str,
                        help="Specify which fields to sort by")
    parser.add_argument("-I", "--os-version-id", "--osversionid",
                        help="A Boom OsProfile version ID",
                        metavar="OSVERSIONID", type=str)
    parser.add_argument("--os-options", "--osoptions", metavar="OPTIONS",
                        help="A Boom OsProfile options template", type=str)
    parser.add_argument("--os-release", "--osrelease", metavar="OSRELEASE",
                        help="Path to an os-release file", type=str)
    parser.add_argument("-p", "--profile", metavar="OS_ID", type=str,
                        help="A boom operating system profile "
                        "identifier")
    parser.add_argument("-r", "--root-device", "--rootdevice", metavar="ROOT",
                        help="The root device for a boot entry", type=str)
    parser.add_argument("-R", "--initramfs-pattern", "--initramfspattern",
                        type=str, help="A pattern for generating initramfs "
                        "paths", metavar="PATTERN")
    parser.add_argument("--rows", action="store_true",
                        help="Output report columnes as rows")
    parser.add_argument("--separator", metavar="SEP", type=str,
                        help="Report field separator")
    parser.add_argument("-s", "--short-name", "--shortname",
                        help="A Boom OsProfile short name",
                        metavar="OSSHORTNAME", type=str)
    parser.add_argument("-t", "--title", metavar="TITLE", type=str,
                        help="The title of a boom boot entry")
    parser.add_argument("-u", "--uname-pattern", "--unamepattern",
                        help="A Boom OsProfile uname pattern",
                        metavar="PATTERN", type=str)
    parser.add_argument("-V", "--verbose", help="Enable verbose ouput",
                        action="count")
    parser.add_argument("-v", "--version", metavar="VERSION", type=str,
                        help="The kernel version of a boom "
                        "boot entry")

    if len(args) < 3:
        parser.print_usage()
        print("Too few arguments: %s" % " ".join(args[1:]))
        return 1

    cmd_types = [cmdtype[0] for cmdtype in _boom_command_types]
    cmd_verbs = _get_command_verbs()

    type_arg = args[1] if len(args) > 1 else ""
    cmd_arg = args[2] if len(args) > 2 else ""

    if type_arg not in cmd_types or cmd_arg not in cmd_verbs:
        parser.print_usage()
        print("Unknown command: %s %s" % (type_arg, cmd_arg))
        return 1

    try:
        cmd_args = parser.parse_args(args=args[1:])
    except SystemExit as e:
        return e.code

    try:
        set_debug(cmd_args.debug)
    except ValueError as e:
        print(e)
        return 1
    setup_logging(cmd_args)
    cmd_type = _match_cmd_type(cmd_args.type)
    if not cmd_type:
        print("Unknown command type: %s" % cmd_args.type)
        return 1

    if cmd_args.boot_dir or BOOM_BOOT_PATH_ENV in environ:
        boot_path = cmd_args.boot_dir or environ[BOOM_BOOT_PATH_ENV]
        if not isabs(boot_path):
            boot_path = join(getcwd(), boot_path)
        set_boot_path(boot_path)
        set_boom_config_path("boom.conf")

    if cmd_args.config:
        set_boom_config_path(cmd_args.config)

    bc = load_boom_config()

    if not path_exists(get_boom_path()):
        _log_error("Configuration directory '%s' not found." %
                   get_boom_path())
        return 1

    if not path_exists(get_boom_config_path()):
        _log_error("Configuration file '%s' not found." %
                   get_boom_config_path())
        return 1

    if not path_exists(boom_profiles_path()):
        _log_error("OS profile configuration path '%s' not found." %
                   boom_profiles_path())
        return 1

    if not path_exists(boom_host_profiles_path()):
        _log_error("Host profile configuration path '%s' not found." %
                   boom_host_profiles_path())
        return 1

    if not path_exists(boom_entries_path()):
        _log_error("Boot loader entries directory '%s' not found." %
                   boom_entries_path())
        return 1

    if cmd_type[0] == CACHE_TYPE and not bc.cache_enable:
        _log_error("Boot image cache disabled (config.cache_enable=False)")
        return 1

    # Parse an LV name from root_lv and re-write the root_device if found
    if cmd_args.root_lv:
        try:
            root_lv = _canonicalize_lv_name(cmd_args.root_lv)
        except ValueError as e:
            print(e)
            print("Invalid logical volume name: '%s'" % cmd_args.root_lv)
            return 1
        root_device = DEV_PATTERN % root_lv
        if cmd_args.root_device and cmd_args.root_device != root_device:
            print("Options --root-lv %s and --root-device %s do not match." %
                  (root_lv, root_device))
            return 1
        cmd_args.root_device = root_device
        cmd_args.root_lv = root_lv

    # Try parsing an LV name from root_device and rewrite root_lv if found
    elif cmd_args.root_device:
        try:
            root_lv = _canonicalize_lv_name(cmd_args.root_device)
            cmd_args.root_lv = root_lv
        except ValueError:
            # No valid VG name
            pass

    type_cmds = cmd_type[1]
    command = _match_command(cmd_args.command, type_cmds)
    if not command:
        print("Unknown command: %s %s" % (cmd_type[0], cmd_args.command))
        return 1

    if cmd_args.backup and not bc.cache_enable:
        print("--backup specified but cache disabled"
              " (config.cache_enable=False)")
        return 1
    elif cmd_args.backup:
        load_cache()

    select = Selection.from_cmd_args(cmd_args)
    opts = _report_opts_from_args(cmd_args)
    identifier = _id_from_arg(cmd_args, cmd_type[0], command[0])
    status = 1

    if cmd_args.debug:
        status = command[1](cmd_args, select, opts, identifier)
    else:
        try:
            status = command[1](cmd_args, select, opts, identifier)
        except Exception as e:
            _log_error("Command failed: %s" % e)

    if bc.cache_enable and bc.cache_auto_clean:
        try:
            clean_cache()
        except Exception as e:
            _log_error("Could not clean boot image cache: %s" % e)

    shutdown_logging()
    return status


__all__ = [
    # BootEntry manipulation
    'create_entry', 'delete_entries', 'clone_entry', 'edit_entry',
    'list_entries', 'print_entries',

    # OsProfile manipulation
    'create_profile', 'delete_profiles', 'clone_profile', 'edit_profile',
    'list_profiles', 'print_profiles',

    # HostProfile manipulation
    'create_host', 'delete_hosts', 'clone_host', 'edit_host',
    'list_hosts', 'print_hosts',

    # Cache manipulation
    'print_cache', 'print_cache_images',
    'I_NONE', 'I_CACHE', 'I_BACKUP'
]

# vim: set et ts=4 sw=4 :
