Skip to main content

systemprompt_cli/commands/cloud/tenant/
mod.rs

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