[virt-tools-list] [virt-install PATCH 3/3] virt-install: Add --unattended support

Fabiano Fidêncio fidencio at redhat.com
Wed Feb 6 13:21:31 UTC 2019


This patch introduces the preliminary support for unattended
installations using virt-install.

The command-line added is something like:
--unattended
profile="jeos|desktop",user-password="...",admin-password="...",product-key="..."

In case no profile is passed, "jeos" is used as the default one.
In case no user or admin password is passed, those are going to be
automatically generated and printed to the user.
The "product-key" argument is needed for Windows(es) installations.

The --unattended argument works with:
- --cdrom, thus the user can pass a media to be used;
- --location, thus the user can pass a tree/media to be used;
- with no arguments, then the tree is taken from osinfo-db for that
distro;

Using --os-variant is required for when using --unattended.

There are still a few things missing in this path that will require some
more work to be done, as:
- Windowses:
  - Handle more than one script (usually needed for installing drivers);
  - Support a second disk containing drivers;
- General:
  - Have a proper check and help about which OSes support which kind of
  unattended installations;
  - Have a proper way to detect the keyboard layout being used by the
  user (currently we just guess it by the user's system language);

Signed-off-by: Fabiano Fidêncio <fidencio at redhat.com>
---
 virt-install                   |  46 +++++++++++--
 virtinst/cli.py                |  31 ++++++++-
 virtinst/installer.py          | 116 +++++++++++++++++++++++++++++++++
 virtinst/installertreemedia.py |  17 +++++
 virtinst/osdict.py             | 106 ++++++++++++++++++++++++++++++
 5 files changed, 311 insertions(+), 5 deletions(-)

diff --git a/virt-install b/virt-install
index a7cf0c2d..af465df3 100755
--- a/virt-install
+++ b/virt-install
@@ -355,6 +355,11 @@ def validate_required_options(options, guest, installer):
             _("An install method must be specified\n(%(methods)s)") %
             {"methods": install_methods})
 
+    if options.unattended:
+        if not options.distro_variant:
+            msg += "\n" + (
+                _("An OS variant must be specified\n"))
+
     if msg:
         fail(msg)
 
@@ -398,6 +403,10 @@ def check_option_collisions(options, guest, installer):
         fail(_("--initrd-inject only works if specified with --location.") +
              cdrom_err)
 
+    if options.unattended:
+        if options.initrd_inject or options.extra_args:
+            fail(_("--unattended does not support --initrd-inject nor --extra-args."))
+
 
 def _show_nographics_warnings(options, guest, installer):
     if guest.devices.graphics:
@@ -472,8 +481,21 @@ def build_installer(options, guest):
     location_kernel = None
     location_initrd = None
     install_bootdev = None
+    network_install = False
 
     has_installer = True
+    if options.unattended:
+        if not guest.osinfo.is_windows():
+            if options.cdrom:
+                options.location = options.cdrom
+                options.cdrom = None
+            elif options.location:
+                if not options.location.endswith(".iso"):
+                    network_install = True
+            else:
+                options.location = guest.osinfo.get_url(guest.os.arch)
+                network_install = True
+
     if options.location:
         (location,
          location_kernel,
@@ -502,10 +524,21 @@ def build_installer(options, guest):
             install_bootdev=install_bootdev)
     if cdrom and options.livecd:
         installer.livecd = True
-    if options.extra_args:
-        installer.extra_args = options.extra_args
-    if options.initrd_inject:
-        installer.set_initrd_injections(options.initrd_inject)
+
+    if network_install:
+        installer.networkinstall = True
+
+    if options.unattended:
+        unattended_data = cli.parse_unattended(options.unattended)
+        options.unattended = None
+
+        installer.set_unattended(guest, options.distro_variant, options.name, unattended_data)
+    else:
+        if options.extra_args:
+            installer.extra_args = options.extra_args
+        if options.initrd_inject:
+            installer.set_initrd_injections(options.initrd_inject)
+
     if options.autostart:
         installer.autostart = True
 
@@ -531,6 +564,9 @@ def build_guest_instance(conn, options):
         guest.os.machine = options.machine
 
     guest.set_capabilities_defaults()
+    if options.unattended:
+        guest.set_os_name(options.distro_variant)
+
     installer = build_installer(options, guest)
     if installer:
         set_distro_variant(options, guest, installer)
@@ -787,6 +823,8 @@ def parse_args():
                            "booted from --location"))
     insg.add_argument("--initrd-inject", action="append",
                     help=_("Add given file to root of initrd from --location"))
