Skip to main content

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

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