Skip to main content

systemprompt_cli/commands/cloud/profile/
profile_steps.rs

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