summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2021-02-20 08:55:17 +0200
committerLars Wirzenius <liw@liw.fi>2021-02-20 19:39:24 +0200
commita315fab485429c0e4dfd665ced86f51130e3ac3c (patch)
tree8e320e2a4595befaff447005868d47ad266c0463
parent0d10bc096bb4d791b6528d7ca6d450c83cfd1778 (diff)
downloadvmadm-a315fab485429c0e4dfd665ced86f51130e3ac3c.tar.gz
feat: vmadm command to create, list, and delete virtual machines
Includes test suite.
-rw-r--r--.gitignore5
-rw-r--r--Cargo.lock540
-rw-r--r--Cargo.toml7
-rwxr-xr-xcheck35
-rw-r--r--src/bin/tool.rs27
-rw-r--r--src/bin/vmadm.rs156
-rw-r--r--src/image.rs2
-rw-r--r--src/install.rs12
-rw-r--r--src/lib.rs1
-rw-r--r--src/spec.rs37
-rw-r--r--ssh/config4
-rw-r--r--ssh/test.key27
-rw-r--r--ssh/test.key.pub1
-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.py50
-rw-r--r--subplot/vmadm.yaml9
-rw-r--r--vmadm.md95
20 files changed, 1528 insertions, 35 deletions
diff --git a/.gitignore b/.gitignore
index 96ef6c0..4a1a838 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
/target
-Cargo.lock
+vmadm.html
+vmadm.pdf
+test.py
+test.log
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..23e8b95
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,540 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+[[package]]
+name = "aho-corasick"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "ansi_term"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "afddf7f520a80dbf76e6f50a35bca42a2331ef227a28b3b6dc5c2e2338d114b1"
+
+[[package]]
+name = "atty"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "bitflags"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
+
+[[package]]
+name = "bytesize"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81a18687293a1546b67c246452202bbbf143d239cb43494cc163da14979082da"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "clap"
+version = "2.33.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002"
+dependencies = [
+ "ansi_term",
+ "atty",
+ "bitflags",
+ "strsim",
+ "textwrap",
+ "unicode-width",
+ "vec_map",
+]
+
+[[package]]
+name = "dtoa"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88d7ed2934d741c6b37e33e3832298e8850b53fd2d2bea03873375596c7cea4e"
+
+[[package]]
+name = "env_logger"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36"
+dependencies = [
+ "atty",
+ "humantime",
+ "log",
+ "regex",
+ "termcolor",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "heck"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "humantime"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f"
+dependencies = [
+ "quick-error",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
+name = "libc"
+version = "0.2.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7282d924be3275cec7f6756ff4121987bc6481325397dde6ba3e7802b1a8b1c"
+
+[[package]]
+name = "linked-hash-map"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
+
+[[package]]
+name = "log"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "memchr"
+version = "2.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525"
+
+[[package]]
+name = "once_cell"
+version = "1.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13bd41f508810a131401606d54ac32a467c97172d74ba7662562ebba5ad07fa0"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857"
+
+[[package]]
+name = "pretty_env_logger"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "926d36b9553851b8b0005f1275891b392ee4d2d833852c417ed025477350fb9d"
+dependencies = [
+ "env_logger",
+ "log",
+]
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71"
+dependencies = [
+ "unicode-xid",
+]
+
+[[package]]
+name = "quick-error"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
+
+[[package]]
+name = "quote"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+ "rand_hc",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34cf66eb183df1c5876e2dcf6b13d57340741e8dc255b48e40a26de954d06ae7"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "rand_hc"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94341e4e44e24f6b591b59e47a8a027df12e008d73fd5672dbea9cc22f4507d9"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9251239e129e16308e70d853559389de218ac275b515068abc96829d05b948a"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+ "thread_local",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.6.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5eb417147ba9860a96cfe72a0b93bf88fee1744b5636ec99ab20c1aa9376581"
+
+[[package]]
+name = "remove_dir_all"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d5161132722baa40d802cc70b15262b98258453e85e5d1d365c757c73869ae"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9391c295d64fc0abb2c556bad848f33cb8296276b1ad2677d1ae1ace4f258f31"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.8.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15654ed4ab61726bf918a39cb8d98a2e2995b002387807fa6ba58fdf7f59bb23"
+dependencies = [
+ "dtoa",
+ "linked-hash-map",
+ "serde",
+ "yaml-rust",
+]
+
+[[package]]
+name = "strsim"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
+
+[[package]]
+name = "structopt"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5277acd7ee46e63e5168a80734c9f6ee81b1367a7d8772a2d765df2a3705d28c"
+dependencies = [
+ "clap",
+ "lazy_static",
+ "structopt-derive",
+]
+
+[[package]]
+name = "structopt-derive"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ba9cdfda491b814720b6b06e0cac513d922fc407582032e8706e9f137976f90"
+dependencies = [
+ "heck",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c700597eca8a5a762beb35753ef6b94df201c81cca676604f547495a0d7f0081"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "rand",
+ "redox_syscall",
+ "remove_dir_all",
+ "winapi",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
+dependencies = [
+ "unicode-width",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0f4a65597094d4483ddaed134f409b2cb7c1beccf25201a9f73c719254fa98e"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7765189610d8241a44529806d6fd1f2e0a08734313a35d5b3a556f92b381f3c0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
+
+[[package]]
+name = "vec_map"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
+
+[[package]]
+name = "version_check"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed"
+
+[[package]]
+name = "virt"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06ce5cd536aa559d1088ddc531bbf3e55edc5db8ec0a5329dfb17f83a5b469ca"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "vmadm"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "bytesize",
+ "log",
+ "pretty_env_logger",
+ "serde",
+ "serde_yaml",
+ "structopt",
+ "tempfile",
+ "thiserror",
+ "virt",
+]
+
+[[package]]
+name = "wasi"
+version = "0.10.2+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "yaml-rust"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
+dependencies = [
+ "linked-hash-map",
+]
diff --git a/Cargo.toml b/Cargo.toml
index acda35e..1772d0d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,5 +8,12 @@ edition = "2018"
[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"
+log = "0.4"
+pretty_env_logger = "0.4"
diff --git a/check b/check
new file mode 100755
index 0000000..c488509
--- /dev/null
+++ b/check
@@ -0,0 +1,35 @@
+#!/bin/sh
+#
+# Run the automated tests for the project.
+
+set -eu
+
+hideok=chronic
+if [ "$#" -gt 0 ]
+then
+ case "$1" in
+ verbose | -v | --verbose)
+ hideok=
+ shift
+ ;;
+ esac
+fi
+
+got_cargo_cmd()
+{
+ cargo --list | grep " $1 " > /dev/null
+}
+
+$hideok cargo build --all-targets
+got_cargo_cmd clippy && $hideok cargo clippy
+got_cargo_cmd fmt && $hideok cargo fmt -- --check
+$hideok cargo test
+
+sp-docgen vmadm.md -o vmadm.html
+sp-docgen vmadm.md -o vmadm.pdf
+
+sp-codegen vmadm.md -o test.py
+rm -f test.log
+$hideok python3 test.py --log test.log "$@"
+
+echo "Everything seems to be in order."
diff --git a/src/bin/tool.rs b/src/bin/tool.rs
deleted file mode 100644
index 18a5c19..0000000
--- a/src/bin/tool.rs
+++ /dev/null
@@ -1,27 +0,0 @@
-use std::path::PathBuf;
-
-use vmadm::cloudinit::CloudInitConfig;
-use vmadm::image::VirtualMachineImage;
-use vmadm::install::{virt_install, VirtInstallArgs};
-
-const BASE_PATH: &'static str = "/home/liw/tmp/debian-10-openstack-amd64.qcow2";
-const IMAGE_PATH: &'static str = "/home/liw/tmp/try-vm.qcow2";
-
-fn main() -> anyhow::Result<()> {
- let mut init = CloudInitConfig::default();
- init.set_hostname("toy-vm");
- init.set_authorized_keys("xxx liw-openpgp xxx");
- println!("init: {:#?}", init);
-
- let base = PathBuf::from(BASE_PATH);
- let image = PathBuf::from(IMAGE_PATH);
- let image = VirtualMachineImage::new_from_base(&base, &image)?;
- image.resize(1024 * 1024 * 1024 * 10)?;
-
- let args = VirtInstallArgs::new("toy-vm", &image, &init);
- println!("{:#?}", args);
- virt_install(&args)?;
- println!("OK");
-
- Ok(())
-}
diff --git a/src/bin/vmadm.rs b/src/bin/vmadm.rs
new file mode 100644
index 0000000..0da9af9
--- /dev/null
+++ b/src/bin/vmadm.rs
@@ -0,0 +1,156 @@
+use bytesize::GIB;
+use log::{debug, info};
+use std::fs;
+use std::net::TcpStream;
+use std::path::{Path, PathBuf};
+use std::thread;
+use std::time::Duration;
+use structopt::StructOpt;
+use virt::connect::Connect;
+use vmadm::cloudinit::CloudInitConfig;
+use vmadm::image::VirtualMachineImage;
+use vmadm::install::{virt_install, VirtInstallArgs};
+use vmadm::spec::Specification;
+
+const SSH_PORT: i32 = 22;
+
+#[derive(StructOpt, Debug)]
+enum Cli {
+ New {
+ #[structopt(help = "create a new virtual machine", parse(from_os_str))]
+ spec: PathBuf,
+ },
+ List,
+ Delete {
+ #[structopt(help = "create a new virtual machine", parse(from_os_str))]
+ spec: PathBuf,
+ },
+}
+
+fn main() -> anyhow::Result<()> {
+ pretty_env_logger::init();
+ match Cli::from_args() {
+ Cli::New { spec } => new(&spec)?,
+ Cli::List => list()?,
+ Cli::Delete { spec } => delete(&spec)?,
+ }
+ Ok(())
+}
+
+fn new(spec: &Path) -> anyhow::Result<()> {
+ info!("creating new VM");
+
+ debug!("reading specification from {}", spec.display());
+ let spec = fs::read(spec)?;
+ let spec: Specification = serde_yaml::from_slice(&spec)?;
+
+ debug!("reading specified SSH public keys");
+ let ssh_keys = spec.ssh_keys()?;
+
+ info!("creating cloud-init config");
+ let mut init = CloudInitConfig::default();
+ init.set_hostname(&spec.name);
+ init.set_authorized_keys(&ssh_keys);
+
+ info!(
+ "creating VM image {} from {}",
+ spec.image.display(),
+ spec.base.display()
+ );
+ let image = VirtualMachineImage::new_from_base(&spec.base, &spec.image)?;
+
+ info!("resizing image to {} GiB", spec.image_size_gib);
+ image.resize(spec.image_size_gib * GIB)?;
+
+ info!("creating VM");
+ let mut args = VirtInstallArgs::new(&spec.name, &image, &init);
+ args.set_memory(spec.memory_mib);
+ args.set_vcpus(spec.cpus);
+ virt_install(&args)?;
+
+ info!("waiting for {} to open its SSH port", spec.name);
+ wait_for_port(&spec.name, SSH_PORT)?;
+
+ Ok(())
+}
+
+fn wait_for_port(name: &str, port: i32) -> anyhow::Result<()> {
+ let addr = format!("{}:{}", name, port);
+ loop {
+ match TcpStream::connect(&addr) {
+ Ok(_) => return Ok(()),
+ Err(_) => (),
+ }
+ }
+}
+
+fn list() -> anyhow::Result<()> {
+ let conn = Connect::open("qemu:///system")?;
+ let domains = conn.list_all_domains(0)?;
+ for domain in domains {
+ let name = domain.get_name()?;
+ let (state, _) = domain.get_state()?;
+ let state = state_name(state);
+ println!("{} {}", name, state);
+ }
+
+ Ok(())
+}
+
+fn state_name(state: virt::domain::DomainState) -> String {
+ let name = match state {
+ virt::domain::VIR_DOMAIN_NOSTATE => "none",
+ virt::domain::VIR_DOMAIN_RUNNING => "running",
+ virt::domain::VIR_DOMAIN_BLOCKED => "blocked",
+ virt::domain::VIR_DOMAIN_PAUSED => "paused",
+ virt::domain::VIR_DOMAIN_SHUTDOWN => "shutdown",
+ virt::domain::VIR_DOMAIN_SHUTOFF => "shutoff",
+ virt::domain::VIR_DOMAIN_CRASHED => "crashed",
+ virt::domain::VIR_DOMAIN_PMSUSPENDED => "power management suspended",
+ _ => "unknown",
+ };
+ name.to_string()
+}
+
+fn delete(spec: &Path) -> anyhow::Result<()> {
+ info!("deleting virtual machine specified in {}", spec.display());
+
+ debug!("reading specification from {}", spec.display());
+ let spec = fs::read(spec)?;
+ let spec: Specification = serde_yaml::from_slice(&spec)?;
+
+ debug!("connecting to libvirtd");
+ let conn = Connect::open("qemu:///system")?;
+
+ debug!("listing all domains");
+ let domains = conn.list_all_domains(0)?;
+
+ for domain in domains {
+ debug!("considering {}", domain.get_name()?);
+ if domain.get_name()? == spec.name {
+ debug!("shutdown {}", spec.name);
+ domain.shutdown().ok();
+
+ let briefly = Duration::from_millis(1000);
+ loop {
+ thread::sleep(briefly);
+ match domain.is_active() {
+ Ok(true) => (),
+ Ok(false) => break,
+ Err(err) => {
+ debug!("is_active: {}", err);
+ ()
+ }
+ }
+ debug!("{} is still running", spec.name);
+ }
+
+ debug!("undefine {}", spec.name);
+ domain.undefine()?;
+
+ debug!("removing image file {}", spec.image.display());
+ std::fs::remove_file(&spec.image)?;
+ }
+ }
+ Ok(())
+}
diff --git a/src/image.rs b/src/image.rs
index da7e559..db6102f 100644
--- a/src/image.rs
+++ b/src/image.rs
@@ -32,7 +32,7 @@ impl VirtualMachineImage {
&self.filename
}
- pub fn resize(&self, new_size: usize) -> Result<(), ImageError> {
+ pub fn resize(&self, new_size: u64) -> Result<(), ImageError> {
let r = Command::new("qemu-img")
.arg("resize")
.arg(self.filename())
diff --git a/src/install.rs b/src/install.rs
index 89969e0..21b9f3f 100644
--- a/src/install.rs
+++ b/src/install.rs
@@ -22,8 +22,8 @@ pub enum VirtInstallError {
#[derive(Debug)]
pub struct VirtInstallArgs {
name: String,
- memory: usize,
- vcpus: usize,
+ memory: u64,
+ vcpus: u64,
image: VirtualMachineImage,
init: CloudInitConfig,
}
@@ -43,19 +43,19 @@ impl VirtInstallArgs {
&self.name
}
- pub fn memory(&self) -> usize {
+ pub fn memory(&self) -> u64 {
self.memory
}
- pub fn set_memory(&mut self, memory: usize) {
+ pub fn set_memory(&mut self, memory: u64) {
self.memory = memory
}
- pub fn vcpus(&self) -> usize {
+ pub fn vcpus(&self) -> u64 {
self.vcpus
}
- pub fn set_vcpus(&mut self, vcpus: usize) {
+ pub fn set_vcpus(&mut self, vcpus: u64) {
self.vcpus = vcpus
}
diff --git a/src/lib.rs b/src/lib.rs
index 5ea98ac..4cc1d15 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,4 @@
pub mod cloudinit;
pub mod image;
pub mod install;
+pub mod spec;
diff --git a/src/spec.rs b/src/spec.rs
new file mode 100644
index 0000000..1209040
--- /dev/null
+++ b/src/spec.rs
@@ -0,0 +1,37 @@
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Specification {
+ pub name: String,
+
+ #[serde(default)]
+ pub ssh_key_files: Vec<PathBuf>,
+
+ pub base: PathBuf,
+ pub image: PathBuf,
+ pub image_size_gib: u64,
+ pub memory_mib: u64,
+ pub cpus: u64,
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum SpecificationError {
+ #[error(transparent)]
+ IoError(#[from] std::io::Error),
+
+ #[error(transparent)]
+ FromUtf8Error(#[from] std::string::FromUtf8Error),
+}
+
+impl Specification {
+ pub fn ssh_keys(&self) -> Result<String, SpecificationError> {
+ let mut keys = String::new();
+ for filename in self.ssh_key_files.iter() {
+ let key = std::fs::read(filename)?;
+ let key = String::from_utf8(key)?;
+ keys.push_str(&key);
+ }
+ Ok(keys)
+ }
+}
diff --git a/ssh/config b/ssh/config
new file mode 100644
index 0000000..eb1be72
--- /dev/null
+++ b/ssh/config
@@ -0,0 +1,4 @@
+host *
+ userknownhostsfile=/dev/null
+ stricthostkeychecking=accept-new
+ identityfile=ssh/test.key \ No newline at end of file
diff --git a/ssh/test.key b/ssh/test.key
new file mode 100644
index 0000000..75eba0f
--- /dev/null
+++ b/ssh/test.key
@@ -0,0 +1,27 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
+NhAAAAAwEAAQAAAQEAoWeplbhiwaVu0mqxVP07uk3pqrjcWrWzFGbk5PEalbW5L0ynlhWe
+YebZ7Vjx4Ek/MpGBWiK6/HmLikJCnQcR1ux/JHo0zcEbv6w20WF+cMU5+I8OEVaKRk6cTJ
+Rq1nruQpFj6CwIULSM81AJ6wxqfbKhuj9RuKISOtcGTBULWvUdrjcV553rPntq+GO/BsZp
+UB/6NKLiPHwZ7MUStCKEnxbNi7rTusI8s0efRXQvU0+8Ln3eZFzEc8bJjxnl8zXGqrxxsQ
+b0bnBhXJQpKkkcJNxjRKV2UIZQiKEyKEbtCDwKf+N9LZcfcyTIYcA5WbDjl0axWKXVlTEk
+wAChAi0mMQAAA8jfx6KY38eimAAAAAdzc2gtcnNhAAABAQChZ6mVuGLBpW7SarFU/Tu6Te
+mquNxatbMUZuTk8RqVtbkvTKeWFZ5h5tntWPHgST8ykYFaIrr8eYuKQkKdBxHW7H8kejTN
+wRu/rDbRYX5wxTn4jw4RVopGTpxMlGrWeu5CkWPoLAhQtIzzUAnrDGp9sqG6P1G4ohI61w
+ZMFQta9R2uNxXnnes+e2r4Y78GxmlQH/o0ouI8fBnsxRK0IoSfFs2LutO6wjyzR59FdC9T
+T7wufd5kXMRzxsmPGeXzNcaqvHGxBvRucGFclCkqSRwk3GNEpXZQhlCIoTIoRu0IPAp/43
+0tlx9zJMhhwDlZsOOXRrFYpdWVMSTAAKECLSYxAAAAAwEAAQAAAQAJ417pVD2AnZD3hR/O
+FCGHnWRWWDLvv7fz5QXa3MaDK3nn4utVb4efedQaDVvsILCleXKSQhRwiUW6N6r7EcbPAv
+gbFP2NKWp4yKUnGLD1Wa/egW0cNNAN1J0Qt/r/ntJf86ZKQABWaWlFMr8Yzk7r2ni7/0sT
+Y6J4RloVaSij7s1uZ76sTw/REGF/BNX0BC1FTDlQE3jTQptEYxGbLGQFYYhDl37Zv3Emnf
+j7ZA8pkwrUn6mPy5JEZTjp2MgFD8oF8XxzfXWtFJP9UuDUJrLcQD8hOunUrPQbnazpxOHw
+0vvF7K7B9l0mLJX7UmQlBW8Op+tv1jJugJ29rDjT3FcRAAAAgFtDhZCLc2ihQiOK2zEqb5
+bUk4x9a42othQQNOvMEkwCLxmKTVjoYrClSp+9j6blkKESiGCxAu8MC7Hc8JV2NOkIrorl
+K35KgwCiLyEXPedQ/+ZPoO1a4ZIGHPfxosbmh9byJYgvDQ4E8gqRUlEhtYSOzyeObfXh7T
+7QcNtKOvo+AAAAgQDVK194Jl97URrb23+jgFPfWeNb1daeLUc/DYCDaJgHVom8zAxKPsHP
+sYdy5dPGNTiMttdk2JpzqAmo7G1/QDPB5sHNcWEAQE0PGagYGFJhBMhB9Ug6iJek14h9nS
+/m6BfVy3fQW062sztE7dw5lzGTTQajJWX7z4VDm4VSaraHXQAAAIEAwdW4Xk8xS2Jdp3Gr
+/+0YG4+90rLpidS1z0SgT+a8NeLA3KXwDhdunHDxGn9QKgSCZ8ogHmcGN4x07jI3+3ajdK
+7Xe/QIlNLAnXEX7Kkrx3+FmakCXs/aN5xTA0J6s2Hyj9MwJIX0C+EmAzxEcIbHvbKxKVBp
+V4cecTlFJGBtUOUAAAAMbGl3QGV4b2xvYmUxAQIDBAUGBw==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/ssh/test.key.pub b/ssh/test.key.pub
new file mode 100644
index 0000000..d622722
--- /dev/null
+++ b/ssh/test.key.pub
@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQChZ6mVuGLBpW7SarFU/Tu6TemquNxatbMUZuTk8RqVtbkvTKeWFZ5h5tntWPHgST8ykYFaIrr8eYuKQkKdBxHW7H8kejTNwRu/rDbRYX5wxTn4jw4RVopGTpxMlGrWeu5CkWPoLAhQtIzzUAnrDGp9sqG6P1G4ohI61wZMFQta9R2uNxXnnes+e2r4Y78GxmlQH/o0ouI8fBnsxRK0IoSfFs2LutO6wjyzR59FdC9TT7wufd5kXMRzxsmPGeXzNcaqvHGxBvRucGFclCkqSRwk3GNEpXZQhlCIoTIoRu0IPAp/430tlx9zJMhhwDlZsOOXRrFYpdWVMSTAAKECLSYx liw@exolobe1
diff --git a/subplot/vendored/files.py b/subplot/vendored/files.py
new file mode 100644
index 0000000..ec37b9d
--- /dev/null
+++ b/subplot/vendored/files.py
@@ -0,0 +1,158 @@
+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
new file mode 100644
index 0000000..be69920
--- /dev/null
+++ b/subplot/vendored/files.yaml
@@ -0,0 +1,62 @@
+- 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
new file mode 100644
index 0000000..a2564c6
--- /dev/null
+++ b/subplot/vendored/runcmd.py
@@ -0,0 +1,252 @@
+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
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
diff --git a/subplot/vmadm.py b/subplot/vmadm.py
new file mode 100644
index 0000000..7a13635
--- /dev/null
+++ b/subplot/vmadm.py
@@ -0,0 +1,50 @@
+import os
+import yaml
+
+
+def install_vmadm(ctx):
+ runcmd_prepend_to_path = globals()["runcmd_prepend_to_path"]
+ srcdir = globals()["srcdir"]
+ runcmd_prepend_to_path(ctx, os.path.join(srcdir, "target", "debug"))
+
+ # Set permissions on the datadir and its parent. They are 0o700 by default,
+ # which means that the libvirt daemon can't access the virtual machine
+ # image we create.
+ os.chmod(".", 0o711)
+ os.chmod("..", 0o711)
+
+ # Create .ssh directory, so that the scenario can put files there later.
+ # This can be removed once the Subplot lib/files library creates
+ # directories.
+ os.mkdir(".ssh")
+
+
+def create_vm(ctx, filename=None):
+ runcmd_run = globals()["runcmd_run"]
+ runcmd_exit_code_is_zero = globals()["runcmd_exit_code_is_zero"]
+
+ with open(filename) as f:
+ spec = yaml.load(f)
+ ctx["spec"] = spec
+
+ runcmd_run(ctx, ["vmadm", "new", filename])
+ runcmd_exit_code_is_zero(ctx)
+
+
+def delete_vm(ctx, filename=None):
+ runcmd_run = globals()["runcmd_run"]
+ runcmd_exit_code_is_zero = globals()["runcmd_exit_code_is_zero"]
+
+ name = ctx["spec"]["name"]
+ runcmd_run(ctx, ["vmadm", "delete", name])
+ runcmd_exit_code_is_zero(ctx)
+
+
+def run_hostname_over_ssh(ctx, config=None, target=None, args=None):
+ runcmd_run = globals()["runcmd_run"]
+ runcmd_exit_code_is_zero = globals()["runcmd_exit_code_is_zero"]
+
+ # Fix permissions for .ssh and its contents.
+ runcmd_run(ctx, ["chmod", "-R", "u=rwX,go=", ".ssh"])
+ runcmd_run(ctx, ["ssh", "-F", config, target] + args.split())
+ runcmd_exit_code_is_zero(ctx)
diff --git a/subplot/vmadm.yaml b/subplot/vmadm.yaml
new file mode 100644
index 0000000..40fce49
--- /dev/null
+++ b/subplot/vmadm.yaml
@@ -0,0 +1,9 @@
+- given: "an installed vmadm"
+ function: install_vmadm
+
+- when: "I invoke vmadm new {filename}"
+ function: create_vm
+ cleanup: delete_vm
+
+- when: "I invoke ssh -F {config} {target} {args:text}"
+ function: run_hostname_over_ssh
diff --git a/vmadm.md b/vmadm.md
new file mode 100644
index 0000000..4936dad
--- /dev/null
+++ b/vmadm.md
@@ -0,0 +1,95 @@
+# Create a virtual machine
+
+This scenario verifies that vmadm can create a virtual machine and
+that the user can log into it as root via SSH after it has booted.
+This requires that the environment it set up so that virtual machines
+can be addressed by name.
+
+~~~scenario
+given an installed vmadm
+given file smoke.yaml
+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
+when I invoke vmadm new smoke.yaml
+when I invoke ssh -F .ssh/config debian@smoke hostname
+then stdout contains "smoke"
+when I invoke ssh -F .ssh/config debian@smoke df -h /
+then stdout contains "4.9G"
+when I invoke ssh -F .ssh/config debian@smoke free -m
+then stdout contains "2000"
+~~~
+
+
+~~~{#smoke.yaml .file .yaml}
+name: smoke
+ssh_key_files:
+ - .ssh/id_rsa.pub
+base: /home/liw/tmp/debian-10-openstack-amd64.qcow2
+image: smoke.qcow2
+image_size_gib: 5
+memory_mib: 2048
+~~~
+
+~~~{#ssh_key .file}
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
+NhAAAAAwEAAQAAAQEAoWeplbhiwaVu0mqxVP07uk3pqrjcWrWzFGbk5PEalbW5L0ynlhWe
+YebZ7Vjx4Ek/MpGBWiK6/HmLikJCnQcR1ux/JHo0zcEbv6w20WF+cMU5+I8OEVaKRk6cTJ
+Rq1nruQpFj6CwIULSM81AJ6wxqfbKhuj9RuKISOtcGTBULWvUdrjcV553rPntq+GO/BsZp
+UB/6NKLiPHwZ7MUStCKEnxbNi7rTusI8s0efRXQvU0+8Ln3eZFzEc8bJjxnl8zXGqrxxsQ
+b0bnBhXJQpKkkcJNxjRKV2UIZQiKEyKEbtCDwKf+N9LZcfcyTIYcA5WbDjl0axWKXVlTEk
+wAChAi0mMQAAA8jfx6KY38eimAAAAAdzc2gtcnNhAAABAQChZ6mVuGLBpW7SarFU/Tu6Te
+mquNxatbMUZuTk8RqVtbkvTKeWFZ5h5tntWPHgST8ykYFaIrr8eYuKQkKdBxHW7H8kejTN
+wRu/rDbRYX5wxTn4jw4RVopGTpxMlGrWeu5CkWPoLAhQtIzzUAnrDGp9sqG6P1G4ohI61w
+ZMFQta9R2uNxXnnes+e2r4Y78GxmlQH/o0ouI8fBnsxRK0IoSfFs2LutO6wjyzR59FdC9T
+T7wufd5kXMRzxsmPGeXzNcaqvHGxBvRucGFclCkqSRwk3GNEpXZQhlCIoTIoRu0IPAp/43
+0tlx9zJMhhwDlZsOOXRrFYpdWVMSTAAKECLSYxAAAAAwEAAQAAAQAJ417pVD2AnZD3hR/O
+FCGHnWRWWDLvv7fz5QXa3MaDK3nn4utVb4efedQaDVvsILCleXKSQhRwiUW6N6r7EcbPAv
+gbFP2NKWp4yKUnGLD1Wa/egW0cNNAN1J0Qt/r/ntJf86ZKQABWaWlFMr8Yzk7r2ni7/0sT
+Y6J4RloVaSij7s1uZ76sTw/REGF/BNX0BC1FTDlQE3jTQptEYxGbLGQFYYhDl37Zv3Emnf
+j7ZA8pkwrUn6mPy5JEZTjp2MgFD8oF8XxzfXWtFJP9UuDUJrLcQD8hOunUrPQbnazpxOHw
+0vvF7K7B9l0mLJX7UmQlBW8Op+tv1jJugJ29rDjT3FcRAAAAgFtDhZCLc2ihQiOK2zEqb5
+bUk4x9a42othQQNOvMEkwCLxmKTVjoYrClSp+9j6blkKESiGCxAu8MC7Hc8JV2NOkIrorl
+K35KgwCiLyEXPedQ/+ZPoO1a4ZIGHPfxosbmh9byJYgvDQ4E8gqRUlEhtYSOzyeObfXh7T
+7QcNtKOvo+AAAAgQDVK194Jl97URrb23+jgFPfWeNb1daeLUc/DYCDaJgHVom8zAxKPsHP
+sYdy5dPGNTiMttdk2JpzqAmo7G1/QDPB5sHNcWEAQE0PGagYGFJhBMhB9Ug6iJek14h9nS
+/m6BfVy3fQW062sztE7dw5lzGTTQajJWX7z4VDm4VSaraHXQAAAIEAwdW4Xk8xS2Jdp3Gr
+/+0YG4+90rLpidS1z0SgT+a8NeLA3KXwDhdunHDxGn9QKgSCZ8ogHmcGN4x07jI3+3ajdK
+7Xe/QIlNLAnXEX7Kkrx3+FmakCXs/aN5xTA0J6s2Hyj9MwJIX0C+EmAzxEcIbHvbKxKVBp
+V4cecTlFJGBtUOUAAAAMbGl3QGV4b2xvYmUxAQIDBAUGBw==
+-----END OPENSSH PRIVATE KEY-----
+~~~
+
+~~~{#ssh_key_pub .file}
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQChZ6mVuGLBpW7SarFU/Tu6TemquNxatbMUZuTk8RqVtbkvTKeWFZ5h5tntWPHgST8ykYFaIrr8eYuKQkKdBxHW7H8kejTNwRu/rDbRYX5wxTn4jw4RVopGTpxMlGrWeu5CkWPoLAhQtIzzUAnrDGp9sqG6P1G4ohI61wZMFQta9R2uNxXnnes+e2r4Y78GxmlQH/o0ouI8fBnsxRK0IoSfFs2LutO6wjyzR59FdC9TT7wufd5kXMRzxsmPGeXzNcaqvHGxBvRucGFclCkqSRwk3GNEpXZQhlCIoTIoRu0IPAp/430tlx9zJMhhwDlZsOOXRrFYpdWVMSTAAKECLSYx liw@exolobe1
+~~~
+
+~~~{#ssh_config .file}
+host *
+ userknownhostsfile=/dev/null
+ stricthostkeychecking=accept-new
+ identityfile=.ssh/id_rsa
+ passwordauthentication=no
+~~~
+
+
+
+# Colophon
+
+This is a document meant to be processed with [Subplot][] into an HTML
+document, a PDF document, and an executable program.
+
+---
+title: "vmadm &ndash; virtual machine administration"
+author: "Lars Wirzenius"
+template: python
+bindings:
+ - subplot/vmadm.yaml
+ - subplot/vendored/files.yaml
+ - subplot/vendored/runcmd.yaml
+functions:
+ - subplot/vmadm.py
+ - subplot/vendored/files.py
+ - subplot/vendored/runcmd.py
+...