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    /// Default port mappings applied to all VMs (format: "HOST:GUEST" or "PORT").
37    #[serde(default)]
38    pub ports: Vec<String>,
39
40    /// Default environment variables injected into all VMs (format: "KEY=VALUE").
41    #[serde(default)]
42    pub env: Vec<String>,
43}
44
45#[derive(Debug, Deserialize, Default)]
46pub struct VmConfig {
47    #[serde(default)]
48    pub profile: Option<String>,
49
50    #[serde(default)]
51    pub cpus: Option<u32>,
52
53    #[serde(default)]
54    pub memory: Option<u32>,
55
56    #[serde(default)]
57    pub volumes: Vec<String>,
58
59    /// Port mappings (format: "HOST:GUEST" or "PORT"). Replaces defaults if non-empty.
60    #[serde(default)]
61    pub ports: Vec<String>,
62
63    /// Environment variables (format: "KEY=VALUE"). Replaces defaults if non-empty.
64    #[serde(default)]
65    pub env: Vec<String>,
66}
67
68const DEFAULT_CPUS: u32 = 2;
69const DEFAULT_MEM: u32 = 1024;
70
71/// Resolved VM configuration after merging VM-level > defaults > hardcoded.
72pub struct ResolvedVm {
73    pub name: String,
74    pub profile: Option<String>,
75    pub cpus: u32,
76    pub memory: u32,
77    pub volumes: Vec<String>,
78    /// Merged port mappings (VM-specific replaces defaults).
79    pub ports: Vec<String>,
80    /// Merged environment variables (VM-specific replaces defaults).
81    pub env: Vec<String>,
82}
83
84/// Search for `mvm.toml` starting from cwd, walking up the directory tree.
85///
86/// Returns `(config, directory_containing_mvm_toml)` so flake paths can be
87/// resolved relative to the config file location.
88pub fn find_fleet_config() -> Result<Option<(FleetConfig, PathBuf)>> {
89    let mut dir = std::env::current_dir()?;
90    loop {
91        let candidate = dir.join("mvm.toml");
92        if candidate.is_file() {
93            let content = std::fs::read_to_string(&candidate)
94                .with_context(|| format!("Failed to read {}", candidate.display()))?;
95            let config: FleetConfig = toml::from_str(&content)
96                .with_context(|| format!("Failed to parse {}", candidate.display()))?;
97            return Ok(Some((config, dir)));
98        }
99        if !dir.pop() {
100            return Ok(None);
101        }
102    }
103}
104
105/// Parse a fleet config from a TOML string.
106pub fn parse_fleet_config(content: &str) -> Result<FleetConfig> {
107    toml::from_str(content).context("Failed to parse fleet config")
108}
109
110/// Resolve a single VM's effective configuration by merging:
111/// VM-specific > [defaults] > hardcoded defaults.
112pub fn resolve_vm(fleet: &FleetConfig, name: &str) -> Result<ResolvedVm> {
113    let vm = fleet
114        .vms
115        .get(name)
116        .ok_or_else(|| anyhow::anyhow!("VM '{}' not defined in fleet config", name))?;
117
118    let profile = vm
119        .profile
120        .clone()
121        .or_else(|| fleet.defaults.profile.clone());
122
123    let cpus = vm.cpus.or(fleet.defaults.cpus).unwrap_or(DEFAULT_CPUS);
124
125    let memory = vm.memory.or(fleet.defaults.memory).unwrap_or(DEFAULT_MEM);
126
127    // Ports: VM-specific replaces defaults entirely (fall through if empty)
128    let ports = if vm.ports.is_empty() {
129        fleet.defaults.ports.clone()
130    } else {
131        vm.ports.clone()
132    };
133
134    // Env: VM-specific replaces defaults entirely (fall through if empty)
135    let env = if vm.env.is_empty() {
136        fleet.defaults.env.clone()
137    } else {
138        vm.env.clone()
139    };
140
141    Ok(ResolvedVm {
142        name: name.to_string(),
143        profile,
144        cpus,
145        memory,
146        volumes: vm.volumes.clone(),
147        ports,
148        env,
149    })
150}
151
152// ============================================================================
153// Tests
154// ============================================================================
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_parse_full_config() {
162        let toml = r#"
163            flake = "./nix/examples/openclaw/"
164
165            [defaults]
166            cpus = 2
167            memory = 1024
168
169            [vms.gw]
170            profile = "gateway"
171
172            [vms.w1]
173            profile = "worker"
174
175            [vms.w2]
176            profile = "worker"
177            cpus = 4
178            memory = 2048
179            volumes = ["./data:/mnt/data:2G"]
180        "#;
181
182        let config = parse_fleet_config(toml).unwrap();
183        assert_eq!(config.flake, "./nix/examples/openclaw/");
184        assert_eq!(config.defaults.cpus, Some(2));
185        assert_eq!(config.defaults.memory, Some(1024));
186        assert_eq!(config.vms.len(), 3);
187
188        let gw = &config.vms["gw"];
189        assert_eq!(gw.profile.as_deref(), Some("gateway"));
190        assert_eq!(gw.cpus, None);
191
192        let w2 = &config.vms["w2"];
193        assert_eq!(w2.cpus, Some(4));
194        assert_eq!(w2.memory, Some(2048));
195        assert_eq!(w2.volumes, vec!["./data:/mnt/data:2G"]);
196    }
197
198    #[test]
199    fn test_parse_config_with_ports_and_env() {
200        let toml = r#"
201            flake = "."
202
203            [defaults]
204            ports = ["3333:3000", "3334:3002"]
205            env = ["NODE_ENV=production"]
206
207            [vms.oc]
208            profile = "gateway"
209            ports = ["8080:3000"]
210            env = ["OPENCLAW_EXTERNAL_PORT=8080"]
211
212            [vms.worker]
213            profile = "worker"
214        "#;
215
216        let config = parse_fleet_config(toml).unwrap();
217        assert_eq!(config.defaults.ports, vec!["3333:3000", "3334:3002"]);
218        assert_eq!(config.defaults.env, vec!["NODE_ENV=production"]);
219
220        let oc = &config.vms["oc"];
221        assert_eq!(oc.ports, vec!["8080:3000"]);
222        assert_eq!(oc.env, vec!["OPENCLAW_EXTERNAL_PORT=8080"]);
223
224        let worker = &config.vms["worker"];
225        assert!(worker.ports.is_empty());
226        assert!(worker.env.is_empty());
227    }
228
229    #[test]
230    fn test_parse_minimal_config() {
231        let toml = r#"
232            flake = "."
233
234            [vms.dev]
235            profile = "worker"
236        "#;
237
238        let config = parse_fleet_config(toml).unwrap();
239        assert_eq!(config.flake, ".");
240        assert_eq!(config.defaults.cpus, None);
241        assert_eq!(config.defaults.memory, None);
242        assert!(config.defaults.ports.is_empty());
243        assert!(config.defaults.env.is_empty());
244        assert_eq!(config.vms.len(), 1);
245    }
246
247    #[test]
248    fn test_parse_no_vms() {
249        let toml = r#"flake = ".""#;
250
251        let config = parse_fleet_config(toml).unwrap();
252        assert!(config.vms.is_empty());
253    }
254
255    #[test]
256    fn test_parse_requires_flake() {
257        let toml = r#"
258            [vms.dev]
259            profile = "worker"
260        "#;
261
262        let result = parse_fleet_config(toml);
263        assert!(result.is_err());
264    }
265
266    #[test]
267    fn test_resolve_vm_uses_vm_level_overrides() {
268        let config = parse_fleet_config(
269            r#"
270            flake = "."
271            [defaults]
272            cpus = 2
273            memory = 1024
274
275            [vms.big]
276            profile = "worker"
277            cpus = 8
278            memory = 4096
279        "#,
280        )
281        .unwrap();
282
283        let resolved = resolve_vm(&config, "big").unwrap();
284        assert_eq!(resolved.cpus, 8);
285        assert_eq!(resolved.memory, 4096);
286        assert_eq!(resolved.profile.as_deref(), Some("worker"));
287    }
288
289    #[test]
290    fn test_resolve_vm_falls_through_to_defaults() {
291        let config = parse_fleet_config(
292            r#"
293            flake = "."
294            [defaults]
295            cpus = 4
296            memory = 2048
297            profile = "worker"
298
299            [vms.small]
300        "#,
301        )
302        .unwrap();
303
304        let resolved = resolve_vm(&config, "small").unwrap();
305        assert_eq!(resolved.cpus, 4);
306        assert_eq!(resolved.memory, 2048);
307        assert_eq!(resolved.profile.as_deref(), Some("worker"));
308    }
309
310    #[test]
311    fn test_resolve_vm_falls_through_to_hardcoded() {
312        let config = parse_fleet_config(
313            r#"
314            flake = "."
315            [vms.bare]
316        "#,
317        )
318        .unwrap();
319
320        let resolved = resolve_vm(&config, "bare").unwrap();
321        assert_eq!(resolved.cpus, DEFAULT_CPUS);
322        assert_eq!(resolved.memory, DEFAULT_MEM);
323        assert!(resolved.profile.is_none());
324        assert!(resolved.ports.is_empty());
325        assert!(resolved.env.is_empty());
326    }
327
328    #[test]
329    fn test_resolve_vm_not_found() {
330        let config = parse_fleet_config(r#"flake = ".""#).unwrap();
331        let result = resolve_vm(&config, "missing");
332        assert!(result.is_err());
333    }
334
335    #[test]
336    fn test_vm_ordering_is_deterministic() {
337        let config = parse_fleet_config(
338            r#"
339            flake = "."
340            [vms.charlie]
341            [vms.alpha]
342            [vms.bravo]
343        "#,
344        )
345        .unwrap();
346
347        let names: Vec<&str> = config.vms.keys().map(|s| s.as_str()).collect();
348        assert_eq!(names, vec!["alpha", "bravo", "charlie"]);
349    }
350
351    #[test]
352    fn test_resolve_profile_priority() {
353        // VM profile beats defaults profile
354        let config = parse_fleet_config(
355            r#"
356            flake = "."
357            [defaults]
358            profile = "worker"
359
360            [vms.gw]
361            profile = "gateway"
362
363            [vms.w1]
364        "#,
365        )
366        .unwrap();
367
368        let gw = resolve_vm(&config, "gw").unwrap();
369        assert_eq!(gw.profile.as_deref(), Some("gateway"));
370
371        let w1 = resolve_vm(&config, "w1").unwrap();
372        assert_eq!(w1.profile.as_deref(), Some("worker"));
373    }
374
375    #[test]
376    fn test_resolve_ports_vm_overrides_defaults() {
377        let config = parse_fleet_config(
378            r#"
379            flake = "."
380            [defaults]
381            ports = ["3333:3000", "3334:3002"]
382
383            [vms.oc]
384            ports = ["8080:3000"]
385
386            [vms.worker]
387        "#,
388        )
389        .unwrap();
390
391        // VM-level ports replace defaults entirely
392        let oc = resolve_vm(&config, "oc").unwrap();
393        assert_eq!(oc.ports, vec!["8080:3000"]);
394
395        // No VM-level ports → fall through to defaults
396        let worker = resolve_vm(&config, "worker").unwrap();
397        assert_eq!(worker.ports, vec!["3333:3000", "3334:3002"]);
398    }
399
400    #[test]
401    fn test_resolve_env_vm_overrides_defaults() {
402        let config = parse_fleet_config(
403            r#"
404            flake = "."
405            [defaults]
406            env = ["NODE_ENV=production"]
407
408            [vms.oc]
409            env = ["NODE_ENV=development", "DEBUG=true"]
410
411            [vms.worker]
412        "#,
413        )
414        .unwrap();
415
416        let oc = resolve_vm(&config, "oc").unwrap();
417        assert_eq!(oc.env, vec!["NODE_ENV=development", "DEBUG=true"]);
418
419        let worker = resolve_vm(&config, "worker").unwrap();
420        assert_eq!(worker.env, vec!["NODE_ENV=production"]);
421    }
422}