summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2020-11-13 11:20:50 +0000
committerLars Wirzenius <liw@liw.fi>2020-11-13 11:20:50 +0000
commit73479123f4086474da9bb97f7c1dc4f3cf31cc6d (patch)
tree1beed5eb44e1939c2702179896cceb52004c16c9
parent3fe6befd8d1fbcee75df39f6b5b5cd291c75297c (diff)
parentfa6501f87041ca3d3a239988d4b1ae03d7442700 (diff)
downloadobnam2-73479123f4086474da9bb97f7c1dc4f3cf31cc6d.tar.gz
Merge branch 'subplot' into 'main'
Subplot See merge request larswirzenius/obnam!11
-rw-r--r--obnam.md35
-rw-r--r--src/bin/obnam-server.rs4
-rw-r--r--subplot/client.py60
-rw-r--r--subplot/client.yaml14
-rw-r--r--subplot/data.py18
-rw-r--r--subplot/data.yaml10
-rw-r--r--subplot/obnam.yaml77
-rw-r--r--subplot/server.py (renamed from subplot/obnam.py)122
-rw-r--r--subplot/server.yaml39
-rw-r--r--subplot/vendored/runcmd.md169
-rw-r--r--subplot/vendored/runcmd.py (renamed from subplot/runcmd.py)9
-rw-r--r--subplot/vendored/runcmd.yaml83
12 files changed, 431 insertions, 209 deletions
diff --git a/obnam.md b/obnam.md
index 920f037..b3646d3 100644
--- a/obnam.md
+++ b/obnam.md
@@ -222,7 +222,8 @@ it, and verify the results, and finally terminate the server.
We must be able to create a new chunk.
~~~scenario
-given a chunk server
+given an installed obnam
+and a running chunk server
and a file data.dat containing some random data
when I POST data.dat to /chunks, with chunk-meta: {"sha256":"abc"}
then HTTP status code is 201
@@ -271,7 +272,8 @@ We must get the right error if we try to retrieve a chunk that does
not exist.
~~~scenario
-given a chunk server
+given an installed obnam
+and a running chunk server
when I try to GET /chunks/any.random.string
then HTTP status code is 404
~~~
@@ -281,7 +283,8 @@ then HTTP status code is 404
We must get an empty result if searching for chunks that don't exist.
~~~scenario
-given a chunk server
+given an installed obnam
+and a running chunk server
when I GET /chunks?sha256=abc
then HTTP status code is 200
and content-type is application/json
@@ -293,7 +296,8 @@ and the JSON body matches {}
We must get the right error when deleting a chunk that doesn't exist.
~~~scenario
-given a chunk server
+given an installed obnam
+and a running chunk server
when I try to DELETE /chunks/any.random.string
then HTTP status code is 404
~~~
@@ -307,15 +311,13 @@ possible, but still useful requirement for a backup system.
~~~scenario
given an installed obnam
-and a chunk server
+and a running chunk server
and a client config based on smoke.yaml
and a file live/data.dat containing some random data
-when I invoke obnam backup smoke.yaml
-then backup command is successful
-and backup generation is GEN
-when I invoke obnam list smoke.yaml
-then backup command is successful
-and generation list contains <GEN>
+when I run obnam backup smoke.yaml
+then backup generation is GEN
+when I run obnam list smoke.yaml
+then generation list contains <GEN>
when I invoke obnam restore smoke.yaml <GEN> restore.db rest
then data in live and rest match
~~~
@@ -377,11 +379,16 @@ title: "Obnam2&mdash;a backup system"
author: Lars Wirzenius
documentclass: report
bindings:
- - subplot/obnam.yaml
+ - subplot/server.yaml
+ - subplot/client.yaml
+ - subplot/data.yaml
+ - subplot/vendored/runcmd.yaml
functions:
- - subplot/obnam.py
- - subplot/runcmd.py
+ - subplot/server.py
+ - subplot/client.py
+ - subplot/data.py
- subplot/daemon.py
+ - subplot/vendored/runcmd.py
classes:
- json
...
diff --git a/src/bin/obnam-server.rs b/src/bin/obnam-server.rs
index 4e53953..520d6a9 100644
--- a/src/bin/obnam-server.rs
+++ b/src/bin/obnam-server.rs
@@ -32,7 +32,8 @@ async fn main() -> anyhow::Result<()> {
let index = warp::any().map(move || Arc::clone(&index));
info!("Obnam server starting up");
- debug!("Configuration: {:?}", config_bare);
+ debug!("opt: {:#?}", opt);
+ debug!("Configuration: {:#?}", config_bare);
let create = warp::post()
.and(warp::path("chunks"))
@@ -65,6 +66,7 @@ async fn main() -> anyhow::Result<()> {
let log = warp::log("obnam");
let webroot = create.or(fetch).or(search).or(delete).with(log);
+ debug!("starting warp");
warp::serve(webroot)
// .tls()
// .key_path(config_bare.tls_key)
diff --git a/subplot/client.py b/subplot/client.py
new file mode 100644
index 0000000..f159e74
--- /dev/null
+++ b/subplot/client.py
@@ -0,0 +1,60 @@
+import os
+import subprocess
+import yaml
+
+
+def install_obnam(ctx):
+ runcmd_prepend_to_path = globals()["runcmd_prepend_to_path"]
+ srcdir = globals()["srcdir"]
+
+ # Add the directory with built Rust binaries to the path.
+ runcmd_prepend_to_path(ctx, dirname=os.path.join(srcdir, "target", "debug"))
+
+
+def configure_client(ctx, filename=None):
+ get_file = globals()["get_file"]
+
+ assert ctx.get("server_name") is not None
+ assert ctx.get("server_port") is not None
+
+ config = get_file(filename)
+ config = yaml.safe_load(config)
+ config["server_name"] = ctx["server_name"]
+ config["server_port"] = ctx["server_port"]
+
+ with open(filename, "w") as f:
+ yaml.safe_dump(config, stream=f)
+
+
+def run_obnam_restore(ctx, filename=None, genid=None, dbname=None, todir=None):
+ runcmd_run = globals()["runcmd_run"]
+
+ genid = ctx["vars"][genid]
+ runcmd_run(
+ ctx,
+ ["env", "RUST_LOG=obnam", "obnam", "restore", filename, genid, dbname, todir],
+ )
+
+
+def capture_generation_id(ctx, varname=None):
+ runcmd_get_stdout = globals()["runcmd_get_stdout"]
+
+ stdout = runcmd_get_stdout(ctx)
+ gen_id = "unknown"
+ for line in stdout.splitlines():
+ if line.startswith("gen id:"):
+ gen_id = line.split()[-1]
+
+ v = ctx.get("vars", {})
+ v[varname] = gen_id
+ ctx["vars"] = v
+
+
+def live_and_restored_data_match(ctx, live=None, restore=None):
+ subprocess.check_call(["diff", "-rq", f"{live}/.", f"{restore}/{live}/."])
+
+
+def generation_list_contains(ctx, gen_id=None):
+ runcmd_stdout_contains = globals()["runcmd_stdout_contains"]
+ gen_id = ctx["vars"][gen_id]
+ runcmd_stdout_contains(ctx, text=gen_id)
diff --git a/subplot/client.yaml b/subplot/client.yaml
new file mode 100644
index 0000000..80b69f2
--- /dev/null
+++ b/subplot/client.yaml
@@ -0,0 +1,14 @@
+- given: "an installed obnam"
+ function: install_obnam
+
+- given: "a client config based on {filename}"
+ function: configure_client
+
+- when: "I invoke obnam restore {filename} <{genid}> {dbname} {todir}"
+ function: run_obnam_restore
+
+- then: "backup generation is {varname}"
+ function: capture_generation_id
+
+- then: "generation list contains <{gen_id}>"
+ function: generation_list_contains
diff --git a/subplot/data.py b/subplot/data.py
new file mode 100644
index 0000000..a1b9032
--- /dev/null
+++ b/subplot/data.py
@@ -0,0 +1,18 @@
+import logging
+import os
+import random
+import subprocess
+
+
+def create_file_with_random_data(ctx, filename=None):
+ N = 128
+ data = "".join(chr(random.randint(0, 255)) for i in range(N)).encode("UTF-8")
+ dirname = os.path.dirname(filename) or "."
+ logging.debug(f"create_file_with_random_data: dirname={dirname}")
+ os.makedirs(dirname, exist_ok=True)
+ with open(filename, "wb") as f:
+ f.write(data)
+
+
+def live_and_restored_data_match(ctx, live=None, restore=None):
+ subprocess.check_call(["diff", "-rq", f"{live}/.", f"{restore}/{live}/."])
diff --git a/subplot/data.yaml b/subplot/data.yaml
new file mode 100644
index 0000000..8006240
--- /dev/null
+++ b/subplot/data.yaml
@@ -0,0 +1,10 @@
+- given: >
+ a file (?P<filename>\\S+) containing "(?P<data>.*)"
+ regex: true
+ function: create_file_with_given_data
+
+- given: "a file {filename} containing some random data"
+ function: create_file_with_random_data
+
+- then: "data in {live} and {restore} match"
+ function: live_and_restored_data_match
diff --git a/subplot/obnam.yaml b/subplot/obnam.yaml
deleted file mode 100644
index 8bde009..0000000
--- a/subplot/obnam.yaml
+++ /dev/null
@@ -1,77 +0,0 @@
-- given: "an installed obnam"
- function: install_obnam
-
-- given: "a client config based on {filename}"
- function: configure_client
-
-- given: "a chunk server"
- function: start_chunk_server
- cleanup: stop_chunk_server
-
-- given: >
- a file (?P<filename>\\S+) containing "(?P<data>.*)"
- regex: true
- function: create_file_with_given_data
-
-- given: "a file {filename} containing some random data"
- function: create_file_with_random_data
-
-- when: "I POST (?P<filename>\\S+) to (?P<path>\\S+), with (?P<header>\\S+): (?P<json>.*)"
- regex: true
- function: post_file
-
-- when: "I GET /chunks/<{var}>"
- function: get_chunk_via_var
-
-- when: "I try to GET /chunks/{chunk_id}"
- function: get_chunk_by_id
-
-- when: "I GET /chunks?sha256={sha}"
- regex: false
- function: find_chunks_with_sha
-
-- when: "I DELETE /chunks/<{var}>"
- function: delete_chunk_via_var
-
-- when: "I try to DELETE /chunks/{chunk_id}"
- function: delete_chunk_by_id
-
-- when: "I back up {dirname} with obnam-backup"
- function: back_up_directory
-
-- when: "I invoke obnam backup {filename}"
- function: run_obnam_backup
-
-- when: "I invoke obnam list {filename}"
- function: run_obnam_list
-
-- when: "I invoke obnam restore {filename} <{genid}> {dbname} {todir}"
- function: run_obnam_restore
-
-- then: "HTTP status code is {status}"
- function: status_code_is
-
-- then: "{header} is {value}"
- function: header_is
-
-- then: "the JSON body has a field {field}, henceforth {var}"
- function: remember_json_field
-
-- then: "the JSON body matches (?P<wanted>.*)"
- regex: true
- function: json_body_matches
-
-- then: "the body matches file {filename}"
- function: body_matches_file
-
-- then: "backup command is successful"
- function: command_is_successful
-
-- then: "backup generation is {varname}"
- function: capture_generation_id
-
-- then: "data in {live} and {restore} match"
- function: live_and_restored_data_match
-
-- then: "generation list contains <{gen_id}>"
- function: generation_list_contains
diff --git a/subplot/obnam.py b/subplot/server.py
index 7df283a..c159798 100644
--- a/subplot/obnam.py
+++ b/subplot/server.py
@@ -6,8 +6,6 @@ import re
import requests
import shutil
import socket
-import subprocess
-import tarfile
import time
import urllib3
import yaml
@@ -39,7 +37,11 @@ def start_chunk_server(ctx):
ctx["server_port"] = port
ctx["url"] = f"http://localhost:{port}"
- start_daemon(ctx, "obnam-server", [_binary("obnam-server"), filename])
+ start_daemon(
+ ctx,
+ "obnam-server",
+ [os.path.join(srcdir, "target", "debug", "obnam-server"), filename],
+ )
if not port_open("localhost", port, 5.0):
stderr = open(ctx["daemon"]["obnam-server"]["stderr"]).read()
@@ -52,16 +54,6 @@ def stop_chunk_server(ctx):
stop_daemon(ctx, "obnam-server")
-def create_file_with_random_data(ctx, filename=None):
- N = 128
- data = "".join(chr(random.randint(0, 255)) for i in range(N)).encode("UTF-8")
- dirname = os.path.dirname(filename) or "."
- logging.debug(f"create_file_with_random_data: dirname={dirname}")
- os.makedirs(dirname, exist_ok=True)
- with open(filename, "wb") as f:
- f.write(data)
-
-
def post_file(ctx, filename=None, path=None, header=None, json=None):
url = f"{ctx['url']}/chunks"
headers = {header: json}
@@ -132,38 +124,6 @@ def json_body_matches(ctx, wanted=None):
assert_eq(body.get(key, "not.there"), wanted[key])
-def back_up_directory(ctx, dirname=None):
- runcmd_run = globals()["runcmd_run"]
-
- runcmd_run(ctx, ["pgrep", "-laf", "obnam"])
-
- config = {"server_name": "localhost", "server_port": ctx["config"]["port"]}
- config = yaml.safe_dump(config)
- logging.debug(f"back_up_directory: {config}")
- filename = "client.yaml"
- with open(filename, "w") as f:
- f.write(config)
-
- tarball = f"{dirname}.tar"
- t = tarfile.open(name=tarball, mode="w")
- t.add(dirname, arcname=".")
- t.close()
-
- with open(tarball, "rb") as f:
- runcmd_run(ctx, [_binary("obnam-backup"), filename], stdin=f)
-
-
-def command_is_successful(ctx):
- runcmd_exit_code_is_zero = globals()["runcmd_exit_code_is_zero"]
- runcmd_exit_code_is_zero(ctx)
-
-
-# Name of Rust binary, debug-build.
-def _binary(name):
- srcdir = globals()["srcdir"]
- return os.path.abspath(os.path.join(srcdir, "target", "debug", name))
-
-
# Wait for a port to be open
def port_open(host, port, timeout):
logging.debug(f"Waiting for port localhost:{port} to be available")
@@ -216,75 +176,3 @@ def _expand_vars(ctx, s):
result.append(value)
s = s[m.end() :]
return "".join(result)
-
-
-def install_obnam(ctx):
- runcmd_prepend_to_path = globals()["runcmd_prepend_to_path"]
- srcdir = globals()["srcdir"]
-
- # Add the directory with built Rust binaries to the path.
- runcmd_prepend_to_path(ctx, dirname=os.path.join(srcdir, "target", "debug"))
-
-
-def configure_client(ctx, filename=None):
- get_file = globals()["get_file"]
-
- config = get_file(filename)
- ctx["client-config"] = yaml.safe_load(config)
-
-
-def run_obnam_backup(ctx, filename=None):
- runcmd_run = globals()["runcmd_run"]
-
- _write_obnam_client_config(ctx, filename)
- runcmd_run(ctx, ["env", "RUST_LOG=obnam", "obnam", "backup", filename])
-
-
-def run_obnam_list(ctx, filename=None):
- runcmd_run = globals()["runcmd_run"]
-
- _write_obnam_client_config(ctx, filename)
- runcmd_run(ctx, ["env", "RUST_LOG=obnam", "obnam", "list", filename])
-
-
-def _write_obnam_client_config(ctx, filename):
- config = ctx["client-config"]
- config["server_name"] = ctx["server_name"]
- config["server_port"] = ctx["server_port"]
- with open(filename, "w") as f:
- yaml.safe_dump(config, stream=f)
-
-
-def run_obnam_restore(ctx, filename=None, genid=None, dbname=None, todir=None):
- runcmd_run = globals()["runcmd_run"]
-
- genid = ctx["vars"][genid]
- _write_obnam_client_config(ctx, filename)
- runcmd_run(
- ctx,
- ["env", "RUST_LOG=obnam", "obnam", "restore", filename, genid, dbname, todir],
- )
-
-
-def capture_generation_id(ctx, varname=None):
- runcmd_get_stdout = globals()["runcmd_get_stdout"]
-
- stdout = runcmd_get_stdout(ctx)
- gen_id = "unknown"
- for line in stdout.splitlines():
- if line.startswith("gen id:"):
- gen_id = line.split()[-1]
-
- v = ctx.get("vars", {})
- v[varname] = gen_id
- ctx["vars"] = v
-
-
-def live_and_restored_data_match(ctx, live=None, restore=None):
- subprocess.check_call(["diff", "-rq", f"{live}/.", f"{restore}/{live}/."])
-
-
-def generation_list_contains(ctx, gen_id=None):
- runcmd_stdout_contains = globals()["runcmd_stdout_contains"]
- gen_id = ctx["vars"][gen_id]
- runcmd_stdout_contains(ctx, text=gen_id)
diff --git a/subplot/server.yaml b/subplot/server.yaml
new file mode 100644
index 0000000..e7a72b2
--- /dev/null
+++ b/subplot/server.yaml
@@ -0,0 +1,39 @@
+- given: "a running chunk server"
+ function: start_chunk_server
+ cleanup: stop_chunk_server
+
+- when: "I POST (?P<filename>\\S+) to (?P<path>\\S+), with (?P<header>\\S+): (?P<json>.*)"
+ regex: true
+ function: post_file
+
+- when: "I GET /chunks/<{var}>"
+ function: get_chunk_via_var
+
+- when: "I try to GET /chunks/{chunk_id}"
+ function: get_chunk_by_id
+
+- when: "I GET /chunks?sha256={sha}"
+ regex: false
+ function: find_chunks_with_sha
+
+- when: "I DELETE /chunks/<{var}>"
+ function: delete_chunk_via_var
+
+- when: "I try to DELETE /chunks/{chunk_id}"
+ function: delete_chunk_by_id
+
+- then: "HTTP status code is {status}"
+ function: status_code_is
+
+- then: "{header} is {value}"
+ function: header_is
+
+- then: "the JSON body has a field {field}, henceforth {var}"
+ function: remember_json_field
+
+- then: "the JSON body matches (?P<wanted>.*)"
+ regex: true
+ function: json_body_matches
+
+- then: "the body matches file {filename}"
+ function: body_matches_file
diff --git a/subplot/vendored/runcmd.md b/subplot/vendored/runcmd.md
new file mode 100644
index 0000000..bb42005
--- /dev/null
+++ b/subplot/vendored/runcmd.md
@@ -0,0 +1,169 @@
+# Introduction
+
+The [Subplot][] library `runcmd` for Python provides scenario steps
+and their implementations for running Unix commands and examining the
+results. The library consists of a bindings file `lib/runcmd.yaml` and
+implementations in Python in `lib/runcmd.py`. There is no Bash
+version.
+
+[Subplot]: https://subplot.liw.fi/
+
+This document explains the acceptance criteria for the library and how
+they're verified. It uses the steps and functions from the
+`lib/runcmd` library. The scenarios all have the same structure: run a
+command, then examine the exit code, standard output (stdout for
+short), or standard error output (stderr) of the command.
+
+The scenarios use the Unix commands `/bin/true` and `/bin/false` to
+generate exit codes, and `/bin/echo` to produce stdout. To generate
+stderr, they use the little helper script below.
+
+~~~{#err.sh .file .sh .numberLines}
+#!/bin/sh
+echo "$@" 1>&2
+~~~
+
+# Check exit code
+
+These scenarios verify the exit code. To make it easier to write
+scenarios in language that flows more naturally, there are a couple of
+variations.
+
+## Successful execution
+
+~~~scenario
+when I run /bin/true
+then exit code is 0
+and command is successful
+~~~
+
+## Failed execution
+
+~~~scenario
+when I try to run /bin/false
+then exit code is not 0
+and command fails
+~~~
+
+# Check output has what we want
+
+These scenarios verify that stdout or stderr do have something we want
+to have.
+
+## Check stdout is exactly as wanted
+
+Note that the string is surrounded by double quotes to make it clear
+to the reader what's inside. Also, C-style string escapes are
+understood.
+
+~~~scenario
+when I run /bin/echo hello, world
+then stdout is exactly "hello, world\n"
+~~~
+
+## Check stderr is exactly as wanted
+
+~~~scenario
+given helper script err.sh for runcmd
+when I run sh err.sh hello, world
+then stderr is exactly "hello, world\n"
+~~~
+
+## Check stdout using sub-string search
+
+Exact string comparisons are not always enough, so we can verify a
+sub-string is in output.
+
+~~~scenario
+when I run /bin/echo hello, world
+then stdout contains "world\n"
+and exit code is 0
+~~~
+
+## Check stderr using sub-string search
+
+~~~scenario
+given helper script err.sh for runcmd
+when I run sh err.sh hello, world
+then stderr contains "world\n"
+~~~
+
+## Check stdout using regular expressions
+
+Fixed strings are not always enough, so we can verify output matches a
+regular expression. Note that the regular expression is not delimited
+and does not get any C-style string escaped decoded.
+
+~~~scenario
+when I run /bin/echo hello, world
+then stdout matches regex world$
+~~~
+
+## Check stderr using regular expressions
+
+~~~scenario
+given helper script err.sh for runcmd
+when I run sh err.sh hello, world
+then stderr matches regex world$
+~~~
+
+# Check output doesn't have what we want to avoid
+
+These scenarios verify that the stdout or stderr do not
+have something we want to avoid.
+
+## Check stdout is not exactly something
+
+~~~scenario
+when I run /bin/echo hi
+then stdout isn't exactly "hello, world\n"
+~~~
+
+## Check stderr is not exactly something
+
+~~~scenario
+given helper script err.sh for runcmd
+when I run sh err.sh hi
+then stderr isn't exactly "hello, world\n"
+~~~
+
+## Check stdout doesn't contain sub-string
+
+~~~scenario
+when I run /bin/echo hi
+then stdout doesn't contain "world"
+~~~
+
+## Check stderr doesn't contain sub-string
+
+~~~scenario
+given helper script err.sh for runcmd
+when I run sh err.sh hi
+then stderr doesn't contain "world"
+~~~
+
+## Check stdout doesn't match regular expression
+
+~~~scenario
+when I run /bin/echo hi
+then stdout doesn't match regex world$
+
+~~~
+
+## Check stderr doesn't match regular expressions
+
+~~~scenario
+given helper script err.sh for runcmd
+when I run sh err.sh hi
+then stderr doesn't match regex world$
+~~~
+
+
+---
+title: Acceptance criteria for the lib/runcmd Subplot library
+author: The Subplot project
+bindings:
+- runcmd.yaml
+functions:
+- runcmd.py
+...
diff --git a/subplot/runcmd.py b/subplot/vendored/runcmd.py
index 532b60b..a2564c6 100644
--- a/subplot/runcmd.py
+++ b/subplot/vendored/runcmd.py
@@ -49,7 +49,16 @@ def runcmd_get_argv(ctx):
# ctx context.
def runcmd_run(ctx, argv, **kwargs):
ns = ctx.declare("_runcmd")
+
+ # The Subplot Python template empties os.environ at startup, modulo a small
+ # number of variables with carefully chosen values. Here, we don't need to
+ # care about what those variables are, but we do need to not overwrite
+ # them, so we just add anything in the env keyword argument, if any, to
+ # os.environ.
env = dict(os.environ)
+ for key, arg in kwargs.pop("env", {}).items():
+ env[key] = arg
+
pp = ns.get("path-prefix")
if pp:
env["PATH"] = pp + ":" + env["PATH"]
diff --git a/subplot/vendored/runcmd.yaml b/subplot/vendored/runcmd.yaml
new file mode 100644
index 0000000..48dde90
--- /dev/null
+++ b/subplot/vendored/runcmd.yaml
@@ -0,0 +1,83 @@
+# Steps to run commands.
+
+- given: helper script {filename} for runcmd
+ function: runcmd_helper_script
+
+- given: srcdir is in the PATH
+ function: runcmd_helper_srcdir_path
+
+- when: I run (?P<argv0>\S+)(?P<args>.*)
+ regex: true
+ function: runcmd_step
+
+- when: I try to run (?P<argv0>\S+)(?P<args>.*)
+ regex: true
+ function: runcmd_try_to_run
+
+# Steps to examine exit code of latest command.
+
+- then: exit code is {exit}
+ function: runcmd_exit_code_is
+
+- then: exit code is not {exit}
+ function: runcmd_exit_code_is_not
+
+- then: command is successful
+ function: runcmd_exit_code_is_zero
+
+- then: command fails
+ function: runcmd_exit_code_is_nonzero
+
+# Steps to examine stdout/stderr for exact content.
+
+- then: stdout is exactly "(?P<text>.*)"
+ regex: true
+ function: runcmd_stdout_is
+
+- then: "stdout isn't exactly \"(?P<text>.*)\""
+ regex: true
+ function: runcmd_stdout_isnt
+
+- then: stderr is exactly "(?P<text>.*)"
+ regex: true
+ function: runcmd_stderr_is
+
+- then: "stderr isn't exactly \"(?P<text>.*)\""
+ regex: true
+ function: runcmd_stderr_isnt
+
+# Steps to examine stdout/stderr for sub-strings.
+
+- then: stdout contains "(?P<text>.*)"
+ regex: true
+ function: runcmd_stdout_contains
+
+- then: "stdout doesn't contain \"(?P<text>.*)\""
+ regex: true
+ function: runcmd_stdout_doesnt_contain
+
+- then: stderr contains "(?P<text>.*)"
+ regex: true
+ function: runcmd_stderr_contains
+
+- then: "stderr doesn't contain \"(?P<text>.*)\""
+ regex: true
+ function: runcmd_stderr_doesnt_contain
+
+# Steps to match stdout/stderr against regular expressions.
+
+- then: stdout matches regex (?P<regex>.*)
+ regex: true
+ function: runcmd_stdout_matches_regex
+
+- then: stdout doesn't match regex (?P<regex>.*)
+ regex: true
+ function: runcmd_stdout_doesnt_match_regex
+
+- then: stderr matches regex (?P<regex>.*)
+ regex: true
+ function: runcmd_stderr_matches_regex
+
+- then: stderr doesn't match regex (?P<regex>.*)
+ regex: true
+ function: runcmd_stderr_doesnt_match_regex