1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6
7#[derive(Debug, Deserialize)]
12pub struct FleetConfig {
13 pub flake: String,
15
16 #[serde(default)]
18 pub defaults: FleetDefaults,
19
20 #[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 #[serde(default)]
38 pub ports: Vec<String>,
39
40 #[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 #[serde(default)]
61 pub ports: Vec<String>,
62
63 #[serde(default)]
65 pub env: Vec<String>,
66}
67
68const DEFAULT_CPUS: u32 = 2;
69const DEFAULT_MEM: u32 = 1024;
70
71pub 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 pub ports: Vec<String>,
80 pub env: Vec<String>,
82}
83
84pub 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
105pub fn parse_fleet_config(content: &str) -> Result<FleetConfig> {
107 toml::from_str(content).context("Failed to parse fleet config")
108}
109
110pub 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 let ports = if vm.ports.is_empty() {
129 fleet.defaults.ports.clone()
130 } else {
131 vm.ports.clone()
132 };
133
134 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#[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 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 let oc = resolve_vm(&config, "oc").unwrap();
393 assert_eq!(oc.ports, vec!["8080:3000"]);
394
395 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}