systemprompt_cli/commands/cloud/tenant/
mod.rs1mod create;
2mod crud;
3mod docker;
4mod rotate;
5mod select;
6mod validation;
7
8pub use create::{
9 create_cloud_tenant, create_external_tenant, create_local_tenant, swap_to_external_host,
10};
11pub use crud::{cancel_subscription, delete_tenant, edit_tenant, list_tenants, show_tenant};
12pub use docker::wait_for_postgres_healthy;
13pub use rotate::{rotate_credentials, rotate_sync_token};
14pub use select::{get_credentials, resolve_tenant_id};
15pub use validation::{check_build_ready, find_services_config};
16
17use anyhow::Result;
18use clap::{Args, Subcommand};
19use dialoguer::theme::ColorfulTheme;
20use dialoguer::Select;
21use systemprompt_cloud::{get_cloud_paths, CloudPath, TenantStore};
22use systemprompt_logging::CliService;
23
24use crate::cli_settings::CliConfig;
25use crate::shared::render_result;
26
27#[derive(Debug, Subcommand)]
28pub enum TenantCommands {
29 #[command(about = "Create a new tenant (local or cloud)")]
30 Create {
31 #[arg(long, default_value = "iad")]
32 region: String,
33 },
34
35 #[command(
36 about = "List all tenants",
37 after_help = "EXAMPLES:\n systemprompt cloud tenant list\n systemprompt cloud tenant \
38 list --json"
39 )]
40 List,
41
42 #[command(about = "Show tenant details")]
43 Show { id: Option<String> },
44
45 #[command(about = "Delete a tenant")]
46 Delete(TenantDeleteArgs),
47
48 #[command(about = "Edit tenant configuration")]
49 Edit { id: Option<String> },
50
51 #[command(about = "Rotate database credentials")]
52 RotateCredentials(TenantRotateArgs),
53
54 #[command(about = "Rotate sync token")]
55 RotateSyncToken(TenantRotateArgs),
56
57 #[command(about = "Cancel subscription and destroy tenant (IRREVERSIBLE)")]
58 Cancel(TenantCancelArgs),
59}
60
61#[derive(Debug, Args)]
62pub struct TenantRotateArgs {
63 pub id: Option<String>,
64
65 #[arg(short = 'y', long, help = "Skip confirmation prompts")]
66 pub yes: bool,
67}
68
69#[derive(Debug, Args)]
70pub struct TenantDeleteArgs {
71 pub id: Option<String>,
72
73 #[arg(short = 'y', long, help = "Skip confirmation prompts")]
74 pub yes: bool,
75}
76
77#[derive(Debug, Args)]
78pub struct TenantCancelArgs {
79 pub id: Option<String>,
80}
81
82pub async fn execute(cmd: Option<TenantCommands>, config: &CliConfig) -> Result<()> {
83 if let Some(cmd) = cmd {
84 execute_command(cmd, config).await.map(drop)
85 } else {
86 if !config.is_interactive() {
87 return Err(anyhow::anyhow!(
88 "Tenant subcommand required in non-interactive mode"
89 ));
90 }
91 while let Some(cmd) = select_operation()? {
92 if execute_command(cmd, config).await? {
93 break;
94 }
95 }
96 Ok(())
97 }
98}
99
100async fn execute_command(cmd: TenantCommands, config: &CliConfig) -> Result<bool> {
101 match cmd {
102 TenantCommands::Create { region } => tenant_create(®ion, config).await.map(|()| true),
103 TenantCommands::List => {
104 let result = list_tenants(config).await?;
105 render_result(&result);
106 Ok(false)
107 },
108 TenantCommands::Show { id } => {
109 let result = show_tenant(id, config).await?;
110 render_result(&result);
111 Ok(false)
112 },
113 TenantCommands::Delete(args) => {
114 let result = delete_tenant(args, config).await?;
115 render_result(&result);
116 Ok(false)
117 },
118 TenantCommands::Edit { id } => {
119 let result = edit_tenant(id, config).await?;
120 render_result(&result);
121 Ok(false)
122 },
123 TenantCommands::RotateCredentials(args) => {
124 let result =
125 rotate_credentials(args.id, args.yes || !config.is_interactive(), config).await?;
126 render_result(&result);
127 Ok(false)
128 },
129 TenantCommands::RotateSyncToken(args) => {
130 let result =
131 rotate_sync_token(args.id, args.yes || !config.is_interactive(), config).await?;
132 render_result(&result);
133 Ok(false)
134 },
135 TenantCommands::Cancel(args) => {
136 let result = cancel_subscription(args, config).await?;
137 render_result(&result);
138 Ok(false)
139 },
140 }
141}
142
143fn select_operation() -> Result<Option<TenantCommands>> {
144 let cloud_paths = get_cloud_paths()?;
145 let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
146 let store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
147 CliService::warning(&format!("Failed to load tenant store: {}", e));
148 TenantStore::default()
149 });
150 let has_tenants = !store.tenants.is_empty();
151
152 let edit_label = if has_tenants {
153 "Edit".to_string()
154 } else {
155 "Edit (unavailable - no tenants configured)".to_string()
156 };
157 let delete_label = if has_tenants {
158 "Delete".to_string()
159 } else {
160 "Delete (unavailable - no tenants configured)".to_string()
161 };
162
163 let operations = vec![
164 "Create".to_string(),
165 "List".to_string(),
166 edit_label,
167 delete_label,
168 "Done".to_string(),
169 ];
170
171 let selection = Select::with_theme(&ColorfulTheme::default())
172 .with_prompt("Tenant operation")
173 .items(&operations)
174 .default(0)
175 .interact()?;
176
177 let cmd = match selection {
178 0 => Some(TenantCommands::Create {
179 region: "iad".to_string(),
180 }),
181 1 => Some(TenantCommands::List),
182 2 | 3 if !has_tenants => {
183 CliService::warning("No tenants configured");
184 CliService::info(
185 "Run 'systemprompt cloud tenant create' (or 'just tenant') to create one.",
186 );
187 return Ok(Some(TenantCommands::List));
188 },
189 2 => Some(TenantCommands::Edit { id: None }),
190 3 => Some(TenantCommands::Delete(TenantDeleteArgs {
191 id: None,
192 yes: false,
193 })),
194 4 => None,
195 _ => unreachable!(),
196 };
197
198 Ok(cmd)
199}
200
201async fn tenant_create(default_region: &str, config: &CliConfig) -> Result<()> {
202 if !config.is_interactive() {
203 return Err(anyhow::anyhow!(
204 "Tenant creation requires interactive mode.\nUse specific tenant type commands in \
205 non-interactive mode (not yet implemented)."
206 ));
207 }
208
209 CliService::section("Create Tenant");
210
211 let creds = get_credentials()?;
212
213 let build_result = check_build_ready();
214 let cloud_option = match &build_result {
215 Ok(()) => "Cloud (requires subscription at systemprompt.io)".to_string(),
216 Err(_) => "Cloud (unavailable - release build required)".to_string(),
217 };
218
219 let options = vec![
220 "Local (creates PostgreSQL container automatically)".to_string(),
221 cloud_option,
222 ];
223
224 let selection = Select::with_theme(&ColorfulTheme::default())
225 .with_prompt("Tenant type")
226 .items(&options)
227 .default(0)
228 .interact()?;
229
230 let tenant = match selection {
231 0 => {
232 let db_options = vec![
233 "Docker (creates PostgreSQL container automatically)",
234 "External PostgreSQL (use your own database)",
235 ];
236
237 let db_selection = Select::with_theme(&ColorfulTheme::default())
238 .with_prompt("Database source")
239 .items(&db_options)
240 .default(0)
241 .interact()?;
242
243 match db_selection {
244 0 => create_local_tenant().await?,
245 _ => create_external_tenant().await?,
246 }
247 },
248 _ if build_result.is_err() => {
249 CliService::warning("Cloud tenant creation requires a release build.");
250 CliService::info("");
251 CliService::info("Run the following command to build:");
252 CliService::info(" cargo build --release --workspace");
253 CliService::info("");
254 if let Err(err) = &build_result {
255 CliService::info("Specific issue:");
256 CliService::error(err);
257 }
258 return Ok(());
259 },
260 _ => create_cloud_tenant(&creds, default_region).await?,
261 };
262
263 let cloud_paths = get_cloud_paths()?;
264 let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
265 let mut store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
266 CliService::warning(&format!("Failed to load tenant store: {}", e));
267 TenantStore::default()
268 });
269
270 if let Some(existing) = store.tenants.iter_mut().find(|t| t.id == tenant.id) {
271 *existing = tenant.clone();
272 } else {
273 store.tenants.push(tenant.clone());
274 }
275 store.save_to_path(&tenants_path)?;
276
277 CliService::success("Tenant created");
278 CliService::key_value("ID", &tenant.id);
279 CliService::key_value("Name", &tenant.name);
280 CliService::key_value("Type", &format!("{:?}", tenant.tenant_type));
281
282 if let Some(ref url) = tenant.database_url {
283 if !url.is_empty() {
284 CliService::key_value("Database URL", url);
285 }
286 }
287
288 Ok(())
289}