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//!
15//! Copyright (c) systemprompt.io — Business Source License 1.1.
16//! See <https://systemprompt.io> for licensing details.
17
18use 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}