systemprompt_models/profile/
mod.rs1mod cloud;
4mod database;
5mod from_env;
6mod paths;
7mod rate_limits;
8mod runtime;
9mod secrets;
10mod security;
11mod server;
12mod site;
13mod style;
14mod validation;
15
16pub use cloud::{CloudConfig, CloudValidationMode};
17pub use database::DatabaseConfig;
18pub use paths::{expand_home, resolve_path, resolve_with_home, PathsConfig};
19pub use rate_limits::{
20 default_a2a_multiplier, default_admin_multiplier, default_agent_registry, default_agents,
21 default_anon_multiplier, default_artifacts, default_burst, default_content, default_contexts,
22 default_mcp, default_mcp_multiplier, default_mcp_registry, default_oauth_auth,
23 default_oauth_public, default_service_multiplier, default_stream, default_tasks,
24 default_user_multiplier, RateLimitsConfig, TierMultipliers,
25};
26pub use runtime::{Environment, LogLevel, OutputFormat, RuntimeConfig};
27pub use secrets::{SecretsConfig, SecretsSource, SecretsValidationMode};
28pub use security::SecurityConfig;
29pub use server::ServerConfig;
30pub use site::SiteConfig;
31pub use style::ProfileStyle;
32
33use anyhow::{Context, Result};
34use regex::Regex;
35use serde::{Deserialize, Serialize};
36use std::path::{Path, PathBuf};
37use std::sync::LazyLock;
38
39#[allow(clippy::expect_used)]
40static ENV_VAR_REGEX: LazyLock<Regex> = LazyLock::new(|| {
41 Regex::new(r"\$\{(\w+)\}")
42 .expect("ENV_VAR_REGEX is a valid regex - this is a compile-time constant")
43});
44
45fn env_var_regex() -> &'static Regex {
46 &ENV_VAR_REGEX
47}
48
49fn substitute_env_vars(content: &str) -> String {
50 env_var_regex()
51 .replace_all(content, |caps: ®ex::Captures| {
52 let var_name = &caps[1];
53 std::env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
54 })
55 .to_string()
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
59#[serde(rename_all = "lowercase")]
60pub enum ProfileType {
61 #[default]
62 Local,
63 Cloud,
64}
65
66impl ProfileType {
67 pub const fn is_cloud(&self) -> bool {
68 matches!(self, Self::Cloud)
69 }
70
71 pub const fn is_local(&self) -> bool {
72 matches!(self, Self::Local)
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct Profile {
78 pub name: String,
79
80 pub display_name: String,
81
82 #[serde(default)]
83 pub target: ProfileType,
84
85 pub site: SiteConfig,
86
87 pub database: DatabaseConfig,
88
89 pub server: ServerConfig,
90
91 pub paths: PathsConfig,
92
93 pub security: SecurityConfig,
94
95 pub rate_limits: RateLimitsConfig,
96
97 #[serde(default)]
98 pub runtime: RuntimeConfig,
99
100 #[serde(default)]
101 pub cloud: Option<CloudConfig>,
102
103 #[serde(default)]
104 pub secrets: Option<SecretsConfig>,
105}
106
107impl Profile {
108 pub fn parse(content: &str, profile_path: &Path) -> Result<Self> {
109 let content = substitute_env_vars(content);
110
111 let mut profile: Self = serde_yaml::from_str(&content)
112 .with_context(|| format!("Failed to parse profile: {}", profile_path.display()))?;
113
114 let profile_dir = profile_path
115 .parent()
116 .with_context(|| format!("Invalid profile path: {}", profile_path.display()))?;
117
118 profile.paths.resolve_relative_to(profile_dir);
119
120 Ok(profile)
121 }
122
123 pub fn to_yaml(&self) -> Result<String> {
124 serde_yaml::to_string(self).context("Failed to serialize profile")
125 }
126
127 pub fn list_available(services_path: &Path) -> Vec<String> {
130 let profiles_dir = services_path.join("profiles");
131 if !profiles_dir.exists() {
132 return Vec::new();
133 }
134
135 std::fs::read_dir(&profiles_dir)
136 .map(|entries| {
137 entries
138 .filter_map(std::result::Result::ok)
139 .filter_map(|e| {
140 let name = e.file_name().to_string_lossy().to_string();
141 if name.ends_with(".secrets.profile.yaml") {
142 Some(name.trim_end_matches(".secrets.profile.yaml").to_string())
143 } else {
144 None
145 }
146 })
147 .collect()
148 })
149 .unwrap_or_default()
150 }
151
152 pub fn save(&self, services_path: &Path) -> Result<()> {
155 let profiles_dir = services_path.join("profiles");
156 std::fs::create_dir_all(&profiles_dir).context("Failed to create profiles directory")?;
157
158 let profile_path = profiles_dir.join(format!("{}.secrets.profile.yaml", self.name));
159 let content = serde_yaml::to_string(self).context("Failed to serialize profile")?;
160
161 let content_with_header = format!(
162 "# SystemPrompt Profile: {}\n# \n# WARNING: This file contains secrets (API keys, JWT \
163 secrets, database credentials).\n# DO NOT commit this file to version control.\n# DO \
164 NOT share this file publicly.\n# \n# Generated from environment variables\n\n{}",
165 self.display_name, content
166 );
167
168 std::fs::write(&profile_path, content_with_header)
169 .with_context(|| format!("Failed to write profile file: {}", profile_path.display()))?;
170
171 Ok(())
172 }
173
174 pub fn profile_style(&self) -> ProfileStyle {
175 match self.name.to_lowercase().as_str() {
176 "dev" | "development" | "local" => ProfileStyle::Development,
177 "prod" | "production" => ProfileStyle::Production,
178 "staging" | "stage" => ProfileStyle::Staging,
179 "test" | "testing" => ProfileStyle::Test,
180 _ => ProfileStyle::Custom,
181 }
182 }
183
184 pub fn mask_secret(value: &str, visible_chars: usize) -> String {
185 if value.is_empty() {
186 return "(not set)".to_string();
187 }
188 if value.len() <= visible_chars {
189 return "***".to_string();
190 }
191 format!("{}...", &value[..visible_chars])
192 }
193
194 pub fn mask_database_url(url: &str) -> String {
195 if let Some(at_pos) = url.find('@') {
196 if let Some(colon_pos) = url[..at_pos].rfind(':') {
197 let prefix = &url[..=colon_pos];
198 let suffix = &url[at_pos..];
199 return format!("{}***{}", prefix, suffix);
200 }
201 }
202 url.to_string()
203 }
204
205 pub fn credentials_path(&self, profile_dir: Option<&Path>) -> Result<PathBuf> {
206 let cloud = self
207 .cloud
208 .as_ref()
209 .context("Profile missing cloud configuration")?;
210 Ok(Self::resolve_cloud_path(
211 &cloud.credentials_path,
212 profile_dir,
213 ))
214 }
215
216 pub fn tenants_path(&self, profile_dir: Option<&Path>) -> Result<PathBuf> {
217 let cloud = self
218 .cloud
219 .as_ref()
220 .context("Profile missing cloud configuration")?;
221 Ok(Self::resolve_cloud_path(&cloud.tenants_path, profile_dir))
222 }
223
224 fn resolve_cloud_path(path_str: &str, profile_dir: Option<&Path>) -> PathBuf {
225 profile_dir.map_or_else(
226 || expand_home(path_str),
227 |base| resolve_with_home(base, path_str),
228 )
229 }
230}