Skip to main content

mvm_core/
pool.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::tenant::tenant_pools_dir;
6
7// ============================================================================
8// Role-based VM type
9// ============================================================================
10
11/// Role for a pool's instances. Determines services, ports, drive
12/// expectations, reconcile ordering, and sleep policy.
13#[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// ============================================================================
35// Minimum runtime policy
36// ============================================================================
37
38// ============================================================================
39// Pool metadata
40// ============================================================================
41
42/// Optional metadata for categorizing and tagging pools.
43/// Enables capability-based queries and policies without hardcoding types in Role enum.
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct PoolMetadata {
46    /// Capability identifier (e.g., "openclaw", "mcp-server", "database").
47    /// Used for grouping pools by functional capability.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub capability: Option<String>,
50
51    /// Integration types supported by this pool (e.g., ["telegram", "discord"]).
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub integration_types: Vec<String>,
54
55    /// Arbitrary key-value tags for custom categorization.
56    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
57    pub tags: std::collections::BTreeMap<String, String>,
58}
59
60/// Per-pool runtime policy for minimum runtime enforcement and graceful lifecycle.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct RuntimePolicy {
63    /// Minimum seconds an instance must stay Running before eligible for Warm.
64    #[serde(default = "default_min_running")]
65    pub min_running_seconds: u64,
66    /// Minimum seconds an instance must stay Warm before eligible for Sleep.
67    #[serde(default = "default_min_warm")]
68    pub min_warm_seconds: u64,
69    /// Maximum seconds to wait for guest drain ACK before forcing sleep.
70    #[serde(default = "default_drain_timeout")]
71    pub drain_timeout_seconds: u64,
72    /// Maximum seconds for graceful shutdown before SIGKILL.
73    #[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// ============================================================================
102// Pool spec
103// ============================================================================
104
105/// A WorkerPool defines a homogeneous group of instances within a tenant.
106/// Has desired counts but NO runtime state.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct PoolSpec {
109    pub pool_id: String,
110    pub tenant_id: String,
111    pub flake_ref: String,
112    /// Guest profile name. Built-in: "minimal", "python".
113    /// Users can define custom profiles in their own flake.
114    pub profile: String,
115    /// Role for all instances in this pool.
116    #[serde(default)]
117    pub role: Role,
118    pub instance_resources: InstanceResources,
119    pub desired_counts: DesiredCounts,
120    /// Minimum runtime policy for this pool's instances.
121    #[serde(default)]
122    pub runtime_policy: RuntimePolicy,
123    /// Optional metadata for capability tagging and categorization.
124    #[serde(default)]
125    pub metadata: PoolMetadata,
126    /// "baseline" | "strict"
127    #[serde(default = "default_seccomp")]
128    pub seccomp_policy: String,
129    /// "none" | "lz4" | "zstd"
130    #[serde(default = "default_compression")]
131    pub snapshot_compression: String,
132    #[serde(default)]
133    pub metadata_enabled: bool,
134    /// If true, reconcile won't auto-sleep this pool's instances.
135    #[serde(default)]
136    pub pinned: bool,
137    /// If true, reconcile won't touch this pool at all.
138    #[serde(default)]
139    pub critical: bool,
140    /// Per-integration secret scoping. When non-empty, secrets are split
141    /// into per-integration directories on the secrets drive.
142    #[serde(default)]
143    pub secret_scopes: Vec<SecretScope>,
144    /// Optional template reference for shared base image.
145    #[serde(default)]
146    pub template_id: String,
147}
148
149/// Scoped secret delivery: only give an integration the secrets it needs.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SecretScope {
152    /// Integration name (e.g. "whatsapp", "telegram").
153    pub integration: String,
154    /// Secret key names to include for this integration.
155    /// Empty means include all keys (no filtering).
156    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/// Resource allocation for each instance in the pool.
168#[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/// Desired instance counts by status, evaluated by the reconcile loop.
177#[derive(Debug, Clone, Default, Serialize, Deserialize)]
178pub struct DesiredCounts {
179    pub running: u32,
180    pub warm: u32,
181    pub sleeping: u32,
182}
183
184/// A completed build revision with artifact locations.
185#[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/// Paths to build artifacts within the pool's artifact directory.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct ArtifactPaths {
197    pub vmlinux: String,
198    pub rootfs: String,
199    pub fc_base_config: String,
200}
201
202// --- Filesystem paths ---
203
204pub 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
224/// Directory for pool-level configuration data (mounted as config drive).
225pub 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        // JSON without role/runtime_policy should deserialize with defaults
318        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        // JSON without secret_scopes should parse fine (defaults to empty vec)
366        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}