Skip to main content

systemprompt_cli/commands/cloud/tenant/create/
cloud.rs

1//! Cloud tenant creation via subscription checkout.
2//!
3//! Validates a release build, drives the plan/region selection and Paddle
4//! checkout callback, waits for provisioning, retrieves database credentials,
5//! optionally enables external database access, and writes a profile for the
6//! new tenant.
7
8use anyhow::{Context, Result, anyhow, bail};
9use dialoguer::theme::ColorfulTheme;
10use dialoguer::{Confirm, Input, Select};
11use systemprompt_cloud::constants::api::{DB_PRODUCTION_HOST, DB_SANDBOX_HOST};
12use systemprompt_cloud::constants::checkout::CALLBACK_PORT;
13use systemprompt_cloud::constants::regions::AVAILABLE;
14use systemprompt_cloud::{
15    CheckoutTemplates, CloudApiClient, CloudCredentials, StoredTenant, TenantType,
16};
17use systemprompt_identifiers::{PriceId, TenantId};
18use systemprompt_logging::CliService;
19use url::Url;
20
21pub(super) use crate::cloud::deploy::deploy_with_secrets;
22use crate::cloud::profile::{collect_api_keys, create_profile_for_tenant};
23use crate::cloud::templates::{CHECKOUT_ERROR_HTML, CHECKOUT_SUCCESS_HTML, WAITING_HTML};
24use systemprompt_cloud::{run_checkout_callback_flow, wait_for_provisioning};
25
26use super::super::validation::{validate_build_ready, warn_required_secrets};
27
28pub async fn create_cloud_tenant(
29    creds: &CloudCredentials,
30    _default_region: &str,
31) -> Result<StoredTenant> {
32    let validation = validate_build_ready().context(
33        "Cloud tenant creation requires a built project.\nRun 'just build --release' before \
34         creating a cloud tenant.",
35    )?;
36
37    CliService::success("Build validation passed");
38    CliService::info("Creating cloud tenant via subscription");
39
40    let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
41
42    let selected_plan = select_plan(&client).await?;
43    let selected_region = select_region()?;
44
45    let redirect_uri = format!("http://127.0.0.1:{}/callback", CALLBACK_PORT);
46    let spinner = CliService::spinner("Creating checkout session...");
47    let checkout = client
48        .create_checkout(&selected_plan, selected_region, Some(&redirect_uri))
49        .await?;
50    spinner.finish_and_clear();
51
52    let templates = CheckoutTemplates {
53        success_html: CHECKOUT_SUCCESS_HTML,
54        error_html: CHECKOUT_ERROR_HTML,
55        waiting_html: WAITING_HTML,
56    };
57
58    let result = run_checkout_callback_flow(&client, &checkout.checkout_url, templates).await?;
59    let tenant_id = result.tenant_id.clone();
60    CliService::success(&format!(
61        "Checkout complete! Tenant ID: {}",
62        tenant_id.as_str()
63    ));
64
65    let spinner = CliService::spinner("Waiting for infrastructure provisioning...");
66    wait_for_provisioning(&client, &tenant_id, |event| {
67        if let Some(msg) = &event.message {
68            CliService::info(msg);
69        }
70    })
71    .await?;
72    spinner.finish_and_clear();
73    CliService::success("Tenant provisioned successfully");
74
75    let internal_database_url = fetch_credentials(&client, &tenant_id).await?;
76
77    let (external_db_access, external_database_url) =
78        configure_external_access(&client, &tenant_id, &internal_database_url).await?;
79
80    let stored_tenant = build_stored_tenant(
81        &client,
82        &tenant_id,
83        TenantDatabaseConfig {
84            external_database_url,
85            internal_database_url,
86            external_db_access,
87        },
88    )
89    .await?;
90
91    CliService::section("Profile Setup");
92    let profile_name: String = Input::with_theme(&ColorfulTheme::default())
93        .with_prompt("Profile name")
94        .default(stored_tenant.name.clone())
95        .interact_text()?;
96
97    CliService::section("API Keys");
98    let api_keys = collect_api_keys()?;
99
100    let profile = create_profile_for_tenant(
101        &stored_tenant,
102        &api_keys,
103        &profile_name,
104        Some(&creds.api_url),
105    )?;
106    CliService::success(&format!("Profile '{}' created", profile.name));
107
108    if result.needs_deploy {
109        CliService::section("Initial Deploy");
110        CliService::info("Deploying your code with profile configuration...");
111        deploy_with_secrets(&client, &tenant_id, &profile.name).await?;
112    }
113
114    warn_required_secrets(&validation.required_secrets);
115
116    Ok(stored_tenant)
117}
118
119async fn select_plan(client: &CloudApiClient) -> Result<PriceId> {
120    let spinner = CliService::spinner("Fetching available plans...");
121    let plans = client.get_plans().await?;
122    spinner.finish_and_clear();
123
124    if plans.is_empty() {
125        bail!("No plans available. Please contact support.");
126    }
127
128    let plan_options: Vec<String> = plans.iter().map(|p| p.name.clone()).collect();
129
130    let plan_selection = Select::with_theme(&ColorfulTheme::default())
131        .with_prompt("Select a plan")
132        .items(&plan_options)
133        .default(0)
134        .interact()?;
135
136    Ok(plans[plan_selection].paddle_price_id.clone())
137}
138
139fn select_region() -> Result<&'static str> {
140    let region_options: Vec<String> = AVAILABLE
141        .iter()
142        .map(|(code, name)| format!("{} ({})", name, code))
143        .collect();
144
145    let region_selection = Select::with_theme(&ColorfulTheme::default())
146        .with_prompt("Select a region")
147        .items(&region_options)
148        .default(0)
149        .interact()?;
150
151    Ok(AVAILABLE[region_selection].0)
152}
153
154async fn fetch_credentials(client: &CloudApiClient, tenant_id: &TenantId) -> Result<String> {
155    let spinner = CliService::spinner("Fetching database credentials...");
156    let status = client.get_tenant_status(tenant_id).await?;
157    let secrets_url = status
158        .secrets_url
159        .ok_or_else(|| anyhow!("Tenant is ready but secrets URL is missing"))?;
160    let secrets = client.fetch_secrets(&secrets_url).await?;
161    spinner.finish_and_clear();
162    CliService::success("Database credentials retrieved");
163    Ok(secrets.database_url)
164}
165
166async fn configure_external_access(
167    client: &CloudApiClient,
168    tenant_id: &TenantId,
169    internal_database_url: &str,
170) -> Result<(bool, Option<String>)> {
171    CliService::section("Database Access");
172    CliService::info(
173        "External database access allows direct PostgreSQL connections from your local machine.",
174    );
175    CliService::info("This is required for local development workflows.");
176
177    let enable_external = Confirm::with_theme(&ColorfulTheme::default())
178        .with_prompt("Enable external database access?")
179        .default(true)
180        .interact()?;
181
182    if !enable_external {
183        CliService::info("External access disabled. Some local features will be limited.");
184        return Ok((false, None));
185    }
186
187    let spinner = CliService::spinner("Enabling external database access...");
188    match client.set_external_db_access(tenant_id, true).await {
189        Ok(_) => {
190            let external_url = swap_to_external_host(internal_database_url);
191            spinner.finish_and_clear();
192            CliService::success("External database access enabled");
193            print_database_connection_info(&external_url);
194            Ok((true, Some(external_url)))
195        },
196        Err(e) => {
197            spinner.finish_and_clear();
198            CliService::warning(&format!("Failed to enable external access: {}", e));
199            CliService::info("You can enable it later with 'systemprompt cloud tenant edit'");
200            Ok((false, None))
201        },
202    }
203}
204
205struct TenantDatabaseConfig {
206    external_database_url: Option<String>,
207    internal_database_url: String,
208    external_db_access: bool,
209}
210
211async fn build_stored_tenant(
212    client: &CloudApiClient,
213    tenant_id: &TenantId,
214    db_config: TenantDatabaseConfig,
215) -> Result<StoredTenant> {
216    let spinner = CliService::spinner("Syncing new tenant...");
217    let response = client.get_user().await?;
218    spinner.finish_and_clear();
219
220    let new_tenant = response
221        .tenants
222        .iter()
223        .find(|t| t.id == tenant_id.as_str())
224        .ok_or_else(|| anyhow!("New tenant not found after checkout"))?;
225
226    Ok(StoredTenant {
227        id: new_tenant.id.clone(),
228        name: new_tenant.name.clone(),
229        tenant_type: TenantType::Cloud,
230        app_id: new_tenant.app_id.clone(),
231        hostname: new_tenant.hostname.clone(),
232        region: new_tenant.region.clone(),
233        database_url: db_config.external_database_url,
234        internal_database_url: Some(db_config.internal_database_url),
235        external_db_access: db_config.external_db_access,
236        shared_container_db: None,
237    })
238}
239
240pub fn swap_to_external_host(url: &str) -> String {
241    let Ok(parsed) = Url::parse(url) else {
242        return url.to_owned();
243    };
244
245    let host = parsed.host_str().unwrap_or("");
246    let external_host = if host.contains("sandbox") {
247        DB_SANDBOX_HOST
248    } else {
249        DB_PRODUCTION_HOST
250    };
251
252    url.replace(host, external_host)
253        .replace("sslmode=disable", "sslmode=require")
254}
255
256fn print_database_connection_info(url: &str) {
257    let Ok(parsed) = Url::parse(url) else {
258        return;
259    };
260
261    let host = parsed.host_str().unwrap_or("unknown");
262    let port = parsed.port().unwrap_or(5432);
263    let database = parsed.path().trim_start_matches('/');
264    let username = parsed.username();
265    let password = parsed.password().unwrap_or("********");
266
267    CliService::section("Database Connection");
268    CliService::key_value("Host", host);
269    CliService::key_value("Port", &port.to_string());
270    CliService::key_value("Database", database);
271    CliService::key_value("User", username);
272    CliService::key_value("Password", password);
273    CliService::key_value("SSL", "required");
274    CliService::info("");
275    CliService::key_value("Connection URL", url);
276    CliService::info("");
277    CliService::info(&format!(
278        "Connect with psql:\n  PGPASSWORD='{}' psql -h {} -p {} -U {} -d {}",
279        password, host, port, username, database
280    ));
281}