+    insg.add_argument("--unattended", const="profile=jeos", default="", nargs='?',
+                    help=_("Unattended installation"))
 
     # Takes a URL and just prints to stdout the detected distro name
     insg.add_argument("--test-media-detection", help=argparse.SUPPRESS)
diff --git a/virtinst/cli.py b/virtinst/cli.py
index 52a876e1..ea36b015 100644
--- a/virtinst/cli.py
+++ b/virtinst/cli.py
@@ -462,7 +462,7 @@ def get_meter():
 ###########################
 
 def _get_completer_parsers():
-    return VIRT_PARSERS + [ParseCLICheck, ParserLocation]
+    return VIRT_PARSERS + [ParseCLICheck, ParserLocation, ParseCLIUnattended]
 
 
 def _virtparser_completer(prefix, **kwargs):
@@ -1417,6 +1417,35 @@ class VirtCLIParser(metaclass=InitClass):
         """Do nothing callback"""
 
 
+########################
+# --unattended parsing #
+########################
+
+class ParseCLIUnattended(VirtCLIParser):
+    cli_arg_name = "unattended"
+
+    @classmethod
+    def __init_class__(cls, **kwargs):
+        VirtCLIParser.__init_class__(**kwargs)
+        cls.add_arg("profile", "profile")
+        cls.add_arg("admin_password", "admin-password")
+        cls.add_arg("user_password", "user-password")
+        cls.add_arg("product_key", "product-key")
+
+def parse_unattended(unattended):
+    class UnattendedData:
+        def __init__(self):
+            self.profile = "jeos"
+            self.admin_password = None
+            self.user_password = None
+            self.product_key = None
+
+    ret = UnattendedData()
+    parser = ParseCLIUnattended(None, unattended)
+    parser.parse(ret)
+    return ret
+
+
 ###################
 # --check parsing #
 ###################
diff --git a/virtinst/installer.py b/virtinst/installer.py
index e3ccfa42..31772527 100644
--- a/virtinst/installer.py
+++ b/virtinst/installer.py
@@ -8,6 +8,7 @@
 
 import os
 import logging
+import subprocess
 
 import libvirt
 
@@ -17,6 +18,11 @@ from .osdict import OSDB
 from .installertreemedia import InstallerTreeMedia
 from . import util
 
