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