1use anyhow::{Context, Result};
2use clap::{CommandFactory, Parser, Subcommand};
3use serde::Serialize;
4use std::sync::{Arc, Mutex};
5
6use crate::bootstrap;
7use crate::fleet;
8use crate::logging::{self, LogFormat};
9use crate::shell_init;
10use crate::template_cmd;
11use crate::ui;
12use crate::update;
13
14use mvm_core::naming::{validate_flake_ref, validate_template_name, validate_vm_name};
15use mvm_core::util::parse_human_size;
16use mvm_core::vm_backend::VmId;
17use mvm_runtime::config;
18use mvm_runtime::shell;
19use mvm_runtime::vm::backend::AnyBackend;
20use mvm_runtime::vm::{firecracker, image, lima, microvm};
21
22struct VmStartParams<'a> {
24 name: String,
25 rootfs_path: String,
26 vmlinux_path: String,
27 initrd_path: Option<String>,
28 revision_hash: String,
29 flake_ref: String,
30 profile: Option<String>,
31 cpus: u32,
32 memory_mib: u32,
33 volumes: &'a [image::RuntimeVolume],
34 config_files: &'a [microvm::DriveFile],
35 secret_files: &'a [microvm::DriveFile],
36 port_mappings: &'a [config::PortMapping],
37}
38
39impl VmStartParams<'_> {
40 fn into_start_config(self) -> mvm_core::vm_backend::VmStartConfig {
41 mvm_core::vm_backend::VmStartConfig {
42 name: self.name,
43 rootfs_path: self.rootfs_path,
44 kernel_path: Some(self.vmlinux_path),
45 initrd_path: self.initrd_path,
46 revision_hash: self.revision_hash,
47 flake_ref: self.flake_ref,
48 profile: self.profile,
49 cpus: self.cpus,
50 memory_mib: self.memory_mib,
51 ports: self
52 .port_mappings
53 .iter()
54 .map(|p| mvm_core::vm_backend::VmPortMapping {
55 host: p.host,
56 guest: p.guest,
57 })
58 .collect(),
59 volumes: self
60 .volumes
61 .iter()
62 .map(|v| mvm_core::vm_backend::VmVolume {
63 host: v.host.clone(),
64 guest: v.guest.clone(),
65 size: v.size.clone(),
66 read_only: v.read_only,
67 })
68 .collect(),
69 config_files: self
70 .config_files
71 .iter()
72 .map(|f| mvm_core::vm_backend::VmFile {
73 name: f.name.clone(),
74 content: f.content.clone(),
75 mode: f.mode,
76 })
77 .collect(),
78 secret_files: self
79 .secret_files
80 .iter()
81 .map(|f| mvm_core::vm_backend::VmFile {
82 name: f.name.clone(),
83 content: f.content.clone(),
84 mode: f.mode,
85 })
86 .collect(),
87 runner_dir: None,
88 }
89 }
90}
91
92static CHILD_PIDS: std::sync::LazyLock<Arc<Mutex<Vec<u32>>>> =
94 std::sync::LazyLock::new(|| Arc::new(Mutex::new(Vec::new())));
95
96static IN_CONSOLE_MODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
99
100#[derive(Parser)]
101#[command(name = "mvmctl", version, about = "Lightweight VM development tool")]
102struct Cli {
103 #[arg(long, global = true)]
105 log_format: Option<String>,
106
107 #[arg(long, global = true)]
109 fc_version: Option<String>,
110
111 #[command(subcommand)]
112 command: Commands,
113}
114
115#[derive(Subcommand)]
116#[allow(clippy::large_enum_variant)] enum Commands {
118 Bootstrap {
120 #[arg(long)]
122 production: bool,
123 },
124 Setup {
126 #[arg(long)]
128 recreate: bool,
129 #[arg(long)]
131 force: bool,
132 #[arg(long, default_value = "8")]
134 lima_cpus: u32,
135 #[arg(long, default_value = "16")]
137 lima_mem: u32,
138 },
139 Dev {
141 #[command(subcommand)]
142 action: Option<DevCmd>,
143 },
144 Cleanup {
146 #[arg(long)]
148 keep: Option<usize>,
149 #[arg(long)]
151 all: bool,
152 #[arg(long)]
154 verbose: bool,
155 },
156 Logs {
158 #[arg(value_parser = clap_vm_name)]
160 name: String,
161 #[arg(long, short = 'f')]
163 follow: bool,
164 #[arg(long, short = 'n', default_value = "50")]
166 lines: u32,
167 #[arg(long)]
169 hypervisor: bool,
170 },
171 Forward {
173 #[arg(value_parser = clap_vm_name)]
175 name: String,
176 #[arg(short, long, value_name = "PORT", value_parser = clap_port_spec)]
178 port: Vec<String>,
179 #[arg(trailing_var_arg = true, hide = true)]
181 ports: Vec<String>,
182 },
183 #[command(alias = "ls", alias = "status")]
185 Ps {
186 #[arg(long, short = 'a')]
188 all: bool,
189 #[arg(long)]
191 json: bool,
192 },
193 Update {
195 #[arg(long)]
197 check: bool,
198 #[arg(long)]
200 force: bool,
201 #[arg(long)]
203 skip_verify: bool,
204 },
205 Doctor {
207 #[arg(long)]
209 json: bool,
210 },
211 Template {
213 #[command(subcommand)]
214 action: TemplateCmd,
215 },
216 Build {
218 #[arg(default_value = ".")]
220 path: String,
221 #[arg(long, short = 'o')]
223 output: Option<String>,
224 #[arg(long, value_parser = clap_flake_ref)]
226 flake: Option<String>,
227 #[arg(long)]
229 profile: Option<String>,
230 #[arg(long)]
232 watch: bool,
233 #[arg(long)]
235 json: bool,
236 },
237 #[command(alias = "start", alias = "run", group(clap::ArgGroup::new("source")))]
243 Up {
244 #[arg(long, group = "source", value_parser = clap_flake_ref)]
246 flake: Option<String>,
247 #[arg(long, group = "source")]
249 template: Option<String>,
250 #[arg(long, value_parser = clap_vm_name)]
252 name: Option<String>,
253 #[arg(long)]
255 profile: Option<String>,
256 #[arg(long)]
258 cpus: Option<u32>,
259 #[arg(long)]
261 memory: Option<String>,
262 #[arg(long)]
264 config: Option<String>,
265 #[arg(long, short = 'v', value_parser = clap_volume_spec)]
267 volume: Vec<String>,
268 #[arg(long, default_value = "firecracker")]
270 hypervisor: String,
271 #[arg(long, short = 'p', value_parser = clap_port_spec)]
273 port: Vec<String>,
274 #[arg(long, short = 'e')]
276 env: Vec<String>,
277 #[arg(long)]
279 forward: bool,
280 #[arg(long, default_value = "0")]
282 metrics_port: u16,
283 #[arg(long)]
285 watch_config: bool,
286 #[arg(long)]
288 watch: bool,
289 #[arg(long, short = 'd')]
291 detach: bool,
292 #[arg(long)]
294 network_preset: Option<String>,
295 #[arg(long)]
297 network_allow: Vec<String>,
298 #[arg(long, default_value = "unrestricted")]
300 seccomp: String,
301 #[arg(long, short = 's')]
303 secret: Vec<String>,
304 #[arg(long, default_value = "default")]
306 network: String,
307 },
308 Down {
310 name: Option<String>,
312 #[arg(long, short = 'f')]
314 config: Option<String>,
315 },
316 Completions {
318 #[arg(value_enum)]
320 shell: clap_complete::Shell,
321 },
322 ShellInit,
324 Metrics {
326 #[arg(long)]
328 json: bool,
329 },
330 Config {
332 #[command(subcommand)]
333 action: ConfigAction,
334 },
335 Uninstall {
337 #[arg(long, short = 'y')]
339 yes: bool,
340 #[arg(long)]
342 all: bool,
343 #[arg(long)]
345 dry_run: bool,
346 },
347 Audit {
349 #[command(subcommand)]
350 action: AuditCmd,
351 },
352 Flake {
354 #[command(subcommand)]
355 action: FlakeCmd,
356 },
357 Diff {
359 name: String,
361 #[arg(long)]
363 json: bool,
364 },
365 Network {
367 #[command(subcommand)]
368 action: NetworkCmd,
369 },
370 Image {
372 #[command(subcommand)]
373 action: ImageCmd,
374 },
375 Console {
377 #[arg(value_parser = clap_vm_name)]
379 name: String,
380 #[arg(long)]
382 command: Option<String>,
383 },
384 Cache {
386 #[command(subcommand)]
387 action: CacheCmd,
388 },
389 Init {
391 #[arg(long)]
393 non_interactive: bool,
394 #[arg(long, default_value = "8")]
396 lima_cpus: u32,
397 #[arg(long, default_value = "16")]
399 lima_mem: u32,
400 },
401 Security {
403 #[command(subcommand)]
404 action: SecurityCmd,
405 },
406 Exec {
415 #[arg(long)]
420 template: Option<String>,
421 #[arg(long, default_value = "2")]
423 cpus: u32,
424 #[arg(long, default_value = "512M")]
426 memory: String,
427 #[arg(long = "add-dir", short = 'd')]
433 add_dir: Vec<String>,
434 #[arg(long, short = 'e')]
437 env: Vec<String>,
438 #[arg(long, default_value = "60")]
440 timeout: u64,
441 #[arg(long = "launch-plan", value_name = "PATH", conflicts_with = "argv")]
445 launch_plan: Option<String>,
446 #[arg(trailing_var_arg = true, required_unless_present = "launch_plan")]
449 argv: Vec<String>,
450 },
451}
452
453#[derive(Subcommand)]
454enum AuditCmd {
455 Tail {
457 #[arg(long, short = 'n', default_value = "20")]
459 lines: usize,
460 #[arg(long, short = 'f')]
462 follow: bool,
463 },
464}
465
466#[derive(Subcommand)]
467enum DevCmd {
468 Up {
470 #[arg(long, default_value = "8")]
472 lima_cpus: u32,
473 #[arg(long, default_value = "16")]
475 lima_mem: u32,
476 #[arg(long)]
478 project: Option<String>,
479 #[arg(long, default_value = "0")]
481 metrics_port: u16,
482 #[arg(long)]
484 watch_config: bool,
485 #[arg(long)]
487 lima: bool,
488 #[arg(long, short = 's')]
490 shell: bool,
491 },
492 Down,
494 Shell {
496 #[arg(long)]
498 project: Option<String>,
499 },
500 Status,
502 Rebuild {
504 #[arg(long, default_value = "8")]
506 lima_cpus: u32,
507 #[arg(long, default_value = "16")]
509 lima_mem: u32,
510 #[arg(long)]
512 lima: bool,
513 #[arg(long, short = 's')]
515 shell: bool,
516 },
517}
518
519#[derive(Subcommand)]
520enum FlakeCmd {
521 Check {
523 #[arg(long, default_value = ".")]
525 flake: String,
526 #[arg(long)]
528 json: bool,
529 },
530}
531
532#[derive(Subcommand)]
533enum NetworkCmd {
534 #[command(alias = "new")]
536 Create {
537 name: String,
539 #[arg(long)]
541 subnet: Option<String>,
542 },
543 #[command(alias = "ls")]
545 List,
546 Inspect {
548 name: String,
550 },
551 #[command(alias = "rm")]
553 Remove {
554 name: String,
556 },
557}
558
559#[derive(Subcommand)]
560enum ImageCmd {
561 #[command(alias = "ls")]
563 List,
564 Search {
566 query: String,
568 },
569 Fetch {
571 name: String,
573 },
574 Info {
576 name: String,
578 },
579}
580
581#[derive(Subcommand)]
582enum SecurityCmd {
583 Status {
585 #[arg(long)]
587 json: bool,
588 },
589}
590
591#[derive(Subcommand)]
592enum CacheCmd {
593 Prune {
595 #[arg(long)]
597 dry_run: bool,
598 },
599 Info,
601}
602
603#[derive(Subcommand)]
604enum TemplateCmd {
605 #[command(alias = "new")]
607 Create {
608 name: String,
610 #[arg(long, default_value = ".", value_parser = clap_flake_ref)]
612 flake: String,
613 #[arg(long, default_value = "default")]
615 profile: String,
616 #[arg(long, default_value = "worker")]
618 role: String,
619 #[arg(long, default_value = "2")]
621 cpus: u8,
622 #[arg(long, default_value = "1024")]
624 mem: String,
625 #[arg(long, default_value = "0")]
627 data_disk: String,
628 },
629 CreateMulti {
631 base: String,
633 #[arg(long, default_value = ".", value_parser = clap_flake_ref)]
635 flake: String,
636 #[arg(long, default_value = "default")]
638 profile: String,
639 #[arg(long)]
641 roles: String,
642 #[arg(long, default_value = "2")]
644 cpus: u8,
645 #[arg(long, default_value = "1024")]
647 mem: String,
648 #[arg(long, default_value = "0")]
650 data_disk: String,
651 },
652 Build {
654 name: String,
656 #[arg(long)]
658 force: bool,
659 #[arg(long)]
661 snapshot: bool,
662 #[arg(long)]
664 config: Option<String>,
665 #[arg(long)]
667 update_hash: bool,
668 },
669 Push {
671 name: String,
673 #[arg(long)]
675 revision: Option<String>,
676 },
677 Pull {
679 name: String,
681 #[arg(long)]
683 revision: Option<String>,
684 },
685 Verify {
687 name: String,
689 #[arg(long)]
691 revision: Option<String>,
692 },
693 List {
695 #[arg(long)]
697 json: bool,
698 },
699 Info {
701 name: String,
703 #[arg(long)]
705 json: bool,
706 },
707 Edit {
709 name: String,
711 #[arg(long)]
713 flake: Option<String>,
714 #[arg(long)]
716 profile: Option<String>,
717 #[arg(long)]
719 role: Option<String>,
720 #[arg(long)]
722 cpus: Option<u8>,
723 #[arg(long)]
725 mem: Option<String>,
726 #[arg(long)]
728 data_disk: Option<String>,
729 },
730 Delete {
732 name: String,
734 #[arg(long)]
736 force: bool,
737 },
738 Init {
740 name: String,
742 #[arg(long)]
744 local: bool,
745 #[arg(long)]
747 vm: bool,
748 #[arg(long, default_value = ".")]
750 dir: String,
751 #[arg(long)]
753 preset: Option<String>,
754 #[arg(long)]
756 prompt: Option<String>,
757 },
758}
759
760#[derive(Subcommand)]
761enum ConfigAction {
762 Show,
764 Edit,
766 Set {
768 key: String,
770 value: String,
772 },
773}
774
775#[derive(Debug, Serialize)]
781struct PhaseEvent {
782 timestamp: String,
783 command: &'static str,
784 phase: String,
785 status: &'static str,
786 #[serde(skip_serializing_if = "Option::is_none")]
787 message: Option<String>,
788 #[serde(skip_serializing_if = "Option::is_none")]
789 error: Option<String>,
790}
791
792impl PhaseEvent {
793 fn new(command: &'static str, phase: &str, status: &'static str) -> Self {
794 Self {
795 timestamp: chrono::Utc::now().to_rfc3339(),
796 command,
797 phase: phase.to_string(),
798 status,
799 message: None,
800 error: None,
801 }
802 }
803
804 fn with_message(mut self, msg: &str) -> Self {
805 self.message = Some(msg.to_string());
806 self
807 }
808
809 fn with_error(mut self, err: &str) -> Self {
810 self.error = Some(err.to_string());
811 self
812 }
813
814 fn emit(&self) {
815 if let Ok(json) = serde_json::to_string(self) {
816 println!("{}", json);
817 }
818 }
819}
820
821pub fn cli_command() -> clap::Command {
830 use clap::CommandFactory;
831 Cli::command()
832}
833
834pub fn run() -> Result<()> {
835 let cli = Cli::parse();
836
837 if let Some(ref version) = cli.fc_version {
840 unsafe { std::env::set_var("MVM_FC_VERSION", version) };
841 }
842
843 let log_format = match cli.log_format.as_deref() {
845 Some("json") => LogFormat::Json,
846 Some("human") => LogFormat::Human,
847 Some(other) => {
848 eprintln!(
849 "Unknown --log-format '{}', using 'human'. Valid: human, json",
850 other
851 );
852 LogFormat::Human
853 }
854 None => LogFormat::Human,
855 };
856 logging::init(log_format);
857
858 let pids = Arc::clone(&CHILD_PIDS);
860 if let Err(e) = ctrlc::set_handler(move || {
861 if IN_CONSOLE_MODE.load(std::sync::atomic::Ordering::SeqCst) {
863 return;
864 }
865 eprintln!("\nInterrupted, cleaning up...");
866 if let Ok(pids) = pids.lock() {
868 for &pid in pids.iter() {
869 unsafe {
870 libc::kill(pid as libc::pid_t, libc::SIGTERM);
871 }
872 }
873 }
874 std::process::exit(130);
875 }) {
876 tracing::warn!("failed to install signal handler: {e}");
877 }
878
879 let cfg = mvm_core::user_config::load(None);
881
882 let result = match cli.command {
883 Commands::Bootstrap { production } => cmd_bootstrap(production),
884 Commands::Setup {
885 recreate,
886 force,
887 lima_cpus,
888 lima_mem,
889 } => {
890 let effective_cpus = if lima_cpus == 8 {
891 cfg.lima_cpus
892 } else {
893 lima_cpus
894 };
895 let effective_mem = if lima_mem == 16 {
896 cfg.lima_mem_gib
897 } else {
898 lima_mem
899 };
900 cmd_setup(recreate, force, effective_cpus, effective_mem)
901 }
902 Commands::Dev { action } => {
903 let action = action.unwrap_or(DevCmd::Up {
904 lima_cpus: 8,
905 lima_mem: 16,
906 project: None,
907 metrics_port: 0,
908 watch_config: false,
909 lima: false,
910 shell: false,
911 });
912 match action {
913 DevCmd::Up {
914 lima_cpus,
915 lima_mem,
916 project,
917 metrics_port,
918 watch_config,
919 lima,
920 shell,
921 } => {
922 let effective_cpus = if lima_cpus == 8 {
923 cfg.lima_cpus
924 } else {
925 lima_cpus
926 };
927 let effective_mem = if lima_mem == 16 {
928 cfg.lima_mem_gib
929 } else {
930 lima_mem
931 };
932
933 let use_apple_container =
934 !lima && mvm_core::platform::current().has_apple_containers();
935
936 if use_apple_container {
937 cmd_dev_apple_container(effective_cpus, effective_mem, shell)
938 } else {
939 cmd_dev(
940 effective_cpus,
941 effective_mem,
942 project.as_deref(),
943 metrics_port,
944 watch_config,
945 )
946 }
947 }
948 DevCmd::Down => {
949 if mvm_core::platform::current().has_apple_containers() {
950 cmd_dev_apple_container_down()
951 } else {
952 cmd_dev_down()
953 }
954 }
955 DevCmd::Shell { project } => {
956 if mvm_core::platform::current().has_apple_containers() {
957 if !is_apple_container_dev_running() {
958 anyhow::bail!("Dev VM is not running. Start it with: mvmctl dev up");
959 }
960 match console_interactive("mvm-dev") {
962 Ok(()) => Ok(()),
963 Err(_) => {
964 anyhow::bail!(
965 "Dev VM is running but owned by another process.\n\
966 Use the terminal where you ran 'mvmctl dev up',\n\
967 or restart with: mvmctl dev down && mvmctl dev up --shell"
968 )
969 }
970 }
971 } else {
972 cmd_shell(project.as_deref())
973 }
974 }
975 DevCmd::Status => {
976 if mvm_core::platform::current().has_apple_containers() {
977 cmd_dev_apple_container_status()
978 } else {
979 cmd_dev_status()
980 }
981 }
982 DevCmd::Rebuild {
983 lima_cpus,
984 lima_mem,
985 lima,
986 shell,
987 } => {
988 if mvm_core::platform::current().has_apple_containers() {
990 let _ = cmd_dev_apple_container_down();
991 } else {
992 let _ = cmd_dev_down();
993 }
994
995 let cache_dir = format!("{}/dev", mvm_core::config::mvm_cache_dir());
997 let _ = std::fs::remove_dir_all(&cache_dir);
998
999 let effective_cpus = if lima_cpus == 8 {
1001 cfg.lima_cpus
1002 } else {
1003 lima_cpus
1004 };
1005 let effective_mem = if lima_mem == 16 {
1006 cfg.lima_mem_gib
1007 } else {
1008 lima_mem
1009 };
1010 let use_apple_container =
1011 !lima && mvm_core::platform::current().has_apple_containers();
1012 if use_apple_container {
1013 cmd_dev_apple_container(effective_cpus, effective_mem, shell)
1014 } else {
1015 cmd_dev(effective_cpus, effective_mem, None, 0, false)
1016 }
1017 }
1018 }
1019 }
1020 Commands::Cleanup { keep, all, verbose } => cmd_cleanup(keep, all, verbose),
1021 Commands::Logs {
1022 name,
1023 follow,
1024 lines,
1025 hypervisor,
1026 } => cmd_logs(&name, follow, lines, hypervisor),
1027 Commands::Forward { name, port, ports } => {
1028 let mut all_ports = port;
1029 all_ports.extend(ports);
1030 cmd_forward(&name, &all_ports)
1031 }
1032
1033 Commands::Ps { all, json } => cmd_ls(all, json),
1034 Commands::Update {
1035 check,
1036 force,
1037 skip_verify,
1038 } => cmd_update(check, force, skip_verify),
1039 Commands::Doctor { json } => cmd_doctor(json),
1040 Commands::Build {
1041 path,
1042 output,
1043 flake,
1044 profile,
1045 watch,
1046 json,
1047 } => {
1048 if let Some(flake_ref) = flake {
1049 cmd_build_flake(&flake_ref, profile.as_deref(), watch, json)
1050 } else {
1051 cmd_build(&path, output.as_deref())
1052 }
1053 }
1054 Commands::Up {
1055 flake,
1056 template,
1057 name,
1058 profile,
1059 cpus,
1060 memory,
1061 config,
1062 volume,
1063 hypervisor,
1064 port,
1065 env,
1066 forward,
1067 metrics_port,
1068 watch_config,
1069 watch,
1070 detach,
1071 network_preset,
1072 network_allow,
1073 seccomp,
1074 secret,
1075 network,
1076 } => {
1077 let memory_mb = memory
1078 .as_ref()
1079 .map(|s| parse_human_size(s))
1080 .transpose()
1081 .context("Invalid memory size")?;
1082 let effective_cpus = cpus.or(Some(cfg.default_cpus));
1084 let effective_memory = memory_mb.or(Some(cfg.default_memory_mib));
1085
1086 let network_policy = resolve_network_policy(network_preset.as_deref(), &network_allow)?;
1087 let seccomp_tier: mvm_security::seccomp::SeccompTier =
1088 seccomp.parse().context("Invalid --seccomp value")?;
1089 let secret_bindings: Vec<mvm_core::secret_binding::SecretBinding> = secret
1090 .iter()
1091 .map(|s| s.parse())
1092 .collect::<Result<Vec<_>>>()
1093 .context("Invalid --secret value")?;
1094
1095 cmd_run(RunParams {
1096 flake_ref: flake.as_deref(),
1097 template_name: template.as_deref(),
1098 name: name.as_deref(),
1099 profile: profile.as_deref(),
1100 cpus: effective_cpus,
1101 memory: effective_memory,
1102 config_path: config.as_deref(),
1103 volumes: &volume,
1104 hypervisor: &hypervisor,
1105 ports: &port,
1106 env_vars: &env,
1107 forward,
1108 metrics_port,
1109 watch_config,
1110 watch,
1111 detach,
1112 network_policy,
1113 network_name: &network,
1114 seccomp_tier,
1115 secret_bindings,
1116 })
1117 }
1118 Commands::Down { name, config } => cmd_down(name.as_deref(), config.as_deref()),
1119 Commands::Completions { shell } => cmd_completions(shell),
1120 Commands::ShellInit => shell_init::print_shell_init(),
1121 Commands::Metrics { json } => cmd_metrics(json),
1122 Commands::Template { action } => cmd_template(action),
1123 Commands::Config { action } => cmd_config(action),
1124 Commands::Uninstall { yes, all, dry_run } => cmd_uninstall(yes, all, dry_run),
1125 Commands::Audit { action } => cmd_audit(action),
1126 Commands::Diff { name, json } => cmd_diff(&name, json),
1127 Commands::Flake { action } => cmd_flake(action),
1128 Commands::Network { action } => cmd_network(action),
1129 Commands::Image { action } => cmd_image(action),
1130 Commands::Console { name, command } => cmd_console(&name, command.as_deref()),
1131 Commands::Cache { action } => cmd_cache(action),
1132 Commands::Init {
1133 non_interactive,
1134 lima_cpus,
1135 lima_mem,
1136 } => cmd_init(non_interactive, lima_cpus, lima_mem),
1137 Commands::Security { action } => cmd_security(action),
1138 Commands::Exec {
1139 template,
1140 cpus,
1141 memory,
1142 add_dir,
1143 env,
1144 timeout,
1145 launch_plan,
1146 argv,
1147 } => run_oneshot(OneshotParams {
1148 template,
1149 cpus,
1150 memory: &memory,
1151 add_dir: &add_dir,
1152 env: &env,
1153 timeout,
1154 launch_plan,
1155 argv,
1156 }),
1157 };
1158
1159 with_hints(result)
1160}
1161
1162fn clap_vm_name(s: &str) -> Result<String, String> {
1168 mvm_core::naming::validate_vm_name(s).map_err(|e| e.to_string())?;
1169 Ok(s.to_owned())
1170}
1171
1172fn clap_flake_ref(s: &str) -> Result<String, String> {
1174 mvm_core::naming::validate_flake_ref(s).map_err(|e| e.to_string())?;
1175 Ok(s.to_owned())
1176}
1177
1178fn clap_port_spec(s: &str) -> Result<String, String> {
1180 if s.is_empty() {
1181 return Err("port spec must not be empty".to_owned());
1182 }
1183 if let Some((host_part, guest_part)) = s.split_once(':') {
1184 host_part
1185 .parse::<u16>()
1186 .map_err(|_| format!("invalid host port {:?} in {:?}", host_part, s))?;
1187 guest_part
1188 .parse::<u16>()
1189 .map_err(|_| format!("invalid guest port {:?} in {:?}", guest_part, s))?;
1190 } else {
1191 s.parse::<u16>()
1192 .map_err(|_| format!("invalid port {:?} — expected PORT or HOST:GUEST", s))?;
1193 }
1194 Ok(s.to_owned())
1195}
1196
1197fn clap_volume_spec(s: &str) -> Result<String, String> {
1199 if s.is_empty() {
1200 return Err("volume spec must not be empty".to_owned());
1201 }
1202 let parts: Vec<&str> = s.splitn(3, ':').collect();
1203 if parts.len() < 2 || parts[0].is_empty() || parts[1].is_empty() {
1204 return Err(format!(
1205 "invalid volume {:?} — expected host:/guest or host:/guest:size",
1206 s
1207 ));
1208 }
1209 Ok(s.to_owned())
1210}
1211
1212fn cmd_bootstrap(production: bool) -> Result<()> {
1217 ui::info("Bootstrapping full environment...\n");
1218
1219 if !production {
1220 bootstrap::check_package_manager()?;
1221 }
1222
1223 ui::info("\nInstalling prerequisites...");
1224 bootstrap::ensure_lima()?;
1225
1226 run_setup_steps(false, 8, 16)?;
1228
1229 ui::success("\nBootstrap complete! Run 'mvmctl dev' to enter the development environment.");
1230 Ok(())
1231}
1232
1233fn cmd_setup(recreate: bool, force: bool, lima_cpus: u32, lima_mem: u32) -> Result<()> {
1234 if recreate {
1235 recreate_rootfs()?;
1236 ui::success("\nRootfs recreated! Run 'mvmctl start' or 'mvmctl dev' to launch.");
1237 return Ok(());
1238 }
1239
1240 if !bootstrap::is_lima_required() {
1241 run_setup_steps(force, lima_cpus, lima_mem)?;
1243 ui::success("\nSetup complete! Run 'mvmctl start' to launch a microVM.");
1244 return Ok(());
1245 }
1246
1247 which::which("limactl").map_err(|_| {
1248 anyhow::anyhow!(
1249 "'limactl' not found. Install Lima first: brew install lima\n\
1250 Or run 'mvmctl bootstrap' for full automatic setup."
1251 )
1252 })?;
1253
1254 run_setup_steps(force, lima_cpus, lima_mem)?;
1255
1256 ui::success("\nSetup complete! Run 'mvmctl start' to launch a microVM.");
1257 Ok(())
1258}
1259
1260fn recreate_rootfs() -> Result<()> {
1262 if bootstrap::is_lima_required() {
1263 lima::require_running()?;
1264 }
1265
1266 if firecracker::is_running()? {
1268 ui::info("Stopping running microVM...");
1269 microvm::stop()?;
1270 }
1271
1272 ui::info("Removing existing rootfs...");
1273 shell::run_in_vm(&format!(
1274 "rm -f {dir}/ubuntu-*.ext4",
1275 dir = config::MICROVM_DIR,
1276 ))?;
1277
1278 ui::info("Rebuilding rootfs...");
1279 firecracker::prepare_rootfs()?;
1280 firecracker::write_state()?;
1281
1282 Ok(())
1283}
1284
1285fn cmd_dev(
1286 lima_cpus: u32,
1287 lima_mem: u32,
1288 project: Option<&str>,
1289 metrics_port: u16,
1290 watch_config: bool,
1291) -> Result<()> {
1292 let _metrics_server = if metrics_port > 0 {
1293 Some(crate::metrics_server::MetricsServer::start(metrics_port)?)
1294 } else {
1295 None
1296 };
1297
1298 let _config_watcher = if watch_config {
1300 let config_path = {
1301 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1302 std::path::PathBuf::from(home)
1303 .join(".mvm")
1304 .join("config.toml")
1305 };
1306 if config_path.exists() {
1307 match crate::config_watcher::ConfigWatcher::start(&config_path) {
1308 Ok(w) => {
1309 tracing::info!("Watching ~/.mvm/config.toml for changes");
1310 Some(w)
1311 }
1312 Err(e) => {
1313 tracing::warn!("Could not start config watcher: {e}");
1314 None
1315 }
1316 }
1317 } else {
1318 None
1319 }
1320 } else {
1321 None
1322 };
1323
1324 ui::info("Launching development environment...\n");
1325
1326 if bootstrap::is_lima_required() {
1327 if which::which("limactl").is_err() {
1329 ui::info("Lima not found. Running bootstrap...\n");
1330 cmd_bootstrap(false)?;
1331 } else {
1332 let lima_status = lima::get_status()?;
1333 match lima_status {
1334 lima::LimaStatus::NotFound => {
1335 ui::info("Lima VM not found. Running setup...\n");
1336 run_setup_steps(false, lima_cpus, lima_mem)?;
1337 }
1338 lima::LimaStatus::Stopped => {
1339 ui::info("Lima VM is stopped. Starting...");
1340 lima::start()?;
1341 }
1342 lima::LimaStatus::Running => {}
1343 }
1344 }
1345 }
1346
1347 if !firecracker::is_installed()? {
1349 ui::info("Firecracker not installed. Installing...\n");
1350 firecracker::install()?;
1351 }
1352
1353 if !firecracker::has_base_assets()? {
1355 ui::info("Downloading kernel and rootfs...\n");
1356 firecracker::download_assets()?;
1357 firecracker::prepare_rootfs()?;
1358 firecracker::write_state()?;
1359 }
1360
1361 shell_init::ensure_shell_init()?;
1363
1364 cmd_shell(project)
1366}
1367
1368fn cmd_dev_down() -> Result<()> {
1369 if !bootstrap::is_lima_required() {
1370 ui::info("Lima is not required on this platform (native KVM available).");
1371 return Ok(());
1372 }
1373
1374 if which::which("limactl").is_err() {
1375 anyhow::bail!("Lima is not installed. Run 'mvmctl dev up' to bootstrap first.");
1376 }
1377
1378 let status = lima::get_status()?;
1379 match status {
1380 lima::LimaStatus::Running => {
1381 ui::info("Stopping Lima development VM...");
1382 lima::stop()?;
1383 ui::success("Development VM stopped.");
1384 Ok(())
1385 }
1386 lima::LimaStatus::Stopped => {
1387 ui::info("Development VM is already stopped.");
1388 Ok(())
1389 }
1390 lima::LimaStatus::NotFound => {
1391 anyhow::bail!(
1392 "Lima VM '{}' does not exist. Run 'mvmctl dev up' first.",
1393 config::VM_NAME
1394 );
1395 }
1396 }
1397}
1398
1399fn cmd_dev_status() -> Result<()> {
1400 if !bootstrap::is_lima_required() {
1401 ui::info("Lima is not required on this platform (native KVM available).");
1402 return Ok(());
1403 }
1404
1405 if which::which("limactl").is_err() {
1406 ui::warn("Lima is not installed. Run 'mvmctl dev up' to bootstrap.");
1407 return Ok(());
1408 }
1409
1410 let status = lima::get_status()?;
1411 let status_str = match status {
1412 lima::LimaStatus::Running => "Running",
1413 lima::LimaStatus::Stopped => "Stopped",
1414 lima::LimaStatus::NotFound => "Not found",
1415 };
1416
1417 ui::info(&format!("Lima VM '{}': {status_str}", config::VM_NAME));
1418
1419 if matches!(status, lima::LimaStatus::Running) {
1420 let fc_ver = shell::run_in_vm_stdout("firecracker --version 2>/dev/null | head -1")
1421 .unwrap_or_default();
1422 let nix_ver = shell::run_in_vm_stdout("nix --version 2>/dev/null").unwrap_or_default();
1423
1424 ui::info(&format!(
1425 " Firecracker: {}",
1426 if fc_ver.trim().is_empty() {
1427 "not installed"
1428 } else {
1429 fc_ver.trim()
1430 }
1431 ));
1432 ui::info(&format!(
1433 " Nix: {}",
1434 if nix_ver.trim().is_empty() {
1435 "not installed"
1436 } else {
1437 nix_ver.trim()
1438 }
1439 ));
1440
1441 let mvm_in_vm =
1442 shell::run_in_vm_stdout("test -f /usr/local/bin/mvmctl && echo yes || echo no")
1443 .unwrap_or_default();
1444 if mvm_in_vm.trim() == "yes" {
1445 let mvm_ver = shell::run_in_vm_stdout("/usr/local/bin/mvmctl --version 2>/dev/null")
1446 .unwrap_or_default();
1447 ui::info(&format!(
1448 " mvmctl: {}",
1449 if mvm_ver.trim().is_empty() {
1450 "installed"
1451 } else {
1452 mvm_ver.trim()
1453 }
1454 ));
1455 } else {
1456 ui::warn(" mvmctl not installed in VM. Run 'mvmctl sync' to build and install it.");
1457 }
1458 }
1459
1460 Ok(())
1461}
1462
1463const DEV_VM_NAME: &str = "mvm-dev";
1468
1469fn is_apple_container_dev_running() -> bool {
1472 let pid_running = mvm_apple_container::list_ids()
1474 .iter()
1475 .any(|id| id == DEV_VM_NAME);
1476 if pid_running {
1477 return true;
1478 }
1479 if dev_launchd_plist_path().exists() {
1481 let output = std::process::Command::new("launchctl")
1482 .args(["list", DEV_LAUNCHD_LABEL])
1483 .output();
1484 if let Ok(o) = output
1485 && o.status.success()
1486 {
1487 return true;
1488 }
1489 }
1490 false
1491}
1492
1493fn cmd_dev_apple_container(cpus: u32, memory_gib: u32, open_shell: bool) -> Result<()> {
1495 let is_daemon = std::env::var("MVM_DEV_DAEMON").as_deref() == Ok("1");
1496
1497 if is_daemon {
1499 return cmd_dev_apple_container_daemon(cpus, memory_gib);
1500 }
1501
1502 ui::info("Starting dev environment via Apple Container...\n");
1503
1504 if is_apple_container_dev_running() {
1505 if open_shell {
1506 ui::info("Dev VM already running. Opening shell...");
1507 return console_interactive(DEV_VM_NAME);
1508 }
1509 ui::info("Dev VM already running.");
1510 return Ok(());
1511 }
1512
1513 cleanup_stale_dev_vm();
1515
1516 let (kernel, rootfs) = ensure_dev_image()?;
1518
1519 let exe = std::env::current_exe().context("cannot find current executable")?;
1521 let log_dir = format!("{}/dev", mvm_core::config::mvm_cache_dir());
1522 std::fs::create_dir_all(&log_dir)?;
1523
1524 mvm_apple_container::ensure_signed();
1527
1528 ui::info(&format!(
1529 "Booting dev VM ({} vCPUs, {} GiB memory)...",
1530 cpus, memory_gib
1531 ));
1532
1533 install_dev_launchd_agent(&exe, &kernel, &rootfs, cpus, memory_gib, &log_dir)?;
1536
1537 let proxy_path = dev_vsock_proxy_path();
1539 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
1540 loop {
1541 if std::time::Instant::now() > deadline {
1542 anyhow::bail!(
1543 "Dev VM did not start within 60 seconds.\n\
1544 Check logs: {log_dir}/daemon-stderr.log"
1545 );
1546 }
1547 if std::path::Path::new(&proxy_path).exists()
1548 && vsock_proxy_connect(&proxy_path, mvm_guest::vsock::GUEST_AGENT_PORT).is_ok()
1549 {
1550 break;
1551 }
1552 std::thread::sleep(std::time::Duration::from_millis(500));
1553 }
1554
1555 ui::success("Dev VM ready.");
1556 ui::info(" Shell: mvmctl dev shell");
1557 ui::info(" Stop VM: mvmctl dev down");
1558
1559 if open_shell {
1560 ui::info("");
1561 let _ = console_interactive(DEV_VM_NAME);
1562 }
1563
1564 Ok(())
1565}
1566
1567fn dev_vsock_proxy_path() -> String {
1569 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1570 format!("{home}/.mvm/vms/{DEV_VM_NAME}/vsock.sock")
1571}
1572
1573fn cmd_dev_apple_container_daemon(cpus: u32, memory_gib: u32) -> Result<()> {
1575 let kernel = std::env::var("MVM_DEV_KERNEL")
1576 .unwrap_or_else(|_| format!("{}/dev/vmlinux", mvm_core::config::mvm_cache_dir()));
1577 let rootfs = std::env::var("MVM_DEV_ROOTFS")
1578 .unwrap_or_else(|_| format!("{}/dev/rootfs.ext4", mvm_core::config::mvm_cache_dir()));
1579
1580 let memory_mib = (memory_gib as u64) * 1024;
1581 mvm_apple_container::start(DEV_VM_NAME, &kernel, &rootfs, cpus, memory_mib)
1582 .map_err(|e| anyhow::anyhow!("Failed to start dev VM: {e}"))?;
1583
1584 let proxy_path = dev_vsock_proxy_path();
1588 let _ = std::fs::remove_file(&proxy_path);
1589 start_vsock_proxy(&proxy_path);
1590
1591 loop {
1593 std::thread::park();
1594 }
1595}
1596
1597const DEV_LAUNCHD_LABEL: &str = "com.mvm.dev";
1598
1599fn dev_launchd_plist_path() -> std::path::PathBuf {
1600 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1601 std::path::PathBuf::from(format!(
1602 "{home}/Library/LaunchAgents/{DEV_LAUNCHD_LABEL}.plist"
1603 ))
1604}
1605
1606fn install_dev_launchd_agent(
1607 exe: &std::path::Path,
1608 kernel: &str,
1609 rootfs: &str,
1610 cpus: u32,
1611 memory_gib: u32,
1612 log_dir: &str,
1613) -> Result<()> {
1614 unload_dev_launchd_agent();
1616
1617 let plist = format!(
1618 r#"<?xml version="1.0" encoding="UTF-8"?>
1619<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1620<plist version="1.0">
1621<dict>
1622 <key>Label</key>
1623 <string>{DEV_LAUNCHD_LABEL}</string>
1624 <key>ProgramArguments</key>
1625 <array>
1626 <string>{exe}</string>
1627 <string>dev</string>
1628 <string>up</string>
1629 </array>
1630 <key>EnvironmentVariables</key>
1631 <dict>
1632 <key>MVM_DEV_DAEMON</key>
1633 <string>1</string>
1634 <key>MVM_DEV_KERNEL</key>
1635 <string>{kernel}</string>
1636 <key>MVM_DEV_ROOTFS</key>
1637 <string>{rootfs}</string>
1638 <key>MVM_DEV_CPUS</key>
1639 <string>{cpus}</string>
1640 <key>MVM_DEV_MEM_GIB</key>
1641 <string>{memory_gib}</string>
1642 <key>MVM_SIGNED</key>
1643 <string>0</string>
1644 </dict>
1645 <key>RunAtLoad</key>
1646 <true/>
1647 <key>KeepAlive</key>
1648 <false/>
1649 <key>StandardOutPath</key>
1650 <string>{log_dir}/daemon-stdout.log</string>
1651 <key>StandardErrorPath</key>
1652 <string>{log_dir}/daemon-stderr.log</string>
1653</dict>
1654</plist>"#,
1655 exe = exe.display(),
1656 );
1657
1658 let plist_path = dev_launchd_plist_path();
1659 let agents_dir = plist_path.parent().expect("plist path must have parent");
1660 std::fs::create_dir_all(agents_dir)?;
1661 std::fs::write(&plist_path, &plist)?;
1662
1663 let output = std::process::Command::new("launchctl")
1664 .args(["load", plist_path.to_str().unwrap_or("")])
1665 .output()
1666 .context("Failed to run launchctl")?;
1667
1668 if !output.status.success() {
1669 let stderr = String::from_utf8_lossy(&output.stderr);
1670 anyhow::bail!("launchctl load failed: {stderr}");
1671 }
1672
1673 Ok(())
1674}
1675
1676fn unload_dev_launchd_agent() {
1677 let plist_path = dev_launchd_plist_path();
1678 if plist_path.exists() {
1679 let _ = std::process::Command::new("launchctl")
1680 .args(["unload", plist_path.to_str().unwrap_or("")])
1681 .output();
1682 let _ = std::fs::remove_file(&plist_path);
1683 }
1684}
1685
1686fn start_vsock_proxy(socket_path: &str) {
1688 use std::os::unix::net::UnixListener;
1689
1690 let listener = match UnixListener::bind(socket_path) {
1691 Ok(l) => l,
1692 Err(e) => {
1693 tracing::warn!("Failed to start vsock proxy: {e}");
1694 return;
1695 }
1696 };
1697
1698 std::thread::spawn(move || {
1699 for stream in listener.incoming().flatten() {
1700 std::thread::spawn(move || {
1703 use std::io::Read;
1704 let mut client = stream;
1705 let mut port_buf = [0u8; 4];
1706 if client.read_exact(&mut port_buf).is_err() {
1707 return;
1708 }
1709 let port = u32::from_le_bytes(port_buf);
1710
1711 let vsock = match mvm_apple_container::vsock_connect(DEV_VM_NAME, port) {
1712 Ok(s) => s,
1713 Err(_) => return,
1714 };
1715
1716 let mut vsock_read = match vsock.try_clone() {
1718 Ok(s) => s,
1719 Err(_) => return,
1720 };
1721 let mut client_write = match client.try_clone() {
1722 Ok(s) => s,
1723 Err(_) => return,
1724 };
1725
1726 let h = std::thread::spawn(move || {
1727 let _ = std::io::copy(&mut vsock_read, &mut client_write);
1728 });
1729 let mut vsock_write = vsock;
1730 let _ = std::io::copy(&mut client, &mut vsock_write);
1731 let _ = h.join();
1732 });
1733 }
1734 });
1735}
1736
1737fn stop_dev_vm_owner() {
1739 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1740 let vm_dir = std::path::PathBuf::from(format!("{home}/.mvm/vms/{DEV_VM_NAME}"));
1741 let pid_file = vm_dir.join("pid");
1742
1743 if let Ok(pid_str) = std::fs::read_to_string(&pid_file)
1744 && let Ok(pid) = pid_str.trim().parse::<i32>()
1745 {
1746 if pid as u32 != std::process::id() {
1748 unsafe {
1749 libc::kill(pid, libc::SIGTERM);
1750 }
1751 for _ in 0..20 {
1753 if unsafe { libc::kill(pid, 0) } != 0 {
1754 break;
1755 }
1756 std::thread::sleep(std::time::Duration::from_millis(100));
1757 }
1758 }
1759 }
1760
1761 let _ = std::fs::remove_dir_all(&vm_dir);
1762}
1763
1764fn cleanup_stale_dev_vm() {
1766 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1767 let vm_dir = std::path::PathBuf::from(format!("{home}/.mvm/vms/{DEV_VM_NAME}"));
1768 let pid_file = vm_dir.join("pid");
1769
1770 if !pid_file.exists() {
1771 return;
1772 }
1773
1774 let pid_str = match std::fs::read_to_string(&pid_file) {
1775 Ok(s) => s.trim().to_string(),
1776 Err(_) => return,
1777 };
1778 let pid: i32 = match pid_str.parse() {
1779 Ok(p) => p,
1780 Err(_) => return,
1781 };
1782
1783 let alive = unsafe { libc::kill(pid, 0) } == 0;
1785 if alive {
1786 return; }
1788
1789 ui::info("Cleaning up stale dev VM state from a previous session...");
1790 let _ = std::fs::remove_dir_all(&vm_dir);
1791}
1792
1793fn cmd_dev_apple_container_down() -> Result<()> {
1795 let was_running = is_apple_container_dev_running() || dev_launchd_plist_path().exists();
1796
1797 unload_dev_launchd_agent();
1799 stop_dev_vm_owner();
1801 cleanup_stale_dev_vm();
1803 let _ = std::fs::remove_file(dev_vsock_proxy_path());
1804
1805 if was_running {
1806 ui::success("Dev VM stopped.");
1807 } else {
1808 ui::info("Dev VM is not running.");
1809 }
1810 Ok(())
1811}
1812
1813fn cmd_dev_apple_container_status() -> Result<()> {
1815 let running = is_apple_container_dev_running();
1816 ui::info("Backend: Apple Container (Virtualization.framework)");
1817 ui::info(&format!("Dev VM: {DEV_VM_NAME}"));
1818 ui::info(&format!(
1819 "Status: {}",
1820 if running { "running" } else { "stopped" }
1821 ));
1822
1823 if running
1824 && let Ok(mut stream) =
1825 mvm_apple_container::vsock_connect(DEV_VM_NAME, mvm_guest::vsock::GUEST_AGENT_PORT)
1826 && let Ok(mvm_guest::vsock::GuestResponse::ExecResult { stdout, .. }) =
1827 mvm_guest::vsock::send_request(
1828 &mut stream,
1829 &mvm_guest::vsock::GuestRequest::Exec {
1830 command: "uname -r".to_string(),
1831 stdin: None,
1832 timeout_secs: Some(5),
1833 },
1834 )
1835 {
1836 ui::info(&format!(" Kernel: {}", stdout.trim()));
1837 }
1838
1839 let cache_dir = format!("{}/dev", mvm_core::config::mvm_cache_dir());
1841 let kernel_path = format!("{cache_dir}/vmlinux");
1842 let rootfs_path = format!("{cache_dir}/rootfs.ext4");
1843 ui::info(&format!(
1844 " Image: {}",
1845 if std::path::Path::new(&rootfs_path).exists() {
1846 "cached"
1847 } else {
1848 "not built"
1849 }
1850 ));
1851 if std::path::Path::new(&kernel_path).exists() {
1852 ui::info(&format!(" Kernel: {kernel_path}"));
1853 }
1854 if std::path::Path::new(&rootfs_path).exists() {
1855 ui::info(&format!(" Rootfs: {rootfs_path}"));
1856 }
1857
1858 Ok(())
1859}
1860
1861fn ensure_linux_builder_ssh_config() -> bool {
1874 #[cfg(not(target_os = "macos"))]
1875 {
1876 false
1877 }
1878
1879 #[cfg(target_os = "macos")]
1880 {
1881 use std::io::Write;
1882 use std::net::TcpStream;
1883 use std::time::Duration;
1884
1885 let key_path = "/etc/nix/builder_ed25519";
1886 let builder_port: u16 = 31022;
1887
1888 let builder_listening = || {
1889 TcpStream::connect_timeout(
1890 &format!("127.0.0.1:{builder_port}")
1891 .parse()
1892 .expect("valid socket address literal"),
1893 Duration::from_secs(2),
1894 )
1895 .is_ok()
1896 };
1897
1898 if !builder_listening() {
1900 let nix_bin = find_nix_binary();
1901 ui::info(" Starting Nix linux-builder VM in the background...");
1902
1903 let log_path = format!("{}/linux-builder.log", mvm_core::config::mvm_cache_dir());
1907 let log_file = std::fs::File::create(&log_path)
1908 .or_else(|_| std::fs::File::create("/dev/null"))
1909 .expect("failed to open /dev/null");
1910 let stderr_file = log_file
1911 .try_clone()
1912 .or_else(|_| std::fs::File::create("/dev/null"))
1913 .expect("failed to open /dev/null");
1914
1915 let child = std::process::Command::new(&nix_bin)
1916 .args(["run", "nixpkgs#darwin.linux-builder"])
1917 .stdout(log_file)
1918 .stderr(stderr_file)
1919 .stdin(std::process::Stdio::null())
1920 .spawn();
1921
1922 if child.is_err() {
1923 return false;
1924 }
1925
1926 ui::info(
1929 " Waiting for linux-builder to become ready (this may take a minute on first run)...",
1930 );
1931 let deadline = std::time::Instant::now() + Duration::from_secs(120);
1932 loop {
1933 if std::time::Instant::now() > deadline {
1934 ui::warn(" Timed out waiting for linux-builder to start.");
1935 return false;
1936 }
1937 if std::path::Path::new(key_path).exists() && builder_listening() {
1938 break;
1939 }
1940 std::thread::sleep(Duration::from_secs(2));
1941 }
1942 }
1943
1944 if !std::path::Path::new(key_path).exists() {
1946 return false;
1947 }
1948
1949 let ssh_config_dir = std::path::Path::new("/etc/ssh/ssh_config.d");
1953 let config_path = ssh_config_dir.join("200-linux-builder.conf");
1954
1955 let expected_config = format!(
1956 "Host linux-builder\n\
1957 \x20 HostName localhost\n\
1958 \x20 Port {builder_port}\n\
1959 \x20 User builder\n\
1960 \x20 IdentityFile {key_path}\n\
1961 \x20 IdentitiesOnly yes\n\
1962 \x20 StrictHostKeyChecking no\n\
1963 \x20 UserKnownHostsFile /dev/null\n\
1964 \x20 LogLevel ERROR\n"
1965 );
1966
1967 let ssh_needs_write = if config_path.exists() {
1968 std::fs::read_to_string(&config_path)
1970 .map(|c| !c.contains("User builder"))
1971 .unwrap_or(true)
1972 } else {
1973 let ssh_check = std::process::Command::new("ssh")
1976 .args(["-G", "linux-builder"])
1977 .output();
1978 if let Ok(out) = ssh_check {
1979 let cfg = String::from_utf8_lossy(&out.stdout);
1980 let has_host = cfg.lines().any(|l| {
1981 l.strip_prefix("hostname ")
1982 .is_some_and(|h| h.trim() != "linux-builder")
1983 });
1984 let has_user = cfg.lines().any(|l| {
1985 l.strip_prefix("user ")
1986 .is_some_and(|u| u.trim() == "builder")
1987 });
1988 !has_host || !has_user
1989 } else {
1990 true
1991 }
1992 };
1993
1994 let mut ssh_ok = !ssh_needs_write;
1995 if ssh_needs_write {
1996 let tmp_path = "/tmp/mvm-linux-builder-ssh.conf";
1997 if let Ok(mut f) = std::fs::File::create(tmp_path)
1998 && f.write_all(expected_config.as_bytes()).is_ok()
1999 {
2000 let status = std::process::Command::new("sudo")
2001 .args(["cp", tmp_path, config_path.to_str().unwrap_or_default()])
2002 .status();
2003 let _ = std::fs::remove_file(tmp_path);
2004 ssh_ok = matches!(status, Ok(s) if s.success());
2005 }
2006 }
2007
2008 if !ssh_ok {
2009 return false;
2010 }
2011
2012 let builders_line = format!(
2017 "builders = ssh-ng://builder@linux-builder aarch64-linux {key_path} 4 1 kvm,big-parallel - -"
2018 );
2019
2020 let nix_custom = std::path::Path::new("/etc/nix/nix.custom.conf");
2021 let nix_conf = std::path::Path::new("/etc/nix/nix.conf");
2022
2023 let nix_needs_write = {
2024 let has_correct = [nix_custom, nix_conf].iter().any(|path| {
2025 std::fs::read_to_string(path)
2026 .map(|c| {
2027 c.lines().any(|l| {
2028 l.trim_start().starts_with("builders")
2029 && l.contains("builder@linux-builder")
2030 })
2031 })
2032 .unwrap_or(false)
2033 });
2034 !has_correct
2035 };
2036
2037 if nix_needs_write {
2038 ui::info(" Configuring nix-daemon to use the linux-builder...");
2039
2040 let existing = std::fs::read_to_string(nix_custom).unwrap_or_default();
2042 let cleaned: String = {
2043 let mut skip = false;
2044 let mut lines = Vec::new();
2045 for line in existing.lines() {
2046 if line.contains("Added by mvmctl for darwin.linux-builder") {
2047 skip = true;
2048 continue;
2049 }
2050 if skip {
2051 if line.trim_start().starts_with("builders") {
2053 continue;
2054 }
2055 if line.trim().is_empty() {
2057 skip = false;
2058 continue;
2059 }
2060 skip = false;
2061 }
2062 lines.push(line);
2063 }
2064 lines.join("\n")
2065 };
2066
2067 let new_content = format!(
2068 "{cleaned}\n\
2069 # Added by mvmctl for darwin.linux-builder\n\
2070 {builders_line}\n\
2071 builders-use-substitutes = true\n"
2072 );
2073
2074 let tmp_path = "/tmp/mvm-nix-custom-append.conf";
2075 if let Ok(mut f) = std::fs::File::create(tmp_path)
2076 && f.write_all(new_content.as_bytes()).is_ok()
2077 {
2078 let status = std::process::Command::new("sudo")
2079 .args(["cp", tmp_path, nix_custom.to_str().unwrap_or_default()])
2080 .status();
2081 let _ = std::fs::remove_file(tmp_path);
2082 if !matches!(status, Ok(s) if s.success()) {
2083 return false;
2084 }
2085
2086 let restarted = std::process::Command::new("sudo")
2088 .args([
2089 "launchctl",
2090 "kickstart",
2091 "-k",
2092 "system/systems.determinate.nix-daemon",
2093 ])
2094 .status()
2095 .is_ok_and(|s| s.success());
2096 if !restarted {
2097 let _ = std::process::Command::new("sudo")
2098 .args([
2099 "launchctl",
2100 "kickstart",
2101 "-k",
2102 "system/org.nixos.nix-daemon",
2103 ])
2104 .status();
2105 }
2106 }
2107 }
2108
2109 true
2110 }
2111}
2112
2113fn ensure_dev_image() -> Result<(String, String)> {
2118 let cache_dir = format!("{}/dev", mvm_core::config::mvm_cache_dir());
2119 std::fs::create_dir_all(&cache_dir)?;
2120
2121 let kernel_path = format!("{cache_dir}/vmlinux");
2122 let rootfs_path = format!("{cache_dir}/rootfs.ext4");
2123
2124 if std::path::Path::new(&kernel_path).exists() && std::path::Path::new(&rootfs_path).exists() {
2125 return Ok((kernel_path, rootfs_path));
2126 }
2127
2128 let plat = mvm_core::platform::current();
2130 if plat.has_host_nix()
2131 && let Ok(flake_dir) = find_dev_image_flake()
2132 {
2133 ui::info("Building dev image via Nix (first time only)...");
2134 let nix_bin = find_nix_binary();
2135
2136 if cfg!(target_os = "macos") && ensure_linux_builder_ssh_config() {
2139 ui::info(" Linux builder detected and SSH configured.");
2140 }
2141
2142 let mut child = std::process::Command::new(&nix_bin)
2145 .args([
2146 "build",
2147 &format!(
2148 "{flake_dir}#packages.{}.default",
2149 mvm_build::dev_build::linux_system()
2150 ),
2151 "--no-link",
2152 "--print-out-paths",
2153 ])
2154 .stdout(std::process::Stdio::piped())
2155 .stderr(std::process::Stdio::inherit())
2156 .spawn()
2157 .context("Failed to run nix build")?;
2158
2159 let stdout = {
2160 let mut buf = String::new();
2161 if let Some(mut out) = child.stdout.take() {
2162 use std::io::Read;
2163 let _ = out.read_to_string(&mut buf);
2164 }
2165 buf
2166 };
2167 let status = child.wait().context("nix build process failed")?;
2168
2169 if status.success() {
2170 let store_path = stdout.trim().to_string();
2171 let ks = format!("{store_path}/vmlinux");
2172 let rs = format!("{store_path}/rootfs.ext4");
2173 if std::path::Path::new(&ks).exists() && std::path::Path::new(&rs).exists() {
2174 std::fs::copy(&ks, &kernel_path)?;
2175 std::fs::copy(&rs, &rootfs_path)?;
2176 ui::success("Dev image built and cached.");
2177 return Ok((kernel_path, rootfs_path));
2178 }
2179 }
2180
2181 let diag = std::process::Command::new(&nix_bin)
2184 .args([
2185 "build",
2186 &format!(
2187 "{flake_dir}#packages.{}.default",
2188 mvm_build::dev_build::linux_system()
2189 ),
2190 "--no-link",
2191 "--dry-run",
2192 ])
2193 .output()
2194 .ok();
2195 let stderr = diag
2196 .as_ref()
2197 .map(|o| String::from_utf8_lossy(&o.stderr).into_owned())
2198 .unwrap_or_default();
2199 if stderr.contains("required system or feature not available") {
2200 ui::warn(
2201 "Nix cannot cross-compile Linux images on this Mac.\n\
2202 No Linux builder detected. To fix this, either:\n\n\
2203 \x20 1. Run in another terminal (keeps running):\n\
2204 \x20 nix run 'nixpkgs#darwin.linux-builder'\n\n\
2205 \x20 2. Or add to /etc/nix/nix.conf (permanent):\n\
2206 \x20 builders = ssh-ng://builder@linux-builder aarch64-linux /etc/nix/builder_ed25519 4 1 kvm,big-parallel - -\n\
2207 \x20 builders-use-substitutes = true\n\n\
2208 Falling back to downloading a pre-built dev image...",
2209 );
2210 } else {
2211 ui::warn(&format!("Nix build failed, trying download:\n{stderr}"));
2212 }
2213 }
2214
2215 download_dev_image(&kernel_path, &rootfs_path)
2217}
2218
2219fn download_dev_image(kernel_path: &str, rootfs_path: &str) -> Result<(String, String)> {
2221 let version = env!("CARGO_PKG_VERSION");
2222 let base_url = format!("https://github.com/auser/mvm/releases/download/v{version}");
2223 let arch = if cfg!(target_arch = "aarch64") {
2227 "aarch64"
2228 } else {
2229 "x86_64"
2230 };
2231 let kernel_url = format!("{base_url}/dev-vmlinux-{arch}");
2232 let rootfs_url = format!("{base_url}/dev-rootfs-{arch}.ext4");
2233
2234 ui::info(&format!("Downloading dev image (v{version})..."));
2235
2236 ui::info(" Fetching kernel...");
2238 download_file(&kernel_url, kernel_path)
2239 .with_context(|| format!("Failed to download kernel from {kernel_url}"))?;
2240
2241 ui::info(" Fetching rootfs...");
2243 download_file(&rootfs_url, rootfs_path)
2244 .with_context(|| format!("Failed to download rootfs from {rootfs_url}"))?;
2245
2246 ui::success("Dev image downloaded and cached.");
2247 Ok((kernel_path.to_string(), rootfs_path.to_string()))
2248}
2249
2250fn download_file(url: &str, dest: &str) -> Result<()> {
2252 let status = std::process::Command::new("curl")
2253 .args(["-fSL", "--progress-bar", "-o", dest, url])
2254 .stdin(std::process::Stdio::inherit())
2255 .stdout(std::process::Stdio::inherit())
2256 .stderr(std::process::Stdio::inherit())
2257 .status()
2258 .context("Failed to run curl")?;
2259
2260 if !status.success() {
2261 let _ = std::fs::remove_file(dest);
2263 anyhow::bail!(
2264 "Download failed. Pre-built images for v{version} may not yet be\n\
2265 published — release tags are pushed before the artifact-build\n\
2266 matrix completes, so a 404 here often just means the build is\n\
2267 still in flight. Check the release page or retry in a few\n\
2268 minutes:\n\
2269 \n\
2270 \x20 https://github.com/auser/mvm/releases/tag/v{version}\n\
2271 \n\
2272 To build locally instead, set up a Nix Linux builder:\n\
2273 \n\
2274 \x20 Option 1 — Temporary (run in another terminal):\n\
2275 \x20 nix run 'nixpkgs#darwin.linux-builder'\n\
2276 \n\
2277 \x20 Option 2 — Permanent (add to /etc/nix/nix.conf):\n\
2278 \x20 builders = ssh-ng://builder@linux-builder aarch64-linux /etc/nix/builder_ed25519 4 1 kvm,big-parallel - -\n\
2279 \x20 builders-use-substitutes = true",
2280 version = env!("CARGO_PKG_VERSION")
2281 );
2282 }
2283 Ok(())
2284}
2285
2286fn find_nix_binary() -> String {
2288 if which::which("nix").is_ok() {
2289 return "nix".to_string();
2290 }
2291 for path in &[
2292 "/nix/var/nix/profiles/default/bin/nix",
2293 "/run/current-system/sw/bin/nix",
2294 ] {
2295 if std::path::Path::new(path).exists() {
2296 return path.to_string();
2297 }
2298 }
2299 "nix".to_string() }
2301
2302fn find_dev_image_flake() -> Result<String> {
2304 let manifest_dir = env!("CARGO_MANIFEST_DIR");
2306 let workspace_root = std::path::Path::new(manifest_dir)
2307 .parent()
2308 .and_then(|p| p.parent())
2309 .ok_or_else(|| anyhow::anyhow!("Cannot find workspace root"))?;
2310
2311 let candidate = workspace_root.join("nix").join("dev-image");
2312 if candidate.join("flake.nix").exists() {
2313 return Ok(candidate.to_str().unwrap_or(".").to_string());
2314 }
2315
2316 let guest_lib = workspace_root.join("nix").join("guest-lib");
2318 if guest_lib.join("flake.nix").exists() {
2319 return Ok(guest_lib.to_str().unwrap_or(".").to_string());
2320 }
2321
2322 anyhow::bail!(
2323 "Dev image flake not found. Expected at nix/dev-image/flake.nix\n\
2324 or nix/guest-lib/flake.nix"
2325 )
2326}
2327
2328fn find_default_microvm_flake() -> Result<String> {
2334 let manifest_dir = env!("CARGO_MANIFEST_DIR");
2335 let workspace_root = std::path::Path::new(manifest_dir)
2336 .parent()
2337 .and_then(|p| p.parent())
2338 .ok_or_else(|| anyhow::anyhow!("Cannot find workspace root"))?;
2339
2340 let candidate = workspace_root.join("nix").join("default-microvm");
2341 if candidate.join("flake.nix").exists() {
2342 return Ok(candidate.to_str().unwrap_or(".").to_string());
2343 }
2344 anyhow::bail!(
2345 "Default microVM image flake not found. Expected at nix/default-microvm/flake.nix"
2346 )
2347}
2348
2349pub(crate) fn ensure_default_microvm_image() -> Result<(String, String)> {
2359 let cache_dir = format!("{}/default-microvm", mvm_core::config::mvm_cache_dir());
2360 std::fs::create_dir_all(&cache_dir)?;
2361
2362 let kernel_path = format!("{cache_dir}/vmlinux");
2363 let rootfs_path = format!("{cache_dir}/rootfs.ext4");
2364
2365 if std::path::Path::new(&kernel_path).exists() && std::path::Path::new(&rootfs_path).exists() {
2366 return Ok((kernel_path, rootfs_path));
2367 }
2368
2369 let plat = mvm_core::platform::current();
2370 if plat.has_host_nix()
2371 && let Ok(flake_dir) = find_default_microvm_flake()
2372 {
2373 let nix_bin = find_nix_binary();
2374
2375 if cfg!(target_os = "macos") && ensure_linux_builder_ssh_config() {
2376 ui::info(" Linux builder detected and SSH configured.");
2377 }
2378
2379 ui::info("Building default microVM image via Nix (first time only)...");
2380 let mut child = std::process::Command::new(&nix_bin)
2381 .args([
2382 "build",
2383 &format!(
2384 "{flake_dir}#packages.{}.default",
2385 mvm_build::dev_build::linux_system()
2386 ),
2387 "--no-link",
2388 "--print-out-paths",
2389 ])
2390 .stdout(std::process::Stdio::piped())
2391 .stderr(std::process::Stdio::inherit())
2392 .spawn()
2393 .context("Failed to run nix build")?;
2394
2395 let stdout = {
2396 let mut buf = String::new();
2397 if let Some(mut out) = child.stdout.take() {
2398 use std::io::Read;
2399 let _ = out.read_to_string(&mut buf);
2400 }
2401 buf
2402 };
2403 let status = child.wait().context("nix build process failed")?;
2404
2405 if status.success() {
2406 let store_path = stdout.trim().to_string();
2407 let ks = format!("{store_path}/vmlinux");
2408 let rs = format!("{store_path}/rootfs.ext4");
2409 if std::path::Path::new(&ks).exists() && std::path::Path::new(&rs).exists() {
2410 std::fs::copy(&ks, &kernel_path)?;
2411 std::fs::copy(&rs, &rootfs_path)?;
2412 ui::success("Default microVM image built and cached.");
2413 return Ok((kernel_path, rootfs_path));
2414 }
2415 }
2416
2417 ui::warn("Local Nix build failed; falling back to pre-built download.");
2418 }
2419
2420 download_default_microvm_image(&kernel_path, &rootfs_path)
2421}
2422
2423fn download_default_microvm_image(
2426 kernel_path: &str,
2427 rootfs_path: &str,
2428) -> Result<(String, String)> {
2429 let version = env!("CARGO_PKG_VERSION");
2430 let base_url = format!("https://github.com/auser/mvm/releases/download/v{version}");
2431 let arch = if cfg!(target_arch = "aarch64") {
2432 "aarch64"
2433 } else {
2434 "x86_64"
2435 };
2436 let kernel_url = format!("{base_url}/default-microvm-vmlinux-{arch}");
2437 let rootfs_url = format!("{base_url}/default-microvm-rootfs-{arch}.ext4");
2438
2439 ui::info(&format!(
2440 "Downloading default microVM image (v{version})..."
2441 ));
2442
2443 ui::info(" Fetching kernel...");
2444 download_file(&kernel_url, kernel_path)
2445 .with_context(|| format!("Failed to download kernel from {kernel_url}"))?;
2446
2447 ui::info(" Fetching rootfs...");
2448 download_file(&rootfs_url, rootfs_path)
2449 .with_context(|| format!("Failed to download rootfs from {rootfs_url}"))?;
2450
2451 ui::success("Default microVM image downloaded and cached.");
2452 Ok((kernel_path.to_string(), rootfs_path.to_string()))
2453}
2454
2455fn run_setup_steps(force: bool, lima_cpus: u32, lima_mem: u32) -> Result<()> {
2456 let total = 5;
2457
2458 if bootstrap::is_lima_required() {
2460 let lima_status = lima::get_status()?;
2461 if !force && matches!(lima_status, lima::LimaStatus::Running) {
2462 ui::step(1, total, "Lima VM already running — skipping.");
2463 } else {
2464 let opts = config::LimaRenderOptions {
2465 cpus: Some(lima_cpus),
2466 memory_gib: Some(lima_mem),
2467 ..Default::default()
2468 };
2469 let lima_yaml = config::render_lima_yaml_with(&opts)?;
2470 ui::info(&format!(
2471 "Lima VM resources: {} vCPUs, {} GiB memory",
2472 lima_cpus, lima_mem,
2473 ));
2474 ui::step(1, total, "Setting up Lima VM...");
2475 lima::ensure_running(lima_yaml.path())?;
2476 }
2477 } else {
2478 ui::step(1, total, "Native Linux detected — skipping Lima VM setup.");
2479 }
2480
2481 if !force && firecracker::is_installed()? {
2483 ui::step(2, total, "Firecracker already installed — skipping.");
2484 } else {
2485 ui::step(2, total, "Installing Firecracker...");
2486 firecracker::install()?;
2487 }
2488
2489 if !force && firecracker::has_base_assets()? {
2491 ui::step(
2492 3,
2493 total,
2494 "Kernel and rootfs already present \u{2014} skipping.",
2495 );
2496 } else {
2497 ui::step(3, total, "Downloading kernel and rootfs...");
2498 firecracker::download_assets()?;
2499 }
2500
2501 if firecracker::has_squashfs()? && !firecracker::validate_rootfs_squashfs()? {
2502 ui::warn("Downloaded rootfs is corrupted. Re-downloading...");
2503 shell::run_in_vm(&format!(
2504 "rm -f {dir}/ubuntu-*.squashfs.upstream",
2505 dir = config::MICROVM_DIR,
2506 ))?;
2507 firecracker::download_assets()?;
2508 }
2509
2510 ui::step(4, total, "Preparing root filesystem...");
2512 firecracker::prepare_rootfs()?;
2513
2514 firecracker::write_state()?;
2515
2516 ui::step(5, total, "Setting up security baseline...");
2518 setup_security_baseline()?;
2519
2520 Ok(())
2521}
2522
2523fn setup_security_baseline() -> Result<()> {
2527 use mvm_runtime::security::{jailer, seccomp};
2528
2529 seccomp::ensure_strict_profile()?;
2531 ui::info(" Seccomp strict profile deployed.");
2532
2533 shell::run_in_vm("sudo mkdir -p /var/lib/mvm/tenants")?;
2535 ui::info(" Audit log directory created.");
2536
2537 match jailer::jailer_available() {
2539 Ok(true) => ui::info(" Jailer binary available."),
2540 _ => ui::warn(" Jailer binary not found (may not be in this Firecracker release)."),
2541 }
2542
2543 Ok(())
2544}
2545
2546fn shell_escape(s: &str) -> String {
2547 if s.contains(' ') || s.contains('\'') || s.contains('"') {
2548 format!("'{}'", s.replace('\'', "'\\''"))
2549 } else {
2550 s.to_string()
2551 }
2552}
2553
2554fn cmd_shell(project: Option<&str>) -> Result<()> {
2555 lima::require_running()?;
2556
2557 let fc_ver =
2559 shell::run_in_vm_stdout("firecracker --version 2>/dev/null | head -1").unwrap_or_default();
2560 let nix_ver = shell::run_in_vm_stdout("nix --version 2>/dev/null").unwrap_or_default();
2561
2562 ui::info("mvmctl development shell");
2563 ui::info(&format!(
2564 " Firecracker: {}",
2565 if fc_ver.trim().is_empty() {
2566 "not installed"
2567 } else {
2568 fc_ver.trim()
2569 }
2570 ));
2571 ui::info(&format!(
2572 " Nix: {}",
2573 if nix_ver.trim().is_empty() {
2574 "not installed"
2575 } else {
2576 nix_ver.trim()
2577 }
2578 ));
2579 let mvm_in_vm = shell::run_in_vm_stdout("test -f /usr/local/bin/mvmctl && echo yes || echo no")
2580 .unwrap_or_default();
2581 if mvm_in_vm.trim() == "yes" {
2582 let mvm_ver = shell::run_in_vm_stdout("/usr/local/bin/mvmctl --version 2>/dev/null")
2583 .unwrap_or_default();
2584 ui::info(&format!(
2585 " mvmctl: {}",
2586 if mvm_ver.trim().is_empty() {
2587 "installed"
2588 } else {
2589 mvm_ver.trim()
2590 }
2591 ));
2592 } else {
2593 ui::warn(" mvmctl not installed in VM. Run 'mvmctl sync' to build and install it.");
2594 }
2595
2596 ui::info(&format!(" Lima VM: {}\n", config::VM_NAME));
2597
2598 if let Err(e) = shell_init::ensure_shell_init_in_vm() {
2601 ui::warn(&format!("Shell init in VM failed: {e}"));
2602 }
2603
2604 match project {
2605 Some(path) => {
2606 let cmd = format!("cd {} && exec bash -l", shell_escape(path));
2607 shell::replace_process("limactl", &["shell", config::VM_NAME, "bash", "-c", &cmd])
2608 }
2609 None => shell::replace_process("limactl", &["shell", config::VM_NAME, "bash", "-l"]),
2610 }
2611}
2612
2613fn cmd_cleanup(keep: Option<usize>, all: bool, verbose: bool) -> Result<()> {
2614 let keep_count = if all { 0 } else { keep.unwrap_or(5) };
2615
2616 if !all && keep_count == 0 {
2617 anyhow::bail!("--keep must be greater than 0 (or use --all)");
2618 }
2619
2620 let disk_before = vm_disk_usage_pct();
2622 if let Some(pct) = disk_before {
2623 ui::info(&format!("Lima VM disk usage: {}%", pct));
2624 }
2625
2626 ui::info("Clearing temporary files...");
2629 let _ = shell::run_in_vm("sudo rm -rf /tmp/* /var/tmp/* 2>/dev/null");
2630
2631 let env = mvm_runtime::build_env::RuntimeBuildEnv;
2633 let report = mvm_build::dev_build::cleanup_old_dev_builds(&env, keep_count)?;
2634
2635 if verbose {
2636 if report.removed_paths.is_empty() {
2637 ui::info("No cached build paths removed.");
2638 } else {
2639 ui::info("Removed cached build paths:");
2640 for path in &report.removed_paths {
2641 println!(" {}", path);
2642 }
2643 }
2644 }
2645
2646 if all {
2647 ui::success(&format!(
2648 "Removed {} cached build(s).",
2649 report.removed_count
2650 ));
2651 } else {
2652 ui::success(&format!(
2653 "Removed {} cached build(s), kept newest {}.",
2654 report.removed_count, keep_count
2655 ));
2656 }
2657
2658 ui::info("Running nix-collect-garbage...");
2660 match shell::run_in_vm_stdout("nix-collect-garbage -d 2>&1 | tail -3") {
2661 Ok(output) => {
2662 let trimmed = output.trim();
2663 if !trimmed.is_empty() {
2664 println!("{trimmed}");
2665 }
2666 }
2667 Err(e) => {
2668 ui::warn(&format!("nix-collect-garbage failed: {e}"));
2671 ui::info("Retrying after clearing Nix profile generations...");
2672 let _ = shell::run_in_vm("rm -rf ~/.local/state/nix/profiles/* 2>/dev/null");
2673 match shell::run_in_vm_stdout("nix-collect-garbage -d 2>&1 | tail -3") {
2674 Ok(output) => {
2675 let trimmed = output.trim();
2676 if !trimmed.is_empty() {
2677 println!("{trimmed}");
2678 }
2679 }
2680 Err(e2) => ui::warn(&format!("nix-collect-garbage retry failed: {e2}")),
2681 }
2682 }
2683 }
2684
2685 let disk_after = vm_disk_usage_pct();
2687 if let Some(pct) = disk_after {
2688 let freed_msg = match disk_before {
2689 Some(before) if before > pct => format!(" (freed {}%)", before - pct),
2690 _ => String::new(),
2691 };
2692 ui::success(&format!("Lima VM disk usage: {}%{}", pct, freed_msg));
2693 }
2694
2695 Ok(())
2696}
2697
2698fn vm_disk_usage_pct() -> Option<u8> {
2700 let output = shell::run_in_vm_stdout("df --output=pcent / 2>/dev/null | tail -1").ok()?;
2701 output.trim().trim_end_matches('%').trim().parse().ok()
2702}
2703
2704fn cmd_logs(name: &str, follow: bool, lines: u32, hypervisor: bool) -> Result<()> {
2705 validate_vm_name(name).with_context(|| format!("Invalid VM name: {:?}", name))?;
2706 microvm::logs(name, follow, lines, hypervisor)
2707}
2708
2709fn cmd_diff(name: &str, json: bool) -> Result<()> {
2710 validate_vm_name(name).with_context(|| format!("Invalid VM name: {:?}", name))?;
2711
2712 let instance_dir = microvm::resolve_running_vm_dir(name)?;
2713 let changes = mvm_guest::vsock::query_fs_diff(&instance_dir)?;
2714
2715 if json {
2716 println!("{}", serde_json::to_string_pretty(&changes)?);
2717 } else if changes.is_empty() {
2718 ui::info("No filesystem changes detected.");
2719 } else {
2720 ui::info(&format!("{} change(s):", changes.len()));
2721 for change in &changes {
2722 let prefix = match change.kind {
2723 mvm_guest::vsock::FsChangeKind::Created => "+",
2724 mvm_guest::vsock::FsChangeKind::Modified => "~",
2725 mvm_guest::vsock::FsChangeKind::Deleted => "-",
2726 };
2727 if change.size > 0 {
2728 println!(
2729 " {} {} ({})",
2730 prefix,
2731 change.path,
2732 human_bytes(change.size)
2733 );
2734 } else {
2735 println!(" {} {}", prefix, change.path);
2736 }
2737 }
2738 }
2739
2740 Ok(())
2741}
2742
2743fn human_bytes(bytes: u64) -> String {
2744 if bytes < 1024 {
2745 format!("{bytes}B")
2746 } else if bytes < 1024 * 1024 {
2747 format!("{:.1}K", bytes as f64 / 1024.0)
2748 } else if bytes < 1024 * 1024 * 1024 {
2749 format!("{:.1}M", bytes as f64 / (1024.0 * 1024.0))
2750 } else {
2751 format!("{:.1}G", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
2752 }
2753}
2754
2755fn wait_for_guest_agent(vm_id: &str, timeout_secs: u64) -> bool {
2758 use std::io::{Read, Write};
2759 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
2760 let ping = serde_json::to_vec(&mvm_guest::vsock::GuestRequest::Ping).unwrap_or_default();
2761 let len_bytes = (ping.len() as u32).to_be_bytes();
2762
2763 while std::time::Instant::now() < deadline {
2764 if let Ok(mut s) =
2765 mvm_apple_container::vsock_connect(vm_id, mvm_guest::vsock::GUEST_AGENT_PORT)
2766 && s.write_all(&len_bytes).is_ok()
2767 && s.write_all(&ping).is_ok()
2768 && s.flush().is_ok()
2769 {
2770 let mut resp_len = [0u8; 4];
2771 if s.read_exact(&mut resp_len).is_ok() {
2772 return true;
2773 }
2774 }
2775 std::thread::sleep(std::time::Duration::from_millis(500));
2776 }
2777 false
2778}
2779
2780fn request_port_forward(vm_id: &str, guest_port: u16) -> Result<u32> {
2782 let mut stream = mvm_apple_container::vsock_connect(vm_id, mvm_guest::vsock::GUEST_AGENT_PORT)
2783 .map_err(|e| anyhow::anyhow!("{e}"))?;
2784 mvm_guest::vsock::start_port_forward_on(&mut stream, guest_port)
2785}
2786
2787fn cmd_forward(name: &str, port_specs: &[String]) -> Result<()> {
2796 validate_vm_name(name).with_context(|| format!("Invalid VM name: {:?}", name))?;
2797 let _abs_dir = resolve_running_vm(name)?;
2799
2800 let info = microvm::read_vm_run_info(name)?;
2802
2803 let parsed: Vec<(u16, u16)> = if port_specs.is_empty() {
2805 if info.ports.is_empty() {
2806 anyhow::bail!(
2807 "VM '{}' has no port mappings configured.\n\
2808 Specify ports: mvmctl forward {} <PORT>...\n\
2809 Or declare ports in mvm.toml.",
2810 name,
2811 name,
2812 );
2813 }
2814 ui::info("Using port mappings from VM config.");
2815 info.ports.iter().map(|p| (p.host, p.guest)).collect()
2816 } else {
2817 port_specs
2818 .iter()
2819 .map(|s| parse_port_spec(s))
2820 .collect::<Result<_>>()?
2821 };
2822 let guest_ip = info
2823 .guest_ip
2824 .as_deref()
2825 .filter(|s| !s.is_empty())
2826 .ok_or_else(|| {
2827 anyhow::anyhow!(
2828 "VM '{}' has no guest_ip in run-info. Was it started with 'mvmctl run'?",
2829 name,
2830 )
2831 })?;
2832
2833 for &(local_port, guest_port) in &parsed {
2834 ui::info(&format!(
2835 "Forwarding localhost:{} -> {}:{} (VM '{}')",
2836 local_port, guest_ip, guest_port, name,
2837 ));
2838 }
2839 ui::info("Press Ctrl-C to stop forwarding.");
2840
2841 if bootstrap::is_lima_required() {
2842 lima::require_running()?;
2845 let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
2846 let ssh_config = format!("{}/.lima/{}/ssh.config", home, config::VM_NAME);
2847
2848 let mut cmd = std::process::Command::new("ssh");
2849 cmd.arg("-F").arg(&ssh_config).arg("-N"); for &(local_port, guest_port) in &parsed {
2851 cmd.arg("-L")
2852 .arg(format!("{}:{}:{}", local_port, guest_ip, guest_port));
2853 }
2854 cmd.arg(format!("lima-{}", config::VM_NAME));
2855
2856 let status = cmd
2857 .status()
2858 .context("Failed to start SSH port forward. Is Lima running?")?;
2859
2860 if !status.success() {
2861 anyhow::bail!("SSH port forward exited with status {}", status);
2862 }
2863 } else {
2864 let mut children: Vec<std::process::Child> = Vec::new();
2867 for &(local_port, guest_port) in &parsed {
2868 let child = std::process::Command::new("socat")
2869 .arg(format!("TCP-LISTEN:{},fork,reuseaddr", local_port))
2870 .arg(format!("TCP:{}:{}", guest_ip, guest_port))
2871 .spawn()
2872 .context("Failed to start socat. Install it with: sudo apt install socat")?;
2873 if let Ok(mut pids) = CHILD_PIDS.lock() {
2875 pids.push(child.id());
2876 }
2877 children.push(child);
2878 }
2879 for mut child in children {
2882 if let Err(e) = child.wait() {
2883 tracing::warn!("failed to wait on socat child: {e}");
2884 }
2885 }
2886 if let Ok(mut pids) = CHILD_PIDS.lock() {
2888 pids.clear();
2889 }
2890 }
2891
2892 Ok(())
2893}
2894
2895fn parse_port_spec(spec: &str) -> Result<(u16, u16)> {
2897 if let Some((local, guest)) = spec.split_once(':') {
2898 let local: u16 = local
2899 .parse()
2900 .with_context(|| format!("invalid local port '{}'", local))?;
2901 let guest: u16 = guest
2902 .parse()
2903 .with_context(|| format!("invalid guest port '{}'", guest))?;
2904 Ok((local, guest))
2905 } else {
2906 let port: u16 = spec
2907 .parse()
2908 .with_context(|| format!("invalid port '{}'", spec))?;
2909 Ok((port, port))
2910 }
2911}
2912
2913fn parse_port_specs(specs: &[String]) -> Result<Vec<mvm_runtime::config::PortMapping>> {
2915 specs
2916 .iter()
2917 .map(|s| {
2918 let (host, guest) = parse_port_spec(s)?;
2919 Ok(mvm_runtime::config::PortMapping { host, guest })
2920 })
2921 .collect()
2922}
2923
2924fn ports_to_drive_file(ports: &[mvm_runtime::config::PortMapping]) -> Option<microvm::DriveFile> {
2927 if ports.is_empty() {
2928 return None;
2929 }
2930 let map_str = ports
2931 .iter()
2932 .map(|p| format!("{}:{}", p.host, p.guest))
2933 .collect::<Vec<_>>()
2934 .join(",");
2935 Some(microvm::DriveFile {
2936 name: "mvm-ports.env".to_string(),
2937 content: format!("export MVM_PORT_MAP=\"{}\"\n", map_str),
2938 mode: 0o444,
2939 })
2940}
2941
2942fn env_vars_to_drive_file(env_vars: &[String]) -> Option<microvm::DriveFile> {
2944 if env_vars.is_empty() {
2945 return None;
2946 }
2947 let content = env_vars
2948 .iter()
2949 .map(|kv| format!("export {}", kv))
2950 .collect::<Vec<_>>()
2951 .join("\n");
2952 Some(microvm::DriveFile {
2953 name: "mvm-env.env".to_string(),
2954 content: format!("{}\n", content),
2955 mode: 0o444,
2956 })
2957}
2958
2959fn cmd_ls(_all: bool, json: bool) -> Result<()> {
2960 use mvm_core::vm_backend::VmInfo;
2961
2962 let mut all_vms: Vec<VmInfo> = Vec::new();
2963
2964 let ac_backend = AnyBackend::from_hypervisor("apple-container");
2966 if let Ok(vms) = ac_backend.list() {
2967 all_vms.extend(vms);
2968 }
2969
2970 let docker_backend = AnyBackend::from_hypervisor("docker");
2972 if let Ok(vms) = docker_backend.list() {
2973 all_vms.extend(vms);
2974 }
2975
2976 if bootstrap::is_lima_required() {
2978 if let Ok(lima::LimaStatus::Running) = lima::get_status() {
2979 let fc_backend = AnyBackend::from_hypervisor("firecracker");
2980 if let Ok(vms) = fc_backend.list() {
2981 all_vms.extend(vms);
2982 }
2983 }
2984 } else {
2985 let fc_backend = AnyBackend::from_hypervisor("firecracker");
2987 if let Ok(vms) = fc_backend.list() {
2988 all_vms.extend(vms);
2989 }
2990 }
2991
2992 if json {
2993 println!("{}", serde_json::to_string_pretty(&all_vms)?);
2994 return Ok(());
2995 }
2996
2997 if all_vms.is_empty() {
2998 println!("No running VMs.");
2999 return Ok(());
3000 }
3001
3002 println!(
3004 "{:<20} {:<18} {:<10} {:<8} {:<10} {:<20} IMAGE",
3005 "NAME", "BACKEND", "STATUS", "CPUS", "MEMORY", "PORTS"
3006 );
3007 for vm in &all_vms {
3008 let backend_name = if vm.flake_ref.as_deref().is_some() {
3009 if mvm_core::platform::current().has_apple_containers() {
3011 "apple-container"
3012 } else {
3013 "firecracker"
3014 }
3015 } else {
3016 "unknown"
3017 };
3018 let status = format!("{:?}", vm.status);
3019 let mem = if vm.memory_mib > 0 {
3020 format!("{}Mi", vm.memory_mib)
3021 } else {
3022 "-".to_string()
3023 };
3024 let image = vm
3025 .flake_ref
3026 .as_deref()
3027 .or(vm.profile.as_deref())
3028 .unwrap_or("-");
3029 let ports = if vm.ports.is_empty() {
3030 "-".to_string()
3031 } else {
3032 vm.ports
3033 .iter()
3034 .map(|p| format!("{}→{}", p.host, p.guest))
3035 .collect::<Vec<_>>()
3036 .join(", ")
3037 };
3038 println!(
3039 "{:<20} {:<18} {:<10} {:<8} {:<10} {:<20} {}",
3040 vm.name,
3041 backend_name,
3042 status,
3043 if vm.cpus > 0 {
3044 vm.cpus.to_string()
3045 } else {
3046 "-".to_string()
3047 },
3048 mem,
3049 ports,
3050 image,
3051 );
3052 }
3053
3054 Ok(())
3055}
3056
3057fn cmd_update(check: bool, force: bool, skip_verify: bool) -> Result<()> {
3058 let result = update::update(check, force, skip_verify);
3059 if result.is_ok() && !check {
3060 mvm_core::audit::emit(mvm_core::audit::LocalAuditKind::UpdateInstall, None, None);
3061 }
3062 result
3063}
3064
3065fn cmd_doctor(json: bool) -> Result<()> {
3066 crate::doctor::run(json)
3067}
3068
3069fn with_hints(result: Result<()>) -> Result<()> {
3075 if let Err(ref e) = result {
3076 let msg = format!("{:#}", e);
3077 if msg.contains("limactl: command not found") || msg.contains("limactl: not found") {
3078 ui::warn("Hint: Install Lima with 'brew install lima' or run 'mvmctl bootstrap'.");
3079 } else if msg.contains("firecracker: command not found")
3080 || msg.contains("firecracker: not found")
3081 {
3082 ui::warn("Hint: Run 'mvmctl setup' to install Firecracker.");
3083 } else if msg.contains("/dev/kvm") {
3084 ui::warn(
3085 "Hint: Enable KVM/virtualization in your BIOS or VM settings.\n \
3086 On macOS, KVM is available inside the Lima VM.",
3087 );
3088 } else if msg.contains("Permission denied") && msg.contains(".mvm") {
3089 ui::warn("Hint: Check directory permissions on ~/.mvm (set MVM_DATA_DIR to override).");
3090 } else if msg.contains("nix: command not found") || msg.contains("nix: not found") {
3091 ui::warn("Hint: Nix is installed inside the Lima VM. Run 'mvmctl shell' first.");
3092 } else if msg.contains("Lima VM is not running") || msg.contains("VM is not started") {
3093 ui::warn(
3094 "Hint: Start the dev environment with 'mvmctl dev' or run 'mvmctl setup' \
3095 to initialise it first.",
3096 );
3097 } else if msg.contains("already exists") && msg.contains("template") {
3098 ui::warn("Hint: Use '--force' to overwrite the existing template.");
3099 } else if msg.contains("error: builder for") && msg.contains("failed with exit code") {
3100 ui::warn(
3101 "Hint: Nix build failed. Check the log above for the failing derivation.\n \
3102 Common fixes: ensure flake inputs are up to date ('nix flake update'), \
3103 or check your flake.nix for syntax errors.",
3104 );
3105 } else if msg.contains("does not provide attribute")
3106 || msg.contains("flake has no")
3107 || msg.contains("does not provide a package")
3108 {
3109 ui::warn(
3110 "Hint: Flake attribute not found. Your flake.lock may be stale.\n \
3111 Try: nix flake update (inside the Lima VM or flake directory).",
3112 );
3113 } else if msg.contains("No space left on device") || msg.contains("ENOSPC") {
3114 ui::warn(
3115 "Hint: Disk full. Run 'mvmctl doctor' to check space, \
3116 or run 'nix-collect-garbage -d' inside the Lima VM.",
3117 );
3118 } else if msg.contains("timed out") || msg.contains("connection refused") {
3119 ui::warn(
3120 "Hint: The Lima VM may be unresponsive. Try 'mvmctl status' or \
3121 restart with 'mvmctl stop && mvmctl dev'.",
3122 );
3123 } else if msg.contains("hash mismatch") && msg.contains("got:") {
3124 ui::warn(
3125 "Hint: Fixed-output derivation hash changed. Run \
3126 'mvmctl template build <name> --update-hash' to recompute.",
3127 );
3128 } else if msg.contains("does it exist?") && msg.contains("template") {
3129 ui::warn("Hint: List available templates with 'mvmctl template list'.");
3130 }
3131 }
3132 result
3133}
3134
3135fn cmd_build(path: &str, output: Option<&str>) -> Result<()> {
3136 let elf_path = image::build(path, output)?;
3137 ui::success(&format!("\nImage ready: {}", elf_path));
3138 ui::info(&format!("Run with: mvmctl start {}", elf_path));
3139 Ok(())
3140}
3141
3142fn cmd_build_flake(flake_ref: &str, profile: Option<&str>, watch: bool, json: bool) -> Result<()> {
3143 validate_flake_ref(flake_ref)
3144 .with_context(|| format!("Invalid flake reference: {:?}", flake_ref))?;
3145
3146 let build_env = mvm_runtime::build_env::default_build_env();
3147 let env = build_env.as_ref();
3148
3149 let using_host_nix = mvm_core::platform::current().has_host_nix();
3151 if !using_host_nix && bootstrap::is_lima_required() {
3152 lima::require_running()?;
3153 }
3154
3155 let resolved = resolve_flake_ref(flake_ref)?;
3156 let watch_enabled = watch && !resolved.contains(':');
3157
3158 if watch && resolved.contains(':') && !json {
3159 ui::warn("Watch mode requires a local flake; running a single build instead.");
3160 }
3161
3162 loop {
3163 let profile_display = profile.unwrap_or("default");
3164
3165 if json {
3166 PhaseEvent::new("build", "nix-build", "started")
3167 .with_message(&format!("flake={} profile={}", resolved, profile_display))
3168 .emit();
3169 } else {
3170 ui::step(
3171 1,
3172 2,
3173 &format!("Building flake {} (profile={})", resolved, profile_display),
3174 );
3175 }
3176
3177 let result = match mvm_build::dev_build::dev_build(env, &resolved, profile) {
3178 Ok(r) => r,
3179 Err(e) => {
3180 if json {
3181 PhaseEvent::new("build", "nix-build", "failed")
3182 .with_error(&format!("{:#}", e))
3183 .emit();
3184 }
3185 return Err(e);
3186 }
3187 };
3188 if let Err(e) = mvm_build::dev_build::ensure_guest_agent_if_needed(env, &result) {
3189 ui::warn(&format!(
3190 "Could not verify guest agent ({}). If built with mkGuest, the agent is already included.",
3191 e
3192 ));
3193 }
3194
3195 if json {
3196 #[derive(Serialize)]
3197 struct BuildResult {
3198 timestamp: String,
3199 command: &'static str,
3200 phase: &'static str,
3201 status: &'static str,
3202 revision: String,
3203 cached: bool,
3204 kernel: String,
3205 rootfs: String,
3206 }
3207 let event = BuildResult {
3208 timestamp: chrono::Utc::now().to_rfc3339(),
3209 command: "build",
3210 phase: "nix-build",
3211 status: "completed",
3212 revision: result.revision_hash.clone(),
3213 cached: result.cached,
3214 kernel: result.vmlinux_path.clone(),
3215 rootfs: result.rootfs_path.clone(),
3216 };
3217 if let Ok(j) = serde_json::to_string(&event) {
3218 println!("{}", j);
3219 }
3220 } else {
3221 ui::step(2, 2, "Build complete");
3222
3223 if result.cached {
3224 ui::success(&format!("\nCache hit — revision {}", result.revision_hash));
3225 } else {
3226 ui::success(&format!(
3227 "\nBuild complete — revision {}",
3228 result.revision_hash
3229 ));
3230 }
3231
3232 ui::info(&format!(" Kernel: {}", result.vmlinux_path));
3233 ui::info(&format!(" Rootfs: {}", result.rootfs_path));
3234 ui::info(&format!("\nRun with: mvmctl run --flake {}", flake_ref));
3235 }
3236
3237 if !watch_enabled {
3238 return Ok(());
3239 }
3240
3241 if !json {
3243 ui::info("Watching for .nix and .lock changes (Ctrl+C to exit)...");
3244 }
3245 match crate::watch::wait_for_changes(&resolved) {
3246 Ok(trigger) => {
3247 if !json {
3248 let display = crate::watch::display_trigger(&trigger, &resolved);
3249 ui::info(&format!("\nChange detected: {display} — rebuilding..."));
3250 }
3251 }
3252 Err(e) => {
3253 if !json {
3254 ui::warn(&format!("Watch error: {e} — falling back to single build"));
3255 }
3256 return Ok(());
3257 }
3258 }
3259 }
3260}
3261
3262fn resolve_network_policy(
3265 preset: Option<&str>,
3266 allow: &[String],
3267) -> Result<mvm_core::network_policy::NetworkPolicy> {
3268 use mvm_core::network_policy::{HostPort, NetworkPolicy, NetworkPreset};
3269
3270 match (preset, allow.is_empty()) {
3271 (Some(_), false) => {
3272 anyhow::bail!("--network-preset and --network-allow are mutually exclusive")
3273 }
3274 (Some(name), true) => {
3275 let p: NetworkPreset = name.parse()?;
3276 Ok(NetworkPolicy::preset(p))
3277 }
3278 (None, false) => {
3279 let rules: Vec<HostPort> = allow
3280 .iter()
3281 .map(|s| s.parse())
3282 .collect::<Result<Vec<_>>>()?;
3283 Ok(NetworkPolicy::allow_list(rules))
3284 }
3285 (None, true) => Ok(NetworkPolicy::default()),
3286 }
3287}
3288
3289fn resolve_flake_ref(flake_ref: &str) -> Result<String> {
3292 if flake_ref.contains(':') {
3293 return Ok(flake_ref.to_string());
3295 }
3296
3297 let path = std::path::Path::new(flake_ref);
3299 let canonical = path
3300 .canonicalize()
3301 .with_context(|| format!("Flake path '{}' does not exist", flake_ref))?;
3302
3303 Ok(canonical.to_string_lossy().to_string())
3304}
3305
3306struct RunParams<'a> {
3307 flake_ref: Option<&'a str>,
3308 template_name: Option<&'a str>,
3309 name: Option<&'a str>,
3310 profile: Option<&'a str>,
3311 cpus: Option<u32>,
3312 memory: Option<u32>,
3313 config_path: Option<&'a str>,
3314 volumes: &'a [String],
3315 hypervisor: &'a str,
3316 ports: &'a [String],
3317 env_vars: &'a [String],
3318 forward: bool,
3319 metrics_port: u16,
3320 watch_config: bool,
3321 watch: bool,
3322 detach: bool,
3323 network_policy: mvm_core::network_policy::NetworkPolicy,
3324 network_name: &'a str,
3325 seccomp_tier: mvm_security::seccomp::SeccompTier,
3326 secret_bindings: Vec<mvm_core::secret_binding::SecretBinding>,
3327}
3328
3329fn cmd_run(params: RunParams<'_>) -> Result<()> {
3330 let RunParams {
3331 flake_ref,
3332 template_name,
3333 name,
3334 profile,
3335 cpus,
3336 memory,
3337 config_path,
3338 volumes,
3339 hypervisor,
3340 ports,
3341 env_vars,
3342 forward,
3343 metrics_port,
3344 watch_config,
3345 watch,
3346 detach,
3347 network_policy,
3348 network_name,
3349 seccomp_tier,
3350 secret_bindings,
3351 } = params;
3352 let _span =
3353 tracing::info_span!("cmd_run", name = ?name, cpus = ?cpus, memory_mib = ?memory).entered();
3354 if let Some(n) = name {
3355 validate_vm_name(n).with_context(|| format!("Invalid VM name: {:?}", n))?;
3356 }
3357 if let Some(f) = flake_ref {
3358 validate_flake_ref(f).with_context(|| format!("Invalid flake reference: {:?}", f))?;
3359 }
3360 if let Some(t) = template_name {
3361 validate_template_name(t).with_context(|| format!("Invalid template name: {:?}", t))?;
3362 }
3363 let effective_hypervisor = if hypervisor == "firecracker" {
3366 let plat = mvm_core::platform::current();
3367 if plat.has_kvm() {
3368 "firecracker" } else if plat.has_apple_containers() {
3370 "apple-container" } else if plat.has_docker() {
3372 "docker" } else {
3374 "firecracker" }
3376 } else {
3377 hypervisor
3378 };
3379
3380 let needs_lima = effective_hypervisor != "apple-container"
3383 && effective_hypervisor != "docker"
3384 && bootstrap::is_lima_required();
3385 if needs_lima {
3386 lima::require_running()?;
3387 }
3388 let _metrics_server = if metrics_port > 0 {
3389 Some(crate::metrics_server::MetricsServer::start(metrics_port)?)
3390 } else {
3391 None
3392 };
3393
3394 let _config_watcher = if watch_config {
3397 let config_path = {
3398 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
3399 std::path::PathBuf::from(home)
3400 .join(".mvm")
3401 .join("config.toml")
3402 };
3403 if config_path.exists() {
3404 match crate::config_watcher::ConfigWatcher::start(&config_path) {
3405 Ok(w) => {
3406 tracing::info!("Watching ~/.mvm/config.toml for changes");
3407 Some(w)
3408 }
3409 Err(e) => {
3410 tracing::warn!("Could not start config watcher: {e}");
3411 None
3412 }
3413 }
3414 } else {
3415 None
3416 }
3417 } else {
3418 None
3419 };
3420
3421 let vm_name = match name {
3425 Some(n) => n.to_string(),
3426 None => std::env::var("MVM_REEXEC_NAME").unwrap_or_else(|_| {
3427 let mut generator = names::Generator::default();
3428 generator.next().unwrap_or_else(|| "vm-0".to_string())
3429 }),
3430 };
3431
3432 let registry_path = mvm_runtime::vm::name_registry::registry_path();
3434 if let Ok(mut registry) = mvm_runtime::vm::name_registry::VmNameRegistry::load(®istry_path) {
3435 registry.deregister(&vm_name);
3437 let _ = registry.register(&vm_name, "", network_name, None, 0);
3438 let _ = registry.save(®istry_path);
3439 }
3440
3441 if std::env::var("MVM_DIRECT_BOOT").as_deref() == Ok("1") {
3444 let kernel = std::env::var("MVM_KERNEL_PATH")
3445 .map_err(|_| anyhow::anyhow!("MVM_KERNEL_PATH not set"))?;
3446 let rootfs = std::env::var("MVM_ROOTFS_PATH")
3447 .map_err(|_| anyhow::anyhow!("MVM_ROOTFS_PATH not set"))?;
3448
3449 let start_config = mvm_core::vm_backend::VmStartConfig {
3450 name: vm_name.clone(),
3451 rootfs_path: rootfs,
3452 kernel_path: Some(kernel),
3453 cpus: cpus.unwrap_or(2),
3454 memory_mib: memory.unwrap_or(512),
3455 ..Default::default()
3456 };
3457
3458 let backend = AnyBackend::from_hypervisor(effective_hypervisor);
3459 backend.start(&start_config)?;
3460
3461 if let Ok(ports_str) = std::env::var("MVM_PORTS")
3463 && !ports_str.is_empty()
3464 {
3465 ui::info("Waiting for guest agent...");
3466 if wait_for_guest_agent(&vm_name, 30) {
3467 for spec in ports_str.split(',') {
3468 if let Some((host, guest)) = spec.split_once(':')
3469 && let (Ok(h), Ok(g)) = (host.parse::<u16>(), guest.parse::<u16>())
3470 {
3471 let _ = request_port_forward(&vm_name, g);
3472 mvm_apple_container::start_port_proxy(&vm_name, h, g);
3473 ui::info(&format!("Forwarding localhost:{h} → guest tcp/{g} (vsock)"));
3474 }
3475 }
3476 } else {
3477 ui::warn("Guest agent not reachable — port forwarding unavailable.");
3478 }
3479 }
3480
3481 ui::info(&format!("VM '{}' running. Press Ctrl+C to stop.", vm_name));
3482
3483 let pair = std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
3485 let pair2 = pair.clone();
3486 let _ = ctrlc::set_handler(move || {
3487 let (lock, cvar) = &*pair2;
3488 *lock.lock().unwrap_or_else(|e| e.into_inner()) = true;
3489 cvar.notify_all();
3490 });
3491 let (lock, cvar) = &*pair;
3492 let mut stopped = lock.lock().unwrap_or_else(|e| e.into_inner());
3493 while !*stopped {
3494 stopped = cvar
3495 .wait_timeout(stopped, std::time::Duration::from_secs(1))
3496 .unwrap_or_else(|e| e.into_inner())
3497 .0;
3498 }
3499 let _ = backend.stop(&mvm_core::vm_backend::VmId(vm_name));
3500 return Ok(());
3501 }
3502
3503 let (
3505 vmlinux_path,
3506 initrd_path,
3507 rootfs_path,
3508 revision_hash,
3509 source_flake,
3510 source_profile,
3511 tmpl_cpus,
3512 tmpl_mem,
3513 snapshot_info,
3514 ) = if let Some(tmpl) = template_name {
3515 ui::step(
3516 1,
3517 2,
3518 &format!("Loading template '{}' for VM '{}'", tmpl, vm_name),
3519 );
3520 let (spec, vmlinux, initrd, rootfs, rev) =
3521 mvm_runtime::vm::template::lifecycle::template_artifacts(tmpl)?;
3522 ui::info(&format!("Using revision {}", rev));
3523
3524 let snap_info = mvm_runtime::vm::template::lifecycle::template_snapshot_info(tmpl)?;
3526 if snap_info.is_some() {
3527 ui::info("Snapshot available — will restore instantly");
3528 }
3529
3530 (
3531 vmlinux,
3532 initrd,
3533 rootfs,
3534 rev,
3535 spec.flake_ref.clone(),
3536 Some(spec.profile.clone()),
3537 Some(spec.vcpus as u32),
3538 Some(spec.mem_mib),
3539 snap_info,
3540 )
3541 } else if let Some(flake) = flake_ref {
3542 let resolved = resolve_flake_ref(flake)?;
3543 let profile_display = profile.unwrap_or("default");
3544 ui::step(
3545 1,
3546 2,
3547 &format!(
3548 "Building flake {} (profile={}, name={})",
3549 resolved, profile_display, vm_name
3550 ),
3551 );
3552 let run_build_env = mvm_runtime::build_env::default_build_env();
3553 let env = run_build_env.as_ref();
3554 let result = mvm_build::dev_build::dev_build(env, &resolved, profile)?;
3555 if let Err(e) = mvm_build::dev_build::ensure_guest_agent_if_needed(env, &result) {
3556 ui::warn(&format!(
3557 "Could not verify guest agent ({}). If built with mkGuest, the agent is already included.",
3558 e
3559 ));
3560 }
3561 if result.cached {
3562 ui::info(&format!("Cache hit — revision {}", result.revision_hash));
3563 } else {
3564 ui::info(&format!(
3565 "Build complete — revision {}",
3566 result.revision_hash
3567 ));
3568 }
3569 (
3570 result.vmlinux_path,
3571 result.initrd_path,
3572 result.rootfs_path,
3573 result.revision_hash,
3574 flake.to_string(),
3575 profile.map(|s| s.to_string()),
3576 None,
3577 None,
3578 None, )
3580 } else {
3581 ui::step(
3582 1,
3583 2,
3584 &format!(
3585 "No --flake or --template; using bundled default microVM image for '{}'",
3586 vm_name
3587 ),
3588 );
3589 let (kernel, rootfs) = ensure_default_microvm_image()?;
3590 (
3591 kernel,
3592 None,
3593 rootfs,
3594 String::new(),
3595 "default-microvm".to_string(),
3596 None,
3597 None,
3598 None,
3599 None,
3600 )
3601 };
3602
3603 let backend_label = match effective_hypervisor {
3604 "apple-container" => "Apple Container",
3605 "qemu" => "QEMU (microvm.nix)",
3606 _ => "Firecracker VM",
3607 };
3608 ui::step(2, 2, &format!("Booting {} '{}'", backend_label, vm_name));
3609
3610 let rt_config = match config_path {
3611 Some(p) => image::parse_runtime_config(p)?,
3612 None => image::RuntimeConfig::default(),
3613 };
3614
3615 let mut volume_cfg: Vec<image::RuntimeVolume> = Vec::new();
3617 let mut config_files: Vec<microvm::DriveFile> = Vec::new();
3618 let mut secret_files: Vec<microvm::DriveFile> = Vec::new();
3619
3620 if !volumes.is_empty() {
3621 for v in volumes {
3622 match parse_volume_spec(v)? {
3623 VolumeSpec::DirInject {
3624 host_dir,
3625 guest_mount,
3626 } => match guest_mount.as_str() {
3627 "/mnt/config" => {
3628 config_files.extend(
3629 read_dir_to_drive_files(&host_dir, 0o444)
3630 .with_context(|| format!("reading volume '{}'", v))?,
3631 );
3632 }
3633 "/mnt/secrets" => {
3634 secret_files.extend(
3635 read_dir_to_drive_files(&host_dir, 0o400)
3636 .with_context(|| format!("reading volume '{}'", v))?,
3637 );
3638 }
3639 other => anyhow::bail!(
3640 "Unsupported guest mount '{}'. Supported: /mnt/config, /mnt/secrets",
3641 other
3642 ),
3643 },
3644 VolumeSpec::Persistent(vol) => volume_cfg.push(vol),
3645 }
3646 }
3647 } else {
3648 volume_cfg = rt_config.volumes.clone();
3649 };
3650
3651 let user_cfg = mvm_core::user_config::load(None);
3652 let final_cpus = cpus
3653 .or(rt_config.cpus)
3654 .or(tmpl_cpus)
3655 .unwrap_or(user_cfg.default_cpus);
3656 let final_memory = memory
3657 .or(rt_config.memory)
3658 .or(tmpl_mem)
3659 .unwrap_or(user_cfg.default_memory_mib);
3660
3661 let port_mappings = parse_port_specs(ports)?;
3663 if let Some(f) = ports_to_drive_file(&port_mappings) {
3664 config_files.push(f);
3665 }
3666
3667 if let Some(f) = env_vars_to_drive_file(env_vars) {
3669 config_files.push(f);
3670 }
3671
3672 if let Some(manifest) = seccomp_tier.to_manifest() {
3674 let json = serde_json::to_string_pretty(&manifest)
3675 .context("failed to serialize seccomp manifest")?;
3676 config_files.push(microvm::DriveFile {
3677 name: "seccomp.json".to_string(),
3678 content: json,
3679 mode: 0o644,
3680 });
3681 }
3682
3683 if !secret_bindings.is_empty() {
3685 let resolved = mvm_core::secret_binding::ResolvedSecrets::resolve(&secret_bindings)
3686 .context("failed to resolve secret bindings")?;
3687
3688 for (filename, content) in resolved.to_secret_files() {
3690 secret_files.push(microvm::DriveFile {
3691 name: filename,
3692 content,
3693 mode: 0o600,
3694 });
3695 }
3696
3697 config_files.push(microvm::DriveFile {
3699 name: "secrets-manifest.json".to_string(),
3700 content: resolved.manifest_json(),
3701 mode: 0o644,
3702 });
3703
3704 let placeholders: Vec<String> = resolved
3706 .placeholder_env_vars()
3707 .iter()
3708 .map(|(k, v)| format!("{}={}", k, v))
3709 .collect();
3710 if let Some(f) = env_vars_to_drive_file(&placeholders) {
3711 config_files.push(microvm::DriveFile {
3712 name: "secret-env.env".to_string(),
3713 content: f.content,
3714 mode: f.mode,
3715 });
3716 }
3717
3718 for b in &secret_bindings {
3720 ui::info(&format!(
3721 "Secret {} bound to {} (header: {})",
3722 b.env_var, b.target_host, b.header
3723 ));
3724 }
3725 }
3726
3727 let vm_name_owned = vm_name.clone();
3728 let has_ports = !port_mappings.is_empty();
3729
3730 unsafe { std::env::set_var("MVM_REEXEC_NAME", &vm_name) };
3735
3736 let backend = AnyBackend::from_hypervisor(effective_hypervisor);
3739 if let Some(ref snap_info) = snapshot_info
3740 && let Some(tmpl) = template_name
3741 && backend.capabilities().snapshots
3742 {
3743 let slot = microvm::allocate_slot(&vm_name)?;
3744 let run_config = microvm::FlakeRunConfig {
3745 name: vm_name,
3746 slot,
3747 vmlinux_path,
3748 initrd_path,
3749 rootfs_path,
3750 revision_hash,
3751 flake_ref: source_flake,
3752 profile: source_profile,
3753 cpus: final_cpus,
3754 memory: final_memory,
3755 volumes: volume_cfg,
3756 config_files,
3757 secret_files,
3758 ports: port_mappings,
3759 network_policy: network_policy.clone(),
3760 };
3761 let rev = mvm_runtime::vm::template::lifecycle::current_revision_id(tmpl)?;
3762 let snap_dir = mvm_core::template::template_snapshot_dir(tmpl, &rev);
3763 ui::step(
3764 2,
3765 2,
3766 &format!("Restoring VM '{}' from snapshot", vm_name_owned),
3767 );
3768 microvm::restore_from_template_snapshot(tmpl, &run_config, &snap_dir, snap_info)?;
3769 } else {
3770 let start_config = VmStartParams {
3771 name: vm_name,
3772 rootfs_path,
3773 vmlinux_path,
3774 initrd_path,
3775 revision_hash,
3776 flake_ref: source_flake,
3777 profile: source_profile,
3778 cpus: final_cpus,
3779 memory_mib: final_memory,
3780 volumes: &volume_cfg,
3781 config_files: &config_files,
3782 secret_files: &secret_files,
3783 port_mappings: &port_mappings,
3784 }
3785 .into_start_config();
3786
3787 if detach && effective_hypervisor == "apple-container" {
3791 mvm_apple_container::ensure_signed();
3794
3795 let port_specs: Vec<String> = parse_port_specs(ports)
3799 .unwrap_or_default()
3800 .iter()
3801 .map(|p| format!("{}:{}", p.host, p.guest))
3802 .collect();
3803
3804 mvm_apple_container::install_launchd_direct(
3805 &start_config.name,
3806 start_config.kernel_path.as_deref().unwrap_or(""),
3807 &start_config.rootfs_path,
3808 start_config.cpus,
3809 start_config.memory_mib as u64,
3810 &port_specs,
3811 )
3812 .map_err(|e| anyhow::anyhow!("{e}"))?;
3813 println!("{vm_name_owned}");
3814 return Ok(());
3815 }
3816
3817 backend.start(&start_config)?;
3818 }
3819
3820 mvm_core::audit::emit(
3821 mvm_core::audit::LocalAuditKind::VmStart,
3822 Some(&vm_name_owned),
3823 None,
3824 );
3825
3826 if effective_hypervisor == "apple-container" && !detach {
3828 if has_ports {
3833 let pm_list = parse_port_specs(ports).unwrap_or_default();
3834
3835 ui::info("Waiting for guest agent...");
3836 let agent_ready = wait_for_guest_agent(&vm_name_owned, 30);
3837 if !agent_ready {
3838 ui::warn("Guest agent not reachable — port forwarding unavailable.");
3839 } else {
3840 for pm in &pm_list {
3842 match request_port_forward(&vm_name_owned, pm.guest) {
3843 Ok(vsock_port) => {
3844 ui::info(&format!(
3845 "Guest forwarding vsock:{vsock_port} → tcp/{}",
3846 pm.guest
3847 ));
3848 }
3849 Err(e) => {
3850 ui::warn(&format!(
3851 "Failed to set up guest forwarder for port {}: {e}",
3852 pm.guest
3853 ));
3854 }
3855 }
3856 }
3857
3858 for pm in &pm_list {
3860 mvm_apple_container::start_port_proxy(&vm_name_owned, pm.host, pm.guest);
3861 ui::info(&format!(
3862 "Forwarding localhost:{} → guest tcp/{} (vsock)",
3863 pm.host, pm.guest
3864 ));
3865 }
3866
3867 let ports_str: Vec<String> = pm_list
3869 .iter()
3870 .map(|p| format!("{}:{}", p.host, p.guest))
3871 .collect();
3872 let ports_file = format!(
3873 "{}/.mvm/vms/{}/ports",
3874 std::env::var("HOME").unwrap_or_default(),
3875 vm_name_owned
3876 );
3877 let _ = std::fs::write(&ports_file, ports_str.join(","));
3878 }
3879 }
3880
3881 ui::info(&format!(
3882 "VM '{}' running. Press Ctrl+C to stop.",
3883 vm_name_owned
3884 ));
3885
3886 let pair = std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new()));
3888 let pair2 = pair.clone();
3889 let _ = ctrlc::set_handler(move || {
3890 let (lock, cvar) = &*pair2;
3891 *lock.lock().unwrap_or_else(|e| e.into_inner()) = true;
3892 cvar.notify_all();
3893 });
3894
3895 let (lock, cvar) = &*pair;
3896 let mut stopped = lock.lock().unwrap_or_else(|e| e.into_inner());
3897 while !*stopped {
3898 stopped = cvar
3899 .wait_timeout(stopped, std::time::Duration::from_secs(1))
3900 .unwrap_or_else(|e| e.into_inner())
3901 .0;
3902 }
3903
3904 ui::info(&format!("Stopping VM '{}'...", vm_name_owned));
3905 let _ = backend.stop(&mvm_core::vm_backend::VmId(vm_name_owned.clone()));
3906 return Ok(());
3907 }
3908
3909 if forward {
3910 if has_ports {
3911 cmd_forward(&vm_name_owned, &[])?;
3912 } else {
3913 ui::warn("--forward was set but no ports were declared. Use -p to specify ports.");
3914 }
3915 }
3916
3917 if watch {
3919 let Some(flake) = flake_ref else {
3920 return Ok(());
3922 };
3923 if flake.contains(':') {
3924 ui::warn("--watch requires a local flake; running a single boot instead.");
3925 return Ok(());
3926 }
3927 let flake_dir = resolve_flake_ref(flake)?;
3928 loop {
3929 ui::info("Watching for .nix and .lock changes (Ctrl+C to exit)...");
3930 match crate::watch::wait_for_changes(&flake_dir) {
3931 Ok(trigger) => {
3932 let display = crate::watch::display_trigger(&trigger, &flake_dir);
3933 ui::info(&format!("\nChange detected: {display} — rebuilding..."));
3934 }
3935 Err(e) => {
3936 tracing::warn!("Watch error: {e}");
3937 break;
3938 }
3939 }
3940
3941 let backend = AnyBackend::default_backend();
3943 if let Err(e) = backend.stop(&VmId::from(vm_name_owned.as_str())) {
3944 tracing::warn!("Could not stop '{}': {e}", vm_name_owned);
3945 }
3946
3947 let env = mvm_runtime::build_env::RuntimeBuildEnv;
3949 let result = match mvm_build::dev_build::dev_build(&env, &flake_dir, profile) {
3950 Ok(r) => r,
3951 Err(e) => {
3952 ui::warn(&format!("Rebuild failed: {e}; waiting for next change..."));
3953 continue;
3954 }
3955 };
3956 if let Err(e) = mvm_build::dev_build::ensure_guest_agent_if_needed(&env, &result) {
3957 tracing::warn!("Guest agent check failed: {e}");
3958 }
3959 ui::success(&format!(
3960 "Build complete — revision {}",
3961 result.revision_hash
3962 ));
3963
3964 let rt_cfg_watch = match config_path {
3966 Some(p) => image::parse_runtime_config(p).unwrap_or_default(),
3967 None => image::RuntimeConfig::default(),
3968 };
3969 let mut w_volume_cfg: Vec<image::RuntimeVolume> = Vec::new();
3970 let mut w_config_files: Vec<microvm::DriveFile> = Vec::new();
3971 let mut w_secret_files: Vec<microvm::DriveFile> = Vec::new();
3972 if !volumes.is_empty() {
3973 for v in volumes {
3974 match parse_volume_spec(v) {
3975 Ok(VolumeSpec::DirInject {
3976 host_dir,
3977 guest_mount,
3978 }) => match guest_mount.as_str() {
3979 "/mnt/config" => {
3980 if let Ok(files) = read_dir_to_drive_files(&host_dir, 0o444) {
3981 w_config_files.extend(files);
3982 }
3983 }
3984 "/mnt/secrets" => {
3985 if let Ok(files) = read_dir_to_drive_files(&host_dir, 0o400) {
3986 w_secret_files.extend(files);
3987 }
3988 }
3989 _ => {}
3990 },
3991 Ok(VolumeSpec::Persistent(vol)) => w_volume_cfg.push(vol),
3992 Err(_) => {}
3993 }
3994 }
3995 } else {
3996 w_volume_cfg = rt_cfg_watch.volumes.clone();
3997 }
3998 let w_port_mappings = parse_port_specs(ports).unwrap_or_default();
3999 if let Some(f) = ports_to_drive_file(&w_port_mappings) {
4000 w_config_files.push(f);
4001 }
4002 if let Some(f) = env_vars_to_drive_file(env_vars) {
4003 w_config_files.push(f);
4004 }
4005 let w_start_config = VmStartParams {
4006 name: vm_name_owned.clone(),
4007 rootfs_path: result.rootfs_path,
4008 vmlinux_path: result.vmlinux_path,
4009 initrd_path: result.initrd_path,
4010 revision_hash: result.revision_hash,
4011 flake_ref: flake.to_string(),
4012 profile: profile.map(|s| s.to_string()),
4013 cpus: final_cpus,
4014 memory_mib: final_memory,
4015 volumes: &w_volume_cfg,
4016 config_files: &w_config_files,
4017 secret_files: &w_secret_files,
4018 port_mappings: &w_port_mappings,
4019 }
4020 .into_start_config();
4021 let w_backend = AnyBackend::from_hypervisor(effective_hypervisor);
4022 if let Err(e) = w_backend.start(&w_start_config) {
4023 ui::warn(&format!(
4024 "Could not start VM: {e}; waiting for next change..."
4025 ));
4026 } else {
4027 mvm_core::audit::emit(
4028 mvm_core::audit::LocalAuditKind::VmStart,
4029 Some(&vm_name_owned),
4030 None,
4031 );
4032 ui::success(&format!("VM '{}' rebooted.", vm_name_owned));
4033 }
4034 }
4035 }
4036
4037 Ok(())
4038}
4039
4040fn read_dir_to_drive_files(dir: &str, default_mode: u32) -> Result<Vec<microvm::DriveFile>> {
4042 let mut files = Vec::new();
4043 for entry in std::fs::read_dir(dir)? {
4044 let entry = entry?;
4045 if entry.file_type()?.is_file() {
4046 files.push(microvm::DriveFile {
4047 name: entry.file_name().to_string_lossy().to_string(),
4048 content: std::fs::read_to_string(entry.path())?,
4049 mode: default_mode,
4050 });
4051 }
4052 }
4053 Ok(files)
4054}
4055
4056enum VolumeSpec {
4058 DirInject {
4060 host_dir: String,
4061 guest_mount: String,
4062 },
4063 Persistent(image::RuntimeVolume),
4065}
4066
4067fn parse_volume_spec(spec: &str) -> Result<VolumeSpec> {
4068 let parts: Vec<&str> = spec.splitn(3, ':').collect();
4069 match parts.len() {
4070 2 => Ok(VolumeSpec::DirInject {
4071 host_dir: parts[0].to_string(),
4072 guest_mount: parts[1].to_string(),
4073 }),
4074 3 => Ok(VolumeSpec::Persistent(image::RuntimeVolume {
4075 host: parts[0].to_string(),
4076 guest: parts[1].to_string(),
4077 size: parts[2].to_string(),
4078 read_only: false,
4079 })),
4080 _ => anyhow::bail!(
4081 "Invalid volume '{}'. Expected host_dir:/guest/path or host:/guest/path:size",
4082 spec
4083 ),
4084 }
4085}
4086
4087fn load_fleet_config(
4089 config_path: Option<&str>,
4090) -> Result<Option<(fleet::FleetConfig, std::path::PathBuf)>> {
4091 match config_path {
4092 Some(path) => {
4093 let content = std::fs::read_to_string(path)
4094 .with_context(|| format!("Failed to read {}", path))?;
4095 let config: fleet::FleetConfig =
4096 toml::from_str(&content).with_context(|| format!("Failed to parse {}", path))?;
4097 let dir = std::path::Path::new(path)
4098 .parent()
4099 .unwrap_or(std::path::Path::new("."))
4100 .to_path_buf();
4101 Ok(Some((config, dir)))
4102 }
4103 None => fleet::find_fleet_config(),
4104 }
4105}
4106
4107fn cmd_down(name: Option<&str>, config_path: Option<&str>) -> Result<()> {
4108 let backend = if mvm_core::platform::current().has_apple_containers() {
4110 AnyBackend::from_hypervisor("apple-container")
4111 } else {
4112 AnyBackend::default_backend()
4113 };
4114 match name {
4115 Some(n) => {
4116 let result = backend.stop(&VmId::from(n));
4117 let registry_path = mvm_runtime::vm::name_registry::registry_path();
4119 if let Ok(mut registry) =
4120 mvm_runtime::vm::name_registry::VmNameRegistry::load(®istry_path)
4121 {
4122 registry.deregister(n);
4123 let _ = registry.save(®istry_path);
4124 }
4125 result
4126 }
4127 None => {
4128 let found = load_fleet_config(config_path)?;
4129 if let Some((fleet_config, _base_dir)) = found {
4130 let mut stopped = 0;
4131 for vm_name in fleet_config.vms.keys() {
4132 if backend.stop(&VmId::from(vm_name.as_str())).is_ok() {
4133 stopped += 1;
4134 }
4135 }
4136
4137 let remaining = backend.list().unwrap_or_default();
4139 if remaining.is_empty() {
4140 let _ = mvm_runtime::vm::network::bridge_teardown();
4141 }
4142
4143 ui::success(&format!("Stopped {} VMs", stopped));
4144 Ok(())
4145 } else {
4146 backend.stop_all()
4147 }
4148 }
4149 }
4150}
4151
4152fn cmd_metrics(json: bool) -> Result<()> {
4153 let metrics = mvm_core::observability::metrics::global();
4154 if json {
4155 let snap = metrics.snapshot();
4156 println!("{}", serde_json::to_string_pretty(&snap)?);
4157 } else {
4158 print!("{}", metrics.prometheus_exposition());
4159 }
4160 Ok(())
4161}
4162
4163fn cmd_completions(shell: clap_complete::Shell) -> Result<()> {
4164 let mut cmd = Cli::command();
4165 clap_complete::generate(shell, &mut cmd, "mvmctl", &mut std::io::stdout());
4166 Ok(())
4167}
4168
4169fn cmd_uninstall(yes: bool, all: bool, dry_run: bool) -> Result<()> {
4170 let mut actions: Vec<String> = vec![
4173 "Destroy Lima VM 'mvm' (if present)".to_string(),
4174 "Remove /var/lib/mvm/ (VM state, volumes, run-info)".to_string(),
4175 ];
4176 if all {
4177 actions.push("Remove ~/.mvm/ (config, signing keys)".to_string());
4178 actions.push("Remove /usr/local/bin/mvmctl (binary)".to_string());
4179 }
4180
4181 if dry_run {
4182 ui::info("Dry run — the following would be removed:");
4183 for a in &actions {
4184 println!(" • {a}");
4185 }
4186 return Ok(());
4187 }
4188
4189 if !yes {
4191 ui::info("The following will be removed:");
4192 for a in &actions {
4193 println!(" • {a}");
4194 }
4195 if !ui::confirm("Proceed with uninstall?") {
4196 ui::info("Cancelled.");
4197 return Ok(());
4198 }
4199 }
4200
4201 let lima_status = lima::get_status().unwrap_or(lima::LimaStatus::NotFound);
4203
4204 if matches!(lima_status, lima::LimaStatus::Running)
4206 && let Err(e) = microvm::stop()
4207 {
4208 tracing::warn!("failed to stop microVMs before uninstall: {e}");
4209 }
4210
4211 if !matches!(lima_status, lima::LimaStatus::NotFound) {
4213 ui::info("Destroying Lima VM...");
4214 if let Err(e) = lima::destroy() {
4215 tracing::warn!("failed to destroy Lima VM: {e}");
4216 }
4217 }
4218
4219 let state_dir = std::path::Path::new("/var/lib/mvm");
4221 if state_dir.exists() {
4222 ui::info("Removing /var/lib/mvm/...");
4223 let status = std::process::Command::new("sudo")
4224 .args(["rm", "-rf", "/var/lib/mvm"])
4225 .status();
4226 match status {
4227 Ok(s) if s.success() => {}
4228 Ok(s) => tracing::warn!("sudo rm /var/lib/mvm exited with status {s}"),
4229 Err(e) => tracing::warn!("failed to remove /var/lib/mvm: {e}"),
4230 }
4231 }
4232
4233 if all {
4234 if let Ok(home) = std::env::var("HOME") {
4236 let config_dir = std::path::PathBuf::from(home).join(".mvm");
4237 if config_dir.exists() {
4238 ui::info("Removing ~/.mvm/...");
4239 if let Err(e) = std::fs::remove_dir_all(&config_dir) {
4240 tracing::warn!("failed to remove ~/.mvm/: {e}");
4241 }
4242 }
4243 }
4244
4245 let bin = std::path::Path::new("/usr/local/bin/mvmctl");
4247 if bin.exists() {
4248 ui::info("Removing /usr/local/bin/mvmctl...");
4249 let status = std::process::Command::new("sudo")
4250 .args(["rm", "-f", "/usr/local/bin/mvmctl"])
4251 .status();
4252 match status {
4253 Ok(s) if s.success() => {}
4254 Ok(s) => tracing::warn!("sudo rm mvmctl exited with status {s}"),
4255 Err(e) => tracing::warn!("failed to remove /usr/local/bin/mvmctl: {e}"),
4256 }
4257 }
4258 }
4259
4260 mvm_core::audit::emit(mvm_core::audit::LocalAuditKind::Uninstall, None, None);
4261 ui::success("Uninstall complete.");
4262 Ok(())
4263}
4264
4265fn cmd_audit(action: AuditCmd) -> Result<()> {
4270 match action {
4271 AuditCmd::Tail { lines, follow } => cmd_audit_tail(lines, follow),
4272 }
4273}
4274
4275fn cmd_flake(action: FlakeCmd) -> Result<()> {
4280 match action {
4281 FlakeCmd::Check { flake, json } => cmd_flake_check(&flake, json),
4282 }
4283}
4284
4285fn cmd_flake_check(flake: &str, json: bool) -> Result<()> {
4286 let resolved = resolve_flake_ref(flake)?;
4287
4288 if bootstrap::is_lima_required() {
4289 lima::require_running()?;
4290 }
4291
4292 let script = format!("nix flake check {resolved}");
4293
4294 if json {
4295 let output = shell::run_in_vm_capture(&script);
4297 match output {
4298 Ok(out) => {
4299 let combined = format!(
4300 "{}{}",
4301 String::from_utf8_lossy(&out.stdout),
4302 String::from_utf8_lossy(&out.stderr)
4303 );
4304 if out.status.success() {
4305 println!("{{\"valid\":true}}");
4306 } else {
4307 let msg = combined.trim().replace('"', "'");
4308 println!("{{\"valid\":false,\"error\":\"{msg}\"}}");
4309 std::process::exit(1);
4310 }
4311 Ok(())
4312 }
4313 Err(e) => {
4314 let msg = e.to_string().replace('"', "'");
4315 println!("{{\"valid\":false,\"error\":\"{msg}\"}}");
4316 std::process::exit(1);
4317 }
4318 }
4319 } else {
4320 match shell::run_in_vm_visible(&script) {
4322 Ok(()) => {
4323 ui::success("Flake is valid.");
4324 Ok(())
4325 }
4326 Err(e) => Err(e.context("Flake check failed")),
4327 }
4328 }
4329}
4330
4331fn cmd_audit_tail(lines: usize, follow: bool) -> Result<()> {
4332 let log_path = mvm_core::audit::default_audit_log();
4333 let path = std::path::Path::new(&log_path);
4334
4335 if !path.exists() {
4336 ui::info(&format!(
4337 "No audit log found. Events are recorded at {log_path}."
4338 ));
4339 return Ok(());
4340 }
4341
4342 print_last_n_lines(path, lines)?;
4343
4344 if !follow {
4345 return Ok(());
4346 }
4347
4348 let mut pos = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
4350
4351 loop {
4352 std::thread::sleep(std::time::Duration::from_millis(500));
4353 if !path.exists() {
4354 continue;
4355 }
4356 let new_len = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
4357 if new_len > pos {
4358 let mut file = std::fs::File::open(path)?;
4359 use std::io::{BufRead, Seek, SeekFrom};
4360 file.seek(SeekFrom::Start(pos))?;
4361 let reader = std::io::BufReader::new(&file);
4362 for line in reader.lines() {
4363 let line = line?;
4364 print_audit_line(&line);
4365 }
4366 pos = new_len;
4367 }
4368 }
4369}
4370
4371fn print_last_n_lines(path: &std::path::Path, n: usize) -> Result<()> {
4372 use std::io::BufRead;
4373 let file =
4374 std::fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
4375 let reader = std::io::BufReader::new(file);
4376 let lines: Vec<String> = reader.lines().map_while(Result::ok).collect();
4377 let start = lines.len().saturating_sub(n);
4378 for line in &lines[start..] {
4379 print_audit_line(line);
4380 }
4381 Ok(())
4382}
4383
4384fn print_audit_line(line: &str) {
4385 match serde_json::from_str::<mvm_core::audit::LocalAuditEvent>(line) {
4386 Ok(event) => {
4387 let kind = serde_json::to_string(&event.kind)
4388 .unwrap_or_default()
4389 .trim_matches('"')
4390 .to_string();
4391 let vm = event
4392 .vm_name
4393 .as_deref()
4394 .map(|n| format!(" [{n}]"))
4395 .unwrap_or_default();
4396 let detail = event
4397 .detail
4398 .as_deref()
4399 .map(|d| format!(" {d}"))
4400 .unwrap_or_default();
4401 println!("{ts} {kind}{vm}{detail}", ts = event.timestamp);
4402 }
4403 Err(_) => {
4404 println!("{line}");
4406 }
4407 }
4408}
4409
4410fn cmd_template(action: TemplateCmd) -> Result<()> {
4415 match action {
4416 TemplateCmd::Create {
4417 name,
4418 flake,
4419 profile,
4420 role,
4421 cpus,
4422 mem,
4423 data_disk,
4424 } => {
4425 validate_template_name(&name)
4426 .with_context(|| format!("Invalid template name: {:?}", name))?;
4427 validate_flake_ref(&flake)
4428 .with_context(|| format!("Invalid flake reference: {:?}", flake))?;
4429 let mem_mb = parse_human_size(&mem).context("Invalid memory size")?;
4430 let data_disk_mb = parse_human_size(&data_disk).context("Invalid data disk size")?;
4431 template_cmd::create_single(&name, &flake, &profile, &role, cpus, mem_mb, data_disk_mb)
4432 }
4433 TemplateCmd::CreateMulti {
4434 base,
4435 flake,
4436 profile,
4437 roles,
4438 cpus,
4439 mem,
4440 data_disk,
4441 } => {
4442 validate_template_name(&base)
4443 .with_context(|| format!("Invalid template base name: {:?}", base))?;
4444 validate_flake_ref(&flake)
4445 .with_context(|| format!("Invalid flake reference: {:?}", flake))?;
4446 let mem_mb = parse_human_size(&mem).context("Invalid memory size")?;
4447 let data_disk_mb = parse_human_size(&data_disk).context("Invalid data disk size")?;
4448 let role_list: Vec<String> = roles.split(',').map(|s| s.trim().to_string()).collect();
4449 template_cmd::create_multi(
4450 &base,
4451 &flake,
4452 &profile,
4453 &role_list,
4454 cpus,
4455 mem_mb,
4456 data_disk_mb,
4457 )
4458 }
4459 TemplateCmd::Build {
4460 name,
4461 force,
4462 snapshot,
4463 config,
4464 update_hash,
4465 } => {
4466 validate_template_name(&name)
4467 .with_context(|| format!("Invalid template name: {:?}", name))?;
4468 template_cmd::build(&name, force, snapshot, config.as_deref(), update_hash)
4469 }
4470 TemplateCmd::Push { name, revision } => {
4471 validate_template_name(&name)
4472 .with_context(|| format!("Invalid template name: {:?}", name))?;
4473 template_cmd::push(&name, revision.as_deref())
4474 }
4475 TemplateCmd::Pull { name, revision } => {
4476 validate_template_name(&name)
4477 .with_context(|| format!("Invalid template name: {:?}", name))?;
4478 template_cmd::pull(&name, revision.as_deref())
4479 }
4480 TemplateCmd::Verify { name, revision } => {
4481 validate_template_name(&name)
4482 .with_context(|| format!("Invalid template name: {:?}", name))?;
4483 template_cmd::verify(&name, revision.as_deref())
4484 }
4485 TemplateCmd::List { json } => template_cmd::list(json),
4486 TemplateCmd::Info { name, json } => {
4487 validate_template_name(&name)
4488 .with_context(|| format!("Invalid template name: {:?}", name))?;
4489 template_cmd::info(&name, json)
4490 }
4491 TemplateCmd::Edit {
4492 name,
4493 flake,
4494 profile,
4495 role,
4496 cpus,
4497 mem,
4498 data_disk,
4499 } => {
4500 validate_template_name(&name)
4501 .with_context(|| format!("Invalid template name: {:?}", name))?;
4502 if let Some(ref f) = flake {
4503 validate_flake_ref(f)
4504 .with_context(|| format!("Invalid flake reference: {:?}", f))?;
4505 }
4506 let mem_mb = mem
4507 .as_ref()
4508 .map(|s| parse_human_size(s))
4509 .transpose()
4510 .context("Invalid memory size")?;
4511 let data_disk_mb = data_disk
4512 .as_ref()
4513 .map(|s| parse_human_size(s))
4514 .transpose()
4515 .context("Invalid data disk size")?;
4516 template_cmd::edit(
4517 &name,
4518 flake.as_deref(),
4519 profile.as_deref(),
4520 role.as_deref(),
4521 cpus,
4522 mem_mb,
4523 data_disk_mb,
4524 )
4525 }
4526 TemplateCmd::Delete { name, force } => {
4527 validate_template_name(&name)
4528 .with_context(|| format!("Invalid template name: {:?}", name))?;
4529 template_cmd::delete(&name, force)
4530 }
4531 TemplateCmd::Init {
4532 name,
4533 local,
4534 vm,
4535 dir,
4536 preset,
4537 prompt,
4538 } => {
4539 validate_template_name(&name)
4540 .with_context(|| format!("Invalid template name: {:?}", name))?;
4541 let use_local = local && !vm;
4542 template_cmd::init(&name, use_local, &dir, preset.as_deref(), prompt.as_deref())
4543 }
4544 }
4545}
4546
4547fn resolve_running_vm(name: &str) -> Result<String> {
4550 if bootstrap::is_lima_required() {
4551 lima::require_running()?;
4552 }
4553
4554 let abs_vms = shell::run_in_vm_stdout(&format!("echo {}", config::VMS_DIR))?;
4555 let abs_dir = format!("{}/{}", abs_vms, name);
4556 let pid_file = format!("{}/fc.pid", abs_dir);
4557
4558 if !firecracker::is_vm_running(&pid_file)? {
4559 anyhow::bail!(
4560 "VM '{}' is not running. Use 'mvmctl status' to list running VMs.",
4561 name
4562 );
4563 }
4564
4565 Ok(abs_dir)
4566}
4567
4568fn cmd_config(action: ConfigAction) -> Result<()> {
4573 match action {
4574 ConfigAction::Show => cmd_config_show(),
4575 ConfigAction::Edit => cmd_config_edit(),
4576 ConfigAction::Set { key, value } => cmd_config_set(&key, &value),
4577 }
4578}
4579
4580fn cmd_config_show() -> Result<()> {
4581 let cfg = mvm_core::user_config::load(None);
4582 let text = toml::to_string_pretty(&cfg).context("Failed to serialize config")?;
4583 print!("{}", text);
4584 Ok(())
4585}
4586
4587fn cmd_config_edit() -> Result<()> {
4588 let _ = mvm_core::user_config::load(None);
4590 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
4591 let config_path = std::path::PathBuf::from(home)
4592 .join(".mvm")
4593 .join("config.toml");
4594 let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
4595 let status = std::process::Command::new(&editor)
4596 .arg(&config_path)
4597 .status()
4598 .with_context(|| format!("Failed to launch editor {:?}", editor))?;
4599 if !status.success() {
4600 anyhow::bail!("Editor exited with status {}", status);
4601 }
4602 Ok(())
4603}
4604
4605fn cmd_config_set(key: &str, value: &str) -> Result<()> {
4606 let mut cfg = mvm_core::user_config::load(None);
4607 mvm_core::user_config::set_key(&mut cfg, key, value)?;
4608 mvm_core::user_config::save(&cfg, None)?;
4609 println!("Set {} = {}", key, value);
4610 Ok(())
4611}
4612
4613fn cmd_network(action: NetworkCmd) -> Result<()> {
4618 use mvm_core::dev_network::{DevNetwork, network_path, networks_dir, validate_network_name};
4619
4620 match action {
4621 NetworkCmd::Create { name, subnet: _ } => {
4622 validate_network_name(&name)?;
4623 let dir = networks_dir();
4624 std::fs::create_dir_all(&dir)?;
4625
4626 let path = network_path(&name);
4627 if std::path::Path::new(&path).exists() {
4628 anyhow::bail!("Network {:?} already exists", name);
4629 }
4630
4631 let mut max_slot: u8 = 0;
4633 if let Ok(entries) = std::fs::read_dir(&dir) {
4634 for entry in entries.flatten() {
4635 if let Ok(text) = std::fs::read_to_string(entry.path())
4636 && let Ok(net) = serde_json::from_str::<DevNetwork>(&text)
4637 {
4638 let parts: Vec<&str> = net.subnet.split('.').collect();
4639 if parts.len() >= 3
4640 && let Ok(s) = parts[2].parse::<u8>()
4641 {
4642 max_slot = max_slot.max(s);
4643 }
4644 }
4645 }
4646 }
4647
4648 let net = if name == "default" {
4649 DevNetwork::default_network()
4650 } else {
4651 DevNetwork::new(&name, max_slot + 1)?
4652 };
4653
4654 let json = serde_json::to_string_pretty(&net)?;
4655 std::fs::write(&path, json)?;
4656
4657 mvm_core::audit::emit(
4658 mvm_core::audit::LocalAuditKind::NetworkCreate,
4659 None,
4660 Some(&name),
4661 );
4662
4663 ui::success(&format!(
4664 "Created network {:?} (bridge={}, subnet={})",
4665 net.name, net.bridge_name, net.subnet
4666 ));
4667 Ok(())
4668 }
4669 NetworkCmd::List => {
4670 let dir = networks_dir();
4671 if !std::path::Path::new(&dir).exists() {
4672 ui::info("No networks configured.");
4673 return Ok(());
4674 }
4675
4676 let mut networks: Vec<DevNetwork> = Vec::new();
4677 for entry in std::fs::read_dir(&dir)?.flatten() {
4678 if entry.path().extension().is_some_and(|e| e == "json")
4679 && let Ok(text) = std::fs::read_to_string(entry.path())
4680 && let Ok(net) = serde_json::from_str::<DevNetwork>(&text)
4681 {
4682 networks.push(net);
4683 }
4684 }
4685
4686 if networks.is_empty() {
4687 ui::info("No networks configured.");
4688 } else {
4689 println!("{:<15} {:<15} {:<20}", "NAME", "BRIDGE", "SUBNET");
4690 for net in &networks {
4691 println!(
4692 "{:<15} {:<15} {:<20}",
4693 net.name, net.bridge_name, net.subnet
4694 );
4695 }
4696 }
4697 Ok(())
4698 }
4699 NetworkCmd::Inspect { name } => {
4700 let path = network_path(&name);
4701 if !std::path::Path::new(&path).exists() {
4702 anyhow::bail!("Network {:?} not found", name);
4703 }
4704 let text = std::fs::read_to_string(&path)?;
4705 let net: DevNetwork = serde_json::from_str(&text)?;
4706 println!("{}", serde_json::to_string_pretty(&net)?);
4707 Ok(())
4708 }
4709 NetworkCmd::Remove { name } => {
4710 if name == "default" {
4711 anyhow::bail!("Cannot remove the default network");
4712 }
4713 let path = network_path(&name);
4714 if !std::path::Path::new(&path).exists() {
4715 anyhow::bail!("Network {:?} not found", name);
4716 }
4717 std::fs::remove_file(&path)?;
4718
4719 mvm_core::audit::emit(
4720 mvm_core::audit::LocalAuditKind::NetworkRemove,
4721 None,
4722 Some(&name),
4723 );
4724
4725 ui::success(&format!("Removed network {:?}", name));
4726 Ok(())
4727 }
4728 }
4729}
4730
4731fn cmd_image(action: ImageCmd) -> Result<()> {
4736 let catalog = load_bundled_catalog();
4737
4738 match action {
4739 ImageCmd::List => {
4740 if catalog.entries.is_empty() {
4741 ui::info("No images in catalog.");
4742 } else {
4743 println!(
4744 "{:<20} {:<40} {:<6} {:<8}",
4745 "NAME", "DESCRIPTION", "CPUS", "MEM"
4746 );
4747 for entry in &catalog.entries {
4748 println!(
4749 "{:<20} {:<40} {:<6} {:<8}",
4750 entry.name,
4751 entry.description,
4752 entry.default_cpus,
4753 format!("{}M", entry.default_memory_mib),
4754 );
4755 }
4756 }
4757 Ok(())
4758 }
4759 ImageCmd::Search { query } => {
4760 let results = catalog.search(&query);
4761 if results.is_empty() {
4762 ui::info(&format!("No images matching {:?}", query));
4763 } else {
4764 println!("{:<20} {:<40} {:<30}", "NAME", "DESCRIPTION", "TAGS");
4765 for entry in results {
4766 println!(
4767 "{:<20} {:<40} {:<30}",
4768 entry.name,
4769 entry.description,
4770 entry.tags.join(", "),
4771 );
4772 }
4773 }
4774 Ok(())
4775 }
4776 ImageCmd::Fetch { name } => {
4777 let entry = catalog
4778 .find(&name)
4779 .ok_or_else(|| anyhow::anyhow!("Image {:?} not found in catalog", name))?;
4780
4781 ui::info(&format!(
4782 "Fetching image {:?} from {}...",
4783 entry.name, entry.flake_ref
4784 ));
4785 ui::info("This will create a template and build it via Nix.");
4786 ui::info(&format!(
4787 "Equivalent to: mvmctl template create {} --flake {} --profile {} && mvmctl template build {}",
4788 entry.name, entry.flake_ref, entry.profile, entry.name
4789 ));
4790
4791 mvm_core::audit::emit(
4792 mvm_core::audit::LocalAuditKind::ImageFetch,
4793 None,
4794 Some(&name),
4795 );
4796
4797 template_cmd::create_single(
4799 &entry.name,
4800 &entry.flake_ref,
4801 &entry.profile,
4802 "worker",
4803 entry.default_cpus,
4804 entry.default_memory_mib,
4805 0, )?;
4807 ui::success(&format!("Created template {:?} from catalog.", entry.name));
4808
4809 ui::info(&format!("Building template {:?}...", entry.name));
4810 template_cmd::build(&entry.name, false, false, None, false)?;
4811 ui::success(&format!(
4812 "Image {:?} is ready. Run with: mvmctl up --template {}",
4813 entry.name, entry.name
4814 ));
4815 Ok(())
4816 }
4817 ImageCmd::Info { name } => {
4818 let entry = catalog
4819 .find(&name)
4820 .ok_or_else(|| anyhow::anyhow!("Image {:?} not found in catalog", name))?;
4821 println!("{}", serde_json::to_string_pretty(entry)?);
4822 Ok(())
4823 }
4824 }
4825}
4826
4827fn load_bundled_catalog() -> mvm_core::catalog::Catalog {
4829 mvm_core::catalog::Catalog {
4830 schema_version: 1,
4831 entries: vec![
4832 mvm_core::catalog::CatalogEntry {
4833 name: "minimal".to_string(),
4834 description: "Bare-bones microVM with init only".to_string(),
4835 flake_ref: ".".to_string(),
4836 profile: "minimal".to_string(),
4837 default_cpus: 1,
4838 default_memory_mib: 256,
4839 tags: vec!["base".to_string(), "minimal".to_string()],
4840 },
4841 mvm_core::catalog::CatalogEntry {
4842 name: "http".to_string(),
4843 description: "HTTP server (Nginx or custom)".to_string(),
4844 flake_ref: ".".to_string(),
4845 profile: "http".to_string(),
4846 default_cpus: 2,
4847 default_memory_mib: 512,
4848 tags: vec!["web".to_string(), "http".to_string(), "nginx".to_string()],
4849 },
4850 mvm_core::catalog::CatalogEntry {
4851 name: "postgres".to_string(),
4852 description: "PostgreSQL database server".to_string(),
4853 flake_ref: ".".to_string(),
4854 profile: "postgres".to_string(),
4855 default_cpus: 2,
4856 default_memory_mib: 1024,
4857 tags: vec![
4858 "database".to_string(),
4859 "sql".to_string(),
4860 "postgres".to_string(),
4861 ],
4862 },
4863 mvm_core::catalog::CatalogEntry {
4864 name: "worker".to_string(),
4865 description: "Background job worker".to_string(),
4866 flake_ref: ".".to_string(),
4867 profile: "worker".to_string(),
4868 default_cpus: 2,
4869 default_memory_mib: 512,
4870 tags: vec!["worker".to_string(), "background".to_string()],
4871 },
4872 mvm_core::catalog::CatalogEntry {
4873 name: "python".to_string(),
4874 description: "Python runtime environment".to_string(),
4875 flake_ref: ".".to_string(),
4876 profile: "python".to_string(),
4877 default_cpus: 2,
4878 default_memory_mib: 512,
4879 tags: vec!["python".to_string(), "runtime".to_string()],
4880 },
4881 ],
4882 }
4883}
4884
4885fn cmd_cache(action: CacheCmd) -> Result<()> {
4890 let cache_dir = mvm_core::config::mvm_cache_dir();
4891
4892 match action {
4893 CacheCmd::Info => {
4894 println!("Cache directory: {cache_dir}");
4895 let path = std::path::Path::new(&cache_dir);
4896 if path.exists() {
4897 let size = dir_size(path);
4898 println!("Disk usage: {}", human_bytes(size));
4899 } else {
4900 println!("(not yet created)");
4901 }
4902 Ok(())
4903 }
4904 CacheCmd::Prune { dry_run } => {
4905 let path = std::path::Path::new(&cache_dir);
4906 if !path.exists() {
4907 ui::info("Cache directory does not exist. Nothing to prune.");
4908 return Ok(());
4909 }
4910
4911 let mut removed = 0u64;
4913 let mut freed = 0u64;
4914 for entry in walkdir(path)? {
4915 let entry_path = entry.path();
4916 if let Some(name) = entry_path.file_name().and_then(|n| n.to_str())
4918 && (name.starts_with("mvm-lima-") || name.ends_with(".tmp"))
4919 {
4920 let size = entry_path.metadata().map(|m| m.len()).unwrap_or(0);
4921 if dry_run {
4922 println!(
4923 "Would remove: {} ({})",
4924 entry_path.display(),
4925 human_bytes(size)
4926 );
4927 } else if entry_path.is_dir() {
4928 let _ = std::fs::remove_dir_all(entry_path);
4929 } else {
4930 let _ = std::fs::remove_file(entry_path);
4931 }
4932 removed += 1;
4933 freed += size;
4934 }
4935 }
4936
4937 if removed == 0 {
4938 ui::info("Nothing to prune.");
4939 } else if dry_run {
4940 ui::info(&format!(
4941 "Would remove {} items, freeing {}",
4942 removed,
4943 human_bytes(freed)
4944 ));
4945 } else {
4946 ui::success(&format!(
4947 "Pruned {} items, freed {}",
4948 removed,
4949 human_bytes(freed)
4950 ));
4951 }
4952 Ok(())
4953 }
4954 }
4955}
4956
4957fn dir_size(path: &std::path::Path) -> u64 {
4959 walkdir(path)
4960 .unwrap_or_default()
4961 .iter()
4962 .filter(|e| e.path().is_file())
4963 .map(|e| e.path().metadata().map(|m| m.len()).unwrap_or(0))
4964 .sum()
4965}
4966
4967fn walkdir(path: &std::path::Path) -> Result<Vec<std::fs::DirEntry>> {
4969 let mut entries = Vec::new();
4970 if path.is_dir() {
4971 for entry in std::fs::read_dir(path)? {
4972 let entry = entry?;
4973 let epath = entry.path();
4974 let is_dir = epath.is_dir();
4975 entries.push(entry);
4976 if is_dir && let Ok(sub) = walkdir(&epath) {
4977 entries.extend(sub);
4978 }
4979 }
4980 }
4981 Ok(entries)
4982}
4983
4984fn cmd_init(non_interactive: bool, lima_cpus: u32, lima_mem: u32) -> Result<()> {
4989 use mvm_core::dev_network::{DevNetwork, network_path, networks_dir};
4990
4991 ui::info("Welcome to mvmctl! Running first-time setup...\n");
4992
4993 let plat = mvm_core::platform::current();
4995 ui::info(&format!("Platform: {}", platform_label(plat)));
4996
4997 if plat.has_apple_containers() {
4998 ui::info("Apple Container support detected (macOS 26+).");
4999 }
5000
5001 ui::info("\nChecking dependencies...");
5003 match bootstrap::check_package_manager() {
5004 Ok(()) => {}
5005 Err(e) => {
5006 if non_interactive {
5007 return Err(e);
5008 }
5009 ui::warn(&format!("Package manager issue: {e}"));
5010 ui::info("Please install a package manager and retry.");
5011 return Err(e);
5012 }
5013 }
5014
5015 if plat.needs_lima() {
5016 ui::info("Ensuring Lima is installed...");
5017 bootstrap::ensure_lima()?;
5018 }
5019
5020 ui::info("\nSetting up development environment...");
5022 run_setup_steps(false, lima_cpus, lima_mem)?;
5023
5024 let dir = networks_dir();
5026 let default_path = network_path("default");
5027 if !std::path::Path::new(&default_path).exists() {
5028 ui::info("\nCreating default network...");
5029 std::fs::create_dir_all(&dir)?;
5030 let net = DevNetwork::default_network();
5031 let json = serde_json::to_string_pretty(&net)?;
5032 std::fs::write(&default_path, json)?;
5033 ui::success(&format!(
5034 "Created default network (bridge={}, subnet={})",
5035 net.bridge_name, net.subnet
5036 ));
5037 } else {
5038 ui::info("\nDefault network already configured.");
5039 }
5040
5041 ui::info("\nCreating data directories...");
5043 let dirs = [
5044 mvm_core::config::mvm_cache_dir(),
5045 mvm_core::config::mvm_config_dir(),
5046 mvm_core::config::mvm_state_dir(),
5047 mvm_core::config::mvm_share_dir(),
5048 ];
5049 for d in &dirs {
5050 std::fs::create_dir_all(d)?;
5051 }
5052
5053 ui::info("\nAvailable images in catalog:");
5055 let catalog = load_bundled_catalog();
5056 for entry in &catalog.entries {
5057 ui::info(&format!(" {} — {}", entry.name, entry.description));
5058 }
5059
5060 ui::success("\nSetup complete!");
5061 ui::info("Next steps:");
5062 ui::info(" mvmctl dev # Enter development environment");
5063 ui::info(" mvmctl image list # Browse available images");
5064 ui::info(" mvmctl doctor # Verify everything is working");
5065 ui::info(" mvmctl up --flake . # Build and run a VM from a Nix flake");
5066
5067 Ok(())
5068}
5069
5070fn platform_label(plat: mvm_core::platform::Platform) -> &'static str {
5071 match plat {
5072 mvm_core::platform::Platform::MacOS => "macOS (Lima + Firecracker)",
5073 mvm_core::platform::Platform::LinuxNative => "Linux (native KVM)",
5074 mvm_core::platform::Platform::LinuxNoKvm => "Linux (no KVM — limited)",
5075 mvm_core::platform::Platform::Wsl2 => "WSL2 (Linux via Windows)",
5076 mvm_core::platform::Platform::Windows => "Windows (experimental)",
5077 }
5078}
5079
5080fn cmd_security(action: SecurityCmd) -> Result<()> {
5085 match action {
5086 SecurityCmd::Status { json } => cmd_security_status(json),
5087 }
5088}
5089
5090fn cmd_security_status(json: bool) -> Result<()> {
5091 use mvm_core::security::{PostureCheck, SecurityLayer};
5092 use mvm_security::posture::SecurityPosture;
5093
5094 let mut checks = Vec::new();
5095
5096 let audit_path = mvm_core::audit::default_audit_log();
5098 let audit_exists = std::path::Path::new(&audit_path).exists();
5099 checks.push(PostureCheck {
5100 layer: SecurityLayer::AuditLogging,
5101 name: "Local audit log".to_string(),
5102 passed: audit_exists,
5103 detail: if audit_exists {
5104 format!("Active at {audit_path}")
5105 } else {
5106 format!("Not found at {audit_path}")
5107 },
5108 });
5109
5110 let share_dir = mvm_core::config::mvm_share_dir();
5112 let xdg_exists = std::path::Path::new(&share_dir).exists();
5113 checks.push(PostureCheck {
5114 layer: SecurityLayer::ConfigImmutability,
5115 name: "XDG data directory".to_string(),
5116 passed: xdg_exists,
5117 detail: if xdg_exists {
5118 format!("Present at {share_dir}")
5119 } else {
5120 "Not yet created — run `mvmctl init`".to_string()
5121 },
5122 });
5123
5124 let net_path = mvm_core::dev_network::network_path("default");
5126 let net_exists = std::path::Path::new(&net_path).exists();
5127 checks.push(PostureCheck {
5128 layer: SecurityLayer::NetworkIsolation,
5129 name: "Default dev network".to_string(),
5130 passed: net_exists,
5131 detail: if net_exists {
5132 "Configured".to_string()
5133 } else {
5134 "Not configured — run `mvmctl init` or `mvmctl network create default`".to_string()
5135 },
5136 });
5137
5138 checks.push(PostureCheck {
5140 layer: SecurityLayer::SeccompFilter,
5141 name: "Seccomp profiles".to_string(),
5142 passed: true,
5143 detail: "5-tier profiles available (essential → unrestricted)".to_string(),
5144 });
5145
5146 checks.push(PostureCheck {
5148 layer: SecurityLayer::VsockAuth,
5149 name: "Vsock authentication".to_string(),
5150 passed: true,
5151 detail: "Ed25519 signing with replay protection".to_string(),
5152 });
5153
5154 checks.push(PostureCheck {
5156 layer: SecurityLayer::GuestHardening,
5157 name: "No SSH policy".to_string(),
5158 passed: true,
5159 detail: "Vsock-only guest communication (no sshd)".to_string(),
5160 });
5161
5162 checks.push(PostureCheck {
5164 layer: SecurityLayer::SupplyChainIntegrity,
5165 name: "Nix-based builds".to_string(),
5166 passed: true,
5167 detail: "All images built from Nix flakes (content-addressed)".to_string(),
5168 });
5169
5170 let timestamp = mvm_core::time::utc_now();
5171 let report = SecurityPosture::evaluate(checks, ×tamp);
5172
5173 if json {
5174 println!("{}", serde_json::to_string_pretty(&report)?);
5175 } else {
5176 print!("{}", SecurityPosture::summary(&report));
5177
5178 let uncovered = SecurityPosture::uncovered_layers(&report.checks);
5179 if !uncovered.is_empty() {
5180 println!("\nUncovered layers (no checks):");
5181 for layer in uncovered {
5182 println!(" - {:?}", layer);
5183 }
5184 }
5185 }
5186
5187 Ok(())
5188}
5189
5190struct OneshotParams<'a> {
5195 template: Option<String>,
5196 cpus: u32,
5197 memory: &'a str,
5198 add_dir: &'a [String],
5199 env: &'a [String],
5200 timeout: u64,
5201 launch_plan: Option<String>,
5202 argv: Vec<String>,
5203}
5204
5205fn run_oneshot(p: OneshotParams<'_>) -> Result<()> {
5206 let OneshotParams {
5207 template,
5208 cpus,
5209 memory,
5210 add_dir,
5211 env,
5212 timeout,
5213 launch_plan,
5214 argv,
5215 } = p;
5216 let target = match (launch_plan.as_ref(), argv.is_empty()) {
5217 (Some(_), false) => {
5218 anyhow::bail!("--launch-plan and a trailing argv are mutually exclusive");
5219 }
5220 (Some(path), true) => {
5221 let entrypoint = crate::exec::load_launch_plan(std::path::Path::new(path))?;
5222 crate::exec::ExecTarget::LaunchPlan { entrypoint }
5223 }
5224 (None, true) => {
5225 anyhow::bail!("`mvmctl exec` requires a command (after `--`) or `--launch-plan <PATH>`")
5226 }
5227 (None, false) => crate::exec::ExecTarget::Inline { argv },
5228 };
5229 let memory_mib = parse_human_size(memory).context("Invalid --memory")?;
5230 let mut add_dirs = Vec::with_capacity(add_dir.len());
5231 for spec in add_dir {
5232 add_dirs.push(crate::exec::AddDir::parse(spec)?);
5233 }
5234 let mut env_pairs = Vec::with_capacity(env.len());
5235 for kv in env {
5236 let (k, v) = kv
5237 .split_once('=')
5238 .ok_or_else(|| anyhow::anyhow!("--env '{kv}': expected KEY=VALUE"))?;
5239 if k.is_empty() {
5240 anyhow::bail!("--env '{kv}': KEY must not be empty");
5241 }
5242 if !k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
5243 || k.starts_with(|c: char| c.is_ascii_digit())
5244 {
5245 anyhow::bail!("--env '{kv}': KEY must match [A-Za-z_][A-Za-z0-9_]* (got '{k}')");
5246 }
5247 env_pairs.push((k.to_string(), v.to_string()));
5248 }
5249 let image = match template {
5250 Some(name) => crate::exec::ImageSource::Template(name),
5251 None => {
5252 ui::info("No --template specified; using bundled default microVM image.");
5253 let (kernel_path, rootfs_path) = ensure_default_microvm_image()?;
5254 crate::exec::ImageSource::Prebuilt {
5255 kernel_path,
5256 rootfs_path,
5257 initrd_path: None,
5258 label: "default-microvm".to_string(),
5259 }
5260 }
5261 };
5262 let req = crate::exec::ExecRequest {
5263 image,
5264 cpus,
5265 memory_mib,
5266 add_dirs,
5267 env: env_pairs,
5268 target,
5269 timeout_secs: timeout,
5270 };
5271 let exit_code = crate::exec::run(req)?;
5272 if exit_code != 0 {
5273 std::process::exit(exit_code);
5274 }
5275 Ok(())
5276}
5277
5278fn cmd_console(name: &str, command: Option<&str>) -> Result<()> {
5283 validate_vm_name(name).with_context(|| format!("Invalid VM name: {:?}", name))?;
5284
5285 if let Some(cmd) = command {
5286 let resp = if let Ok(mut stream) =
5288 mvm_apple_container::vsock_connect(name, mvm_guest::vsock::GUEST_AGENT_PORT)
5289 {
5290 mvm_guest::vsock::send_request(
5291 &mut stream,
5292 &mvm_guest::vsock::GuestRequest::Exec {
5293 command: cmd.to_string(),
5294 stdin: None,
5295 timeout_secs: Some(30),
5296 },
5297 )?
5298 } else {
5299 let instance_dir = microvm::resolve_running_vm_dir(name)?;
5300 mvm_guest::vsock::exec_at(
5301 &mvm_guest::vsock::vsock_uds_path(&instance_dir),
5302 cmd,
5303 None,
5304 30,
5305 )?
5306 };
5307 match resp {
5308 mvm_guest::vsock::GuestResponse::ExecResult {
5309 exit_code,
5310 stdout,
5311 stderr,
5312 } => {
5313 if !stdout.is_empty() {
5314 print!("{stdout}");
5315 }
5316 if !stderr.is_empty() {
5317 eprint!("{stderr}");
5318 }
5319 if exit_code != 0 {
5320 std::process::exit(exit_code);
5321 }
5322 Ok(())
5323 }
5324 mvm_guest::vsock::GuestResponse::Error { message } => {
5325 anyhow::bail!("Console exec error: {message}")
5326 }
5327 other => anyhow::bail!("Unexpected response: {other:?}"),
5328 }
5329 } else {
5330 console_interactive(name)
5332 }
5333}
5334
5335enum ConsoleBackend {
5339 AppleContainer(String),
5340 VsockProxy(String),
5342 Firecracker(String),
5343}
5344
5345fn vsock_proxy_connect(proxy_path: &str, port: u32) -> Result<std::os::unix::net::UnixStream> {
5347 use std::io::Write;
5348 let mut stream = std::os::unix::net::UnixStream::connect(proxy_path)
5349 .with_context(|| format!("Failed to connect to vsock proxy at {proxy_path}"))?;
5350 stream.write_all(&port.to_le_bytes())?;
5351 Ok(stream)
5352}
5353
5354fn console_interactive(name: &str) -> Result<()> {
5359 let (cols, rows) = get_terminal_size();
5361
5362 ui::info(&format!(
5364 "Opening console to VM {:?} ({}x{})...",
5365 name, cols, rows
5366 ));
5367
5368 let backend =
5370 if mvm_apple_container::vsock_connect(name, mvm_guest::vsock::GUEST_AGENT_PORT).is_ok() {
5371 ConsoleBackend::AppleContainer(name.to_string())
5372 } else if std::path::Path::new(&dev_vsock_proxy_path()).exists() {
5373 ConsoleBackend::VsockProxy(dev_vsock_proxy_path())
5374 } else {
5375 let instance_dir = microvm::resolve_running_vm_dir(name)?;
5376 ConsoleBackend::Firecracker(instance_dir)
5377 };
5378
5379 let (resp, connect_data) = match &backend {
5381 ConsoleBackend::AppleContainer(vm_id) => {
5382 let mut stream =
5383 mvm_apple_container::vsock_connect(vm_id, mvm_guest::vsock::GUEST_AGENT_PORT)
5384 .map_err(|e| anyhow::anyhow!("{e}"))?;
5385 let resp = mvm_guest::vsock::send_request(
5386 &mut stream,
5387 &mvm_guest::vsock::GuestRequest::ConsoleOpen { cols, rows },
5388 )?;
5389 (resp, backend)
5390 }
5391 ConsoleBackend::VsockProxy(proxy_path) => {
5392 let mut stream = vsock_proxy_connect(proxy_path, mvm_guest::vsock::GUEST_AGENT_PORT)?;
5393 let resp = mvm_guest::vsock::send_request(
5394 &mut stream,
5395 &mvm_guest::vsock::GuestRequest::ConsoleOpen { cols, rows },
5396 )?;
5397 (resp, backend)
5398 }
5399 ConsoleBackend::Firecracker(instance_dir) => {
5400 let uds = mvm_guest::vsock::vsock_uds_path(instance_dir);
5401 let mut stream = mvm_guest::vsock::connect_to(&uds, 10)?;
5402 let resp = mvm_guest::vsock::send_request(
5403 &mut stream,
5404 &mvm_guest::vsock::GuestRequest::ConsoleOpen { cols, rows },
5405 )?;
5406 (resp, backend)
5407 }
5408 };
5409
5410 let (session_id, data_port) = match resp {
5411 mvm_guest::vsock::GuestResponse::ConsoleOpened {
5412 session_id,
5413 data_port,
5414 } => (session_id, data_port),
5415 mvm_guest::vsock::GuestResponse::Error { message } => {
5416 anyhow::bail!("Console open failed: {message}");
5417 }
5418 other => {
5419 anyhow::bail!("Unexpected response: {other:?}");
5420 }
5421 };
5422
5423 ui::info(&format!(
5424 "Console session {} opened, connecting to data port {}...",
5425 session_id, data_port
5426 ));
5427
5428 std::thread::sleep(std::time::Duration::from_millis(200));
5430
5431 let data_stream = match &connect_data {
5433 ConsoleBackend::AppleContainer(vm_id) => {
5434 mvm_apple_container::vsock_connect(vm_id, data_port)
5435 .map_err(|e| anyhow::anyhow!("Failed to connect to console data port: {e}"))?
5436 }
5437 ConsoleBackend::VsockProxy(proxy_path) => vsock_proxy_connect(proxy_path, data_port)?,
5438 ConsoleBackend::Firecracker(instance_dir) => {
5439 let uds = mvm_guest::vsock::vsock_uds_path(instance_dir);
5441 mvm_guest::vsock::connect_to(&uds, 10)
5442 .context("Failed to connect to console data port")?
5443 }
5444 };
5445
5446 mvm_core::audit::emit(
5447 mvm_core::audit::LocalAuditKind::ConsoleSessionStart,
5448 Some(name),
5449 Some(&format!("session_id={session_id}")),
5450 );
5451
5452 let resize_sender = setup_sigwinch_handler(&connect_data, session_id);
5454
5455 IN_CONSOLE_MODE.store(true, std::sync::atomic::Ordering::SeqCst);
5459 let orig_termios = enter_raw_mode()?;
5460 let result = run_console_relay(data_stream);
5461
5462 restore_terminal(&orig_termios);
5464 IN_CONSOLE_MODE.store(false, std::sync::atomic::Ordering::SeqCst);
5465 drop(resize_sender);
5466
5467 mvm_core::audit::emit(
5468 mvm_core::audit::LocalAuditKind::ConsoleSessionEnd,
5469 Some(name),
5470 Some(&format!("session_id={session_id}")),
5471 );
5472
5473 println!("\nConsole session ended.");
5474 result.map(|_| ())
5475}
5476
5477static SIGWINCH_RECEIVED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
5479
5480extern "C" fn sigwinch_handler(_sig: libc::c_int) {
5481 SIGWINCH_RECEIVED.store(true, std::sync::atomic::Ordering::SeqCst);
5482}
5483
5484fn setup_sigwinch_handler(
5488 backend: &ConsoleBackend,
5489 session_id: u32,
5490) -> Option<std::sync::mpsc::Sender<()>> {
5491 use std::sync::atomic::Ordering;
5492
5493 let backend_info = match backend {
5495 ConsoleBackend::AppleContainer(vm_id) => ConsoleBackend::AppleContainer(vm_id.clone()),
5496 ConsoleBackend::VsockProxy(path) => ConsoleBackend::VsockProxy(path.clone()),
5497 ConsoleBackend::Firecracker(dir) => ConsoleBackend::Firecracker(dir.clone()),
5498 };
5499
5500 let (tx, rx) = std::sync::mpsc::channel::<()>();
5501
5502 unsafe {
5504 libc::signal(
5505 libc::SIGWINCH,
5506 sigwinch_handler as *const () as libc::sighandler_t,
5507 );
5508 }
5509
5510 std::thread::spawn(move || {
5512 loop {
5513 std::thread::sleep(std::time::Duration::from_millis(250));
5514
5515 if let Err(std::sync::mpsc::TryRecvError::Disconnected) = rx.try_recv() {
5517 break;
5518 }
5519
5520 if !SIGWINCH_RECEIVED.swap(false, Ordering::SeqCst) {
5521 continue;
5522 }
5523
5524 let (cols, rows) = get_terminal_size();
5525
5526 let _ = match &backend_info {
5528 ConsoleBackend::AppleContainer(vm_id) => {
5529 mvm_apple_container::vsock_connect(vm_id, mvm_guest::vsock::GUEST_AGENT_PORT)
5530 .ok()
5531 .and_then(|mut stream| {
5532 mvm_guest::vsock::send_request(
5533 &mut stream,
5534 &mvm_guest::vsock::GuestRequest::ConsoleResize {
5535 session_id,
5536 cols,
5537 rows,
5538 },
5539 )
5540 .ok()
5541 })
5542 }
5543 ConsoleBackend::VsockProxy(proxy_path) => {
5544 vsock_proxy_connect(proxy_path, mvm_guest::vsock::GUEST_AGENT_PORT)
5545 .ok()
5546 .and_then(|mut stream| {
5547 mvm_guest::vsock::send_request(
5548 &mut stream,
5549 &mvm_guest::vsock::GuestRequest::ConsoleResize {
5550 session_id,
5551 cols,
5552 rows,
5553 },
5554 )
5555 .ok()
5556 })
5557 }
5558 ConsoleBackend::Firecracker(instance_dir) => {
5559 let uds = mvm_guest::vsock::vsock_uds_path(instance_dir);
5560 mvm_guest::vsock::connect_to(&uds, 5)
5561 .ok()
5562 .and_then(|mut stream| {
5563 mvm_guest::vsock::send_request(
5564 &mut stream,
5565 &mvm_guest::vsock::GuestRequest::ConsoleResize {
5566 session_id,
5567 cols,
5568 rows,
5569 },
5570 )
5571 .ok()
5572 })
5573 }
5574 };
5575 }
5576 });
5577
5578 Some(tx)
5579}
5580
5581fn get_terminal_size() -> (u16, u16) {
5583 unsafe {
5585 let mut ws: libc::winsize = std::mem::zeroed();
5586 if libc::ioctl(1, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 && ws.ws_row > 0 {
5587 (ws.ws_col, ws.ws_row)
5588 } else {
5589 (80, 24)
5590 }
5591 }
5592}
5593
5594fn enter_raw_mode() -> Result<libc::termios> {
5596 unsafe {
5597 let mut orig: libc::termios = std::mem::zeroed();
5598 if libc::tcgetattr(0, &mut orig) != 0 {
5599 anyhow::bail!("Failed to get terminal attributes");
5600 }
5601
5602 let mut raw = orig;
5603 libc::cfmakeraw(&mut raw);
5604 if libc::tcsetattr(0, libc::TCSANOW, &raw) != 0 {
5605 anyhow::bail!("Failed to set raw terminal mode");
5606 }
5607
5608 Ok(orig)
5609 }
5610}
5611
5612fn restore_terminal(orig: &libc::termios) {
5614 unsafe {
5615 libc::tcsetattr(0, libc::TCSANOW, orig);
5616 }
5617}
5618
5619fn run_console_relay(data_stream: std::os::unix::net::UnixStream) -> Result<()> {
5626 use std::io::{Read, Write};
5627 use std::os::unix::io::AsRawFd;
5628
5629 let read_stream = data_stream
5630 .try_clone()
5631 .context("Failed to clone data stream")?;
5632 let write_stream = data_stream;
5633 let stdin_fd = std::io::stdin().as_raw_fd();
5634 let vsock_fd = read_stream.as_raw_fd();
5635
5636 let orig_stdin_flags = unsafe { libc::fcntl(stdin_fd, libc::F_GETFL) };
5638 unsafe {
5639 libc::fcntl(stdin_fd, libc::F_SETFL, orig_stdin_flags | libc::O_NONBLOCK);
5640 libc::fcntl(vsock_fd, libc::F_SETFL, libc::O_NONBLOCK);
5641 }
5642
5643 let mut stdout = std::io::stdout();
5644 let mut writer = write_stream;
5645 let mut buf = [0u8; 4096];
5646
5647 loop {
5648 let mut fds = [
5649 libc::pollfd {
5650 fd: stdin_fd,
5651 events: libc::POLLIN,
5652 revents: 0,
5653 },
5654 libc::pollfd {
5655 fd: vsock_fd,
5656 events: libc::POLLIN,
5657 revents: 0,
5658 },
5659 ];
5660 let ret = unsafe { libc::poll(fds.as_mut_ptr(), 2, 500) };
5661 if ret < 0 {
5662 if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
5663 continue;
5664 }
5665 break;
5666 }
5667
5668 if fds[1].revents & libc::POLLIN != 0 {
5670 match (&read_stream).read(&mut buf) {
5671 Ok(0) => break,
5672 Ok(n) => {
5673 let _ = stdout.write_all(&buf[..n]);
5674 let _ = stdout.flush();
5675 }
5676 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
5677 Err(_) => break,
5678 }
5679 }
5680 if fds[1].revents & (libc::POLLHUP | libc::POLLERR) != 0
5681 && fds[1].revents & libc::POLLIN == 0
5682 {
5683 break;
5684 }
5685
5686 if fds[0].revents & (libc::POLLIN | libc::POLLHUP) != 0 {
5688 let mut inbuf = [0u8; 1024];
5689 match std::io::stdin().read(&mut inbuf) {
5690 Ok(0) => break,
5691 Ok(n) => {
5692 if writer.write_all(&inbuf[..n]).is_err() {
5693 break;
5694 }
5695 let _ = writer.flush();
5696 }
5697 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
5698 Err(_) => break,
5699 }
5700 }
5701 }
5702
5703 unsafe {
5705 libc::fcntl(stdin_fd, libc::F_SETFL, orig_stdin_flags);
5706 }
5707
5708 Ok(())
5709}
5710
5711#[cfg(test)]
5716mod tests {
5717 use super::*;
5718 use clap::Parser;
5719
5720 #[test]
5721 fn test_cleanup_defaults() {
5722 let cli = Cli::try_parse_from(["mvmctl", "cleanup"]).unwrap();
5723 match cli.command {
5724 Commands::Cleanup { keep, all, verbose } => {
5725 assert_eq!(keep, None);
5726 assert!(!all);
5727 assert!(!verbose);
5728 }
5729 _ => panic!("Expected Cleanup command"),
5730 }
5731 }
5732
5733 #[test]
5734 fn test_cleanup_keep_flag() {
5735 let cli = Cli::try_parse_from(["mvmctl", "cleanup", "--keep", "9"]).unwrap();
5736 match cli.command {
5737 Commands::Cleanup { keep, all, verbose } => {
5738 assert_eq!(keep, Some(9));
5739 assert!(!all);
5740 assert!(!verbose);
5741 }
5742 _ => panic!("Expected Cleanup command"),
5743 }
5744 }
5745
5746 #[test]
5747 fn test_cleanup_all_flag() {
5748 let cli = Cli::try_parse_from(["mvmctl", "cleanup", "--all"]).unwrap();
5749 match cli.command {
5750 Commands::Cleanup { keep, all, verbose } => {
5751 assert_eq!(keep, None);
5752 assert!(all);
5753 assert!(!verbose);
5754 }
5755 _ => panic!("Expected Cleanup command"),
5756 }
5757 }
5758
5759 #[test]
5760 fn test_cleanup_verbose_flag() {
5761 let cli = Cli::try_parse_from(["mvmctl", "cleanup", "--verbose"]).unwrap();
5762 match cli.command {
5763 Commands::Cleanup { keep, all, verbose } => {
5764 assert_eq!(keep, None);
5765 assert!(!all);
5766 assert!(verbose);
5767 }
5768 _ => panic!("Expected Cleanup command"),
5769 }
5770 }
5771
5772 #[test]
5775 fn test_build_flake_with_profile() {
5776 let cli = Cli::try_parse_from(["mvmctl", "build", "--flake", ".", "--profile", "gateway"])
5777 .unwrap();
5778 match cli.command {
5779 Commands::Build { flake, profile, .. } => {
5780 assert_eq!(flake.as_deref(), Some("."));
5781 assert_eq!(profile.as_deref(), Some("gateway"));
5782 }
5783 _ => panic!("Expected Build command"),
5784 }
5785 }
5786
5787 #[test]
5788 fn test_build_flake_defaults_to_no_profile() {
5789 let cli = Cli::try_parse_from(["mvmctl", "build", "--flake", "."]).unwrap();
5790 match cli.command {
5791 Commands::Build { flake, profile, .. } => {
5792 assert_eq!(flake.as_deref(), Some("."));
5793 assert!(profile.is_none(), "profile should be None when omitted");
5794 }
5795 _ => panic!("Expected Build command"),
5796 }
5797 }
5798
5799 #[test]
5800 fn test_build_mvmfile_mode_still_works() {
5801 let cli = Cli::try_parse_from(["mvmctl", "build", "myimage"]).unwrap();
5802 match cli.command {
5803 Commands::Build { path, flake, .. } => {
5804 assert_eq!(path, "myimage");
5805 assert!(flake.is_none(), "Mvmfile mode should have no --flake");
5806 }
5807 _ => panic!("Expected Build command"),
5808 }
5809 }
5810
5811 #[test]
5812 fn test_resolve_flake_ref_remote_passthrough() {
5813 let resolved = resolve_flake_ref("github:user/repo").unwrap();
5814 assert_eq!(resolved, "github:user/repo");
5815 }
5816
5817 #[test]
5818 fn test_resolve_flake_ref_remote_with_path() {
5819 let resolved = resolve_flake_ref("github:user/repo#attr").unwrap();
5820 assert_eq!(resolved, "github:user/repo#attr");
5821 }
5822
5823 #[test]
5824 fn test_resolve_flake_ref_absolute_path() {
5825 let resolved = resolve_flake_ref("/tmp").unwrap();
5826 assert!(
5828 resolved == "/tmp" || resolved == "/private/tmp",
5829 "unexpected resolved path: {}",
5830 resolved
5831 );
5832 }
5833
5834 #[test]
5835 fn test_resolve_flake_ref_nonexistent_fails() {
5836 let result = resolve_flake_ref("/nonexistent/path/that/does/not/exist");
5837 assert!(result.is_err());
5838 }
5839
5840 #[test]
5843 fn test_run_parses_all_flags() {
5844 let cli = Cli::try_parse_from([
5845 "mvmctl",
5846 "run",
5847 "--flake",
5848 ".",
5849 "--profile",
5850 "full",
5851 "--cpus",
5852 "4",
5853 "--memory",
5854 "2048",
5855 ])
5856 .unwrap();
5857 match cli.command {
5858 Commands::Up {
5859 flake,
5860 profile,
5861 cpus,
5862 memory,
5863 ..
5864 } => {
5865 assert_eq!(flake, Some(".".to_string()));
5866 assert_eq!(profile.as_deref(), Some("full"));
5867 assert_eq!(cpus, Some(4));
5868 assert_eq!(memory, Some("2048".to_string()));
5869 }
5870 _ => panic!("Expected Run command"),
5871 }
5872 }
5873
5874 #[test]
5875 fn test_run_defaults() {
5876 let cli = Cli::try_parse_from(["mvmctl", "run", "--flake", "."]).unwrap();
5877 match cli.command {
5878 Commands::Up {
5879 flake,
5880 template,
5881 name,
5882 profile,
5883 cpus,
5884 memory,
5885 volume,
5886 hypervisor,
5887 ..
5888 } => {
5889 assert_eq!(flake, Some(".".to_string()));
5890 assert!(template.is_none(), "template should be None when omitted");
5891 assert!(name.is_none(), "name should be None when omitted");
5892 assert!(profile.is_none(), "profile should be None when omitted");
5893 assert!(cpus.is_none(), "cpus should be None when omitted");
5894 assert!(memory.is_none(), "memory should be None when omitted");
5895 assert_eq!(volume.len(), 0);
5896 assert_eq!(hypervisor, "firecracker");
5897 }
5898 _ => panic!("Expected Run command"),
5899 }
5900 }
5901
5902 #[test]
5903 fn test_run_without_source_uses_default_microvm() {
5904 let cli = Cli::try_parse_from(["mvmctl", "run"]).expect("parse");
5908 match cli.command {
5909 Commands::Up {
5910 flake, template, ..
5911 } => {
5912 assert!(flake.is_none(), "no --flake should be parsed");
5913 assert!(template.is_none(), "no --template should be parsed");
5914 }
5915 _ => panic!("Expected Run command"),
5916 }
5917 }
5918
5919 #[test]
5920 fn test_run_template_flag() {
5921 let cli = Cli::try_parse_from(["mvmctl", "run", "--template", "openclaw"]).unwrap();
5922 match cli.command {
5923 Commands::Up {
5924 flake, template, ..
5925 } => {
5926 assert!(flake.is_none());
5927 assert_eq!(template, Some("openclaw".to_string()));
5928 }
5929 _ => panic!("Expected Run command"),
5930 }
5931 }
5932
5933 #[test]
5934 fn test_run_flake_and_template_conflict() {
5935 let result =
5936 Cli::try_parse_from(["mvmctl", "run", "--flake", ".", "--template", "openclaw"]);
5937 assert!(
5938 result.is_err(),
5939 "--flake and --template should be mutually exclusive"
5940 );
5941 }
5942
5943 #[test]
5944 fn test_run_volume_dir_inject() {
5945 let cli = Cli::try_parse_from([
5946 "mvmctl",
5947 "run",
5948 "--flake",
5949 ".",
5950 "-v",
5951 "/tmp/config:/mnt/config",
5952 "-v",
5953 "/tmp/secrets:/mnt/secrets",
5954 ])
5955 .unwrap();
5956 match cli.command {
5957 Commands::Up { volume, .. } => {
5958 assert_eq!(volume.len(), 2);
5959 assert_eq!(volume[0], "/tmp/config:/mnt/config");
5960 assert_eq!(volume[1], "/tmp/secrets:/mnt/secrets");
5961 }
5962 _ => panic!("Expected Run command"),
5963 }
5964 }
5965
5966 #[test]
5967 fn test_run_volume_persistent() {
5968 let cli =
5969 Cli::try_parse_from(["mvmctl", "run", "--flake", ".", "-v", "/data:/mnt/data:4G"])
5970 .unwrap();
5971 match cli.command {
5972 Commands::Up { volume, .. } => {
5973 assert_eq!(volume.len(), 1);
5974 assert_eq!(volume[0], "/data:/mnt/data:4G");
5975 }
5976 _ => panic!("Expected Run command"),
5977 }
5978 }
5979
5980 #[test]
5981 fn test_parse_volume_spec_dir_inject() {
5982 let spec = parse_volume_spec("/tmp/config:/mnt/config").unwrap();
5983 match spec {
5984 VolumeSpec::DirInject {
5985 host_dir,
5986 guest_mount,
5987 } => {
5988 assert_eq!(host_dir, "/tmp/config");
5989 assert_eq!(guest_mount, "/mnt/config");
5990 }
5991 _ => panic!("Expected DirInject"),
5992 }
5993 }
5994
5995 #[test]
5996 fn test_parse_volume_spec_persistent() {
5997 let spec = parse_volume_spec("/data:/mnt/data:4G").unwrap();
5998 match spec {
5999 VolumeSpec::Persistent(vol) => {
6000 assert_eq!(vol.host, "/data");
6001 assert_eq!(vol.guest, "/mnt/data");
6002 assert_eq!(vol.size, "4G");
6003 }
6004 _ => panic!("Expected Persistent"),
6005 }
6006 }
6007
6008 #[test]
6009 fn test_parse_volume_spec_invalid() {
6010 let result = parse_volume_spec("just-a-path");
6011 assert!(result.is_err());
6012 }
6013
6014 #[test]
6015 fn test_parse_volume_spec_unsupported_mount() {
6016 let spec = parse_volume_spec("/tmp/foo:/mnt/custom").unwrap();
6017 match spec {
6019 VolumeSpec::DirInject { guest_mount, .. } => {
6020 assert_eq!(guest_mount, "/mnt/custom");
6021 }
6022 _ => panic!("Expected DirInject"),
6023 }
6024 }
6025
6026 #[test]
6027 fn test_run_port_and_env_flags() {
6028 let cli = Cli::try_parse_from([
6029 "mvmctl",
6030 "run",
6031 "--flake",
6032 ".",
6033 "-p",
6034 "3333:3000",
6035 "-p",
6036 "3334:3002",
6037 "-e",
6038 "NODE_ENV=production",
6039 "-e",
6040 "DEBUG=true",
6041 ])
6042 .unwrap();
6043 match cli.command {
6044 Commands::Up { port, env, .. } => {
6045 assert_eq!(port, vec!["3333:3000", "3334:3002"]);
6046 assert_eq!(env, vec!["NODE_ENV=production", "DEBUG=true"]);
6047 }
6048 _ => panic!("Expected Run command"),
6049 }
6050 }
6051
6052 #[test]
6053 fn test_run_port_and_env_default_empty() {
6054 let cli = Cli::try_parse_from(["mvmctl", "run", "--flake", "."]).unwrap();
6055 match cli.command {
6056 Commands::Up { port, env, .. } => {
6057 assert!(port.is_empty());
6058 assert!(env.is_empty());
6059 }
6060 _ => panic!("Expected Run command"),
6061 }
6062 }
6063
6064 #[test]
6065 fn test_run_forward_flag() {
6066 let cli = Cli::try_parse_from([
6067 "mvmctl",
6068 "run",
6069 "--flake",
6070 ".",
6071 "-p",
6072 "3333:3000",
6073 "--forward",
6074 ])
6075 .unwrap();
6076 match cli.command {
6077 Commands::Up { forward, port, .. } => {
6078 assert!(forward);
6079 assert_eq!(port, vec!["3333:3000"]);
6080 }
6081 _ => panic!("Expected Run command"),
6082 }
6083 }
6084
6085 #[test]
6086 fn test_run_forward_default_false() {
6087 let cli = Cli::try_parse_from(["mvmctl", "run", "--flake", "."]).unwrap();
6088 match cli.command {
6089 Commands::Up { forward, .. } => {
6090 assert!(!forward);
6091 }
6092 _ => panic!("Expected Run command"),
6093 }
6094 }
6095
6096 #[test]
6097 fn test_parse_port_specs_multiple() {
6098 let specs = vec!["3333:3000".to_string(), "8080".to_string()];
6099 let result = parse_port_specs(&specs).unwrap();
6100 assert_eq!(result.len(), 2);
6101 assert_eq!(result[0].host, 3333);
6102 assert_eq!(result[0].guest, 3000);
6103 assert_eq!(result[1].host, 8080);
6104 assert_eq!(result[1].guest, 8080);
6105 }
6106
6107 #[test]
6108 fn test_parse_port_specs_empty() {
6109 let specs: Vec<String> = vec![];
6110 let result = parse_port_specs(&specs).unwrap();
6111 assert!(result.is_empty());
6112 }
6113
6114 #[test]
6115 fn test_ports_to_drive_file() {
6116 use mvm_runtime::config::PortMapping;
6117 let ports = vec![
6118 PortMapping {
6119 host: 3333,
6120 guest: 3000,
6121 },
6122 PortMapping {
6123 host: 3334,
6124 guest: 3002,
6125 },
6126 ];
6127 let f = ports_to_drive_file(&ports).unwrap();
6128 assert_eq!(f.name, "mvm-ports.env");
6129 assert!(f.content.contains("MVM_PORT_MAP=\"3333:3000,3334:3002\""));
6130 assert_eq!(f.mode, 0o444);
6131 }
6132
6133 #[test]
6134 fn test_ports_to_drive_file_empty() {
6135 assert!(ports_to_drive_file(&[]).is_none());
6136 }
6137
6138 #[test]
6139 fn test_env_vars_to_drive_file() {
6140 let vars = vec!["NODE_ENV=production".to_string(), "DEBUG=true".to_string()];
6141 let f = env_vars_to_drive_file(&vars).unwrap();
6142 assert_eq!(f.name, "mvm-env.env");
6143 assert!(f.content.contains("export NODE_ENV=production"));
6144 assert!(f.content.contains("export DEBUG=true"));
6145 assert_eq!(f.mode, 0o444);
6146 }
6147
6148 #[test]
6149 fn test_env_vars_to_drive_file_empty() {
6150 let vars: Vec<String> = vec![];
6151 assert!(env_vars_to_drive_file(&vars).is_none());
6152 }
6153
6154 #[test]
6159 fn test_down_parses_no_args() {
6160 let cli = Cli::try_parse_from(["mvmctl", "down"]).unwrap();
6161 match cli.command {
6162 Commands::Down { name, config } => {
6163 assert!(name.is_none());
6164 assert!(config.is_none());
6165 }
6166 _ => panic!("Expected Down command"),
6167 }
6168 }
6169
6170 #[test]
6171 fn test_down_parses_with_name() {
6172 let cli = Cli::try_parse_from(["mvmctl", "down", "gw"]).unwrap();
6173 match cli.command {
6174 Commands::Down { name, config } => {
6175 assert_eq!(name.as_deref(), Some("gw"));
6176 assert!(config.is_none());
6177 }
6178 _ => panic!("Expected Down command"),
6179 }
6180 }
6181
6182 #[test]
6183 fn test_down_parses_with_config() {
6184 let cli = Cli::try_parse_from(["mvmctl", "down", "-f", "my-fleet.toml"]).unwrap();
6185 match cli.command {
6186 Commands::Down { name, config } => {
6187 assert!(name.is_none());
6188 assert_eq!(config.as_deref(), Some("my-fleet.toml"));
6189 }
6190 _ => panic!("Expected Down command"),
6191 }
6192 }
6193
6194 #[test]
6197 fn test_read_dir_to_drive_files_reads_files() {
6198 let dir = tempfile::tempdir().unwrap();
6199 std::fs::write(dir.path().join("a.txt"), "hello").unwrap();
6200 std::fs::write(dir.path().join("b.env"), "KEY=val").unwrap();
6201
6202 let files = read_dir_to_drive_files(dir.path().to_str().unwrap(), 0o444).unwrap();
6203 assert_eq!(files.len(), 2);
6204
6205 let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
6206 assert!(names.contains(&"a.txt"));
6207 assert!(names.contains(&"b.env"));
6208
6209 for f in &files {
6210 assert_eq!(f.mode, 0o444);
6211 }
6212 }
6213
6214 #[test]
6215 fn test_read_dir_to_drive_files_skips_directories() {
6216 let dir = tempfile::tempdir().unwrap();
6217 std::fs::write(dir.path().join("file.txt"), "content").unwrap();
6218 std::fs::create_dir(dir.path().join("subdir")).unwrap();
6219
6220 let files = read_dir_to_drive_files(dir.path().to_str().unwrap(), 0o400).unwrap();
6221 assert_eq!(files.len(), 1);
6222 assert_eq!(files[0].name, "file.txt");
6223 assert_eq!(files[0].mode, 0o400);
6224 }
6225
6226 #[test]
6227 fn test_read_dir_to_drive_files_empty_dir() {
6228 let dir = tempfile::tempdir().unwrap();
6229 let files = read_dir_to_drive_files(dir.path().to_str().unwrap(), 0o444).unwrap();
6230 assert!(files.is_empty());
6231 }
6232
6233 #[test]
6234 fn test_read_dir_to_drive_files_nonexistent_dir() {
6235 let result = read_dir_to_drive_files("/nonexistent/path/abc123", 0o444);
6236 assert!(result.is_err());
6237 }
6238
6239 #[test]
6242 fn test_forward_parses() {
6243 let cli = Cli::try_parse_from(["mvmctl", "forward", "swift", "3000"]).unwrap();
6244 match cli.command {
6245 Commands::Forward { name, port, ports } => {
6246 assert_eq!(name, "swift");
6247 assert!(port.is_empty());
6249 assert_eq!(ports, vec!["3000"]);
6250 }
6251 _ => panic!("Expected Forward command"),
6252 }
6253 }
6254
6255 #[test]
6256 fn test_forward_with_port_mapping() {
6257 let cli = Cli::try_parse_from(["mvmctl", "forward", "swift", "8080:3000"]).unwrap();
6258 match cli.command {
6259 Commands::Forward { name, port, ports } => {
6260 assert_eq!(name, "swift");
6261 assert!(port.is_empty());
6262 assert_eq!(ports, vec!["8080:3000"]);
6263 }
6264 _ => panic!("Expected Forward command"),
6265 }
6266 }
6267
6268 #[test]
6269 fn test_forward_with_flag() {
6270 let cli = Cli::try_parse_from(["mvmctl", "forward", "swift", "-p", "3000"]).unwrap();
6271 match cli.command {
6272 Commands::Forward { name, port, ports } => {
6273 assert_eq!(name, "swift");
6274 assert_eq!(port, vec!["3000"]);
6275 assert!(ports.is_empty());
6276 }
6277 _ => panic!("Expected Forward command"),
6278 }
6279 }
6280
6281 #[test]
6282 fn test_forward_multiple_ports() {
6283 let cli =
6284 Cli::try_parse_from(["mvmctl", "forward", "swift", "-p", "3000", "-p", "8080:443"])
6285 .unwrap();
6286 match cli.command {
6287 Commands::Forward { name, port, ports } => {
6288 assert_eq!(name, "swift");
6289 assert_eq!(port, vec!["3000", "8080:443"]);
6290 assert!(ports.is_empty());
6291 }
6292 _ => panic!("Expected Forward command"),
6293 }
6294 }
6295
6296 #[test]
6297 fn test_forward_multiple_positional() {
6298 let cli = Cli::try_parse_from(["mvmctl", "forward", "swift", "3000", "8080:443"]).unwrap();
6299 match cli.command {
6300 Commands::Forward { name, port, ports } => {
6301 assert_eq!(name, "swift");
6302 assert!(port.is_empty());
6303 assert_eq!(ports, vec!["3000", "8080:443"]);
6304 }
6305 _ => panic!("Expected Forward command"),
6306 }
6307 }
6308
6309 #[test]
6310 fn test_forward_no_ports_parses() {
6311 let cli = Cli::try_parse_from(["mvmctl", "forward", "swift"]).unwrap();
6314 match cli.command {
6315 Commands::Forward { name, port, ports } => {
6316 assert_eq!(name, "swift");
6317 assert!(port.is_empty());
6318 assert!(ports.is_empty());
6319 }
6320 _ => panic!("Expected Forward command"),
6321 }
6322 }
6323
6324 #[test]
6325 fn test_parse_port_spec_single() {
6326 let (local, guest) = parse_port_spec("3000").unwrap();
6327 assert_eq!(local, 3000);
6328 assert_eq!(guest, 3000);
6329 }
6330
6331 #[test]
6332 fn test_parse_port_spec_mapping() {
6333 let (local, guest) = parse_port_spec("8080:3000").unwrap();
6334 assert_eq!(local, 8080);
6335 assert_eq!(guest, 3000);
6336 }
6337
6338 #[test]
6339 fn test_parse_port_spec_invalid() {
6340 assert!(parse_port_spec("abc").is_err());
6341 assert!(parse_port_spec("abc:3000").is_err());
6342 assert!(parse_port_spec("3000:abc").is_err());
6343 assert!(parse_port_spec("99999").is_err());
6344 }
6345
6346 #[test]
6351 fn test_ls_alias_for_ps() {
6352 let cli = Cli::try_parse_from(["mvmctl", "ls"]).unwrap();
6353 assert!(matches!(cli.command, Commands::Ps { .. }));
6354 }
6355
6356 #[test]
6357 fn test_ps_command() {
6358 let cli = Cli::try_parse_from(["mvmctl", "ps"]).unwrap();
6359 assert!(matches!(cli.command, Commands::Ps { .. }));
6360 }
6361
6362 #[test]
6363 fn test_start_alias_for_run() {
6364 assert!(Cli::try_parse_from(["mvmctl", "start", "--flake", "."]).is_ok());
6366 }
6367
6368 #[test]
6373 fn test_metrics_command_parses() {
6374 let cli = Cli::try_parse_from(["mvmctl", "metrics"]).unwrap();
6375 assert!(matches!(cli.command, Commands::Metrics { json: false }));
6376 }
6377
6378 #[test]
6379 fn test_metrics_json_flag_parses() {
6380 let cli = Cli::try_parse_from(["mvmctl", "metrics", "--json"]).unwrap();
6381 assert!(matches!(cli.command, Commands::Metrics { json: true }));
6382 }
6383
6384 #[test]
6385 fn test_metrics_snapshot_serializes_to_json() {
6386 let snap = mvm_core::observability::metrics::global().snapshot();
6387 let json = serde_json::to_string(&snap).expect("snapshot must serialize");
6388 assert!(json.contains("requests_total"));
6389 assert!(json.contains("instances_created"));
6390 }
6391
6392 #[test]
6393 fn test_prometheus_exposition_has_expected_metrics() {
6394 let prom = mvm_core::observability::metrics::global().prometheus_exposition();
6395 assert!(prom.contains("mvm_requests_total"));
6396 assert!(prom.contains("mvm_instances_created_total"));
6397 assert!(prom.contains("# HELP"));
6398 assert!(prom.contains("# TYPE"));
6399 }
6400
6401 #[test]
6404 fn test_config_show_parses() {
6405 let cli = Cli::try_parse_from(["mvmctl", "config", "show"]).unwrap();
6406 assert!(matches!(
6407 cli.command,
6408 Commands::Config {
6409 action: ConfigAction::Show
6410 }
6411 ));
6412 }
6413
6414 #[test]
6415 fn test_config_set_parses() {
6416 let cli = Cli::try_parse_from(["mvmctl", "config", "set", "lima_cpus", "4"]).unwrap();
6417 match cli.command {
6418 Commands::Config {
6419 action: ConfigAction::Set { key, value },
6420 } => {
6421 assert_eq!(key, "lima_cpus");
6422 assert_eq!(value, "4");
6423 }
6424 _ => panic!("Expected Config Set command"),
6425 }
6426 }
6427
6428 #[test]
6429 fn test_config_show_output_contains_lima_cpus() {
6430 let tmp = tempfile::tempdir().unwrap();
6431 let cfg = mvm_core::user_config::MvmConfig::default();
6432 mvm_core::user_config::save(&cfg, Some(tmp.path())).unwrap();
6433 let loaded = mvm_core::user_config::load(Some(tmp.path()));
6434 let text = toml::to_string_pretty(&loaded).unwrap();
6435 assert!(text.contains("lima_cpus"));
6436 }
6437
6438 #[test]
6439 fn test_config_set_persists() {
6440 let tmp = tempfile::tempdir().unwrap();
6441 let mut cfg = mvm_core::user_config::load(Some(tmp.path()));
6442 mvm_core::user_config::set_key(&mut cfg, "lima_cpus", "4").unwrap();
6443 mvm_core::user_config::save(&cfg, Some(tmp.path())).unwrap();
6444 let reloaded = mvm_core::user_config::load(Some(tmp.path()));
6445 assert_eq!(reloaded.lima_cpus, 4);
6446 }
6447
6448 #[test]
6449 fn test_config_set_unknown_key_fails() {
6450 let mut cfg = mvm_core::user_config::MvmConfig::default();
6451 let err = mvm_core::user_config::set_key(&mut cfg, "nonexistent_key", "5").unwrap_err();
6452 assert!(err.to_string().contains("Unknown config key"));
6453 }
6454
6455 #[test]
6458 fn test_uninstall_parses_defaults() {
6459 let cli = Cli::try_parse_from(["mvmctl", "uninstall", "--yes"]).unwrap();
6460 assert!(matches!(
6461 cli.command,
6462 Commands::Uninstall {
6463 yes: true,
6464 all: false,
6465 dry_run: false,
6466 }
6467 ));
6468 }
6469
6470 #[test]
6471 fn test_uninstall_dry_run_parses() {
6472 let cli = Cli::try_parse_from(["mvmctl", "uninstall", "--dry-run", "--yes"]).unwrap();
6473 assert!(matches!(
6474 cli.command,
6475 Commands::Uninstall {
6476 yes: true,
6477 all: false,
6478 dry_run: true,
6479 }
6480 ));
6481 }
6482
6483 #[test]
6484 fn test_uninstall_all_flag_parses() {
6485 let cli = Cli::try_parse_from(["mvmctl", "uninstall", "--all", "--yes"]).unwrap();
6486 assert!(matches!(
6487 cli.command,
6488 Commands::Uninstall {
6489 yes: true,
6490 all: true,
6491 dry_run: false,
6492 }
6493 ));
6494 }
6495
6496 #[test]
6499 fn test_audit_tail_parses() {
6500 let cli = Cli::try_parse_from(["mvmctl", "audit", "tail"]).unwrap();
6501 assert!(matches!(
6502 cli.command,
6503 Commands::Audit {
6504 action: AuditCmd::Tail {
6505 lines: 20,
6506 follow: false,
6507 }
6508 }
6509 ));
6510 }
6511
6512 #[test]
6513 fn test_audit_tail_follow_parses() {
6514 let cli =
6515 Cli::try_parse_from(["mvmctl", "audit", "tail", "--follow", "--lines", "50"]).unwrap();
6516 assert!(matches!(
6517 cli.command,
6518 Commands::Audit {
6519 action: AuditCmd::Tail {
6520 lines: 50,
6521 follow: true,
6522 }
6523 }
6524 ));
6525 }
6526
6527 #[test]
6528 fn test_audit_tail_no_log_prints_message() {
6529 let tmp = tempfile::tempdir().unwrap();
6532 let nonexistent = tmp.path().join("audit.jsonl");
6533 assert!(!nonexistent.exists());
6535 }
6536
6537 #[test]
6540 fn test_clap_port_spec_valid() {
6541 assert!(clap_port_spec("8080").is_ok());
6542 assert!(clap_port_spec("8080:80").is_ok());
6543 assert!(clap_port_spec("443:443").is_ok());
6544 assert!(clap_port_spec("0:0").is_ok());
6545 }
6546
6547 #[test]
6548 fn test_clap_port_spec_invalid() {
6549 assert!(clap_port_spec("").is_err());
6550 assert!(clap_port_spec("abc").is_err());
6551 assert!(clap_port_spec("8080:abc").is_err());
6552 assert!(clap_port_spec("abc:80").is_err());
6553 assert!(clap_port_spec("99999").is_err()); }
6555
6556 #[test]
6557 fn test_clap_volume_spec_valid() {
6558 assert!(clap_volume_spec("/host:/guest").is_ok());
6559 assert!(clap_volume_spec("/host/path:/guest/mount").is_ok());
6560 assert!(clap_volume_spec("/host:/guest:1G").is_ok());
6561 assert!(clap_volume_spec("./local:/app").is_ok());
6562 }
6563
6564 #[test]
6565 fn test_clap_volume_spec_invalid() {
6566 assert!(clap_volume_spec("").is_err());
6567 assert!(clap_volume_spec("nocolon").is_err());
6568 assert!(clap_volume_spec(":/guest").is_err()); }
6570
6571 #[test]
6572 fn test_clap_vm_name_valid() {
6573 assert!(clap_vm_name("my-vm").is_ok());
6574 assert!(clap_vm_name("vm1").is_ok());
6575 assert!(clap_vm_name("a").is_ok());
6576 }
6577
6578 #[test]
6579 fn test_clap_vm_name_invalid() {
6580 assert!(clap_vm_name("").is_err());
6581 assert!(clap_vm_name("UPPER").is_err());
6582 assert!(clap_vm_name("has space").is_err());
6583 assert!(clap_vm_name("-leading").is_err());
6584 }
6585
6586 #[test]
6587 fn test_clap_flake_ref_valid() {
6588 assert!(clap_flake_ref(".").is_ok());
6589 assert!(clap_flake_ref("github:org/repo").is_ok());
6590 assert!(clap_flake_ref("/absolute/path").is_ok());
6591 }
6592
6593 #[test]
6594 fn test_clap_flake_ref_invalid() {
6595 assert!(clap_flake_ref("").is_err());
6596 assert!(clap_flake_ref(". ; rm -rf /").is_err());
6597 assert!(clap_flake_ref("$(evil)").is_err());
6598 }
6599
6600 #[test]
6601 fn test_run_rejects_invalid_vm_name_at_parse_time() {
6602 let result = Cli::try_parse_from(["mvmctl", "run", "--flake", ".", "--name", "INVALID"]);
6604 assert!(
6605 result.is_err(),
6606 "uppercase VM name should fail at parse time"
6607 );
6608 }
6609
6610 #[test]
6611 fn test_run_rejects_invalid_flake_at_parse_time() {
6612 let result =
6613 Cli::try_parse_from(["mvmctl", "run", "--flake", ". ; rm -rf /", "--name", "vm1"]);
6614 assert!(
6615 result.is_err(),
6616 "shell-injection flake ref should fail at parse time"
6617 );
6618 }
6619
6620 #[test]
6621 fn test_run_rejects_invalid_port_at_parse_time() {
6622 let result = Cli::try_parse_from(["mvmctl", "run", "--flake", ".", "--port", "notaport"]);
6623 assert!(result.is_err(), "invalid port should fail at parse time");
6624 }
6625
6626 #[test]
6629 fn test_run_uses_config_default_cpus() {
6630 let cfg = mvm_core::user_config::MvmConfig {
6632 default_cpus: 4,
6633 ..mvm_core::user_config::MvmConfig::default()
6634 };
6635
6636 let cli_cpus: Option<u32> = None;
6638 let effective = cli_cpus.or(Some(cfg.default_cpus));
6639 assert_eq!(effective, Some(4));
6640 }
6641
6642 #[test]
6643 fn test_run_cli_flag_overrides_config_cpus() {
6644 let cfg = mvm_core::user_config::MvmConfig {
6646 default_cpus: 4,
6647 ..mvm_core::user_config::MvmConfig::default()
6648 };
6649
6650 let cli_cpus: Option<u32> = Some(8);
6651 let effective = cli_cpus.or(Some(cfg.default_cpus));
6652 assert_eq!(effective, Some(8));
6653 }
6654
6655 #[test]
6656 fn test_run_uses_config_default_memory() {
6657 let cfg = mvm_core::user_config::MvmConfig {
6658 default_memory_mib: 2048,
6659 ..mvm_core::user_config::MvmConfig::default()
6660 };
6661
6662 let cli_memory: Option<u32> = None;
6663 let effective = cli_memory.or(Some(cfg.default_memory_mib));
6664 assert_eq!(effective, Some(2048));
6665 }
6666
6667 #[test]
6668 fn test_run_cli_flag_overrides_config_memory() {
6669 let cfg = mvm_core::user_config::MvmConfig {
6670 default_memory_mib: 2048,
6671 ..mvm_core::user_config::MvmConfig::default()
6672 };
6673
6674 let cli_memory: Option<u32> = Some(512);
6675 let effective = cli_memory.or(Some(cfg.default_memory_mib));
6676 assert_eq!(effective, Some(512));
6677 }
6678
6679 #[test]
6680 fn test_resolve_network_policy_default() {
6681 let policy = resolve_network_policy(None, &[]).unwrap();
6682 assert!(policy.is_unrestricted());
6683 }
6684
6685 #[test]
6686 fn test_resolve_network_policy_preset() {
6687 let policy = resolve_network_policy(Some("dev"), &[]).unwrap();
6688 assert!(!policy.is_unrestricted());
6689 let rules = policy.resolve_rules().unwrap();
6690 assert!(rules.iter().any(|r| r.host == "github.com"));
6691 }
6692
6693 #[test]
6694 fn test_resolve_network_policy_allow_list() {
6695 let allow = vec![
6696 "github.com:443".to_string(),
6697 "api.openai.com:443".to_string(),
6698 ];
6699 let policy = resolve_network_policy(None, &allow).unwrap();
6700 let rules = policy.resolve_rules().unwrap();
6701 assert_eq!(rules.len(), 2);
6702 }
6703
6704 #[test]
6705 fn test_resolve_network_policy_mutual_exclusion() {
6706 let allow = vec!["github.com:443".to_string()];
6707 let result = resolve_network_policy(Some("dev"), &allow);
6708 assert!(result.is_err());
6709 }
6710
6711 #[test]
6712 fn test_resolve_network_policy_invalid_preset() {
6713 let result = resolve_network_policy(Some("bogus"), &[]);
6714 assert!(result.is_err());
6715 }
6716
6717 #[test]
6718 fn test_resolve_network_policy_invalid_allow_entry() {
6719 let allow = vec!["not-a-host-port".to_string()];
6720 let result = resolve_network_policy(None, &allow);
6721 assert!(result.is_err());
6722 }
6723
6724 #[test]
6727 fn test_network_list_help() {
6728 let cli = Cli::try_parse_from(["mvmctl", "network", "list"]);
6729 assert!(cli.is_ok());
6730 }
6731
6732 #[test]
6733 fn test_network_create_help() {
6734 let cli = Cli::try_parse_from(["mvmctl", "network", "create", "mynet"]);
6735 assert!(cli.is_ok());
6736 }
6737
6738 #[test]
6739 fn test_network_inspect_help() {
6740 let cli = Cli::try_parse_from(["mvmctl", "network", "inspect", "mynet"]);
6741 assert!(cli.is_ok());
6742 }
6743
6744 #[test]
6745 fn test_network_remove_help() {
6746 let cli = Cli::try_parse_from(["mvmctl", "network", "rm", "mynet"]);
6747 assert!(cli.is_ok());
6748 }
6749
6750 #[test]
6753 fn test_image_list_help() {
6754 let cli = Cli::try_parse_from(["mvmctl", "image", "list"]);
6755 assert!(cli.is_ok());
6756 }
6757
6758 #[test]
6759 fn test_image_search_help() {
6760 let cli = Cli::try_parse_from(["mvmctl", "image", "search", "http"]);
6761 assert!(cli.is_ok());
6762 }
6763
6764 #[test]
6765 fn test_image_fetch_help() {
6766 let cli = Cli::try_parse_from(["mvmctl", "image", "fetch", "minimal"]);
6767 assert!(cli.is_ok());
6768 }
6769
6770 #[test]
6771 fn test_image_info_help() {
6772 let cli = Cli::try_parse_from(["mvmctl", "image", "info", "postgres"]);
6773 assert!(cli.is_ok());
6774 }
6775
6776 #[test]
6779 fn test_console_help() {
6780 let cli = Cli::try_parse_from(["mvmctl", "console", "myvm"]);
6781 assert!(cli.is_ok());
6782 }
6783
6784 #[test]
6785 fn test_console_with_command() {
6786 let cli = Cli::try_parse_from(["mvmctl", "console", "myvm", "--command", "ls"]);
6787 assert!(cli.is_ok());
6788 match cli.unwrap().command {
6789 Commands::Console { name, command } => {
6790 assert_eq!(name, "myvm");
6791 assert_eq!(command.as_deref(), Some("ls"));
6792 }
6793 _ => panic!("Expected Console command"),
6794 }
6795 }
6796
6797 #[test]
6800 fn exec_default_template_argv_only() {
6801 let cli = Cli::try_parse_from(["mvmctl", "exec", "--", "uname", "-a"]).expect("parse");
6802 match cli.command {
6803 Commands::Exec {
6804 template,
6805 cpus,
6806 memory,
6807 add_dir,
6808 env,
6809 timeout,
6810 launch_plan,
6811 argv,
6812 } => {
6813 assert!(template.is_none(), "template should default to None");
6814 assert_eq!(cpus, 2);
6815 assert_eq!(memory, "512M");
6816 assert!(add_dir.is_empty());
6817 assert!(env.is_empty());
6818 assert_eq!(timeout, 60);
6819 assert!(launch_plan.is_none(), "launch_plan should default to None");
6820 assert_eq!(argv, vec!["uname".to_string(), "-a".to_string()]);
6821 }
6822 _ => panic!("Expected Exec command"),
6823 }
6824 }
6825
6826 #[test]
6827 fn exec_with_launch_plan_no_argv() {
6828 let cli =
6829 Cli::try_parse_from(["mvmctl", "exec", "--launch-plan", "./plan.json"]).expect("parse");
6830 match cli.command {
6831 Commands::Exec {
6832 launch_plan, argv, ..
6833 } => {
6834 assert_eq!(launch_plan.as_deref(), Some("./plan.json"));
6835 assert!(argv.is_empty());
6836 }
6837 _ => panic!("Expected Exec command"),
6838 }
6839 }
6840
6841 #[test]
6842 fn exec_launch_plan_conflicts_with_argv() {
6843 let cli = Cli::try_parse_from([
6844 "mvmctl",
6845 "exec",
6846 "--launch-plan",
6847 "./plan.json",
6848 "--",
6849 "echo",
6850 "hi",
6851 ]);
6852 assert!(
6853 cli.is_err(),
6854 "--launch-plan and trailing argv must be mutually exclusive"
6855 );
6856 }
6857
6858 #[test]
6859 fn exec_with_template_and_resources() {
6860 let cli = Cli::try_parse_from([
6861 "mvmctl",
6862 "exec",
6863 "--template",
6864 "my-tpl",
6865 "--cpus",
6866 "4",
6867 "--memory",
6868 "1G",
6869 "--",
6870 "/bin/true",
6871 ])
6872 .expect("parse");
6873 match cli.command {
6874 Commands::Exec {
6875 template,
6876 cpus,
6877 memory,
6878 argv,
6879 ..
6880 } => {
6881 assert_eq!(template.as_deref(), Some("my-tpl"));
6882 assert_eq!(cpus, 4);
6883 assert_eq!(memory, "1G");
6884 assert_eq!(argv, vec!["/bin/true".to_string()]);
6885 }
6886 _ => panic!("Expected Exec command"),
6887 }
6888 }
6889
6890 #[test]
6891 fn exec_with_add_dir_and_env() {
6892 let cli = Cli::try_parse_from([
6893 "mvmctl",
6894 "exec",
6895 "--add-dir",
6896 "/tmp:/work",
6897 "--add-dir",
6898 "/etc:/host-etc",
6899 "--env",
6900 "FOO=bar",
6901 "--env",
6902 "BAZ=qux",
6903 "--",
6904 "ls",
6905 "/work",
6906 ])
6907 .expect("parse");
6908 match cli.command {
6909 Commands::Exec {
6910 add_dir, env, argv, ..
6911 } => {
6912 assert_eq!(
6913 add_dir,
6914 vec!["/tmp:/work".to_string(), "/etc:/host-etc".to_string()]
6915 );
6916 assert_eq!(env, vec!["FOO=bar".to_string(), "BAZ=qux".to_string()]);
6917 assert_eq!(argv, vec!["ls".to_string(), "/work".to_string()]);
6918 }
6919 _ => panic!("Expected Exec command"),
6920 }
6921 }
6922
6923 #[test]
6924 fn exec_requires_argv() {
6925 let cli = Cli::try_parse_from(["mvmctl", "exec"]);
6927 assert!(cli.is_err());
6928 }
6929
6930 #[test]
6933 fn test_init_defaults() {
6934 let cli = Cli::try_parse_from(["mvmctl", "init"]).unwrap();
6935 match cli.command {
6936 Commands::Init {
6937 non_interactive,
6938 lima_cpus,
6939 lima_mem,
6940 } => {
6941 assert!(!non_interactive);
6942 assert_eq!(lima_cpus, 8);
6943 assert_eq!(lima_mem, 16);
6944 }
6945 _ => panic!("Expected Init command"),
6946 }
6947 }
6948
6949 #[test]
6950 fn test_init_non_interactive() {
6951 let cli = Cli::try_parse_from(["mvmctl", "init", "--non-interactive", "--lima-cpus", "4"])
6952 .unwrap();
6953 match cli.command {
6954 Commands::Init {
6955 non_interactive,
6956 lima_cpus,
6957 ..
6958 } => {
6959 assert!(non_interactive);
6960 assert_eq!(lima_cpus, 4);
6961 }
6962 _ => panic!("Expected Init command"),
6963 }
6964 }
6965
6966 #[test]
6969 fn test_security_status_help() {
6970 let cli = Cli::try_parse_from(["mvmctl", "security", "status"]);
6971 assert!(cli.is_ok());
6972 }
6973
6974 #[test]
6975 fn test_security_status_json() {
6976 let cli = Cli::try_parse_from(["mvmctl", "security", "status", "--json"]).unwrap();
6977 match cli.command {
6978 Commands::Security {
6979 action: SecurityCmd::Status { json },
6980 } => {
6981 assert!(json);
6982 }
6983 _ => panic!("Expected Security Status command"),
6984 }
6985 }
6986
6987 #[test]
6990 fn test_cache_info() {
6991 let cli = Cli::try_parse_from(["mvmctl", "cache", "info"]);
6992 assert!(cli.is_ok());
6993 }
6994
6995 #[test]
6996 fn test_cache_prune() {
6997 let cli = Cli::try_parse_from(["mvmctl", "cache", "prune"]);
6998 assert!(cli.is_ok());
6999 }
7000
7001 #[test]
7002 fn test_cache_prune_dry_run() {
7003 let cli = Cli::try_parse_from(["mvmctl", "cache", "prune", "--dry-run"]).unwrap();
7004 match cli.command {
7005 Commands::Cache {
7006 action: CacheCmd::Prune { dry_run },
7007 } => {
7008 assert!(dry_run);
7009 }
7010 _ => panic!("Expected Cache Prune command"),
7011 }
7012 }
7013
7014 #[test]
7017 fn test_up_network_default() {
7018 let cli = Cli::try_parse_from(["mvmctl", "up", "--flake", "."]).unwrap();
7019 match cli.command {
7020 Commands::Up { network, .. } => {
7021 assert_eq!(network, "default");
7022 }
7023 _ => panic!("Expected Up command"),
7024 }
7025 }
7026
7027 #[test]
7028 fn test_up_network_custom() {
7029 let cli =
7030 Cli::try_parse_from(["mvmctl", "up", "--flake", ".", "--network", "isolated"]).unwrap();
7031 match cli.command {
7032 Commands::Up { network, .. } => {
7033 assert_eq!(network, "isolated");
7034 }
7035 _ => panic!("Expected Up command"),
7036 }
7037 }
7038
7039 #[test]
7040 fn test_template_init_defaults_to_no_preset_or_prompt() {
7041 let cli = Cli::try_parse_from(["mvmctl", "template", "init", "demo", "--local"]).unwrap();
7042 match cli.command {
7043 Commands::Template {
7044 action: TemplateCmd::Init { preset, prompt, .. },
7045 } => {
7046 assert!(preset.is_none(), "preset should be None when omitted");
7047 assert!(prompt.is_none(), "prompt should be None when omitted");
7048 }
7049 _ => panic!("Expected Template Init command"),
7050 }
7051 }
7052
7053 #[test]
7054 fn test_template_init_parses_prompt_flag() {
7055 let cli = Cli::try_parse_from([
7056 "mvmctl",
7057 "template",
7058 "init",
7059 "demo",
7060 "--local",
7061 "--prompt",
7062 "python worker that polls an API",
7063 ])
7064 .unwrap();
7065 match cli.command {
7066 Commands::Template {
7067 action: TemplateCmd::Init { prompt, preset, .. },
7068 } => {
7069 assert_eq!(prompt.as_deref(), Some("python worker that polls an API"));
7070 assert!(preset.is_none(), "preset should remain None when omitted");
7071 }
7072 _ => panic!("Expected Template Init command"),
7073 }
7074 }
7075
7076 #[test]
7079 fn test_dev_up_with_lima_flag() {
7080 let cli = Cli::try_parse_from(["mvmctl", "dev", "up", "--lima"]).unwrap();
7081 match cli.command {
7082 Commands::Dev {
7083 action: Some(DevCmd::Up { lima, .. }),
7084 } => {
7085 assert!(lima);
7086 }
7087 _ => panic!("Expected Dev Up command"),
7088 }
7089 }
7090
7091 #[test]
7092 fn test_dev_down_parses() {
7093 let cli = Cli::try_parse_from(["mvmctl", "dev", "down"]);
7094 assert!(cli.is_ok());
7095 }
7096
7097 #[test]
7098 fn test_dev_shell_parses() {
7099 let cli = Cli::try_parse_from(["mvmctl", "dev", "shell"]);
7100 assert!(cli.is_ok());
7101 }
7102
7103 #[test]
7104 fn test_dev_status_parses() {
7105 let cli = Cli::try_parse_from(["mvmctl", "dev", "status"]);
7106 assert!(cli.is_ok());
7107 }
7108
7109 #[test]
7110 fn test_is_apple_container_dev_running_returns_bool() {
7111 let _ = is_apple_container_dev_running();
7113 }
7114}