Skip to main content

mvm_cli/
fleet.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6
7/// Project-level fleet configuration loaded from `mvm.toml`.
8///
9/// Defines a set of named VMs that share a Nix flake reference.
10/// Each VM can override resource defaults (cpus, memory, profile).
11#[derive(Debug, Deserialize)]
12pub struct FleetConfig {
13    /// Nix flake reference, shared across all VMs.
14    pub flake: String,
15
16    /// Default resource settings applied to all VMs unless overridden.
17    #[serde(default)]
18    pub defaults: FleetDefaults,
19
20    /// Named VM definitions. BTreeMap for deterministic ordering.
21    #[serde(default)]
22    pub vms: BTreeMap<String, VmConfig>,
23}
24
25#[derive(Debug, Deserialize, Default)]
26pub struct FleetDefaults {
27    #[serde(default)]
28    pub cpus: Option<u32>,
29
30    #[serde(default)]
31    pub memory: Option<u32>,
32
33    #[serde(default)]
34    pub profile: Option<String>,
35}
36
37#[derive(Debug, Deserialize, Default)]
38pub struct VmConfig {
39    #[serde(default)]
40    pub profile: Option<String>,
41
42    #[serde(default)]
43    pub cpus: Option<u32>,
44
45    #[serde(default)]
46    pub memory: Option<u32>,
47
48    #[serde(default)]
49    pub volumes: Vec<String>,
50}
51
52const DEFAULT_CPUS: u32 = 2;
53const DEFAULT_MEM: u32 = 1024;
54
55/// Resolved VM configuration after merging VM-level > defaults > hardcoded.
56pub struct ResolvedVm {
57    pub name: String,
58    pub profile: Option<String>,
59    pub cpus: u32,
60    pub memory: u32,
61    pub volumes: Vec<String>,
62}
63
64/// Search for `mvm.toml` starting from cwd, walking up the directory tree.
65///
66/// Returns `(config, directory_containing_mvm_toml)` so flake paths can be
67/// resolved relative to the config file location.
68pub fn find_fleet_config() -> Result<Option<(FleetConfig, PathBuf)>> {
69    let mut dir = std::env::current_dir()?;
70    loop {
71        let candidate = dir.join("mvm.toml");
72        if candidate.is_file() {
73            let content = std::fs::read_to_string(&candidate)
74                .with_context(|| format!("Failed to read {}", candidate.display()))?;
75            let config: FleetConfig = toml::from_str(&content)
76                .with_context(|| format!("Failed to parse {}", candidate.display()))?;
77            return Ok(Some((config, dir)));
78        }
79        if !dir.pop() {
80            return Ok(None);
81        }
82    }
83}
84
85/// Parse a fleet config from a TOML string.
86pub fn parse_fleet_config(content: &str) -> Result<FleetConfig> {
87    toml::from_str(content).context("Failed to parse fleet config")
88}
89
90/// Resolve a single VM's effective configuration by merging:
91/// VM-specific > [defaults] > hardcoded defaults.
92pub fn resolve_vm(fleet: &FleetConfig, name: &str) -> Result<ResolvedVm> {
93    let vm = fleet
94        .vms
95        .get(name)
96        .ok_or_else(|| anyhow::anyhow!("VM '{}' not defined in fleet config", name))?;
97
98    let profile = vm
99        .profile
100        .clone()
101        .or_else(|| fleet.defaults.profile.clone());
102
103    let cpus = vm.cpus.or(fleet.defaults.cpus).unwrap_or(DEFAULT_CPUS);
104
105    let memory = vm.memory.or(fleet.defaults.memory).unwrap_or(DEFAULT_MEM);
106
107    Ok(ResolvedVm {
108        name: name.to_string(),
109        profile,
110        cpus,
111        memory,
112        volumes: vm.volumes.clone(),
113    })
114}
115
116// ============================================================================
117// Tests
118// ============================================================================
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_parse_full_config() {
126        let toml = r#"
127            flake = "./nix/openclaw/"
128
129            [defaults]
130            cpus = 2
131            memory = 1024
132
133            [vms.gw]
134            profile = "gateway"
135
136            [vms.w1]
137            profile = "worker"
138
139            [vms.w2]
140            profile = "worker"
141            cpus = 4
142            memory = 2048
143            volumes = ["./data:/mnt/data:2G"]
144        "#;
145
146        let config = parse_fleet_config(toml).unwrap();
147        assert_eq!(config.flake, "./nix/openclaw/");
148        assert_eq!(config.defaults.cpus, Some(2));
149        assert_eq!(config.defaults.memory, Some(1024));
150        assert_eq!(config.vms.len(), 3);
151
152        let gw = &config.vms["gw"];
153        assert_eq!(gw.profile.as_deref(), Some("gateway"));
154        assert_eq!(gw.cpus, None);
155
156        let w2 = &config.vms["w2"];
157        assert_eq!(w2.cpus, Some(4));
158        assert_eq!(w2.memory, Some(2048));
159        assert_eq!(w2.volumes, vec!["./data:/mnt/data:2G"]);
160    }
161
162    #[test]
163    fn test_parse_minimal_config() {
164        let toml = r#"
165            flake = "."
166
167            [vms.dev]
168            profile = "worker"
169        "#;
170
171        let config = parse_fleet_config(toml).unwrap();
172        assert_eq!(config.flake, ".");
173        assert_eq!(config.defaults.cpus, None);
174        assert_eq!(config.defaults.memory, None);
175        assert_eq!(config.vms.len(), 1);
176    }
177
178    #[test]
179    fn test_parse_no_vms() {
180        let toml = r#"flake = ".""#;
181
182        let config = parse_fleet_config(toml).unwrap();
183        assert!(config.vms.is_empty());
184    }
185
186    #[test]
187    fn test_parse_requires_flake() {
188        let toml = r#"
189            [vms.dev]
190            profile = "worker"
191        "#;
192
193        let result = parse_fleet_config(toml);
194        assert!(result.is_err());
195    }
196
197    #[test]
198    fn test_resolve_vm_uses_vm_level_overrides() {
199        let config = parse_fleet_config(
200            r#"
201            flake = "."
202            [defaults]
203            cpus = 2
204            memory = 1024
205
206            [vms.big]
207            profile = "worker"
208            cpus = 8
209            memory = 4096
210        "#,
211        )
212        .unwrap();
213
214        let resolved = resolve_vm(&config, "big").unwrap();
215        assert_eq!(resolved.cpus, 8);
216        assert_eq!(resolved.memory, 4096);
217        assert_eq!(resolved.profile.as_deref(), Some("worker"));
218    }
219
220    #[test]
221    fn test_resolve_vm_falls_through_to_defaults() {
222        let config = parse_fleet_config(
223            r#"
224            flake = "."
225            [defaults]
226            cpus = 4
227            memory = 2048
228            profile = "worker"
229
230            [vms.small]
231        "#,
232        )
233        .unwrap();
234
235        let resolved = resolve_vm(&config, "small").unwrap();
236        assert_eq!(resolved.cpus, 4);
237        assert_eq!(resolved.memory, 2048);
238        assert_eq!(resolved.profile.as_deref(), Some("worker"));
239    }
240
241    #[test]
242    fn test_resolve_vm_falls_through_to_hardcoded() {
243        let config = parse_fleet_config(
244            r#"
245            flake = "."
246            [vms.bare]
247        "#,
248        )
249        .unwrap();
250
251        let resolved = resolve_vm(&config, "bare").unwrap();
252        assert_eq!(resolved.cpus, DEFAULT_CPUS);
253        assert_eq!(resolved.memory, DEFAULT_MEM);
254        assert!(resolved.profile.is_none());
255    }
256
257    #[test]
258    fn test_resolve_vm_not_found() {
259        let config = parse_fleet_config(r#"flake = ".""#).unwrap();
260        let result = resolve_vm(&config, "missing");
261        assert!(result.is_err());
262    }
263
264    #[test]
265    fn test_vm_ordering_is_deterministic() {
266        let config = parse_fleet_config(
267            r#"
268            flake = "."
269            [vms.charlie]
270            [vms.alpha]
271            [vms.bravo]
272        "#,
273        )
274        .unwrap();
275
276        let names: Vec<&str> = config.vms.keys().map(|s| s.as_str()).collect();
277        assert_eq!(names, vec!["alpha", "bravo", "charlie"]);
278    }
279
280    #[test]
281    fn test_resolve_profile_priority() {
282        // VM profile beats defaults profile
283        let config = parse_fleet_config(
284            r#"
285            flake = "."
286            [defaults]
287            profile = "worker"
288
289            [vms.gw]
290            profile = "gateway"
291
292            [vms.w1]
293        "#,
294        )
295        .unwrap();
296
297        let gw = resolve_vm(&config, "gw").unwrap();
298        assert_eq!(gw.profile.as_deref(), Some("gateway"));
299
300        let w1 = resolve_vm(&config, "w1").unwrap();
301        assert_eq!(w1.profile.as_deref(), Some("worker"));
302    }
303}