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::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 = TenantId::new(&result.tenant_id);
53    CliService::success(&format!("Checkout complete! Tenant ID: {}", tenant_id));
54
55    let spinner = CliService::spinner("Waiting for infrastructure provisioning...");
56    wait_for_provisioning(&client, tenant_id.as_str(), |event| {
57        if let Some(msg) = &event.message {
58            CliService::info(msg);
59        }
60    })
61    .await?;
62    spinner.finish_and_clear();
63    CliService::success("Tenant provisioned successfully");
64
65    let (internal_database_url, sync_token) = fetch_credentials(&client, &tenant_id).await?;
66
67    let (external_db_access, external_database_url) =
68        configure_external_access(&client, &tenant_id, &internal_database_url).await?;
69
70    let stored_tenant = build_stored_tenant(
71        &client,
72        &tenant_id,
73        TenantDatabaseConfig {
74            external_database_url,
75            internal_database_url,
76            external_db_access,
77            sync_token,
78        },
79    )
80    .await?;
81
82    CliService::section("Profile Setup");
83    let profile_name: String = Input::with_theme(&ColorfulTheme::default())
84        .with_prompt("Profile name")
85        .default(stored_tenant.name.clone())
86        .interact_text()?;
87
88    CliService::section("API Keys");
89    let api_keys = collect_api_keys()?;
90
91    let profile = create_profile_for_tenant(&stored_tenant, &api_keys, &profile_name)?;
92    CliService::success(&format!("Profile '{}' created", profile.name));
93
94    if result.needs_deploy {
95        CliService::section("Initial Deploy");
96        CliService::info("Deploying your code with profile configuration...");
97        deploy_with_secrets(&client, &tenant_id, &profile.name).await?;
98    }
99
100    warn_required_secrets(&validation.required_secrets);
101
102    Ok(stored_tenant)
103}
104
105async fn select_plan(client: &CloudApiClient) -> Result<String> {
106    let spinner = CliService::spinner("Fetching available plans...");
107    let plans = client.get_plans().await?;
108    spinner.finish_and_clear();
109
110    if plans.is_empty() {
111        bail!("No plans available. Please contact support.");
112    }
113
114    let plan_options: Vec<String> = plans.iter().map(|p| p.name.clone()).collect();
115
116    let plan_selection = Select::with_theme(&ColorfulTheme::default())
117        .with_prompt("Select a plan")
118        .items(&plan_options)
119        .default(0)
120        .interact()?;
121
122    Ok(plans[plan_selection].paddle_price_id.clone())
123}
124
125fn select_region() -> Result<&'static str> {
126    let region_options: Vec<String> = AVAILABLE
127        .iter()
128        .map(|(code, name)| format!("{} ({})", name, code))
129        .collect();
130
131    let region_selection = Select::with_theme(&ColorfulTheme::default())
132        .with_prompt("Select a region")
133        .items(&region_options)
134        .default(0)
135        .interact()?;
136
137    Ok(AVAILABLE[region_selection].0)
138}
139
140async fn fetch_credentials(
141    client: &CloudApiClient,
142    tenant_id: &TenantId,
143) -> Result<(String, Option<String>)> {
144    let spinner = CliService::spinner("Fetching database credentials...");
145    let status = client.get_tenant_status(tenant_id.as_str()).await?;
146    let secrets_url = status
147        .secrets_url
148        .ok_or_else(|| anyhow!("Tenant is ready but secrets URL is missing"))?;
149    let secrets = client.fetch_secrets(&secrets_url).await?;
150    spinner.finish_and_clear();
151    CliService::success("Database credentials retrieved");
152    Ok((secrets.database_url, secrets.sync_token))
153}
154
155async fn configure_external_access(
156    client: &CloudApiClient,
157    tenant_id: &TenantId,
158    internal_database_url: &str,
159) -> Result<(bool, Option<String>)> {
160    CliService::section("Database Access");
161    CliService::info(
162        "External database access allows direct PostgreSQL connections from your local machine.",
163    );
164    CliService::info("This is required for local development workflows.");
165
166    let enable_external = Confirm::with_theme(&ColorfulTheme::default())
167        .with_prompt("Enable external database access?")
168        .default(true)
169        .interact()?;
170
171    if !enable_external {
172        CliService::info("External access disabled. Some local features will be limited.");
173        return Ok((false, None));
174    }
175
176    let spinner = CliService::spinner("Enabling external database access...");
177    match client
178        .set_external_db_access(tenant_id.as_str(), true)
179        .await
180    {
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}