microsandbox_core/management/
config.rs

1//! Configuration management for the Microsandbox runtime.
2//!
3//! This module provides structures and utilities for modifying Microsandbox
4//! configuration.
5
6use microsandbox_utils::{DEFAULT_SHELL, MICROSANDBOX_CONFIG_FILENAME};
7use nondestructive::yaml;
8use sqlx::{Pool, Sqlite};
9use std::{
10    collections::HashMap,
11    path::{Path, PathBuf},
12};
13use tokio::fs;
14use typed_path::Utf8UnixPathBuf;
15
16use crate::{
17    config::{EnvPair, Microsandbox, PathSegment, PortPair, Sandbox},
18    oci::Reference,
19    MicrosandboxError, MicrosandboxResult,
20};
21
22use super::db;
23
24//--------------------------------------------------------------------------------------------------
25// Types
26//--------------------------------------------------------------------------------------------------
27
28#[derive(Debug, Clone)]
29/// The component to add to the Microsandbox configuration.
30pub enum Component {
31    /// A sandbox component.
32    Sandbox {
33        /// The image to use for the sandbox.
34        image: String,
35
36        /// The amount of memory in MiB to use.
37        memory: Option<u32>,
38
39        /// The number of CPUs to use.
40        cpus: Option<u32>,
41
42        /// The volumes to mount.
43        volumes: Vec<String>,
44
45        /// The ports to expose.
46        ports: Vec<String>,
47
48        /// The environment variables to use.
49        envs: Vec<String>,
50
51        /// The environment file to use.
52        env_file: Option<Utf8UnixPathBuf>,
53
54        /// The dependencies to use for the sandbox.
55        depends_on: Vec<String>,
56
57        /// The working directory to use for the sandbox.
58        workdir: Option<Utf8UnixPathBuf>,
59
60        /// The shell to use for the sandbox.
61        shell: Option<String>,
62
63        /// The scripts to use for the sandbox.
64        scripts: HashMap<String, String>,
65
66        /// The imports to use for the sandbox.
67        imports: HashMap<String, Utf8UnixPathBuf>,
68
69        /// The exports to use for the sandbox.
70        exports: HashMap<String, Utf8UnixPathBuf>,
71
72        /// The network scope to use for the sandbox.
73        scope: Option<String>,
74    },
75    /// A build component.
76    Build {},
77    /// A group component.
78    Group {},
79}
80
81/// The type of component to add to the Microsandbox configuration.
82#[derive(Debug, Clone)]
83pub enum ComponentType {
84    /// A sandbox component.
85    Sandbox,
86    /// A build component.
87    Build,
88    /// A group component.
89    Group,
90}
91
92//--------------------------------------------------------------------------------------------------
93// Functions
94//--------------------------------------------------------------------------------------------------
95
96/// Adds one or more components to the Microsandbox configuration.
97///
98/// Modifies the Microsandbox configuration file by adding new components while preserving
99/// the existing formatting and structure.
100///
101/// ## Arguments
102///
103/// * `names` - Names for the components to add
104/// * `component` - The component specification to add
105/// * `project_dir` - Optional project directory path (defaults to current directory)
106/// * `config_file` - Optional config file path (defaults to standard filename)
107///
108/// ## Returns
109///
110/// * `Ok(())` on success, or error if the file cannot be found/read/written,
111///   contains invalid YAML, or a component with the same name already exists
112pub async fn add(
113    names: &[String],
114    component: &Component,
115    project_dir: Option<&Path>,
116    config_file: Option<&str>,
117) -> MicrosandboxResult<()> {
118    let (_, _, full_config_path) = resolve_config_paths(project_dir, config_file).await?;
119
120    // Read the configuration file content
121    let config_contents = fs::read_to_string(&full_config_path).await?;
122
123    // Parse the YAML document using nondestructive
124    let mut doc = yaml::from_slice(config_contents.as_bytes())
125        .map_err(|e| MicrosandboxError::ConfigParseError(e.to_string()))?;
126
127    for name in names {
128        match component {
129            Component::Sandbox {
130                image,
131                memory,
132                cpus,
133                volumes,
134                ports,
135                envs,
136                env_file,
137                depends_on,
138                workdir,
139                shell,
140                scripts,
141                imports,
142                exports,
143                scope,
144            } => {
145                let doc_mut = doc.as_mut();
146                let mut root_mapping = doc_mut.make_mapping();
147
148                // Ensure the "sandboxes" key exists in the root mapping
149                let mut sandboxes_mapping =
150                    if let Some(sandboxes_mut) = root_mapping.get_mut("sandboxes") {
151                        // Get the existing sandboxes mapping
152                        sandboxes_mut.make_mapping()
153                    } else {
154                        // Create a new sandboxes mapping if it doesn't exist
155                        root_mapping
156                            .insert("sandboxes", yaml::Separator::Auto)
157                            .make_mapping()
158                    };
159
160                // Check if the sandbox already exists by trying to get it
161                if sandboxes_mapping.get_mut(name).is_some() {
162                    return Err(MicrosandboxError::ConfigValidation(format!(
163                        "Sandbox with name '{}' already exists",
164                        name
165                    )));
166                }
167
168                // Create a new sandbox mapping
169                let mut sandbox_mapping = sandboxes_mapping
170                    .insert(name, yaml::Separator::Auto)
171                    .make_mapping();
172
173                // Add image field (required)
174                sandbox_mapping.insert_str("image", image.to_string());
175
176                // Add optional fields
177                if let Some(memory_value) = memory {
178                    sandbox_mapping.insert_u32("memory", *memory_value);
179                }
180
181                if let Some(cpus_value) = cpus {
182                    sandbox_mapping.insert_u32("cpus", *cpus_value as u32);
183                }
184
185                // Add shell (default if not provided)
186                if let Some(shell_value) = shell {
187                    sandbox_mapping.insert_str("shell", shell_value);
188                } else if sandbox_mapping.get_mut("shell").is_none() {
189                    sandbox_mapping.insert_str("shell", DEFAULT_SHELL);
190                }
191
192                // Add volumes if any
193                if !volumes.is_empty() {
194                    let mut volumes_sequence = sandbox_mapping
195                        .insert("volumes", yaml::Separator::Auto)
196                        .make_sequence();
197
198                    for volume in volumes {
199                        volumes_sequence.push_string(volume);
200                    }
201                }
202
203                // Add ports if any
204                if !ports.is_empty() {
205                    let mut ports_sequence = sandbox_mapping
206                        .insert("ports", yaml::Separator::Auto)
207                        .make_sequence();
208
209                    for port in ports {
210                        ports_sequence.push_string(port);
211                    }
212                }
213
214                // Add env vars if any
215                if !envs.is_empty() {
216                    let mut envs_sequence = sandbox_mapping
217                        .insert("envs", yaml::Separator::Auto)
218                        .make_sequence();
219
220                    for env in envs {
221                        envs_sequence.push_string(env);
222                    }
223                }
224
225                // Add env_file if provided
226                if let Some(env_file_path) = env_file {
227                    sandbox_mapping.insert_str("env_file", env_file_path.to_string());
228                }
229
230                // Add depends_on if any
231                if !depends_on.is_empty() {
232                    let mut depends_on_sequence = sandbox_mapping
233                        .insert("depends_on", yaml::Separator::Auto)
234                        .make_sequence();
235
236                    for dep in depends_on {
237                        depends_on_sequence.push_string(dep);
238                    }
239                }
240
241                // Add workdir if provided
242                if let Some(workdir_path) = workdir {
243                    sandbox_mapping.insert_str("workdir", workdir_path.to_string());
244                }
245
246                // Add scripts if any
247                if !scripts.is_empty() {
248                    let mut scripts_mapping = sandbox_mapping
249                        .insert("scripts", yaml::Separator::Auto)
250                        .make_mapping();
251
252                    for (script_name, script_content) in scripts {
253                        scripts_mapping.insert_str(script_name, script_content);
254                    }
255                }
256
257                // Add imports if any
258                if !imports.is_empty() {
259                    let mut imports_mapping = sandbox_mapping
260                        .insert("imports", yaml::Separator::Auto)
261                        .make_mapping();
262
263                    for (import_name, import_path) in imports {
264                        imports_mapping.insert_str(import_name, import_path.to_string());
265                    }
266                }
267
268                // Add exports if any
269                if !exports.is_empty() {
270                    let mut exports_mapping = sandbox_mapping
271                        .insert("exports", yaml::Separator::Auto)
272                        .make_mapping();
273
274                    for (export_name, export_path) in exports {
275                        exports_mapping.insert_str(export_name, export_path.to_string());
276                    }
277                }
278
279                // Add network scope if provided
280                if let Some(scope_value) = scope {
281                    let mut network_mapping = sandbox_mapping
282                        .insert("network", yaml::Separator::Auto)
283                        .make_mapping();
284
285                    network_mapping.insert_str("scope", scope_value);
286                }
287            }
288            Component::Build {} => {}
289            Component::Group {} => {}
290        }
291    }
292
293    // Write the modified YAML back to the file, preserving formatting
294    let modified_content = doc.to_string();
295
296    // TODO: Validate config before writing
297    fs::write(full_config_path, modified_content).await?;
298
299    Ok(())
300}
301
302/// Removes a component from the Microsandbox configuration.
303///
304/// Modifies the Microsandbox configuration file by removing an existing component
305/// while preserving the existing formatting and structure.
306///
307/// ## Arguments
308///
309/// * `component` - The component to remove from the configuration
310///
311/// ## Returns
312///
313/// * `Ok(())` on success, or error if the file cannot be found/read/written,
314///   contains invalid YAML, or the component does not exist
315///
316/// Note: This function is currently a placeholder and needs to be implemented.
317pub async fn remove(
318    component_type: ComponentType,
319    names: &[String],
320    project_dir: Option<&Path>,
321    config_file: Option<&str>,
322) -> MicrosandboxResult<()> {
323    let (_, _, full_config_path) = resolve_config_paths(project_dir, config_file).await?;
324
325    // Read the configuration file content
326    let config_contents = fs::read_to_string(&full_config_path).await?;
327
328    let mut doc = yaml::from_slice(config_contents.as_bytes())
329        .map_err(|e| MicrosandboxError::ConfigParseError(e.to_string()))?;
330
331    match component_type {
332        ComponentType::Sandbox => {
333            let doc_mut = doc.as_mut();
334            let mut root_mapping =
335                doc_mut
336                    .into_mapping_mut()
337                    .ok_or(MicrosandboxError::ConfigParseError(
338                        "config is not valid. expected an object".to_string(),
339                    ))?;
340
341            // Ensure the "sandboxes" key exists in the root mapping
342            let mut sandboxes_mapping =
343                if let Some(sandboxes_mut) = root_mapping.get_mut("sandboxes") {
344                    // Get the existing sandboxes mapping
345                    sandboxes_mut
346                        .into_mapping_mut()
347                        .ok_or(MicrosandboxError::ConfigParseError(
348                            "sandboxes is not a valid mapping".to_string(),
349                        ))?
350                } else {
351                    // Create a new sandboxes mapping if it doesn't exist
352                    root_mapping
353                        .insert("sandboxes", yaml::Separator::Auto)
354                        .make_mapping()
355                };
356
357            for name in names {
358                sandboxes_mapping.remove(name);
359            }
360        }
361        _ => (),
362    }
363
364    // Write the modified YAML back to the file, preserving formatting
365    let modified_content = doc.to_string();
366
367    // TODO: Validate config before writing
368    fs::write(full_config_path, modified_content).await?;
369
370    Ok(())
371}
372
373/// Lists components in the Microsandbox configuration.
374///
375/// Retrieves and displays information about components defined in the Microsandbox configuration.
376///
377/// ## Arguments
378///
379/// * `component_type` - The type of component to list
380/// * `project_dir` - Optional path to the project directory. If None, defaults to current directory
381/// * `config_file` - Optional path to the Microsandbox config file. If None, uses default filename
382///
383/// ## Returns
384///
385/// * `Ok(())` on success, or error if the file cannot be found/read/written,
386///   contains invalid YAML, or the component does not exist
387pub async fn list(
388    component_type: ComponentType,
389    project_dir: Option<&Path>,
390    config_file: Option<&str>,
391) -> MicrosandboxResult<Vec<String>> {
392    let (config, _, _) = load_config(project_dir, config_file).await?;
393
394    match component_type {
395        ComponentType::Sandbox => {
396            return Ok(config.get_sandboxes().keys().cloned().collect());
397        }
398        _ => return Ok(vec![]),
399    }
400}
401
402//--------------------------------------------------------------------------------------------------
403// Functions: Helpers
404//--------------------------------------------------------------------------------------------------
405
406/// Loads a Microsandbox configuration from a file.
407///
408/// This function handles all the common steps for loading a Microsandbox configuration, including:
409/// - Resolving the project directory and config file path
410/// - Validating the config file path
411/// - Checking if the config file exists
412/// - Reading and parsing the config file
413///
414/// ## Arguments
415///
416/// * `project_dir` - Optional path to the project directory. If None, defaults to current directory
417/// * `config_file` - Optional path to the Microsandbox config file. If None, uses default filename
418///
419/// ## Returns
420///
421/// Returns a tuple containing:
422/// - The loaded Microsandbox configuration
423/// - The canonical project directory path
424/// - The config file name
425///
426/// Or a `MicrosandboxError` if:
427/// - The config file path is invalid
428/// - The config file does not exist
429/// - The config file cannot be read
430/// - The config file contains invalid YAML
431pub async fn load_config(
432    project_dir: Option<&Path>,
433    config_file: Option<&str>,
434) -> MicrosandboxResult<(Microsandbox, PathBuf, String)> {
435    // Get the target path, defaulting to current directory if none specified
436    let project_dir = project_dir.unwrap_or_else(|| Path::new("."));
437    let canonical_project_dir = fs::canonicalize(project_dir).await?;
438
439    // Validate the config file path
440    let config_file = config_file.unwrap_or_else(|| MICROSANDBOX_CONFIG_FILENAME);
441    let _ = PathSegment::try_from(config_file)?;
442    let full_config_path = canonical_project_dir.join(config_file);
443
444    // Check if config file exists
445    if !full_config_path.exists() {
446        return Err(MicrosandboxError::MicrosandboxConfigNotFound(
447            project_dir.display().to_string(),
448        ));
449    }
450
451    // Read and parse the config file
452    let config_contents = fs::read_to_string(&full_config_path).await?;
453    let config: Microsandbox = serde_yaml::from_str(&config_contents)?;
454
455    Ok((config, canonical_project_dir, config_file.to_string()))
456}
457
458/// Resolves the paths for a Microsandbox configuration.
459///
460/// This function is similar to `load_config` but without actually loading the file.
461/// It just resolves the paths that would be used.
462///
463/// ## Arguments
464///
465/// * `project_dir` - Optional path to the project directory. If None, defaults to current directory
466/// * `config_file` - Optional path to the Microsandbox config file. If None, uses default filename
467///
468/// ## Returns
469///
470/// Returns a tuple containing:
471/// - The canonical project directory path
472/// - The config file name
473/// - The full config file path
474pub async fn resolve_config_paths(
475    project_dir: Option<&Path>,
476    config_file: Option<&str>,
477) -> MicrosandboxResult<(PathBuf, String, PathBuf)> {
478    // Get the target path, defaulting to current directory if none specified
479    let project_dir = project_dir.unwrap_or_else(|| Path::new("."));
480    let canonical_project_dir = fs::canonicalize(project_dir).await?;
481
482    // Validate the config file path
483    let config_file = config_file.unwrap_or_else(|| MICROSANDBOX_CONFIG_FILENAME);
484    let _ = PathSegment::try_from(config_file)?;
485    let full_config_path = canonical_project_dir.join(config_file);
486
487    // Check if config file exists
488    if !full_config_path.exists() {
489        return Err(MicrosandboxError::MicrosandboxConfigNotFound(
490            project_dir.display().to_string(),
491        ));
492    }
493
494    Ok((
495        canonical_project_dir,
496        config_file.to_string(),
497        full_config_path,
498    ))
499}
500
501/// Applies defaults from an OCI image configuration to a sandbox configuration.
502///
503/// This function enhances the sandbox configuration with defaults from the OCI image
504/// configuration when they are not explicitly defined in the sandbox config.
505///
506/// The following defaults are applied:
507/// - Script: Uses the entrypoint and cmd from the image if a script is missing
508/// - Environment variables: Combines image env variables with sandbox env variables
509/// - Working directory: Uses the image's working directory if not specified
510/// - Exposed ports: Combines image exposed ports with sandbox ports
511///
512/// ## Arguments
513///
514/// * `sandbox_config` - Mutable reference to the sandbox configuration to enhance
515/// * `reference` - OCI image reference to get defaults from
516/// * `script_name` - The name of the script we're trying to run
517///
518/// ## Returns
519///
520/// Returns `Ok(())` if defaults were successfully applied, or a `MicrosandboxError` if:
521/// - The image configuration could not be retrieved
522/// - Any conversion or parsing operations fail
523pub async fn apply_image_defaults(
524    sandbox_config: &mut Sandbox,
525    reference: &Reference,
526    oci_db: &Pool<Sqlite>,
527) -> MicrosandboxResult<()> {
528    // Get the image configuration
529    if let Some(config) = db::get_image_config(&oci_db, &reference.to_string()).await? {
530        tracing::info!("applying defaults from image configuration");
531
532        // Apply working directory if not set in sandbox
533        if sandbox_config.get_workdir().is_none() && config.config_working_dir.is_some() {
534            let workdir = config.config_working_dir.unwrap();
535            tracing::debug!("using image working directory: {}", workdir);
536            let workdir_path = Utf8UnixPathBuf::from(workdir);
537            sandbox_config.workdir = Some(workdir_path);
538        }
539
540        // Combine environment variables
541        if let Some(config_env_json) = config.config_env_json {
542            if let Ok(image_env_vars) = serde_json::from_str::<Vec<String>>(&config_env_json) {
543                let mut image_env_pairs = Vec::new();
544                for env_var in image_env_vars {
545                    if let Ok(env_pair) = env_var.parse::<EnvPair>() {
546                        image_env_pairs.push(env_pair);
547                    }
548                }
549                tracing::debug!("image env vars: {:#?}", image_env_pairs);
550
551                // Combine image env vars with sandbox env vars (image vars come first)
552                let mut combined_env = image_env_pairs;
553                combined_env.extend_from_slice(sandbox_config.get_envs());
554                sandbox_config.envs = combined_env;
555            }
556        }
557
558        // Apply entrypoint and cmd as command if no command is defined
559        if sandbox_config.get_command().is_empty() {
560            let mut command_vec: Vec<String> = Vec::new();
561            let mut has_entrypoint_or_cmd = false;
562
563            // Try to use entrypoint and cmd from image config
564            if let Some(entrypoint_json) = &config.config_entrypoint_json {
565                if let Ok(entrypoint) = serde_json::from_str::<Vec<String>>(entrypoint_json) {
566                    if !entrypoint.is_empty() {
567                        has_entrypoint_or_cmd = true;
568                        command_vec = entrypoint;
569
570                        // Add CMD args if they exist
571                        if let Some(cmd_json) = &config.config_cmd_json {
572                            if let Ok(cmd) = serde_json::from_str::<Vec<String>>(cmd_json) {
573                                if !cmd.is_empty() {
574                                    command_vec.extend(cmd);
575                                }
576                            }
577                        }
578
579                        tracing::debug!("entrypoint exec content: {:?}", command_vec);
580                    }
581                }
582            } else if let Some(cmd_json) = &config.config_cmd_json {
583                if let Ok(cmd) = serde_json::from_str::<Vec<String>>(cmd_json) {
584                    if !cmd.is_empty() {
585                        has_entrypoint_or_cmd = true;
586                        command_vec = cmd;
587                        tracing::debug!("cmd exec content: {:?}", command_vec);
588                    }
589                }
590            }
591
592            // If we found an entrypoint or cmd, set it as the command
593            if has_entrypoint_or_cmd {
594                tracing::debug!("setting command to: {:?}", command_vec);
595                sandbox_config.command = command_vec;
596            } else if let Some(shell_value) = &sandbox_config.shell {
597                // If no entrypoint or cmd, use shell as fallback command
598                tracing::debug!("using shell as fallback command");
599                sandbox_config.command = vec![shell_value.clone()];
600            }
601        }
602
603        // Combine exposed ports
604        if let Some(exposed_ports_json) = &config.config_exposed_ports_json {
605            if let Ok(exposed_ports_map) =
606                serde_json::from_str::<serde_json::Value>(exposed_ports_json)
607            {
608                if let Some(exposed_ports_obj) = exposed_ports_map.as_object() {
609                    let mut additional_ports = Vec::new();
610
611                    for port_key in exposed_ports_obj.keys() {
612                        // Port keys in OCI format are like "80/tcp"
613                        if let Some(container_port) = port_key.split('/').next() {
614                            if let Ok(port_num) = container_port.parse::<u16>() {
615                                // Create a port mapping from host port to container port
616                                // We'll use the same port on both sides
617                                let port_pair =
618                                    format!("{}:{}", port_num, port_num).parse::<PortPair>();
619                                if let Ok(port_pair) = port_pair {
620                                    // Only add if not already defined in sandbox config
621                                    let existing_ports = sandbox_config.get_ports();
622                                    if !existing_ports
623                                        .iter()
624                                        .any(|p| p.get_guest() == port_pair.get_guest())
625                                    {
626                                        additional_ports.push(port_pair);
627                                    }
628                                }
629                            }
630                        }
631                    }
632
633                    tracing::debug!("additional ports: {:?}", additional_ports);
634
635                    // Add new ports to existing ones
636                    let mut combined_ports = sandbox_config.get_ports().to_vec();
637                    combined_ports.extend(additional_ports);
638                    sandbox_config.ports = combined_ports;
639                }
640            }
641        }
642    }
643
644    Ok(())
645}