starter-kit/bots/machine/machine_core/machine_virtual.py
2019-08-25 15:21:02 +00:00

686 lines
24 KiB
Python

# This file is part of Cockpit.
#
# Copyright (C) 2013 Red Hat, Inc.
#
# Cockpit is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2.1 of the License, or
# (at your option) any later version.
#
# Cockpit is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
import contextlib
import errno
import fcntl
import libvirt
import libvirt_qemu
import os
import string
import socket
import subprocess
import tempfile
import sys
import time
from .exceptions import Failure, RepeatableFailure
from .machine import Machine
from .constants import TEST_DIR, BOTS_DIR
from .directories import get_temp_dir
MEMORY_MB = 1024
# The Atomic variants can't build their own packages, so we build in
# their non-Atomic siblings. For example, fedora-atomic is built
# in fedora-29
def get_build_image(image):
(test_os, unused) = os.path.splitext(os.path.basename(image))
if test_os == "fedora-atomic":
image = "fedora-29"
elif test_os == "rhel-atomic":
image = "rhel-7-7"
elif test_os == "continuous-atomic":
image = "centos-7"
return image
# some tests have suffixes that run the same image in different modes; map a
# test context image to an actual physical image name
def get_test_image(image):
return image.replace("-distropkg", "")
# based on http://stackoverflow.com/a/17753573
# we use this to quieten down calls
@contextlib.contextmanager
def stdchannel_redirected(stdchannel, dest_filename):
"""
A context manager to temporarily redirect stdout or stderr
e.g.:
with stdchannel_redirected(sys.stderr, os.devnull):
noisy_function()
"""
try:
stdchannel.flush()
oldstdchannel = os.dup(stdchannel.fileno())
dest_file = open(dest_filename, 'w')
os.dup2(dest_file.fileno(), stdchannel.fileno())
yield
finally:
if oldstdchannel is not None:
os.dup2(oldstdchannel, stdchannel.fileno())
if dest_file is not None:
dest_file.close()
TEST_CONSOLE_XML="""
<console type='pty'>
<target type='serial' port='0'/>
</console>
"""
TEST_GRAPHICS_XML="""
<video>
<model type='qxl' ram='65536' vram='65536' vgamem='16384' heads='1' primary='yes'/>
<alias name='video0'/>
<address type='pci' domain='0x0000' bus='0x00' slot='0x02' function='0x0'/>
</video>
<graphics type='vnc' autoport='yes' listen='127.0.0.1'>
<listen type='address' address='127.0.0.1'/>
</graphics>
"""
TEST_DOMAIN_XML="""
<domain type='{type}' xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
<name>{label}</name>
{cpu}
<os>
<type arch='{arch}'>hvm</type>
<boot dev='hd'/>
{loader}
</os>
<memory unit='MiB'>{memory_in_mib}</memory>
<currentMemory unit='MiB'>{memory_in_mib}</currentMemory>
<features>
<acpi/>
</features>
<devices>
<disk type='file' snapshot='external'>
<driver name='qemu' type='qcow2'/>
<source file='{drive}'/>
<target dev='vda' bus='{disk}'/>
<serial>ROOT</serial>
</disk>
<controller type='scsi' model='virtio-scsi' index='0' id='hot'/>
{console}
<disk type='file' device='cdrom'>
<source file='{iso}'/>
<target dev='hdb' bus='ide'/>
<readonly/>
</disk>
<rng model='virtio'>
<backend model='random'>/dev/urandom</backend>
</rng>
{bridgedev}
</devices>
<qemu:commandline>
{ethernet}
{redir}
</qemu:commandline>
</domain>
"""
TEST_DISK_XML="""
<disk type='file'>
<driver name='qemu' type='%(type)s'/>
<source file='%(file)s'/>
<serial>%(serial)s</serial>
<address type='drive' controller='0' bus='0' target='2' unit='%(unit)d'/>
<target dev='%(dev)s' bus='scsi'/>
</disk>
"""
TEST_KVM_XML="""
<cpu mode='host-passthrough'/>
<vcpu>{cpus}</vcpu>
"""
# The main network interface which we use to communicate between VMs
TEST_MCAST_XML="""
<qemu:arg value='-netdev'/>
<qemu:arg value='socket,mcast=230.0.0.1:{mcast},id=mcast0'/>
<qemu:arg value='-device'/>
<qemu:arg value='{netdriver},netdev=mcast0,mac={mac},bus=pci.0,addr=0x0f'/>
"""
TEST_BRIDGE_XML="""
<interface type="bridge">
<source bridge="{bridge}"/>
<mac address="{mac}"/>
<model type="{netdriver}"/>
</interface>
"""
# Used to access SSH from the main host into the virtual machines
TEST_REDIR_XML="""
<qemu:arg value='-netdev'/>
<qemu:arg value='user,id=base0,restrict={restrict},net=172.27.0.0/24,hostname={name},{forwards}'/>
<qemu:arg value='-device'/>
<qemu:arg value='{netdriver},netdev=base0,bus=pci.0,addr=0x0e'/>
"""
class VirtNetwork:
def __init__(self, network=None, bridge=None, image="generic"):
self.locked = [ ]
self.bridge = bridge
self.image = image
if network is None:
offset = 0
force = False
else:
offset = network * 100
force = True
# This is a shared port used as the identifier for the socket mcast network
self.network = self._lock(5500 + offset, step=100, force=force)
# An offset for other ports allocated later
self.offset = (self.network - 5500)
# The last machine we allocated
self.last = 0
# Unique hostnet identifiers
self.hostnet = 8
def _lock(self, start, step=1, force=False):
resources = os.path.join(tempfile.gettempdir(), ".cockpit-test-resources")
try:
os.mkdir(resources, 0o755)
except FileExistsError:
pass
for port in range(start, start + (100 * step), step):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
lockpath = os.path.join(resources, "network-{0}".format(port))
try:
lockf = os.open(lockpath, os.O_WRONLY | os.O_CREAT)
fcntl.flock(lockf, fcntl.LOCK_NB | fcntl.LOCK_EX)
sock.bind(("127.0.0.1", port))
self.locked.append(lockf)
except IOError:
if force:
return port
os.close(lockf)
continue
else:
return port
finally:
sock.close()
raise Failure("Couldn't find unique network port number")
# Create resources for an interface, returns address and XML
def interface(self, number=None):
if number is None:
number = self.last + 1
if number > self.last:
self.last = number
mac = self._lock(10000 + self.offset + number) - (10000 + self.offset)
hostnet = self.hostnet
self.hostnet += 1
result = {
"number": self.offset + number,
"mac": '52:54:01:{:02x}:{:02x}:{:02x}'.format((mac >> 16) & 0xff, (mac >> 8) & 0xff, mac & 0xff),
"name": "m{0}.cockpit.lan".format(mac),
"mcast": self.network,
"hostnet": "hostnet{0}".format(hostnet)
}
return result
# Create resources for a host, returns address and XML
def host(self, number=None, restrict=False, isolate=False, forward={ }):
result = self.interface(number)
result["mcast"] = self.network
result["restrict"] = restrict and "on" or "off"
result["forward"] = { "22": 2200, "9090": 9090 }
result["forward"].update(forward)
result["netdriver"] = ("windows" in self.image) and "rtl8139" or "virtio-net-pci"
forwards = []
for remote, local in result["forward"].items():
local = self._lock(int(local) + result["number"])
result["forward"][remote] = "127.0.0.2:{}".format(local)
forwards.append("hostfwd=tcp:{}-:{}".format(result["forward"][remote], remote))
if remote == "22":
result["control"] = result["forward"][remote]
elif remote == "9090":
result["browser"] = result["forward"][remote]
if isolate:
result["bridge"] = ""
result["bridgedev"] = ""
result["ethernet"] = ""
elif self.bridge:
result["bridge"] = self.bridge
result["bridgedev"] = TEST_BRIDGE_XML.format(**result)
result["ethernet"] = ""
else:
result["bridge"] = ""
result["bridgedev"] = ""
result["ethernet"] = TEST_MCAST_XML.format(**result)
result["forwards"] = ",".join(forwards)
result["redir"] = TEST_REDIR_XML.format(**result)
return result
def kill(self):
locked = self.locked
self.locked = [ ]
for x in locked:
os.close(x)
class VirtMachine(Machine):
network = None
memory_mb = None
cpus = None
def __init__(self, image, networking=None, maintain=False, memory_mb=None, cpus=None, graphics=False, **args):
self.maintain = maintain
# Currently all images are run on x86_64. When that changes we will have
# an override file for those images that are not
self.arch = "x86_64"
self.memory_mb = memory_mb or VirtMachine.memory_mb or MEMORY_MB
self.cpus = cpus or VirtMachine.cpus or 1
self.graphics = graphics or "windows" in image
# Set up some temporary networking info if necessary
if networking is None:
networking = VirtNetwork(image=image).host()
# Allocate network information about this machine
self.networking = networking
args["address"] = networking["control"]
args["browser"] = networking["browser"]
self.forward = networking["forward"]
# The path to the image file to load, and parse an image name
if "/" in image:
self.image_file = image = os.path.abspath(image)
else:
self.image_file = os.path.join(TEST_DIR, "images", image)
if not os.path.lexists(self.image_file):
self.image_file = os.path.join(BOTS_DIR, "images", image)
(image, extension) = os.path.splitext(os.path.basename(image))
Machine.__init__(self, image=image, **args)
base_dir = os.path.dirname(BOTS_DIR)
self.run_dir = os.path.join(get_temp_dir(), "run")
self.virt_connection = self._libvirt_connection(hypervisor = "qemu:///session")
self._disks = [ ]
self._domain = None
# init variables needed for running a vm
self._cleanup()
def _libvirt_connection(self, hypervisor, read_only = False):
tries_left = 5
connection = None
if read_only:
open_function = libvirt.openReadOnly
else:
open_function = libvirt.open
while not connection and (tries_left > 0):
try:
connection = open_function(hypervisor)
except:
# wait a bit
time.sleep(1)
pass
tries_left -= 1
if not connection:
# try again, but if an error occurs, don't catch it
connection = open_function(hypervisor)
return connection
def _start_qemu(self):
self._cleanup()
os.makedirs(self.run_dir, 0o750, exist_ok=True)
def execute(*args):
self.message(*args)
return subprocess.check_call(args)
image_to_use = self.image_file
if not self.maintain:
(unused, self._transient_image) = tempfile.mkstemp(suffix='.qcow2', prefix="", dir=self.run_dir)
execute("qemu-img", "create", "-q", "-f", "qcow2",
"-o", "backing_file=%s" % self.image_file, self._transient_image)
image_to_use = self._transient_image
keys = {
"label": self.label,
"image": self.image,
"type": "qemu",
"arch": self.arch,
"cpu": "",
"cpus": self.cpus,
"memory_in_mib": self.memory_mb,
"drive": image_to_use,
"iso": os.path.join(BOTS_DIR, "machine", "cloud-init.iso"),
}
if os.path.exists("/dev/kvm"):
keys["type"] = "kvm"
keys["cpu"] = TEST_KVM_XML.format(**keys)
else:
sys.stderr.write("WARNING: Starting virtual machine with emulation due to missing KVM\n")
sys.stderr.write("WARNING: Machine will run about 10-20 times slower\n")
keys.update(self.networking)
keys["name"] = "{image}-{control}".format(**keys)
# No need or use for redir network on windows
if "windows" in self.image:
keys["disk"] = "ide"
keys["redir"] = ""
else:
keys["disk"] = "virtio"
if self.graphics:
keys["console"] = TEST_GRAPHICS_XML.format(**keys)
else:
keys["console"] = TEST_CONSOLE_XML.format(**keys)
if "windows-10" in self.image:
keys["loader"] = "<loader readonly='yes' type='pflash'>/usr/share/edk2/ovmf/OVMF_CODE.fd</loader>"
else:
keys["loader"] = ""
test_domain_desc = TEST_DOMAIN_XML.format(**keys)
# add the virtual machine
try:
# print >> sys.stderr, test_domain_desc
self._domain = self.virt_connection.createXML(test_domain_desc, libvirt.VIR_DOMAIN_START_AUTODESTROY)
except libvirt.libvirtError as le:
if 'already exists with uuid' in str(le):
raise RepeatableFailure("libvirt domain already exists: " + str(le))
else:
raise
# start virsh console
def qemu_console(self, extra_message=""):
self.message("Started machine {0}".format(self.label))
if self.maintain:
message = "\nWARNING: Uncontrolled shutdown can lead to a corrupted image\n"
else:
message = "\nWARNING: All changes are discarded, the image file won't be changed\n"
message += self.diagnose() + extra_message + "\nlogin: "
message = message.replace("\n", "\r\n")
try:
proc = subprocess.Popen("virsh -c qemu:///session console %s" % str(self._domain.ID()), shell=True)
# Fill in information into /etc/issue about login access
pid = 0
while pid == 0:
if message:
try:
with stdchannel_redirected(sys.stderr, os.devnull):
Machine.wait_boot(self)
sys.stderr.write(message)
except (Failure, subprocess.CalledProcessError):
pass
message = None
(pid, ret) = os.waitpid(proc.pid, message and os.WNOHANG or 0)
try:
if self.maintain:
self.shutdown()
else:
self.kill()
except libvirt.libvirtError as le:
# the domain may have already been freed (shutdown) while the console was running
self.message("libvirt error during shutdown: %s" % (le.get_error_message()))
except OSError as ex:
raise Failure("Failed to launch virsh command: {0}".format(ex.strerror))
finally:
self._cleanup()
def graphics_console(self):
self.message("Started machine {0}".format(self.label))
if self.maintain:
message = "\nWARNING: Uncontrolled shutdown can lead to a corrupted image\n"
else:
message = "\nWARNING: All changes are discarded, the image file won't be changed\n"
if "bridge" in self.networking:
message += "\nIn the machine a web browser can access Cockpit on parent host:\n\n"
message += " https://10.111.112.1:9090\n"
message = message.replace("\n", "\r\n")
try:
proc = subprocess.Popen(["virt-viewer", str(self._domain.ID())])
sys.stderr.write(message)
proc.wait()
except OSError as ex:
raise Failure("Failed to launch virt-viewer command: {0}".format(ex.strerror))
finally:
self._cleanup()
def pull(self, image):
if "/" in image:
image_file = os.path.abspath(image)
else:
image_file = os.path.join(BOTS_DIR, "images", image)
if not os.path.exists(image_file):
try:
subprocess.check_call([ os.path.join(BOTS_DIR, "image-download"), image_file ])
except OSError as ex:
if ex.errno != errno.ENOENT:
raise
return image_file
def start(self):
tries = 0
while True:
try:
self._start_qemu()
if not self._domain.isActive():
self._domain.start()
except RepeatableFailure:
self.kill()
if tries < 10:
tries += 1
time.sleep(tries)
continue
else:
raise
except:
self.kill()
raise
# Normally only one pass
break
def _diagnose_no_address(self):
SCRIPT = """
spawn virsh -c qemu:///session console $argv
set timeout 300
expect "Escape character"
send "\r"
expect " login: "
send "root\r"
expect "Password: "
send "foobar\r"
expect " ~]# "
send "ip addr\r\n"
expect " ~]# "
exit 0
"""
expect = subprocess.Popen(["expect", "--", "-", str(self._domain.ID())], stdin=subprocess.PIPE,
universal_newlines=True)
expect.communicate(SCRIPT)
def wait_boot(self, timeout_sec=120):
"""Wait for a machine to boot"""
try:
Machine.wait_boot(self, timeout_sec)
except Failure:
self._diagnose_no_address()
raise
def stop(self, timeout_sec=120):
if self.maintain:
self.shutdown(timeout_sec=timeout_sec)
else:
self.kill()
def _cleanup(self, quick=False):
self.disconnect()
try:
for disk in self._disks:
self.rem_disk(disk, quick)
self._domain = None
if hasattr(self, '_transient_image') and self._transient_image and os.path.exists(self._transient_image):
os.unlink(self._transient_image)
except:
(type, value, traceback) = sys.exc_info()
sys.stderr.write("WARNING: Cleanup failed:%s\n" % value)
def kill(self):
# stop system immediately, with potential data loss
# to shutdown gracefully, use shutdown()
try:
self.disconnect()
except Exception:
pass
if self._domain:
try:
# not graceful
with stdchannel_redirected(sys.stderr, os.devnull):
self._domain.destroyFlags(libvirt.VIR_DOMAIN_DESTROY_DEFAULT)
except:
pass
self._cleanup(quick=True)
def wait_poweroff(self, timeout_sec=120):
# shutdown must have already been triggered
if self._domain:
start_time = time.time()
while (time.time() - start_time) < timeout_sec:
try:
with stdchannel_redirected(sys.stderr, os.devnull):
if not self._domain.isActive():
break
except libvirt.libvirtError as le:
if 'no domain' in str(le) or 'not found' in str(le):
break
raise
time.sleep(1)
else:
raise Failure("Waiting for machine poweroff timed out")
try:
with stdchannel_redirected(sys.stderr, os.devnull):
self._domain.destroyFlags(libvirt.VIR_DOMAIN_DESTROY_DEFAULT)
except libvirt.libvirtError as le:
if 'not found' not in str(le):
raise
self._cleanup(quick=True)
def shutdown(self, timeout_sec=120):
# shutdown the system gracefully
# to stop it immediately, use kill()
self.disconnect()
try:
if self._domain:
self._domain.shutdown()
self.wait_poweroff(timeout_sec=timeout_sec)
finally:
self._cleanup()
def add_disk(self, size=None, serial=None, path=None, type='raw'):
index = len(self._disks)
os.makedirs(self.run_dir, 0o750, exist_ok=True)
if path:
(unused, image) = tempfile.mkstemp(suffix='.qcow2', prefix=os.path.basename(path), dir=self.run_dir)
subprocess.check_call([ "qemu-img", "create", "-q", "-f", "qcow2",
"-o", "backing_file=" + os.path.realpath(path), image ])
else:
assert size is not None
name = "disk-{0}".format(self._domain.name())
(unused, image) = tempfile.mkstemp(suffix='qcow2', prefix=name, dir=self.run_dir)
subprocess.check_call(["qemu-img", "create", "-q", "-f", "raw", image, str(size)])
if not serial:
serial = "DISK{0}".format(index)
dev = 'sd' + string.ascii_lowercase[index]
disk_desc = TEST_DISK_XML % {
'file': image,
'serial': serial,
'unit': index,
'dev': dev,
'type': type,
}
if self._domain.attachDeviceFlags(disk_desc, libvirt.VIR_DOMAIN_AFFECT_LIVE) != 0:
raise Failure("Unable to add disk to vm")
disk = {
"path": image,
"serial": serial,
"filename": image,
"dev": dev,
"index": index,
"type": type,
}
self._disks.append(disk)
return disk
def rem_disk(self, disk, quick=False):
if not quick:
disk_desc = TEST_DISK_XML % {
'file': disk["filename"],
'serial': disk["serial"],
'unit': disk["index"],
'dev': disk["dev"],
'type': disk["type"]
}
if self._domain:
if self._domain.detachDeviceFlags(disk_desc, libvirt.VIR_DOMAIN_AFFECT_LIVE ) != 0:
raise Failure("Unable to remove disk from vm")
def _qemu_monitor(self, command):
self.message("& " + command)
# you can run commands manually using virsh:
# virsh -c qemu:///session qemu-monitor-command [domain name/id] --hmp [command]
output = libvirt_qemu.qemuMonitorCommand(self._domain, command, libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_HMP)
self.message(output.strip())
return output
def add_netiface(self, networking=None):
if not networking:
networking = VirtNetwork(image=self.image).interface()
self._qemu_monitor("netdev_add socket,mcast=230.0.0.1:{mcast},id={id}".format(mcast=networking["mcast"], id=networking["hostnet"]))
cmd = "device_add virtio-net-pci,mac={0},netdev={1}".format(networking["mac"], networking["hostnet"])
self._qemu_monitor("device_add virtio-net-pci,mac={0},netdev={1}".format(networking["mac"], networking["hostnet"]))
return networking["mac"]
def needs_writable_usr(self):
# On atomic systems, we need a hack to change files in /usr/lib/systemd
if self.atomic_image:
self.execute(command="mount -o remount,rw /usr")