Skip to main content

systemprompt_cloud/tenants/
provisioning.rs

1//! Cloud tenant provisioning behind a progress seam.
2//!
3//! [`TenantProvisioningService`] drives the subscription flow end to end:
4//! checkout session creation, the browser callback, the provisioning watch
5//! ([`wait_for_provisioning`]), database-credential retrieval, optional
6//! external database access, and the final tenant-store record. Callers
7//! assemble a [`TenantCreatePlan`] up front (all prompting happens before the
8//! flow starts) and receive every step boundary as a
9//! [`ProvisioningProgressEvent`] for rendering.
10//!
11//! [`TenantProvisioningService::finalize_tenant`] is the post-checkout half
12//! of the flow; it is public so a tenant whose checkout already completed can
13//! be resumed without a new payment session.
14
15use 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}