diff options
Diffstat (limited to 'subplot')
-rwxr-xr-x | subplot/qemumgr.py | 216 | ||||
-rw-r--r-- | subplot/subplot.py | 145 | ||||
-rw-r--r-- | subplot/subplot.yaml | 42 |
3 files changed, 403 insertions, 0 deletions
diff --git a/subplot/qemumgr.py b/subplot/qemumgr.py new file mode 100755 index 0000000..c4fd385 --- /dev/null +++ b/subplot/qemumgr.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 + + +import logging +import os +import random +import shlex +import shutil +import signal +import subprocess +import tempfile +import time + + +CLOUD_INIT_META_TEMPLATE = """\ +# Amazon EC2 style metadata +local-hostname: {hostname} +""" + +CLOUD_INIT_USER_TEMPLATE = """\ +#cloud-config +ssh_authorized_keys: +- {pubkey} +""" + + +class QemuSystem: + def __init__(self, name, base_image, disk_size, pubkey): + self._name = name + self._dirname = "." + self._image = self._copy_image(base_image, disk_size) + self._pubkey = pubkey + self._memory = 512 + self._vcpus = 1 + self._username = None + self._port = None + self._pid = None + + fd, self._knownhosts = tempfile.mkstemp() + os.close(fd) + + def _join(self, *names): + return os.path.join(self._dirname, *names) + + def set_disk_size(self, size): + self._disk_size = str(size * 1024 * 1024) + + def set_memory(self, memory): + self._memory = memory + + def set_vcpus(self, vcpus): + self._vcpus = vcpus + + def set_username(self, username): + self._username = username + + def get_port(self): + return self._port + + def _copy_image(self, base_image, size): + image = self._join("qemu.img") + logging.debug(f"QemuSystem: actual disk image: {image}") + shutil.copy(base_image, image) + if size is not None: + subprocess.check_call(["qemu-img", "resize", "-q", image, str(size)]) + return image + + def _cloud_init_iso(self): + iso = self._join("cloud-init.iso") + + config = self._join("cloud-init") + os.mkdir(config) + + meta = os.path.join(config, "meta-data") + write_file(meta, CLOUD_INIT_META_TEMPLATE.format(hostname=self._name)) + + user = os.path.join(config, "user-data") + write_file(user, CLOUD_INIT_USER_TEMPLATE.format(pubkey=self._pubkey)) + + if os.path.exists(iso): + os.remove(iso) + subprocess.check_call( + [ + "genisoimage", + "-quiet", + "-joliet", + "-rock", + "-output", + iso, + "-volid", + "cidata", + config, + ] + ) + + return iso + + def start(self): + iso = self._cloud_init_iso() + + self._port = random.randint(2000, 30000) + pid_file = self._join("qemu.pid") + out_file = self._join("qemu.out") + err_file = self._join("qemu.err") + + MiB = 1024 ** 2 + memory = int(self._memory / MiB) + + argv = [ + "/usr/bin/daemonize", + "-c", + self._dirname, + "-p", + pid_file, + "-e", + err_file, + "-o", + out_file, + "/usr/bin/qemu-system-x86_64", + "-name", + self._name, + "-m", + str(memory), + "-cpu", + "host", + "-smp", + f"cpus={self._vcpus}", + "-drive", + f"file={self._image},format=qcow2,cache=none,if=virtio", + "-drive", + f"file={iso},if=ide,media=cdrom", + "-device", + "virtio-net,netdev=user.0", + "-netdev", + f"user,id=user.0,hostfwd=tcp::{self._port}-:22", + "-nographic", + "-enable-kvm", + ] + + subprocess.check_call(argv, stdout=None, stderr=None) + self._pid = int(wait_for(lambda: got_pid(pid_file), 1)) + + logging.debug("started qemu-system") + logging.debug(f" argv: {argv}") + logging.debug(f" pid: {self._pid}") + + def stop(self): + if self._pid is not None: + logging.debug(f"killing qemu-system process {self._pid}") + os.kill(self._pid, signal.SIGTERM) + + def wait_for_ssh(self): + def ssh_ok(): + output, exit = self.ssh(["true"]) + logging.debug(f"tried ssh, exit={exit}") + if exit == 0: + return True + return None + + return wait_for(ssh_ok, 300, sleep=5) + + def ssh(self, argv): + assert self._username is not None + + srcdir = globals()["srcdir"] + + ssh_opts = [ + "-i", + os.path.join(srcdir, "ssh", "id"), + "-p", + str(self._port), + "-l", + self._username, + f"-ouserknownhostsfile={self._knownhosts}", + "-ostricthostkeychecking=accept-new", + "-opasswordauthentication=no", + ] + + real_argv = ( + ["ssh"] + ssh_opts + ["localhost", "--"] + [shlex.quote(x) for x in argv] + ) + + logging.debug(f"running on VM: {real_argv}") + p = subprocess.Popen( + real_argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) + output, _ = p.communicate() + logging.debug(f"exit code: {p.returncode}") + logging.debug(f"output: {output}") + return output, p.returncode + + +def read_file(filename): + with open(filename, "r") as f: + return f.read() + + +def write_file(filename, content): + with open(filename, "w") as f: + f.write(content) + + +def wait_for(func, timeout, sleep=0.1): + start = time.time() + while time.time() < start + timeout: + val = func() + if val is not None: + return val + time.sleep(sleep) + + +def got_pid(pid_file): + if os.path.exists(pid_file): + pid = read_file(pid_file) + if pid: + return pid.strip() diff --git a/subplot/subplot.py b/subplot/subplot.py new file mode 100644 index 0000000..08472bd --- /dev/null +++ b/subplot/subplot.py @@ -0,0 +1,145 @@ +import logging +import os +import yaml + + +def fixme(ctx, **kwargs): + assert 0 + + +def create_vm(ctx): + QemuSystem = globals()["QemuSystem"] + srcdir = globals()["srcdir"] + + MiB = 1024 ** 2 + GiB = 1024 ** 3 + + cfg = yaml.safe_load(open(os.path.join(srcdir, "test.cfg"))) + name = cfg["name"] + base_image = cfg["base_image"] + username = cfg["username"] + cpus = cfg["cpus"] + memory = cfg["memory"] * MiB + + # We use a hard-coded test key that we have in the source tree. + pubkey = open(os.path.join(srcdir, "ssh", "id.pub")).read().strip() + + # We hard code the disk image size, since we don't expect scenarios to have + # any specific size needs, and also, the qcow2 image format is only as big + # as the data put into the disk, so we can choose a size that fits all. + disk_size = 10 * GiB + + logging.info("starting a VM using qemu-system") + logging.info(f" name : {name}") + logging.info(f" image : {base_image}") + logging.info(f" disk : {disk_size}") + logging.info(f" pubkey : {pubkey}") + logging.info(f" memory : {memory}") + logging.info(f" cpus : {cpus}") + logging.info(f" username: {username}") + + qemu = QemuSystem(name, base_image, disk_size, pubkey) + qemu.set_memory(memory) + qemu.set_vcpus(cpus) + qemu.set_username(username) + qemu.start() + + logging.debug("waiting for SSH to be ready") + if qemu.wait_for_ssh(): + logging.debug("SSH is ready") + else: + logging.error("SSH did not get ready") + assert 0 + logging.info("a qemu-system VM is up and running and accessible over SSH") + ctx["qemu"] = qemu + + +def destroy_vm(ctx): + logging.debug(f"destroying qemu running") + qemu = ctx["qemu"] + qemu.stop() + + +def run_true_on_host(ctx): + qemu = ctx["qemu"] + qemu.ssh(["/bin/true"]) + + +def use_role_in_playbook(ctx, role=None): + empty_playbook = { + "hosts": "test-host", + "remote_user": "debian", # FIXME: don't hardcode this + "become": True, + "roles": [], + } + playbook = ctx.get("playbook", dict(empty_playbook)) + playbook["roles"].append(role) + ctx["playbook"] = playbook + + +def set_vars_file(ctx, filename=None): + get_file = globals()["get_file"] + data = get_file(filename) + with open("vars.yml", "wb") as f: + f.write(data) + + +def try_playbook(ctx): + runcmd_run = globals()["runcmd_run"] + assert_ne = globals()["assert_ne"] + srcdir = globals()["srcdir"] + + qemu = ctx["qemu"] + + with open("hosts", "w") as f: + f.write("test-host\n") + + if not os.path.exists("vars.yml"): + with open("vars.yml", "w") as f: + yaml.safe_dump({}, stream=f) + + playbook = [ctx["playbook"]] + assert_ne(playbook, None) + with open("playbook.yml", "w") as f: + yaml.safe_dump(playbook, stream=f) + + ssh_opts = [ + "-ouserknownhostsfile=/dev/null", + "-ostricthostkeychecking=accept-new", + "-i", + os.path.join(srcdir, "ssh", "id"), + f"-p{qemu.get_port()}", + ] + + env = dict(os.environ) + env["ANSIBLE_SSH_ARGS"] = " ".join(ssh_opts) + env["ANSIBLE_LOG"] = "ansible.log" + env["ANSIBLE_ROLES_PATH"] = os.path.join(srcdir, "roles") + + argv = [ + "ansible-playbook", + "-i", + "hosts", + f"-e@vars.yml", + "-eansible_ssh_host=localhost", + f"-eansible_ssh_port={qemu.get_port()}", + "playbook.yml", + ] + + runcmd_run(ctx, argv, env=env) + + +def command_fails(ctx): + runcmd_exit_code_is_nonzero = globals()["runcmd_exit_code_is_nonzero"] + runcmd_exit_code_is_nonzero(ctx) + + +def run_playbook(ctx): + runcmd_exit_code_is_zero = globals()["runcmd_exit_code_is_zero"] + try_playbook(ctx) + runcmd_exit_code_is_zero(ctx) + + +def xstdout_contains(ctx, text=None): + runcmd_stdout_contains = globals()["runcmd_stdout_contains"] + runcmd_stdout_contains(ctx, text=text) diff --git a/subplot/subplot.yaml b/subplot/subplot.yaml new file mode 100644 index 0000000..286e878 --- /dev/null +++ b/subplot/subplot.yaml @@ -0,0 +1,42 @@ +- given: a host running Debian + impl: + python: + function: create_vm + cleanup: destroy_vm + +- then: I can run /bin/true on the host + impl: + python: + function: run_true_on_host + +- when: I use role {role} + impl: + python: + function: use_role_in_playbook + +- when: I use variables from {filename} + impl: + python: + function: set_vars_file + types: + filename: file + +- when: I run the playbook + impl: + python: + function: run_playbook + +- when: I try to run the playbook + impl: + python: + function: try_playbook + +- then: the command fails + impl: + python: + function: command_fails + +- then: stdout contains "{text:text}" + impl: + python: + function: xstdout_contains |