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;
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(&region, 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}