systemprompt_cli/commands/cloud/tenant/
mod.rs1mod cancel;
11pub mod create;
12mod create_flow;
13pub(super) mod delete;
14pub mod docker;
15mod edit;
16mod list;
17mod rotate;
18pub mod select;
19mod show;
20mod validation;
21
22pub use cancel::cancel_subscription;
23pub use create::{
24 create_cloud_tenant, create_external_tenant, create_local_tenant, swap_to_external_host,
25};
26pub use delete::delete_tenant;
27pub(in crate::commands::cloud) use docker::container::wait_for_postgres_healthy;
28pub use edit::edit_tenant;
29pub use list::list_tenants;
30pub use rotate::rotate_credentials;
31pub use select::{get_credentials, resolve_tenant_id, select_tenant};
32pub use show::show_tenant;
33pub use validation::{check_build_ready, validate_ai_config};
34
35use anyhow::Result;
36use clap::{Args, Subcommand};
37use systemprompt_cloud::{CloudPath, TenantStore, get_cloud_paths};
38use systemprompt_logging::CliService;
39
40use crate::context::CommandContext;
41use crate::interactive::Prompter;
42use crate::shared::render_result;
43use create_flow::tenant_create;
44
45#[derive(Debug, Subcommand)]
46pub enum TenantCommands {
47 #[command(about = "Create a new tenant (local or cloud)")]
48 Create {
49 #[arg(long, default_value = "iad")]
50 region: String,
51 },
52
53 #[command(
54 about = "List all tenants",
55 after_help = "EXAMPLES:\n systemprompt cloud tenant list\n systemprompt cloud tenant \
56 list --json"
57 )]
58 List,
59
60 #[command(about = "Show tenant details")]
61 Show { id: Option<String> },
62
63 #[command(about = "Delete a tenant")]
64 Delete(TenantDeleteArgs),
65
66 #[command(about = "Edit tenant configuration")]
67 Edit { id: Option<String> },
68
69 #[command(about = "Rotate database credentials")]
70 RotateCredentials(TenantRotateArgs),
71
72 #[command(about = "Cancel subscription and destroy tenant (IRREVERSIBLE)")]
73 Cancel(TenantCancelArgs),
74}
75
76#[derive(Debug, Args)]
77pub struct TenantRotateArgs {
78 pub id: Option<String>,
79
80 #[arg(short = 'y', long, help = "Skip confirmation prompts")]
81 pub yes: bool,
82}
83
84#[derive(Debug, Args)]
85pub struct TenantDeleteArgs {
86 pub id: Option<String>,
87
88 #[arg(short = 'y', long, help = "Skip confirmation prompts")]
89 pub yes: bool,
90}
91
92#[derive(Debug, Args)]
93pub struct TenantCancelArgs {
94 pub id: Option<String>,
95}
96
97pub async fn execute(cmd: Option<TenantCommands>, ctx: &CommandContext) -> Result<()> {
98 if let Some(cmd) = cmd {
99 execute_command(cmd, ctx).await.map(drop)
100 } else {
101 if !ctx.cli.is_interactive() {
102 return Err(anyhow::anyhow!(
103 "Tenant subcommand required in non-interactive mode"
104 ));
105 }
106 while let Some(cmd) = select_operation(ctx.prompter())? {
107 if execute_command(cmd, ctx).await? {
108 break;
109 }
110 }
111 Ok(())
112 }
113}
114
115async fn execute_command(cmd: TenantCommands, ctx: &CommandContext) -> Result<bool> {
116 match cmd {
117 TenantCommands::Create { region } => tenant_create(®ion, ctx.prompter(), &ctx.cli)
118 .await
119 .map(|()| true),
120 TenantCommands::List => {
121 let result = list_tenants(ctx.prompter(), &ctx.cli).await?;
122 render_result(&result, &ctx.cli);
123 Ok(false)
124 },
125 TenantCommands::Show { id } => {
126 let result = show_tenant(ctx.prompter(), id.as_ref(), &ctx.cli)?;
127 render_result(&result, &ctx.cli);
128 Ok(false)
129 },
130 TenantCommands::Delete(args) => {
131 let result = delete_tenant(args, ctx.prompter(), &ctx.cli).await?;
132 render_result(&result, &ctx.cli);
133 Ok(false)
134 },
135 TenantCommands::Edit { id } => {
136 let result = edit_tenant(id, ctx.prompter(), &ctx.cli)?;
137 render_result(&result, &ctx.cli);
138 Ok(false)
139 },
140 TenantCommands::RotateCredentials(args) => {
141 let result = rotate_credentials(
142 args.id,
143 args.yes || !ctx.cli.is_interactive(),
144 ctx.prompter(),
145 &ctx.cli,
146 )
147 .await?;
148 render_result(&result, &ctx.cli);
149 Ok(false)
150 },
151 TenantCommands::Cancel(args) => {
152 let result = cancel_subscription(args, ctx.prompter(), &ctx.cli).await?;
153 render_result(&result, &ctx.cli);
154 Ok(false)
155 },
156 }
157}
158
159fn select_operation(prompter: &dyn Prompter) -> Result<Option<TenantCommands>> {
160 let cloud_paths = get_cloud_paths();
161 let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
162 let store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
163 CliService::warning(&format!("Failed to load tenant store: {}", e));
164 TenantStore::default()
165 });
166 let has_tenants = !store.tenants.is_empty();
167
168 choose_tenant_operation(prompter, has_tenants)
169}
170
171pub fn choose_tenant_operation(
172 prompter: &dyn Prompter,
173 has_tenants: bool,
174) -> Result<Option<TenantCommands>> {
175 let edit_label = if has_tenants {
176 "Edit".to_owned()
177 } else {
178 "Edit (unavailable - no tenants configured)".to_owned()
179 };
180 let delete_label = if has_tenants {
181 "Delete".to_owned()
182 } else {
183 "Delete (unavailable - no tenants configured)".to_owned()
184 };
185
186 let operations = vec![
187 "Create".to_owned(),
188 "List".to_owned(),
189 edit_label,
190 delete_label,
191 "Done".to_owned(),
192 ];
193
194 let selection = prompter.select("Tenant operation", &operations)?;
195
196 let cmd = match selection {
197 0 => Some(TenantCommands::Create {
198 region: "iad".to_owned(),
199 }),
200 1 => Some(TenantCommands::List),
201 2 | 3 if !has_tenants => {
202 CliService::warning("No tenants configured");
203 CliService::info(
204 "Run 'systemprompt cloud tenant create' (or 'just tenant') to create one.",
205 );
206 return Ok(Some(TenantCommands::List));
207 },
208 2 => Some(TenantCommands::Edit { id: None }),
209 3 => Some(TenantCommands::Delete(TenantDeleteArgs {
210 id: None,
211 yes: false,
212 })),
213 4 => None,
214 other => return Err(anyhow::anyhow!("unexpected menu selection: {other}")),
215 };
216
217 Ok(cmd)
218}