+import gi
+gi.require_version('Libosinfo', '1.0')
+from gi.repository import Libosinfo as libosinfo
+from gi.repository import Gio as gio
+
 
 class Installer(object):
     """
@@ -39,6 +45,7 @@ class Installer(object):
             location_kernel=None, location_initrd=None):
         self.conn = conn
 
+        self.networkinstall = False
         self.livecd = False
         self.extra_args = []
 
@@ -49,6 +56,8 @@ class Installer(object):
         self._install_kernel = None
         self._install_initrd = None
         self._install_cdrom_device = None
+        self._unattended_floppy_device = None
+        self._unattended_files = []
         self._defaults_are_set = False
 
         if location_kernel or location_initrd:
@@ -111,6 +120,28 @@ class Installer(object):
         self._install_cdrom_device.path = None
         self._install_cdrom_device.sync_path_props()
 
+    def _add_unattended_floppy_device(self, guest, location):
+        if self._unattended_floppy_device:
+            return
+        dev = DeviceDisk(self.conn)
+        dev.device = dev.DEVICE_FLOPPY
+        dev.path = location
+        dev.sync_path_props()
+        dev.validate()
+        self._unattended_floppy_device = dev
+        guest.add_device(self._unattended_floppy_device)
+
+    def _remove_unattended_floppy_device(self, guest):
+        if not self._unattended_floppy_device:
+            return
+
+        self._unattended_floppy_device.path = None
+        self._unattended_floppy_device.sync_path_props()
+
+    def _cleanup_unattended_files(self):
+        for f in self._unattended_files:
+            os.unlink(f)
+
     def _build_boot_order(self, guest, bootdev):
         bootorder = [bootdev]
 
@@ -168,6 +199,8 @@ class Installer(object):
     def _cleanup(self, guest):
         if self._treemedia:
             self._treemedia.cleanup(guest)
+        else:
+            self._cleanup_unattended_files()
 
     def _get_postinstall_bootdev(self, guest):
         if self.cdrom and self.livecd:
@@ -272,6 +305,88 @@ class Installer(object):
         logging.debug("installer.detect_distro returned=%s", ret)
         return ret
 
+    def set_unattended(self, guest, variant, name, unattended_data):
+        """
+        Set up the unattended installation based on the OS variant of the
+        guest. In case some error happens, a warning will be printed and
+        no unattended installation will happen.
+        """
+        # Those bits about installation source and injection method are
+        # mostly needed for Linuxes and they become a no-op on Windows(es)
+        # Guests.
+        installation_source = libosinfo.InstallScriptInstallationSource.MEDIA \
+            if not self.networkinstall \
+            else libosinfo.InstallScriptInstallationSource.NETWORK
+
+        injection_method = libosinfo.InstallScriptInjectionMethod.FLOPPY \
+            if guest.osinfo.is_windows() \
+            else libosinfo.InstallScriptInjectionMethod.INITRD
+
+        script = guest.osinfo.get_install_script(unattended_data.profile)
+
+        supported_injection_methods = script.get_injection_methods()
+
+        if (injection_method & supported_injection_methods == 0):
+             logging.warning(
+                 _("%s does not support unattended installation for the %s profile "
+                   "when using initrd as injection method."), name, profile)
+             return
+
+        script.set_preferred_injection_method(injection_method)
+        script.set_installation_source(installation_source)
+
+        config = guest.osinfo.get_install_script_config(script, guest.os.arch, name)
+
+        if unattended_data.admin_password and not unattended_data.user_password:
+            unattended_data.user_password = unattended_data.admin_password
+
+        if unattended_data.user_password and not unattended_data.admin_password:
+            unattended_data.admin_password = unattended_data.user_password
+
+        if unattended_data.user_password or unattended_data.admin_password:
+            config.set_admin_password(unattended_data.admin_password)
+            config.set_user_password(unattended_data.user_password)
+        else:
+            # "desktop" profiles will always have a user set up. The same
+            # happen for Windows(es) installations, even the "jeos" ones.
+            if unattended_data.profile == "desktop" or guest.osinfo.is_windows():
+                print(_("User password: %s" % config.get_user_password()))
+            print(_("Admin password: %s" % config.get_admin_password()))
+
+        if self._treemedia:
+            cmdline = self._treemedia.generate_install_script(guest, script, config)
+            self.extra_args = [cmdline]
+        else:
+            # This branch targets Windows(es) unattended installations, where
+            # we have to:
+            # - set the target disk accordingly;
+            # - set the product key
+            # - create a floppy drive with a MS-DOS filesystem
+            # - copy the installation files to the drive
+            config.set_target_disk("C")
+            config.set_reg_product_key(unattended_data.product_key)
+
+            scratch = os.path.join(util.get_cache_dir(), "unattended")
+            if not os.path.exists(scratch):
+                os.makedirs(scratch, 0o751)
+
+            img = os.path.join(scratch, name + "-unattended.img")
+            self._unattended_files.append(img)
+
+            guest.osinfo.generate_install_script_output(script, config, gio.File.new_for_path(scratch))
+            path = os.path.join(scratch, script.get_expected_filename())
+            self._unattended_files.append(path)
+
+            cmd = ["mkfs.msdos", "-C", img, "1440"]
+            logging.debug("Running mkisofs: %s", cmd)
+            output = subprocess.check_output(cmd)
+
+            cmd = ["mcopy", "-i", img, path, "::"]
+            logging.debug("Running mcopy: %s", cmd)
+            output = subprocess.check_output(cmd)
+
+            self._add_unattended_floppy_device(guest, img)
+
 
     ##########################
     # guest install handling #
@@ -297,6 +412,7 @@ class Installer(object):
             return ret
         finally:
             self._remove_install_cdrom_media(guest)
+            self._remove_unattended_floppy_device(guest)
             self._finish_get_install_xml(guest, data)
 
     def _build_xml(self, guest):
diff --git a/virtinst/installertreemedia.py b/virtinst/installertreemedia.py
index d11d2798..66ed1691 100644
--- a/virtinst/installertreemedia.py
+++ b/virtinst/installertreemedia.py
@@ -7,6 +7,9 @@
 import logging
 import os
 
+import gi
+from gi.repository import Gio as gio
+
 from . import urldetect
 from . import urlfetcher
 from . import util
@@ -166,6 +169,20 @@ class InstallerTreeMedia(object):
     # Public API #
     ##############
 
+    def generate_install_script(self, guest, script, config):
+        scratch = os.path.join(util.get_cache_dir(), "unattended")
+        if not os.path.exists(scratch):
+            os.makedirs(scratch, 0o751)
+
+        guest.osinfo.generate_install_script_output(script, config, gio.File.new_for_path(scratch))
+
+        path = os.path.join(scratch, script.get_expected_filename())
+        self.initrd_injections = [path]
+
+        self._tmpfiles.append(path)
+
+        return guest.osinfo.generate_install_script_cmdline(script, config)
+
     def prepare(self, guest, meter):
         fetcher = self._get_fetcher(guest, meter)
         return self._prepare_kernel_url(guest, fetcher)
diff --git a/virtinst/osdict.py b/virtinst/osdict.py
index 72edc4a6..755bbae7 100644
--- a/virtinst/osdict.py
+++ b/virtinst/osdict.py
@@ -13,6 +13,7 @@ import re
 import gi
 gi.require_version('Libosinfo', '1.0')
 from gi.repository import Libosinfo as libosinfo
+from gi.repository import GLib as glib, Gio as gio
 
 
 ###################
@@ -477,5 +478,110 @@ class _OsVariant(object):
 
         return None
 
+    def get_url(self, arch):
+        if not self._os:
+            return None
+
+        tree_filter = libosinfo.Filter()
+        tree_filter.add_constraint(libosinfo.TREE_PROP_ARCHITECTURE, arch)
+        tree_list = self._os.get_tree_list()
+        if tree_list.get_length() < 1:
+            logging.warning(
+                _("%s does not support unattended tree installation"),
+                self.name)
+            return None
+        filtered_tree_list = tree_list.new_filtered(tree_filter)
+        if filtered_tree_list.get_length() < 1:
+            logging.warning(
+                _("%s does not support unattened tree installation for the "
+                  "%s architecture", self.name, arch))
+            return None
+        return filtered_tree_list.get_nth(0).get_url()
+
+    def get_install_script(self, profile):
+        if not self._os:
+            return None
+
+        script_list = self._os.get_install_script_list()
+        if script_list.get_length == 0:
+            logging.warning(
+                _("%s does not support unattended installation."), self.name)
+
+        profile_filter = libosinfo.Filter()
+        profile_filter.add_constraint(libosinfo.INSTALL_SCRIPT_PROP_PROFILE, profile)
+
+        filtered_script_list = script_list.new_filtered(profile_filter)
+        if filtered_script_list.get_length() == 0:
+            logging.warning(
+                _("%s does not support unattended installation for the %s profile."),
+                self.name, profile)
+            return None
+
+        return filtered_script_list.get_nth(0)
+
+    def get_install_script_config(self, script, arch, hostname):
+
+        def get_timezone():
+            TZ_FILE = "/etc/localtime"
+            localtime = gio.File.new_for_path(TZ_FILE)
+            if not localtime.query_exists():
+                return None
+            info = localtime.query_info(gio.FILE_ATTRIBUTE_STANDARD_SYMLINK_TARGET, gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS)
+            if not info:
+                return None
+            target = info.get_symlink_target()
+            if not target:
+                return None
+            tokens = target.split("zoneinfo/")
+            if not tokens or len(tokens) < 2:
+                return None
+            return tokens[1]
+
+        def get_language():
+            names = glib.get_language_names()
+            if not names or len(names) < 2:
+                return None
+
+            return names[1]
+
+        config = libosinfo.InstallConfig()
+
+        config.set_user_login(glib.get_user_name())
+        config.set_user_realname(glib.get_user_name())
+        config.set_user_password(config.get_admin_password())
+
+        config.set_target_disk("/dev/vda" if self.supports_virtiodisk() else "/dev/sda")
+        config.set_hardware_arch(arch)
+        config.set_hostname(hostname)
+
+        timezone = get_timezone()
+        if timezone:
+            config.set_l10n_timezone(timezone)
+        else:
+            logging.warning(
+                _("us timezone will be used for the unattended installation"))
+
+        language = get_language()
+        if language:
+            config.set_l10n_language(language)
+            config.set_l10n_keyboard(language)
+        else:
+            logging.warning(
+                _("us language and keyboard layout will be used for the "
+                  "unattended installation"))
+
+        return config
+
+    def generate_install_script_output(self, script, config, output_dir):
+        if not self._os:
+            return None
+
+        script.generate_output(self._os, config, output_dir)
+
+    def generate_install_script_cmdline(self, script, config):
+        if not self._os:
+            return None
+
+        return script.generate_command_line(self._os, config)
 
 OSDB = _OSDB()
-- 
2.20.1




More information about the virt-tools-list mailing list