Skip to main content

systemprompt_cli/commands/cloud/profile/
profile_steps.rs

1use anyhow::{Context, Result, bail};
2use dialoguer::Input;
3use dialoguer::theme::ColorfulTheme;
4use systemprompt_cloud::{
5    CloudApiClient, ProfilePath, ProjectContext, StoredTenant, TenantStore, TenantType,
6};
7use systemprompt_logging::CliService;
8use systemprompt_models::Profile;
9
10use systemprompt_identifiers::TenantId;
11
12use crate::commands::cloud::tenant::get_credentials;
13
14use systemprompt_models::profile::TrustedIssuer;
15
16use super::api_keys::ApiKeys;
17use super::builders::{CloudProfileBuilder, LocalProfileBuilder};
18use super::templates::{
19    DatabaseUrls, get_services_path, save_dockerfile, save_dockerignore, save_entrypoint,
20    save_profile, save_secrets, update_ai_config_default_provider,
21};
22use super::{CreateArgs, TenantTypeArg};
23
24#[derive(Debug)]
25pub struct CreatedProfile {
26    pub name: String,
27}
28
29pub fn create_profile_for_tenant(
30    tenant: &StoredTenant,
31    api_keys: &ApiKeys,
32    profile_name: &str,
33    control_plane_api_url: Option<&str>,
34) -> Result<CreatedProfile> {
35    let ctx = ProjectContext::discover();
36    let mut name = profile_name.to_owned();
37
38    loop {
39        let profile_dir = ctx.profile_dir(&name);
40        if !profile_dir.exists() {
41            break;
42        }
43
44        CliService::warning(&format!(
45            "Profile '{}' already exists at {}",
46            name,
47            profile_dir.display()
48        ));
49
50        name = Input::with_theme(&ColorfulTheme::default())
51            .with_prompt("Enter a different profile name")
52            .interact_text()?;
53    }
54
55    let profile_dir = ctx.profile_dir(&name);
56
57    std::fs::create_dir_all(ctx.profiles_dir())
58        .with_context(|| format!("Failed to create {}", ctx.profiles_dir().display()))?;
59
60    std::fs::create_dir_all(&profile_dir)
61        .with_context(|| format!("Failed to create directory {}", profile_dir.display()))?;
62
63    std::fs::create_dir_all(ctx.storage_dir()).with_context(|| {
64        format!(
65            "Failed to create storage directory {}",
66            ctx.storage_dir().display()
67        )
68    })?;
69
70    let secrets_path = ProfilePath::Secrets.resolve(&profile_dir);
71    let local_db_url = tenant
72        .get_local_database_url()
73        .ok_or_else(|| anyhow::anyhow!("Tenant database URL is required"))?;
74    let db_urls = DatabaseUrls {
75        external: local_db_url,
76        internal: tenant.internal_database_url.as_deref(),
77    };
78    save_secrets(
79        &db_urls,
80        api_keys,
81        &secrets_path,
82        tenant.tenant_type == TenantType::Cloud,
83    )?;
84    CliService::success(&format!("Created: {}", secrets_path.display()));
85
86    update_ai_config_default_provider(api_keys.selected_provider())?;
87
88    let profile_path = ProfilePath::Config.resolve(&profile_dir);
89
90    let built_profile = match tenant.tenant_type {
91        TenantType::Local => {
92            let services_path = get_services_path()?;
93            LocalProfileBuilder::new(&name, "./secrets.json", &services_path)
94                .with_tenant_id(TenantId::new(&tenant.id))
95                .build()
96        },
97        TenantType::Cloud => {
98            let mut builder = CloudProfileBuilder::new(&name)
99                .with_tenant_id(TenantId::new(&tenant.id))
100                .with_external_db_access(tenant.external_db_access)
101                .with_secrets_path("./secrets.json");
102            if let Some(hostname) = &tenant.hostname {
103                builder = builder.with_external_url(format!("https://{}", hostname));
104            }
105            if let Some(api_url) = control_plane_api_url {
106                let trimmed = api_url.trim_end_matches('/').to_owned();
107                builder = builder.with_trusted_issuer(TrustedIssuer {
108                    issuer: trimmed.clone(),
109                    jwks_uri: format!("{}/.well-known/jwks.json", trimmed),
110                    audience: tenant.id.clone(),
111                });
112            }
113            builder.build()
114        },
115    };
116
117    save_profile(&built_profile, &profile_path)?;
118    CliService::success(&format!("Created: {}", profile_path.display()));
119
120    let docker_dir = ctx.profile_docker_dir(&name);
121    std::fs::create_dir_all(&docker_dir)
122        .with_context(|| format!("Failed to create docker directory {}", docker_dir.display()))?;
123
124    let dockerfile_path = ctx.profile_dockerfile(&name);
125    save_dockerfile(&dockerfile_path, &name, ctx.root())?;
126    CliService::success(&format!("Created: {}", dockerfile_path.display()));
127
128    let entrypoint_path = ctx.profile_entrypoint(&name);
129    save_entrypoint(&entrypoint_path)?;
130    CliService::success(&format!("Created: {}", entrypoint_path.display()));
131
132    let dockerignore_path = ctx.profile_dockerignore(&name);
133    save_dockerignore(&dockerignore_path)?;
134    CliService::success(&format!("Created: {}", dockerignore_path.display()));
135
136    match built_profile.validate() {
137        Ok(()) => CliService::success("Profile validated"),
138        Err(e) => CliService::warning(&format!("Validation warning: {}", e)),
139    }
140
141    Ok(CreatedProfile { name })
142}
143
144pub(super) fn resolve_tenant_from_args(
145    args: &CreateArgs,
146    store: &TenantStore,
147) -> Result<StoredTenant> {
148    let tenant_id = args.tenant.as_ref().ok_or_else(|| {
149        anyhow::anyhow!(
150            "Missing required flag: --tenant-id\nIn non-interactive mode, --tenant-id is \
151             required.\nList tenants with: systemprompt cloud tenant list"
152        )
153    })?;
154
155    let tenant = store.find_tenant(tenant_id).ok_or_else(|| {
156        anyhow::anyhow!(
157            "Tenant '{}' not found.\nList available tenants with: systemprompt cloud tenant list",
158            tenant_id
159        )
160    })?;
161
162    let expected_type: TenantType = match args.tenant_type {
163        TenantTypeArg::Local => TenantType::Local,
164        TenantTypeArg::Cloud => TenantType::Cloud,
165    };
166
167    if tenant.tenant_type != expected_type {
168        bail!(
169            "Tenant '{}' is type {:?}, but --tenant-type {:?} was specified",
170            tenant_id,
171            tenant.tenant_type,
172            args.tenant_type
173        );
174    }
175
176    Ok(tenant.clone())
177}
178
179struct RefreshedCredentials {
180    pub external_database_url: String,
181    pub internal_database_url: String,
182}
183
184async fn refresh_tenant_credentials(
185    client: &CloudApiClient,
186    tenant_id: &TenantId,
187) -> Result<RefreshedCredentials> {
188    let status = client.get_tenant_status(tenant_id).await?;
189    let secrets_url = status
190        .secrets_url
191        .ok_or_else(|| anyhow::anyhow!("No secrets URL available for tenant"))?;
192    let secrets = client.fetch_secrets(&secrets_url).await?;
193    Ok(RefreshedCredentials {
194        external_database_url: secrets.database_url,
195        internal_database_url: secrets.internal_database_url,
196    })
197}
198
199pub(super) async fn ensure_unmasked_credentials(
200    tenant: StoredTenant,
201    tenants_path: &std::path::Path,
202) -> Result<StoredTenant> {
203    if tenant.tenant_type != TenantType::Cloud {
204        return Ok(tenant);
205    }
206
207    let external_url = tenant.database_url.as_deref();
208    let internal_url = tenant.internal_database_url.as_deref();
209
210    let needs_external = tenant.external_db_access && external_url.is_none();
211    let needs_refresh = needs_external
212        || external_url.is_some_and(Profile::is_masked_database_url)
213        || internal_url.is_none_or(Profile::is_masked_database_url);
214
215    if !needs_refresh {
216        return Ok(tenant);
217    }
218
219    CliService::info("Fetching database credentials...");
220    let creds = get_credentials()?;
221    let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
222
223    match refresh_tenant_credentials(&client, &TenantId::new(&tenant.id)).await {
224        Ok(creds) => {
225            let mut updated_tenant = tenant.clone();
226            updated_tenant.internal_database_url = Some(creds.internal_database_url);
227            if updated_tenant.external_db_access {
228                updated_tenant.database_url = Some(creds.external_database_url);
229            }
230
231            let mut store = TenantStore::load_from_path(tenants_path)
232                .unwrap_or_else(|_| TenantStore::default());
233            if let Some(t) = store.tenants.iter_mut().find(|t| t.id == tenant.id) {
234                *t = updated_tenant.clone();
235                store.save_to_path(tenants_path)?;
236            }
237
238            CliService::success("Database credentials retrieved");
239            Ok(updated_tenant)
240        },
241        Err(e) => {
242            CliService::warning(&format!("Could not fetch credentials: {}", e));
243            CliService::warning(
244                "Run 'systemprompt cloud tenant rotate-credentials' to fetch real credentials.",
245            );
246            Ok(tenant)
247        },
248    }
249}