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