summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2023-08-31 09:04:27 +0300
committerLars Wirzenius <liw@liw.fi>2023-09-01 07:01:32 +0300
commitad1663f151988e3d01df85bc9f796fd3d5fd3b01 (patch)
tree6c1c4150edb3c8de67f161553b7a7f819a17dc8f
parent60636d04447a7d03ca4cef8b24c761c7a9030000 (diff)
downloadambient-run-ad1663f151988e3d01df85bc9f796fd3d5fd3b01.tar.gz
feat: actually run a (dummy) build in a VM
Sponsored-by: author
-rw-r--r--Cargo.lock5
-rw-r--r--Cargo.toml1
-rw-r--r--ambient-run.md27
-rw-r--r--src/bin/ambient-run.rs25
-rw-r--r--src/error.rs3
-rw-r--r--src/lib.rs1
-rw-r--r--src/project.rs13
-rw-r--r--src/qemu.rs220
-rw-r--r--subplot/ambient-run.rs18
-rw-r--r--subplot/ambient-run.yaml4
10 files changed, 311 insertions, 6 deletions
diff --git a/Cargo.lock b/Cargo.lock
index a8cefda..7149f3d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -32,6 +32,7 @@ dependencies = [
"subplot-build",
"subplotlib",
"tar",
+ "tempfile",
"thiserror",
]
@@ -1235,9 +1236,9 @@ dependencies = [
[[package]]
name = "tempfile"
-version = "3.7.0"
+version = "3.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5486094ee78b2e5038a6382ed7645bc084dc2ec433426ca4c3cb61e2007b8998"
+checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef"
dependencies = [
"cfg-if",
"fastrand",
diff --git a/Cargo.toml b/Cargo.toml
index 4e6243a..46648ff 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,6 +10,7 @@ serde = { version = "1.0.182", features = ["derive"] }
serde_yaml = "0.9.25"
subplotlib = "0.8.0"
tar = "0.4.40"
+tempfile = "3.8.0"
thiserror = "1.0.44"
[build-dependencies]
diff --git a/ambient-run.md b/ambient-run.md
index c5dc188..0387d0e 100644
--- a/ambient-run.md
+++ b/ambient-run.md
@@ -172,6 +172,33 @@ image: /my/image.qcow2
## Building with ambient-run
### Smoke test build works
+
+_Requirement:_ I can do a simple build that just runs the shell
+command `echo hello, world` using `ambient-run`.
+
+_Justification:_ This is the simplest possible project build, and if
+it works, then at least the very fundamental parts of `ambient-run`
+work, and vice versa.
+
+_Stakeholder:_ Lars.
+
+~~~scenario
+given an installed ambient-run
+given file hello-project.yaml
+given image file image.qcow2 specified for test suite
+when I run ambient-run build hello-project.yaml --log hello.log
+then file hello.log contains "hello, world"
+~~~
+
+~~~{#hello-project.yaml .file .yaml}
+source: .
+shell: |
+ #!/bin/bash
+ set -xeuo pipefail
+ echo hello, world
+image: image.qcow2
+~~~
+
### Build is given dependencies
### Cache is persistent between builds
### Build gets the resources is demands
diff --git a/src/bin/ambient-run.rs b/src/bin/ambient-run.rs
index a2d3809..d0ca22b 100644
--- a/src/bin/ambient-run.rs
+++ b/src/bin/ambient-run.rs
@@ -2,6 +2,7 @@ use ambient_run::{
config::{canonical, default_config_file, Config},
error::AmbientRunError,
project::Project,
+ qemu::Qemu,
vdrive::VirtualDriveBuilder,
};
use clap::{Parser, Subcommand};
@@ -28,6 +29,7 @@ fn fallible_main() -> Result<(), AmbientRunError> {
Command::Config(x) => x.run(&args, &config),
Command::Project(x) => x.run(&args, &config),
Command::VDrive(x) => x.run(&args, &config),
+ Command::Build(x) => x.run(&args, &config),
}
}
@@ -53,6 +55,7 @@ enum Command {
Project(ProjectCommand),
#[clap(name = "vdrive")]
VDrive(VDriveCommand),
+ Build(BuildCommand),
}
#[derive(Debug, Parser)]
@@ -196,3 +199,25 @@ impl VDriveExtractCommand {
Ok(())
}
}
+
+#[derive(Debug, Parser)]
+struct BuildCommand {
+ /// Project build specification.
+ filename: PathBuf,
+
+ /// Build log.
+ #[clap(long)]
+ log: Option<PathBuf>,
+}
+
+impl BuildCommand {
+ fn run(&self, _global: &Args, config: &Config) -> Result<(), AmbientRunError> {
+ let project = Project::load(&self.filename, config)?;
+ let mut qemu = Qemu::new(project.image()).with_shell(project.shell());
+ if let Some(log) = &self.log {
+ qemu = qemu.with_log(log);
+ }
+ qemu.run()?;
+ Ok(())
+ }
+}
diff --git a/src/error.rs b/src/error.rs
index d3a03ed..0958d35 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -10,5 +10,8 @@ pub enum AmbientRunError {
Project(#[from] crate::project::ProjectError),
#[error(transparent)]
+ Qemu(#[from] crate::qemu::QemuError),
+
+ #[error(transparent)]
VDrive(#[from] crate::vdrive::VirtualDriveError),
}
diff --git a/src/lib.rs b/src/lib.rs
index 3e7428d..c29912e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -5,4 +5,5 @@
pub mod config;
pub mod error;
pub mod project;
+pub mod qemu;
pub mod vdrive;
diff --git a/src/project.rs b/src/project.rs
index b672867..341b5ad 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -38,13 +38,10 @@ impl Project {
/// Load named project file, update self with values.
pub fn add_from(&mut self, filename: &Path) -> Result<(), ProjectError> {
- eprintln!("adding from {}", filename.display());
let bytes = std::fs::read(filename).map_err(|e| ProjectError::Open(filename.into(), e))?;
let text = String::from_utf8_lossy(&bytes);
- eprintln!("text: {:?}", text);
let snippet: ProjectSnippet =
serde_yaml::from_str(&text).map_err(|e| ProjectError::Yaml(filename.into(), e))?;
- eprintln!("snippet: {:#?}", snippet);
if let Some(x) = snippet.source {
self.source = x;
}
@@ -61,6 +58,16 @@ impl Project {
pub fn as_yaml(&self) -> Result<String, ProjectError> {
serde_yaml::to_string(self).map_err(ProjectError::AsYaml)
}
+
+ /// VM image to use for this build.
+ pub fn image(&self) -> &Path {
+ self.image.as_ref()
+ }
+
+ /// Shell snippet to run to build the project.
+ pub fn shell(&self) -> &str {
+ self.shell.as_ref()
+ }
}
#[derive(Debug, Deserialize)]
diff --git a/src/qemu.rs b/src/qemu.rs
new file mode 100644
index 0000000..c0aa1b1
--- /dev/null
+++ b/src/qemu.rs
@@ -0,0 +1,220 @@
+//! Run QEMU.
+
+use crate::vdrive::{VirtualDrive, VirtualDriveBuilder, VirtualDriveError};
+
+use std::{
+ fs::{copy, write, File},
+ path::{Path, PathBuf},
+ process::{Command, Stdio},
+};
+
+const OVMF_FD: &str = "/usr/share/ovmf/OVMF.fd";
+
+/// A QEMU runner.
+#[derive(Debug, Default)]
+pub struct Qemu {
+ image: PathBuf,
+ log: Option<PathBuf>,
+ source: Option<PathBuf>,
+ shell: Option<String>,
+}
+
+impl Qemu {
+ /// Create a new runner.
+ pub fn new(image: &Path) -> Self {
+ Self {
+ image: image.into(),
+ ..Default::default()
+ }
+ }
+
+ /// Set log file.
+ pub fn with_log(mut self, log: &Path) -> Self {
+ self.log = Some(log.into());
+ self
+ }
+
+ /// Set source directory.
+ pub fn with_source(mut self, source: &Path) -> Self {
+ self.source = Some(source.into());
+ self
+ }
+
+ /// Set script to run.
+ pub fn with_shell(mut self, shell: &str) -> Self {
+ self.shell = Some(shell.into());
+ self
+ }
+
+ /// Run QEMU in the specified way.
+ pub fn run(&self) -> Result<(), QemuError> {
+ let tmp = tempfile::tempdir().map_err(QemuError::TempDir)?;
+ let empty = tempfile::tempdir().map_err(QemuError::TempDir)?;
+
+ let image = tmp.path().join("vm.qcow2");
+ let vars = tmp.path().join("vars.fd");
+
+ copy(&self.image, &image).map_err(|e| QemuError::Copy(self.image.clone(), e))?;
+ copy(OVMF_FD, &vars).map_err(|e| QemuError::Copy(OVMF_FD.into(), e))?;
+
+ let output_drive = Self::create_tar(tmp.path().join("output"), empty.path())?;
+ let cache_drive = Self::create_tar(tmp.path().join("cache"), empty.path())?;
+ let deps_drive = Self::create_tar(tmp.path().join("deps"), empty.path())?;
+
+ if let Some(shell) = &self.shell {
+ write(empty.path().join(".ambient-script"), shell).map_err(QemuError::Write)?;
+ }
+ let source_drive = Self::create_tar(tmp.path().join("src"), empty.path())?;
+
+ let args = QemuArgs::default()
+ .with_valued_arg("-m", "16384")
+ .with_valued_arg("-smp", "cpus=4")
+ .with_valued_arg("-display", "none")
+ .with_valued_arg("-chardev", "stdio,id=serial0")
+ .with_valued_arg("-serial", "chardev:serial0")
+ .with_ipflash(0, OVMF_FD, true)
+ .with_ipflash(1, vars.to_str().unwrap(), false)
+ .with_qcow2(image.to_str().unwrap())
+ .with_raw(source_drive.filename())
+ .with_raw(output_drive.filename())
+ .with_raw(cache_drive.filename())
+ .with_raw(deps_drive.filename())
+ .with_arg("-nodefaults");
+
+ let log = Self::create_file(&self.log)?;
+
+ let child = Command::new("kvm")
+ .args(args.iter())
+ .stdin(Stdio::null())
+ .stdout(Stdio::from(log))
+ .stderr(Stdio::null())
+ .spawn()
+ .map_err(QemuError::Run)?;
+
+ let output = child.wait_with_output().map_err(QemuError::Run)?;
+ if output.status.success() {
+ eprintln!("kvm OK");
+ Ok(())
+ } else {
+ let out = String::from_utf8_lossy(&output.stdout);
+ let err = String::from_utf8_lossy(&output.stderr);
+ eprintln!("kvm ERROR\n{}\n{}", out, err);
+ Err(QemuError::Kvm)
+ }
+ }
+
+ fn create_file(filename: &Option<PathBuf>) -> Result<File, QemuError> {
+ let filename = if let Some(filename) = filename {
+ filename
+ } else {
+ Path::new("/dev/null")
+ };
+ let file = File::create(filename).map_err(|e| QemuError::Log(filename.into(), e))?;
+ Ok(file)
+ }
+
+ fn create_tar(tar_filename: PathBuf, dirname: &Path) -> Result<VirtualDrive, QemuError> {
+ let tar = VirtualDriveBuilder::default()
+ .filename(&tar_filename)
+ .root_directory(dirname)
+ .create()
+ .map_err(|e| QemuError::Tar(dirname.into(), e))?;
+ Ok(tar)
+ }
+}
+
+#[derive(Debug, Default)]
+struct QemuArgs {
+ args: Vec<String>,
+}
+
+impl QemuArgs {
+ fn with_arg(mut self, arg: &str) -> Self {
+ self.args.push(arg.into());
+ self
+ }
+
+ fn with_valued_arg(mut self, arg: &str, value: &str) -> Self {
+ self.args.push(arg.into());
+ self.args.push(value.into());
+ self
+ }
+
+ fn with_ipflash(mut self, unit: usize, path: &str, readonly: bool) -> Self {
+ self.args.push("-drive".into());
+ self.args.push(format!(
+ "if=pflash,format=raw,unit={},file={}{}",
+ unit,
+ path,
+ if readonly { ",readonly=on" } else { "" },
+ ));
+ self
+ }
+
+ fn with_qcow2(mut self, path: &str) -> Self {
+ self.args.push("-drive".into());
+ self.args
+ .push(format!("format=qcow2,if=virtio,file={}", path));
+ self
+ }
+
+ fn with_raw(mut self, path: &Path) -> Self {
+ self.args.push("-drive".into());
+ self.args.push(format!(
+ "format=raw,if=virtio,file={},readonly=on",
+ path.display()
+ ));
+ self
+ }
+
+ fn iter(&self) -> impl Iterator<Item = &str> {
+ self.args.iter().map(|s| s.as_str())
+ }
+}
+
+/// Possible errors from running Qemu.
+#[allow(missing_docs)]
+#[derive(Debug, thiserror::Error)]
+pub enum QemuError {
+ #[error("failed to run QEMU")]
+ Run(#[source] std::io::Error),
+
+ #[error("failed to run QEMU")]
+ Kvm,
+
+ #[error("failed to create a temporary directory")]
+ TempDir(#[source] std::io::Error),
+
+ #[error("failed to copy to temporary directory: {0}")]
+ Copy(PathBuf, #[source] std::io::Error),
+
+ #[error("failed to write temporary file")]
+ Write(#[source] std::io::Error),
+
+ #[error("failed to open log file for writing")]
+ Log(PathBuf, #[source] std::io::Error),
+
+ #[error("failed to create a tar archive from {0}")]
+ Tar(PathBuf, #[source] VirtualDriveError),
+}
+
+#[cfg(test)]
+mod test {
+ use super::Qemu;
+ use std::path::Path;
+
+ #[test]
+ fn sets_image() {
+ let path = Path::new("/my/image.qcow2");
+ let qemu = Qemu::new(path);
+ assert_eq!(&qemu.image, &path);
+ }
+
+ #[test]
+ fn sets_log() {
+ let path = Path::new("/my/image.qcow2");
+ let log = Path::new("/my/log");
+ let qemu = Qemu::new(path).with_log(log);
+ assert_eq!(qemu.log, Some(log.into()));
+ }
+}
diff --git a/subplot/ambient-run.rs b/subplot/ambient-run.rs
index 508588c..0b5f097 100644
--- a/subplot/ambient-run.rs
+++ b/subplot/ambient-run.rs
@@ -5,7 +5,7 @@ use subplotlib::steplibrary::datadir::Datadir;
use subplotlib::steplibrary::files::Files;
use subplotlib::steplibrary::runcmd::Runcmd;
-use std::path::Path;
+use std::path::{Path, PathBuf};
#[derive(Debug, Default)]
struct SubplotContext {}
@@ -30,6 +30,22 @@ fn install_ambient_run(context: &ScenarioContext) {
#[step]
#[context(SubplotContext)]
+#[context(Datadir)]
+fn copy_image_file(context: &ScenarioContext, filename: &Path) {
+ let image = PathBuf::from(std::env::var_os("IMAGE").expect("exected IMAGE to be set"));
+ let filename = context.with_mut(
+ |context: &mut Datadir| {
+ Ok(context
+ .canonicalise_filename(filename)
+ .expect("canonical filename for image file copy"))
+ },
+ false,
+ )?;
+ std::os::unix::fs::symlink(image, filename).expect("symlink $IMAGE to test dir");
+}
+
+#[step]
+#[context(SubplotContext)]
#[context(Runcmd)]
#[context(Files)]
fn stdout_matches_yaml(context: &ScenarioContext, file: SubplotDataFile) {
diff --git a/subplot/ambient-run.yaml b/subplot/ambient-run.yaml
index 50d04f9..f44eb29 100644
--- a/subplot/ambient-run.yaml
+++ b/subplot/ambient-run.yaml
@@ -2,6 +2,10 @@
impl:
rust:
function: install_ambient_run
+- given: image file {filename} specified for test suite
+ impl:
+ rust:
+ function: copy_image_file
- then: stdout, as YAML, matches file {file:file}
impl:
rust: