# -*- 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 = "