add `vmctl attach`
This commit is contained in:
parent
bc8270b30f
commit
723b663cb5
49
src/cli.rs
49
src/cli.rs
|
@ -16,7 +16,10 @@ pub struct App {
|
|||
#[derive(Subcommand, Clone, Debug)]
|
||||
pub enum Commands {
|
||||
/// Start a headless VM in the background.
|
||||
Start,
|
||||
Start {
|
||||
#[arg(long, default_value = "1G")]
|
||||
memory: String,
|
||||
},
|
||||
|
||||
/// Stop a headless VM.
|
||||
Stop,
|
||||
|
@ -61,17 +64,17 @@ pub enum Commands {
|
|||
},
|
||||
|
||||
/// Test synchronization by repeatedly running commands.
|
||||
Test {}
|
||||
Test {},
|
||||
}
|
||||
|
||||
/// An attachable USB device identifier.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DeviceIdentifier {
|
||||
/// The Vendor ID.
|
||||
pub vendorid: String,
|
||||
pub vendor_id: u16,
|
||||
|
||||
/// The Device ID.
|
||||
pub deviceid: String,
|
||||
pub device_id: u16,
|
||||
}
|
||||
|
||||
/// An error encountered while parsing a USB device identifier
|
||||
|
@ -80,8 +83,11 @@ pub enum DeviceIdentifierFromStrError {
|
|||
#[error("could not split input by colon; expected output similar to `lsusb`")]
|
||||
CouldNotSplitByColon,
|
||||
|
||||
#[error("found non-hex {0} at position {1}")]
|
||||
BadChar(char, usize),
|
||||
#[error("could not parse hex from vendor or device ID")]
|
||||
Hex(#[from] hex::FromHexError),
|
||||
|
||||
#[error("could not decode u64 from bytes: {0:?}")]
|
||||
BadBytes(Vec<u8>),
|
||||
}
|
||||
|
||||
impl FromStr for DeviceIdentifier {
|
||||
|
@ -91,23 +97,22 @@ impl FromStr for DeviceIdentifier {
|
|||
let Some((first, last)) = s.split_once(':') else {
|
||||
return Err(DeviceIdentifierFromStrError::CouldNotSplitByColon);
|
||||
};
|
||||
if let Some((position, ch)) = first
|
||||
.chars()
|
||||
.enumerate()
|
||||
.find(|(_, ch)| !ch.is_ascii_hexdigit())
|
||||
{
|
||||
return Err(DeviceIdentifierFromStrError::BadChar(ch, position));
|
||||
}
|
||||
if let Some((position, ch)) = last
|
||||
.chars()
|
||||
.enumerate()
|
||||
.find(|(_, ch)| !ch.is_ascii_hexdigit())
|
||||
{
|
||||
return Err(DeviceIdentifierFromStrError::BadChar(ch, position));
|
||||
}
|
||||
|
||||
let vendor_id = u16::from_be_bytes(
|
||||
hex::decode(first)?
|
||||
.try_into()
|
||||
.map_err(DeviceIdentifierFromStrError::BadBytes)?,
|
||||
);
|
||||
|
||||
let device_id = u16::from_be_bytes(
|
||||
hex::decode(last)?
|
||||
.try_into()
|
||||
.map_err(DeviceIdentifierFromStrError::BadBytes)?,
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
vendorid: first.to_owned(),
|
||||
deviceid: last.to_owned(),
|
||||
vendor_id,
|
||||
device_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
27
src/main.rs
27
src/main.rs
|
@ -21,9 +21,12 @@ fn main() -> eyre::Result<()> {
|
|||
let opts = cli::App::parse_from(args);
|
||||
|
||||
match opts.subcommand {
|
||||
cli::Commands::Start => {
|
||||
let spawn_args = SpawnArguments::default();
|
||||
let mut vm = VirtualMachine::start(spawn_args)?;
|
||||
cli::Commands::Start { memory } => {
|
||||
let spawn_args = SpawnArguments {
|
||||
memory: memory.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let vm = VirtualMachine::start(spawn_args)?;
|
||||
let pid = vm.pid();
|
||||
std::fs::write(&opts.lockfile, pid.to_string()).with_context(|| {
|
||||
format!(
|
||||
|
@ -31,9 +34,6 @@ fn main() -> eyre::Result<()> {
|
|||
lockfile = opts.lockfile.display(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// temp
|
||||
vm.run_command("uptime", [])?;
|
||||
}
|
||||
cli::Commands::Stop => {
|
||||
let spawn_arguments = SpawnArguments::default();
|
||||
|
@ -41,8 +41,7 @@ fn main() -> eyre::Result<()> {
|
|||
vm.kill()?;
|
||||
}
|
||||
cli::Commands::Shell => {
|
||||
// TODO: qemu inline, is it possible to pass through stdin/stdout w/o buffering?
|
||||
todo!()
|
||||
todo!("custom args to starting a VM and piping stdin/stdout are not yet implemented");
|
||||
}
|
||||
cli::Commands::Status => {
|
||||
let spawn_arguments = SpawnArguments::default();
|
||||
|
@ -57,7 +56,17 @@ fn main() -> eyre::Result<()> {
|
|||
eprintln!("hostname: {hostname}");
|
||||
eprint!("{}", String::from_utf8_lossy(&uptime.0));
|
||||
}
|
||||
cli::Commands::Attach { device } => todo!(),
|
||||
cli::Commands::Attach { device } => {
|
||||
let spawn_arguments = SpawnArguments::default();
|
||||
let mut vm = VirtualMachine::load(spawn_arguments, None)?;
|
||||
vm.execute_host("qmp_capabilities", serde_json::json!({}))?;
|
||||
vm.execute_host("device_add", serde_json::json!({
|
||||
"driver": "usb-host",
|
||||
"bus": "usb.0",
|
||||
"vendorid": device.vendor_id,
|
||||
"productid": device.device_id,
|
||||
}))?;
|
||||
},
|
||||
cli::Commands::Push {
|
||||
local_path,
|
||||
remote_path,
|
||||
|
|
108
src/vm.rs
108
src/vm.rs
|
@ -30,7 +30,7 @@ fn spinner(msg: impl Display) -> ProgressBar {
|
|||
}
|
||||
|
||||
fn bar(count: u64, msg: impl Display) -> ProgressBar {
|
||||
let template = "[{elapsed_precise}] {wide_bar} {percent}% {msg}";
|
||||
let template = "{elapsed_precise} [{wide_bar}] {percent}% {msg}";
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "unicode")] {
|
||||
let style = ProgressStyle::with_template(template).unwrap();
|
||||
|
@ -119,16 +119,20 @@ fn find_pci_device_by_class(class: u16) -> Result<Vec<Device>> {
|
|||
#[derive(Debug)]
|
||||
pub struct VirtualMachine {
|
||||
pid: u32,
|
||||
writer: UnixStream,
|
||||
reader: BufReader<UnixStream>,
|
||||
// qemu guest agent (proxied to guest)
|
||||
guest_writer: UnixStream,
|
||||
guest_reader: BufReader<UnixStream>,
|
||||
// qemu machine protocol (host)
|
||||
host_writer: UnixStream,
|
||||
host_reader: BufReader<UnixStream>,
|
||||
args: SpawnArguments,
|
||||
}
|
||||
|
||||
/// The configuration to use when starting a VM.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SpawnArguments {
|
||||
/// The PCI device to use for connecting to a network.
|
||||
pub network_pci_device: Option<String>,
|
||||
/// The amount of memory to allocate to a VM.
|
||||
pub memory: String,
|
||||
|
||||
/// The image file to use when booting the machine.
|
||||
///
|
||||
|
@ -148,7 +152,7 @@ pub struct SpawnArguments {
|
|||
impl Default for SpawnArguments {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
network_pci_device: None,
|
||||
memory: String::from("1G"),
|
||||
guest_image: PathBuf::from("/guest.img"),
|
||||
guest_agent_socket_path: PathBuf::from("/var/run/netvm_qga.sock"),
|
||||
qmp_socket_path: PathBuf::from("/var/run/netvm_qmp.sock"),
|
||||
|
@ -220,7 +224,7 @@ impl VirtualMachine {
|
|||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.args(["-m", "4G"])
|
||||
.args(["-m", &args.memory])
|
||||
.args(["-machine", "q35"])
|
||||
.arg("-nographic")
|
||||
.args(["-serial", "none"])
|
||||
|
@ -255,7 +259,9 @@ impl VirtualMachine {
|
|||
return Err(eyre::eyre!("child exited with code {:?}", status.code()));
|
||||
}
|
||||
|
||||
if std::fs::exists(&args.guest_agent_socket_path)? {
|
||||
if std::fs::exists(&args.guest_agent_socket_path)?
|
||||
&& std::fs::exists(&args.qmp_socket_path)?
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
|
@ -277,14 +283,11 @@ impl VirtualMachine {
|
|||
}
|
||||
};
|
||||
|
||||
let writer = UnixStream::connect(&args.guest_agent_socket_path)
|
||||
let guest = UnixStream::connect(&args.guest_agent_socket_path)
|
||||
.context("could not open socket to QVM guest agent")?;
|
||||
|
||||
let reader = BufReader::new(
|
||||
writer
|
||||
.try_clone()
|
||||
.context("couldn't clone socket to make buffered reader")?,
|
||||
);
|
||||
let host = UnixStream::connect(&args.qmp_socket_path)
|
||||
.context("could not open socket to qemu management socket")?;
|
||||
|
||||
bar.println(format!(
|
||||
"Connected to VM with PID {} and socket {}",
|
||||
|
@ -293,21 +296,38 @@ impl VirtualMachine {
|
|||
));
|
||||
bar.finish_and_clear();
|
||||
|
||||
let vm = Self::from_parts(pid, writer, reader, args)?;
|
||||
let vm = Self::from_parts(pid, guest, host, args)?;
|
||||
|
||||
Ok(vm)
|
||||
}
|
||||
|
||||
fn from_parts(
|
||||
pid: u32,
|
||||
writer: UnixStream,
|
||||
reader: BufReader<UnixStream>,
|
||||
guest_socket: UnixStream,
|
||||
host_socket: UnixStream,
|
||||
args: SpawnArguments,
|
||||
) -> Result<Self> {
|
||||
let guest_reader = BufReader::new(
|
||||
guest_socket
|
||||
.try_clone()
|
||||
.context("couldn't clone socket to make buffered reader")?,
|
||||
);
|
||||
let mut host_reader = BufReader::new(
|
||||
host_socket
|
||||
.try_clone()
|
||||
.context("couldn't clone socket to make buffered reader")?,
|
||||
);
|
||||
|
||||
let mut server_hello = String::new();
|
||||
host_reader.read_line(&mut server_hello)
|
||||
.context("can't read line from socket (pre-load)")?;
|
||||
|
||||
let mut vm = Self {
|
||||
pid,
|
||||
writer,
|
||||
reader,
|
||||
guest_writer: guest_socket,
|
||||
guest_reader,
|
||||
host_writer: host_socket,
|
||||
host_reader,
|
||||
args,
|
||||
};
|
||||
|
||||
|
@ -347,21 +367,21 @@ impl VirtualMachine {
|
|||
// * read a line from the parser to reset the input
|
||||
|
||||
let bar = spinner("Re-establishing connection...");
|
||||
self.writer
|
||||
self.guest_writer
|
||||
.set_nonblocking(true)
|
||||
.context("flush: can't set nonblocking")?;
|
||||
if let Err(e) = self.reader.read_to_end(&mut vec![]) {
|
||||
if let Err(e) = self.guest_reader.read_to_end(&mut vec![]) {
|
||||
if e.kind() != std::io::ErrorKind::WouldBlock {
|
||||
return Err(e).context("flush: can't read nonblocked data");
|
||||
}
|
||||
}
|
||||
self.writer
|
||||
self.guest_writer
|
||||
.set_nonblocking(false)
|
||||
.context("flush: can't set blocking")?;
|
||||
self.writer
|
||||
self.guest_writer
|
||||
.write_all(&[0x1b])
|
||||
.context("flush: can't send reset byte")?;
|
||||
self.reader
|
||||
self.guest_reader
|
||||
.read_line(&mut String::new())
|
||||
.context("flush: can't read error")?;
|
||||
bar.finish_and_clear();
|
||||
|
@ -599,12 +619,12 @@ impl VirtualMachine {
|
|||
"arguments": args,
|
||||
});
|
||||
|
||||
serde_json::to_writer(&mut self.writer, &message)
|
||||
serde_json::to_writer(&mut self.guest_writer, &message)
|
||||
.context("could not send message over socket")?;
|
||||
writeln!(&mut self.writer).context("could not send newline over socket")?;
|
||||
writeln!(&mut self.guest_writer).context("could not send newline over socket")?;
|
||||
|
||||
let mut line = String::new();
|
||||
self.reader
|
||||
self.guest_reader
|
||||
.read_line(&mut line)
|
||||
.context("can't read line from socket")?;
|
||||
|
||||
|
@ -631,6 +651,40 @@ impl VirtualMachine {
|
|||
result
|
||||
}
|
||||
|
||||
pub fn execute_host<S: serde::Serialize + Debug>(
|
||||
&mut self,
|
||||
command: &'static str,
|
||||
args: S,
|
||||
) -> Result<serde_json::Value> {
|
||||
let bar = spinner(format!("Executing: {command:?} with {args:?}"));
|
||||
|
||||
let message = serde_json::json!({
|
||||
"execute": command,
|
||||
"arguments": args,
|
||||
});
|
||||
|
||||
serde_json::to_writer(&mut self.host_writer, &message)
|
||||
.context("could not send message over socket")?;
|
||||
writeln!(&mut self.host_writer).context("could not send newline over socket")?;
|
||||
|
||||
let mut line = String::new();
|
||||
self.host_reader
|
||||
.read_line(&mut line)
|
||||
.context("can't read line from socket")?;
|
||||
|
||||
let response: serde_json::Value = serde_json::from_str(&line).context("response from qemu is not json")?;
|
||||
|
||||
bar.finish_and_clear();
|
||||
|
||||
if let Some(response) = response.get("return") {
|
||||
Ok(response.clone())
|
||||
} else if let Some(error) = response.get("error") {
|
||||
Err(eyre::eyre!("error response from qemu: {error:?}"))
|
||||
} else {
|
||||
Err(eyre::eyre!("invalid response from qemu: {response:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: u32 is returned from Process::id(), i32 is the Linux internal version
|
||||
// This should be safe; the kernel wouldn't give a value that, when converted
|
||||
// to a u32, can't be made back into an i32
|
||||
|
|
Loading…
Reference in New Issue