systemprompt_cli/commands/cloud/tenant/create/
cloud.rs1use 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(®ion_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}