summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2021-02-27 07:54:40 +0200
committerLars Wirzenius <liw@liw.fi>2021-02-28 12:24:08 +0200
commit1c5c97532c91faf8a59bbe7ebcfc645f653db697 (patch)
tree5d690b3e826472c8515710dce3d9bd256106b249
parent98c482d7903d059a6598a6e7280f0e7b431eac29 (diff)
downloadvmadm-1c5c97532c91faf8a59bbe7ebcfc645f653db697.tar.gz
feat: export cloud-init config, set SSH host keys
-rw-r--r--Cargo.lock7
-rw-r--r--Cargo.toml1
-rw-r--r--src/bin/vmadm.rs60
-rw-r--r--src/cloudinit.rs307
-rw-r--r--src/spec.rs10
-rw-r--r--subplot/vmadm.py30
-rw-r--r--subplot/vmadm.yaml6
-rw-r--r--vmadm.md105
8 files changed, 440 insertions, 86 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 23e8b95..2a03b2b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -337,6 +337,12 @@ dependencies = [
]
[[package]]
+name = "shell-words"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fa3938c99da4914afedd13bf3d79bcb6c277d1b2c398d23257a304d9e1b074"
+
+[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -487,6 +493,7 @@ dependencies = [
"pretty_env_logger",
"serde",
"serde_yaml",
+ "shell-words",
"structopt",
"tempfile",
"thiserror",
diff --git a/Cargo.toml b/Cargo.toml
index 1772d0d..54db867 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,3 +17,4 @@ serde_yaml = "0.8"
bytesize = "1"
log = "0.4"
pretty_env_logger = "0.4"
+shell-words = "1"
diff --git a/src/bin/vmadm.rs b/src/bin/vmadm.rs
index 0da9af9..75e0cdf 100644
--- a/src/bin/vmadm.rs
+++ b/src/bin/vmadm.rs
@@ -17,14 +17,28 @@ const SSH_PORT: i32 = 22;
#[derive(StructOpt, Debug)]
enum Cli {
New {
- #[structopt(help = "create a new virtual machine", parse(from_os_str))]
+ #[structopt(parse(from_os_str))]
spec: PathBuf,
},
List,
Delete {
- #[structopt(help = "create a new virtual machine", parse(from_os_str))]
+ #[structopt(parse(from_os_str))]
spec: PathBuf,
},
+ CloudInit {
+ #[structopt(parse(from_os_str))]
+ spec: PathBuf,
+
+ #[structopt(parse(from_os_str))]
+ dirname: PathBuf,
+ },
+ CloudInitIso {
+ #[structopt(parse(from_os_str))]
+ spec: PathBuf,
+
+ #[structopt(parse(from_os_str))]
+ iso: PathBuf,
+ },
}
fn main() -> anyhow::Result<()> {
@@ -33,6 +47,8 @@ fn main() -> anyhow::Result<()> {
Cli::New { spec } => new(&spec)?,
Cli::List => list()?,
Cli::Delete { spec } => delete(&spec)?,
+ Cli::CloudInit { spec, dirname } => cloud_init(&spec, &dirname)?,
+ Cli::CloudInitIso { spec, iso } => cloud_init_iso(&spec, &iso)?,
}
Ok(())
}
@@ -44,13 +60,8 @@ fn new(spec: &Path) -> anyhow::Result<()> {
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);
+ let init = CloudInitConfig::from(&spec)?;
info!(
"creating VM image {} from {}",
@@ -154,3 +165,36 @@ fn delete(spec: &Path) -> anyhow::Result<()> {
}
Ok(())
}
+
+fn cloud_init(spec: &Path, dirname: &Path) -> anyhow::Result<()> {
+ info!("generating cloud-init configuration");
+
+ debug!("reading specification from {}", spec.display());
+ let spec = fs::read(spec)?;
+ let spec: Specification = serde_yaml::from_slice(&spec)?;
+ debug!("spec:\n{:#?}", spec);
+
+ info!("creating cloud-init config");
+ let init = CloudInitConfig::from(&spec)?;
+
+ debug!("creating directory {}", dirname.display());
+ std::fs::create_dir_all(dirname)?;
+ init.create_dir(dirname)?;
+
+ Ok(())
+}
+
+fn cloud_init_iso(spec: &Path, iso: &Path) -> anyhow::Result<()> {
+ info!("generating cloud-init ISO");
+
+ debug!("reading specification from {}", spec.display());
+ let spec = fs::read(spec)?;
+ let spec: Specification = serde_yaml::from_slice(&spec)?;
+ debug!("spec:\n{:#?}", spec);
+
+ info!("creating cloud-init config");
+ let init = CloudInitConfig::from(&spec)?;
+ init.create_iso(iso)?;
+
+ Ok(())
+}
diff --git a/src/cloudinit.rs b/src/cloudinit.rs
index 6c52eff..f1b56ce 100644
--- a/src/cloudinit.rs
+++ b/src/cloudinit.rs
@@ -1,69 +1,288 @@
-use std::default::Default;
+use crate::spec::{Specification, SpecificationError};
+use log::debug;
+use serde::{Deserialize, Serialize};
+use shell_words::quote;
use std::fs::write;
+// use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use tempfile::tempdir;
+const SCRIPT: &str = r#"
+import os
+import yaml
+
+
+def log(msg):
+ logfile.write(msg)
+ logfile.write("\n")
+ logfile.flush()
+
+
+logfile = open("/tmp/vmadm.script", "w")
+log("vmadm cloud-init script starting")
+
+if os.environ.get("VMADM_TESTING"):
+ filename = "smoke/user-data"
+ etc = "x"
+else:
+ filename = "/var/lib/cloud/instance/user-data.txt"
+ etc = "/etc/ssh"
+
+key_types = ("rsa", "dsa", "ecdsa", "ed25519")
+
+log(f"loading user-data from {filename}")
+obj = yaml.safe_load(open(filename))
+
+ssh_keys = obj.get("ssh_keys", {})
+# log(f"ssh_keys: {json.dumps(ssh_keys)}")
+
+keys = []
+certs = []
+
+for key_type in key_types:
+ filename = os.path.join(etc, f"ssh_host_{key_type}_key.pub")
+ if os.path.exists(filename):
+ log(f"removing {filename}")
+ os.remove(filename)
+ else:
+ log(f"file {filename} does not exist")
+
+for key_type in key_types:
+ key = ssh_keys.get(f"{key_type}_private")
+ cert = ssh_keys.get(f"{key_type}_certificate")
+ log(f"key {key_type} {key}")
+ log(f"cert {key_type} {cert }")
+
+ if key:
+ filename = os.path.join(etc, f"ssh_host_{key_type}_key")
+ log(f"writing key {filename}")
+ keys.append(filename)
+ with open(filename, "w") as f:
+ f.write(key)
+
+ if cert:
+ filename = os.path.join(etc, f"ssh_host_{key_type}_key-cert.pub")
+ log(f"writing cert {filename}")
+ certs.append(filename)
+ with open(filename, "w") as f:
+ f.write(cert)
+
+config = os.path.join(etc, "sshd_config")
+data = ""
+if os.path.exists(config):
+ data = open(config).read()
+
+log(f"configuring sshd {config}")
+log(f"keys {keys}")
+log(f"certs {certs}")
+
+with open(config, "w") as f:
+ for filename in keys:
+ log(f"hostkey {filename}")
+ f.write(f"hostkey {filename}\n")
+ for filename in certs:
+ log(f"hostcert {filename}")
+ f.write(f"hostcertificate {filename}\n")
+ f.write(data)
+
+log("vmadm cloud-init script ending")
+logfile.close()
+"#;
+
#[derive(Debug, thiserror::Error)]
pub enum CloudInitError {
#[error("failed to create ISO image with genisoimage: {0}")]
IsoFailed(String),
#[error(transparent)]
+ SpecificationError(#[from] SpecificationError),
+
+ #[error(transparent)]
IoError(#[from] std::io::Error),
#[error(transparent)]
StringError(#[from] std::string::FromUtf8Error),
+
+ #[error(transparent)]
+ SerdeError(#[from] serde_yaml::Error),
+}
+
+#[derive(Clone, Debug, Serialize)]
+#[serde(rename_all = "kebab-case")]
+struct Metadata {
+ local_hostname: String,
+}
+
+impl Metadata {
+ fn from(spec: &Specification) -> Self {
+ Self {
+ local_hostname: spec.name.to_string(),
+ }
+ }
+
+ fn as_yaml(&self) -> Result<String, serde_yaml::Error> {
+ let yaml = serde_yaml::to_string(self)?;
+ let yaml = cleanup_document(&yaml);
+ Ok(format!("# Amazon EC2 style metadata\n{}", &yaml))
+ }
+}
+
+fn cleanup_document(doc: &str) -> String {
+ let marker = "---\n";
+ let doc = if doc.starts_with(marker) {
+ &doc[marker.len()..]
+ } else {
+ doc
+ };
+ if doc.ends_with("\n") {
+ &doc[..doc.len() - 1]
+ } else {
+ doc
+ };
+ doc.to_string()
+}
+
+#[derive(Clone, Debug, Serialize)]
+struct Userdata {
+ ssh_authorized_keys: Vec<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ ssh_keys: Option<Hostkeys>,
+
+ runcmd: Vec<String>,
+}
+
+impl Userdata {
+ fn from(spec: &Specification) -> Result<Self, CloudInitError> {
+ Ok(Self {
+ ssh_authorized_keys: spec.ssh_keys()?.lines().map(|s| s.to_string()).collect(),
+ ssh_keys: Hostkeys::from(spec),
+ runcmd: vec![
+ format!("python3 -c {}", quote(SCRIPT)),
+ format!("systemctl reload ssh"),
+ ],
+ })
+ }
+
+ fn as_yaml(&self) -> Result<String, serde_yaml::Error> {
+ let yaml = serde_yaml::to_string(self)?;
+ let yaml = cleanup_document(&yaml);
+ debug!("userdata self:\n{:#?}", self);
+ debug!("userdata yaml:\n{}", yaml);
+ Ok(format!("#cloud-config\n{}", &yaml))
+ }
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+struct Hostkeys {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ rsa_private: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ rsa_certificate: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ dsa_private: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ dsa_certificate: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ ecdsa_private: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ ecdsa_certificate: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ ed25519_private: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ ed25519_certificate: Option<String>,
+}
+
+impl Hostkeys {
+ fn from(spec: &Specification) -> Option<Self> {
+ let rsa = spec.rsa_host_key.clone();
+ let rsa_cert = spec.rsa_host_cert.clone();
+
+ let dsa = spec.dsa_host_key.clone();
+ let dsa_cert = spec.dsa_host_cert.clone();
+
+ let ecdsa = spec.ecdsa_host_key.clone();
+ let ecdsa_cert = spec.ecdsa_host_cert.clone();
+
+ let ed25519 = spec.ed25519_host_key.clone();
+ let ed25519_cert = spec.ed25519_host_cert.clone();
+
+ if rsa.is_some() || dsa.is_some() || ecdsa.is_some() || ed25519.is_some() {
+ Some(Self {
+ rsa_private: rsa,
+ rsa_certificate: rsa_cert,
+ dsa_private: dsa,
+ dsa_certificate: dsa_cert,
+ ecdsa_private: ecdsa,
+ ecdsa_certificate: ecdsa_cert,
+ ed25519_private: ed25519,
+ ed25519_certificate: ed25519_cert,
+ })
+ } else {
+ None
+ }
+ }
}
-#[derive(Default, Debug, Clone)]
+#[derive(Clone, Debug)]
pub struct CloudInitConfig {
- hostname: String,
- authorized_keys: String,
+ metadata: Metadata,
+ userdata: Userdata,
}
impl CloudInitConfig {
- pub fn hostname(&self) -> &str {
- &self.hostname
+ pub fn from(spec: &Specification) -> Result<Self, CloudInitError> {
+ let metadata = Metadata::from(spec);
+ let userdata = Userdata::from(spec)?;
+ Ok(CloudInitConfig { metadata, userdata })
}
- pub fn set_hostname(&mut self, hostname: &str) {
- self.hostname = hostname.to_string();
+ pub fn dump(&self) {
+ println!("==== meta-data:\n{}", self.metadata().unwrap());
+ println!("==== user-data:\n{}", self.userdata().unwrap());
}
- pub fn authorized_keys(&self) -> &str {
- &self.authorized_keys
+ fn metadata(&self) -> Result<String, serde_yaml::Error> {
+ Ok(self.metadata.as_yaml()?)
}
- pub fn set_authorized_keys(&mut self, keys: &str) {
- self.authorized_keys = keys.to_string();
+ fn userdata(&self) -> Result<String, serde_yaml::Error> {
+ Ok(self.userdata.as_yaml()?)
}
- fn write(&self, dir: &Path) -> std::io::Result<()> {
- let metadata = dir.join("meta-data");
- let userdata = dir.join("user-data");
-
- write(
- &metadata,
- format!(
- "# Amazon EC2 style metadata\nlocal-hostname: {}\n",
- self.hostname()
- ),
- )?;
- write(
- &userdata,
- format!(
- "#cloud-config\nssh_authorized_keys:\n- {}",
- self.authorized_keys()
- ),
- )?;
+ pub fn create_dir(&self, path: &Path) -> Result<(), CloudInitError> {
+ let metadata = path.join("meta-data");
+ debug!("writing metadata to {}", metadata.display());
+ write(&metadata, self.metadata()?)?;
+
+ let userdata = path.join("user-data");
+ debug!("writing userdata to {}", userdata.display());
+ write(&userdata, self.userdata()?)?;
+
+ // let scriptsdir = path.join("scripts/per-once");
+ // let script = scriptsdir.join("vmadm-configure-sshd");
+ // std::fs::create_dir_all(&scriptsdir)?;
+ // debug!("writing script to {}", script.display());
+ // write(&script, SCRIPT)?;
+ // let mut permissions = std::fs::metadata(&script)?.permissions();
+ // permissions.set_mode(0o755);
+ // debug!("setting permissions on {:?} to {:?}", script, permissions);
+ // std::fs::set_permissions(&script, permissions)?;
Ok(())
}
pub fn create_iso(&self, filename: &Path) -> Result<(), CloudInitError> {
let dir = tempdir()?;
- self.write(dir.path())?;
+ self.create_dir(dir.path())?;
let r = Command::new("genisoimage")
.arg("-quiet")
@@ -82,29 +301,3 @@ impl CloudInitConfig {
Ok(())
}
}
-
-#[cfg(test)]
-mod test {
- use super::CloudInitConfig;
-
- #[test]
- fn is_empty_by_default() {
- let init = CloudInitConfig::default();
- assert_eq!(init.hostname(), "");
- assert_eq!(init.authorized_keys(), "");
- }
-
- #[test]
- fn sets_hostname() {
- let mut init = CloudInitConfig::default();
- init.set_hostname("foo");
- assert_eq!(init.hostname(), "foo");
- }
-
- #[test]
- fn sets_authorized_keys() {
- let mut init = CloudInitConfig::default();
- init.set_authorized_keys("auth");
- assert_eq!(init.authorized_keys(), "auth");
- }
-}
diff --git a/src/spec.rs b/src/spec.rs
index 1209040..2cb61f6 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -2,12 +2,22 @@ use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
pub struct Specification {
pub name: String,
#[serde(default)]
pub ssh_key_files: Vec<PathBuf>,
+ pub rsa_host_key: Option<String>,
+ pub rsa_host_cert: Option<String>,
+ pub dsa_host_key: Option<String>,
+ pub dsa_host_cert: Option<String>,
+ pub ecdsa_host_key: Option<String>,
+ pub ecdsa_host_cert: Option<String>,
+ pub ed25519_host_key: Option<String>,
+ pub ed25519_host_cert: Option<String>,
+
pub base: PathBuf,
pub image: PathBuf,
pub image_size_gib: u64,
diff --git a/subplot/vmadm.py b/subplot/vmadm.py
index 2374baf..48737a3 100644
--- a/subplot/vmadm.py
+++ b/subplot/vmadm.py
@@ -1,4 +1,6 @@
+import logging
import os
+import yaml
def install_vmadm(ctx):
@@ -16,6 +18,34 @@ def install_vmadm(ctx):
# This can be removed once the Subplot lib/files library creates
# directories.
os.mkdir(".ssh")
+ os.mkdir("expected")
+
+
+def invoke_cloud_init(ctx, filename=None, dirname=None):
+ runcmd_run = globals()["runcmd_run"]
+ runcmd_exit_code_is_zero = globals()["runcmd_exit_code_is_zero"]
+
+ runcmd_run(ctx, ["vmadm", "cloud-init", filename, dirname])
+ runcmd_exit_code_is_zero(ctx)
+
+
+def directories_match(ctx, actual=None, expected=None):
+ assert_eq = globals()["assert_eq"]
+
+ efiles = list(sorted(os.listdir(expected)))
+ afiles = list(sorted(os.listdir(expected)))
+ assert_eq(efiles, afiles)
+ for filename in efiles:
+ with open(os.path.join("expected", filename)) as f:
+ edata = yaml.safe_load(f)
+ with open(os.path.join("actual", filename)) as f:
+ adata = yaml.safe_load(f)
+ if "runcmd" in adata:
+ del adata["runcmd"]
+ logging.debug(f"directories_match: filename={filename}")
+ logging.debug(f"directories_match: edata={edata!r}")
+ logging.debug(f"directories_match: adata={adata!r}")
+ assert_eq(edata, adata)
def create_vm(ctx, filename=None):
diff --git a/subplot/vmadm.yaml b/subplot/vmadm.yaml
index 40fce49..374391b 100644
--- a/subplot/vmadm.yaml
+++ b/subplot/vmadm.yaml
@@ -1,9 +1,15 @@
- given: "an installed vmadm"
function: install_vmadm
+- when: "I invoke vmadm cloud-init {filename} {dirname}"
+ function: invoke_cloud_init
+
- 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
+
+- then: "directories {actual} and {expected} are identical"
+ function: directories_match
diff --git a/vmadm.md b/vmadm.md
index a63370c..9d05b2a 100644
--- a/vmadm.md
+++ b/vmadm.md
@@ -1,25 +1,6 @@
-# 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 "1997"
-~~~
+# Data files for scenarios
+This section has some data files used by scenarios.
~~~{#smoke.yaml .file .yaml}
name: smoke
@@ -74,6 +55,88 @@ host *
passwordauthentication=no
~~~
+# Cloud-init configuration
+
+This scenario verifies that vmadm creates the cloud-init configuration
+correctly.
+
+~~~scenario
+given an installed vmadm
+given file init.yaml
+given file .ssh/id_rsa.pub from init_ssh_key_pub
+given file expected/meta-data from init-metadata
+given file expected/user-data from init-userdata
+when I invoke vmadm cloud-init init.yaml actual
+then directories actual and expected are identical
+~~~
+
+~~~{#init.yaml .file .yaml}
+name: init-test
+ssh_key_files:
+ - .ssh/id_rsa.pub
+rsa_host_key: rsa-private
+rsa_host_cert: rsa-certificate
+dsa_host_key: dsa-private
+dsa_host_cert: dsa-certificate
+ecdsa_host_key: ecdsa-private
+ecdsa_host_cert: ecdsa-certificate
+ed25519_host_key: ed25519-private
+ed25519_host_cert: ed25519-certificate
+base: /home/liw/tmp/debian-10-openstack-amd64.qcow2
+image: init.qcow2
+image_size_gib: 5
+memory_mib: 2048
+cpus: 1
+~~~
+
+~~~{#init_ssh_key_pub .file}
+init-authz-key
+~~~
+
+~~~{#init-metadata .file}
+# Amazon EC2 style metadata
+local-hostname: init-test
+~~~
+
+~~~{#init-userdata .file}
+#cloud-config
+ssh_authorized_keys:
+ - init-authz-key
+ssh_keys:
+ rsa_private: rsa-private
+ rsa_certificate: rsa-certificate
+ dsa_private: dsa-private
+ dsa_certificate: dsa-certificate
+ ecdsa_private: ecdsa-private
+ ecdsa_certificate: ecdsa-certificate
+ ed25519_private: ed25519-private
+ ed25519_certificate: ed25519-certificate
+~~~
+
+# 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 "1997"
+~~~
+
+
+
# Colophon