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
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
55pub 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
64pub 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
85pub fn parse_fleet_config(content: &str) -> Result<FleetConfig> {
87 toml::from_str(content).context("Failed to parse fleet config")
88}
89
90pub 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#[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 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}