summaryrefslogtreecommitdiff
path: root/subplot
diff options
context:
space:
mode:
Diffstat (limited to 'subplot')
-rwxr-xr-xsubplot/qemumgr.py216
-rw-r--r--subplot/subplot.py145
-rw-r--r--subplot/subplot.yaml42
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