use std::{
ffi::{OsStr, OsString},
path::{Path, PathBuf},
};
use anyhow::Context as _;
use futures_core::future::BoxFuture;
use tokio::process::Command;
mod relay;
pub use relay::Relay;
use crate::{
cache_root,
fs::Mount,
image::ImageSource,
protocol::{
Call, Client, ExecCall, ExecResp, Failure, InitCall, MountSpec, Notification, Port,
ReadCall, ReadResp, Response, WriteCall, WriteResp, stdio::StdioClient,
},
};
pub(crate) type ClientFactory = Box<dyn FnOnce() -> anyhow::Result<Box<dyn Client>> + Send>;
pub struct ConsoleClientBuilder {
client_factory: ClientFactory,
image: Option<ImageSource>,
snapshot: Option<Vec<u8>>,
mounts: Vec<Mounted>,
network: bool,
ports: Vec<Port>,
vcpus: Option<u8>,
memory_mib: Option<u32>,
gpu: Option<bool>,
gpu_memory_mib: Option<u32>,
disk_gib: Option<u32>,
}
impl Default for ConsoleClientBuilder {
fn default() -> Self {
ConsoleClientBuilder {
client_factory: stdio_factory(&[cache_root().join("bin").join("virtx-uvm")]),
image: None,
snapshot: None,
mounts: Vec::new(),
network: true,
ports: Vec::new(),
vcpus: None,
memory_mib: None,
gpu: None,
gpu_memory_mib: None,
disk_gib: None,
}
}
}
impl ConsoleClientBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn client(mut self, client: impl Client + 'static) -> Self {
self.client_factory = Box::new(move || Ok(Box::new(client)));
self
}
pub fn cmd(mut self, cmd: &[impl AsRef<OsStr>]) -> Self {
self.client_factory = stdio_factory(cmd);
self
}
pub fn mount(mut self, mount: impl Mount + 'static, at: impl Into<PathBuf>) -> Self {
self.mounts.push(Mounted {
mount: Box::new(mount),
at: at.into(),
readonly: false,
});
self
}
pub fn mount_readonly(mut self, mount: impl Mount + 'static, at: impl Into<PathBuf>) -> Self {
self.mounts.push(Mounted {
mount: Box::new(mount),
at: at.into(),
readonly: true,
});
self
}
pub fn image(mut self, image: impl Into<ImageSource>) -> Self {
self.image = Some(image.into());
self
}
pub fn snapshot(mut self, snapshot: Vec<u8>) -> Self {
self.snapshot = Some(snapshot);
self
}
pub fn network(mut self, network: bool) -> Self {
self.network = network;
self
}
pub fn ports(mut self, ports: impl IntoIterator<Item = Port>) -> Self {
self.ports = ports.into_iter().collect();
self
}
pub fn vcpus(mut self, vcpus: u8) -> Self {
self.vcpus = Some(vcpus);
self
}
pub fn memory_mib(mut self, memory_mib: u32) -> Self {
self.memory_mib = Some(memory_mib);
self
}
pub fn gpu(mut self, gpu: bool) -> Self {
self.gpu = Some(gpu);
self
}
pub fn gpu_memory_mib(mut self, gpu_memory_mib: u32) -> Self {
self.gpu_memory_mib = Some(gpu_memory_mib);
self
}
pub fn disk_gib(mut self, disk_gib: u32) -> Self {
self.disk_gib = Some(disk_gib);
self
}
pub async fn build(self) -> anyhow::Result<ConsoleClient> {
ConsoleClient::new(self).await
}
}
struct Tree {
#[allow(dead_code)]
mount: Box<dyn Mount>,
path: PathBuf,
}
pub struct ConsoleClient {
client: Box<dyn Client>,
mounts: Vec<Tree>,
}
impl ConsoleClient {
pub fn builder() -> ConsoleClientBuilder {
ConsoleClientBuilder::default()
}
pub async fn new(builder: ConsoleClientBuilder) -> anyhow::Result<Self> {
let ConsoleClientBuilder {
client_factory,
image,
mounts,
network,
ports,
snapshot,
vcpus,
memory_mib,
gpu,
gpu_memory_mib,
disk_gib,
} = builder;
let mut client = client_factory()?;
let mut specs = Vec::with_capacity(mounts.len());
let mut held = Vec::with_capacity(mounts.len());
for mounted in mounts {
specs.push(named(&mounted)?);
held.push(Tree {
mount: mounted.mount,
path: mounted.at,
});
}
let session = InitCall {
mounts: specs,
image,
network,
ports,
snapshot,
vcpus,
memory_mib,
gpu,
gpu_memory_mib,
disk_gib,
};
client.init(session).await?;
Ok(ConsoleClient {
client,
mounts: held,
})
}
pub fn mounts(&self) -> impl ExactSizeIterator<Item = &Path> {
self.mounts.iter().map(|tree| tree.path.as_path())
}
pub async fn start(&mut self) -> Result<(), Failure> {
self.client.start().await
}
pub async fn stop(&mut self) -> Result<(), Failure> {
self.client.stop().await
}
pub async fn exec(
&mut self,
cmd: impl IntoIterator<Item = impl AsRef<str>>,
timeout_ms: Option<u64>,
) -> Result<ExecResp, Failure> {
let exec = ExecCall {
cmd: cmd.into_iter().map(|s| s.as_ref().to_string()).collect(),
timeout_ms,
};
self.client.exec(exec).await
}
pub async fn read(
&mut self,
path: impl AsRef<str>,
offset: Option<u64>,
len: Option<u64>,
) -> Result<ReadResp, Failure> {
let read = ReadCall {
path: path.as_ref().to_string(),
offset,
len,
};
self.client.read(read).await
}
pub async fn snapshot(&mut self) -> Result<Vec<u8>, Failure> {
self.client.snapshot().await.map(|answer| answer.blob)
}
pub async fn write(
&mut self,
path: impl AsRef<str>,
data: impl Into<Vec<u8>>,
offset: Option<u64>,
) -> Result<WriteResp, Failure> {
let write = WriteCall {
path: path.as_ref().to_string(),
data: data.into(),
offset,
};
self.client.write(write).await
}
pub async fn close(mut self) -> Result<(), Failure> {
let mut client = std::mem::replace(&mut self.client, Box::new(Spent));
let mounts = std::mem::take(&mut self.mounts);
drop(self);
let ended = client.quit().await;
drop(client);
let _ = tokio::task::spawn_blocking(move || drop(mounts)).await;
ended
}
}
impl Drop for ConsoleClient {
fn drop(&mut self) {
hang_up(&mut self.client, std::mem::take(&mut self.mounts));
}
}
pub(crate) fn stdio_factory(cmd: &[impl AsRef<OsStr>]) -> ClientFactory {
let cmd: Vec<OsString> = cmd.iter().map(|s| s.as_ref().to_owned()).collect();
Box::new(move || {
let (program, args) = cmd
.split_first()
.context("a console server needs a program to run")?;
let mut server = Command::new(program);
server.args(args);
let client = StdioClient::new(server).context("starting the console server")?;
Ok(Box::new(client))
})
}
struct Spent;
impl Client for Spent {
fn call(&mut self, _: Call) -> BoxFuture<'_, Result<Response, Failure>> {
Box::pin(async { Err(Failure::broken("the session has ended")) })
}
fn notify(&mut self, _: Notification) -> BoxFuture<'_, Result<(), Failure>> {
Box::pin(async { Err(Failure::broken("the session has ended")) })
}
}
pub(crate) fn hang_up<K: Send + 'static>(client: &mut Box<dyn Client>, keep: K) {
let client = std::mem::replace(client, Box::new(Spent));
let ending = (client, keep);
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
runtime.spawn(async move {
let mut ending = ending;
let _ = ending.0.quit().await;
});
}
}
struct Mounted {
mount: Box<dyn Mount>,
at: PathBuf,
readonly: bool,
}
fn named(mounted: &Mounted) -> anyhow::Result<MountSpec> {
let url = mounted.mount.url().with_context(|| {
format!(
"a mount point that is not an absolute UTF-8 path cannot be named: {:?}",
mounted.mount.mountpoint()
)
})?;
let at = mounted.at.to_str().with_context(|| {
format!(
"a tree can only be mounted at a UTF-8 path: {:?}",
mounted.at
)
})?;
let spec = MountSpec::new(url, at)?;
Ok(if mounted.readonly {
spec.read_only()
} else {
spec
})
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use futures_core::future::BoxFuture;
use super::*;
use crate::protocol::{Call, Error, InitResp, Method, Notification, Response};
#[derive(Clone)]
struct Log {
methods: Arc<Mutex<Vec<Method>>>,
calls: Arc<Mutex<Vec<Call>>>,
}
impl Log {
fn methods(&self) -> Vec<Method> {
self.methods.lock().unwrap().clone()
}
fn call(&self, n: usize) -> Call {
self.calls.lock().unwrap()[n].clone()
}
}
struct Recorder {
answers: Vec<Response>,
log: Log,
}
impl Client for Recorder {
fn call(&mut self, call: Call) -> BoxFuture<'_, Result<Response, Failure>> {
self.log.methods.lock().unwrap().push(call.method());
self.log.calls.lock().unwrap().push(call);
let answer = if self.answers.is_empty() {
Err(Failure::broken("nothing left to answer with"))
} else {
match self.answers.remove(0) {
Response::Error(error) => Err(Failure::Refused(error)),
answer => Ok(answer),
}
};
Box::pin(async move { answer })
}
fn notify(&mut self, notification: Notification) -> BoxFuture<'_, Result<(), Failure>> {
self.log.methods.lock().unwrap().push(notification.method());
Box::pin(async { Ok(()) })
}
}
fn recorder(answers: Vec<Response>) -> (Recorder, Log) {
let log = Log {
methods: Arc::new(Mutex::new(Vec::new())),
calls: Arc::new(Mutex::new(Vec::new())),
};
(
Recorder {
answers,
log: log.clone(),
},
log,
)
}
fn initialized() -> Response {
Response::Init(InitResp::default())
}
fn ran(stdout: &[u8]) -> Response {
Response::Exec(ExecResp {
code: 0,
stdout: stdout.to_vec(),
..ExecResp::default()
})
}
#[tokio::test]
async fn a_stdio_console_starts_its_server_when_it_is_built() {
let Err(e) = ConsoleClient::builder()
.cmd(&["virtx-no-such-console"])
.build()
.await
else {
panic!("a console over a program that does not exist should not build");
};
assert!(e.to_string().contains("starting the console server"), "{e}");
let Err(e) = ConsoleClient::builder().cmd(&[] as &[&str]).build().await else {
panic!("a console over no program at all should not build");
};
assert!(e.to_string().contains("needs a program to run"), "{e}");
let Err(e) = ConsoleClient::builder().cmd(&["cat", "-u"]).build().await else {
panic!("a console over a program that cannot answer should not build");
};
assert!(e.to_string().contains("cannot answer"), "{e}");
}
#[tokio::test]
async fn a_session_is_the_servers_to_keep() {
let (client, log) = recorder(vec![initialized(), ran(b"hi\n")]);
let mut console = ConsoleClient::builder()
.client(client)
.build()
.await
.unwrap();
console.start().await.unwrap();
assert_eq!(
console.exec(["echo", "hi"], None).await.unwrap().stdout,
b"hi\n"
);
console.stop().await.unwrap();
drop(console);
tokio::task::yield_now().await;
assert_eq!(
log.methods(),
[
Method::Init,
Method::Start,
Method::Exec,
Method::Stop,
Method::Quit
]
);
let Call::Init(init) = log.call(0) else {
panic!("{:?} is not an init", log.call(0));
};
assert_eq!(init, InitCall::default());
let Call::Exec(exec) = log.call(1) else {
panic!("{:?} is not an exec", log.call(1));
};
assert_eq!(
exec,
ExecCall {
cmd: vec!["echo".into(), "hi".into()],
timeout_ms: None,
}
);
}
#[tokio::test]
async fn a_console_the_server_will_not_have_does_not_exist() {
let (client, log) = recorder(vec![Response::Error(Error::new(
Error::UNSUPPORTED_MOUNT,
"s3: this server realizes file:// and nothing else",
))]);
let Err(e) = ConsoleClient::builder().client(client).build().await else {
panic!("a console whose init was refused should not build");
};
assert!(e.to_string().contains("file://"), "{e}");
tokio::task::yield_now().await;
assert_eq!(log.methods(), [Method::Init]);
}
#[tokio::test]
async fn dropping_a_console_ends_its_session() {
let (client, log) = recorder(vec![initialized()]);
let console = ConsoleClient::builder()
.client(client)
.build()
.await
.unwrap();
drop(console);
tokio::task::yield_now().await;
assert_eq!(log.methods(), [Method::Init, Method::Quit]);
}
#[tokio::test]
async fn closing_a_console_returns_once_its_mounts_are_let_go() {
struct Watched {
at: PathBuf,
log: Log,
let_go_after: Arc<Mutex<Option<Vec<Method>>>>,
}
impl Mount for Watched {
fn mountpoint(&self) -> &Path {
&self.at
}
}
impl Drop for Watched {
fn drop(&mut self) {
*self.let_go_after.lock().unwrap() = Some(self.log.methods());
}
}
let (client, log) = recorder(vec![initialized()]);
let let_go_after = Arc::new(Mutex::new(None));
let console = ConsoleClient::builder()
.client(client)
.mount(
Watched {
at: std::env::temp_dir(),
log: log.clone(),
let_go_after: let_go_after.clone(),
},
"/work",
)
.build()
.await
.unwrap();
console.close().await.unwrap();
assert_eq!(
*let_go_after.lock().unwrap(),
Some(vec![Method::Init, Method::Quit])
);
tokio::task::yield_now().await;
assert_eq!(log.methods(), [Method::Init, Method::Quit]);
}
#[tokio::test]
async fn a_console_names_its_mounts_and_where_it_put_them() {
let (client, log) = recorder(vec![initialized()]);
let console = ConsoleClient::builder()
.client(client)
.mount_readonly(PathBuf::from("/mnt/project"), "/work")
.mount(PathBuf::from("/mnt/collected"), "/work/out")
.build()
.await
.unwrap();
let Call::Init(init) = log.call(0) else {
panic!("{:?} is not an init", log.call(0));
};
assert_eq!(
init.mounts
.iter()
.map(MountSpec::to_string)
.collect::<Vec<_>>(),
[
"file:///mnt/project:/work:ro",
"file:///mnt/collected:/work/out"
]
);
assert_eq!(
console.mounts().collect::<Vec<_>>(),
[Path::new("/work"), Path::new("/work/out")]
);
}
#[tokio::test]
async fn a_mount_this_end_cannot_name_is_not_a_session() {
let (client, log) = recorder(vec![initialized()]);
let Err(e) = ConsoleClient::builder()
.client(client)
.mount(PathBuf::from("relative/here"), "/work")
.build()
.await
else {
panic!("a console over a mount point with no URL should not build");
};
assert!(e.to_string().contains("cannot be named"), "{e}");
assert_eq!(log.methods(), [] as [Method; 0]);
let (client, _) = recorder(vec![initialized()]);
let Err(e) = ConsoleClient::builder()
.client(client)
.mount(PathBuf::from("/mnt/here"), "work")
.build()
.await
else {
panic!("a console over a relative guest path should not build");
};
assert!(e.to_string().contains("guest path is absolute"), "{e}");
}
}