summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore7
-rwxr-xr-xcheck43
-rw-r--r--roles/unix_users/subplot.md19
-rw-r--r--roles/unix_users/subplot.py7
-rw-r--r--roles/unix_users/subplot.yaml2
-rw-r--r--ssh.config.in10
-rw-r--r--ssh/id27
-rw-r--r--ssh/id.pub1
-rw-r--r--subplot.md53
-rw-r--r--subplot/daemon.py84
-rwxr-xr-xsubplot/qemumgr.py210
-rw-r--r--subplot/runcmd.py77
-rw-r--r--subplot/subplot.py120
-rw-r--r--subplot/subplot.yaml15
-rw-r--r--test.config1
-rw-r--r--test.key27
16 files changed, 703 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b36d6fb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+subplot.html
+subplot.pdf
+test.log
+test.md
+test.py
+qemu.out
+ssh.config
diff --git a/check b/check
new file mode 100755
index 0000000..4d36e49
--- /dev/null
+++ b/check
@@ -0,0 +1,43 @@
+ #!/bin/bash
+#
+# Run the automated tests for the project.
+
+set -eu -o pipefail
+
+quiet=-q
+hideok=chronic
+if [ "$#" -gt 0 ]
+then
+ case "$1" in
+ verbose | -v | --verbose)
+ quiet=
+ hideok=
+ shift 1
+ ;;
+ esac
+fi
+
+dir="$(mktemp -d -p .)"
+
+trap 'rm -rf "$dir"' EXIT
+
+rm -f test.log test.py
+cp subplot.md "$dir"
+cat subplot.md roles/*/subplot.md > "$dir/subplot.md"
+cat subplot/*.py roles/*/subplot.py > "$dir/subplot.py"
+cat subplot/*.yaml roles/*/subplot.yaml > "$dir/subplot.yaml"
+
+(
+ set -eu -o pipefail
+ cd "$dir"
+ sp-docgen subplot.md -o ../subplot.pdf
+ sp-docgen subplot.md -o ../subplot.html
+ sp-codegen subplot.md -o ../test.py
+)
+
+# Fix private key permissions. git doesn't preserve them.
+chmod 0600 ssh/id
+
+$hideok python3 test.py --log test.log "$@"
+
+echo "Everything seems to be in order."
diff --git a/roles/unix_users/subplot.md b/roles/unix_users/subplot.md
new file mode 100644
index 0000000..049bfc2
--- /dev/null
+++ b/roles/unix_users/subplot.md
@@ -0,0 +1,19 @@
+# unix_users – manage Unix users
+
+This role creates or updates Unix users.
+
+## Create user with unix_users
+
+~~~scenario
+given a host running Debian
+when I use role unix_users
+and I use variables from foo.yml
+and I run the playbook
+then the host has user foo
+~~~
+
+~~~{#foo.yml .file .yaml}
+unix_users:
+- username: foo
+ comment: Foo Bar
+~~~
diff --git a/roles/unix_users/subplot.py b/roles/unix_users/subplot.py
new file mode 100644
index 0000000..118d007
--- /dev/null
+++ b/roles/unix_users/subplot.py
@@ -0,0 +1,7 @@
+def host_has_user(ctx, username=None):
+ assert_eq = globals()["assert_eq"]
+ qemu = ctx["qemu"]
+ output, exit = qemu.ssh(["getent", "passwd", username])
+ assert_eq(exit, 0)
+ output = output.decode("UTF8")
+ assert f"\n{username}:" in output
diff --git a/roles/unix_users/subplot.yaml b/roles/unix_users/subplot.yaml
new file mode 100644
index 0000000..9fcc961
--- /dev/null
+++ b/roles/unix_users/subplot.yaml
@@ -0,0 +1,2 @@
+- then: the host has user {username}
+ function: host_has_user
diff --git a/ssh.config.in b/ssh.config.in
new file mode 100644
index 0000000..3f1e3f6
--- /dev/null
+++ b/ssh.config.in
@@ -0,0 +1,10 @@
+host qemu
+hostname localhost
+user debian
+port <PORT>
+userknownhostsfile /dev/null
+passwordauthentication no
+stricthostkeychecking accept-new
+identityfile ssh/test.key
+controlmaster auto
+controlpersist 60s
diff --git a/ssh/id b/ssh/id
new file mode 100644
index 0000000..4f65d74
--- /dev/null
+++ b/ssh/id
@@ -0,0 +1,27 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
+NhAAAAAwEAAQAAAQEAylWkH8c88KcJO+tyHDwbN9PE6WGKevjUzjL2Go9493sM/H3mEIN5
+TpSqP+cWj7oNmcsBur+raZ/6dHa6L0+WACDyYrT+8iQHxMyXv4Du3PbsMdLiKXunHPG8ER
+tFxXVFStBJ3gei6SHnPt47o+M4rRgZtH3yKCw4SeydHWk80Detq2WsP9ko0r4G357t2tgW
+ET89TRol3bzgm8rVUi5VAEeksTT6xXYfwSZ0uLpsg2gS/9Y9smsP1pZH2T5+TYt1EmS0XG
+x5wVFG6OuIsAcCWm4XaXodMSxk6g5GnrnVIGY5a0Rxcgm8CUz7Q8ybDy2+Ryhhs3Vzxz+y
+5OjzNgiDswAAA8i+UR6evlEengAAAAdzc2gtcnNhAAABAQDKVaQfxzzwpwk763IcPBs308
+TpYYp6+NTOMvYaj3j3ewz8feYQg3lOlKo/5xaPug2ZywG6v6tpn/p0drovT5YAIPJitP7y
+JAfEzJe/gO7c9uwx0uIpe6cc8bwRG0XFdUVK0EneB6LpIec+3juj4zitGBm0ffIoLDhJ7J
+0daTzQN62rZaw/2SjSvgbfnu3a2BYRPz1NGiXdvOCbytVSLlUAR6SxNPrFdh/BJnS4umyD
+aBL/1j2yaw/WlkfZPn5Ni3USZLRcbHnBUUbo64iwBwJabhdpeh0xLGTqDkaeudUgZjlrRH
+FyCbwJTPtDzJsPLb5HKGGzdXPHP7Lk6PM2CIOzAAAAAwEAAQAAAQEAh8izbPQbTHD8fG7E
+VHht16hRdEGWWnJU9dAzYp24E3VLwMKIu7pPlVGlc18Uv/2fFP+suHPah/bpcHEg/5EMXC
+fAIkfO9BcD86lNiSHwqu82kTUxu58VBhKgIGbKCvppNwzTFaLQTF4JPyKKqbBaH6eV0I/Z
+C+apG8sjoVI3ko8oKjjwTQ/oHHC71APXmLznhupxF2ohHf0jZW3g2Ktc86AiL0JUwmE6nb
+FrZxCTBaYSaa7pQSlgJOjR0+xqLcf7a/rechXlAknKnFCBU2j2rlBMFy8QZUS0ADY1sWcq
+jMRksBVSdPgfC5Ki/a8JWERhX6QoSnvnNR2RJxWpcPs1eQAAAIACOBS8WM26pw5OhDorV2
+NJIqwkzT8fMjjCuTACsa75wtqO4c61rOYy9K0C0/ukTmGHwi9HhjLTC9Ndhi9cWMKOyQL+
+UMKaurmnvtu39odLAG3CGE/SIM6Z6WyyGVpy/FJweTbDKAP7ldr1J49W+dIaCSPyHyatYT
+Fwa7OsJRga5gAAAIEA+mvBnY9U9dj6NVZZAWHF4umSI+VuVmZl8NI0DMTG2hIAPbqi2Eez
+zxp3195mc2Tf9YM81YqC1MVn3nqYJ5Kx+kibbTBphAWcbQe6dhn6zR8jKc8FNA8kdTJi4i
+lvQ2wTEmrJtv2dEMkSNEZOrVw0eAZ9AuDBtcCvmzeRTAt0eQUAAACBAM7XoVeGHIaVA/GL
+skuyNb7J56rCnMXAMJ9jgoaZxx3ON9DDfz8YiAnL1drzdkAtUNL0QlvVQuFlzAUlCPzCHO
+HSEUEW7VFoejy+WSxoP6+F1qVQNnr38OQEswkZSko/+yiDMdgiAAUPaE8v0UZerJg8quHB
+gDal1friWML9x0dXAAAADGxpd0BleG9sb2JlMQECAwQFBg==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/ssh/id.pub b/ssh/id.pub
new file mode 100644
index 0000000..714c5cc
--- /dev/null
+++ b/ssh/id.pub
@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDKVaQfxzzwpwk763IcPBs308TpYYp6+NTOMvYaj3j3ewz8feYQg3lOlKo/5xaPug2ZywG6v6tpn/p0drovT5YAIPJitP7yJAfEzJe/gO7c9uwx0uIpe6cc8bwRG0XFdUVK0EneB6LpIec+3juj4zitGBm0ffIoLDhJ7J0daTzQN62rZaw/2SjSvgbfnu3a2BYRPz1NGiXdvOCbytVSLlUAR6SxNPrFdh/BJnS4umyDaBL/1j2yaw/WlkfZPn5Ni3USZLRcbHnBUUbo64iwBwJabhdpeh0xLGTqDkaeudUgZjlrRHFyCbwJTPtDzJsPLb5HKGGzdXPHP7Lk6PM2CIOz liw@exolobe1
diff --git a/subplot.md b/subplot.md
new file mode 100644
index 0000000..ae7fbb0
--- /dev/null
+++ b/subplot.md
@@ -0,0 +1,53 @@
+# Introduction
+
+`debian-ansible` is a collection of Ansible roles for managing Debian
+systems. The roles are re-usable and parameterised so that they can be
+adapted to some variations.
+
+This document describes the roles and also acts as an acceptance test
+suite for them. The [Subplot][] program can generate a test program
+based on this document, and the test program tests that the roles work
+as intended.
+
+[Subplot]: https://subplot.liw.fi/
+
+## Source files
+
+At the root of the `debian-ansible` source tree is a `subplot.md`, and
+the subdirectory `subplot` with some other files used by the main
+file. Each role directory should have a `subplot.md`, and may also
+have `subplot.yaml` and `subplot.py`. The files in role directories
+get combined with the main ones the the `./check` script.
+
+## Scenario structure
+
+Each `subplot.md` file is meant to contain at least one scenario. All
+scenarios have the same structure:
+
+* create a new VM
+* construct an Ansible playbook that uses the role in a specific way
+* run the playbook against the VM
+* examine the VM to verify it has been changed in the expected way
+
+The VM is created using a base image, which the user must specify to
+the test program by setting the `BASEIMAGE` environment variable.
+
+## Sanity check
+
+Verify that everything looks OK and that other scenarios can be run.
+
+~~~scenario
+given a host running Debian
+then I can run /bin/true on the host
+~~~
+
+
+
+---
+title: "debian-ansible&mdash;Ansible roles for Debian systems"
+author: Lars Wirzenius
+bindings:
+- subplot.yaml
+functions:
+- subplot.py
+...
diff --git a/subplot/daemon.py b/subplot/daemon.py
new file mode 100644
index 0000000..e223505
--- /dev/null
+++ b/subplot/daemon.py
@@ -0,0 +1,84 @@
+#############################################################################
+# Start and stop daemons, or background processes.
+
+
+import logging
+import os
+import signal
+import time
+
+
+# Start a process in the background.
+def start_daemon(ctx, name, argv):
+ runcmd = globals()["runcmd"]
+ exit_code_is = globals()["exit_code_is"]
+
+ logging.debug(f"Starting daemon {name}")
+ logging.debug(f" ctx={ctx.as_dict()}")
+ logging.debug(f" name={name}")
+ logging.debug(f" argv={argv}")
+
+ if "daemon" not in ctx.as_dict():
+ ctx["daemon"] = {}
+ assert name not in ctx["daemon"]
+ this = ctx["daemon"][name] = {
+ "pid-file": f"{name}.pid",
+ "stderr": f"{name}.stderr",
+ "stdout": f"{name}.stdout",
+ }
+ runcmd(
+ ctx,
+ [
+ "/usr/sbin/daemonize",
+ "-c",
+ os.getcwd(),
+ "-p",
+ this["pid-file"],
+ "-e",
+ this["stderr"],
+ "-o",
+ this["stdout"],
+ ]
+ + argv,
+ )
+
+ # Wait for a bit for daemon to start and maybe find a problem and die.
+ time.sleep(3)
+ if ctx["exit"] != 0:
+ logging.error(f"obnam-server stderr: {ctx['stderr']}")
+
+ exit_code_is(ctx, 0)
+ this["pid"] = int(open(this["pid-file"]).read().strip())
+ assert process_exists(this["pid"])
+
+ logging.debug(f"Started daemon {name}")
+ logging.debug(f" ctx={ctx.as_dict()}")
+
+
+# Stop a daemon.
+def stop_daemon(ctx, name):
+ logging.debug(f"Stopping daemon {name}")
+ logging.debug(f" ctx={ctx.as_dict()}")
+ logging.debug(f" ctx['daemon']={ctx.as_dict()['daemon']}")
+
+ this = ctx["daemon"][name]
+ terminate_process(this["pid"], signal.SIGKILL)
+
+
+# Does a process exist?
+def process_exists(pid):
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ return False
+ return True
+
+
+# Terminate process.
+def terminate_process(pid, signalno):
+ logging.debug(f"Terminating process {pid} with signal {signalno}")
+ try:
+ os.kill(pid, signalno)
+ except ProcessLookupError:
+ logging.debug("Process did not actually exist (anymore?)")
+ pass
diff --git a/subplot/qemumgr.py b/subplot/qemumgr.py
new file mode 100755
index 0000000..8951122
--- /dev/null
+++ b/subplot/qemumgr.py
@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+
+
+import logging
+import os
+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
+
+ 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 _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 = 2222 # FIXME
+ 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/sbin/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(f"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, 60, 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,
+ "-ouserknownhostsfile=/dev/null",
+ "-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/runcmd.py b/subplot/runcmd.py
new file mode 100644
index 0000000..7193c15
--- /dev/null
+++ b/subplot/runcmd.py
@@ -0,0 +1,77 @@
+# Some step implementations for running commands and capturing the result.
+
+import subprocess
+
+
+# Run a command, capture its stdout, stderr, and exit code in context.
+def runcmd(ctx, argv, **kwargs):
+ p = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs)
+ stdout, stderr = p.communicate("")
+ ctx["argv"] = argv
+ ctx["stdout"] = stdout.decode("utf-8")
+ ctx["stderr"] = stderr.decode("utf-8")
+ ctx["exit"] = p.returncode
+
+
+# Check that latest exit code captured by runcmd was a specific one.
+def exit_code_is(ctx, wanted):
+ if ctx.get("exit") != wanted:
+ print("context:", ctx.as_dict())
+ assert_eq(ctx.get("exit"), wanted)
+
+
+# Check that latest exit code captured by runcmd was not a specific one.
+def exit_code_is_not(ctx, unwanted):
+ if ctx.get("exit") == unwanted:
+ print("context:", ctx.as_dict())
+ assert_ne(ctx.get("exit"), unwanted)
+
+
+# Check that latest exit code captured by runcmd was zero.
+def exit_code_zero(ctx):
+ exit_code_is(ctx, 0)
+
+
+# Check that latest exit code captured by runcmd was not zero.
+def exit_code_nonzero(ctx):
+ exit_code_is_not(ctx, 0)
+
+
+# Check that stdout of latest runcmd contains a specific string.
+def stdout_contains(ctx, pattern=None):
+ stdout = ctx.get("stdout", "")
+ if pattern not in stdout:
+ print("pattern:", repr(pattern))
+ print("stdout:", repr(stdout))
+ print("ctx:", ctx.as_dict())
+ assert_eq(pattern in stdout, True)
+
+
+# Check that stdout of latest runcmd does not contain a specific string.
+def stdout_does_not_contain(ctx, pattern=None):
+ stdout = ctx.get("stdout", "")
+ if pattern in stdout:
+ print("pattern:", repr(pattern))
+ print("stdout:", repr(stdout))
+ print("ctx:", ctx.as_dict())
+ assert_eq(pattern not in stdout, True)
+
+
+# Check that stderr of latest runcmd does contains a specific string.
+def stderr_contains(ctx, pattern=None):
+ stderr = ctx.get("stderr", "")
+ if pattern not in stderr:
+ print("pattern:", repr(pattern))
+ print("stderr:", repr(stderr))
+ print("ctx:", ctx.as_dict())
+ assert_eq(pattern in stderr, True)
+
+
+# Check that stderr of latest runcmd does not contain a specific string.
+def stderr_does_not_contain(ctx, pattern=None):
+ stderr = ctx.get("stderr", "")
+ if pattern not in stderr:
+ print("pattern:", repr(pattern))
+ print("stderr:", repr(stderr))
+ print("ctx:", ctx.as_dict())
+ assert_eq(pattern not in stderr, True)
diff --git a/subplot/subplot.py b/subplot/subplot.py
new file mode 100644
index 0000000..4434f24
--- /dev/null
+++ b/subplot/subplot.py
@@ -0,0 +1,120 @@
+import logging
+import os
+import yaml
+
+
+def fixme(ctx, **kwargs):
+ assert 0
+
+
+def create_vm(ctx):
+ QemuSystem = globals()["QemuSystem"]
+ srcdir = globals()["srcdir"]
+
+ name = "debian-ansible-test"
+ base_image = "/home/liw/tmp/debian-10-openstack-amd64.qcow2"
+ GiB = 1024 ** 3
+ disk_size = 10 * GiB
+ pubkey = open(os.path.join(srcdir, "ssh", "id.pub")).read().strip()
+ memory = 1 * GiB
+ cpus = 2
+ username = "debian"
+
+ 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 run_playbook(ctx):
+ runcmd = globals()["runcmd"]
+ exit_code_zero = globals()["exit_code_zero"]
+ assert_ne = globals()["assert_ne"]
+ srcdir = globals()["srcdir"]
+
+ 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"),
+ "-p2222",
+ ]
+
+ 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",
+ "-eansible_ssh_port=2222",
+ "playbook.yml",
+ ]
+
+ runcmd(ctx, argv, env=env)
+ exit_code_zero(ctx)
diff --git a/subplot/subplot.yaml b/subplot/subplot.yaml
new file mode 100644
index 0000000..01c85f5
--- /dev/null
+++ b/subplot/subplot.yaml
@@ -0,0 +1,15 @@
+- given: a host running Debian
+ function: create_vm
+ cleanup: destroy_vm
+
+- then: I can run /bin/true on the host
+ function: run_true_on_host
+
+- when: I use role {role}
+ function: use_role_in_playbook
+
+- when: I use variables from {filename}
+ function: set_vars_file
+
+- when: I run the playbook
+ function: run_playbook
diff --git a/test.config b/test.config
new file mode 100644
index 0000000..e1a73bb
--- /dev/null
+++ b/test.config
@@ -0,0 +1 @@
+image: /home/liw/tmp/debian-10-openstack-amd64.qcow2
diff --git a/test.key b/test.key
new file mode 100644
index 0000000..347f335
--- /dev/null
+++ b/test.key
@@ -0,0 +1,27 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
+NhAAAAAwEAAQAAAQEAozhFGOsofrh7iVBOGqFOevAZ6UADRK9J78HhbZ3XGxjwDJ8/f3Ov
+XIELh+hhjWM1DlK+0qxEpEXJwoTiLlzHuA/HEqX8J5yApXkCvWNDamZNVqSDKpsP5gcaix
+KXFz2VaL3IGGumDzWuf8qtcJ3TAvVcpWAahLNEvV4HdQZBXL0bnnTYhc9cVSkWYGAKtwdm
+CBX5MkpTTFQpkHV300jF1s6Vbf6upDSP7xp2WrIzZ4CAKVV1mjpIrZx+zvszsv1Xfm+Zg4
+nekRFbmfehreiXHylzM5h4zkNhUBL+z/lr40/J1VeTzZKhyrwjzDgyd1CAI2mfU+Jgybax
+HQZ+Ke2ZTQAAA8iH8gOIh/IDiAAAAAdzc2gtcnNhAAABAQCjOEUY6yh+uHuJUE4aoU568B
+npQANEr0nvweFtndcbGPAMnz9/c69cgQuH6GGNYzUOUr7SrESkRcnChOIuXMe4D8cSpfwn
+nICleQK9Y0NqZk1WpIMqmw/mBxqLEpcXPZVovcgYa6YPNa5/yq1wndMC9VylYBqEs0S9Xg
+d1BkFcvRuedNiFz1xVKRZgYAq3B2YIFfkySlNMVCmQdXfTSMXWzpVt/q6kNI/vGnZasjNn
+gIApVXWaOkitnH7O+zOy/Vd+b5mDid6REVuZ96Gt6JcfKXMzmHjOQ2FQEv7P+WvjT8nVV5
+PNkqHKvCPMODJ3UIAjaZ9T4mDJtrEdBn4p7ZlNAAAAAwEAAQAAAQEAkRQ6Lu0PgJvgiuxL
+kFYjGRmEHpUckpewg6F1C+dJxTdEYCPI4DPnArVdl39R/sfY6BNatI4VMWMq9HEDTqx/hb
+bYf1X0rQzqOKs4aTjrOo8WXVY7lMphtlhIGqf6jtjZjlFqo3JEF67inYp84eYXIsEPiZvD
+1oI2LpB+1mEqBhBSHHxl76X6OHeMINFYylQwTg+0DUIriv4qBOQ0iLIfyM8ERlGWigWe5M
+hR9hReX1o+adDfmRQyF3wPyeYpuv/RGZIGN8xpHr/CK8arUGtV8QrJAXdt0o0jLdX/Ulv7
+kwOZFXN2e5xA6HvvigdgNkGtXscPy9ELHtcnEVD++ihFgQAAAIB3c54FGhbxOTpy758TWv
+Zrx3XKEpgWr981ym0H9ponp2qC0lnpEQFcHCz1gmjVOnrTWgJYlAfa/cp++LqHOsGtyD/F
+OafdI+RsByiQHDcBAZjceIySnw2XEzNyAC/kKEhS91/UsB9xnAx49A9eBkEyq3tSr6DU6d
+BrUey3Oc1pXQAAAIEA0MS7tlxQahMzXkgid39Xun4bG3lhzSsLwB8vaInz2BQW+wj8yFyV
+A8UTgSMDAZfgq9IaVtunlYuItMxVa3f5xtCX7lA3QNEmQuTPDUy6DA4MJMsA21/qTNDnjA
+wl+PTiZ2r8O2yCfNaJ2oct8d+q5WHvC+oJQZPt13xFK7nbAKEAAACBAMglfRedYwqBVxIS
+sCLGEnVbSFsi3/futfhEenEW1+rMhuEqApyxiq/Fo82ZJZjQPcOz0wt4Re3nYK0dqVtKnw
+iHDPIZhyU/l2s5IBfbidgmunrS7DJva6GGVQnPnGt15AsRVSCLjktMUo9qtOzc/FoEPpLS
+G1zC/u0llRa+tF0tAAAADGxpd0BleG9sb2JlMQECAwQFBg==
+-----END OPENSSH PRIVATE KEY-----