Refactor cloud-init support and VM Salt config seeding

Missing package dependancies added.

A missing "config" parameter for qemu-nbd based seeding
method added.

A new seeding method utilising Cloud-init added.
The qemu-nbd based method is still a default method
for backward compatibility.

To enable cloud-init, set the "seed" parameter on
a cluster or node level to "cloud-init".
To disable seeding, set this parameter to "false".
Setting this parameter to "true" will default to
the "qemu-nbd" method.

Salt Minion config file will be created automatically
and may be overrided via cluster or node level
metadata:

  salt:
    control:
      cluster:
        mycluster:
          seed: cloud-init
          cloud_init:
            user_data:
              salt_minion:
                conf:
                  master: 10.1.1.1

or for qemu-nbd case:

  salt:
    control:
      cluster:
        mycluster:
          seed: true
          config:
            host: 10.1.1.1

That may be useful when Salt Master has two IPs in
different networks and one of the networks isn't accessible
from a VM at the moment it's created. Setting a reachable
Salt master IP from metadata helps avoid potential problems.

Also, a liitle optimization has been done to parse/dump
an libvirt XML only once while modifying it.

Change-Id: I091cf409cb43ba2d0a18eaf2a08c11e88d0334e2
Closes-Bug: PROD-22191
diff --git a/_modules/virtng.py b/_modules/virtng.py
index 2c6bc9d..2da9d36 100644
--- a/_modules/virtng.py
+++ b/_modules/virtng.py
@@ -9,6 +9,7 @@
 
 # Import python libs
 from __future__ import absolute_import
+import collections
 import copy
 import os
 import re
@@ -20,12 +21,13 @@
 
 # Import third party libs
 import yaml
-import json
 import jinja2
 import jinja2.exceptions
 import salt.ext.six as six
 from salt.ext.six.moves import StringIO as _StringIO  # pylint: disable=import-error
+from salt.utils.odict import OrderedDict
 from xml.dom import minidom
+from xml.etree import ElementTree
 try:
     import libvirt  # pylint: disable=import-error
     HAS_ALL_IMPORTS = True
@@ -253,8 +255,12 @@
             context['console'] = True
 
     context['disks'] = {}
+    context['cdrom'] = []
     for i, disk in enumerate(diskp):
         for disk_name, args in disk.items():
+            if args.get('device', 'disk') == 'cdrom':
+                context['cdrom'].append(args)
+                continue
             context['disks'][disk_name] = {}
             fn_ = '{0}.{1}'.format(disk_name, args['format'])
             context['disks'][disk_name]['file_name'] = fn_
@@ -282,8 +288,23 @@
         log.error('Could not load template {0}'.format(fn_))
         return ''
 
-    return template.render(**context)
+    xml = template.render(**context)
 
