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