Skip to main content

systemprompt_cloud/tenants/
mod.rs

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