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