systemprompt_cli/commands/cloud/tenant/
create.rs1use anyhow::{anyhow, bail, Context, Result};
2use dialoguer::theme::ColorfulTheme;
3use dialoguer::{Confirm, Input, Select};
4use std::fs;
5use std::process::Command;
6use systemprompt_cloud::constants::checkout::CALLBACK_PORT;
7use systemprompt_cloud::constants::regions::AVAILABLE;
8use systemprompt_cloud::{
9 run_checkout_callback_flow, wait_for_provisioning, CheckoutTemplates, CloudApiClient,
10 CloudCredentials, ProjectContext, StoredTenant, TenantType,
11};
12use systemprompt_logging::CliService;
13use url::Url;
14
15use crate::cloud::deploy::deploy_with_secrets;
16use crate::cloud::profile::{
17 collect_api_keys, create_profile_for_tenant, get_cloud_user, handle_local_tenant_setup,
18};
19use crate::cloud::templates::{CHECKOUT_ERROR_HTML, CHECKOUT_SUCCESS_HTML, WAITING_HTML};
20
21use super::docker::{
22 check_volume_exists, create_database_for_tenant, ensure_admin_role, generate_admin_password,
23 generate_shared_postgres_compose, get_container_password, is_shared_container_running,
24 load_shared_config, nanoid, remove_shared_volume, save_shared_config,
25 wait_for_postgres_healthy, SharedContainerConfig, SHARED_ADMIN_USER, SHARED_PORT,
26 SHARED_VOLUME_NAME,
27};
28use super::validation::{validate_build_ready, warn_required_secrets};
29use crate::cloud::profile::templates::validate_connection;
30
31pub async fn create_local_tenant() -> Result<StoredTenant> {
32 CliService::section("Create Local PostgreSQL Tenant");
33
34 let name: String = Input::with_theme(&ColorfulTheme::default())
35 .with_prompt("Tenant name")
36 .default("local".to_string())
37 .interact_text()?;
38
39 if name.is_empty() {
40 bail!("Tenant name cannot be empty");
41 }
42
43 let unique_suffix = nanoid();
44 let db_name = format!("{}_{}", sanitize_database_name(&name), unique_suffix);
45
46 let ctx = ProjectContext::discover();
47 let docker_dir = ctx.docker_dir();
48 fs::create_dir_all(&docker_dir).context("Failed to create docker directory")?;
49
50 let shared_config = load_shared_config()?;
51 let container_running = is_shared_container_running();
52
53 let (config, needs_start) = match (shared_config, container_running) {
54 (Some(config), true) => {
55 CliService::info("Using existing shared PostgreSQL container");
56 (config, false)
57 },
58 (Some(config), false) => {
59 CliService::info("Shared container config found, restarting container...");
60 (config, true)
61 },
62 (None, true) => {
63 CliService::info("Found existing shared PostgreSQL container.");
64
65 let use_existing = Confirm::with_theme(&ColorfulTheme::default())
66 .with_prompt("Use existing container?")
67 .default(true)
68 .interact()?;
69
70 if !use_existing {
71 bail!(
72 "To create a new container, first stop the existing one:\n docker stop \
73 systemprompt-postgres-shared && docker rm systemprompt-postgres-shared"
74 );
75 }
76
77 let spinner = CliService::spinner("Connecting to container...");
78 let password = get_container_password()
79 .ok_or_else(|| anyhow!("Could not retrieve password from container"))?;
80 spinner.finish_and_clear();
81
82 CliService::success("Connected to existing container");
83 let config = SharedContainerConfig::new(password, SHARED_PORT);
84 (config, false)
85 },
86 (None, false) => {
87 if check_volume_exists() {
88 CliService::warning(
89 "PostgreSQL data volume exists but no container or configuration found.",
90 );
91 CliService::info(&format!(
92 "Volume '{}' contains data from a previous installation.",
93 SHARED_VOLUME_NAME
94 ));
95
96 let reset = Confirm::with_theme(&ColorfulTheme::default())
97 .with_prompt("Reset volume? (This will delete existing database data)")
98 .default(false)
99 .interact()?;
100
101 if reset {
102 let spinner = CliService::spinner("Removing orphaned volume...");
103 remove_shared_volume()?;
104 spinner.finish_and_clear();
105 CliService::success("Volume removed");
106 } else {
107 bail!(
108 "Cannot create container with orphaned volume.\nEither reset the volume \
109 or remove it manually:\n docker volume rm {}",
110 SHARED_VOLUME_NAME
111 );
112 }
113 }
114
115 CliService::info("Creating new shared PostgreSQL container...");
116 let password = generate_admin_password();
117 let config = SharedContainerConfig::new(password, SHARED_PORT);
118 (config, true)
119 },
120 };
121
122 let compose_path = docker_dir.join("shared.yaml");
123
124 if needs_start {
125 let compose_content = generate_shared_postgres_compose(&config.admin_password, config.port);
126 fs::write(&compose_path, &compose_content)
127 .with_context(|| format!("Failed to write {}", compose_path.display()))?;
128 CliService::success(&format!("Created: {}", compose_path.display()));
129
130 CliService::info("Starting shared PostgreSQL container...");
131 let compose_path_str = compose_path
132 .to_str()
133 .ok_or_else(|| anyhow!("Invalid compose path"))?;
134
135 let status = Command::new("docker")
136 .args(["compose", "-f", compose_path_str, "up", "-d"])
137 .status()
138 .context("Failed to execute docker compose. Is Docker running?")?;
139
140 if !status.success() {
141 bail!("Failed to start PostgreSQL container. Is Docker running?");
142 }
143
144 let spinner = CliService::spinner("Waiting for PostgreSQL to be ready...");
145 wait_for_postgres_healthy(&compose_path, 60).await?;
146 spinner.finish_and_clear();
147 CliService::success("Shared PostgreSQL container is ready");
148 }
149
150 let spinner = CliService::spinner("Verifying admin role...");
151 ensure_admin_role(&config.admin_password)?;
152 spinner.finish_and_clear();
153
154 let spinner = CliService::spinner(&format!("Creating database '{}'...", db_name));
155 create_database_for_tenant(&config.admin_password, config.port, &db_name).await?;
156 spinner.finish_and_clear();
157 CliService::success(&format!("Database '{}' created", db_name));
158
159 let database_url = format!(
160 "postgres://{}:{}@localhost:{}/{}",
161 SHARED_ADMIN_USER, config.admin_password, config.port, db_name
162 );
163
164 let id = format!("local_{}", unique_suffix);
165 let tenant =
166 StoredTenant::new_local_shared(id, name.clone(), database_url.clone(), db_name.clone());
167
168 let mut updated_config = config;
169 updated_config.add_tenant(tenant.id.clone(), db_name);
170 save_shared_config(&updated_config)?;
171
172 CliService::section("Profile Setup");
173 let profile_name: String = Input::with_theme(&ColorfulTheme::default())
174 .with_prompt("Profile name")
175 .default(name.clone())
176 .interact_text()?;
177
178 CliService::section("API Keys");
179 let api_keys = collect_api_keys()?;
180
181 let profile = create_profile_for_tenant(&tenant, &api_keys, &profile_name)?;
182 CliService::success(&format!("Profile '{}' created", profile.name));
183
184 let cloud_user = get_cloud_user()?;
185 let ctx = ProjectContext::discover();
186 let profile_path = ctx.profile_dir(&profile.name).join("profile.yaml");
187 handle_local_tenant_setup(&cloud_user, &database_url, &name, &profile_path).await?;
188
189 Ok(tenant)
190}
191
192pub async fn create_external_tenant() -> Result<StoredTenant> {
193 CliService::section("Create Local Tenant (External PostgreSQL)");
194
195 let name: String = Input::with_theme(&ColorfulTheme::default())
196 .with_prompt("Tenant name")
197 .default("local".to_string())
198 .interact_text()?;
199
200 if name.is_empty() {
201 bail!("Tenant name cannot be empty");
202 }
203
204 let database_url: String = Input::with_theme(&ColorfulTheme::default())
205 .with_prompt("PostgreSQL connection URL")
206 .interact_text()?;
207
208 if database_url.is_empty() {
209 bail!("Database URL cannot be empty");
210 }
211
212 let spinner = CliService::spinner("Validating connection...");
213 let valid = validate_connection(&database_url).await;
214 spinner.finish_and_clear();
215
216 if !valid {
217 bail!("Could not connect to database. Check your connection URL and try again.");
218 }
219 CliService::success("Database connection verified");
220
221 let unique_suffix = nanoid();
222 let id = format!("local_{}", unique_suffix);
223 let tenant = StoredTenant::new_local(id, name.clone(), database_url.clone());
224
225 CliService::section("Profile Setup");
226 let profile_name: String = Input::with_theme(&ColorfulTheme::default())
227 .with_prompt("Profile name")
228 .default(name.clone())
229 .interact_text()?;
230
231 CliService::section("API Keys");
232 let api_keys = collect_api_keys()?;
233
234 let profile = create_profile_for_tenant(&tenant, &api_keys, &profile_name)?;
235 CliService::success(&format!("Profile '{}' created", profile.name));
236
237 let cloud_user = get_cloud_user()?;
238 let ctx = ProjectContext::discover();
239 let profile_path = ctx.profile_dir(&profile.name).join("profile.yaml");
240 handle_local_tenant_setup(&cloud_user, &database_url, &name, &profile_path).await?;
241
242 Ok(tenant)
243}
244
245fn sanitize_database_name(name: &str) -> String {
246 let sanitized: String = name
247 .chars()
248 .map(|c| {
249 if c.is_ascii_alphanumeric() || c == '_' {
250 c
251 } else {
252 '_'
253 }
254 })
255 .collect();
256
257 if sanitized.is_empty() {
258 "systemprompt".to_string()
259 } else if sanitized.chars().next().is_some_and(|c| c.is_ascii_digit()) {
260 format!("db_{}", sanitized)
261 } else {
262 sanitized
263 }
264}
265
266pub async fn create_cloud_tenant(
267 creds: &CloudCredentials,
268 _default_region: &str,
269) -> Result<StoredTenant> {
270 let validation = validate_build_ready().context(
271 "Cloud tenant creation requires a built project.\nRun 'just build --release' before \
272 creating a cloud tenant.",
273 )?;
274
275 CliService::success("Build validation passed");
276 CliService::info("Creating cloud tenant via subscription");
277
278 let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
279
280 let spinner = CliService::spinner("Fetching available plans...");
281 let plans = client.get_plans().await?;
282 spinner.finish_and_clear();
283
284 if plans.is_empty() {
285 bail!("No plans available. Please contact support.");
286 }
287
288 let plan_options: Vec<String> = plans.iter().map(|p| p.name.clone()).collect();
289
290 let plan_selection = Select::with_theme(&ColorfulTheme::default())
291 .with_prompt("Select a plan")
292 .items(&plan_options)
293 .default(0)
294 .interact()?;
295
296 let selected_plan = &plans[plan_selection];
297
298 let region_options: Vec<String> = AVAILABLE
299 .iter()
300 .map(|(code, name)| format!("{} ({})", name, code))
301 .collect();
302
303 let region_selection = Select::with_theme(&ColorfulTheme::default())
304 .with_prompt("Select a region")
305 .items(®ion_options)
306 .default(0)
307 .interact()?;
308
309 let selected_region = AVAILABLE[region_selection].0;
310
311 let redirect_uri = format!("http://127.0.0.1:{}/callback", CALLBACK_PORT);
312 let spinner = CliService::spinner("Creating checkout session...");
313 let checkout = client
314 .create_checkout(
315 &selected_plan.paddle_price_id,
316 selected_region,
317 Some(&redirect_uri),
318 )
319 .await?;
320 spinner.finish_and_clear();
321
322 let templates = CheckoutTemplates {
323 success_html: CHECKOUT_SUCCESS_HTML,
324 error_html: CHECKOUT_ERROR_HTML,
325 waiting_html: WAITING_HTML,
326 };
327
328 let result = run_checkout_callback_flow(&client, &checkout.checkout_url, templates).await?;
329 CliService::success(&format!(
330 "Checkout complete! Tenant ID: {}",
331 result.tenant_id
332 ));
333
334 let spinner = CliService::spinner("Waiting for infrastructure provisioning...");
335 wait_for_provisioning(&client, &result.tenant_id, |event| {
336 if let Some(msg) = &event.message {
337 CliService::info(msg);
338 }
339 })
340 .await?;
341 spinner.finish_and_clear();
342 CliService::success("Tenant provisioned successfully");
343
344 let spinner = CliService::spinner("Fetching database credentials...");
345 let status = client.get_tenant_status(&result.tenant_id).await?;
346 let secrets_url = status
347 .secrets_url
348 .ok_or_else(|| anyhow!("Tenant is ready but secrets URL is missing"))?;
349 let secrets = client.fetch_secrets(&secrets_url).await?;
350 let internal_database_url = secrets.database_url;
351 let sync_token = secrets.sync_token;
352 spinner.finish_and_clear();
353 CliService::success("Database credentials retrieved");
354
355 CliService::section("Database Access");
356 CliService::info(
357 "External database access allows direct PostgreSQL connections from your local machine.",
358 );
359 CliService::info("This is required for the TUI and local development workflows.");
360
361 let enable_external = Confirm::with_theme(&ColorfulTheme::default())
362 .with_prompt("Enable external database access?")
363 .default(true)
364 .interact()?;
365
366 let (external_db_access, external_database_url) = if enable_external {
367 let spinner = CliService::spinner("Enabling external database access...");
368 match client.set_external_db_access(&result.tenant_id, true).await {
369 Ok(_) => {
370 let external_url = swap_to_external_host(&internal_database_url);
371 spinner.finish_and_clear();
372 CliService::success("External database access enabled");
373 print_database_connection_info(&external_url);
374 (true, Some(external_url))
375 },
376 Err(e) => {
377 spinner.finish_and_clear();
378 CliService::warning(&format!("Failed to enable external access: {}", e));
379 CliService::info("You can enable it later with 'systemprompt cloud tenant edit'");
380 (false, None)
381 },
382 }
383 } else {
384 CliService::info("External access disabled. TUI features will be limited.");
385 (false, None)
386 };
387
388 let spinner = CliService::spinner("Syncing new tenant...");
389 let response = client.get_user().await?;
390 spinner.finish_and_clear();
391
392 let new_tenant = response
393 .tenants
394 .iter()
395 .find(|t| t.id == result.tenant_id)
396 .ok_or_else(|| anyhow!("New tenant not found after checkout"))?;
397
398 let stored_tenant = StoredTenant {
399 id: new_tenant.id.clone(),
400 name: new_tenant.name.clone(),
401 tenant_type: TenantType::Cloud,
402 app_id: new_tenant.app_id.clone(),
403 hostname: new_tenant.hostname.clone(),
404 region: new_tenant.region.clone(),
405 database_url: external_database_url,
406 internal_database_url: Some(internal_database_url),
407 external_db_access,
408 sync_token,
409 shared_container_db: None,
410 };
411
412 CliService::section("Profile Setup");
413 let profile_name: String = Input::with_theme(&ColorfulTheme::default())
414 .with_prompt("Profile name")
415 .default(stored_tenant.name.clone())
416 .interact_text()?;
417
418 CliService::section("API Keys");
419 let api_keys = collect_api_keys()?;
420
421 let profile = create_profile_for_tenant(&stored_tenant, &api_keys, &profile_name)?;
422 CliService::success(&format!("Profile '{}' created", profile.name));
423
424 if result.needs_deploy {
425 CliService::section("Initial Deploy");
426 CliService::info("Deploying your code with profile configuration...");
427 deploy_with_secrets(&client, &result.tenant_id, &profile.name).await?;
428 }
429
430 warn_required_secrets(&validation.required_secrets);
431
432 Ok(stored_tenant)
433}
434
435pub fn swap_to_external_host(url: &str) -> String {
436 let Ok(parsed) = Url::parse(url) else {
437 return url.to_string();
438 };
439
440 let host = parsed.host_str().unwrap_or("");
441 let external_host = if host.contains("sandbox") {
442 "db-sandbox.systemprompt.io"
443 } else {
444 "db.systemprompt.io"
445 };
446
447 url.replace(host, external_host)
448 .replace("sslmode=disable", "sslmode=require")
449}
450
451fn print_database_connection_info(url: &str) {
452 let Ok(parsed) = Url::parse(url) else {
453 return;
454 };
455
456 let host = parsed.host_str().unwrap_or("unknown");
457 let port = parsed.port().unwrap_or(5432);
458 let database = parsed.path().trim_start_matches('/');
459 let username = parsed.username();
460 let password = parsed.password().unwrap_or("********");
461
462 CliService::section("Database Connection");
463 CliService::key_value("Host", host);
464 CliService::key_value("Port", &port.to_string());
465 CliService::key_value("Database", database);
466 CliService::key_value("User", username);
467 CliService::key_value("Password", password);
468 CliService::key_value("SSL", "required");
469 CliService::info("");
470 CliService::key_value("Connection URL", url);
471 CliService::info("");
472 CliService::info(&format!(
473 "Connect with psql:\n PGPASSWORD='{}' psql -h {} -p {} -U {} -d {}",
474 password, host, port, username, database
475 ));
476}