Skip to main content

mvm_core/
agent.rs

1use serde::{Deserialize, Serialize};
2
3use crate::instance::InstanceState;
4use crate::node::{NodeInfo, NodeStats};
5use crate::pool::{DesiredCounts, InstanceResources, Role, RuntimePolicy, SecretScope};
6use crate::routing::RoutingTable;
7use crate::signing::SignedPayload;
8use crate::tenant::TenantQuota;
9
10// ============================================================================
11// Desired state schema (pushed by coordinator)
12// ============================================================================
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct DesiredState {
17    pub schema_version: u32,
18    pub node_id: String,
19    pub tenants: Vec<DesiredTenant>,
20    #[serde(default)]
21    pub prune_unknown_tenants: bool,
22    #[serde(default)]
23    pub prune_unknown_pools: bool,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct DesiredTenant {
29    pub tenant_id: String,
30    pub network: DesiredTenantNetwork,
31    pub quotas: TenantQuota,
32    #[serde(default)]
33    pub secrets_hash: Option<String>,
34    pub pools: Vec<DesiredPool>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct DesiredTenantNetwork {
40    pub tenant_net_id: u16,
41    pub ipv4_subnet: String,
42}
43
44/// Maximum desired instances per pool per state.
45pub const MAX_DESIRED_PER_STATE: u32 = 100;
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct DesiredPool {
50    pub pool_id: String,
51    pub flake_ref: String,
52    pub profile: String,
53    #[serde(default)]
54    pub role: Role,
55    pub instance_resources: InstanceResources,
56    pub desired_counts: DesiredCounts,
57    #[serde(default)]
58    pub runtime_policy: RuntimePolicy,
59    #[serde(default = "default_seccomp")]
60    pub seccomp_policy: String,
61    #[serde(default = "default_compression")]
62    pub snapshot_compression: String,
63    #[serde(default)]
64    pub routing_table: Option<RoutingTable>,
65    #[serde(default)]
66    pub secret_scopes: Vec<SecretScope>,
67}
68
69fn default_seccomp() -> String {
70    "baseline".to_string()
71}
72
73fn default_compression() -> String {
74    "none".to_string()
75}
76
77// ============================================================================
78// Reconcile report
79// ============================================================================
80
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
82pub struct ReconcileReport {
83    pub tenants_created: Vec<String>,
84    pub tenants_pruned: Vec<String>,
85    pub pools_created: Vec<String>,
86    pub instances_created: u32,
87    pub instances_started: u32,
88    pub instances_warmed: u32,
89    pub instances_slept: u32,
90    pub instances_stopped: u32,
91    #[serde(default)]
92    pub instances_deferred: u32,
93    pub errors: Vec<String>,
94}
95
96// ============================================================================
97// Typed message protocol (QUIC API)
98// ============================================================================
99
100/// Strongly typed request sent over QUIC streams.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub enum AgentRequest {
103    /// Push a new desired state for reconciliation (unsigned, dev mode only).
104    Reconcile(DesiredState),
105    /// Push a signed desired state for reconciliation (production mode).
106    ReconcileSigned(SignedPayload),
107    /// Query node capabilities and identity.
108    NodeInfo,
109    /// Query aggregate node statistics.
110    NodeStats,
111    /// List all tenants on this node.
112    TenantList,
113    /// List instances for a specific tenant (optionally filtered by pool).
114    InstanceList {
115        tenant_id: String,
116        pool_id: Option<String>,
117    },
118    /// Urgently wake a sleeping instance.
119    WakeInstance {
120        tenant_id: String,
121        pool_id: String,
122        instance_id: String,
123    },
124}
125
126/// Strongly typed response returned over QUIC streams.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub enum AgentResponse {
129    /// Result of a reconcile pass.
130    ReconcileResult(ReconcileReport),
131    /// Node info.
132    NodeInfo(NodeInfo),
133    /// Aggregate node stats.
134    NodeStats(NodeStats),
135    /// List of tenant IDs.
136    TenantList(Vec<String>),
137    /// List of instance states.
138    InstanceList(Vec<InstanceState>),
139    /// Result of a wake operation.
140    WakeResult { success: bool },
141    /// Error response.
142    Error { code: u16, message: String },
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_agent_request_serde() {
151        let req = AgentRequest::NodeInfo;
152        let json = serde_json::to_string(&req).unwrap();
153        let parsed: AgentRequest = serde_json::from_str(&json).unwrap();
154        assert!(matches!(parsed, AgentRequest::NodeInfo));
155    }
156
157    #[test]
158    fn test_agent_response_error() {
159        let resp = AgentResponse::Error {
160            code: 404,
161            message: "not found".to_string(),
162        };
163        let json = serde_json::to_string(&resp).unwrap();
164        let parsed: AgentResponse = serde_json::from_str(&json).unwrap();
165        match parsed {
166            AgentResponse::Error { code, message } => {
167                assert_eq!(code, 404);
168                assert_eq!(message, "not found");
169            }
170            _ => panic!("Expected Error variant"),
171        }
172    }
173
174    #[test]
175    fn test_desired_state_serde() {
176        let ds = DesiredState {
177            schema_version: 1,
178            node_id: "node-1".to_string(),
179            tenants: vec![],
180            prune_unknown_tenants: false,
181            prune_unknown_pools: false,
182        };
183        let json = serde_json::to_string(&ds).unwrap();
184        let parsed: DesiredState = serde_json::from_str(&json).unwrap();
185        assert_eq!(parsed.schema_version, 1);
186        assert_eq!(parsed.node_id, "node-1");
187    }
188
189    #[test]
190    fn test_reconcile_report_default() {
191        let report = ReconcileReport::default();
192        assert!(report.tenants_created.is_empty());
193        assert!(report.errors.is_empty());
194        assert_eq!(report.instances_created, 0);
195    }
196}