Skip to main content

systemprompt_cloud/tenants/
mod.rs

1//! On-disk representation of cloud tenants the CLI knows about.
2//!
3//! [`StoredTenant`] is the per-tenant record; [`TenantStore`] (in
4//! `tenant_store.rs`) is the persistent map keyed by tenant id.
5
6mod tenant_store;
7
8use serde::{Deserialize, Serialize};
9use validator::Validate;
10
11use crate::api_client::TenantInfo;
12
13pub use tenant_store::TenantStore;
14
15#[derive(Debug)]
16pub struct NewCloudTenantParams {
17    pub id: String,
18    pub name: String,
19    pub app_id: Option<String>,
20    pub hostname: Option<String>,
21    pub region: Option<String>,
22    pub database_url: Option<String>,
23    pub internal_database_url: String,
24    pub external_db_access: bool,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
28#[serde(rename_all = "snake_case")]
29pub enum TenantType {
30    #[default]
31    Local,
32    Cloud,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
36pub struct StoredTenant {
37    #[validate(length(min = 1, message = "Tenant ID cannot be empty"))]
38    pub id: String,
39
40    #[validate(length(min = 1, message = "Tenant name cannot be empty"))]
41    pub name: String,
42
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub app_id: Option<String>,
45
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub hostname: Option<String>,
48
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub region: Option<String>,
51
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub database_url: Option<String>,
54
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub internal_database_url: Option<String>,
57
58    #[serde(default)]
59    pub tenant_type: TenantType,
60
61    #[serde(default)]
62    pub external_db_access: bool,
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            shared_container_db: None,
82        }
83    }
84
85    #[must_use]
86    pub const fn new_local(id: String, name: String, database_url: String) -> Self {
87        Self {
88            id,
89            name,
90            app_id: None,
91            hostname: None,
92            region: None,
93            database_url: Some(database_url),
94            internal_database_url: None,
95            tenant_type: TenantType::Local,
96            external_db_access: false,
97            shared_container_db: None,
98        }
99    }
100
101    #[must_use]
102    pub const fn new_local_shared(
103        id: String,
104        name: String,
105        database_url: String,
106        shared_container_db: String,
107    ) -> Self {
108        Self {
109            id,
110            name,
111            app_id: None,
112            hostname: None,
113            region: None,
114            database_url: Some(database_url),
115            internal_database_url: None,
116            tenant_type: TenantType::Local,
117            external_db_access: false,
118            shared_container_db: Some(shared_container_db),
119        }
120    }
121
122    #[must_use]
123    pub fn new_cloud(params: NewCloudTenantParams) -> Self {
124        Self {
125            id: params.id,
126            name: params.name,
127            app_id: params.app_id,
128            hostname: params.hostname,
129            region: params.region,
130            database_url: params.database_url,
131            internal_database_url: Some(params.internal_database_url),
132            tenant_type: TenantType::Cloud,
133            external_db_access: params.external_db_access,
134            shared_container_db: None,
135        }
136    }
137
138    #[must_use]
139    pub fn from_tenant_info(info: &TenantInfo) -> Self {
140        Self {
141            id: info.id.clone(),
142            name: info.name.clone(),
143            app_id: info.app_id.clone(),
144            hostname: info.hostname.clone(),
145            region: info.region.clone(),
146            database_url: None,
147            internal_database_url: Some(info.database_url.clone()),
148            tenant_type: TenantType::Cloud,
149            external_db_access: info.external_db_access,
150            shared_container_db: None,
151        }
152    }
153
154    #[must_use]
155    pub const fn uses_shared_container(&self) -> bool {
156        self.shared_container_db.is_some()
157    }
158
159    #[must_use]
160    pub fn has_database_url(&self) -> bool {
161        match self.tenant_type {
162            TenantType::Cloud => self
163                .internal_database_url
164                .as_ref()
165                .is_some_and(|url| !url.is_empty()),
166            TenantType::Local => self
167                .database_url
168                .as_ref()
169                .is_some_and(|url| !url.is_empty()),
170        }
171    }
172
173    #[must_use]
174    pub fn get_local_database_url(&self) -> Option<&String> {
175        self.database_url
176            .as_ref()
177            .or(self.internal_database_url.as_ref())
178    }
179
180    #[must_use]
181    pub const fn is_cloud(&self) -> bool {
182        matches!(self.tenant_type, TenantType::Cloud)
183    }
184
185    #[must_use]
186    pub const fn is_local(&self) -> bool {
187        matches!(self.tenant_type, TenantType::Local)
188    }
189
190    pub fn update_from_tenant_info(&mut self, info: &TenantInfo) {
191        self.name.clone_from(&info.name);
192        self.app_id.clone_from(&info.app_id);
193        self.hostname.clone_from(&info.hostname);
194        self.region.clone_from(&info.region);
195        self.external_db_access = info.external_db_access;
196
197        if !info.database_url.contains(":***@") {
198            self.internal_database_url = Some(info.database_url.clone());
199        }
200    }
201
202    #[must_use]
203    pub fn is_database_url_masked(&self) -> bool {
204        self.internal_database_url
205            .as_ref()
206            .is_some_and(|url| url.contains(":***@") || url.contains(":********@"))
207    }
208
209    #[must_use]
210    pub fn has_missing_credentials(&self) -> bool {
211        self.tenant_type == TenantType::Cloud && self.is_database_url_masked()
212    }
213}