systemprompt_cli/commands/cloud/profile/
profile_steps.rs1use 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 });
151 }
152 builder.build()
153 },
154 })
155}
156
157pub(super) fn write_docker_assets(ctx: &ProjectContext, name: &str) -> Result<()> {
158 let docker_dir = ctx.profile_docker_dir(name);
159 std::fs::create_dir_all(&docker_dir)
160 .with_context(|| format!("Failed to create docker directory {}", docker_dir.display()))?;
161
162 let dockerfile_path = ctx.profile_dockerfile(name);
163 save_dockerfile(&dockerfile_path, name, ctx.root())?;
164 CliService::success(&format!("Created: {}", dockerfile_path.display()));
165
166 let entrypoint_path = ctx.profile_entrypoint(name);
167 save_entrypoint(&entrypoint_path)?;
168 CliService::success(&format!("Created: {}", entrypoint_path.display()));
169
170 let dockerignore_path = ctx.profile_dockerignore(name);
171 save_dockerignore(&dockerignore_path)?;
172 CliService::success(&format!("Created: {}", dockerignore_path.display()));
173
174 Ok(())
175}
176
177pub(super) fn report_profile_validation(profile: &Profile) {
178 match profile.validate() {
179 Ok(()) => CliService::success("Profile validated"),
180 Err(e) => CliService::warning(&format!("Validation warning: {}", e)),
181 }
182}
183
184pub(super) fn resolve_tenant_from_args(
185 args: &CreateArgs,
186 store: &TenantStore,
187) -> Result<StoredTenant> {
188 let tenant_id = args.tenant.as_ref().ok_or_else(|| {
189 anyhow::anyhow!(
190 "Missing required flag: --tenant-id\nIn non-interactive mode, --tenant-id is \
191 required.\nList tenants with: systemprompt cloud tenant list"
192 )
193 })?;
194
195 let tenant = store.find_tenant(tenant_id).ok_or_else(|| {
196 anyhow::anyhow!(
197 "Tenant '{}' not found.\nList available tenants with: systemprompt cloud tenant list",
198 tenant_id
199 )
200 })?;
201
202 let expected_type: TenantType = match args.tenant_type {
203 TenantTypeArg::Local => TenantType::Local,
204 TenantTypeArg::Cloud => TenantType::Cloud,
205 };
206
207 if tenant.tenant_type != expected_type {
208 bail!(
209 "Tenant '{}' is type {:?}, but --tenant-type {:?} was specified",
210 tenant_id,
211 tenant.tenant_type,
212 args.tenant_type
213 );
214 }
215
216 Ok(tenant.clone())
217}
218
219struct RefreshedCredentials {
220 pub external_database_url: String,
221 pub internal_database_url: String,
222}
223
224async fn refresh_tenant_credentials(
225 client: &CloudApiClient,
226 tenant_id: &TenantId,
227) -> Result<RefreshedCredentials> {
228 let status = client.get_tenant_status(tenant_id).await?;
229 let secrets_url = status
230 .secrets_url
231 .ok_or_else(|| anyhow::anyhow!("No secrets URL available for tenant"))?;
232 let secrets = client.fetch_secrets(&secrets_url).await?;
233 Ok(RefreshedCredentials {
234 external_database_url: secrets.database_url,
235 internal_database_url: secrets.internal_database_url,
236 })
237}
238
239pub(super) async fn ensure_unmasked_credentials(
240 tenant: StoredTenant,
241 tenants_path: &Path,
242) -> Result<StoredTenant> {
243 if tenant.tenant_type != TenantType::Cloud {
244 return Ok(tenant);
245 }
246
247 let external_url = tenant.database_url.as_deref();
248 let internal_url = tenant.internal_database_url.as_deref();
249
250 let needs_external = tenant.external_db_access && external_url.is_none();
251 let needs_refresh = needs_external
252 || external_url.is_some_and(Profile::is_masked_database_url)
253 || internal_url.is_none_or(Profile::is_masked_database_url);
254
255 if !needs_refresh {
256 return Ok(tenant);
257 }
258
259 CliService::info("Fetching database credentials...");
260 let creds = get_credentials()?;
261 let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
262
263 match refresh_tenant_credentials(&client, &TenantId::new(&tenant.id)).await {
264 Ok(creds) => {
265 let mut updated_tenant = tenant.clone();
266 updated_tenant.internal_database_url = Some(creds.internal_database_url);
267 if updated_tenant.external_db_access {
268 updated_tenant.database_url = Some(creds.external_database_url);
269 }
270
271 let mut store = TenantStore::load_from_path(tenants_path)
272 .unwrap_or_else(|_| TenantStore::default());
273 if let Some(t) = store.tenants.iter_mut().find(|t| t.id == tenant.id) {
274 *t = updated_tenant.clone();
275 store.save_to_path(tenants_path)?;
276 }
277
278 CliService::success("Database credentials retrieved");
279 Ok(updated_tenant)
280 },
281 Err(e) => {
282 CliService::warning(&format!("Could not fetch credentials: {}", e));
283 CliService::warning(
284 "Run 'systemprompt cloud tenant rotate-credentials' to fetch real credentials.",
285 );
286 Ok(tenant)
287 },
288 }
289}