1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::tenant::tenant_pools_dir;
6
7#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum Role {
16 Gateway,
17 #[default]
18 Worker,
19 Builder,
20 CapabilityImessage,
21}
22
23impl fmt::Display for Role {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 match self {
26 Self::Gateway => write!(f, "gateway"),
27 Self::Worker => write!(f, "worker"),
28 Self::Builder => write!(f, "builder"),
29 Self::CapabilityImessage => write!(f, "capability-imessage"),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct PoolMetadata {
46 #[serde(skip_serializing_if = "Option::is_none")]
49 pub capability: Option<String>,
50
51 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub integration_types: Vec<String>,
54
55 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
57 pub tags: std::collections::BTreeMap<String, String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct RuntimePolicy {
63 #[serde(default = "default_min_running")]
65 pub min_running_seconds: u64,
66 #[serde(default = "default_min_warm")]
68 pub min_warm_seconds: u64,
69 #[serde(default = "default_drain_timeout")]
71 pub drain_timeout_seconds: u64,
72 #[serde(default = "default_graceful_shutdown")]
74 pub graceful_shutdown_seconds: u64,
75}
76
77fn default_min_running() -> u64 {
78 60
79}
80fn default_min_warm() -> u64 {
81 30
82}
83fn default_drain_timeout() -> u64 {
84 30
85}
86fn default_graceful_shutdown() -> u64 {
87 15
88}
89
90impl Default for RuntimePolicy {
91 fn default() -> Self {
92 Self {
93 min_running_seconds: default_min_running(),
94 min_warm_seconds: default_min_warm(),
95 drain_timeout_seconds: default_drain_timeout(),
96 graceful_shutdown_seconds: default_graceful_shutdown(),
97 }
98 }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct PoolSpec {
109 pub pool_id: String,
110 pub tenant_id: String,
111 pub flake_ref: String,
112 pub profile: String,
115 #[serde(default)]
117 pub role: Role,
118 pub instance_resources: InstanceResources,
119 pub desired_counts: DesiredCounts,
120 #[serde(default)]
122 pub runtime_policy: RuntimePolicy,
123 #[serde(default)]
125 pub metadata: PoolMetadata,
126 #[serde(default = "default_seccomp")]
128 pub seccomp_policy: String,
129 #[serde(default = "default_compression")]
131 pub snapshot_compression: String,
132 #[serde(default)]
133 pub metadata_enabled: bool,
134 #[serde(default)]
136 pub pinned: bool,
137 #[serde(default)]
139 pub critical: bool,
140 #[serde(default)]
143 pub secret_scopes: Vec<SecretScope>,
144 #[serde(default)]
146 pub template_id: String,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SecretScope {
152 pub integration: String,
154 pub keys: Vec<String>,
157}
158
159fn default_seccomp() -> String {
160 "baseline".to_string()
161}
162
163fn default_compression() -> String {
164 "none".to_string()
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct InstanceResources {
170 pub vcpus: u8,
171 pub mem_mib: u32,
172 #[serde(default)]
173 pub data_disk_mib: u32,
174}
175
176#[derive(Debug, Clone, Default, Serialize, Deserialize)]
178pub struct DesiredCounts {
179 pub running: u32,
180 pub warm: u32,
181 pub sleeping: u32,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct BuildRevision {
187 pub revision_hash: String,
188 pub flake_ref: String,
189 pub flake_lock_hash: String,
190 pub artifact_paths: ArtifactPaths,
191 pub built_at: String,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct ArtifactPaths {
197 pub vmlinux: String,
198 pub rootfs: String,
199 pub fc_base_config: String,
200}
201
202pub fn pool_dir(tenant_id: &str, pool_id: &str) -> String {
205 format!("{}/{}", tenant_pools_dir(tenant_id), pool_id)
206}
207
208pub fn pool_config_path(tenant_id: &str, pool_id: &str) -> String {
209 format!("{}/pool.json", pool_dir(tenant_id, pool_id))
210}
211
212pub fn pool_artifacts_dir(tenant_id: &str, pool_id: &str) -> String {
213 format!("{}/artifacts", pool_dir(tenant_id, pool_id))
214}
215
216pub fn pool_instances_dir(tenant_id: &str, pool_id: &str) -> String {
217 format!("{}/instances", pool_dir(tenant_id, pool_id))
218}
219
220pub fn pool_snapshots_dir(tenant_id: &str, pool_id: &str) -> String {
221 format!("{}/snapshots", pool_dir(tenant_id, pool_id))
222}
223
224pub fn pool_config_data_dir(tenant_id: &str, pool_id: &str) -> String {
226 format!("{}/config", pool_dir(tenant_id, pool_id))
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn test_pool_dir_path() {
235 assert_eq!(
236 pool_dir("acme", "workers"),
237 "/var/lib/mvm/tenants/acme/pools/workers"
238 );
239 }
240
241 #[test]
242 fn test_pool_config_roundtrip() {
243 let spec = PoolSpec {
244 pool_id: "workers".to_string(),
245 tenant_id: "acme".to_string(),
246 flake_ref: "github:org/repo".to_string(),
247 profile: "minimal".to_string(),
248 role: Role::Worker,
249 instance_resources: InstanceResources {
250 vcpus: 2,
251 mem_mib: 1024,
252 data_disk_mib: 2048,
253 },
254 desired_counts: DesiredCounts {
255 running: 3,
256 warm: 1,
257 sleeping: 2,
258 },
259 runtime_policy: RuntimePolicy::default(),
260 metadata: PoolMetadata::default(),
261 seccomp_policy: "baseline".to_string(),
262 snapshot_compression: "zstd".to_string(),
263 metadata_enabled: false,
264 pinned: false,
265 critical: false,
266 secret_scopes: vec![],
267 template_id: String::new(),
268 };
269
270 let json = serde_json::to_string(&spec).unwrap();
271 let parsed: PoolSpec = serde_json::from_str(&json).unwrap();
272 assert_eq!(parsed.pool_id, "workers");
273 assert_eq!(parsed.instance_resources.vcpus, 2);
274 assert_eq!(parsed.desired_counts.running, 3);
275 assert_eq!(parsed.role, Role::Worker);
276 }
277
278 #[test]
279 fn test_role_serde_roundtrip() {
280 for (role, expected) in [
281 (Role::Gateway, "\"gateway\""),
282 (Role::Worker, "\"worker\""),
283 (Role::Builder, "\"builder\""),
284 (Role::CapabilityImessage, "\"capability-imessage\""),
285 ] {
286 let json = serde_json::to_string(&role).unwrap();
287 assert_eq!(json, expected);
288 let parsed: Role = serde_json::from_str(&json).unwrap();
289 assert_eq!(parsed, role);
290 }
291 }
292
293 #[test]
294 fn test_role_display() {
295 assert_eq!(Role::Gateway.to_string(), "gateway");
296 assert_eq!(Role::Worker.to_string(), "worker");
297 assert_eq!(Role::Builder.to_string(), "builder");
298 assert_eq!(Role::CapabilityImessage.to_string(), "capability-imessage");
299 }
300
301 #[test]
302 fn test_role_default_is_worker() {
303 assert_eq!(Role::default(), Role::Worker);
304 }
305
306 #[test]
307 fn test_runtime_policy_defaults() {
308 let p = RuntimePolicy::default();
309 assert_eq!(p.min_running_seconds, 60);
310 assert_eq!(p.min_warm_seconds, 30);
311 assert_eq!(p.drain_timeout_seconds, 30);
312 assert_eq!(p.graceful_shutdown_seconds, 15);
313 }
314
315 #[test]
316 fn test_pool_spec_backward_compat() {
317 let json = r#"{
319 "pool_id": "workers",
320 "tenant_id": "acme",
321 "flake_ref": ".",
322 "profile": "minimal",
323 "instance_resources": {"vcpus": 1, "mem_mib": 512},
324 "desired_counts": {"running": 1, "warm": 0, "sleeping": 0}
325 }"#;
326 let parsed: PoolSpec = serde_json::from_str(json).unwrap();
327 assert_eq!(parsed.role, Role::Worker);
328 assert_eq!(parsed.runtime_policy.min_running_seconds, 60);
329 }
330
331 #[test]
332 fn test_pool_config_data_dir() {
333 assert_eq!(
334 pool_config_data_dir("acme", "gateways"),
335 "/var/lib/mvm/tenants/acme/pools/gateways/config"
336 );
337 }
338
339 #[test]
340 fn test_secret_scope_serde_roundtrip() {
341 let scopes = vec![
342 SecretScope {
343 integration: "whatsapp".to_string(),
344 keys: vec![
345 "WHATSAPP_API_KEY".to_string(),
346 "WHATSAPP_SECRET".to_string(),
347 ],
348 },
349 SecretScope {
350 integration: "telegram".to_string(),
351 keys: vec!["TELEGRAM_BOT_TOKEN".to_string()],
352 },
353 ];
354
355 let json = serde_json::to_string(&scopes).unwrap();
356 let parsed: Vec<SecretScope> = serde_json::from_str(&json).unwrap();
357 assert_eq!(parsed.len(), 2);
358 assert_eq!(parsed[0].integration, "whatsapp");
359 assert_eq!(parsed[0].keys.len(), 2);
360 assert_eq!(parsed[1].integration, "telegram");
361 }
362
363 #[test]
364 fn test_pool_spec_backward_compat_secret_scopes() {
365 let json = r#"{
367 "pool_id": "workers",
368 "tenant_id": "acme",
369 "flake_ref": ".",
370 "profile": "minimal",
371 "instance_resources": {"vcpus": 1, "mem_mib": 512},
372 "desired_counts": {"running": 1, "warm": 0, "sleeping": 0}
373 }"#;
374 let parsed: PoolSpec = serde_json::from_str(json).unwrap();
375 assert!(parsed.secret_scopes.is_empty());
376 }
377}