diff options
author | Lars Wirzenius <liw@liw.fi> | 2021-07-25 08:47:19 +0000 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2021-07-25 08:47:19 +0000 |
commit | 1cab04fbfc732ff0cbcd0394fa0374300e1176e8 (patch) | |
tree | 39e093d889e00f52be1fd000a88333b45546af01 | |
parent | c27b412e2d8ac7bf052a227caee88e6242c0fe14 (diff) | |
parent | b1a00546f16a8874b8f17e33d529b839692b2bd3 (diff) | |
download | vmadm-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.lock | 26 | ||||
-rw-r--r-- | Cargo.toml | 15 | ||||
-rw-r--r-- | README.md | 4 | ||||
-rw-r--r-- | src/cmd/config.rs | 6 | ||||
-rw-r--r-- | src/cmd/spec.rs | 10 | ||||
-rw-r--r-- | src/config.rs | 4 | ||||
-rw-r--r-- | src/install.rs | 22 | ||||
-rw-r--r-- | src/spec.rs | 1 | ||||
-rw-r--r-- | subplot/vendored/files.py | 158 | ||||
-rw-r--r-- | subplot/vendored/files.yaml | 62 | ||||
-rw-r--r-- | subplot/vendored/runcmd.py | 252 | ||||
-rw-r--r-- | subplot/vendored/runcmd.yaml | 83 | ||||
-rw-r--r-- | subplot/vmadm.py | 45 | ||||
-rw-r--r-- | subplot/vmadm.yaml | 3 | ||||
-rw-r--r-- | vmadm.md | 97 |
15 files changed, 215 insertions, 573 deletions
@@ -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", @@ -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" @@ -73,6 +73,7 @@ following fields: * `default_cpus` – default number of CPUs for a VM * `default_generate_host_certificate` – should SSH host certificates be generated by default? +* `default_networks` – networks to which VM should be added * `image_directory` – directory where VM image files are put * `authorized_keys` – 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` – override host certification setting +* `networks` – 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` – overrides default CA key * `rsa_host_key` – RSA host key to install on host * `rsa_host_cert` – 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 @@ -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 ... |