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