+    # Add cdrom devices separately because a current template doesn't support them.
+    if context['cdrom']:
+        xml_doc = ElementTree.fromstring(xml)
+        xml_devs = xml_doc.find('.//devices')
+        cdrom_xml_tmpl = """<disk type='file' device='cdrom'>
+          <driver name='{driver_name}' type='{driver_type}'/>
+          <source file='{filename}'/>
+          <target dev='{dev}' bus='{bus}'/>
+          <readonly/>
+        </disk>"""
+        for disk in context['cdrom']:
+            cdrom_elem = ElementTree.fromstring(cdrom_xml_tmpl.format(**disk))
+            xml_devs.append(cdrom_elem)
+        xml = ElementTree.tostring(xml_doc)
+    return xml
 
 def _gen_vol_xml(vmname,
                  diskname,
@@ -552,6 +573,10 @@
 
     rng = rng or {'backend':'/dev/urandom'}
     hypervisor = __salt__['config.get']('libvirt:hypervisor', hypervisor)
+    if kwargs.get('seed') not in (False, True, None, 'qemu-nbd', 'cloud-init'):
+        log.warning(
+            "The seeding method '{0}' is not supported".format(kwargs.get('seed'))
+        )
 
     nicp = _nic_profile(nic, hypervisor, **kwargs)
 
@@ -566,9 +591,6 @@
             diskp[0][disk_name]['image'] = image
 
     # Create multiple disks, empty or from specified images.
-    cloud_init = None
-    cfg_drive  = None
-
     for disk in diskp:
         log.debug("Creating disk for VM [ {0} ]: {1}".format(name, disk))
 
@@ -628,40 +650,14 @@
                     except (IOError, OSError) as e:
                         raise CommandExecutionError('problem while copying image. {0} - {1}'.format(args['image'], e))
 
-                    if kwargs.get('seed'):
-                        seed_cmd   = kwargs.get('seed_cmd', 'seedng.apply')
-                        cloud_init = kwargs.get('cloud_init', None)
-                        master     = __salt__['config.option']('master')
-                        cfg_drive  = os.path.join(img_dir,'config-2.iso')
+                    if kwargs.get('seed') in (True, 'qemu-nbd'):
+                        install = kwargs.get('install', True)
+                        seed_cmd = kwargs.get('seed_cmd', 'seedng.apply')
 
-                        if cloud_init:
-                          _tmp         = name.split('.')
-
-                          try:
-                            user_data  = json.dumps(cloud_init["user_data"])
-                          except:
-                            user_data  = None
-
-                          try:
-                            network_data = json.dumps(cloud_init["network_data"])
-                          except:
-                            network_data = None
-
-                          __salt__["cfgdrive.generate"](
-                            dst          = cfg_drive,
-                            hostname     = _tmp.pop(0),
-                            domainname   = '.'.join(_tmp),
-                            user_data    = user_data,
-                            network_data = network_data,
-                            saltconfig   = { "salt_minion": { "conf": { "master": master, "id": name } } }
-                          )
-                        else:
-                          __salt__[seed_cmd](
-                            path      = img_dest,
-                            id_       = name,
-                            config    = kwargs.get('config'),
-                            install   = kwargs.get('install', True)
-                          )
+                        __salt__[seed_cmd](img_dest,
+                                           id_=name,
+                                           config=kwargs.get('config'),
+                                           install=install)
                 else:
                     # Create empty disk
                     try:
@@ -684,55 +680,99 @@
                 raise SaltInvocationError('Unsupported hypervisor when handling disk image: {0}'
                                           .format(hypervisor))
 
+    cloud_init = kwargs.get('cloud_init', {})
+
+    # Seed Salt Minion config via Cloud-init if required.
+    if kwargs.get('seed') == 'cloud-init':
+        # Recursive dict update.
+        def rec_update(d, u):
+            for k, v in u.iteritems():
+                if isinstance(v, collections.Mapping):
+                    d[k] = rec_update(d.get(k, {}), v)
+                else:
+                    d[k] = v
+            return d
+
+        cloud_init_seed = {
+            "user_data": {
+                "salt_minion": {
+                    "conf": {
+                        "master": __salt__['config.option']('master'),
+                        "id": name
+                    }
+                }
+            }
+        }
+        cloud_init = rec_update(cloud_init_seed, cloud_init)
+
+    # Create a cloud-init config drive if defined.
+    if cloud_init:
+        if hypervisor not in ['qemu', 'kvm']:
+            raise SaltInvocationError('Unsupported hypervisor when '
+                                      'handling Cloud-Init disk '
+                                      'image: {0}'.format(hypervisor))
+        cfg_drive = os.path.join(img_dir, 'config-2.iso')
+        vm_hostname, vm_domainname = name.split('.', 1)
+
+        def OrderedDict_to_dict(instance):
+            if isinstance(instance, basestring):
+                return instance
+            elif isinstance(instance, collections.Sequence):
+                return map(OrderedDict_to_dict, instance)
+            elif isinstance(instance, collections.Mapping):
+                if isinstance(instance, OrderedDict):
+                    instance = dict(instance)
+                for k, v in instance.iteritems():
+                    instance[k] = OrderedDict_to_dict(v)
+                return instance
+            else:
+                return instance
+
+        # Yaml.dump dumps OrderedDict in the way to be incompatible with
+        # Cloud-init, hence all OrderedDicts have to be converted to dict first.
+        user_data = OrderedDict_to_dict(cloud_init.get('user_data', None))
+
+        __salt__["cfgdrive.generate"](
+            dst=cfg_drive,
+            hostname=vm_hostname,
+            domainname=vm_domainname,
+            user_data=user_data,
+            network_data=cloud_init.get('network_data', None),
+        )
+        diskp.append({
+            'config_2': {
+                'device': 'cdrom',
+                'driver_name': 'qemu',
+                'driver_type': 'raw',
+                'dev': 'hdc',
+                'bus': 'ide',
+                'filename': cfg_drive
+            }
+        })
+
     xml = _gen_xml(name, cpu, mem, diskp, nicp, hypervisor, **kwargs)
 
-    if cloud_init and cfg_drive:
-      xml_doc = minidom.parseString(xml)
-      iso_xml = xml_doc.createElement("disk")
-      iso_xml.setAttribute("type", "file")
-      iso_xml.setAttribute("device", "cdrom")
-      iso_xml.appendChild(xml_doc.createElement("readonly"))
-      driver = xml_doc.createElement("driver")
-      driver.setAttribute("name", "qemu")
-      driver.setAttribute("type", "raw")
-      target = xml_doc.createElement("target")
-      target.setAttribute("dev", "hdc")
-      target.setAttribute("bus", "ide")
-      source = xml_doc.createElement("source")
-      source.setAttribute("file", cfg_drive)
-      iso_xml.appendChild(driver)
-      iso_xml.appendChild(target)
-      iso_xml.appendChild(source)
-      xml_doc.getElementsByTagName("domain")[0].getElementsByTagName("devices")[0].appendChild(iso_xml)
-      xml = xml_doc.toxml()
-
     # TODO: Remove this code and refactor module, when salt-common would have updated libvirt_domain.jinja template
+    xml_doc = minidom.parseString(xml)
     if cpuset:
-        xml_doc = minidom.parseString(xml)
         xml_doc.getElementsByTagName("vcpu")[0].setAttribute('cpuset', cpuset)
-        xml = xml_doc.toxml()
 
     # TODO: Remove this code and refactor module, when salt-common would have updated libvirt_domain.jinja template
     if cpu_mode:
-        xml_doc = minidom.parseString(xml)
         cpu_xml = xml_doc.createElement("cpu")
         cpu_xml.setAttribute('mode', cpu_mode)
         xml_doc.getElementsByTagName("domain")[0].appendChild(cpu_xml)
-        xml = xml_doc.toxml()
 
     # TODO: Remove this code and refactor module, when salt-common would have updated libvirt_domain.jinja template
     if machine:
-        xml_doc = minidom.parseString(xml)
         os_xml = xml_doc.getElementsByTagName("domain")[0].getElementsByTagName("os")[0]
         os_xml.getElementsByTagName("type")[0].setAttribute('machine', machine)
-        xml = xml_doc.toxml()
 
     # TODO: Remove this code and refactor module, when salt-common would have updated libvirt_domain.jinja template
     if loader and 'path' not in loader:
         log.info('`path` is a required property of `loader`, and cannot be found. Skipping loader configuration')
         loader = None
     elif loader:
-        xml_doc = minidom.parseString(xml)
         loader_xml = xml_doc.createElement("loader")
         for key, val in loader.items():
             if key == 'path':
@@ -741,25 +781,21 @@
         loader_path_xml = xml_doc.createTextNode(loader['path'])
         loader_xml.appendChild(loader_path_xml)
         xml_doc.getElementsByTagName("domain")[0].getElementsByTagName("os")[0].appendChild(loader_xml)
-        xml = xml_doc.toxml()
 
     # TODO: Remove this code and refactor module, when salt-common would have updated libvirt_domain.jinja template
     for _nic in nicp:
         if _nic['virtualport']:
-            xml_doc = minidom.parseString(xml)
             interfaces = xml_doc.getElementsByTagName("domain")[0].getElementsByTagName("devices")[0].getElementsByTagName("interface")
             for interface in interfaces:
                 if interface.getElementsByTagName('mac')[0].getAttribute('address').lower() == _nic['mac'].lower():
                     vport_xml = xml_doc.createElement("virtualport")
                     vport_xml.setAttribute("type", _nic['virtualport']['type'])
                     interface.appendChild(vport_xml)
-            xml = xml_doc.toxml()
 
     # TODO: Remove this code and refactor module, when salt-common would have updated libvirt_domain.jinja template
     if rng:
         rng_model = rng.get('model', 'random')
         rng_backend = rng.get('backend', '/dev/urandom')
-        xml_doc = minidom.parseString(xml)
         rng_xml = xml_doc.createElement("rng")
         rng_xml.setAttribute("model", "virtio")
         backend = xml_doc.createElement("backend")
@@ -774,8 +810,8 @@
             rate.setAttribute("bytes", rng_rate_bytes)
             rng_xml.appendChild(rate)
         xml_doc.getElementsByTagName("domain")[0].getElementsByTagName("devices")[0].appendChild(rng_xml)
-        xml = xml_doc.toxml()
 
+    xml = xml_doc.toxml()
     define_xml_str(xml)
 
     if start: