systemprompt_cli/commands/cloud/profile/
profile_steps.rs1use 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_string();
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_string();
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 fn resolve_tenant_from_args(args: &CreateArgs, store: &TenantStore) -> Result<StoredTenant> {
145 let tenant_id = args.tenant.as_ref().ok_or_else(|| {
146 anyhow::anyhow!(
147 "Missing required flag: --tenant-id\nIn non-interactive mode, --tenant-id is \
148 required.\nList tenants with: systemprompt cloud tenant list"
149 )
150 })?;
151
152 let tenant = store.find_tenant(tenant_id).ok_or_else(|| {
153 anyhow::anyhow!(
154 "Tenant '{}' not found.\nList available tenants with: systemprompt cloud tenant list",
155 tenant_id
156 )
157 })?;
158
159 let expected_type: TenantType = match args.tenant_type {
160 TenantTypeArg::Local => TenantType::Local,
161 TenantTypeArg::Cloud => TenantType::Cloud,
162 };
163
164 if tenant.tenant_type != expected_type {
165 bail!(
166 "Tenant '{}' is type {:?}, but --tenant-type {:?} was specified",
167 tenant_id,
168 tenant.tenant_type,
169 args.tenant_type
170 );
171 }
172
173 Ok(tenant.clone())
174}
175
176pub struct RefreshedCredentials {
177 pub external_database_url: String,
178 pub internal_database_url: String,
179}
180
181pub async fn refresh_tenant_credentials(
182 client: &CloudApiClient,
183 tenant_id: &TenantId,
184) -> Result<RefreshedCredentials> {
185 let status = client.get_tenant_status(tenant_id).await?;
186 let secrets_url = status
187 .secrets_url
188 .ok_or_else(|| anyhow::anyhow!("No secrets URL available for tenant"))?;
189 let secrets = client.fetch_secrets(&secrets_url).await?;
190 Ok(RefreshedCredentials {
191 external_database_url: secrets.database_url,
192 internal_database_url: secrets.internal_database_url,
193 })
194}
195
196pub async fn ensure_unmasked_credentials(
197 tenant: StoredTenant,
198 tenants_path: &std::path::Path,
199) -> Result<StoredTenant> {
200 if tenant.tenant_type != TenantType::Cloud {
201 return Ok(tenant);
202 }
203
204 let external_url = tenant.database_url.as_deref();
205 let internal_url = tenant.internal_database_url.as_deref();
206
207 let needs_external = tenant.external_db_access && external_url.is_none();
208 let needs_refresh = needs_external
209 || external_url.is_some_and(Profile::is_masked_database_url)
210 || internal_url.is_none_or(Profile::is_masked_database_url);
211
212 if !needs_refresh {
213 return Ok(tenant);
214 }
215
216 CliService::info("Fetching database credentials...");
217 let creds = get_credentials()?;
218 let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
219
220 match refresh_tenant_credentials(&client, &TenantId::new(&tenant.id)).await {
221 Ok(creds) => {
222 let mut updated_tenant = tenant.clone();
223 updated_tenant.internal_database_url = Some(creds.internal_database_url);
224 if updated_tenant.external_db_access {
225 updated_tenant.database_url = Some(creds.external_database_url);
226 }
227
228 let mut store = TenantStore::load_from_path(tenants_path)
229 .unwrap_or_else(|_| TenantStore::default());
230 if let Some(t) = store.tenants.iter_mut().find(|t| t.id == tenant.id) {
231 *t = updated_tenant.clone();
232 store.save_to_path(tenants_path)?;
233 }
234
235 CliService::success("Database credentials retrieved");
236 Ok(updated_tenant)
237 },
238 Err(e) => {
239 CliService::warning(&format!("Could not fetch credentials: {}", e));
240 CliService::warning(
241 "Run 'systemprompt cloud tenant rotate-credentials' to fetch real credentials.",
242 );
243 Ok(tenant)
244 },
245 }
246}