Skip to main content

agent_governance/
lifecycle.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Agent lifecycle management -- an eight-state model tracking an agent from
5//! provisioning through decommissioning.
6
7use serde::{Deserialize, Serialize};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// The eight lifecycle states an agent can occupy.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum LifecycleState {
13    /// Agent is being provisioned (initial state).
14    Provisioning,
15    /// Agent is fully operational.
16    Active,
17    /// Agent is temporarily suspended.
18    Suspended,
19    /// Agent credentials are being rotated.
20    Rotating,
21    /// Agent is running in a degraded mode.
22    Degraded,
23    /// Agent has been quarantined due to policy violations or anomalies.
24    Quarantined,
25    /// Agent is in the process of being decommissioned.
26    Decommissioning,
27    /// Agent has been permanently decommissioned (terminal state).
28    Decommissioned,
29}
30
31/// A recorded lifecycle transition event.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct LifecycleEvent {
34    /// State before the transition.
35    pub from: LifecycleState,
36    /// State after the transition.
37    pub to: LifecycleState,
38    /// Human-readable reason for the transition.
39    pub reason: String,
40    /// Who or what initiated the transition.
41    pub initiated_by: String,
42    /// Unix timestamp (seconds) when the transition occurred.
43    pub timestamp: u64,
44}
45
46/// Manages the lifecycle of a single agent.
47pub struct LifecycleManager {
48    agent_id: String,
49    state: LifecycleState,
50    events: Vec<LifecycleEvent>,
51}
52
53impl LifecycleManager {
54    /// Create a new lifecycle manager for the given agent.
55    ///
56    /// The initial state is [`LifecycleState::Provisioning`].
57    pub fn new(agent_id: &str) -> Self {
58        Self {
59            agent_id: agent_id.to_string(),
60            state: LifecycleState::Provisioning,
61            events: Vec::new(),
62        }
63    }
64
65    /// Return the current lifecycle state.
66    pub fn state(&self) -> LifecycleState {
67        self.state
68    }
69
70    /// Return the agent identifier.
71    pub fn agent_id(&self) -> &str {
72        &self.agent_id
73    }
74
75    /// Return all recorded lifecycle events.
76    pub fn events(&self) -> &[LifecycleEvent] {
77        &self.events
78    }
79
80    /// Attempt to transition the agent to `to`.
81    ///
82    /// Returns the resulting [`LifecycleEvent`] on success, or an error
83    /// message describing why the transition is not allowed.
84    pub fn transition(
85        &mut self,
86        to: LifecycleState,
87        reason: &str,
88        initiated_by: &str,
89    ) -> Result<&LifecycleEvent, String> {
90        if !self.can_transition(to) {
91            return Err(format!(
92                "invalid transition from {:?} to {:?}",
93                self.state, to
94            ));
95        }
96
97        let event = LifecycleEvent {
98            from: self.state,
99            to,
100            reason: reason.to_string(),
101            initiated_by: initiated_by.to_string(),
102            timestamp: epoch_now(),
103        };
104        self.state = to;
105        self.events.push(event);
106        Ok(self.events.last().expect("just pushed"))
107    }
108
109    /// Check whether transitioning from the current state to `to` is valid.
110    pub fn can_transition(&self, to: LifecycleState) -> bool {
111        allowed_transitions(self.state).contains(&to)
112    }
113
114    /// Convenience: transition to [`LifecycleState::Active`].
115    pub fn activate(&mut self, reason: &str) -> Result<&LifecycleEvent, String> {
116        self.transition(LifecycleState::Active, reason, "system")
117    }
118
119    /// Convenience: transition to [`LifecycleState::Suspended`].
120    pub fn suspend(&mut self, reason: &str) -> Result<&LifecycleEvent, String> {
121        self.transition(LifecycleState::Suspended, reason, "system")
122    }
123
124    /// Convenience: transition to [`LifecycleState::Quarantined`].
125    pub fn quarantine(&mut self, reason: &str) -> Result<&LifecycleEvent, String> {
126        self.transition(LifecycleState::Quarantined, reason, "system")
127    }
128
129    /// Convenience: transition to [`LifecycleState::Decommissioning`].
130    pub fn decommission(&mut self, reason: &str) -> Result<&LifecycleEvent, String> {
131        self.transition(LifecycleState::Decommissioning, reason, "system")
132    }
133}
134
135/// Return the set of states reachable from `from`.
136fn allowed_transitions(from: LifecycleState) -> &'static [LifecycleState] {
137    use LifecycleState::*;
138    match from {
139        Provisioning => &[Active],
140        Active => &[Suspended, Rotating, Degraded, Decommissioning],
141        Suspended => &[Active, Decommissioning],
142        Rotating => &[Active],
143        Degraded => &[Active, Quarantined, Decommissioning],
144        Quarantined => &[Active, Decommissioning],
145        Decommissioning => &[Decommissioned],
146        Decommissioned => &[],
147    }
148}
149
150fn epoch_now() -> u64 {
151    SystemTime::now()
152        .duration_since(UNIX_EPOCH)
153        .unwrap_or_default()
154        .as_secs()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_initial_state_is_provisioning() {
163        let mgr = LifecycleManager::new("agent-1");
164        assert_eq!(mgr.state(), LifecycleState::Provisioning);
165        assert_eq!(mgr.agent_id(), "agent-1");
166        assert!(mgr.events().is_empty());
167    }
168
169    #[test]
170    fn test_activate_from_provisioning() {
171        let mut mgr = LifecycleManager::new("agent-1");
172        let event = mgr.activate("initial activation").unwrap();
173        assert_eq!(event.from, LifecycleState::Provisioning);
174        assert_eq!(event.to, LifecycleState::Active);
175        assert_eq!(event.reason, "initial activation");
176        assert_eq!(mgr.state(), LifecycleState::Active);
177    }
178
179    #[test]
180    fn test_suspend_from_active() {
181        let mut mgr = LifecycleManager::new("agent-1");
182        mgr.activate("boot").unwrap();
183        let event = mgr.suspend("maintenance window").unwrap();
184        assert_eq!(event.from, LifecycleState::Active);
185        assert_eq!(event.to, LifecycleState::Suspended);
186        assert_eq!(mgr.state(), LifecycleState::Suspended);
187    }
188
189    #[test]
190    fn test_reactivate_from_suspended() {
191        let mut mgr = LifecycleManager::new("agent-1");
192        mgr.activate("boot").unwrap();
193        mgr.suspend("pause").unwrap();
194        let event = mgr.activate("resume").unwrap();
195        assert_eq!(event.from, LifecycleState::Suspended);
196        assert_eq!(event.to, LifecycleState::Active);
197    }
198
199    #[test]
200    fn test_quarantine_from_degraded() {
201        let mut mgr = LifecycleManager::new("agent-1");
202        mgr.activate("boot").unwrap();
203        mgr.transition(LifecycleState::Degraded, "high error rate", "monitor")
204            .unwrap();
205        let event = mgr.quarantine("policy violation detected").unwrap();
206        assert_eq!(event.from, LifecycleState::Degraded);
207        assert_eq!(event.to, LifecycleState::Quarantined);
208    }
209
210    #[test]
211    fn test_decommission_flow() {
212        let mut mgr = LifecycleManager::new("agent-1");
213        mgr.activate("boot").unwrap();
214        mgr.decommission("end of life").unwrap();
215        assert_eq!(mgr.state(), LifecycleState::Decommissioning);
216
217        mgr.transition(LifecycleState::Decommissioned, "cleanup done", "system")
218            .unwrap();
219        assert_eq!(mgr.state(), LifecycleState::Decommissioned);
220    }
221
222    #[test]
223    fn test_decommissioned_is_terminal() {
224        let mut mgr = LifecycleManager::new("agent-1");
225        mgr.activate("boot").unwrap();
226        mgr.decommission("bye").unwrap();
227        mgr.transition(LifecycleState::Decommissioned, "done", "system")
228            .unwrap();
229
230        let result = mgr.activate("try again");
231        assert!(result.is_err());
232        assert!(result
233            .unwrap_err()
234            .contains("invalid transition from Decommissioned"));
235    }
236
237    #[test]
238    fn test_invalid_transition_from_provisioning() {
239        let mut mgr = LifecycleManager::new("agent-1");
240        let result = mgr.suspend("not allowed");
241        assert!(result.is_err());
242    }
243
244    #[test]
245    fn test_invalid_transition_returns_descriptive_error() {
246        let mut mgr = LifecycleManager::new("agent-1");
247        let err = mgr.suspend("nope").unwrap_err();
248        assert!(err.contains("Provisioning"));
249        assert!(err.contains("Suspended"));
250    }
251
252    #[test]
253    fn test_can_transition_returns_true_for_valid() {
254        let mut mgr = LifecycleManager::new("agent-1");
255        assert!(mgr.can_transition(LifecycleState::Active));
256        assert!(!mgr.can_transition(LifecycleState::Suspended));
257
258        mgr.activate("boot").unwrap();
259        assert!(mgr.can_transition(LifecycleState::Suspended));
260        assert!(mgr.can_transition(LifecycleState::Rotating));
261        assert!(mgr.can_transition(LifecycleState::Degraded));
262        assert!(mgr.can_transition(LifecycleState::Decommissioning));
263        assert!(!mgr.can_transition(LifecycleState::Quarantined));
264    }
265
266    #[test]
267    fn test_rotating_returns_to_active() {
268        let mut mgr = LifecycleManager::new("agent-1");
269        mgr.activate("boot").unwrap();
270        mgr.transition(LifecycleState::Rotating, "key rotation", "security")
271            .unwrap();
272        assert_eq!(mgr.state(), LifecycleState::Rotating);
273
274        mgr.activate("rotation complete").unwrap();
275        assert_eq!(mgr.state(), LifecycleState::Active);
276    }
277
278    #[test]
279    fn test_event_history_records_all_transitions() {
280        let mut mgr = LifecycleManager::new("agent-1");
281        mgr.activate("boot").unwrap();
282        mgr.suspend("pause").unwrap();
283        mgr.activate("resume").unwrap();
284
285        let events = mgr.events();
286        assert_eq!(events.len(), 3);
287        assert_eq!(events[0].to, LifecycleState::Active);
288        assert_eq!(events[1].to, LifecycleState::Suspended);
289        assert_eq!(events[2].to, LifecycleState::Active);
290    }
291
292    #[test]
293    fn test_event_timestamps_are_monotonic() {
294        let mut mgr = LifecycleManager::new("agent-1");
295        mgr.activate("boot").unwrap();
296        mgr.suspend("pause").unwrap();
297        mgr.activate("resume").unwrap();
298
299        let events = mgr.events();
300        for window in events.windows(2) {
301            assert!(window[1].timestamp >= window[0].timestamp);
302        }
303    }
304
305    #[test]
306    fn test_lifecycle_state_serde_roundtrip() {
307        let state = LifecycleState::Quarantined;
308        let json = serde_json::to_string(&state).unwrap();
309        let deserialized: LifecycleState = serde_json::from_str(&json).unwrap();
310        assert_eq!(state, deserialized);
311    }
312
313    #[test]
314    fn test_lifecycle_event_serde_roundtrip() {
315        let event = LifecycleEvent {
316            from: LifecycleState::Active,
317            to: LifecycleState::Suspended,
318            reason: "maintenance".to_string(),
319            initiated_by: "admin".to_string(),
320            timestamp: 1700000000,
321        };
322        let json = serde_json::to_string(&event).unwrap();
323        let deserialized: LifecycleEvent = serde_json::from_str(&json).unwrap();
324        assert_eq!(deserialized.from, event.from);
325        assert_eq!(deserialized.to, event.to);
326        assert_eq!(deserialized.reason, event.reason);
327    }
328
329    #[test]
330    fn test_quarantined_can_reactivate() {
331        let mut mgr = LifecycleManager::new("agent-1");
332        mgr.activate("boot").unwrap();
333        mgr.transition(LifecycleState::Degraded, "issues", "monitor")
334            .unwrap();
335        mgr.quarantine("violation").unwrap();
336        mgr.activate("cleared").unwrap();
337        assert_eq!(mgr.state(), LifecycleState::Active);
338    }
339
340    #[test]
341    fn test_quarantined_can_decommission() {
342        let mut mgr = LifecycleManager::new("agent-1");
343        mgr.activate("boot").unwrap();
344        mgr.transition(LifecycleState::Degraded, "issues", "monitor")
345            .unwrap();
346        mgr.quarantine("violation").unwrap();
347        mgr.decommission("permanent removal").unwrap();
348        assert_eq!(mgr.state(), LifecycleState::Decommissioning);
349    }
350}