Skip to main content

microsandbox_core/management/
menv.rs

1//! Microsandbox environment management.
2//!
3//! This module handles the initialization and management of Microsandbox environments.
4//! A Microsandbox environment (menv) is a directory structure that contains all the
5//! necessary components for running sandboxes, including configuration files,
6//! databases, and log directories.
7
8use 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//--------------------------------------------------------------------------------------------------
22// Constants
23//--------------------------------------------------------------------------------------------------
24
25#[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
34//--------------------------------------------------------------------------------------------------
35// Functions
36//--------------------------------------------------------------------------------------------------
37
38/// Initialize a new microsandbox environment at the specified path
39///
40/// ## Arguments
41/// * `project_dir` - Optional path where the microsandbox environment will be initialized. If None, uses current directory
42///
43/// ## Example
44/// ```no_run
45/// use microsandbox_core::management::menv;
46///
47/// # async fn example() -> anyhow::Result<()> {
48/// // Initialize in current directory
49/// menv::initialize(None).await?;
50///
51/// // Initialize in specific directory
52/// menv::initialize(Some("my_project".into())).await?;
53/// # Ok(())
54/// # }
55/// ```
56pub async fn initialize(project_dir: Option<PathBuf>) -> MicrosandboxResult<()> {
57    // Get the target path, defaulting to current directory if none specified
58    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    // Create the required files for the microsandbox environment
74    ensure_menv_files(&menv_path).await?;
75
76    // Create default config file if it doesn't exist
77    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 to include .menv directory
84    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
94/// Clean up the microsandbox environment for a project or a specific sandbox
95///
96/// This function can either:
97/// 1. Remove the entire .menv directory and all its contents (when sandbox_name is None)
98/// 2. Remove just a specific sandbox's data (when sandbox_name is provided)
99///
100/// ## Arguments
101/// * `project_dir` - Optional path where the microsandbox environment should be cleaned.
102///                   If None, uses current directory
103/// * `config_file` - Optional path to the Microsandbox config file. If None, uses default filename
104/// * `sandbox_name` - Optional name of the sandbox to clean. If None, cleans entire project
105/// * `force` - Whether to force cleaning even if the sandbox exists in config or config file exists
106///
107/// ## Example
108/// ```no_run
109/// use microsandbox_core::management::menv;
110///
111/// # async fn example() -> anyhow::Result<()> {
112/// // Clean entire project in current directory
113/// menv::clean(None, None, None, false).await?;
114///
115/// // Clean specific sandbox in current directory
116/// menv::clean(None, None, Some("dev"), false).await?;
117///
118/// // Clean specific sandbox with custom config file, forcing cleanup
119/// menv::clean(None, Some("custom.yaml"), Some("dev"), true).await?;
120/// # Ok(())
121/// # }
122/// ```
123pub async fn clean(
124    project_dir: Option<PathBuf>,
125    config_file: Option<&str>,
126    sandbox_name: Option<&str>,
127    force: bool,
128) -> MicrosandboxResult<()> {
129    // Get the target path, defaulting to current directory if none specified
130    let project_dir = project_dir.unwrap_or_else(|| PathBuf::from("."));
131    let menv_path = project_dir.join(MICROSANDBOX_ENV_DIR);
132
133    // Try to load the configuration if the file exists
134    let config_result =
135        crate::management::config::load_config(Some(&project_dir), config_file).await;
136
137    // If no sandbox name is provided, clean the entire project
138    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 the config file exists and force is false, don't clean
143        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        // Check if .menv directory exists
160        if menv_path.exists() {
161            // Remove the .menv directory and all its contents
162            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    // At this point we know we're cleaning a specific sandbox
181    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 the sandbox exists in the config and force is false, don't clean
192    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    // Get sandbox namespace
213    let namespaced_name = PathBuf::from(config_file).join(sandbox_name);
214
215    // Clean up sandbox-specific directories
216    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    // Remove sandbox directories if they exist
220    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    // Remove log file if it exists
234    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    // Remove sandbox from database
245    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
258/// Show logs for a sandbox
259///
260/// This function can show logs for a sandbox in either follow mode or regular mode.
261/// In follow mode, it uses `tail -f` to continuously show new log entries.
262/// In regular mode, it shows either all logs or the last N lines.
263///
264/// ## Arguments
265/// * `project_dir` - Optional path where the microsandbox environment is located.
266///                   If None, uses current directory
267/// * `config_file` - Optional path to the Microsandbox config file. If None, uses default filename
268/// * `sandbox_name` - Name of the sandbox to show logs for
269/// * `follow` - Whether to follow the log file (tail -f mode)
270/// * `tail` - Optional number of lines to show from the end
271///
272/// ## Example
273/// ```no_run
274/// use microsandbox_core::management::menv;
275///
276/// # async fn example() -> anyhow::Result<()> {
277/// // Show all logs for a sandbox
278/// menv::show_log(None, None, "my-sandbox", false, None).await?;
279///
280/// // Show last 100 lines of logs
281/// menv::show_log(None, None, "my-sandbox", false, Some(100)).await?;
282///
283/// // Follow logs in real-time
284/// menv::show_log(None, None, "my-sandbox", true, None).await?;
285/// # Ok(())
286/// # }
287/// ```
288pub 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    // Check if tail command exists when follow mode is requested
296    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    // Load the configuration to get canonical paths
307    let (_, canonical_project_dir, config_file) =
308        config::load_config(project_dir.as_ref().map(|p| p.as_ref()), config_file).await?;
309
310    // Construct log file path using the hierarchical structure: <project_dir>/.menv/log/<config>/<sandbox>.log
311    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    // Check if log file exists
318    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        // For follow mode, use tokio::process::Command to run `tail -f`
327        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        // Wait for the tail process
335        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        // Read the file contents
344        let contents = tokio::fs::read_to_string(&log_path).await?;
345
346        // Split into lines
347        let lines: Vec<&str> = contents.lines().collect();
348
349        // If tail is specified, only show the last N lines
350        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        // Print the lines
361        for line in lines_to_print {
362            println!("{}", line);
363        }
364    }
365
366    Ok(())
367}
368
369/// Show a formatted list of sandboxes
370///
371/// This function can display sandbox information from any config in a standardized format.
372///
373/// ## Arguments
374/// * `sandboxes` - A reference to a HashMap of sandbox configurations
375///
376/// ## Example
377/// ```no_run
378/// use microsandbox_core::management::menv;
379/// use microsandbox_core::management::config;
380///
381/// # async fn example() -> anyhow::Result<()> {
382/// // Show all sandboxes for a local project
383/// let (config, _, _) = config::load_config(None, None).await?;
384/// menv::show_list(config.get_sandboxes());
385///
386/// // Show all sandboxes for a remote namespace
387/// let (config, _, _) = config::load_config(Some(namespace_path), None).await?;
388/// menv::show_list(config.get_sandboxes());
389/// # Ok(())
390/// # }
391/// ```
392#[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    // Convert the iterator into a HashMap for easier processing
401    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        // Number and name
414        println!("{}. {}", style(i + 1).bold(), style(*name).bold());
415
416        // Image
417        println!(
418            "   {}: {}",
419            style("Image").dim(),
420            sandbox.get_image().to_string()
421        );
422
423        // Resources
424        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        // Network
436        println!(
437            "   {}: {}",
438            style("Network").dim(),
439            format!("{:?}", sandbox.get_scope())
440        );
441
442        // Ports
443        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        // Volumes
454        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        // Scripts
465        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        // Dependencies
476        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/// Show a formatted list of sandboxes across multiple namespaces
489///
490/// This function displays sandbox information from all namespaces in a consolidated view.
491/// It's useful for server mode when you want to see all sandboxes across all namespaces.
492///
493/// ## Arguments
494/// * `namespaces_parent_dir` - The parent directory containing namespace directories
495///
496/// ## Example
497/// ```no_run
498/// use std::path::Path;
499/// use microsandbox_core::management::menv;
500///
501/// # async fn example() -> anyhow::Result<()> {
502/// // Show all sandboxes across all namespaces
503/// menv::show_list_namespaces(Path::new("/path/to/namespaces")).await?;
504/// # Ok(())
505/// # }
506/// ```
507#[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    // First check if namespaces directory exists
517    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    // List all namespace directories
525    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    // Show a message if no namespaces found
536    if namespace_dirs.is_empty() {
537        println!("No namespaces found");
538        return Ok(());
539    }
540
541    // Sort namespace dirs alphabetically
542    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    // Create a loading spinner
549    let loading_sp = term::create_spinner(
550        format!("Loading {} namespaces", namespace_dirs.len()),
551        None,
552        None,
553    );
554
555    // Pre-load all namespace configs to avoid lags between displaying each one
556    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    // Collect all namespace data first
565    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    // Count totals
595    let namespace_count = namespace_dirs.len();
596    let mut total_sandboxes = 0;
597
598    // Display all namespace data without delays
599    for (i, data) in namespace_data.iter().enumerate() {
600        // Add a newline between namespaces
601        if i > 0 {
602            println!();
603        }
604
605        if let Some((config, _, _)) = &data.config {
606            // Count the sandboxes in this namespace
607            let sandbox_count = config.get_sandboxes().len();
608            total_sandboxes += sandbox_count;
609
610            // Only print if there are sandboxes
611            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    // Show summary with the captured counts
622    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/// Prints a stylized header for namespace display
634#[cfg(feature = "cli")]
635pub fn print_namespace_header(namespace: &str) {
636    use console::style;
637
638    // Create the simple title text without padding
639    let title = format!("NAMESPACE: {}", namespace);
640
641    // Print the title with white color and underline styling
642    println!("\n{}", style(title).white().bold());
643
644    // Print a separator line
645    println!("{}", style("─".repeat(80)).dim());
646}
647
648//--------------------------------------------------------------------------------------------------
649// Functions: Helpers
650//--------------------------------------------------------------------------------------------------
651
652/// Create the required directories and files for a microsandbox environment
653pub(crate) async fn ensure_menv_files(menv_path: &PathBuf) -> MicrosandboxResult<()> {
654    // Create log directory if it doesn't exist
655    fs::create_dir_all(menv_path.join(LOG_SUBDIR)).await?;
656
657    // We'll create rootfs directory later when monofs is ready
658    fs::create_dir_all(menv_path.join(RW_SUBDIR)).await?;
659
660    // Get the sandbox database path
661    let db_path = menv_path.join(SANDBOX_DB_FILENAME);
662
663    // Initialize sandbox database
664    let _ = db::initialize(&db_path, &db::SANDBOX_DB_MIGRATOR).await?;
665    tracing::info!("sandbox database at {}", db_path.display());
666
667    Ok(())
668}
669
670/// Create a default microsandbox configuration file
671pub(crate) async fn create_default_config(project_dir: &Path) -> MicrosandboxResult<()> {
672    let config_path = project_dir.join(MICROSANDBOX_CONFIG_FILENAME);
673
674    // Only create if it doesn't exist
675    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
690/// Updates or creates a .gitignore file to include the .menv directory
691pub(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            // Ensure we start on a new line
705            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        // Create new .gitignore with canonical entry (.menv/)
715        fs::write(&gitignore_path, format!("{}\n", canonical_entry)).await?;
716    }
717
718    Ok(())
719}