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
14pub(super) use 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 = 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        },
81    )
82    .await?;
83
84    CliService::section("Profile Setup");
85    let profile_name: String = Input::with_theme(&ColorfulTheme::default())
86        .with_prompt("Profile name")
87        .default(stored_tenant.name.clone())
88        .interact_text()?;
89
90    CliService::section("API Keys");
91    let api_keys = collect_api_keys()?;
92
93    let profile = create_profile_for_tenant(
94        &stored_tenant,
95        &api_keys,
96        &profile_name,
97        Some(&creds.api_url),
98    )?;
99    CliService::success(&format!("Profile '{}' created", profile.name));
100
101    if result.needs_deploy {
102        CliService::section("Initial Deploy");
103        CliService::info("Deploying your code with profile configuration...");
104        deploy_with_secrets(&client, &tenant_id, &profile.name).await?;
105    }
106
107    warn_required_secrets(&validation.required_secrets);
108
109    Ok(stored_tenant)
110}
111
112async fn select_plan(client: &CloudApiClient) -> Result<PriceId> {
113    let spinner = CliService::spinner("Fetching available plans...");
114    let plans = client.get_plans().await?;
115    spinner.finish_and_clear();
116
117    if plans.is_empty() {
118        bail!("No plans available. Please contact support.");
119    }
120
121    let plan_options: Vec<String> = plans.iter().map(|p| p.name.clone()).collect();
122
123    let plan_selection = Select::with_theme(&ColorfulTheme::default())
124        .with_prompt("Select a plan")
125        .items(&plan_options)
126        .default(0)
127        .interact()?;
128
129    Ok(plans[plan_selection].paddle_price_id.clone())
130}
131
132fn select_region() -> Result<&'static str> {
133    let region_options: Vec<String> = AVAILABLE
134        .iter()
135        .map(|(code, name)| format!("{} ({})", name, code))
136        .collect();
137
138    let region_selection = Select::with_theme(&ColorfulTheme::default())
139        .with_prompt("Select a region")
140        .items(&region_options)
141        .default(0)
142        .interact()?;
143
144    Ok(AVAILABLE[region_selection].0)
145}
146
147async fn fetch_credentials(client: &CloudApiClient, tenant_id: &TenantId) -> Result<String> {
148    let spinner = CliService::spinner("Fetching database credentials...");
149    let status = client.get_tenant_status(tenant_id).await?;
150    let secrets_url = status
151        .secrets_url
152        .ok_or_else(|| anyhow!("Tenant is ready but secrets URL is missing"))?;
153    let secrets = client.fetch_secrets(&secrets_url).await?;
154    spinner.finish_and_clear();
155    CliService::success("Database credentials retrieved");
156    Ok(secrets.database_url)
157}
158
159async fn configure_external_access(
160    client: &CloudApiClient,
161    tenant_id: &TenantId,
162    internal_database_url: &str,
163) -> Result<(bool, Option<String>)> {
164    CliService::section("Database Access");
165    CliService::info(
166        "External database access allows direct PostgreSQL connections from your local machine.",
167    );
168    CliService::info("This is required for local development workflows.");
169
170    let enable_external = Confirm::with_theme(&ColorfulTheme::default())
171        .with_prompt("Enable external database access?")
172        .default(true)
173        .interact()?;
174
175    if !enable_external {
176        CliService::info("External access disabled. Some local features will be limited.");
177        return Ok((false, None));
178    }
179
180    let spinner = CliService::spinner("Enabling external database access...");
181    match client.set_external_db_access(tenant_id, true).await {
182        Ok(_) => {
183            let external_url = swap_to_external_host(internal_database_url);
184            spinner.finish_and_clear();
185            CliService::success("External database access enabled");
186            print_database_connection_info(&external_url);
187            Ok((true, Some(external_url)))
188        },
189        Err(e) => {
190            spinner.finish_and_clear();
191            CliService::warning(&format!("Failed to enable external access: {}", e));
192            CliService::info("You can enable it later with 'systemprompt cloud tenant edit'");
193            Ok((false, None))
194        },
195    }
196}
197
198struct TenantDatabaseConfig {
199    external_database_url: Option<String>,
200    internal_database_url: String,
201    external_db_access: bool,
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        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_owned();
236    };
237
238    let host = parsed.host_str().unwrap_or("");
239    let external_host = if host.contains("sandbox") {
240        DB_SANDBOX_HOST
241    } else {
242        DB_PRODUCTION_HOST
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}