# -*- coding: utf-8 -*-
# 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 .
import contextlib
import errno
import fcntl
import libvirt
import libvirt_qemu
import os
import random
import re
import select
import signal
import string
import socket
import subprocess
import tempfile
import sys
import threading
import time
import xml.etree.ElementTree as etree
DEFAULT_IMAGE = os.environ.get("TEST_OS", "fedora-26")
MEMORY_MB = 1024
# Images which are Atomic based
ATOMIC_IMAGES = ["rhel-atomic", "fedora-atomic", "continuous-atomic"]
LOCAL_DIR = os.path.dirname(__file__)
# 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()
class Timeout:
""" Add a timeout to an operation
Specify machine to ensure that a machine's ssh operations are canceled when the timer expires.
"""
def __init__(self, seconds=1, error_message='Timeout', machine=None):
self.seconds = seconds
self.error_message = error_message
self.machine = machine
def handle_timeout(self, signum, frame):
if self.machine:
if self.machine.ssh_process:
self.machine.ssh_process.terminate()
self.machine.disconnect()
raise Exception(self.error_message)
def __enter__(self):
signal.signal(signal.SIGALRM, self.handle_timeout)
signal.alarm(self.seconds)
def __exit__(self, type, value, traceback):
signal.alarm(0)
class Failure(Exception):
def __init__(self, msg):
self.msg = msg
def __str__(self):
return self.msg
class RepeatableFailure(Failure):
pass
class Machine:
def __init__(self, address=None, image=None, verbose=False, label=None, fetch=True):
self.verbose = verbose
# Currently all images are x86_64. When that changes we will have
# an override file for those images that are not
self.arch = "x86_64"
self.image = image or "unknown"
self.atomic_image = self.image in ATOMIC_IMAGES
self.fetch = fetch
self.vm_username = "root"
self.address = address
self.label = label or "UNKNOWN"
self.ssh_master = None
self.ssh_process = None
self.ssh_port = 22
def disconnect(self):
self._kill_ssh_master()
def message(self, *args):
"""Prints args if in verbose mode"""
if not self.verbose:
return
print " ".join(args)
def start(self, maintain=False, macaddr=None, memory_mb=None, cpus=None, wait_for_ip=True):
"""Overridden by machine classes to start the machine"""
self.message("Assuming machine is already running")
def stop(self):
"""Overridden by machine classes to stop the machine"""
self.message("Not shutting down already running machine")
# wait until we can execute something on the machine. ie: wait for ssh
# get_new_address is an optional function to acquire a new ip address for each try
# it is expected to raise an exception on failure and return a valid address otherwise
def wait_execute(self, timeout_sec=120, get_new_address=None):
"""Try to connect to self.address on ssh port"""
# If connected to machine, kill master connection
self._kill_ssh_master()
start_time = time.time()
while (time.time() - start_time) < timeout_sec:
if get_new_address:
try:
self.address = get_new_address()
except:
continue
addrinfo = socket.getaddrinfo(self.address, self.ssh_port, 0, socket.SOCK_STREAM)
(family, socktype, proto, canonname, sockaddr) = addrinfo[0]
sock = socket.socket(family, socktype, proto)
sock.settimeout(1)
try:
sock.connect(sockaddr)
return True
except:
pass
finally:
sock.close()
time.sleep(0.5)
return False
def wait_user_login(self):
"""Wait until logging in as non-root works.
Most tests run as the "admin" user, so we make sure that
user sessions are allowed (and cockit-ws will let "admin"
in) before declaring a test machine as "booted".
"""
tries_left = 60
while (tries_left > 0):
try:
self.execute("! test -f /run/nologin")
return
except subprocess.CalledProcessError:
pass
tries_left = tries_left - 1
time.sleep(1)
raise Failure("Timed out waiting for /run/nologin to disappear")
def wait_boot(self):
"""Wait for a machine to boot"""
assert False, "Cannot wait for a machine we didn't start"
def wait_poweroff(self):
"""Overridden by machine classes to wait for a machine to stop"""
assert False, "Cannot wait for a machine we didn't start"
def kill(self):
"""Overridden by machine classes to unconditionally kill the running machine"""
assert False, "Cannot kill a machine we didn't start"
def shutdown(self):
"""Overridden by machine classes to gracefully shutdown the running machine"""
assert False, "Cannot shutdown a machine we didn't start"
def _start_ssh_master(self):
self._kill_ssh_master()
control = os.path.join(tempfile.gettempdir(), "ssh-%h-%p-%r-" + str(os.getpid()))
cmd = [
"ssh",
"-p", str(self.ssh_port),
"-i", self._calc_identity(),
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "BatchMode=yes",
"-M", # ControlMaster, no stdin
"-o", "ControlPath=" + control,
"-o", "LogLevel=ERROR",
"-l", self.vm_username,
self.address,
"/bin/bash -c 'echo READY; read a'"
]
# Connection might be refused, so try this 10 times
tries_left = 10;
while tries_left > 0:
tries_left = tries_left - 1
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
stdout_fd = proc.stdout.fileno()
output = ""
while stdout_fd > -1 and "READY" not in output:
ret = select.select([stdout_fd], [], [], 10)
for fd in ret[0]:
if fd == stdout_fd:
data = os.read(fd, 1024)
if data == "":
stdout_fd = -1
proc.stdout.close()
output += data
if stdout_fd > -1:
break
# try again if the connection was refused, unless we've used up our tries
proc.wait()
if proc.returncode == 255 and tries_left > 0:
self.message("ssh: connection refused, trying again")
time.sleep(1)
continue
else:
raise Failure("SSH master process exited with code: {0}".format(proc.returncode))
self.ssh_master = control
self.ssh_process = proc
if not self._check_ssh_master():
raise Failure("Couldn't launch an SSH master process")
def _kill_ssh_master(self):
if self.ssh_master:
try:
os.unlink(self.ssh_master)
except OSError as e:
if e.errno != errno.ENOENT:
raise
self.ssh_master = None
if self.ssh_process:
self.ssh_process.stdin.close()
with Timeout(seconds=90, error_message="Timeout while waiting for ssh master to shut down"):
self.ssh_process.wait()
self.ssh_process = None
def _check_ssh_master(self):
if not self.ssh_master:
return False
cmd = [
"ssh",
"-q",
"-p", str(self.ssh_port),
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "BatchMode=yes",
"-S", self.ssh_master,
"-O", "check",
"-l", self.vm_username,
self.address
]
with open(os.devnull, 'w') as devnull:
code = subprocess.call(cmd, stdin=devnull, stdout=devnull, stderr=devnull)
if code == 0:
return True
return False
def _ensure_ssh_master(self):
if not self._check_ssh_master():
self._start_ssh_master()
def debug_shell(self):
"""Run an interactive shell"""
cmd = [
"ssh",
"-p", str(self.ssh_port),
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-i", self._calc_identity(),
"-l", self.vm_username,
self.address
]
subprocess.call(cmd)
def execute(self, command=None, script=None, input=None, environment={}, stdout=None, quiet=False, direct=False):
"""Execute a shell command in the test machine and return its output.
Either specify @command or @script
Arguments:
command: The string to execute by /bin/sh.
script: A multi-line script to execute in /bin/sh
input: Input to send to the command
environment: Additional environmetn variables
Returns:
The command/script output as a string.
"""
assert command or script
assert self.address
if not direct:
self._ensure_ssh_master()
# default to no translations; can be overridden in environment
cmd = [
"env", "-u", "LANGUAGE", "LC_ALL=C",
"ssh",
"-p", str(self.ssh_port),
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "BatchMode=yes"
]
if direct:
cmd += [ "-i", self._calc_identity() ]
else:
cmd += [ "-o", "ControlPath=" + self.ssh_master ]
cmd += [
"-l", self.vm_username,
self.address
]
if command:
assert not environment, "Not yet supported"
if isinstance(command, basestring):
cmd += [command]
if not quiet:
self.message("+", command)
else:
cmd += command
if not quiet:
self.message("+", *command)
else:
assert not input, "input not supported to script"
cmd += ["sh", "-s"]
if self.verbose:
cmd += ["-x"]
input = ""
for name, value in environment.items():
input += "%s='%s'\n" % (name, value)
input += "export %s\n" % name
input += script
command = "