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