xtask_todo_lib/devshell/vm/
mod.rs1#![allow(clippy::pedantic, clippy::nursery)]
4
5use std::cell::RefCell;
6use std::io::Write;
7use std::rc::Rc;
8
9mod config;
10mod guest_fs_ops;
11#[cfg(unix)]
12mod lima_diagnostics;
13#[cfg(feature = "beta-vm")]
14mod podman_machine;
15#[cfg(feature = "beta-vm")]
16mod session_beta;
17#[cfg(unix)]
18mod session_gamma;
19mod session_host;
20pub mod sync;
21mod workspace_host;
22
23pub use config::{
24 exec_timeout_ms_from_env, workspace_mode_from_env, VmConfig, WorkspaceMode, ENV_DEVSHELL_VM,
25 ENV_DEVSHELL_VM_BACKEND, ENV_DEVSHELL_VM_BETA_SESSION_STAGING, ENV_DEVSHELL_VM_CONTAINER_IMAGE,
26 ENV_DEVSHELL_VM_DISABLE_PODMAN_SSH_HOME, ENV_DEVSHELL_VM_EAGER,
27 ENV_DEVSHELL_VM_EXEC_TIMEOUT_MS, ENV_DEVSHELL_VM_LIMA_INSTANCE, ENV_DEVSHELL_VM_LINUX_BINARY,
28 ENV_DEVSHELL_VM_REPO_ROOT, ENV_DEVSHELL_VM_SKIP_PODMAN_BOOTSTRAP, ENV_DEVSHELL_VM_SOCKET,
29 ENV_DEVSHELL_VM_STDIO_TRANSPORT, ENV_DEVSHELL_VM_WORKSPACE_MODE,
30};
31#[cfg(unix)]
32pub use guest_fs_ops::LimaGuestFsOps;
33pub use guest_fs_ops::{
34 guest_path_is_under_mount, guest_project_dir_on_guest, normalize_guest_path, GuestFsError,
35 GuestFsOps, MockGuestFsOps,
36};
37#[cfg(unix)]
38pub use lima_diagnostics::ENV_DEVSHELL_VM_LIMA_HINTS;
39#[cfg(unix)]
40pub use session_gamma::{
41 GammaSession, ENV_DEVSHELL_VM_AUTO_BUILD_ESSENTIAL, ENV_DEVSHELL_VM_AUTO_BUILD_TODO_GUEST,
42 ENV_DEVSHELL_VM_AUTO_TODO_PATH, ENV_DEVSHELL_VM_GUEST_HOST_DIR,
43 ENV_DEVSHELL_VM_GUEST_TODO_HINT, ENV_DEVSHELL_VM_GUEST_WORKSPACE, ENV_DEVSHELL_VM_LIMACTL,
44 ENV_DEVSHELL_VM_STOP_ON_EXIT, ENV_DEVSHELL_VM_WORKSPACE_PARENT,
45 ENV_DEVSHELL_VM_WORKSPACE_USE_CARGO_ROOT,
46};
47pub use session_host::HostSandboxSession;
48pub use sync::{pull_workspace_to_vfs, push_full, push_incremental, VmSyncError};
49pub use workspace_host::workspace_parent_for_instance;
50
51use std::process::ExitStatus;
52
53use super::sandbox;
54use super::vfs::Vfs;
55
56#[derive(Debug)]
58pub enum VmError {
59 Sandbox(sandbox::SandboxError),
60 Sync(VmSyncError),
61 BackendNotImplemented(&'static str),
63 Lima(String),
65 Ipc(String),
67}
68
69impl std::fmt::Display for VmError {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 Self::Sandbox(e) => write!(f, "{e}"),
73 Self::Sync(e) => write!(f, "{e}"),
74 Self::BackendNotImplemented(s) => write!(f, "vm backend not implemented: {s}"),
75 Self::Lima(s) => f.write_str(s),
76 Self::Ipc(s) => f.write_str(s),
77 }
78 }
79}
80
81impl std::error::Error for VmError {
82 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
83 match self {
84 Self::Sandbox(e) => Some(e),
85 Self::Sync(e) => Some(e),
86 Self::BackendNotImplemented(_) | Self::Lima(_) | Self::Ipc(_) => None,
87 }
88 }
89}
90
91pub trait VmExecutionSession {
93 fn ensure_ready(&mut self, vfs: &Vfs, vfs_cwd: &str) -> Result<(), VmError>;
95
96 fn run_rust_tool(
98 &mut self,
99 vfs: &mut Vfs,
100 vfs_cwd: &str,
101 program: &str,
102 args: &[String],
103 ) -> Result<ExitStatus, VmError>;
104
105 fn shutdown(&mut self, vfs: &mut Vfs, vfs_cwd: &str) -> Result<(), VmError>;
107}
108
109#[derive(Debug)]
111pub enum SessionHolder {
112 Host(HostSandboxSession),
113 #[cfg(unix)]
115 Gamma(GammaSession),
116 #[cfg(feature = "beta-vm")]
118 Beta(session_beta::BetaSession),
119}
120
121#[cfg(unix)]
123pub(crate) fn bash_single_quoted(s: &str) -> String {
124 let mut o = String::from("'");
125 for c in s.chars() {
126 if c == '\'' {
127 o.push_str("'\"'\"'");
128 } else {
129 o.push(c);
130 }
131 }
132 o.push('\'');
133 o
134}
135
136#[cfg(all(unix, test))]
137mod bash_single_quoted_tests {
138 use super::bash_single_quoted;
139
140 #[test]
141 fn wraps_plain_path() {
142 assert_eq!(
143 bash_single_quoted("/workspace/p/target/release"),
144 "'/workspace/p/target/release'"
145 );
146 }
147}
148
149impl SessionHolder {
150 pub fn try_from_config(config: &VmConfig) -> Result<Self, VmError> {
156 if !config.enabled {
157 return Ok(Self::Host(HostSandboxSession::new()));
158 }
159 if config.use_host_sandbox() {
160 return Ok(Self::Host(HostSandboxSession::new()));
161 }
162 #[cfg(feature = "beta-vm")]
163 if config.backend.eq_ignore_ascii_case("beta") {
164 return session_beta::BetaSession::new(config).map(SessionHolder::Beta);
165 }
166 #[cfg(not(feature = "beta-vm"))]
167 if config.backend.eq_ignore_ascii_case("beta") {
168 return Err(VmError::BackendNotImplemented(
169 "DEVSHELL_VM_BACKEND=beta requires building xtask-todo-lib with `--features beta-vm`",
170 ));
171 }
172 #[cfg(unix)]
173 if config.backend.eq_ignore_ascii_case("lima") {
174 return GammaSession::new(config).map(SessionHolder::Gamma);
175 }
176 #[cfg(not(unix))]
177 if config.backend.eq_ignore_ascii_case("lima") {
178 return Err(VmError::BackendNotImplemented(
179 "lima backend is only supported on Linux and macOS",
180 ));
181 }
182 Err(VmError::BackendNotImplemented(
183 "unknown DEVSHELL_VM_BACKEND (try host, auto, lima, or beta); see docs/devshell-vm-gamma.md",
184 ))
185 }
186
187 #[must_use]
189 pub fn new_host() -> Self {
190 Self::Host(HostSandboxSession::new())
191 }
192
193 pub fn ensure_ready(&mut self, vfs: &Vfs, vfs_cwd: &str) -> Result<(), VmError> {
194 match self {
195 Self::Host(s) => VmExecutionSession::ensure_ready(s, vfs, vfs_cwd),
196 #[cfg(unix)]
197 Self::Gamma(s) => VmExecutionSession::ensure_ready(s, vfs, vfs_cwd),
198 #[cfg(feature = "beta-vm")]
199 Self::Beta(s) => VmExecutionSession::ensure_ready(s, vfs, vfs_cwd),
200 }
201 }
202
203 pub fn run_rust_tool(
204 &mut self,
205 vfs: &mut Vfs,
206 vfs_cwd: &str,
207 program: &str,
208 args: &[String],
209 ) -> Result<ExitStatus, VmError> {
210 match self {
211 Self::Host(s) => VmExecutionSession::run_rust_tool(s, vfs, vfs_cwd, program, args),
212 #[cfg(unix)]
213 Self::Gamma(s) => VmExecutionSession::run_rust_tool(s, vfs, vfs_cwd, program, args),
214 #[cfg(feature = "beta-vm")]
215 Self::Beta(s) => VmExecutionSession::run_rust_tool(s, vfs, vfs_cwd, program, args),
216 }
217 }
218
219 pub fn shutdown(&mut self, vfs: &mut Vfs, vfs_cwd: &str) -> Result<(), VmError> {
220 match self {
221 Self::Host(s) => VmExecutionSession::shutdown(s, vfs, vfs_cwd),
222 #[cfg(unix)]
223 Self::Gamma(s) => VmExecutionSession::shutdown(s, vfs, vfs_cwd),
224 #[cfg(feature = "beta-vm")]
225 Self::Beta(s) => VmExecutionSession::shutdown(s, vfs, vfs_cwd),
226 }
227 }
228
229 #[cfg(unix)]
233 #[must_use]
234 pub fn guest_primary_gamma_mut(&mut self) -> Option<&mut GammaSession> {
235 match self {
236 Self::Gamma(g) if !g.syncs_vfs_with_host_workspace() => Some(g),
237 _ => None,
238 }
239 }
240
241 #[must_use]
246 pub fn guest_primary_fs_ops_mut(&mut self) -> Option<(&mut dyn GuestFsOps, String)> {
247 match self {
248 #[cfg(unix)]
249 Self::Gamma(g) if !g.syncs_vfs_with_host_workspace() => {
250 let mount = g.guest_mount().to_string();
251 Some((g as &mut dyn GuestFsOps, mount))
252 }
253 #[cfg(feature = "beta-vm")]
254 Self::Beta(b) if !b.syncs_vfs_with_host_workspace() => {
255 let mount = b.guest_mount().to_string();
256 Some((b as &mut dyn GuestFsOps, mount))
257 }
258 _ => None,
259 }
260 }
261
262 #[must_use]
264 pub fn is_guest_primary(&self) -> bool {
265 match self {
266 #[cfg(unix)]
267 Self::Gamma(g) if !g.syncs_vfs_with_host_workspace() => true,
268 #[cfg(feature = "beta-vm")]
269 Self::Beta(b) if !b.syncs_vfs_with_host_workspace() => true,
270 _ => false,
271 }
272 }
273
274 #[must_use]
276 pub fn is_guest_primary_gamma(&self) -> bool {
277 #[cfg(unix)]
278 {
279 matches!(
280 self,
281 Self::Gamma(g) if !g.syncs_vfs_with_host_workspace()
282 )
283 }
284 #[cfg(not(unix))]
285 {
286 false
287 }
288 }
289
290 #[must_use]
292 pub fn is_host_only(&self) -> bool {
293 matches!(self, Self::Host(_))
294 }
295
296 #[cfg(unix)]
300 pub fn exec_lima_interactive_shell(&self) -> std::io::Error {
301 use std::os::unix::process::CommandExt;
302 use std::process::Command;
303 match self {
304 Self::Gamma(g) => {
305 let (workdir, inner) = g.lima_interactive_shell_workdir_and_inner();
306 Command::new(g.limactl_path())
307 .arg("shell")
308 .arg("-y")
309 .arg("--workdir")
310 .arg(workdir)
311 .arg(g.lima_instance_name())
312 .arg("--")
313 .arg("bash")
314 .arg("-lc")
315 .arg(inner)
316 .exec()
317 }
318 _ => std::io::Error::other("exec_lima_interactive_shell: not a Lima gamma session"),
319 }
320 }
321}
322
323#[allow(clippy::result_unit_err)] pub fn try_session_rc(stderr: &mut dyn Write) -> Result<Rc<RefCell<SessionHolder>>, ()> {
327 let config = VmConfig::from_env();
328 match SessionHolder::try_from_config(&config) {
329 Ok(s) => Ok(Rc::new(RefCell::new(s))),
330 Err(e) => {
331 let _ = writeln!(stderr, "dev_shell: {e}");
332 Err(())
333 }
334 }
335}
336
337pub fn try_session_rc_or_host(stderr: &mut dyn Write) -> Rc<RefCell<SessionHolder>> {
340 match try_session_rc(stderr) {
341 Ok(s) => s,
342 Err(()) => {
343 let _ = writeln!(
344 stderr,
345 "dev_shell: VM unavailable — in-process REPL uses the same host directory as the Lima workspace (DEVSHELL_WORKSPACE_ROOT)."
346 );
347 Rc::new(RefCell::new(SessionHolder::Host(HostSandboxSession::new())))
348 }
349 }
350}
351
352#[cfg(unix)]
353pub fn export_devshell_workspace_root_env() {
354 #[cfg(test)]
355 let _workspace_env_test_guard = crate::test_support::devshell_workspace_env_mutex();
356 let c = config::VmConfig::from_env();
357 let p = session_gamma::workspace_parent_for_instance(&c.lima_instance);
358 let _ = std::fs::create_dir_all(&p);
359 if let Ok(can) = p.canonicalize() {
360 std::env::set_var("DEVSHELL_WORKSPACE_ROOT", can.as_os_str());
361 }
362}
363
364#[cfg(not(unix))]
365pub fn export_devshell_workspace_root_env() {}
366
367#[cfg(unix)]
369#[must_use]
370pub fn vm_workspace_host_root() -> std::path::PathBuf {
371 let c = config::VmConfig::from_env();
372 session_gamma::workspace_parent_for_instance(&c.lima_instance)
373}
374
375#[cfg(not(unix))]
378#[must_use]
379pub fn vm_workspace_host_root() -> std::path::PathBuf {
380 std::path::PathBuf::new()
381}
382
383#[cfg(unix)]
384#[must_use]
385pub fn should_delegate_lima_shell(
386 vm_session: &Rc<RefCell<SessionHolder>>,
387 is_tty: bool,
388 run_script: bool,
389) -> bool {
390 if run_script || !is_tty {
391 return false;
392 }
393 if std::env::var("DEVSHELL_VM_INTERNAL_REPL")
394 .map(|s| {
395 let s = s.trim();
396 s == "1" || s.eq_ignore_ascii_case("true") || s.eq_ignore_ascii_case("yes")
397 })
398 .unwrap_or(false)
399 {
400 return false;
401 }
402 matches!(*vm_session.borrow(), SessionHolder::Gamma(_))
403}
404
405#[cfg(test)]
406mod tests;