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
65impl StoredTenant {
66    #[must_use]
67    pub fn new(id: String, name: String) -> Self {
68        Self {
69            id,
70            name,
71            app_id: None,
72            hostname: None,
73            region: None,
74            database_url: None,
75            internal_database_url: None,
76            tenant_type: TenantType::default(),
77            external_db_access: false,
78            sync_token: None,
79        }
80    }
81
82    #[must_use]
83    pub const fn new_local(id: String, name: String, database_url: String) -> Self {
84        Self {
85            id,
86            name,
87            app_id: None,
88            hostname: None,
89            region: None,
90            database_url: Some(database_url),
91            internal_database_url: None,
92            tenant_type: TenantType::Local,
93            external_db_access: false,
94            sync_token: None,
95        }
96    }
97
98    #[must_use]
99    pub fn new_cloud(params: NewCloudTenantParams) -> Self {
100        Self {
101            id: params.id,
102            name: params.name,
103            app_id: params.app_id,
104            hostname: params.hostname,
105            region: params.region,
106            database_url: params.database_url,
107            internal_database_url: Some(params.internal_database_url),
108            tenant_type: TenantType::Cloud,
109            external_db_access: params.external_db_access,
110            sync_token: params.sync_token,
111        }
112    }
113
114    #[must_use]
115    pub fn from_tenant_info(info: &TenantInfo) -> Self {
116        Self {
117            id: info.id.clone(),
118            name: info.name.clone(),
119            app_id: info.app_id.clone(),
120            hostname: info.hostname.clone(),
121            region: info.region.clone(),
122            database_url: None,
123            internal_database_url: Some(info.database_url.clone()),
124            tenant_type: TenantType::Cloud,
125            external_db_access: info.external_db_access,
126            sync_token: None,
127        }
128    }
129
130    #[must_use]
131    pub fn has_database_url(&self) -> bool {
132        match self.tenant_type {
133            TenantType::Cloud => self
134                .internal_database_url
135                .as_ref()
136                .is_some_and(|url| !url.is_empty()),
137            TenantType::Local => self
138                .database_url
139                .as_ref()
140                .is_some_and(|url| !url.is_empty()),
141        }
142    }
143
144    #[must_use]
145    pub fn get_local_database_url(&self) -> Option<&String> {
146        self.database_url
147            .as_ref()
148            .or(self.internal_database_url.as_ref())
149    }
150
151    #[must_use]
152    pub const fn is_cloud(&self) -> bool {
153        matches!(self.tenant_type, TenantType::Cloud)
154    }
155
156    #[must_use]
157    pub const fn is_local(&self) -> bool {
158        matches!(self.tenant_type, TenantType::Local)
159    }
160
161    pub fn update_from_tenant_info(&mut self, info: &TenantInfo) {
162        self.name.clone_from(&info.name);
163        self.app_id.clone_from(&info.app_id);
164        self.hostname.clone_from(&info.hostname);
165        self.region.clone_from(&info.region);
166        self.external_db_access = info.external_db_access;
167    }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
171pub struct TenantStore {
172    #[validate(nested)]
173    pub tenants: Vec<StoredTenant>,
174
175    pub synced_at: DateTime<Utc>,
176}
177
178impl TenantStore {
179    #[must_use]
180    pub fn new(tenants: Vec<StoredTenant>) -> Self {
181        Self {
182            tenants,
183            synced_at: Utc::now(),
184        }
185    }
186
187    #[must_use]
188    pub fn from_tenant_infos(infos: &[TenantInfo]) -> Self {
189        let tenants = infos.iter().map(StoredTenant::from_tenant_info).collect();
190        Self::new(tenants)
191    }
192
193    pub fn load_from_path(path: &Path) -> Result<Self> {
194        if !path.exists() {
195            return Err(CloudError::TenantsNotSynced.into());
196        }
197
198        let content = fs::read_to_string(path)
199            .with_context(|| format!("Failed to read {}", path.display()))?;
200
201        let store: Self = serde_json::from_str(&content)
202            .map_err(|e| CloudError::TenantsStoreCorrupted { source: e })?;
203
204        store
205            .validate()
206            .map_err(|e| CloudError::TenantsStoreInvalid {
207                message: e.to_string(),
208            })?;
209
210        Ok(store)
211    }
212
213    pub fn save_to_path(&self, path: &Path) -> Result<()> {
214        self.validate()
215            .map_err(|e| CloudError::TenantsStoreInvalid {
216                message: e.to_string(),
217            })?;
218
219        if let Some(dir) = path.parent() {
220            fs::create_dir_all(dir)?;
221
222            let gitignore_path = dir.join(".gitignore");
223            if !gitignore_path.exists() {
224                fs::write(&gitignore_path, "*\n")?;
225            }
226        }
227
228        let content = serde_json::to_string_pretty(self)?;
229        fs::write(path, content)?;
230
231        #[cfg(unix)]
232        {
233            use std::os::unix::fs::PermissionsExt;
234            let mut perms = fs::metadata(path)?.permissions();
235            perms.set_mode(0o600);
236            fs::set_permissions(path, perms)?;
237        }
238
239        Ok(())
240    }
241
242    #[must_use]
243    pub fn find_tenant(&self, id: &str) -> Option<&StoredTenant> {
244        self.tenants.iter().find(|t| t.id == id)
245    }
246
247    #[must_use]
248    pub fn is_empty(&self) -> bool {
249        self.tenants.is_empty()
250    }
251
252    #[must_use]
253    pub fn len(&self) -> usize {
254        self.tenants.len()
255    }
256
257    #[must_use]
258    pub fn is_stale(&self, max_age: chrono::Duration) -> bool {
259        let age = Utc::now() - self.synced_at;
260        age > max_age
261    }
262}
263
264impl Default for TenantStore {
265    fn default() -> Self {
266        Self {
267            tenants: Vec::new(),
268            synced_at: Utc::now(),
269        }
270    }
271}