Skip to main content

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

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