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