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