systemprompt_cloud/tenants/
provisioning.rs1use reqwest::Url;
19use systemprompt_identifiers::{PriceId, TenantId};
20
21use crate::CloudApiClient;
22use crate::checkout::{CheckoutTemplates, run_checkout_callback_flow, wait_for_provisioning};
23use crate::constants::api::{DB_PRODUCTION_HOST, DB_SANDBOX_HOST};
24use crate::error::{CloudError, CloudResult};
25
26use super::{NewCloudTenantParams, StoredTenant};
27
28#[derive(Debug)]
29pub struct TenantCreatePlan {
30 pub price_id: PriceId,
31 pub region: String,
32 pub redirect_uri: String,
33 pub external_db_access: bool,
34 pub templates: CheckoutTemplates,
35}
36
37#[derive(Debug)]
38pub struct ProvisionedTenant {
39 pub tenant: StoredTenant,
40 pub needs_deploy: bool,
41}
42
43#[derive(Debug, Clone, Copy)]
44pub enum ProvisioningProgressEvent<'a> {
45 CheckoutSessionStarted,
46 CheckoutSessionCreated,
47 CheckoutComplete { tenant_id: &'a TenantId },
48 ProvisioningStarted,
49 ProvisioningUpdate { message: &'a str },
50 Provisioned,
51 CredentialsFetchStarted,
52 CredentialsFetched,
53 ExternalAccessStarted,
54 ExternalAccessEnabled { database_url: &'a str },
55 ExternalAccessFailed { error: &'a CloudError },
56 TenantSyncStarted,
57 TenantSynced,
58}
59
60pub trait ProvisioningProgress: Send + Sync {
61 fn event(&self, event: &ProvisioningProgressEvent<'_>);
62}
63
64#[derive(Debug)]
65pub struct TenantProvisioningService<'a> {
66 client: &'a CloudApiClient,
67}
68
69impl<'a> TenantProvisioningService<'a> {
70 #[must_use]
71 pub const fn new(client: &'a CloudApiClient) -> Self {
72 Self { client }
73 }
74
75 pub async fn provision(
76 &self,
77 plan: &TenantCreatePlan,
78 progress: &dyn ProvisioningProgress,
79 ) -> CloudResult<ProvisionedTenant> {
80 progress.event(&ProvisioningProgressEvent::CheckoutSessionStarted);
81 let checkout = self
82 .client
83 .create_checkout(&plan.price_id, &plan.region, Some(&plan.redirect_uri))
84 .await?;
85 progress.event(&ProvisioningProgressEvent::CheckoutSessionCreated);
86
87 let result =
88 run_checkout_callback_flow(self.client, &checkout.checkout_url, plan.templates).await?;
89 progress.event(&ProvisioningProgressEvent::CheckoutComplete {
90 tenant_id: &result.tenant_id,
91 });
92
93 let tenant = self
94 .finalize_tenant(&result.tenant_id, plan.external_db_access, progress)
95 .await?;
96
97 Ok(ProvisionedTenant {
98 tenant,
99 needs_deploy: result.needs_deploy,
100 })
101 }
102
103 pub async fn finalize_tenant(
104 &self,
105 tenant_id: &TenantId,
106 external_db_access: bool,
107 progress: &dyn ProvisioningProgress,
108 ) -> CloudResult<StoredTenant> {
109 progress.event(&ProvisioningProgressEvent::ProvisioningStarted);
110 wait_for_provisioning(self.client, tenant_id, |event| {
111 if let Some(message) = &event.message {
112 progress.event(&ProvisioningProgressEvent::ProvisioningUpdate { message });
113 }
114 })
115 .await?;
116 progress.event(&ProvisioningProgressEvent::Provisioned);
117
118 let internal_database_url = self.fetch_database_url(tenant_id, progress).await?;
119
120 let (enabled, external_database_url) = self
121 .configure_external_access(
122 tenant_id,
123 external_db_access,
124 &internal_database_url,
125 progress,
126 )
127 .await?;
128
129 progress.event(&ProvisioningProgressEvent::TenantSyncStarted);
130 let response = self.client.get_user().await?;
131 progress.event(&ProvisioningProgressEvent::TenantSynced);
132
133 let info = response
134 .tenants
135 .iter()
136 .find(|t| t.id == tenant_id.as_str())
137 .ok_or_else(|| CloudError::other("New tenant not found after checkout"))?;
138
139 Ok(StoredTenant::new_cloud(NewCloudTenantParams {
140 id: TenantId::new(info.id.clone()),
141 name: info.name.clone(),
142 app_id: info.app_id.clone(),
143 hostname: info.hostname.clone(),
144 region: info.region.clone(),
145 database_url: external_database_url,
146 internal_database_url,
147 external_db_access: enabled,
148 }))
149 }
150
151 async fn fetch_database_url(
152 &self,
153 tenant_id: &TenantId,
154 progress: &dyn ProvisioningProgress,
155 ) -> CloudResult<String> {
156 progress.event(&ProvisioningProgressEvent::CredentialsFetchStarted);
157 let status = self.client.get_tenant_status(tenant_id).await?;
158 let secrets_url = status
159 .secrets_url
160 .ok_or_else(|| CloudError::other("Tenant is ready but secrets URL is missing"))?;
161 let secrets = self.client.fetch_secrets(&secrets_url).await?;
162 progress.event(&ProvisioningProgressEvent::CredentialsFetched);
163 Ok(secrets.database_url)
164 }
165
166 async fn configure_external_access(
167 &self,
168 tenant_id: &TenantId,
169 external_db_access: bool,
170 internal_database_url: &str,
171 progress: &dyn ProvisioningProgress,
172 ) -> CloudResult<(bool, Option<String>)> {
173 if !external_db_access {
174 return Ok((false, None));
175 }
176
177 progress.event(&ProvisioningProgressEvent::ExternalAccessStarted);
178 match self.client.set_external_db_access(tenant_id, true).await {
179 Ok(_) => {
180 let external_url = swap_to_external_host(internal_database_url);
181 progress.event(&ProvisioningProgressEvent::ExternalAccessEnabled {
182 database_url: &external_url,
183 });
184 Ok((true, Some(external_url)))
185 },
186 Err(error) => {
187 progress.event(&ProvisioningProgressEvent::ExternalAccessFailed { error: &error });
188 Ok((false, None))
189 },
190 }
191 }
192}
193
194#[must_use]
195pub fn swap_to_external_host(url: &str) -> String {
196 let Ok(parsed) = Url::parse(url) else {
197 return url.to_owned();
198 };
199
200 let host = parsed.host_str().unwrap_or("");
201 let external_host = if host.contains("sandbox") {
202 DB_SANDBOX_HOST
203 } else {
204 DB_PRODUCTION_HOST
205 };
206
207 url.replace(host, external_host)
208 .replace("sslmode=disable", "sslmode=require")
209}