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