Source code for COT.deploy_esxi

#!/usr/bin/env python
#
# deploy_esxi.py - Implements "cot deploy ... esxi" command
#
# August 2015, Glenn F. Matthews
# Copyright (c) 2014-2015 the COT project developers.
# See the COPYRIGHT.txt file at the top-level directory of this distribution
#
# This file is part of the Common OVF Tool (COT) project.
# It is subject to the license terms in the LICENSE.txt file found in the
# top-level directory of this distribution. No part of COT, including this
# file, may be copied, modified, propagated, or distributed except
# according to the terms contained in the LICENSE.txt file.

"""Module for deploying VMs to ESXi, vCenter, and vSphere.

**Classes**

.. autosummary::
  :nosignatures:

  COTDeployESXi
  SmarterConnection
  PyVmomiVMReconfigSpec

"""

import getpass
import logging
import os.path
import re
import requests
import shlex
import ssl

from distutils.version import StrictVersion
from pyVmomi import vim
from pyVim.connect import SmartConnection

from .data_validation import ValueUnsupportedError
from .deploy import COTDeploy
from .helpers.ovftool import OVFTool

logger = logging.getLogger(__name__)


[docs]class SmarterConnection(SmartConnection): """A smarter version of pyVmomi's SmartConnection context manager.""" def __init__(self, UI, server, username, password, port=443): """Create a connection to the given server.""" self.UI = UI self.server = server self.username = username self.password = password self.port = port super(SmarterConnection, self).__init__(host=server, user=username, pwd=password, port=port) def __enter__(self): """Establish a connection and use it as the context manager object. Unlike SmartConnection, this lets the user override SSL certificate validation failures and connect anyway. It also produces slightly more meaningful error messages on failure. """ logger.verbose("Establishing connection to {0}:{1}..." .format(self.server, self.port)) try: return super(SmarterConnection, self).__enter__() except vim.fault.HostConnectFault as e: if not re.search("certificate verify failed", e.msg): raise e # Self-signed certificates are pretty common for ESXi servers logger.warning(e.msg) self.UI.confirm_or_die("SSL certificate for {0} is self-signed or " "otherwise not recognized as valid. " "Accept certificate anyway?" .format(self.server)) _create_unverified_context = ssl._create_unverified_context ssl._create_default_https_context = _create_unverified_context return super(SmarterConnection, self).__enter__() except requests.exceptions.ConnectionError as e: # ConnectionError can wrap another internal error; let's unwrap it # so COT can log it more cleanly if e.errno is None: e.errno = e.args[0].args[1].errno e.strerror = ("Error connecting to {0}:{1}: {2}" .format(self.server, self.port, e.args[0].args[1].strerror)) raise def __exit__(self, type, value, trace): """Disconnect from the server.""" super(SmarterConnection, self).__exit__() if type is not None: logger.error("Session failed - {0}".format(value)) # TODO - re-enable SSL certificate validation?
[docs]class PyVmomiVMReconfigSpec: """Context manager for reconfiguring an ESXi VM using PyVmomi.""" def __init__(self, conn, vm_name): """Use the given name to look up a VM using the given connection.""" self.vm = self.get_obj(conn, vim.VirtualMachine, vm_name) assert(self.vm) self.spec = vim.vm.ConfigSpec()
[docs] def get_obj(self, conn, vimtype, name): """Look up an object by name.""" obj = None content = conn.RetrieveContent() container = content.viewManager.CreateContainerView( content.rootFolder, [vimtype], True) for c in container.view: if c.name == name: obj = c break return obj
def __enter__(self): """Use a ConfigSpec as the context manager object.""" return self.spec def __exit__(self, type, value, trace): """If the block exited cleanly, apply the ConfigSpec to the VM.""" # Did we exit cleanly? if type is None: logger.verbose("Reconfiguring VM...") self.vm.ReconfigVM_Task(spec=self.spec)
[docs]class COTDeployESXi(COTDeploy): """Submodule for deploying VMs on ESXi and VMware vCenter/vSphere. Inherited attributes: :attr:`~COTGenericSubmodule.UI`, :attr:`~COTReadOnlySubmodule.package`, :attr:`generic_parser`, :attr:`parser`, :attr:`subparsers`, :attr:`hypervisor`, :attr:`configuration`, :attr:`username`, :attr:`password`, :attr:`power_on`, :attr:`vm_name`, :attr:`network_map` :attr:`serial_connection` Attributes: :attr:`locator`, :attr:`datastore`, :attr:`ovftool_args` """ def __init__(self, UI): """Instantiate this submodule with the given UI.""" super(COTDeployESXi, self).__init__(UI) self.datastore = None """ESXi datastore to deploy to.""" self.host = None """vSphere host to deploy to - set implicitly by self.locator.""" self.server = None """vCenter server or vSphere host - set implicitly by self.locator.""" self._locator = None self._ovftool_args = [] self.ovftool = OVFTool() @property def ovftool_args(self): """List of CLI arguments to pass through to ``ovftool``.""" return list(self._ovftool_args) @ovftool_args.setter def ovftool_args(self, value): # Use shlex to split ovftool_args but respect quoted whitespace self._ovftool_args = shlex.split(value) logger.debug("ovftool_args split to: {0}" .format(self._ovftool_args)) @property def locator(self): """Target vSphere locator.""" return self._locator @locator.setter def locator(self, value): self._locator = value self.server = value.split("/")[0] self.host = os.path.basename(value) logger.debug("locator {0} --> server {1} / host {2}" .format(value, self.server, self.host)) @COTDeploy.serial_connection.setter def serial_connection(self, value): """Override parent property setter to add ESXi validation.""" if len(value) > 4: raise ValueUnsupportedError( 'serial port connection list', value, 'no more than 4 connections (ESXi limitation)') COTDeploy.serial_connection.fset(self, value)
[docs] def ready_to_run(self): """Check whether the module is ready to :meth:`run`. :returns: ``(True, ready_message)`` or ``(False, reason_why_not)`` """ if self.locator is None: return False, "LOCATOR is a mandatory argument" return super(COTDeployESXi, self).ready_to_run()
[docs] def run(self): """Do the actual work of this submodule - deploying to ESXi. :raises InvalidInputError: if :func:`ready_to_run` reports ``False`` """ super(COTDeployESXi, self).run() # ensure user provided proper credentials if self.username is None: self.username = getpass.getuser() if self.password is None: self.password = self.UI.get_password(self.username, self.server) target = ("vi://" + self.username + ":" + self.password + "@" + self.locator) ovftool_args = self.ovftool_args vm = self.vm # If locator is a vCenter locator "<vCenter>/datacenter/host/<host>" # then environment properties will always be used. # Otherwise we may need to help and/or warn the user: if vm.environment_properties and not re.search("/host/", self.locator): if self.ovftool.version < StrictVersion("4.0.0"): self.UI.confirm_or_die( "When deploying an OVF directly to a vSphere target " "using ovftool prior to version 4.0.0, any OVF " "environment properties will not be made available " "to the new guest.\n" "If your guest needs environment properties, please " "either specify a vCenter target locator (such as " "'<vCenter>/<datacenter>/host/<host>') " "or upgrade to ovftool 4.0.0 or later.\n" "Continue deployment without OVF environment?") logger.warning("deploying directly to vSphere and ovftool " "version is too low to add injectOvfEnv " "option. OVF environment properties will " "be ignored.") elif not self.power_on: self.UI.confirm_or_die( "When deploying an OVF directly to a vSphere target, " "OVF environment properties can only be made available to " "the new guest if the guest is to be powered on " "immediately.\n" "If your guest needs environment properties, please " "either specify the '--power-on'/'-P' option or provide " "a vCenter target locator (such as " "'<vCenter>/<datacenter>/host/<host>') " "instead of a vSphere target.\n" "Continue deployment without OVF environment?") logger.warning("deploying directly to vSphere but " "--power-on is not requested. OVF " "environment properties will be ignored.") else: logger.debug("Since ovftool version is sufficient and user " "requested --power-on, adding ovftool args to " "ensure passthru of OVF environment to guest.") ovftool_args.append("--X:injectOvfEnv") if self.configuration is not None: ovftool_args.append("--deploymentOption=" + self.configuration) # Get the number of serial ports in the OVA. # ovftool does not create serial ports when deploying to a VM, # so we'll have to fix this up manually later. serial_count = vm.get_serial_count([self.configuration]) serial_count = serial_count[self.configuration] # pass network settings on to ovftool if self.network_map is not None: for nm in self.network_map: ovftool_args.append("--net:" + nm) # check if user entered a name for the VM if self.vm_name is None: self.vm_name = os.path.splitext(os.path.basename(self.package))[0] ovftool_args.append("--name=" + self.vm_name) # tell ovftool to power on the VM # TODO - if serial port fixup (below) is implemented, # do not power on VM until after serial ports are added. if self.power_on: ovftool_args.append("--powerOn") # specify target datastore if self.datastore is not None: ovftool_args.append("--datastore=" + self.datastore) # add package and target to the list ovftool_args.append(self.package) ovftool_args.append(target) logger.debug("Final args to pass to OVFtool: {0}".format(ovftool_args)) logger.info("Deploying VM...") self.ovftool.call_helper(ovftool_args, capture_output=False) # Post-fix of serial ports (ovftool will not implement) if serial_count > 0: # add serial ports as requested self.fixup_serial_ports(serial_count) # power on VM if power_on
[docs] def fixup_serial_ports(self, serial_count): """Use PyVmomi to create and configure serial ports for the new VM.""" if serial_count > len(self.serial_connection): logger.warning("No serial connectivity information is " "available for {0} serial port(s) - " "they will not be created or configured." .format(serial_count - len(self.serial_connection))) if len(self.serial_connection) == 0: return logger.info("Fixing up serial ports...") with SmarterConnection(self.UI, self.server, self.username, self.password) as conn: logger.verbose("Connection established") with PyVmomiVMReconfigSpec(conn, self.vm_name) as spec: logger.verbose("Spec created") spec.deviceChange = [] # TODO - import backing info from OVF environment # TODO - prompt user for values if not in OVF and not specified for s in self.serial_connection: logger.verbose(s) serial_spec = vim.vm.device.VirtualDeviceSpec() serial_spec.operation = 'add' serial_port = vim.vm.device.VirtualSerialPort() serial_port.yieldOnPoll = True if s.kind == 'device': backing = serial_port.DeviceBackingInfo() logger.info("Serial port will use host device {0}" .format(s.value)) backing.deviceName = s.value elif s.kind == 'telnet' or s.kind == 'tcp': backing = serial_port.URIBackingInfo() backing.serviceURI = s.kind + '://' + s.value if 'server' in s.options: logger.info("Serial port will be a {0} server " "at {1}" .format(s.kind, s.value)) backing.direction = 'server' else: logger.info("Serial port will connect via {0} " "to {1}. Use ',server' option if a " "server is desired instead of client." .format(s.kind, s.value)) backing.direction = 'client' else: # TODO - support other backing types raise NotImplementedError("no support yet for '{0}'" .format(s.kind)) serial_port.backing = backing serial_spec.device = serial_port spec.deviceChange.append(serial_spec) logger.info("Done with serial port fixup")
[docs] def create_subparser(self, parent, storage): """Add subparser for the CLI of this submodule. This will create the shared :attr:`~COTDeploy.parser` under :attr:`parent`, then create our own sub-subparser under :attr:`~COTDeploy.subparsers`. :param object parent: Subparser grouping object returned by :func:`ArgumentParser.add_subparsers` :param dict storage: Dict of { 'label': subparser } to be updated with subparser(s) created, if any. """ super(COTDeployESXi, self).create_subparser(parent, storage) import argparse # Create 'cot deploy ... esxi' parser p = self.subparsers.add_parser( 'esxi', parents=[self.generic_parser], usage=self.UI.fill_usage("deploy PACKAGE esxi", [ "LOCATOR [-u USERNAME] [-p PASSWORD] [-c CONFIGURATION] " "[-n VM_NAME] [-P] [-N OVF1=HOST1 [-N OVF2=HOST2 ...]] " "[-S CONN1 [-S CONN2 ...]] [-d DATASTORE] [-o=OVFTOOL_ARGS]", ]), formatter_class=argparse.RawDescriptionHelpFormatter, help="Deploy to ESXi, vSphere, or vCenter", description="Deploy OVF/OVA to ESXi/vCenter/vSphere hypervisor", epilog=self.UI.fill_examples([ ("Deploy to vSphere/ESXi server 192.0.2.100 with credentials" " admin/admin, creating a VM named 'test_vm' from foo.ova.", 'cot deploy foo.ova esxi 192.0.2.100 -u admin -p admin' ' -n test_vm'), ("Deploy to vSphere/ESXi server 192.0.2.100, with username" " admin (prompting the user to input a password at runtime)," " creating a VM based on profile '1CPU-2.5GB' in foo.ova," " and creating the serial port as a telnet server listening" " on port 10022 of the host", 'cot deploy foo.ova esxi 192.0.2.100 -u admin -c 1CPU-2.5GB' ' -S telnet://:10022,server'), ("Deploy to vSphere server 192.0.2.1 which belongs to" " datacenter 'mydc' on vCenter server 192.0.2.100, and map" " the two NIC networks to vSwitches. Note that in this case" " -u specifies the vCenter login username.", 'cot deploy foo.ova esxi "192.0.2.100/mydc/host/192.0.2.1"' ' -u administrator -N "GigabitEthernet1=VM Network"' ' -N "GigabitEthernet2=myvswitch"'), ("Deploy with passthrough arguments to ovftool.", 'cot deploy foo.ova esxi 192.0.2.100 -u admin -p password' ' --ovftool-args="--overwrite --acceptAllEulas"') ])) # ovftool uses '-ds' as shorthand for '--datastore', so let's allow it. p.add_argument("-d", "-ds", "--datastore", help="ESXi datastore to use for the new VM") p.add_argument("-o", "--ovftool-args", help="Quoted string describing additional CLI " """parameters to pass through to "ovftool". Examples:""" """ -o="--foo", --ovftool-args="--foo --bar" """) p.add_argument("LOCATOR", help="vSphere target locator. Examples: " '"192.0.2.100" (deploy directly to ESXi server), ' '"192.0.2.101/mydatacenter/host/192.0.2.100" ' '(deploy via vCenter server)') p.set_defaults(instance=self) storage['deploy-esxi'] = p