Skip to main content

systemprompt_cli/commands/cloud/tenant/
mod.rs

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