microsandbox_core/management/
menv.rs1use crate::{MicrosandboxError, MicrosandboxResult};
9
10#[cfg(feature = "cli")]
11use microsandbox_utils::term;
12use microsandbox_utils::{
13 DEFAULT_CONFIG, LOG_SUBDIR, MICROSANDBOX_CONFIG_FILENAME, MICROSANDBOX_ENV_DIR, PATCH_SUBDIR,
14 RW_SUBDIR, SANDBOX_DB_FILENAME,
15};
16use std::path::{Path, PathBuf};
17use tokio::{fs, io::AsyncWriteExt};
18
19use super::{config, db};
20
21#[cfg(feature = "cli")]
26const REMOVE_MENV_DIR_MSG: &str = "Remove .menv directory";
27#[cfg(feature = "cli")]
28const INITIALIZE_MENV_DIR_MSG: &str = "Initialize .menv directory";
29#[cfg(feature = "cli")]
30const CREATE_DEFAULT_CONFIG_MSG: &str = "Create default config file";
31#[cfg(feature = "cli")]
32const CLEAN_SANDBOX_MSG: &str = "Clean sandbox";
33
34pub async fn initialize(project_dir: Option<PathBuf>) -> MicrosandboxResult<()> {
57 let project_dir = project_dir.unwrap_or_else(|| PathBuf::from("."));
59 let menv_path = project_dir.join(MICROSANDBOX_ENV_DIR);
60 #[cfg(feature = "cli")]
61 let initialize_menv_dir_sp = if !menv_path.exists() {
62 Some(term::create_spinner(
63 INITIALIZE_MENV_DIR_MSG.to_string(),
64 None,
65 None,
66 ))
67 } else {
68 None
69 };
70
71 fs::create_dir_all(&menv_path).await?;
72
73 ensure_menv_files(&menv_path).await?;
75
76 create_default_config(&project_dir).await?;
78 tracing::info!(
79 "config file at {}",
80 project_dir.join(MICROSANDBOX_CONFIG_FILENAME).display()
81 );
82
83 update_gitignore(&project_dir).await?;
85
86 #[cfg(feature = "cli")]
87 if let Some(sp) = initialize_menv_dir_sp {
88 sp.finish();
89 }
90
91 Ok(())
92}
93
94pub async fn clean(
124 project_dir: Option<PathBuf>,
125 config_file: Option<&str>,
126 sandbox_name: Option<&str>,
127 force: bool,
128) -> MicrosandboxResult<()> {
129 let project_dir = project_dir.unwrap_or_else(|| PathBuf::from("."));
131 let menv_path = project_dir.join(MICROSANDBOX_ENV_DIR);
132
133 let config_result =
135 crate::management::config::load_config(Some(&project_dir), config_file).await;
136
137 if sandbox_name.is_none() {
139 #[cfg(feature = "cli")]
140 let remove_menv_dir_sp = term::create_spinner(REMOVE_MENV_DIR_MSG.to_string(), None, None);
141
142 if config_result.is_ok() && !force {
144 #[cfg(feature = "cli")]
145 term::finish_with_error(&remove_menv_dir_sp);
146
147 #[cfg(feature = "cli")]
148 println!(
149 "Configuration file exists. Use {} to clean the entire environment",
150 console::style("--force").yellow()
151 );
152
153 tracing::info!(
154 "Configuration file exists. Use --force to clean the entire environment"
155 );
156 return Ok(());
157 }
158
159 if menv_path.exists() {
161 fs::remove_dir_all(&menv_path).await?;
163 tracing::info!(
164 "Removed microsandbox environment at {}",
165 menv_path.display()
166 );
167 } else {
168 tracing::info!(
169 "No microsandbox environment found at {}",
170 menv_path.display()
171 );
172 }
173
174 #[cfg(feature = "cli")]
175 remove_menv_dir_sp.finish();
176
177 return Ok(());
178 }
179
180 let sandbox_name = sandbox_name.unwrap();
182 let config_file = config_file.unwrap_or(MICROSANDBOX_CONFIG_FILENAME);
183
184 #[cfg(feature = "cli")]
185 let clean_sandbox_sp = term::create_spinner(
186 format!("{} '{}'", CLEAN_SANDBOX_MSG, sandbox_name),
187 None,
188 None,
189 );
190
191 if let Ok((config, _, _)) = config_result {
193 if config.get_sandbox(sandbox_name).is_some() && !force {
194 #[cfg(feature = "cli")]
195 term::finish_with_error(&clean_sandbox_sp);
196
197 #[cfg(feature = "cli")]
198 println!(
199 "Sandbox '{}' exists in configuration. Use {} to clean it",
200 sandbox_name,
201 console::style("--force").yellow()
202 );
203
204 tracing::info!(
205 "Sandbox '{}' exists in configuration. Use --force to clean it",
206 sandbox_name
207 );
208 return Ok(());
209 }
210 }
211
212 let namespaced_name = PathBuf::from(config_file).join(sandbox_name);
214
215 let rw_path = menv_path.join(RW_SUBDIR).join(&namespaced_name);
217 let patch_path = menv_path.join(PATCH_SUBDIR).join(&namespaced_name);
218
219 if rw_path.exists() {
221 fs::remove_dir_all(&rw_path).await?;
222 tracing::info!("Removed sandbox RW directory at {}", rw_path.display());
223 }
224
225 if patch_path.exists() {
226 fs::remove_dir_all(&patch_path).await?;
227 tracing::info!(
228 "Removed sandbox patch directory at {}",
229 patch_path.display()
230 );
231 }
232
233 let log_file = menv_path
235 .join(LOG_SUBDIR)
236 .join(config_file)
237 .join(format!("{}.log", sandbox_name));
238
239 if log_file.exists() {
240 fs::remove_file(&log_file).await?;
241 tracing::info!("Removed sandbox log file at {}", log_file.display());
242 }
243
244 let db_path = menv_path.join(SANDBOX_DB_FILENAME);
246 if db_path.exists() {
247 let pool = db::get_or_create_pool(&db_path, &db::SANDBOX_DB_MIGRATOR).await?;
248 db::delete_sandbox(&pool, sandbox_name, config_file).await?;
249 tracing::info!("Removed sandbox {} from database", sandbox_name);
250 }
251
252 #[cfg(feature = "cli")]
253 clean_sandbox_sp.finish();
254
255 Ok(())
256}
257
258pub async fn show_log(
289 project_dir: Option<impl AsRef<Path>>,
290 config_file: Option<&str>,
291 sandbox_name: &str,
292 follow: bool,
293 tail: Option<usize>,
294) -> MicrosandboxResult<()> {
295 if follow {
297 let tail_exists = which::which("tail").is_ok();
298 if !tail_exists {
299 return Err(MicrosandboxError::CommandNotFound(
300 "tail command not found. Please install it to use the follow (-f) option."
301 .to_string(),
302 ));
303 }
304 }
305
306 let (_, canonical_project_dir, config_file) =
308 config::load_config(project_dir.as_ref().map(|p| p.as_ref()), config_file).await?;
309
310 let log_path = canonical_project_dir
312 .join(MICROSANDBOX_ENV_DIR)
313 .join(LOG_SUBDIR)
314 .join(&config_file)
315 .join(format!("{}.log", sandbox_name));
316
317 if !log_path.exists() {
319 return Err(MicrosandboxError::LogNotFound(format!(
320 "Log file not found at {}",
321 log_path.display()
322 )));
323 }
324
325 if follow {
326 let mut child = tokio::process::Command::new("tail")
328 .arg("-f")
329 .arg(&log_path)
330 .stdout(std::process::Stdio::inherit())
331 .stderr(std::process::Stdio::inherit())
332 .spawn()?;
333
334 let status = child.wait().await?;
336 if !status.success() {
337 return Err(MicrosandboxError::ProcessWaitError(format!(
338 "tail process exited with status: {}",
339 status
340 )));
341 }
342 } else {
343 let contents = tokio::fs::read_to_string(&log_path).await?;
345
346 let lines: Vec<&str> = contents.lines().collect();
348
349 let lines_to_print = if let Some(n) = tail {
351 if n >= lines.len() {
352 &lines[..]
353 } else {
354 &lines[lines.len() - n..]
355 }
356 } else {
357 &lines[..]
358 };
359
360 for line in lines_to_print {
362 println!("{}", line);
363 }
364 }
365
366 Ok(())
367}
368
369#[cfg(feature = "cli")]
393pub fn show_list<'a, I>(sandboxes: I)
394where
395 I: IntoIterator<Item = (&'a String, &'a crate::config::Sandbox)>,
396{
397 use console::style;
398 use std::collections::HashMap;
399
400 let sandboxes: HashMap<&String, &crate::config::Sandbox> = sandboxes.into_iter().collect();
402
403 if sandboxes.is_empty() {
404 println!("No sandboxes found");
405 return;
406 }
407
408 for (i, (name, sandbox)) in sandboxes.iter().enumerate() {
409 if i > 0 {
410 println!();
411 }
412
413 println!("{}. {}", style(i + 1).bold(), style(*name).bold());
415
416 println!(
418 " {}: {}",
419 style("Image").dim(),
420 sandbox.get_image().to_string()
421 );
422
423 let mut resources = Vec::new();
425 if let Some(cpus) = sandbox.get_cpus() {
426 resources.push(format!("{} CPUs", cpus));
427 }
428 if let Some(memory) = sandbox.get_memory() {
429 resources.push(format!("{} MiB", memory));
430 }
431 if !resources.is_empty() {
432 println!(" {}: {}", style("Resources").dim(), resources.join(", "));
433 }
434
435 println!(
437 " {}: {}",
438 style("Network").dim(),
439 format!("{:?}", sandbox.get_scope())
440 );
441
442 if !sandbox.get_ports().is_empty() {
444 let ports = sandbox
445 .get_ports()
446 .iter()
447 .map(|p| format!("{}:{}", p.get_host(), p.get_guest()))
448 .collect::<Vec<_>>()
449 .join(", ");
450 println!(" {}: {}", style("Ports").dim(), ports);
451 }
452
453 if !sandbox.get_volumes().is_empty() {
455 let volumes = sandbox
456 .get_volumes()
457 .iter()
458 .map(|v| format!("{}:{}", v.get_host(), v.get_guest()))
459 .collect::<Vec<_>>()
460 .join(", ");
461 println!(" {}: {}", style("Volumes").dim(), volumes);
462 }
463
464 if !sandbox.get_scripts().is_empty() {
466 let scripts = sandbox
467 .get_scripts()
468 .keys()
469 .map(|s| s.as_str())
470 .collect::<Vec<_>>()
471 .join(", ");
472 println!(" {}: {}", style("Scripts").dim(), scripts);
473 }
474
475 if !sandbox.get_depends_on().is_empty() {
477 println!(
478 " {}: {}",
479 style("Depends On").dim(),
480 sandbox.get_depends_on().join(", ")
481 );
482 }
483 }
484
485 println!("\n{}: {}", style("Total").dim(), sandboxes.len());
486}
487
488#[cfg(feature = "cli")]
508pub async fn show_list_namespaces(
509 namespaces_parent_dir: &std::path::Path,
510) -> MicrosandboxResult<()> {
511 use crate::management::config;
512 use console::style;
513 use microsandbox_utils::term;
514 use std::path::PathBuf;
515
516 if !namespaces_parent_dir.exists() {
518 return Err(MicrosandboxError::PathNotFound(format!(
519 "Namespaces directory not found at {}",
520 namespaces_parent_dir.display()
521 )));
522 }
523
524 let mut entries = tokio::fs::read_dir(namespaces_parent_dir).await?;
526 let mut namespace_dirs = Vec::new();
527
528 while let Some(entry) = entries.next_entry().await? {
529 let path = entry.path();
530 if path.is_dir() {
531 namespace_dirs.push(path);
532 }
533 }
534
535 if namespace_dirs.is_empty() {
537 println!("No namespaces found");
538 return Ok(());
539 }
540
541 namespace_dirs.sort_by(|a, b| {
543 let a_name = a.file_name().and_then(|n| n.to_str()).unwrap_or("");
544 let b_name = b.file_name().and_then(|n| n.to_str()).unwrap_or("");
545 a_name.cmp(b_name)
546 });
547
548 let loading_sp = term::create_spinner(
550 format!("Loading {} namespaces", namespace_dirs.len()),
551 None,
552 None,
553 );
554
555 struct NamespaceData {
557 name: String,
558 config: Option<(crate::config::Microsandbox, PathBuf, String)>,
559 error: Option<String>,
560 }
561
562 let mut namespace_data = Vec::with_capacity(namespace_dirs.len());
563
564 for namespace_dir in &namespace_dirs {
566 let namespace = namespace_dir
567 .file_name()
568 .and_then(|n| n.to_str())
569 .unwrap_or("unknown")
570 .to_string();
571
572 let config_result = config::load_config(Some(namespace_dir.as_path()), None).await;
573 match config_result {
574 Ok(config) => {
575 namespace_data.push(NamespaceData {
576 name: namespace,
577 config: Some(config),
578 error: None,
579 });
580 }
581 Err(err) => {
582 tracing::warn!("Error loading config from namespace {}: {}", namespace, err);
583 namespace_data.push(NamespaceData {
584 name: namespace,
585 config: None,
586 error: Some(format!("{}", err)),
587 });
588 }
589 }
590 }
591
592 loading_sp.finish_and_clear();
593
594 let namespace_count = namespace_dirs.len();
596 let mut total_sandboxes = 0;
597
598 for (i, data) in namespace_data.iter().enumerate() {
600 if i > 0 {
602 println!();
603 }
604
605 if let Some((config, _, _)) = &data.config {
606 let sandbox_count = config.get_sandboxes().len();
608 total_sandboxes += sandbox_count;
609
610 if sandbox_count > 0 {
612 print_namespace_header(&data.name);
613 show_list(config.get_sandboxes());
614 }
615 } else if let Some(err) = &data.error {
616 print_namespace_header(&data.name);
617 println!(" {}: {}", style("Error").red().bold(), err);
618 }
619 }
620
621 println!(
623 "\n{}: {}, {}: {}",
624 style("Total Namespaces").dim(),
625 namespace_count,
626 style("Total Sandboxes").dim(),
627 total_sandboxes
628 );
629
630 Ok(())
631}
632
633#[cfg(feature = "cli")]
635pub fn print_namespace_header(namespace: &str) {
636 use console::style;
637
638 let title = format!("NAMESPACE: {}", namespace);
640
641 println!("\n{}", style(title).white().bold());
643
644 println!("{}", style("─".repeat(80)).dim());
646}
647
648pub(crate) async fn ensure_menv_files(menv_path: &PathBuf) -> MicrosandboxResult<()> {
654 fs::create_dir_all(menv_path.join(LOG_SUBDIR)).await?;
656
657 fs::create_dir_all(menv_path.join(RW_SUBDIR)).await?;
659
660 let db_path = menv_path.join(SANDBOX_DB_FILENAME);
662
663 let _ = db::initialize(&db_path, &db::SANDBOX_DB_MIGRATOR).await?;
665 tracing::info!("sandbox database at {}", db_path.display());
666
667 Ok(())
668}
669
670pub(crate) async fn create_default_config(project_dir: &Path) -> MicrosandboxResult<()> {
672 let config_path = project_dir.join(MICROSANDBOX_CONFIG_FILENAME);
673
674 if !config_path.exists() {
676 #[cfg(feature = "cli")]
677 let create_default_config_sp =
678 term::create_spinner(CREATE_DEFAULT_CONFIG_MSG.to_string(), None, None);
679
680 let mut file = fs::File::create(&config_path).await?;
681 file.write_all(DEFAULT_CONFIG.as_bytes()).await?;
682
683 #[cfg(feature = "cli")]
684 create_default_config_sp.finish();
685 }
686
687 Ok(())
688}
689
690pub(crate) async fn update_gitignore(project_dir: &Path) -> MicrosandboxResult<()> {
692 let gitignore_path = project_dir.join(".gitignore");
693 let canonical_entry = format!("{}/", MICROSANDBOX_ENV_DIR);
694 let acceptable_entries = [MICROSANDBOX_ENV_DIR, &canonical_entry[..]];
695
696 if gitignore_path.exists() {
697 let content = fs::read_to_string(&gitignore_path).await?;
698 let already_present = content.lines().any(|line| {
699 let trimmed = line.trim();
700 acceptable_entries.contains(&trimmed)
701 });
702
703 if !already_present {
704 let prefix = if content.ends_with('\n') { "" } else { "\n" };
706 let mut file = fs::OpenOptions::new()
707 .append(true)
708 .open(&gitignore_path)
709 .await?;
710 file.write_all(format!("{}{}\n", prefix, canonical_entry).as_bytes())
711 .await?;
712 }
713 } else {
714 fs::write(&gitignore_path, format!("{}\n", canonical_entry)).await?;
716 }
717
718 Ok(())
719}