Skip to main content

systemprompt_cli/commands/cloud/tenant/
create.rs

1use 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, CheckoutTemplates, CloudApiClient, CloudCredentials,
10    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, 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(&format!("Creating database '{}'...", db_name));
151    create_database_for_tenant(&config.admin_password, config.port, &db_name).await?;
152    spinner.finish_and_clear();
153    CliService::success(&format!("Database '{}' created", db_name));
154
155    let database_url = format!(
156        "postgres://{}:{}@localhost:{}/{}",
157        SHARED_ADMIN_USER, config.admin_password, config.port, db_name
158    );
159
160    let id = format!("local_{}", unique_suffix);
161    let tenant =
162        StoredTenant::new_local_shared(id, name.clone(), database_url.clone(), db_name.clone());
163
164    let mut updated_config = config;
165    updated_config.add_tenant(tenant.id.clone(), db_name);
166    save_shared_config(&updated_config)?;
167
168    CliService::section("Profile Setup");
169    let profile_name: String = Input::with_theme(&ColorfulTheme::default())
170        .with_prompt("Profile name")
171        .default(name.clone())
172        .interact_text()?;
173
174    CliService::section("API Keys");
175    let api_keys = collect_api_keys()?;
176
177    let profile = create_profile_for_tenant(&tenant, &api_keys, &profile_name)?;
178    CliService::success(&format!("Profile '{}' created", profile.name));
179
180    let cloud_user = get_cloud_user()?;
181    let ctx = ProjectContext::discover();
182    let profile_path = ctx.profile_dir(&profile.name).join("profile.yaml");
183    handle_local_tenant_setup(&cloud_user, &database_url, &name, &profile_path).await?;
184
185    Ok(tenant)
186}
187
188pub async fn create_external_tenant() -> Result<StoredTenant> {
189    CliService::section("Create Local Tenant (External PostgreSQL)");
190
191    let name: String = Input::with_theme(&ColorfulTheme::default())
192        .with_prompt("Tenant name")
193        .default("local".to_string())
194        .interact_text()?;
195
196    if name.is_empty() {
197        bail!("Tenant name cannot be empty");
198    }
199
200    let database_url: String = Input::with_theme(&ColorfulTheme::default())
201        .with_prompt("PostgreSQL connection URL")
202        .interact_text()?;
203
204    if database_url.is_empty() {
205        bail!("Database URL cannot be empty");
206    }
207
208    let spinner = CliService::spinner("Validating connection...");
209    let valid = validate_connection(&database_url).await;
210    spinner.finish_and_clear();
211
212    if !valid {
213        bail!("Could not connect to database. Check your connection URL and try again.");
214    }
215    CliService::success("Database connection verified");
216
217    let unique_suffix = nanoid();
218    let id = format!("local_{}", unique_suffix);
219    let tenant = StoredTenant::new_local(id, name.clone(), database_url.clone());
220
221    CliService::section("Profile Setup");
222    let profile_name: String = Input::with_theme(&ColorfulTheme::default())
223        .with_prompt("Profile name")
224        .default(name.clone())
225        .interact_text()?;
226
227    CliService::section("API Keys");
228    let api_keys = collect_api_keys()?;
229
230    let profile = create_profile_for_tenant(&tenant, &api_keys, &profile_name)?;
231    CliService::success(&format!("Profile '{}' created", profile.name));
232
233    let cloud_user = get_cloud_user()?;
234    let ctx = ProjectContext::discover();
235    let profile_path = ctx.profile_dir(&profile.name).join("profile.yaml");
236    handle_local_tenant_setup(&cloud_user, &database_url, &name, &profile_path).await?;
237
238    Ok(tenant)
239}
240
241fn sanitize_database_name(name: &str) -> String {
242    let sanitized: String = name
243        .chars()
244        .map(|c| {
245            if c.is_ascii_alphanumeric() || c == '_' {
246                c
247            } else {
248                '_'
249            }
250        })
251        .collect();
252
253    if sanitized.is_empty() {
254        "systemprompt".to_string()
255    } else if sanitized.chars().next().is_some_and(|c| c.is_ascii_digit()) {
256        format!("db_{}", sanitized)
257    } else {
258        sanitized
259    }
260}
261
262pub async fn create_cloud_tenant(
263    creds: &CloudCredentials,
264    _default_region: &str,
265) -> Result<StoredTenant> {
266    let validation = validate_build_ready().context(
267        "Cloud tenant creation requires a built project.\nRun 'just build --release' before \
268         creating a cloud tenant.",
269    )?;
270
271    CliService::success("Build validation passed");
272    CliService::info("Creating cloud tenant via subscription");
273
274    let client = CloudApiClient::new(&creds.api_url, &creds.api_token);
275
276    let spinner = CliService::spinner("Fetching available plans...");
277    let plans = client.get_plans().await?;
278    spinner.finish_and_clear();
279
280    if plans.is_empty() {
281        bail!("No plans available. Please contact support.");
282    }
283
284    let plan_options: Vec<String> = plans.iter().map(|p| p.name.clone()).collect();
285
286    let plan_selection = Select::with_theme(&ColorfulTheme::default())
287        .with_prompt("Select a plan")
288        .items(&plan_options)
289        .default(0)
290        .interact()?;
291
292    let selected_plan = &plans[plan_selection];
293
294    let region_options: Vec<String> = AVAILABLE
295        .iter()
296        .map(|(code, name)| format!("{} ({})", name, code))
297        .collect();
298
299    let region_selection = Select::with_theme(&ColorfulTheme::default())
300        .with_prompt("Select a region")
301        .items(&region_options)
302        .default(0)
303        .interact()?;
304
305    let selected_region = AVAILABLE[region_selection].0;
306
307    let redirect_uri = format!("http://127.0.0.1:{}/callback", CALLBACK_PORT);
308    let spinner = CliService::spinner("Creating checkout session...");
309    let checkout = client
310        .create_checkout(
311            &selected_plan.paddle_price_id,
312            selected_region,
313            Some(&redirect_uri),
314        )
315        .await?;
316    spinner.finish_and_clear();
317
318    let templates = CheckoutTemplates {
319        success_html: CHECKOUT_SUCCESS_HTML,
320        error_html: CHECKOUT_ERROR_HTML,
321        waiting_html: WAITING_HTML,
322    };
323
324    let result = run_checkout_callback_flow(&client, &checkout.checkout_url, templates).await?;
325    CliService::success(&format!(
326        "Checkout complete! Tenant ID: {}",
327        result.tenant_id
328    ));
329
330    CliService::success("Tenant provisioned successfully");
331
332    let spinner = CliService::spinner("Fetching database credentials...");
333    let (database_url, sync_token) = match client.get_tenant_status(&result.tenant_id).await {
334        Ok(status) => {
335            if let Some(secrets_url) = status.secrets_url {
336                match client.fetch_secrets(&secrets_url).await {
337                    Ok(secrets) => (Some(secrets.database_url), secrets.sync_token),
338                    Err(e) => {
339                        tracing::warn!(error = %e, "Failed to fetch secrets");
340                        (None, None)
341                    },
342                }
343            } else {
344                tracing::warn!("No secrets URL available for tenant {}", result.tenant_id);
345                (None, None)
346            }
347        },
348        Err(e) => {
349            tracing::warn!(error = %e, "Failed to get tenant status");
350            (None, None)
351        },
352    };
353    spinner.finish_and_clear();
354
355    let Some(internal_database_url) = database_url else {
356        bail!("Could not retrieve database credentials. Tenant creation incomplete.")
357    };
358    CliService::success("Database credentials retrieved");
359
360    CliService::section("Database Access");
361    CliService::info(
362        "External database access allows direct PostgreSQL connections from your local machine.",
363    );
364    CliService::info("This is required for the TUI and local development workflows.");
365
366    let enable_external = Confirm::with_theme(&ColorfulTheme::default())
367        .with_prompt("Enable external database access?")
368        .default(true)
369        .interact()?;
370
371    let (external_db_access, external_database_url) = if enable_external {
372        let spinner = CliService::spinner("Enabling external database access...");
373        match client.set_external_db_access(&result.tenant_id, true).await {
374            Ok(_) => {
375                let external_url = swap_to_external_host(&internal_database_url);
376                spinner.finish_and_clear();
377                CliService::success("External database access enabled");
378                print_database_connection_info(&external_url);
379                (true, Some(external_url))
380            },
381            Err(e) => {
382                spinner.finish_and_clear();
383                CliService::warning(&format!("Failed to enable external access: {}", e));
384                CliService::info("You can enable it later with 'systemprompt cloud tenant edit'");
385                (false, None)
386            },
387        }
388    } else {
389        CliService::info("External access disabled. TUI features will be limited.");
390        (false, None)
391    };
392
393    let spinner = CliService::spinner("Syncing new tenant...");
394    let response = client.get_user().await?;
395    spinner.finish_and_clear();
396
397    let new_tenant = response
398        .tenants
399        .iter()
400        .find(|t| t.id == result.tenant_id)
401        .ok_or_else(|| anyhow!("New tenant not found after checkout"))?;
402
403    let stored_tenant = StoredTenant {
404        id: new_tenant.id.clone(),
405        name: new_tenant.name.clone(),
406        tenant_type: TenantType::Cloud,
407        app_id: new_tenant.app_id.clone(),
408        hostname: new_tenant.hostname.clone(),
409        region: new_tenant.region.clone(),
410        database_url: external_database_url,
411        internal_database_url: Some(internal_database_url),
412        external_db_access,
413        sync_token,
414        shared_container_db: None,
415    };
416
417    CliService::section("Profile Setup");
418    let profile_name: String = Input::with_theme(&ColorfulTheme::default())
419        .with_prompt("Profile name")
420        .default(stored_tenant.name.clone())
421        .interact_text()?;
422
423    CliService::section("API Keys");
424    let api_keys = collect_api_keys()?;
425
426    let profile = create_profile_for_tenant(&stored_tenant, &api_keys, &profile_name)?;
427    CliService::success(&format!("Profile '{}' created", profile.name));
428
429    if result.needs_deploy {
430        CliService::section("Initial Deploy");
431        CliService::info("Deploying your code with profile configuration...");
432        deploy_with_secrets(&client, &result.tenant_id, &profile.name).await?;
433    }
434
435    warn_required_secrets(&validation.required_secrets);
436
437    Ok(stored_tenant)
438}
439
440pub fn swap_to_external_host(url: &str) -> String {
441    let Ok(parsed) = Url::parse(url) else {
442        return url.to_string();
443    };
444
445    let host = parsed.host_str().unwrap_or("");
446    let external_host = if host.contains("sandbox") {
447        "db-sandbox.systemprompt.io"
448    } else {
449        "db.systemprompt.io"
450    };
451
452    url.replace(host, external_host)
453        .replace("sslmode=disable", "sslmode=require")
454}
455
456fn print_database_connection_info(url: &str) {
457    let Ok(parsed) = Url::parse(url) else {
458        return;
459    };
460
461    let host = parsed.host_str().unwrap_or("unknown");
462    let port = parsed.port().unwrap_or(5432);
463    let database = parsed.path().trim_start_matches('/');
464    let username = parsed.username();
465    let password = parsed.password().unwrap_or("********");
466
467    CliService::section("Database Connection");
468    CliService::key_value("Host", host);
469    CliService::key_value("Port", &port.to_string());
470    CliService::key_value("Database", database);
471    CliService::key_value("User", username);
472    CliService::key_value("Password", password);
473    CliService::key_value("SSL", "required");
474    CliService::info("");
475    CliService::key_value("Connection URL", url);
476    CliService::info("");
477    CliService::info(&format!(
478        "Connect with psql:\n  PGPASSWORD='{}' psql -h {} -p {} -U {} -d {}",
479        password, host, port, username, database
480    ));
481}