summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2021-07-25 08:47:19 +0000
committerLars Wirzenius <liw@liw.fi>2021-07-25 08:47:19 +0000
commit1cab04fbfc732ff0cbcd0394fa0374300e1176e8 (patch)
tree39e093d889e00f52be1fd000a88333b45546af01
parentc27b412e2d8ac7bf052a227caee88e6242c0fe14 (diff)
parentb1a00546f16a8874b8f17e33d529b839692b2bd3 (diff)
downloadvmadm-1cab04fbfc732ff0cbcd0394fa0374300e1176e8.tar.gz
Merge branch 'network' into 'main'
allow networks to be specified Closes #18 See merge request larswirzenius/vmadm!38
-rw-r--r--Cargo.lock26
-rw-r--r--Cargo.toml15
-rw-r--r--README.md4
-rw-r--r--src/cmd/config.rs6
-rw-r--r--src/cmd/spec.rs10
-rw-r--r--src/config.rs4
-rw-r--r--src/install.rs22
-rw-r--r--src/spec.rs1
-rw-r--r--subplot/vendored/files.py158
-rw-r--r--subplot/vendored/files.yaml62
-rw-r--r--subplot/vendored/runcmd.py252
-rw-r--r--subplot/vendored/runcmd.yaml83
-rw-r--r--subplot/vmadm.py45
-rw-r--r--subplot/vmadm.yaml3
-rw-r--r--vmadm.md97
15 files changed, 215 insertions, 573 deletions
diff --git a/Cargo.lock b/Cargo.lock
index afe30bb..8af831c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1,5 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
+version = 3
+
[[package]]
name = "aho-corasick"
version = "0.7.15"
@@ -147,6 +149,12 @@ dependencies = [
]
[[package]]
+name = "itoa"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736"
+
+[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -336,6 +344,12 @@ dependencies = [
]
[[package]]
+name = "ryu"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e"
+
+[[package]]
name = "serde"
version = "1.0.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -356,6 +370,17 @@ dependencies = [
]
[[package]]
+name = "serde_json"
+version = "1.0.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
name = "serde_yaml"
version = "0.8.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -524,6 +549,7 @@ dependencies = [
"log",
"pretty_env_logger",
"serde",
+ "serde_json",
"serde_yaml",
"shell-words",
"structopt",
diff --git a/Cargo.toml b/Cargo.toml
index e199527..a8ca135 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,14 +12,15 @@ repository = "https://gitlab.com/larswirzenius/vmadm"
[dependencies]
anyhow = "1"
-structopt = "0.3"
-tempfile = "3.2"
-thiserror = "1"
-virt = "0.2"
-serde = { version = "1", features = ["derive"] }
-serde_yaml = "0.8"
bytesize = "1"
+directories-next = "2"
log = "0.4"
pretty_env_logger = "0.4"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1.0.64"
+serde_yaml = "0.8"
shell-words = "1"
-directories-next = "2"
+structopt = "0.3"
+tempfile = "3.2"
+thiserror = "1"
+virt = "0.2"
diff --git a/README.md b/README.md
index d21eb40..5e3bd46 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,7 @@ following fields:
* `default_cpus` &ndash; default number of CPUs for a VM
* `default_generate_host_certificate` &ndash; should SSH host
certificates be generated by default?
+* `default_networks` &ndash; networks to which VM should be added
* `image_directory` &ndash; directory where VM image files are put
* `authorized_keys` &ndash; list of filenames to SSH public keys, to
be put into the default user's `authorized_keys` file in the VM
@@ -94,6 +95,9 @@ all of which override some default from the configuration.
path name, is not put into the image directory by default
* `generate_host_certificate` &ndash; override host certification
setting
+* `networks` &ndash; networks to which VM should be added; if this
+ field and the `default_networks` field in the config are not
+ specified, add to the `default` network
* `ca_key` &ndash; overrides default CA key
* `rsa_host_key` &ndash; RSA host key to install on host
* `rsa_host_cert` &ndash; RSA host certificate to install on host
diff --git a/src/cmd/config.rs b/src/cmd/config.rs
index 996439b..0a78a64 100644
--- a/src/cmd/config.rs
+++ b/src/cmd/config.rs
@@ -6,9 +6,11 @@ use crate::config::Configuration;
/// The `config` sub-command.
///
-/// Write the actual run-time configuration to stdout.
+/// Write the actual run-time configuration to stdout as JSON. We
+/// convert the config to JSON to make it clear we parse it the right
+/// way.
pub fn config(config: &Configuration) -> Result<(), std::io::Error> {
- let config = serde_yaml::to_vec(&config).unwrap();
+ let config = serde_json::to_vec(&config).unwrap();
std::io::stdout().write_all(&config)?;
Ok(())
}
diff --git a/src/cmd/spec.rs b/src/cmd/spec.rs
index b41f234..7922cb5 100644
--- a/src/cmd/spec.rs
+++ b/src/cmd/spec.rs
@@ -6,11 +6,11 @@ use crate::spec::Specification;
/// The `spec` sub-command.
///
-/// Write the actual VM specifications to stdout.
+/// Write the actual VM specifications to stdout as JSON. We convert
+/// the spec to JSON to make it more clear that we parse the config
+/// and spec in the right way.
pub fn spec(specs: &[Specification]) -> Result<(), std::io::Error> {
- for spec in specs {
- let spec = serde_yaml::to_vec(&spec).unwrap();
- std::io::stdout().write_all(&spec)?;
- }
+ let spec = serde_json::to_vec(specs).unwrap();
+ std::io::stdout().write_all(&spec)?;
Ok(())
}
diff --git a/src/config.rs b/src/config.rs
index 0ff251a..4786bdb 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -9,6 +9,7 @@ use std::path::{Path, PathBuf};
/// Configuration from configuration file.
#[derive(Default, Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
pub struct Configuration {
/// Base image, if provided.
pub default_base_image: Option<PathBuf>,
@@ -28,6 +29,9 @@ pub struct Configuration {
/// Should new VM be started automatically on host boot?
pub default_autostart: Option<bool>,
+ /// List of default networks to add to hosts.
+ pub default_networks: Option<Vec<String>>,
+
/// Directory where new VM images should be created, if given.
pub image_directory: Option<PathBuf>,
diff --git a/src/install.rs b/src/install.rs
index 94c703c..c84f30a 100644
--- a/src/install.rs
+++ b/src/install.rs
@@ -40,6 +40,7 @@ pub struct VirtInstallArgs {
vcpus: u64,
image: VirtualMachineImage,
init: CloudInitConfig,
+ networks: Vec<String>,
}
impl VirtInstallArgs {
@@ -51,6 +52,7 @@ impl VirtInstallArgs {
vcpus: 1,
image: image.clone(),
init: init.clone(),
+ networks: vec![],
}
}
@@ -88,12 +90,31 @@ impl VirtInstallArgs {
pub fn init(&self) -> &CloudInitConfig {
&self.init
}
+
+ /// Add another network to add to the VM.
+ pub fn add_network(&mut self, network: &str) {
+ self.networks.push(network.to_string());
+ }
+
+ /// Return list of networks to add to the VM.
+ pub fn networks(&self) -> Vec<String> {
+ self.networks.clone()
+ }
}
/// Create new VM with virt-install.
pub fn virt_install(args: &VirtInstallArgs, iso: &Path) -> Result<PathBuf, VirtInstallError> {
args.init().create_iso(&iso)?;
+ let networks: Vec<String> = if args.networks.is_empty() {
+ vec!["--network=default".to_string()]
+ } else {
+ args.networks
+ .iter()
+ .map(|s| format!("--network={}", s))
+ .collect()
+ };
+
let r = Command::new("virt-install")
.arg("--name")
.arg(args.name())
@@ -115,6 +136,7 @@ pub fn virt_install(args: &VirtInstallArgs, iso: &Path) -> Result<PathBuf, VirtI
.arg("--graphics=spice")
.arg("--noautoconsole")
.arg("--quiet")
+ .args(&networks)
.output()
.map_err(VirtInstallError::Run)?;
if !r.status.success() {
diff --git a/src/spec.rs b/src/spec.rs
index bf622c0..1470fdb 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -31,6 +31,7 @@ struct OneVmInputSpecification {
pub cpus: Option<u64>,
pub generate_host_certificate: Option<bool>,
pub autostart: Option<bool>,
+ pub networks: Option<Vec<String>>,
pub ca_key: Option<PathBuf>,
}
diff --git a/subplot/vendored/files.py b/subplot/vendored/files.py
deleted file mode 100644
index ec37b9d..0000000
--- a/subplot/vendored/files.py
+++ /dev/null
@@ -1,158 +0,0 @@
-import logging
-import os
-import re
-import time
-
-
-def files_create_from_embedded(ctx, filename=None):
- files_create_from_embedded_with_other_name(
- ctx, filename_on_disk=filename, embedded_filename=filename
- )
-
-
-def files_create_from_embedded_with_other_name(
- ctx, filename_on_disk=None, embedded_filename=None
-):
- get_file = globals()["get_file"]
- with open(filename_on_disk, "wb") as f:
- f.write(get_file(embedded_filename))
-
-
-def files_create_from_text(ctx, filename=None, text=None):
- with open(filename, "w") as f:
- f.write(text)
-
-
-def files_file_exists(ctx, filename=None):
- assert_eq = globals()["assert_eq"]
- assert_eq(os.path.exists(filename), True)
-
-
-def files_file_does_not_exist(ctx, filename=None):
- assert_eq = globals()["assert_eq"]
- assert_eq(os.path.exists(filename), False)
-
-
-def files_only_these_exist(ctx, filenames=None):
- assert_eq = globals()["assert_eq"]
- filenames = filenames.replace(",", "").split()
- assert_eq(set(os.listdir(".")), set(filenames))
-
-
-def files_file_contains(ctx, filename=None, data=None):
- assert_eq = globals()["assert_eq"]
- with open(filename, "rb") as f:
- actual = f.read()
- actual = actual.decode("UTF-8")
- assert_eq(data in actual, True)
-
-
-def files_file_matches_regex(ctx, filename=None, regex=None):
- assert_eq = globals()["assert_eq"]
- with open(filename) as f:
- content = f.read()
- m = re.search(regex, content)
- if m is None:
- logging.debug(f"files_file_matches_regex: no match")
- logging.debug(f" filenamed: {filename}")
- logging.debug(f" regex: {regex}")
- logging.debug(f" content: {regex}")
- logging.debug(f" match: {m}")
- assert_eq(bool(m), True)
-
-
-def files_match(ctx, filename1=None, filename2=None):
- assert_eq = globals()["assert_eq"]
- with open(filename1, "rb") as f:
- data1 = f.read()
- with open(filename2, "rb") as f:
- data2 = f.read()
- assert_eq(data1, data2)
-
-
-def files_touch_with_timestamp(
- ctx,
- filename=None,
- year=None,
- month=None,
- day=None,
- hour=None,
- minute=None,
- second=None,
-):
- t = (
- int(year),
- int(month),
- int(day),
- int(hour),
- int(minute),
- int(second),
- -1,
- -1,
- -1,
- )
- ts = time.mktime(t)
- _files_touch(filename, ts)
-
-
-def files_touch(ctx, filename=None):
- _files_touch(filename, None)
-
-
-def _files_touch(filename, ts):
- if not os.path.exists(filename):
- open(filename, "w").close()
- times = None
- if ts is not None:
- times = (ts, ts)
- os.utime(filename, times=times)
-
-
-def files_mtime_is_recent(ctx, filename=None):
- st = os.stat(filename)
- age = abs(st.st_mtime - time.time())
- assert age < 1.0
-
-
-def files_mtime_is_ancient(ctx, filename=None):
- st = os.stat(filename)
- age = abs(st.st_mtime - time.time())
- year = 365 * 24 * 60 * 60
- required = 39 * year
- logging.debug(f"ancient? mtime={st.st_mtime} age={age} required={required}")
- assert age > required
-
-
-def files_remember_metadata(ctx, filename=None):
- meta = _files_remembered(ctx)
- meta[filename] = _files_get_metadata(filename)
- logging.debug("files_remember_metadata:")
- logging.debug(f" meta: {meta}")
- logging.debug(f" ctx: {ctx}")
-
-
-# Check that current metadata of a file is as stored in the context.
-def files_has_remembered_metadata(ctx, filename=None):
- assert_eq = globals()["assert_eq"]
- meta = _files_remembered(ctx)
- logging.debug("files_has_remembered_metadata:")
- logging.debug(f" meta: {meta}")
- logging.debug(f" ctx: {ctx}")
- assert_eq(meta[filename], _files_get_metadata(filename))
-
-
-def files_has_different_metadata(ctx, filename=None):
- assert_ne = globals()["assert_ne"]
- meta = _files_remembered(ctx)
- assert_ne(meta[filename], _files_get_metadata(filename))
-
-
-def _files_remembered(ctx):
- ns = ctx.declare("_files")
- return ns.get("remembered-metadata", {})
-
-
-def _files_get_metadata(filename):
- st = os.lstat(filename)
- keys = ["st_dev", "st_gid", "st_ino", "st_mode", "st_mtime", "st_size", "st_uid"]
- return {key: getattr(st, key) for key in keys}
diff --git a/subplot/vendored/files.yaml b/subplot/vendored/files.yaml
deleted file mode 100644
index be69920..0000000
--- a/subplot/vendored/files.yaml
+++ /dev/null
@@ -1,62 +0,0 @@
-- given: file {filename}
- function: files_create_from_embedded
- types:
- filename: file
-
-- given: file {filename_on_disk} from {embedded_filename}
- function: files_create_from_embedded_with_other_name
- types:
- embedded_filename: file
-
-- given: file {filename} has modification time {year}-{month}-{day} {hour}:{minute}:{second}
- function: files_touch_with_timestamp
-
-- when: I write "(?P<text>.*)" to file (?P<filename>\S+)
- regex: true
- function: files_create_from_text
-
-- when: I remember metadata for file {filename}
- function: files_remember_metadata
-
-- when: I touch file {filename}
- function: files_touch
-
-- then: file {filename} exists
- function: files_file_exists
-
-- then: file {filename} does not exist
- function: files_file_does_not_exist
-
-- then: only files (?P<filenames>.+) exist
- function: files_only_these_exist
- regex: true
-
-- then: file (?P<filename>\S+) contains "(?P<data>.*)"
- regex: true
- function: files_file_contains
-
-- then: file (?P<filename>\S+) matches regex /(?P<regex>.*)/
- regex: true
- function: files_file_matches_regex
-
-- then: file (?P<filename>\S+) matches regex "(?P<regex>.*)"
- regex: true
- function: files_file_matches_regex
-
-- then: files {filename1} and {filename2} match
- function: files_match
-
-- then: file {filename} has same metadata as before
- function: files_has_remembered_metadata
-
-- then: file {filename} has different metadata from before
- function: files_has_different_metadata
-
-- then: file {filename} has changed from before
- function: files_has_different_metadata
-
-- then: file {filename} has a very recent modification time
- function: files_mtime_is_recent
-
-- then: file {filename} has a very old modification time
- function: files_mtime_is_ancient
diff --git a/subplot/vendored/runcmd.py b/subplot/vendored/runcmd.py
deleted file mode 100644
index a2564c6..0000000
--- a/subplot/vendored/runcmd.py
+++ /dev/null
@@ -1,252 +0,0 @@
-import logging
-import os
-import re
-import shlex
-import subprocess
-
-
-#
-# Helper functions.
-#
-
-# Get exit code or other stored data about the latest command run by
-# runcmd_run.
-
-
-def _runcmd_get(ctx, name):
- ns = ctx.declare("_runcmd")
- return ns[name]
-
-
-def runcmd_get_exit_code(ctx):
- return _runcmd_get(ctx, "exit")
-
-
-def runcmd_get_stdout(ctx):
- return _runcmd_get(ctx, "stdout")
-
-
-def runcmd_get_stdout_raw(ctx):
- return _runcmd_get(ctx, "stdout.raw")
-
-
-def runcmd_get_stderr(ctx):
- return _runcmd_get(ctx, "stderr")
-
-
-def runcmd_get_stderr_raw(ctx):
- return _runcmd_get(ctx, "stderr.raw")
-
-
-def runcmd_get_argv(ctx):
- return _runcmd_get(ctx, "argv")
-
-
-# Run a command, given an argv and other arguments for subprocess.Popen.
-#
-# This is meant to be a helper function, not bound directly to a step. The
-# stdout, stderr, and exit code are stored in the "_runcmd" namespace in the
-# 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"]
-
- logging.debug(f"runcmd_run")
- logging.debug(f" argv: {argv}")
- logging.debug(f" env: {env}")
- p = subprocess.Popen(
- argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, **kwargs
- )
- stdout, stderr = p.communicate("")
- ns["argv"] = argv
- ns["stdout.raw"] = stdout
- ns["stderr.raw"] = stderr
- ns["stdout"] = stdout.decode("utf-8")
- ns["stderr"] = stderr.decode("utf-8")
- ns["exit"] = p.returncode
- logging.debug(f" ctx: {ctx}")
- logging.debug(f" ns: {ns}")
-
-
-# Step: prepend srcdir to PATH whenever runcmd runs a command.
-def runcmd_helper_srcdir_path(ctx):
- srcdir = globals()["srcdir"]
- runcmd_prepend_to_path(ctx, srcdir)
-
-
-# Step: This creates a helper script.
-def runcmd_helper_script(ctx, filename=None):
- get_file = globals()["get_file"]
- with open(filename, "wb") as f:
- f.write(get_file(filename))
-
-
-#
-# Step functions for running commands.
-#
-
-
-def runcmd_prepend_to_path(ctx, dirname=None):
- ns = ctx.declare("_runcmd")
- pp = ns.get("path-prefix", "")
- if pp:
- pp = f"{pp}:{dirname}"
- else:
- pp = dirname
- ns["path-prefix"] = pp
-
-
-def runcmd_step(ctx, argv0=None, args=None):
- runcmd_try_to_run(ctx, argv0=argv0, args=args)
- runcmd_exit_code_is_zero(ctx)
-
-
-def runcmd_try_to_run(ctx, argv0=None, args=None):
- argv = [shlex.quote(argv0)] + shlex.split(args)
- runcmd_run(ctx, argv)
-
-
-#
-# Step functions for examining exit codes.
-#
-
-
-def runcmd_exit_code_is_zero(ctx):
- runcmd_exit_code_is(ctx, exit=0)
-
-
-def runcmd_exit_code_is(ctx, exit=None):
- assert_eq = globals()["assert_eq"]
- assert_eq(runcmd_get_exit_code(ctx), int(exit))
-
-
-def runcmd_exit_code_is_nonzero(ctx):
- runcmd_exit_code_is_not(ctx, exit=0)
-
-
-def runcmd_exit_code_is_not(ctx, exit=None):
- assert_ne = globals()["assert_ne"]
- assert_ne(runcmd_get_exit_code(ctx), int(exit))
-
-
-#
-# Step functions and helpers for examining output in various ways.
-#
-
-
-def runcmd_stdout_is(ctx, text=None):
- _runcmd_output_is(runcmd_get_stdout(ctx), text)
-
-
-def runcmd_stdout_isnt(ctx, text=None):
- _runcmd_output_isnt(runcmd_get_stdout(ctx), text)
-
-
-def runcmd_stderr_is(ctx, text=None):
- _runcmd_output_is(runcmd_get_stderr(ctx), text)
-
-
-def runcmd_stderr_isnt(ctx, text=None):
- _runcmd_output_isnt(runcmd_get_stderr(ctx), text)
-
-
-def _runcmd_output_is(actual, wanted):
- assert_eq = globals()["assert_eq"]
- wanted = bytes(wanted, "utf8").decode("unicode_escape")
- logging.debug("_runcmd_output_is:")
- logging.debug(f" actual: {actual!r}")
- logging.debug(f" wanted: {wanted!r}")
- assert_eq(actual, wanted)
-
-
-def _runcmd_output_isnt(actual, wanted):
- assert_ne = globals()["assert_ne"]
- wanted = bytes(wanted, "utf8").decode("unicode_escape")
- logging.debug("_runcmd_output_isnt:")
- logging.debug(f" actual: {actual!r}")
- logging.debug(f" wanted: {wanted!r}")
- assert_ne(actual, wanted)
-
-
-def runcmd_stdout_contains(ctx, text=None):
- _runcmd_output_contains(runcmd_get_stdout(ctx), text)
-
-
-def runcmd_stdout_doesnt_contain(ctx, text=None):
- _runcmd_output_doesnt_contain(runcmd_get_stdout(ctx), text)
-
-
-def runcmd_stderr_contains(ctx, text=None):
- _runcmd_output_contains(runcmd_get_stderr(ctx), text)
-
-
-def runcmd_stderr_doesnt_contain(ctx, text=None):
- _runcmd_output_doesnt_contain(runcmd_get_stderr(ctx), text)
-
-
-def _runcmd_output_contains(actual, wanted):
- assert_eq = globals()["assert_eq"]
- wanted = bytes(wanted, "utf8").decode("unicode_escape")
- logging.debug("_runcmd_output_contains:")
- logging.debug(f" actual: {actual!r}")
- logging.debug(f" wanted: {wanted!r}")
- assert_eq(wanted in actual, True)
-
-
-def _runcmd_output_doesnt_contain(actual, wanted):
- assert_ne = globals()["assert_ne"]
- wanted = bytes(wanted, "utf8").decode("unicode_escape")
- logging.debug("_runcmd_output_doesnt_contain:")
- logging.debug(f" actual: {actual!r}")
- logging.debug(f" wanted: {wanted!r}")
- assert_ne(wanted in actual, True)
-
-
-def runcmd_stdout_matches_regex(ctx, regex=None):
- _runcmd_output_matches_regex(runcmd_get_stdout(ctx), regex)
-
-
-def runcmd_stdout_doesnt_match_regex(ctx, regex=None):
- _runcmd_output_doesnt_match_regex(runcmd_get_stdout(ctx), regex)
-
-
-def runcmd_stderr_matches_regex(ctx, regex=None):
- _runcmd_output_matches_regex(runcmd_get_stderr(ctx), regex)
-
-
-def runcmd_stderr_doesnt_match_regex(ctx, regex=None):
- _runcmd_output_doesnt_match_regex(runcmd_get_stderr(ctx), regex)
-
-
-def _runcmd_output_matches_regex(actual, regex):
- assert_ne = globals()["assert_ne"]
- r = re.compile(regex)
- m = r.search(actual)
- logging.debug("_runcmd_output_matches_regex:")
- logging.debug(f" actual: {actual!r}")
- logging.debug(f" regex: {regex!r}")
- logging.debug(f" match: {m}")
- assert_ne(m, None)
-
-
-def _runcmd_output_doesnt_match_regex(actual, regex):
- assert_eq = globals()["assert_eq"]
- r = re.compile(regex)
- m = r.search(actual)
- logging.debug("_runcmd_output_doesnt_match_regex:")
- logging.debug(f" actual: {actual!r}")
- logging.debug(f" regex: {regex!r}")
- logging.debug(f" match: {m}")
- assert_eq(m, None)
diff --git a/subplot/vendored/runcmd.yaml b/subplot/vendored/runcmd.yaml
deleted file mode 100644
index 48dde90..0000000
--- a/subplot/vendored/runcmd.yaml
+++ /dev/null
@@ -1,83 +0,0 @@
-# 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
diff --git a/subplot/vmadm.py b/subplot/vmadm.py
index dcdcbb6..52233ed 100644
--- a/subplot/vmadm.py
+++ b/subplot/vmadm.py
@@ -1,3 +1,5 @@
+import io
+import json
import logging
import os
import shutil
@@ -105,3 +107,46 @@ def run_hostname_over_ssh(ctx, config=None, target=None, args=None):
runcmd_run(ctx, ["chmod", "-R", "u=rwX,go=", ".ssh"])
runcmd_run(ctx, ["ssh", "-F", config, target] + args.split())
runcmd_exit_code_is_zero(ctx)
+
+
+def stdout_json_matches(ctx, filename=None):
+ runcmd_get_stdout = globals()["runcmd_get_stdout"]
+
+ stdout = io.StringIO(runcmd_get_stdout(ctx))
+ actual = yaml.safe_load(stdout)
+
+ with open(filename) as f:
+ expected = json.load(f)
+
+ expected = _expand_tilde(expected)
+
+ logging.debug(f"actual: {actual}")
+ logging.debug(f"expect: {expected}")
+ _assert_equal_objects(actual, expected)
+
+
+def _expand_tilde(o):
+ if isinstance(o, str):
+ return os.path.expanduser(o)
+ elif isinstance(o, dict):
+ return {key: _expand_tilde(o[key]) for key in o}
+ elif isinstance(o, list):
+ return [_expand_tilde(value) for value in o]
+ else:
+ return o
+
+
+def _assert_equal_objects(a, b):
+ assert type(a) == type(b)
+ if isinstance(a, dict):
+ for key in a:
+ assert key in b, f"wanted b to have key {key!r}"
+ _assert_equal_objects(a[key], b[key])
+ elif isinstance(a, list):
+ assert len(a) == len(
+ b
+ ), f"wanted a and b to be of same length ({len(a)} vs {len(b)})"
+ for i in range(len(a)):
+ _assert_equal_objects(a[i], b[i])
+ else:
+ assert a == b, f"wanted {a!r} and {b!r} to be equal"
diff --git a/subplot/vmadm.yaml b/subplot/vmadm.yaml
index fb778dd..c6c5ad1 100644
--- a/subplot/vmadm.yaml
+++ b/subplot/vmadm.yaml
@@ -25,3 +25,6 @@
- then: "directories {actual} and {expected} are identical"
function: directories_match
+
+- then: "stdout, as JSON, matches file {filename} with tilde expansion"
+ function: stdout_json_matches
diff --git a/vmadm.md b/vmadm.md
index 2d97876..8c29443 100644
--- a/vmadm.md
+++ b/vmadm.md
@@ -50,11 +50,65 @@ default_memory_mib: 2048
default_cpus: 1
default_generate_host_certificate: true
default_autostart: true
+default_networks:
+- default
ca_key: ca_key
authorized_keys:
- ~/.ssh/id_rsa.pub
~~~
+~~~{#fullconfig.json .file .json}
+{
+ "image_directory": "~/images",
+ "default_base_image": "~/base.qcow2",
+ "default_image_gib": 5,
+ "default_memory_mib": 2048,
+ "default_cpus": 1,
+ "default_generate_host_certificate": true,
+ "default_autostart": true,
+ "default_networks": [
+ "default"
+ ],
+ "ca_key": "ca_key",
+ "authorized_keys": [
+ "~/.ssh/id_rsa.pub"
+ ]
+}
+~~~
+
+~~~{#spec.yaml .file .yaml}
+foo:
+ networks: ["lan", "wan"]
+~~~
+
+~~~{#fullspec.json .file .json}
+[
+ {
+ "name": "foo",
+ "ssh_keys": [
+ "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQChZ6mVuGLBpW7SarFU/Tu6TemquNxatbMUZuTk8RqVtbkvTKeWFZ5h5tntWPHgST8ykYFaIrr8eYuKQkKdBxHW7H8kejTNwRu/rDbRYX5wxTn4jw4RVopGTpxMlGrWeu5CkWPoLAhQtIzzUAnrDGp9sqG6P1G4ohI61wZMFQta9R2uNxXnnes+e2r4Y78GxmlQH/o0ouI8fBnsxRK0IoSfFs2LutO6wjyzR59FdC9TT7wufd5kXMRzxsmPGeXzNcaqvHGxBvRucGFclCkqSRwk3GNEpXZQhlCIoTIoRu0IPAp/430tlx9zJMhhwDlZsOOXRrFYpdWVMSTAAKECLSYx liw@exolobe1"
+ ],
+ "networks": ["lan", "wan"],
+ "rsa_host_key": null,
+ "rsa_host_cert": null,
+ "dsa_host_key": null,
+ "dsa_host_cert": null,
+ "ecdsa_host_key": null,
+ "ecdsa_host_cert": null,
+ "ed25519_host_key": null,
+ "ed25519_host_cert": null,
+ "base": "~/base.qcow2",
+ "image": "~/images/foo.qcow2",
+ "image_size_gib": 5,
+ "memory_mib": 2048,
+ "cpus": 1,
+ "generate_host_certificate": true,
+ "autostart": true,
+ "ca_key": "ca_key"
+ }
+]
+~~~
+
~~~{#ssh_key_pub .file}
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQChZ6mVuGLBpW7SarFU/Tu6TemquNxatbMUZuTk8RqVtbkvTKeWFZ5h5tntWPHgST8ykYFaIrr8eYuKQkKdBxHW7H8kejTNwRu/rDbRYX5wxTn4jw4RVopGTpxMlGrWeu5CkWPoLAhQtIzzUAnrDGp9sqG6P1G4ohI61wZMFQta9R2uNxXnnes+e2r4Y78GxmlQH/o0ouI8fBnsxRK0IoSfFs2LutO6wjyzR59FdC9TT7wufd5kXMRzxsmPGeXzNcaqvHGxBvRucGFclCkqSRwk3GNEpXZQhlCIoTIoRu0IPAp/430tlx9zJMhhwDlZsOOXRrFYpdWVMSTAAKECLSYx liw@exolobe1
~~~
@@ -202,6 +256,39 @@ when I invoke vmadm delete --config config.yaml smoke.yaml
+# Dump config
+
+This scenario verifies that vmadm can show its actual configuration.
+
+~~~scenario
+given an installed vmadm
+given a Debian 10 OpenStack cloud image
+given file .config/vmadm/config.yaml from config.yaml
+given file fullconfig.json
+when I run vmadm config
+then stdout, as JSON, matches file fullconfig.json with tilde expansion
+~~~
+
+# Dump specification
+
+This scenario verifies that vmadm can show the actual specification it
+will use.
+
+~~~scenario
+given an installed vmadm
+given a Debian 10 OpenStack cloud image
+given file .config/vmadm/config.yaml from config.yaml
+given file ca_key
+given file .ssh/id_rsa from ssh_key
+given file .ssh/id_rsa.pub from ssh_key_pub
+given file .ssh/config from ssh_config
+given file .ssh/known_hosts from known_hosts
+given file spec.yaml
+given file fullspec.json
+when I run vmadm spec spec.yaml
+then stdout, as JSON, matches file fullspec.json with tilde expansion
+~~~
+
# Colophon
This is a document meant to be processed with [Subplot][] into an HTML
@@ -213,10 +300,12 @@ author: "Lars Wirzenius"
template: python
bindings:
- subplot/vmadm.yaml
- - subplot/vendored/files.yaml
- - subplot/vendored/runcmd.yaml
+ - lib/files.yaml
+ - lib/runcmd.yaml
functions:
- subplot/vmadm.py
- - subplot/vendored/files.py
- - subplot/vendored/runcmd.py
+ - lib/files.py
+ - lib/runcmd.py
+classes:
+- json
...