Skip to main content

enact_config/
sync.rs

1//! Cloud Sync - Automatic configuration synchronization
2//!
3//! Syncs configuration between local and cloud for authenticated users.
4//! Respects air-gapped mode (no sync when runtime.mode === "airgapped").
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use tracing::{debug, warn};
9
10use crate::config::{Config, RuntimeMode};
11
12/// Sync status
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SyncStatus {
15    /// Sync is enabled and active
16    Enabled,
17    /// Sync is disabled (air-gapped mode or not authenticated)
18    Disabled,
19    /// Sync is in progress
20    Syncing,
21    /// Sync failed
22    Failed,
23}
24
25/// Cloud sync manager
26pub struct SyncManager {
27    api_url: Option<String>,
28    tenant_id: Option<String>,
29    auto_sync: bool,
30    runtime_mode: RuntimeMode,
31}
32
33#[derive(Debug, Serialize, Deserialize)]
34struct SyncRequest {
35    tenant_id: String,
36    config: Config,
37    timestamp: i64,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41pub struct SyncResponse {
42    pub config: Config,
43    pub timestamp: i64,
44    pub conflicts: Vec<String>,
45}
46
47impl SyncManager {
48    /// Create a new sync manager
49    pub fn new(
50        api_url: Option<String>,
51        tenant_id: Option<String>,
52        auto_sync: bool,
53        runtime_mode: RuntimeMode,
54    ) -> Self {
55        Self {
56            api_url,
57            tenant_id,
58            auto_sync,
59            runtime_mode,
60        }
61    }
62
63    /// Check if sync is enabled
64    ///
65    /// Sync is enabled only if:
66    /// - auto_sync is true
67    /// - runtime_mode is not "airgapped"
68    /// - api_url and tenant_id are set
69    pub fn is_enabled(&self) -> bool {
70        if !self.auto_sync {
71            return false;
72        }
73
74        if matches!(self.runtime_mode, RuntimeMode::AirGapped) {
75            debug!("Sync disabled: air-gapped mode");
76            return false;
77        }
78
79        if self.api_url.is_none() || self.tenant_id.is_none() {
80            debug!("Sync disabled: missing API URL or tenant ID");
81            return false;
82        }
83
84        true
85    }
86
87    /// Get sync status
88    pub fn status(&self) -> SyncStatus {
89        if self.is_enabled() {
90            SyncStatus::Enabled
91        } else {
92            SyncStatus::Disabled
93        }
94    }
95
96    /// Sync configuration to cloud
97    ///
98    /// # Arguments
99    /// * `config` - The configuration to sync
100    ///
101    /// # Returns
102    /// * `Ok(Some(response))` if sync was successful
103    /// * `Ok(None)` if sync is disabled
104    /// * `Err` if there was an error syncing
105    pub async fn sync_to_cloud(&self, config: &Config) -> Result<Option<SyncResponse>> {
106        if !self.is_enabled() {
107            return Ok(None);
108        }
109
110        let api_url = self.api_url.as_ref().unwrap();
111        let tenant_id = self.tenant_id.as_ref().unwrap();
112
113        let sync_request = SyncRequest {
114            tenant_id: tenant_id.clone(),
115            config: config.clone(),
116            timestamp: chrono::Utc::now().timestamp(),
117        };
118
119        let client = reqwest::Client::new();
120        let url = format!("{}/api/v1/config/sync", api_url);
121
122        debug!("Syncing configuration to cloud: {}", url);
123
124        let response = client
125            .post(&url)
126            .json(&sync_request)
127            .send()
128            .await
129            .context("Failed to send sync request")?;
130
131        if !response.status().is_success() {
132            let status = response.status();
133            let error_text = response.text().await.unwrap_or_default();
134            return Err(anyhow::anyhow!(
135                "Sync failed with status {}: {}",
136                status,
137                error_text
138            ));
139        }
140
141        let sync_response: SyncResponse = response
142            .json()
143            .await
144            .context("Failed to parse sync response")?;
145
146        if !sync_response.conflicts.is_empty() {
147            warn!(
148                "Configuration conflicts detected: {:?}",
149                sync_response.conflicts
150            );
151        }
152
153        debug!("Configuration synced successfully");
154        Ok(Some(sync_response))
155    }
156
157    /// Sync configuration from cloud
158    ///
159    /// # Returns
160    /// * `Ok(Some(config))` if sync was successful
161    /// * `Ok(None)` if sync is disabled
162    /// * `Err` if there was an error syncing
163    pub async fn sync_from_cloud(&self) -> Result<Option<Config>> {
164        if !self.is_enabled() {
165            return Ok(None);
166        }
167
168        let api_url = self.api_url.as_ref().unwrap();
169        let tenant_id = self.tenant_id.as_ref().unwrap();
170
171        let client = reqwest::Client::new();
172        let url = format!("{}/api/v1/config/sync?tenant_id={}", api_url, tenant_id);
173
174        debug!("Syncing configuration from cloud: {}", url);
175
176        let response = client
177            .get(&url)
178            .send()
179            .await
180            .context("Failed to send sync request")?;
181
182        if !response.status().is_success() {
183            let status = response.status();
184            let error_text = response.text().await.unwrap_or_default();
185            return Err(anyhow::anyhow!(
186                "Sync failed with status {}: {}",
187                status,
188                error_text
189            ));
190        }
191
192        let sync_response: SyncResponse = response
193            .json()
194            .await
195            .context("Failed to parse sync response")?;
196
197        debug!("Configuration synced from cloud successfully");
198        Ok(Some(sync_response.config))
199    }
200
201    /// Resolve conflicts using last-write-wins strategy
202    ///
203    /// # Arguments
204    /// * `local_config` - Local configuration
205    /// * `cloud_config` - Cloud configuration
206    /// * `conflicts` - List of conflicting keys
207    ///
208    /// # Returns
209    /// * Merged configuration with conflicts resolved
210    pub fn resolve_conflicts(
211        &self,
212        _local_config: &Config,
213        cloud_config: &Config,
214        conflicts: &[String],
215    ) -> Config {
216        // For now, use last-write-wins (cloud wins)
217        // In the future, this could be more sophisticated
218        warn!(
219            "Resolving {} conflicts using last-write-wins (cloud wins)",
220            conflicts.len()
221        );
222        cloud_config.clone()
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_sync_enabled() {
232        let manager = SyncManager::new(
233            Some("https://api.example.com".to_string()),
234            Some("tenant-123".to_string()),
235            true,
236            RuntimeMode::Local,
237        );
238        assert!(manager.is_enabled());
239        assert_eq!(manager.status(), SyncStatus::Enabled);
240    }
241
242    #[test]
243    fn test_sync_disabled_airgapped() {
244        let manager = SyncManager::new(
245            Some("https://api.example.com".to_string()),
246            Some("tenant-123".to_string()),
247            true,
248            RuntimeMode::AirGapped,
249        );
250        assert!(!manager.is_enabled());
251        assert_eq!(manager.status(), SyncStatus::Disabled);
252    }
253
254    #[test]
255    fn test_sync_disabled_no_auth() {
256        let manager = SyncManager::new(None, None, true, RuntimeMode::Local);
257        assert!(!manager.is_enabled());
258        assert_eq!(manager.status(), SyncStatus::Disabled);
259    }
260}