mod cache;
mod container;
mod credentials;
mod home;
pub(crate) mod image;
mod prompts;
use self::cache::{check_cache_size_warning, finalize_caches, setup_caches};
use self::container::{build_container_config, ContainerBuildParams};
use self::credentials::gather_credentials;
use self::image::resolve_image;
use self::prompts::{is_default_network, prompt_network_selection};
use crate::audit::AuditLog;
use crate::cli::args::RunArgs;
use crate::config::Config;
use crate::error::{MinoError, MinoResult};
use crate::network::{
generate_iptables_wrapper, resolve_network_mode, shell_escape, NetworkMode,
NetworkResolutionInput,
};
use crate::orchestration::{create_runtime, ContainerConfig, ContainerRuntime, Platform};
use crate::session::{Session, SessionManager, SessionStatus};
use crate::ui::{self, TaskSpinner, UiContext};
use console::style;
use std::collections::HashMap;
use std::env;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{debug, warn};
use uuid::Uuid;
#[derive(Default)]
struct CacheSession {
volumes_to_finalize: Vec<String>,
}
struct ImageResolution {
image: String,
layer_env: HashMap<String, String>,
}
pub async fn execute(args: RunArgs, config: &Config) -> MinoResult<()> {
#[cfg(unix)]
let _terminal_guard = crate::terminal::TerminalGuard::save();
let ctx = UiContext::detect();
let mut spinner = TaskSpinner::new(&ctx);
spinner.start("Initializing sandbox...");
let runtime: Arc<dyn ContainerRuntime> = Arc::from(create_runtime(config)?);
debug!("Using runtime: {}", runtime.runtime_name());
spinner.message(&format!("Checking {}...", runtime.runtime_name()));
validate_environment().await?;
let project_dir = resolve_project_dir(&args)?;
debug!("Project directory: {}", project_dir.display());
spinner.message(&format!("Starting {}...", runtime.runtime_name()));
runtime.ensure_ready().await?;
if ctx.is_interactive() {
let stale = crate::version::check_stale_images(&*runtime).await;
let update = crate::version::check_for_update(config).await;
if stale.is_some() || update.is_some() {
spinner.clear();
if let Some(info) = stale {
let confirmed = ui::confirm(
&ctx,
&format!(
"Mino upgraded ({} \u{2192} {}). Clear cached layer images to rebuild with the new version?",
info.old, info.new
),
true,
)
.await
.unwrap_or(false);
if confirmed {
if let Err(e) = crate::version::clear_composed_images(&*runtime).await {
warn!("Failed to clear composed images: {}", e);
}
ui::step_ok(
&ctx,
"Cached layer images cleared. They'll rebuild on this run.",
);
}
}
if let Some(info) = update {
let method = crate::version::detect_install_method();
let hint = crate::version::update_hint(&method);
ui::step_info(
&ctx,
&format!(
"Mino v{} available (current: v{}). {}",
info.latest, info.current, hint
),
);
}
spinner.start("Initializing sandbox...");
}
}
let (resolution, using_layers) =
resolve_image(&args, config, &ctx, &mut spinner, &*runtime, &project_dir).await?;
let network_mode = if is_default_network(&args, config) && ctx.is_interactive() {
spinner.clear();
let mode = prompt_network_selection(&ctx, &project_dir).await?;
spinner.start("Initializing sandbox...");
mode
} else {
resolve_network_mode(&NetworkResolutionInput {
cli_network: args.network.as_deref(),
cli_allow_rules: &args.network_allow,
cli_preset: args.network_preset.as_deref(),
config_network: &config.container.network,
config_network_allow: &config.container.network_allow,
config_preset: config.container.network_preset.as_deref(),
})?
};
debug!("Network mode: {:?}", network_mode);
spinner.message("Setting up caches...");
let (cache_mounts, cache_env, cache_session) =
setup_caches(&*runtime, &args, config, &project_dir).await?;
if !args.no_cache && config.cache.enabled {
check_cache_size_warning(&*runtime, config).await;
}
spinner.message("Setting up home volume...");
let home_mount =
home::setup_home_volume(&*runtime, &args, config, &project_dir, &resolution.image).await?;
spinner.message("Gathering credentials...");
let (credentials, active_providers, cred_failures) = gather_credentials(&args, config).await?;
if !cred_failures.is_empty() {
spinner.stop("Credentials");
for (provider, error) in &cred_failures {
ui::step_warn(&ctx, &format!("{}: {}", provider, error));
}
if args.strict_credentials {
return Err(MinoError::User(format!(
"Credential loading failed for: {}. Remove --strict-credentials to continue anyway.",
cred_failures
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>()
.join(", ")
)));
}
spinner.start("Initializing sandbox...");
}
let session_name = args.name.clone().unwrap_or_else(generate_session_name);
let manager = SessionManager::new().await?;
if config.session.auto_cleanup_hours > 0 {
let cleaned = manager.cleanup(config.session.auto_cleanup_hours).await?;
if cleaned > 0 {
debug!("Cleaned up {} old session(s)", cleaned);
}
}
let audit = AuditLog::new(config);
let mut container_config = build_container_config(&ContainerBuildParams {
args: &args,
config,
project_dir: &project_dir,
resolution: &resolution,
env_vars: credentials,
cache_mounts: &cache_mounts,
cache_env,
network_mode: &network_mode,
home_mount: home_mount.clone(),
})?;
if args.detach || !args.command.is_empty() {
container_config
.env
.insert("MINO_QUIET_BOOTSTRAP".to_string(), "1".to_string());
}
let shell_command = if args.command.is_empty() {
if using_layers {
vec!["/bin/zsh".to_string()]
} else {
vec![config.session.shell.clone()]
}
} else {
args.command.clone()
};
let command = if let NetworkMode::Allow(ref rules) = network_mode {
generate_iptables_wrapper(rules, &shell_command)
} else {
shell_command.clone()
};
let is_shell_mode = args.command.is_empty();
let mut session = Session::new(
session_name.clone(),
project_dir.clone(),
command.clone(),
SessionStatus::Starting,
);
session.home_volume = home_mount
.as_ref()
.map(|m| m.split(':').next().unwrap_or_default().to_string());
manager.create(&session).await?;
audit
.log(
"session.created",
&serde_json::json!({
"name": &session_name,
"project_dir": project_dir.display().to_string(),
"image": &container_config.image,
"command": &command,
"network": format!("{:?}", network_mode),
"home_volume": session.home_volume,
}),
)
.await;
if !active_providers.is_empty() {
audit
.log(
"credentials.injected",
&serde_json::json!({
"session_name": &session_name,
"providers": &active_providers,
}),
)
.await;
}
if !runtime
.image_exists(&container_config.image)
.await
.unwrap_or(false)
{
spinner.message(&format!("Pulling image {}...", container_config.image));
} else {
spinner.message("Starting container...");
}
let mut run_ctx = RunContext {
runtime: &runtime,
container_config: &container_config,
command: &command,
session_name: &session_name,
manager: &manager,
audit: &audit,
spinner: &mut spinner,
config,
is_shell_mode,
shell_command,
network_mode: &network_mode,
};
if args.detach {
run_detached(&mut run_ctx, cache_session).await?;
} else {
run_interactive(&mut run_ctx, cache_session).await?;
}
Ok(())
}
struct RunContext<'a> {
runtime: &'a Arc<dyn ContainerRuntime>,
container_config: &'a ContainerConfig,
command: &'a [String],
session_name: &'a str,
manager: &'a SessionManager,
audit: &'a AuditLog,
spinner: &'a mut TaskSpinner,
config: &'a Config,
is_shell_mode: bool,
shell_command: Vec<String>,
network_mode: &'a NetworkMode,
}
impl RunContext<'_> {
async fn record_failure<T>(&self, error: MinoError) -> MinoResult<T> {
self.manager
.update_status(self.session_name, SessionStatus::Failed)
.await?;
self.audit
.log(
"session.failed",
&serde_json::json!({
"name": self.session_name,
"error": error.to_string(),
}),
)
.await;
Err(error)
}
async fn record_start(&self, container_id: &str) -> MinoResult<()> {
self.manager
.set_container_id(self.session_name, container_id)
.await?;
self.manager
.update_status(self.session_name, SessionStatus::Running)
.await?;
self.audit
.log(
"session.started",
&serde_json::json!({
"name": self.session_name,
"container_id": container_id,
}),
)
.await;
Ok(())
}
}
async fn run_detached(ctx: &mut RunContext<'_>, cache_session: CacheSession) -> MinoResult<()> {
let container_id = match ctx.runtime.run(ctx.container_config, ctx.command).await {
Ok(id) => id,
Err(e) => return ctx.record_failure(e).await,
};
ctx.record_start(&container_id).await?;
ctx.spinner.clear();
println!(
"{} Session {} started (container: {})",
style("✓").green(),
style(ctx.session_name).cyan(),
&container_id[..12]
);
println!(" Attach with: mino logs {}", ctx.session_name);
println!(" Stop with: mino stop {}", ctx.session_name);
if !cache_session.volumes_to_finalize.is_empty() {
let bg_runtime = Arc::clone(ctx.runtime);
let bg_container_id = container_id.clone();
let bg_cache_session = cache_session;
tokio::spawn(async move {
let short_id = &bg_container_id[..12.min(bg_container_id.len())];
debug!("Background monitor started for container {}", short_id);
match bg_runtime.get_container_exit_code(&bg_container_id).await {
Ok(Some(0)) => {
debug!("Container {} exited cleanly, finalizing caches", short_id);
finalize_caches(&bg_cache_session).await;
}
Ok(Some(code)) => {
debug!(
"Container {} exited with code {}, skipping cache finalization",
short_id, code
);
}
Ok(None) => {
warn!(
"Container {} exit code unknown, skipping cache finalization",
short_id
);
}
Err(e) => {
warn!(
"Failed to wait for container {}: {}, skipping cache finalization",
short_id, e
);
}
}
});
}
Ok(())
}
async fn run_interactive(ctx: &mut RunContext<'_>, cache_session: CacheSession) -> MinoResult<()> {
let exit_code = if ctx.is_shell_mode {
run_interactive_shell(ctx).await?
} else {
run_interactive_command(ctx).await?
};
if exit_code == 0 && !cache_session.volumes_to_finalize.is_empty() {
finalize_caches(&cache_session).await;
}
ctx.manager
.update_status(ctx.session_name, SessionStatus::Stopped)
.await?;
ctx.audit
.log(
"session.stopped",
&serde_json::json!({
"name": ctx.session_name,
"exit_code": exit_code,
}),
)
.await;
if exit_code != 0 {
println!(
"{} Session exited with code {}",
style("!").yellow(),
exit_code
);
}
if let Some(update) = crate::version::load_cached_update(ctx.config).await {
let method = crate::version::detect_install_method();
let hint = crate::version::update_hint(&method);
println!(
"\n {} Mino v{} available (current: v{}). {}",
style("ℹ").cyan(),
update.latest,
update.current,
hint
);
}
Ok(())
}
async fn run_interactive_command(ctx: &mut RunContext<'_>) -> MinoResult<i32> {
let container_id = match ctx.runtime.create(ctx.container_config, ctx.command).await {
Ok(id) => id,
Err(e) => return ctx.record_failure(e).await,
};
ctx.record_start(&container_id).await?;
ctx.spinner.clear();
debug!("Starting container attached: {}", &container_id[..12]);
let exit_code = ctx.runtime.start_attached(&container_id).await?;
if let Err(e) = ctx.runtime.remove(&container_id).await {
warn!(
"Failed to remove container {}: {}",
&container_id[..12.min(container_id.len())],
e
);
}
Ok(exit_code)
}
async fn run_interactive_shell(ctx: &mut RunContext<'_>) -> MinoResult<i32> {
let sleep_command = vec!["sleep".to_string(), "infinity".to_string()];
let phase1_command = if let NetworkMode::Allow(ref rules) = ctx.network_mode {
generate_iptables_wrapper(rules, &sleep_command)
} else {
sleep_command
};
let container_id = match ctx
.runtime
.create(ctx.container_config, &phase1_command)
.await
{
Ok(id) => id,
Err(e) => return ctx.record_failure(e).await,
};
ctx.record_start(&container_id).await?;
if let Err(e) = ctx.runtime.start_detached(&container_id).await {
let _ = ctx.runtime.remove(&container_id).await;
return ctx.record_failure(e).await;
}
ctx.spinner.message("Setting up environment...");
let bootstrap_timeout = std::time::Duration::from_secs(300);
let found = ctx
.runtime
.logs_follow_until(
&container_id,
"Bootstrap complete.",
bootstrap_timeout,
&|line: String| {
debug!("bootstrap: {}", line);
},
)
.await?;
if !found {
warn!("Bootstrap marker not found within timeout, proceeding anyway");
}
ctx.spinner.clear();
let exec_command = if matches!(ctx.network_mode, NetworkMode::Allow(_)) {
let escaped_args: String = ctx
.shell_command
.iter()
.map(|arg| format!(" '{}'", shell_escape(arg)))
.collect();
vec![
"/bin/sh".to_string(),
"-c".to_string(),
format!(
"if command -v capsh >/dev/null 2>&1; then exec capsh --drop=cap_net_admin -- -c 'exec \"$@\"' --{}; \
else echo 'mino: capsh not found. Cannot drop CAP_NET_ADMIN -- network allowlist is bypassable without it.' >&2; exit 1; fi",
escaped_args
),
]
} else {
ctx.shell_command.clone()
};
debug!(
"Exec into container {}: {:?}",
&container_id[..12],
exec_command
);
let exit_code = ctx
.runtime
.exec_in_container(&container_id, &exec_command, true)
.await?;
if let Err(e) = ctx.runtime.stop(&container_id).await {
warn!("Failed to stop container {}: {}", &container_id[..12], e);
}
if let Err(e) = ctx.runtime.remove(&container_id).await {
warn!(
"Failed to remove container {}: {}",
&container_id[..12.min(container_id.len())],
e
);
}
Ok(exit_code)
}
async fn validate_environment() -> MinoResult<()> {
match Platform::detect() {
Platform::MacOS => {
use crate::orchestration::OrbStack;
if !OrbStack::is_installed().await {
return Err(MinoError::OrbStackNotFound);
}
if !OrbStack::is_running().await? {
return Err(MinoError::OrbStackNotRunning);
}
}
Platform::Linux => {} Platform::Unsupported => {
return Err(MinoError::UnsupportedPlatform(
std::env::consts::OS.to_string(),
));
}
}
Ok(())
}
fn resolve_project_dir(args: &RunArgs) -> MinoResult<PathBuf> {
if let Some(ref path) = args.project {
let canonical = path
.canonicalize()
.map_err(|e| MinoError::io(format!("resolving project path {}", path.display()), e))?;
return Ok(canonical);
}
env::current_dir().map_err(|e| MinoError::io("getting current directory", e))
}
fn generate_session_name() -> String {
let short_id = &Uuid::new_v4().to_string()[..8];
format!("session-{}", short_id)
}
#[cfg(test)]
mod tests {
use self::image::*;
use self::prompts::{is_default_network, upsert_container_toml_key, BASE_ONLY};
use super::*;
use crate::orchestration::mock::{test_container_config, MockRuntime};
use serial_test::serial;
fn test_run_args() -> RunArgs {
RunArgs {
name: None,
project: None,
aws: false,
gcp: false,
azure: false,
all_clouds: false,
no_ssh_agent: false,
no_github: false,
strict_credentials: false,
image: None,
layers: vec![],
env: vec![],
volume: vec![],
detach: false,
read_only: false,
no_cache: false,
no_home: false,
cache_fresh: false,
network: None,
network_allow: vec![],
network_preset: None,
command: vec![],
}
}
#[test]
fn image_alias_to_layer_typescript() {
assert_eq!(image_alias_to_layer("typescript"), Some("typescript"));
assert_eq!(image_alias_to_layer("ts"), Some("typescript"));
assert_eq!(image_alias_to_layer("node"), Some("typescript"));
}
#[test]
fn image_alias_to_layer_rust() {
assert_eq!(image_alias_to_layer("rust"), Some("rust"));
assert_eq!(image_alias_to_layer("cargo"), Some("rust"));
}
#[test]
fn image_alias_to_layer_python() {
assert_eq!(image_alias_to_layer("python"), Some("python"));
assert_eq!(image_alias_to_layer("py"), Some("python"));
}
#[test]
fn image_alias_to_layer_unknown() {
assert_eq!(image_alias_to_layer("base"), None);
assert_eq!(image_alias_to_layer("fedora"), None);
assert_eq!(image_alias_to_layer("ghcr.io/foo/bar:latest"), None);
}
#[test]
fn resolve_image_alias_base() {
assert_eq!(
resolve_image_alias("base"),
"ghcr.io/dean0x/mino-base:latest"
);
}
#[test]
fn resolve_image_alias_passthrough_full_path() {
assert_eq!(
resolve_image_alias("ghcr.io/custom/image:v1"),
"ghcr.io/custom/image:v1"
);
assert_eq!(
resolve_image_alias("docker.io/library/fedora:43"),
"docker.io/library/fedora:43"
);
}
#[test]
fn resolve_image_alias_passthrough_local() {
assert_eq!(resolve_image_alias("my-local-image"), "my-local-image");
assert_eq!(resolve_image_alias("fedora"), "fedora");
}
#[test]
fn is_default_image_with_defaults() {
let args = test_run_args();
let config = Config::default();
assert!(is_default_image(&args, &config));
}
#[test]
fn is_default_image_with_custom_image_arg() {
let mut args = test_run_args();
args.image = Some("custom:latest".to_string());
let config = Config::default();
assert!(!is_default_image(&args, &config));
}
#[test]
fn is_default_image_with_custom_config() {
let args = test_run_args();
let mut config = Config::default();
config.container.image = "ubuntu:24.04".to_string();
assert!(!is_default_image(&args, &config));
}
#[tokio::test]
async fn upsert_creates_new_config() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".mino.toml");
let mut layers = toml_edit::Array::new();
layers.push("rust");
layers.push("typescript");
upsert_container_toml_key(&path, "layers", toml_edit::Value::Array(layers))
.await
.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
let parsed: toml::Value = content.parse().unwrap();
let layers = parsed["container"]["layers"].as_array().unwrap();
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].as_str().unwrap(), "rust");
assert_eq!(layers[1].as_str().unwrap(), "typescript");
}
#[test]
fn parse_layers_env_basic() {
assert_eq!(
parse_layers_env("rust,typescript"),
vec!["rust", "typescript"]
);
}
#[test]
fn parse_layers_env_whitespace() {
assert_eq!(
parse_layers_env(" rust , typescript "),
vec!["rust", "typescript"]
);
}
#[test]
fn parse_layers_env_empty_segments() {
assert_eq!(
parse_layers_env("rust,,typescript,"),
vec!["rust", "typescript"]
);
}
#[test]
fn parse_layers_env_empty_string() {
let result = parse_layers_env("");
assert!(result.is_empty());
}
#[test]
fn parse_layers_env_single() {
assert_eq!(parse_layers_env("rust"), vec!["rust"]);
}
#[test]
fn resolve_layer_names_cli_wins() {
let mut args = test_run_args();
args.layers = vec!["typescript".to_string()];
let mut config = Config::default();
config.container.layers = vec!["rust".to_string()];
assert_eq!(
resolve_layer_names(&args, &config),
Some(vec!["typescript".to_string()])
);
}
#[test]
fn resolve_layer_names_image_blocks_all() {
let mut args = test_run_args();
args.image = Some("fedora:43".to_string());
let mut config = Config::default();
config.container.layers = vec!["rust".to_string()];
assert_eq!(resolve_layer_names(&args, &config), None);
}
#[test]
#[serial]
fn resolve_layer_names_config_layers() {
let args = test_run_args();
let mut config = Config::default();
config.container.layers = vec!["rust".to_string()];
std::env::remove_var("MINO_LAYERS");
assert_eq!(
resolve_layer_names(&args, &config),
Some(vec!["rust".to_string()])
);
}
#[test]
#[serial]
fn resolve_layer_names_none_when_empty() {
let args = test_run_args();
let config = Config::default();
std::env::remove_var("MINO_LAYERS");
assert_eq!(resolve_layer_names(&args, &config), None);
}
#[tokio::test]
async fn upsert_merges_existing_config() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".mino.toml");
tokio::fs::write(
&path,
"[container]\nimage = \"custom:latest\"\nnetwork = \"none\"\n",
)
.await
.unwrap();
let mut layers = toml_edit::Array::new();
layers.push("typescript");
upsert_container_toml_key(&path, "layers", toml_edit::Value::Array(layers))
.await
.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
let parsed: toml::Value = content.parse().unwrap();
let layers = parsed["container"]["layers"].as_array().unwrap();
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].as_str().unwrap(), "typescript");
assert_eq!(
parsed["container"]["image"].as_str().unwrap(),
"custom:latest"
);
assert_eq!(parsed["container"]["network"].as_str().unwrap(), "none");
}
#[tokio::test]
async fn upsert_errors_on_non_table_container() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("bad.toml");
tokio::fs::write(&path, "container = \"not-a-table\"\n")
.await
.unwrap();
let result = upsert_container_toml_key(&path, "network", "bridge".into()).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("not a table"),
"expected 'not a table' in error, got: {}",
err
);
}
#[test]
fn is_default_network_with_defaults() {
let args = test_run_args();
let config = Config::default();
assert!(is_default_network(&args, &config));
}
#[test]
fn is_default_network_with_cli_network() {
let mut args = test_run_args();
args.network = Some("host".to_string());
let config = Config::default();
assert!(!is_default_network(&args, &config));
}
#[test]
fn is_default_network_with_cli_preset() {
let mut args = test_run_args();
args.network_preset = Some("dev".to_string());
let config = Config::default();
assert!(!is_default_network(&args, &config));
}
#[test]
fn is_default_network_with_cli_allow() {
let mut args = test_run_args();
args.network_allow = vec!["github.com:443".to_string()];
let config = Config::default();
assert!(!is_default_network(&args, &config));
}
#[test]
fn is_default_network_with_config_preset() {
let args = test_run_args();
let mut config = Config::default();
config.container.network_preset = Some("dev".to_string());
assert!(!is_default_network(&args, &config));
}
#[test]
fn is_default_network_with_config_allow() {
let args = test_run_args();
let mut config = Config::default();
config.container.network_allow = vec!["github.com:443".to_string()];
assert!(!is_default_network(&args, &config));
}
struct SessionCleanup {
name: String,
}
impl Drop for SessionCleanup {
fn drop(&mut self) {
let path =
crate::config::ConfigManager::sessions_dir().join(format!("{}.json", self.name));
let _ = std::fs::remove_file(path);
}
}
struct SmokeTestFixture {
mock: Arc<MockRuntime>,
runtime: Arc<dyn ContainerRuntime>,
manager: SessionManager,
session_name: String,
_cleanup: SessionCleanup,
container_config: ContainerConfig,
command: Vec<String>,
config: Config,
audit: AuditLog,
spinner: TaskSpinner,
is_shell_mode: bool,
shell_command: Vec<String>,
network_mode: NetworkMode,
}
impl SmokeTestFixture {
async fn new(prefix: &str) -> Self {
Self::with_shell_mode(prefix, false).await
}
async fn with_shell_mode(prefix: &str, shell_mode: bool) -> Self {
let session_name = format!("{}-{}", prefix, &Uuid::new_v4().to_string()[..8]);
let cleanup = SessionCleanup {
name: session_name.clone(),
};
let manager = SessionManager::new().await.unwrap();
let session = Session::new(
session_name.clone(),
PathBuf::from("/tmp/test-project"),
vec!["bash".to_string()],
SessionStatus::Starting,
);
manager.create(&session).await.unwrap();
let mock = Arc::new(MockRuntime::new());
let runtime: Arc<dyn ContainerRuntime> = mock.clone();
let container_config = test_container_config();
let command = vec!["bash".to_string()];
let mut config = Config::default();
config.general.audit_log = false;
config.general.update_check = false;
let audit = AuditLog::new(&config);
let ctx = UiContext::detect();
let spinner = TaskSpinner::new(&ctx);
Self {
mock,
runtime,
manager,
session_name,
_cleanup: cleanup,
container_config,
command,
config,
audit,
spinner,
is_shell_mode: shell_mode,
shell_command: vec!["/bin/zsh".to_string()],
network_mode: NetworkMode::Bridge,
}
}
fn run_ctx(&mut self) -> RunContext<'_> {
RunContext {
runtime: &self.runtime,
container_config: &self.container_config,
command: &self.command,
session_name: &self.session_name,
manager: &self.manager,
audit: &self.audit,
spinner: &mut self.spinner,
config: &self.config,
is_shell_mode: self.is_shell_mode,
shell_command: self.shell_command.clone(),
network_mode: &self.network_mode,
}
}
}
#[tokio::test]
#[serial]
async fn smoke_run_interactive_command() {
let mut f = SmokeTestFixture::new("test-smoke-int").await;
assert!(!f.is_shell_mode);
run_interactive(&mut f.run_ctx(), CacheSession::default())
.await
.unwrap();
f.mock.assert_called("create", 1);
f.mock.assert_called("start_attached", 1);
f.mock.assert_called("remove", 1);
let updated = f.manager.get(&f.session_name).await.unwrap().unwrap();
assert_eq!(updated.status, SessionStatus::Stopped);
}
#[tokio::test]
#[serial]
async fn smoke_run_interactive_shell() {
let mut f = SmokeTestFixture::with_shell_mode("test-smoke-shell", true).await;
run_interactive(&mut f.run_ctx(), CacheSession::default())
.await
.unwrap();
f.mock.assert_called("create", 1);
f.mock.assert_called("start_detached", 1);
f.mock.assert_called("logs_follow_until", 1);
f.mock.assert_called("exec_in_container", 1);
f.mock.assert_called("stop", 1);
f.mock.assert_called("remove", 1);
f.mock.assert_called("start_attached", 0);
let updated = f.manager.get(&f.session_name).await.unwrap().unwrap();
assert_eq!(updated.status, SessionStatus::Stopped);
}
#[tokio::test]
#[serial]
async fn smoke_run_detached() {
let mut f = SmokeTestFixture::new("test-smoke-det").await;
run_detached(&mut f.run_ctx(), CacheSession::default())
.await
.unwrap();
f.mock.assert_called("run", 1);
let updated = f.manager.get(&f.session_name).await.unwrap().unwrap();
assert_eq!(updated.status, SessionStatus::Running);
assert!(updated.container_id.is_some());
}
#[tokio::test]
async fn upsert_base_only_writes_image_key() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join(".mino.toml");
upsert_container_toml_key(&path, "image", "base".into())
.await
.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
let parsed: toml::Value = content.parse().unwrap();
assert_eq!(parsed["container"]["image"].as_str().unwrap(), "base",);
}
#[test]
fn sentinel_base_only_alone_yields_empty() {
let selected = vec![BASE_ONLY.to_string()];
let layer_names: Vec<String> = selected.into_iter().filter(|s| s != BASE_ONLY).collect();
assert!(layer_names.is_empty());
}
#[test]
fn sentinel_base_only_with_layer_yields_layer_only() {
let selected = vec![BASE_ONLY.to_string(), "typescript".to_string()];
let layer_names: Vec<String> = selected.into_iter().filter(|s| s != BASE_ONLY).collect();
assert_eq!(layer_names, vec!["typescript"]);
}
#[test]
fn sentinel_no_base_only_passes_through() {
let selected = vec!["rust".to_string()];
let layer_names: Vec<String> = selected.into_iter().filter(|s| s != BASE_ONLY).collect();
assert_eq!(layer_names, vec!["rust"]);
}
#[test]
fn resolve_final_image_base_only_uses_layer_base_image() {
let resolution = resolve_final_image("fedora:43", true);
assert_eq!(resolution.image, LAYER_BASE_IMAGE);
assert!(resolution.layer_env.is_empty());
}
#[test]
fn resolve_final_image_not_base_only_resolves_alias() {
let resolution = resolve_final_image("fedora:43", false);
assert_eq!(resolution.image, "fedora:43");
assert!(resolution.layer_env.is_empty());
}
#[test]
fn resolve_final_image_base_alias_resolves_to_layer_base() {
let resolution = resolve_final_image("base", false);
assert_eq!(resolution.image, LAYER_BASE_IMAGE);
}
impl SmokeTestFixture {
async fn with_mock(prefix: &str, mock: MockRuntime, shell_mode: bool) -> Self {
let session_name = format!("{}-{}", prefix, &Uuid::new_v4().to_string()[..8]);
let cleanup = SessionCleanup {
name: session_name.clone(),
};
let manager = SessionManager::new().await.unwrap();
let session = Session::new(
session_name.clone(),
PathBuf::from("/tmp/test-project"),
vec!["bash".to_string()],
SessionStatus::Starting,
);
manager.create(&session).await.unwrap();
let mock = Arc::new(mock);
let runtime: Arc<dyn ContainerRuntime> = mock.clone();
let container_config = test_container_config();
let command = vec!["bash".to_string()];
let mut config = Config::default();
config.general.audit_log = false;
config.general.update_check = false;
let audit = AuditLog::new(&config);
let ctx = UiContext::detect();
let spinner = TaskSpinner::new(&ctx);
Self {
mock,
runtime,
manager,
session_name,
_cleanup: cleanup,
container_config,
command,
config,
audit,
spinner,
is_shell_mode: shell_mode,
shell_command: vec!["/bin/zsh".to_string()],
network_mode: NetworkMode::Bridge,
}
}
}
#[tokio::test]
#[serial]
async fn shell_start_detached_failure_cleans_up_container() {
let mock = MockRuntime::new().on_err(
"start_detached",
MinoError::ContainerStart("engine failure".to_string()),
);
let mut f = SmokeTestFixture::with_mock("test-shell-detach-err", mock, true).await;
let result = run_interactive_shell(&mut f.run_ctx()).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("engine failure"),
"expected 'engine failure' in error, got: {}",
err_msg
);
f.mock.assert_called("create", 1);
f.mock.assert_called("start_detached", 1);
f.mock.assert_called("remove", 1);
let updated = f.manager.get(&f.session_name).await.unwrap().unwrap();
assert_eq!(updated.status, SessionStatus::Failed);
}
#[tokio::test]
#[serial]
async fn shell_logs_follow_until_error_propagates() {
let mock = MockRuntime::new().on_err(
"logs_follow_until",
MinoError::Internal("log stream broken".to_string()),
);
let mut f = SmokeTestFixture::with_mock("test-shell-logs-err", mock, true).await;
let result = run_interactive_shell(&mut f.run_ctx()).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("log stream broken"),
"expected 'log stream broken' in error, got: {}",
err_msg
);
f.mock.assert_called("create", 1);
f.mock.assert_called("start_detached", 1);
f.mock.assert_called("logs_follow_until", 1);
f.mock.assert_called("exec_in_container", 0);
}
}