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