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, 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(&region_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}