Skip to main content

orca_control/
deploy_history.rs

1//! In-memory deploy history for rollback support.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use orca_core::config::ServiceConfig;
10
11/// Maximum number of deploy records kept per service.
12const MAX_ENTRIES_PER_SERVICE: usize = 20;
13
14/// A single deploy record.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DeployRecord {
17    /// Unique deploy identifier.
18    pub deploy_id: String,
19    /// Service name.
20    pub service_name: String,
21    /// Image that was deployed.
22    pub image: Option<String>,
23    /// Full service config snapshot at deploy time.
24    pub config: ServiceConfig,
25    /// When this deploy happened.
26    pub timestamp: DateTime<Utc>,
27}
28
29/// In-memory deploy history, keyed by service name.
30#[derive(Debug, Default)]
31pub struct DeployHistory {
32    entries: HashMap<String, Vec<DeployRecord>>,
33}
34
35impl DeployHistory {
36    /// Create an empty deploy history.
37    pub fn new() -> Self {
38        Self {
39            entries: HashMap::new(),
40        }
41    }
42
43    /// Record a new deploy for a service.
44    ///
45    /// Keeps at most [`MAX_ENTRIES_PER_SERVICE`] entries per service,
46    /// dropping the oldest when the limit is exceeded.
47    pub fn record(&mut self, config: &ServiceConfig) {
48        let record = DeployRecord {
49            deploy_id: Uuid::now_v7().to_string(),
50            service_name: config.name.clone(),
51            image: config.image.clone(),
52            config: config.clone(),
53            timestamp: Utc::now(),
54        };
55
56        let history = self.entries.entry(config.name.clone()).or_default();
57        history.push(record);
58
59        // Trim to max entries
60        if history.len() > MAX_ENTRIES_PER_SERVICE {
61            let excess = history.len() - MAX_ENTRIES_PER_SERVICE;
62            history.drain(..excess);
63        }
64    }
65
66    /// Get the second-to-last deploy for rollback.
67    ///
68    /// Returns `None` if fewer than 2 deploys exist for the service.
69    pub fn get_previous(&self, service_name: &str) -> Option<&DeployRecord> {
70        let history = self.entries.get(service_name)?;
71        if history.len() < 2 {
72            return None;
73        }
74        Some(&history[history.len() - 2])
75    }
76
77    /// List all deploy records for a service (oldest first).
78    pub fn list(&self, service_name: &str) -> &[DeployRecord] {
79        self.entries
80            .get(service_name)
81            .map(|v| v.as_slice())
82            .unwrap_or(&[])
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use orca_core::types::Replicas;
90    use std::collections::HashMap;
91
92    fn test_config(name: &str, image: &str) -> ServiceConfig {
93        ServiceConfig {
94            name: name.to_string(),
95            project: None,
96            runtime: Default::default(),
97            image: Some(image.to_string()),
98            module: None,
99            replicas: Replicas::Fixed(1),
100            port: Some(8080),
101            domain: None,
102            health: None,
103            readiness: None,
104            liveness: None,
105            env: HashMap::new(),
106            resources: None,
107            volume: None,
108            deploy: None,
109            placement: None,
110            network: None,
111            aliases: vec![],
112            mounts: vec![],
113            routes: vec![],
114            host_port: None,
115            triggers: Vec::new(),
116            assets: None,
117            build: None,
118            tls_cert: None,
119            tls_key: None,
120            internal: false,
121            depends_on: vec![],
122            cmd: vec![],
123            extra_ports: vec![],
124            strip_prefix: None,
125            pull_policy: Default::default(),
126            backup: None,
127        }
128    }
129
130    #[test]
131    fn record_and_list() {
132        let mut history = DeployHistory::new();
133        history.record(&test_config("api", "api:v1"));
134        history.record(&test_config("api", "api:v2"));
135        assert_eq!(history.list("api").len(), 2);
136        assert_eq!(history.list("api")[0].image.as_deref(), Some("api:v1"));
137    }
138
139    #[test]
140    fn get_previous_returns_second_to_last() {
141        let mut history = DeployHistory::new();
142        history.record(&test_config("api", "api:v1"));
143        assert!(history.get_previous("api").is_none());
144        history.record(&test_config("api", "api:v2"));
145        let prev = history.get_previous("api").unwrap();
146        assert_eq!(prev.image.as_deref(), Some("api:v1"));
147    }
148
149    #[test]
150    fn caps_at_max_entries() {
151        let mut history = DeployHistory::new();
152        for i in 0..25 {
153            history.record(&test_config("svc", &format!("svc:v{i}")));
154        }
155        assert_eq!(history.list("svc").len(), MAX_ENTRIES_PER_SERVICE);
156        assert_eq!(history.list("svc")[0].image.as_deref(), Some("svc:v5"));
157    }
158}