Skip to main content

pipette_client/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! The client for `pipette`.
5
6#![forbid(unsafe_code)]
7
8pub mod process;
9mod send;
10pub mod shell;
11
12pub use pipette_protocol::PIPETTE_PORT;
13pub use pipette_protocol::PIPETTE_READY_MARKER;
14
15use crate::send::PipetteSender;
16use anyhow::Context;
17use futures::AsyncBufReadExt;
18use futures::AsyncRead;
19use futures::AsyncWrite;
20use futures::AsyncWriteExt;
21use futures::FutureExt as _;
22use futures::StreamExt;
23use futures::io::BufReader;
24use futures_concurrency::future::TryJoin;
25use mesh::CancelContext;
26use mesh::error::RemoteError;
27use mesh::payload::Timestamp;
28use mesh::rpc::RpcError;
29use mesh_remote::PointToPointMesh;
30use pal_async::task::Spawn;
31use pal_async::task::Task;
32use pipette_protocol::DiagnosticFile;
33use pipette_protocol::PipetteBootstrap;
34use pipette_protocol::PipetteRequest;
35use pipette_protocol::ReadFileRequest;
36use pipette_protocol::WriteFileRequest;
37use shell::UnixShell;
38use shell::WindowsShell;
39use std::path::Path;
40use std::path::PathBuf;
41use std::time::Duration;
42
43/// A client to a running `pipette` instance inside a VM.
44pub struct PipetteClient {
45    send: PipetteSender,
46    watch: mesh::OneshotReceiver<()>,
47    _mesh: PointToPointMesh,
48    _log_task: Task<()>,
49    _diag_task: Task<()>,
50}
51
52/// Maximum time to wait for a single pipette connection attempt — the mesh
53/// handshake plus the liveness ping — to complete.
54const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
55
56impl PipetteClient {
57    /// Connects to a `pipette` instance inside a VM.
58    ///
59    /// `conn` must be an established connection over some byte stream (e.g., a
60    /// socket).
61    pub async fn new(
62        spawner: impl Spawn,
63        conn: impl 'static + AsyncRead + AsyncWrite + Send + Unpin,
64        output_dir: &Path,
65    ) -> anyhow::Result<Self> {
66        let mut ctx = CancelContext::new().with_timeout(CONNECT_TIMEOUT);
67        match ctx
68            .until_cancelled(Self::connect(spawner, conn, output_dir))
69            .await
70        {
71            Ok(result) => result,
72            Err(_) => Err(anyhow::anyhow!(
73                "timed out establishing pipette connection after {CONNECT_TIMEOUT:?}"
74            )),
75        }
76    }
77
78    async fn connect(
79        spawner: impl Spawn,
80        conn: impl 'static + AsyncRead + AsyncWrite + Send + Unpin,
81        output_dir: &Path,
82    ) -> anyhow::Result<Self> {
83        let (bootstrap_send, bootstrap_recv) = mesh::oneshot::<PipetteBootstrap>();
84        let mesh = PointToPointMesh::new(&spawner, conn, bootstrap_send.into());
85        let bootstrap = bootstrap_recv
86            .await
87            .context("failed to receive pipette bootstrap")?;
88
89        let PipetteBootstrap {
90            requests,
91            diag_file_recv,
92            watch,
93            log,
94        } = bootstrap;
95
96        let log_task = spawner.spawn("pipette-log", replay_logs(log));
97        let diag_task = spawner.spawn(
98            "diagnostics-recv",
99            recv_diag_files(output_dir.to_owned(), diag_file_recv),
100        );
101
102        let client = Self {
103            send: PipetteSender::new(requests),
104            watch,
105            _mesh: mesh,
106            _log_task: log_task,
107            _diag_task: diag_task,
108        };
109
110        // A successful mesh handshake is not, on its own, proof of a usable
111        // connection: a byte stream can deliver the guest's bootstrap to the host
112        // and then be torn down moments later — for example by the reset in a
113        // save/restore pulse, or by a TCP forward that silently drops the
114        // guest's traffic — leaving a client whose requests would never be
115        // answered. To guard against this, we confirm the agent is actually
116        // reachable with a ping, bounded by the timeout in `new`.
117        client
118            .ping()
119            .await
120            .context("pipette liveness ping failed")?;
121
122        Ok(client)
123    }
124
125    /// Pings the agent to check if it's alive.
126    pub async fn ping(&self) -> Result<(), RpcError> {
127        self.send.call(PipetteRequest::Ping, ()).await
128    }
129
130    /// Return a shell object to interact with a Windows guest.
131    pub fn windows_shell(&self) -> WindowsShell<'_> {
132        WindowsShell::new(self)
133    }
134
135    /// Return a shell object to interact with a Linux guest.
136    pub fn unix_shell(&self) -> UnixShell<'_> {
137        UnixShell::new(self)
138    }
139
140    /// Mounts a filesystem inside the guest (Linux only).
141    pub async fn mount(
142        &self,
143        source: &str,
144        target: &str,
145        fstype: &str,
146        flags: u64,
147        mkdir_target: bool,
148    ) -> anyhow::Result<()> {
149        self.send
150            .call_failable(
151                PipetteRequest::Mount,
152                pipette_protocol::MountRequest {
153                    source: source.to_owned(),
154                    target: target.to_owned(),
155                    fstype: fstype.to_owned(),
156                    flags,
157                    mkdir_target,
158                },
159            )
160            .await
161            .context("failed to send mount request")?;
162        Ok(())
163    }
164
165    /// Prepares a chroot by bind-mounting `/proc`, `/dev`, and `/sys` into it,
166    /// and mounting a writable tmpfs at `/tmp`.
167    pub async fn prepare_chroot(&self, target: &str) -> anyhow::Result<()> {
168        // MS_BIND = 0x1000
169        const MS_BIND: u64 = 0x1000;
170        for dir in ["/proc", "/dev", "/sys"] {
171            let mount_target = format!("{target}{dir}");
172            self.mount(dir, &mount_target, "", MS_BIND, true).await?;
173        }
174        // Mount a writable tmpfs so tools like iperf3 can create temp files.
175        let tmp_target = format!("{target}/tmp");
176        self.mount("tmpfs", &tmp_target, "tmpfs", 0, true).await?;
177        Ok(())
178    }
179
180    /// Returns an object used to launch a command inside the guest.
181    ///
182    /// TODO: this is a low-level interface. Make a high-level interface like
183    /// `xshell::Shell` for manipulating the environment and launching
184    /// processes.
185    pub fn command(&self, program: impl AsRef<str>) -> process::Command<'_> {
186        process::Command::new(self, program)
187    }
188
189    /// Sends a request to the guest to power off.
190    pub async fn power_off(&self) -> anyhow::Result<()> {
191        self.shutdown(pipette_protocol::ShutdownType::PowerOff)
192            .await
193    }
194
195    /// Sends a request to the guest to reboot.
196    pub async fn reboot(&self) -> anyhow::Result<()> {
197        self.shutdown(pipette_protocol::ShutdownType::Reboot).await
198    }
199
200    async fn shutdown(&self, shutdown_type: pipette_protocol::ShutdownType) -> anyhow::Result<()> {
201        tracing::debug!(?shutdown_type, "sending shutdown request to guest");
202        let r = self.send.call(
203            PipetteRequest::Shutdown,
204            pipette_protocol::ShutdownRequest { shutdown_type },
205        );
206        match r.await {
207            Ok(r) => r
208                .map_err(anyhow::Error::from)
209                .context("failed to shut down")?,
210            Err(_) => {
211                // Presumably this is an expected error due to the agent exiting
212                // or the guest powering off.
213            }
214        }
215        Ok(())
216    }
217
218    /// Reads the full contents of a file.
219    pub async fn read_file(&self, path: impl AsRef<str>) -> anyhow::Result<Vec<u8>> {
220        let (recv_pipe, send_pipe) = mesh::pipe::pipe();
221        let req = ReadFileRequest {
222            path: path.as_ref().to_string(),
223            sender: send_pipe,
224        };
225
226        let request_future = self.send.call_failable(PipetteRequest::ReadFile, req);
227
228        let mut contents = Vec::new();
229        let transfer_future = async { futures::io::copy(recv_pipe, &mut contents).await };
230
231        tracing::debug!(path = path.as_ref(), "beginning file read transfer");
232        let (bytes_read, io_result) = (request_future, transfer_future.map(Ok))
233            .try_join()
234            .await
235            .context("failed to read file")?;
236
237        io_result.context("io failure")?;
238        if bytes_read != contents.len() as u64 {
239            anyhow::bail!("file truncated");
240        }
241
242        tracing::debug!("file read complete");
243        Ok(contents)
244    }
245
246    /// Writes a file to the guest.
247    /// Note: This may transfer the file in chunks. It is likely not suitable
248    /// for writing to files that require all content to be written at once,
249    /// e.g. files in /proc or /sys.
250    pub async fn write_file(
251        &self,
252        path: impl AsRef<str>,
253        contents: impl AsyncRead,
254    ) -> anyhow::Result<()> {
255        let (recv_pipe, mut send_pipe) = mesh::pipe::pipe();
256        let req = WriteFileRequest {
257            path: path.as_ref().to_string(),
258            receiver: recv_pipe,
259        };
260
261        let request_future = self.send.call_failable(PipetteRequest::WriteFile, req);
262
263        let transfer_future = async {
264            let copy_result = futures::io::copy(contents, &mut send_pipe).await;
265            send_pipe.close().await?;
266            copy_result
267        };
268
269        tracing::debug!(path = path.as_ref(), "beginning file wurite transfer");
270        let (bytes_written, io_result) = (request_future, transfer_future.map(Ok))
271            .try_join()
272            .await
273            .context("failed to write file")?;
274        if bytes_written != io_result.context("io failure")? {
275            anyhow::bail!("file truncated");
276        }
277
278        tracing::debug!("file write complete");
279        Ok(())
280    }
281
282    /// Waits for the agent to exit.
283    pub async fn wait(self) -> Result<(), mesh::RecvError> {
284        self.watch.await
285    }
286
287    /// Returns the current time in the guest.
288    pub async fn get_time(&self) -> anyhow::Result<Timestamp> {
289        self.send
290            .call(PipetteRequest::GetTime, ())
291            .await
292            .context("failed to get time")
293    }
294
295    /// Tell the agent to crash itself.
296    pub async fn crash(&self) -> Result<(), RemoteError> {
297        Self::handle_crash_result(self.send.call_failable(PipetteRequest::Crash, ()).await)
298    }
299
300    /// Tell the agent to crash the kernel.
301    pub async fn kernel_crash(&self) -> Result<(), RemoteError> {
302        Self::handle_crash_result(
303            self.send
304                .call_failable(PipetteRequest::KernelCrash, ())
305                .await,
306        )
307    }
308
309    fn handle_crash_result(r: Result<(), RpcError<RemoteError>>) -> Result<(), RemoteError> {
310        match r {
311            Ok(()) => unreachable!(),
312            Err(RpcError::Call(err)) => Err(err),
313            Err(RpcError::Channel(_)) => {
314                // Presumably this is an expected error due to the agent exiting
315                // or the guest crashing.
316                Ok(())
317            }
318        }
319    }
320}
321
322async fn replay_logs(log: mesh::pipe::ReadPipe) {
323    let mut lines = BufReader::new(log).lines();
324    while let Some(line) = lines.next().await {
325        match line {
326            Ok(line) => tracing::debug!(target: "pipette", "{}", line),
327            Err(err) => {
328                tracing::error!(
329                    error = &err as &dyn std::error::Error,
330                    "pipette log failure"
331                );
332                break;
333            }
334        }
335    }
336}
337
338async fn recv_diag_files(output_dir: PathBuf, mut diag_file_recv: mesh::Receiver<DiagnosticFile>) {
339    while let Some(diag_file) = diag_file_recv.next().await {
340        let DiagnosticFile { name, mut receiver } = diag_file;
341        tracing::debug!(name, "receiving diagnostic file");
342        let path = output_dir.join(&name);
343        let file = fs_err::File::create(&path).expect("failed to create diagnostic file {name}");
344        futures::io::copy(&mut receiver, &mut futures::io::AllowStdIo::new(file))
345            .await
346            .expect("failed to write diagnostic file");
347        tracing::debug!(name, "diagnostic file transfer complete");
348
349        #[expect(
350            clippy::disallowed_methods,
351            reason = "ATTACHMENT is most reliable when using true canonicalized paths"
352        )]
353        let canonical_path = path
354            .canonicalize()
355            .expect("failed to canonicalize attachment path");
356        // Use the inline junit syntax to attach the file to the test result.
357        println!("[[ATTACHMENT|{}]]", canonical_path.display());
358    }
359}