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 super::api_keys::ApiKeys;
15use super::builders::{CloudProfileBuilder, LocalProfileBuilder};
16use super::templates::{
17 DatabaseUrls, get_services_path, save_dockerfile, save_dockerignore, save_entrypoint,
18 save_profile, save_secrets, update_ai_config_default_provider,
19};
20use super::{CreateArgs, TenantTypeArg};
21
22#[derive(Debug)]
23pub struct CreatedProfile {
24 pub name: String,
25}
26
27pub fn create_profile_for_tenant(
28 tenant: &StoredTenant,
29 api_keys: &ApiKeys,
30 profile_name: &str,
31) -> Result<CreatedProfile> {
32 let ctx = ProjectContext::discover();
33 let mut name = profile_name.to_string();
34
35 loop {
36 let profile_dir = ctx.profile_dir(&name);
37 if !profile_dir.exists() {
38 break;
39 }
40
41 CliService::warning(&format!(
42 "Profile '{}' already exists at {}",
43 name,
44 profile_dir.display()
45 ));
46
47 name = Input::with_theme(&ColorfulTheme::default())
48 .with_prompt("Enter a different profile name")
49 .interact_text()?;
50 }
51
52 let profile_dir = ctx.profile_dir(&name);
53
54 std::fs::create_dir_all(ctx.profiles_dir())
55 .with_context(|| format!("Failed to create {}", ctx.profiles_dir().display()))?;
56
57 std::fs::create_dir_all(&profile_dir)
58 .with_context(|| format!("Failed to create directory {}", profile_dir.display()))?;
59
60 std::fs::create_dir_all(ctx.storage_dir()).with_context(|| {
61 format!(
62 "Failed to create storage directory {}",
63 ctx.storage_dir().display()
64 )
65 })?;
66
67 let secrets_path = ProfilePath::Secrets.resolve(&profile_dir);
68 let local_db_url = tenant
69 .get_local_database_url()
70 .ok_or_else(|| anyhow::anyhow!("Tenant database URL is required"))?;
71 let db_urls = DatabaseUrls {
72 external: local_db_url,
73 internal: tenant.internal_database_url.as_deref(),
74 };
75 save_secrets(
76 &db_urls,
77 api_keys,
78 tenant.sync_token.as_deref(),
79 &secrets_path,
80 tenant.tenant_type == TenantType::Cloud,
81 )?;
82 CliService::success(&format!("Created: {}", secrets_path.display()));
83
84 update_ai_config_default_provider(api_keys.selected_provider())?;
85
86 let profile_path = ProfilePath::Config.resolve(&profile_dir);
87
88 let built_profile = match tenant.tenant_type {
89 TenantType::Local => {
90 let services_path = get_services_path()?;
91 LocalProfileBuilder::new(&name, "./secrets.json", &services_path)
92 .with_tenant_id(TenantId::new(&tenant.id))
93 .build()
94 },
95 TenantType::Cloud => {
96 let mut builder = CloudProfileBuilder::new(&name)
97 .with_tenant_id(TenantId::new(&tenant.id))
98 .with_external_db_access(tenant.external_db_access)
99 .with_secrets_path("./secrets.json");
100 if let Some(hostname) = &tenant.hostname {
101 builder = builder.with_external_url(format!("https://{}", hostname));
102 }
103 builder.build()
104 },
105 };
106
107 save_profile(&built_profile, &profile_path)?;
108 CliService::success(&format!("Created: {}", profile_path.display()));
109
110 let docker_dir = ctx.profile_docker_dir(&name);
111 std::fs::create_dir_all(&docker_dir)
112 .with_context(|| format!("Failed to create docker directory {}", docker_dir.display()))?;
113
114 let dockerfile_path = ctx.profile_dockerfile(&name);
115 save_dockerfile(&dockerfile_path, &name, ctx.root())?;
116 CliService::success(&format!("Created: {}", dockerfile_path.display()));
117
118 let entrypoint_path = ctx.profile_entrypoint(&name);
119 save_entrypoint(&entrypoint_path)?;
120 CliService::success(&format!("Created: {}", entrypoint_path.display()));
121
122 let dockerignore_path = ctx.profile_dockerignore(&name);
123 save_dockerignore(&dockerignore_path)?;
124 CliService::success(&format!("Created: {}", dockerignore_path.display()));
125
126 match built_profile.validate() {
127 Ok(()) => CliService::success("Profile validated"),
128 Err(e) => CliService::warning(&format!("Validation warning: {}", e)),
129 }
130
131 Ok(CreatedProfile { name })
132}
133
134pub fn resolve_tenant_from_args(args: &CreateArgs, store: &TenantStore) -> Result<StoredTenant> {
135 let tenant_id = args.tenant.as_ref().ok_or_else(|| {
136 anyhow::anyhow!(
137 "Missing required flag: --tenant-id\nIn non-interactive mode, --tenant-id is \
138 required.\nList tenants with: systemprompt cloud tenant list"
139 )
140 })?;
141
142 let tenant = store.find_tenant(tenant_id).ok_or_else(|| {
143 anyhow::anyhow!(
144 "Tenant '{}' not found.\nList available tenants with: systemprompt cloud tenant list",
145 tenant_id
146 )
147 })?;
148
149 let expected_type: TenantType = match args.tenant_type {
150 TenantTypeArg::Local => TenantType::Local,
151 TenantTypeArg::Cloud => TenantType::Cloud,
152 };
153
154 if tenant.tenant_type != expected_type {
155 bail!(
156 "Tenant '{}' is type {:?}, but --tenant-type {:?} was specified",
157 tenant_id,
158 tenant.tenant_type,
159 args.tenant_type
160 );
161 }
162
163 Ok(tenant.clone())
164}
165
166pub struct RefreshedCredentials {
167 pub external_database_url: String,
168 pub internal_database_url: String,
169 pub sync_token: Option<String>,
170}
171
172pub async fn refresh_tenant_credentials(
173 client: &CloudApiClient,
174 tenant_id: &TenantId,
175) -> Result<RefreshedCredentials> {
176 let status = client.get_tenant_status(tenant_id.as_str()).await?;
177 let secrets_url = status
178 .secrets_url
179 .ok_or_else(|| anyhow::anyhow!("No secrets URL available for tenant"))?;
180 let secrets = client.fetch_secrets(&secrets_url).await?;
181 Ok(RefreshedCredentials {
182 external_database_url: secrets.database_url,
183 internal_database_url: secrets.internal_database_url,
184 sync_token: secrets.sync_token,
185 })
186}
187
188pub async fn ensure_unmasked_credentials(
189 tenant: StoredTenant,
190 tenants_path: &std::path::Path,
191) -> Result<StoredTenant> {
192 if tenant.tenant_type != TenantType::Cloud {
193 return Ok(tenant);
194 }
195
196 let external_url = tenant.database_url.as_deref();
197 let internal_url = tenant.internal_database_url.as_deref();
198
199 let needs_external = tenant.external_db_access && external_url.is_none();
200 let needs_refresh = needs_external
201 || external_url.is_some_and(Profile::is_masked_database_url)
202 || internal_url.is_none_or(Profile::is_masked_database_url);
203
204 if !needs_refresh {
205 return Ok(tenant);
206 }
207
208 CliService::info("Fetching database credentials...");
209 let creds = get_credentials()?;
210 let client = CloudApiClient::new(&creds.api_url, &creds.api_token)?;
211
212 match refresh_tenant_credentials(&client, &TenantId::new(&tenant.id)).await {
213 Ok(creds) => {
214 let mut updated_tenant = tenant.clone();
215 updated_tenant.internal_database_url = Some(creds.internal_database_url);
216 if updated_tenant.external_db_access {
217 updated_tenant.database_url = Some(creds.external_database_url);
218 }
219 if let Some(token) = creds.sync_token {
220 updated_tenant.sync_token = Some(token);
221 }
222
223 let mut store = TenantStore::load_from_path(tenants_path)
224 .unwrap_or_else(|_| TenantStore::default());
225 if let Some(t) = store.tenants.iter_mut().find(|t| t.id == tenant.id) {
226 *t = updated_tenant.clone();
227 store.save_to_path(tenants_path)?;
228 }
229
230 CliService::success("Database credentials retrieved");
231 Ok(updated_tenant)
232 },
233 Err(e) => {
234 CliService::warning(&format!("Could not fetch credentials: {}", e));
235 CliService::warning(
236 "Run 'systemprompt cloud tenant rotate-credentials' to fetch real credentials.",
237 );
238 Ok(tenant)
239 },
240 }
241}