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::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(®ion_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}