Skip to main content

systemprompt_cloud/
tenants.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::Path;
6use validator::Validate;
7
8use crate::api_client::TenantInfo;
9use crate::error::CloudError;
10
11#[derive(Debug)]
12pub struct NewCloudTenantParams {
13    // JSON: external vendor identifier
14    pub id: String,
15    pub name: String,
16    pub app_id: Option<String>,
17    pub hostname: Option<String>,
18    pub region: Option<String>,
19    pub database_url: Option<String>,
20    pub internal_database_url: String,
21    pub external_db_access: bool,
22    pub sync_token: Option<String>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
26#[serde(rename_all = "snake_case")]
27pub enum TenantType {
28    #[default]
29    Local,
30    Cloud,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
34pub struct StoredTenant {
35    // JSON: external vendor identifier
36    #[validate(length(min = 1, message = "Tenant ID cannot be empty"))]
37    pub id: String,
38
39    #[validate(length(min = 1, message = "Tenant name cannot be empty"))]
40    pub name: String,
41
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub app_id: Option<String>,
44
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub hostname: Option<String>,
47
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub region: Option<String>,
50
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub database_url: Option<String>,
53
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub internal_database_url: Option<String>,
56
57    #[serde(default)]
58    pub tenant_type: TenantType,
59
60    #[serde(default)]
61    pub external_db_access: bool,
62
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub sync_token: Option<String>,
65
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub shared_container_db: Option<String>,
68}
69
70impl StoredTenant {
71    #[must_use]
72    pub fn new(id: String, name: String) -> Self {
73        Self {
74            id,
75            name,
76            app_id: None,
77            hostname: None,
78            region: None,
79            database_url: None,
80            internal_database_url: None,
81            tenant_type: TenantType::default(),
82            external_db_access: false,
83            sync_token: None,
84            shared_container_db: None,
85        }
86    }
87
88    #[must_use]
89    pub const fn new_local(id: String, name: String, database_url: String) -> Self {
90        Self {
91            id,
92            name,
93            app_id: None,
94            hostname: None,
95            region: None,
96            database_url: Some(database_url),
97            internal_database_url: None,
98            tenant_type: TenantType::Local,
99            external_db_access: false,
100            sync_token: None,
101            shared_container_db: None,
102        }
103    }
104
105    #[must_use]
106    pub const fn new_local_shared(
107        id: String,
108        name: String,
109        database_url: String,
110        shared_container_db: String,
111    ) -> Self {
112        Self {
113            id,
114            name,
115            app_id: None,
116            hostname: None,
117            region: None,
118            database_url: Some(database_url),
119            internal_database_url: None,
120            tenant_type: TenantType::Local,
121            external_db_access: false,
122            sync_token: None,
123            shared_container_db: Some(shared_container_db),
124        }
125    }
126
127    #[must_use]
128    pub fn new_cloud(params: NewCloudTenantParams) -> Self {
129        Self {
130            id: params.id,
131            name: params.name,
132            app_id: params.app_id,
133            hostname: params.hostname,
134            region: params.region,
135            database_url: params.database_url,
136            internal_database_url: Some(params.internal_database_url),
137            tenant_type: TenantType::Cloud,
138            external_db_access: params.external_db_access,
139            sync_token: params.sync_token,
140            shared_container_db: None,
141        }
142    }
143
144    #[must_use]
145    pub fn from_tenant_info(info: &TenantInfo) -> Self {
146        Self {
147            id: info.id.clone(),
148            name: info.name.clone(),
149            app_id: info.app_id.clone(),
150            hostname: info.hostname.clone(),
151            region: info.region.clone(),
152            database_url: None,
153            internal_database_url: Some(info.database_url.clone()),
154            tenant_type: TenantType::Cloud,
155            external_db_access: info.external_db_access,
156            sync_token: None,
157            shared_container_db: None,
158        }
159    }
160
161    #[must_use]
162    pub const fn uses_shared_container(&self) -> bool {
163        self.shared_container_db.is_some()
164    }
165
166    #[must_use]
167    pub fn has_database_url(&self) -> bool {
168        match self.tenant_type {
169            TenantType::Cloud => self
170                .internal_database_url
171                .as_ref()
172                .is_some_and(|url| !url.is_empty()),
173            TenantType::Local => self
174                .database_url
175                .as_ref()
176                .is_some_and(|url| !url.is_empty()),
177        }
178    }
179
180    #[must_use]
181    pub fn get_local_database_url(&self) -> Option<&String> {
182        self.database_url
183            .as_ref()
184            .or(self.internal_database_url.as_ref())
185    }
186
187    #[must_use]
188    pub const fn is_cloud(&self) -> bool {
189        matches!(self.tenant_type, TenantType::Cloud)
190    }
191
192    #[must_use]
193    pub const fn is_local(&self) -> bool {
194        matches!(self.tenant_type, TenantType::Local)
195    }
196
197    pub fn update_from_tenant_info(&mut self, info: &TenantInfo) {
198        self.name.clone_from(&info.name);
199        self.app_id.clone_from(&info.app_id);
200        self.hostname.clone_from(&info.hostname);
201        self.region.clone_from(&info.region);
202        self.external_db_access = info.external_db_access;
203
204        if !info.database_url.contains(":***@") {
205            self.internal_database_url = Some(info.database_url.clone());
206        }
207    }
208
209    #[must_use]
210    pub fn is_sync_token_missing(&self) -> bool {
211        self.tenant_type == TenantType::Cloud && self.sync_token.is_none()
212    }
213
214    #[must_use]
215    pub fn is_database_url_masked(&self) -> bool {
216        self.internal_database_url
217            .as_ref()
218            .is_some_and(|url| url.contains(":***@") || url.contains(":********@"))
219    }
220
221    #[must_use]
222    pub fn has_missing_credentials(&self) -> bool {
223        self.tenant_type == TenantType::Cloud
224            && (self.is_sync_token_missing() || self.is_database_url_masked())
225    }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
229pub struct TenantStore {
230    #[validate(nested)]
231    pub tenants: Vec<StoredTenant>,
232
233    pub synced_at: DateTime<Utc>,
234}
235
236impl TenantStore {
237    #[must_use]
238    pub fn new(tenants: Vec<StoredTenant>) -> Self {
239        Self {
240            tenants,
241            synced_at: Utc::now(),
242        }
243    }
244
245    #[must_use]
246    pub fn from_tenant_infos(infos: &[TenantInfo]) -> Self {
247        let tenants = infos.iter().map(StoredTenant::from_tenant_info).collect();
248        Self::new(tenants)
249    }
250
251    pub fn load_from_path(path: &Path) -> Result<Self> {
252        if !path.exists() {
253            return Err(CloudError::TenantsNotSynced.into());
254        }
255
256        let content = fs::read_to_string(path)
257            .with_context(|| format!("Failed to read {}", path.display()))?;
258
259        let store: Self = serde_json::from_str(&content)
260            .map_err(|e| CloudError::TenantsStoreCorrupted { source: e })?;
261
262        store
263            .validate()
264            .map_err(|e| CloudError::TenantsStoreInvalid {
265                message: e.to_string(),
266            })?;
267
268        Ok(store)
269    }
270
271    pub fn save_to_path(&self, path: &Path) -> Result<()> {
272        self.validate()
273            .map_err(|e| CloudError::TenantsStoreInvalid {
274                message: e.to_string(),
275            })?;
276
277        if let Some(dir) = path.parent() {
278            fs::create_dir_all(dir)?;
279
280            let gitignore_path = dir.join(".gitignore");
281            if !gitignore_path.exists() {
282                fs::write(&gitignore_path, "*\n")?;
283            }
284        }
285
286        let content = serde_json::to_string_pretty(self)?;
287        fs::write(path, content)?;
288
289        #[cfg(unix)]
290        {
291            use std::os::unix::fs::PermissionsExt;
292            let mut perms = fs::metadata(path)?.permissions();
293            perms.set_mode(0o600);
294            fs::set_permissions(path, perms)?;
295        }
296
297        Ok(())
298    }
299
300    #[must_use]
301    pub fn find_tenant(&self, id: &str) -> Option<&StoredTenant> {
302        self.tenants.iter().find(|t| t.id == id)
303    }
304
305    #[must_use]
306    pub fn is_empty(&self) -> bool {
307        self.tenants.is_empty()
308    }
309
310    #[must_use]
311    pub fn len(&self) -> usize {
312        self.tenants.len()
313    }
314
315    #[must_use]
316    pub fn is_stale(&self, max_age: chrono::Duration) -> bool {
317        let age = Utc::now() - self.synced_at;
318        age > max_age
319    }
320}
321
322impl Default for TenantStore {
323    fn default() -> Self {
324        Self {
325            tenants: Vec::new(),
326            synced_at: Utc::now(),
327        }
328    }
329}