systemprompt_cli/commands/cloud/tenant/
crud.rs1use anyhow::{anyhow, bail, Result};
2use chrono::Utc;
3use dialoguer::theme::ColorfulTheme;
4use dialoguer::{Confirm, Input, Select};
5use systemprompt_cloud::{
6 get_cloud_paths, CloudApiClient, CloudPath, StoredTenant, TenantStore, TenantType,
7};
8use systemprompt_logging::CliService;
9
10use super::docker::{
11 drop_database_for_tenant, load_shared_config, save_shared_config, stop_shared_container,
12};
13use super::select::{get_credentials, select_tenant};
14use crate::cli_settings::CliConfig;
15use crate::cloud::tenant::{TenantCancelArgs, TenantDeleteArgs};
16use crate::cloud::types::{TenantDetailOutput, TenantListOutput, TenantSummary};
17use crate::shared::{CommandResult, SuccessOutput};
18
19pub async fn list_tenants(config: &CliConfig) -> Result<CommandResult<TenantListOutput>> {
20 let cloud_paths = get_cloud_paths()?;
21 let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
22
23 let store = sync_and_load_tenants(&tenants_path).await;
24
25 let summaries: Vec<TenantSummary> = store
26 .tenants
27 .iter()
28 .map(|t| TenantSummary {
29 id: t.id.clone(),
30 name: t.name.clone(),
31 tenant_type: format!("{:?}", t.tenant_type).to_lowercase(),
32 has_database: t.has_database_url(),
33 })
34 .collect();
35
36 let output = TenantListOutput {
37 total: summaries.len(),
38 tenants: summaries,
39 };
40
41 if store.tenants.is_empty() {
42 if !config.is_json_output() {
43 CliService::section("Tenants");
44 CliService::info("No tenants configured.");
45 CliService::info(
46 "Run 'systemprompt cloud tenant create' (or 'just tenant') to create one.",
47 );
48 }
49 return Ok(CommandResult::table(output)
50 .with_title("Tenants")
51 .with_columns(vec![
52 "id".to_string(),
53 "name".to_string(),
54 "tenant_type".to_string(),
55 "has_database".to_string(),
56 ]));
57 }
58
59 if !config.is_json_output() {
60 if config.is_interactive() {
61 let options: Vec<String> = store
62 .tenants
63 .iter()
64 .map(|t| {
65 let type_str = match t.tenant_type {
66 TenantType::Local => "local",
67 TenantType::Cloud => "cloud",
68 };
69 let db_status = if t.has_database_url() {
70 "✓ db"
71 } else {
72 "✗ db"
73 };
74 format!("{} ({}) [{}]", t.name, type_str, db_status)
75 })
76 .chain(std::iter::once("Back".to_string()))
77 .collect();
78
79 loop {
80 CliService::section("Tenants");
81 CliService::info("Manage subscriptions: https://customer-portal.paddle.com/cpl_01j80s3z6crr7zj96htce0kr0f");
82 CliService::info("");
83
84 let selection = Select::with_theme(&ColorfulTheme::default())
85 .with_prompt("Select tenant")
86 .items(&options)
87 .default(0)
88 .interact()?;
89
90 if selection == store.tenants.len() {
91 break;
92 }
93
94 display_tenant_details(&store.tenants[selection]);
95 }
96 } else {
97 CliService::section("Tenants");
98 CliService::info("Manage subscriptions: https://customer-portal.paddle.com/cpl_01j80s3z6crr7zj96htce0kr0f");
99 CliService::info("");
100 for tenant in &store.tenants {
101 let type_str = match tenant.tenant_type {
102 TenantType::Local => "local",
103 TenantType::Cloud => "cloud",
104 };
105 let db_status = if tenant.has_database_url() {
106 "✓ db"
107 } else {
108 "✗ db"
109 };
110 CliService::info(&format!("{} ({}) [{}]", tenant.name, type_str, db_status));
111 }
112 }
113 }
114
115 Ok(CommandResult::table(output)
116 .with_title("Tenants")
117 .with_columns(vec![
118 "id".to_string(),
119 "name".to_string(),
120 "tenant_type".to_string(),
121 "has_database".to_string(),
122 ]))
123}
124
125fn display_tenant_details(tenant: &StoredTenant) {
126 CliService::section(&format!("Tenant: {}", tenant.name));
127 CliService::key_value("ID", &tenant.id);
128 CliService::key_value("Type", &format!("{:?}", tenant.tenant_type));
129
130 if let Some(ref app_id) = tenant.app_id {
131 CliService::key_value("App ID", app_id);
132 }
133
134 if let Some(ref hostname) = tenant.hostname {
135 CliService::key_value("Hostname", hostname);
136 }
137
138 if let Some(ref region) = tenant.region {
139 CliService::key_value("Region", region);
140 }
141
142 CliService::key_value(
143 "Database",
144 if tenant.has_database_url() {
145 "configured"
146 } else {
147 "not configured"
148 },
149 );
150}
151
152pub async fn show_tenant(
153 id: Option<String>,
154 config: &CliConfig,
155) -> Result<CommandResult<TenantDetailOutput>> {
156 let cloud_paths = get_cloud_paths()?;
157 let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
158 let store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
159 if !config.is_json_output() {
160 CliService::warning(&format!("Failed to load tenant store: {}", e));
161 }
162 TenantStore::default()
163 });
164
165 let tenant = match id {
166 Some(ref id) => store
167 .find_tenant(id)
168 .ok_or_else(|| anyhow!("Tenant not found: {}", id))?,
169 None if config.is_interactive() => {
170 if store.tenants.is_empty() {
171 bail!("No tenants configured.");
172 }
173 select_tenant(&store.tenants)?
174 },
175 None => bail!("--id is required in non-interactive mode for tenant show"),
176 };
177
178 let output = TenantDetailOutput {
179 id: tenant.id.clone(),
180 name: tenant.name.clone(),
181 tenant_type: format!("{:?}", tenant.tenant_type).to_lowercase(),
182 app_id: tenant.app_id.clone(),
183 hostname: tenant.hostname.clone(),
184 region: tenant.region.clone(),
185 has_database: tenant.has_database_url(),
186 };
187
188 if !config.is_json_output() {
189 CliService::section(&format!("Tenant: {}", tenant.name));
190 CliService::key_value("ID", &tenant.id);
191 CliService::key_value("Type", &format!("{:?}", tenant.tenant_type));
192
193 if let Some(ref app_id) = tenant.app_id {
194 CliService::key_value("App ID", app_id);
195 }
196
197 if let Some(ref hostname) = tenant.hostname {
198 CliService::key_value("Hostname", hostname);
199 }
200
201 if let Some(ref region) = tenant.region {
202 CliService::key_value("Region", region);
203 }
204
205 if tenant.has_database_url() {
206 CliService::key_value("Database", "configured");
207 } else {
208 CliService::key_value("Database", "not configured");
209 }
210 }
211
212 Ok(CommandResult::card(output).with_title(format!("Tenant: {}", tenant.name)))
213}
214
215pub async fn delete_tenant(
216 args: TenantDeleteArgs,
217 config: &CliConfig,
218) -> Result<CommandResult<SuccessOutput>> {
219 let cloud_paths = get_cloud_paths()?;
220 let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
221 let mut store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
222 if !config.is_json_output() {
223 CliService::warning(&format!("Failed to load tenant store: {}", e));
224 }
225 TenantStore::default()
226 });
227
228 let tenant_id = if let Some(id) = args.id {
229 id
230 } else {
231 if !config.is_interactive() {
232 return Err(anyhow::anyhow!(
233 "--id is required in non-interactive mode for tenant delete"
234 ));
235 }
236 if store.tenants.is_empty() {
237 bail!("No tenants configured.");
238 }
239 select_tenant(&store.tenants)?.id.clone()
240 };
241
242 let tenant = store
243 .tenants
244 .iter()
245 .find(|t| t.id == tenant_id)
246 .ok_or_else(|| anyhow!("Tenant not found: {}", tenant_id))?
247 .clone();
248
249 let is_cloud = tenant.tenant_type == TenantType::Cloud;
250
251 if !args.yes {
252 if !config.is_interactive() {
253 return Err(anyhow::anyhow!(
254 "--yes is required in non-interactive mode for tenant delete"
255 ));
256 }
257
258 let prompt = if is_cloud {
259 format!(
260 "Delete cloud tenant '{}'? This will cancel your subscription and delete all data.",
261 tenant.name
262 )
263 } else {
264 format!("Delete tenant '{}'?", tenant.name)
265 };
266
267 let confirm = Confirm::with_theme(&ColorfulTheme::default())
268 .with_prompt(prompt)
269 .default(false)
270 .interact()?;
271
272 if !confirm {
273 let output = SuccessOutput::new("Cancelled");
274 if !config.is_json_output() {
275 CliService::info("Cancelled");
276 }
277 return Ok(CommandResult::text(output).with_title("Delete Tenant"));
278 }
279 }
280
281 if is_cloud {
282 let creds = get_credentials()?;
283 let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
284
285 if config.is_json_output() {
286 client.delete_tenant(&tenant_id).await?;
287 } else {
288 let spinner = CliService::spinner("Deleting cloud tenant...");
289 client.delete_tenant(&tenant_id).await?;
290 spinner.finish_and_clear();
291 }
292 } else if tenant.uses_shared_container() {
293 cleanup_shared_container_tenant(&tenant, config).await?;
294 }
295
296 store.tenants.retain(|t| t.id != tenant_id);
297 store.save_to_path(&tenants_path)?;
298
299 let output = SuccessOutput::new(format!("Deleted tenant: {}", tenant_id));
300
301 if !config.is_json_output() {
302 CliService::success(&format!("Deleted tenant: {}", tenant_id));
303 }
304
305 Ok(CommandResult::text(output).with_title("Delete Tenant"))
306}
307
308async fn cleanup_shared_container_tenant(tenant: &StoredTenant, config: &CliConfig) -> Result<()> {
309 let Some(ref db_name) = tenant.shared_container_db else {
310 return Ok(());
311 };
312
313 let Some(mut shared_config) = load_shared_config()? else {
314 CliService::warning("Shared container config not found, skipping database cleanup");
315 return Ok(());
316 };
317
318 let spinner = CliService::spinner(&format!("Dropping database '{}'...", db_name));
319 match drop_database_for_tenant(&shared_config.admin_password, shared_config.port, db_name).await
320 {
321 Ok(()) => {
322 spinner.finish_and_clear();
323 CliService::success(&format!("Database '{}' dropped", db_name));
324 },
325 Err(e) => {
326 spinner.finish_and_clear();
327 CliService::warning(&format!("Failed to drop database '{}': {}", db_name, e));
328 },
329 }
330
331 shared_config.remove_tenant(&tenant.id);
332 save_shared_config(&shared_config)?;
333
334 if shared_config.tenant_databases.is_empty() {
335 let should_remove = if config.is_interactive() {
336 Confirm::with_theme(&ColorfulTheme::default())
337 .with_prompt("No local tenants remain. Remove shared PostgreSQL container?")
338 .default(true)
339 .interact()?
340 } else {
341 false
342 };
343
344 if should_remove {
345 stop_shared_container()?;
346 } else {
347 CliService::info(
348 "Shared container kept. Remove manually with 'docker compose -f \
349 .systemprompt/docker/shared.yaml down -v'",
350 );
351 }
352 }
353
354 Ok(())
355}
356
357pub async fn edit_tenant(
358 id: Option<String>,
359 config: &CliConfig,
360) -> Result<CommandResult<TenantDetailOutput>> {
361 if !config.is_interactive() {
362 return Err(anyhow::anyhow!(
363 "Tenant edit requires interactive mode.\nUse specific commands to modify tenant \
364 settings in non-interactive mode."
365 ));
366 }
367
368 let cloud_paths = get_cloud_paths()?;
369 let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
370 let mut store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
371 CliService::warning(&format!("Failed to load tenant store: {}", e));
372 TenantStore::default()
373 });
374
375 let tenant_id = if let Some(id) = id {
376 id
377 } else {
378 if store.tenants.is_empty() {
379 bail!("No tenants configured.");
380 }
381 select_tenant(&store.tenants)?.id.clone()
382 };
383
384 let tenant = store
385 .tenants
386 .iter_mut()
387 .find(|t| t.id == tenant_id)
388 .ok_or_else(|| anyhow!("Tenant not found: {}", tenant_id))?;
389
390 CliService::section(&format!("Edit Tenant: {}", tenant.name));
391
392 let new_name: String = Input::with_theme(&ColorfulTheme::default())
393 .with_prompt("Tenant name")
394 .default(tenant.name.clone())
395 .interact_text()?;
396
397 if new_name.is_empty() {
398 bail!("Tenant name cannot be empty");
399 }
400 tenant.name.clone_from(&new_name);
401
402 if tenant.tenant_type == TenantType::Local {
403 edit_local_tenant_database(tenant)?;
404 }
405
406 if tenant.tenant_type == TenantType::Cloud {
407 display_readonly_cloud_fields(tenant);
408 }
409
410 let output = TenantDetailOutput {
411 id: tenant.id.clone(),
412 name: tenant.name.clone(),
413 tenant_type: format!("{:?}", tenant.tenant_type).to_lowercase(),
414 app_id: tenant.app_id.clone(),
415 hostname: tenant.hostname.clone(),
416 region: tenant.region.clone(),
417 has_database: tenant.has_database_url(),
418 };
419
420 store.save_to_path(&tenants_path)?;
421 CliService::success(&format!("Tenant '{}' updated", new_name));
422
423 Ok(CommandResult::card(output)
424 .with_title(format!("Tenant: {}", new_name))
425 .with_skip_render())
426}
427
428fn edit_local_tenant_database(tenant: &mut StoredTenant) -> Result<()> {
429 if let Some(current_url) = tenant.database_url.clone() {
430 let edit_db = Confirm::with_theme(&ColorfulTheme::default())
431 .with_prompt("Edit database URL?")
432 .default(false)
433 .interact()?;
434
435 if edit_db {
436 let new_url: String = Input::with_theme(&ColorfulTheme::default())
437 .with_prompt("Database URL")
438 .default(current_url)
439 .interact_text()?;
440 tenant.database_url = if new_url.is_empty() {
441 None
442 } else {
443 Some(new_url)
444 };
445 }
446 }
447 Ok(())
448}
449
450fn display_readonly_cloud_fields(tenant: &StoredTenant) {
451 if let Some(ref region) = tenant.region {
452 CliService::info(&format!("Region: {} (cannot be changed)", region));
453 }
454 if let Some(ref hostname) = tenant.hostname {
455 CliService::info(&format!("Hostname: {} (cannot be changed)", hostname));
456 }
457}
458
459async fn sync_and_load_tenants(tenants_path: &std::path::Path) -> TenantStore {
460 let mut local_store =
461 TenantStore::load_from_path(tenants_path).unwrap_or_else(|_| TenantStore::default());
462
463 let Ok(creds) = get_credentials() else {
464 return local_store;
465 };
466
467 let Ok(client) = CloudApiClient::new(&creds.api_url, &creds.api_token) else {
468 return local_store;
469 };
470
471 let cloud_tenant_infos = match client.get_user().await {
472 Ok(response) => response.tenants,
473 Err(e) => {
474 CliService::warning(&format!("Failed to sync cloud tenants: {}", e));
475 return local_store;
476 },
477 };
478
479 for cloud_info in &cloud_tenant_infos {
480 if let Some(existing) = local_store
481 .tenants
482 .iter_mut()
483 .find(|t| t.id == cloud_info.id)
484 {
485 existing.update_from_tenant_info(cloud_info);
486 } else {
487 local_store
488 .tenants
489 .push(StoredTenant::from_tenant_info(cloud_info));
490 }
491 }
492
493 local_store.synced_at = Utc::now();
494
495 if let Err(e) = local_store.save_to_path(tenants_path) {
496 CliService::warning(&format!("Failed to save synced tenants: {}", e));
497 }
498
499 local_store
500}
501
502pub async fn cancel_subscription(
503 args: TenantCancelArgs,
504 config: &CliConfig,
505) -> Result<CommandResult<crate::cloud::types::CancelSubscriptionOutput>> {
506 if !config.is_interactive() {
507 bail!(
508 "Subscription cancellation requires interactive mode for safety.\nThis is an \
509 irreversible operation that destroys all data."
510 );
511 }
512
513 let cloud_paths = get_cloud_paths()?;
514 let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
515 let store =
516 TenantStore::load_from_path(&tenants_path).unwrap_or_else(|_| TenantStore::default());
517
518 let cloud_tenants: Vec<&StoredTenant> = store
519 .tenants
520 .iter()
521 .filter(|t| t.tenant_type == TenantType::Cloud)
522 .collect();
523
524 if cloud_tenants.is_empty() {
525 bail!("No cloud tenants found. Only cloud tenants have subscriptions.");
526 }
527
528 let tenant = if let Some(ref id) = args.id {
529 store
530 .tenants
531 .iter()
532 .find(|t| t.id == *id && t.tenant_type == TenantType::Cloud)
533 .ok_or_else(|| anyhow!("Cloud tenant not found: {}", id))?
534 } else {
535 let options: Vec<String> = cloud_tenants
536 .iter()
537 .map(|t| format!("{} ({})", t.name, t.id))
538 .collect();
539
540 let selection = Select::with_theme(&ColorfulTheme::default())
541 .with_prompt("Select cloud tenant to cancel")
542 .items(&options)
543 .default(0)
544 .interact()?;
545
546 cloud_tenants[selection]
547 };
548
549 CliService::section("⚠️ CANCEL SUBSCRIPTION");
550 CliService::error("THIS ACTION IS IRREVERSIBLE");
551 CliService::info("");
552 CliService::info("This will:");
553 CliService::info(" • Cancel your subscription immediately");
554 CliService::info(" • Stop and destroy the Fly.io machine");
555 CliService::info(" • Delete ALL data in the database");
556 CliService::info(" • Remove all deployed code and configuration");
557 CliService::info("");
558 CliService::warning("Your data CANNOT be recovered after this operation.");
559 CliService::info("");
560
561 CliService::key_value("Tenant", &tenant.name);
562 CliService::key_value("ID", &tenant.id);
563 if let Some(ref hostname) = tenant.hostname {
564 CliService::key_value("URL", &format!("https://{}", hostname));
565 }
566 CliService::info("");
567
568 let confirmation: String = Input::with_theme(&ColorfulTheme::default())
569 .with_prompt(format!("Type '{}' to confirm cancellation", tenant.name))
570 .interact_text()?;
571
572 if confirmation != tenant.name {
573 CliService::info("Cancellation aborted. Tenant name did not match.");
574 let output = crate::cloud::types::CancelSubscriptionOutput {
575 tenant_id: tenant.id.clone(),
576 tenant_name: tenant.name.clone(),
577 message: "Cancellation aborted. Tenant name did not match.".to_string(),
578 };
579 return Ok(CommandResult::text(output)
580 .with_title("Cancel Subscription")
581 .with_skip_render());
582 }
583
584 let creds = get_credentials()?;
585 let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
586
587 let spinner = CliService::spinner("Cancelling subscription...");
588 client.cancel_subscription(&tenant.id).await?;
589 spinner.finish_and_clear();
590
591 CliService::success("Subscription cancelled");
592 CliService::info("Your tenant will be suspended and all data will be destroyed.");
593 CliService::info("You will not be charged for future billing periods.");
594 CliService::info("");
595 CliService::info(
596 "Manage subscriptions: https://customer-portal.paddle.com/cpl_01j80s3z6crr7zj96htce0kr0f",
597 );
598
599 let output = crate::cloud::types::CancelSubscriptionOutput {
600 tenant_id: tenant.id.clone(),
601 tenant_name: tenant.name.clone(),
602 message: "Subscription cancelled. Your tenant will be suspended and all data will be \
603 destroyed."
604 .to_string(),
605 };
606
607 Ok(CommandResult::text(output)
608 .with_title("Cancel Subscription")
609 .with_skip_render())
610}