Skip to main content

systemprompt_cli/commands/cloud/tenant/create/
local.rs

1use anyhow::{Context, Result, anyhow, bail};
2use dialoguer::theme::ColorfulTheme;
3use dialoguer::{Confirm, Input};
4use std::fs;
5use std::process::Command;
6use systemprompt_cloud::{ProjectContext, StoredTenant};
7use systemprompt_logging::CliService;
8
9use crate::cloud::init::ensure_project_scaffolding;
10use crate::cloud::profile::templates::validate_connection;
11use crate::cloud::profile::{
12    collect_api_keys, create_profile_for_tenant, get_cloud_user, handle_local_tenant_setup,
13};
14
15use super::super::docker::{
16    SHARED_ADMIN_USER, SHARED_PORT, SHARED_VOLUME_NAME, SharedContainerConfig, check_volume_exists,
17    create_database_for_tenant, ensure_admin_role, generate_admin_password,
18    generate_shared_postgres_compose, get_container_password, is_shared_container_running,
19    load_shared_config, nanoid, remove_shared_volume, save_shared_config,
20    wait_for_postgres_healthy,
21};
22
23use super::sanitize_database_name;
24
25pub async fn create_local_tenant() -> Result<StoredTenant> {
26    CliService::section("Create Local PostgreSQL Tenant");
27
28    let name: String = Input::with_theme(&ColorfulTheme::default())
29        .with_prompt("Tenant name")
30        .default("local".to_string())
31        .interact_text()?;
32
33    if name.is_empty() {
34        bail!("Tenant name cannot be empty");
35    }
36
37    let unique_suffix = nanoid();
38    let db_name = format!("{}_{}", sanitize_database_name(&name), unique_suffix);
39
40    let ctx = ProjectContext::discover();
41    let docker_dir = ctx.docker_dir();
42    fs::create_dir_all(&docker_dir).context("Failed to create docker directory")?;
43
44    let shared_config = load_shared_config()?;
45    let container_running = is_shared_container_running();
46
47    let (config, needs_start) = resolve_container_state(shared_config, container_running)?;
48
49    let compose_path = docker_dir.join("shared.yaml");
50
51    if needs_start {
52        start_container(&config, &compose_path).await?;
53    }
54
55    let spinner = CliService::spinner("Verifying admin role...");
56    ensure_admin_role(&config.admin_password)?;
57    spinner.finish_and_clear();
58
59    let spinner = CliService::spinner(&format!("Creating database '{}'...", db_name));
60    create_database_for_tenant(&config.admin_password, config.port, &db_name)?;
61    spinner.finish_and_clear();
62    CliService::success(&format!("Database '{}' created", db_name));
63
64    let database_url = format!(
65        "postgres://{}:{}@localhost:{}/{}",
66        SHARED_ADMIN_USER, config.admin_password, config.port, db_name
67    );
68
69    let id = format!("local_{}", unique_suffix);
70    let tenant =
71        StoredTenant::new_local_shared(id, name.clone(), database_url.clone(), db_name.clone());
72
73    let mut updated_config = config;
74    updated_config.add_tenant(tenant.id.clone().into(), db_name);
75    save_shared_config(&updated_config)?;
76
77    setup_local_profile(&tenant, &name, &database_url).await?;
78
79    Ok(tenant)
80}
81
82pub async fn create_external_tenant() -> Result<StoredTenant> {
83    CliService::section("Create Local Tenant (External PostgreSQL)");
84
85    let name: String = Input::with_theme(&ColorfulTheme::default())
86        .with_prompt("Tenant name")
87        .default("local".to_string())
88        .interact_text()?;
89
90    if name.is_empty() {
91        bail!("Tenant name cannot be empty");
92    }
93
94    let database_url: String = Input::with_theme(&ColorfulTheme::default())
95        .with_prompt("PostgreSQL connection URL")
96        .interact_text()?;
97
98    if database_url.is_empty() {
99        bail!("Database URL cannot be empty");
100    }
101
102    let spinner = CliService::spinner("Validating connection...");
103    let valid = validate_connection(&database_url).await;
104    spinner.finish_and_clear();
105
106    if !valid {
107        bail!("Could not connect to database. Check your connection URL and try again.");
108    }
109    CliService::success("Database connection verified");
110
111    let unique_suffix = nanoid();
112    let id = format!("local_{}", unique_suffix);
113    let tenant = StoredTenant::new_local(id, name.clone(), database_url.clone());
114
115    setup_local_profile(&tenant, &name, &database_url).await?;
116
117    Ok(tenant)
118}
119
120fn resolve_container_state(
121    shared_config: Option<SharedContainerConfig>,
122    container_running: bool,
123) -> Result<(SharedContainerConfig, bool)> {
124    match (shared_config, container_running) {
125        (Some(config), true) => {
126            CliService::info("Using existing shared PostgreSQL container");
127            Ok((config, false))
128        },
129        (Some(config), false) => {
130            CliService::info("Shared container config found, restarting container...");
131            Ok((config, true))
132        },
133        (None, true) => {
134            CliService::info("Found existing shared PostgreSQL container.");
135
136            let use_existing = Confirm::with_theme(&ColorfulTheme::default())
137                .with_prompt("Use existing container?")
138                .default(true)
139                .interact()?;
140
141            if !use_existing {
142                bail!(
143                    "To create a new container, first stop the existing one:\n  docker stop \
144                     systemprompt-postgres-shared && docker rm systemprompt-postgres-shared"
145                );
146            }
147
148            let spinner = CliService::spinner("Connecting to container...");
149            let password = get_container_password()
150                .ok_or_else(|| anyhow!("Could not retrieve password from container"))?;
151            spinner.finish_and_clear();
152
153            CliService::success("Connected to existing container");
154            let config = SharedContainerConfig::new(password, SHARED_PORT);
155            Ok((config, false))
156        },
157        (None, false) => {
158            handle_orphaned_volume()?;
159
160            CliService::info("Creating new shared PostgreSQL container...");
161            let password = generate_admin_password();
162            let config = SharedContainerConfig::new(password, SHARED_PORT);
163            Ok((config, true))
164        },
165    }
166}
167
168fn handle_orphaned_volume() -> Result<()> {
169    if !check_volume_exists() {
170        return Ok(());
171    }
172
173    CliService::warning("PostgreSQL data volume exists but no container or configuration found.");
174    CliService::info(&format!(
175        "Volume '{}' contains data from a previous installation.",
176        SHARED_VOLUME_NAME
177    ));
178
179    let reset = Confirm::with_theme(&ColorfulTheme::default())
180        .with_prompt("Reset volume? (This will delete existing database data)")
181        .default(false)
182        .interact()?;
183
184    if reset {
185        let spinner = CliService::spinner("Removing orphaned volume...");
186        remove_shared_volume()?;
187        spinner.finish_and_clear();
188        CliService::success("Volume removed");
189    } else {
190        bail!(
191            "Cannot create container with orphaned volume.\nEither reset the volume or remove it \
192             manually:\n  docker volume rm {}",
193            SHARED_VOLUME_NAME
194        );
195    }
196
197    Ok(())
198}
199
200async fn start_container(
201    config: &SharedContainerConfig,
202    compose_path: &std::path::Path,
203) -> Result<()> {
204    let compose_content = generate_shared_postgres_compose(&config.admin_password, config.port);
205    fs::write(compose_path, &compose_content)
206        .with_context(|| format!("Failed to write {}", compose_path.display()))?;
207    CliService::success(&format!("Created: {}", compose_path.display()));
208
209    CliService::info("Starting shared PostgreSQL container...");
210    let compose_path_str = compose_path
211        .to_str()
212        .ok_or_else(|| anyhow!("Invalid compose path"))?;
213
214    let status = Command::new("docker")
215        .args(["compose", "-f", compose_path_str, "up", "-d"])
216        .status()
217        .context("Failed to execute docker compose. Is Docker running?")?;
218
219    if !status.success() {
220        bail!("Failed to start PostgreSQL container. Is Docker running?");
221    }
222
223    let spinner = CliService::spinner("Waiting for PostgreSQL to be ready...");
224    wait_for_postgres_healthy(compose_path, 60).await?;
225    spinner.finish_and_clear();
226    CliService::success("Shared PostgreSQL container is ready");
227
228    Ok(())
229}
230
231async fn setup_local_profile(tenant: &StoredTenant, name: &str, database_url: &str) -> Result<()> {
232    CliService::section("Profile Setup");
233    let profile_name: String = Input::with_theme(&ColorfulTheme::default())
234        .with_prompt("Profile name")
235        .default(name.to_string())
236        .interact_text()?;
237
238    CliService::section("API Keys");
239    let api_keys = collect_api_keys()?;
240
241    let profile = create_profile_for_tenant(tenant, &api_keys, &profile_name)?;
242    CliService::success(&format!("Profile '{}' created", profile.name));
243
244    let ctx = ProjectContext::discover();
245    ensure_project_scaffolding(ctx.root())?;
246
247    let cloud_user = get_cloud_user()?;
248    let profile_path = ctx.profile_dir(&profile.name).join("profile.yaml");
249    handle_local_tenant_setup(&cloud_user, database_url, name, &profile_path).await?;
250
251    Ok(())
252}