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