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