systemprompt_cli/commands/cloud/tenant/create/
local.rs1use 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}