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