unitycatalog_storage_proxy/cli/
config.rs1use serde::{Deserialize, Serialize};
14
15use crate::auth::{AuthMode, DEFAULT_FORWARDED_USER_HEADER};
16
17#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
19pub struct EnvValue {
20 pub env: String,
21}
22
23#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]
26#[serde(untagged)]
27pub enum ConfigValue {
28 Value(String),
29 Environment(EnvValue),
30}
31
32impl ConfigValue {
33 pub fn value(&self) -> Option<String> {
36 match self {
37 ConfigValue::Value(value) => Some(value.clone()),
38 ConfigValue::Environment(env) => std::env::var(&env.env).ok(),
39 }
40 }
41}
42
43pub const DEFAULT_HOST: &str = "0.0.0.0";
45pub const DEFAULT_PORT: u16 = 8080;
47pub const DEFAULT_BASE_PATH: &str = "/storage-proxy";
50
51#[derive(Debug, Deserialize, Serialize, PartialEq)]
53pub struct Config {
54 #[serde(default)]
56 pub host: Option<String>,
57
58 #[serde(default)]
60 pub port: Option<u16>,
61
62 #[serde(default)]
65 pub base_path: Option<String>,
66
67 pub upstream: UpstreamConfig,
70
71 #[serde(default)]
73 pub auth: AuthConfig,
74}
75
76#[derive(Debug, Deserialize, Serialize, PartialEq)]
78pub struct UpstreamConfig {
79 pub base_url: String,
82
83 #[serde(default)]
86 pub token: Option<ConfigValue>,
87}
88
89#[derive(Debug, Deserialize, Serialize, Default, PartialEq)]
91#[serde(rename_all = "kebab-case")]
92pub struct AuthConfig {
93 #[serde(default)]
95 pub mode: AuthKind,
96
97 #[serde(default)]
100 pub forwarded_user_header: Option<String>,
101
102 #[serde(default)]
105 pub allow_missing_identity: bool,
106}
107
108#[derive(Debug, Deserialize, Serialize, Default, Clone, Copy, PartialEq, Eq)]
111#[serde(rename_all = "kebab-case")]
112pub enum AuthKind {
113 #[default]
115 Anonymous,
116 ReverseProxy,
118}
119
120impl AuthConfig {
121 pub fn resolved_forwarded_header(&self) -> String {
128 self.forwarded_user_header
129 .clone()
130 .unwrap_or_else(|| DEFAULT_FORWARDED_USER_HEADER.to_string())
131 }
132
133 pub fn to_mode(&self) -> AuthMode {
135 match self.mode {
136 AuthKind::Anonymous => AuthMode::Anonymous,
137 AuthKind::ReverseProxy => AuthMode::ReverseProxy {
138 header: self
139 .forwarded_user_header
140 .clone()
141 .unwrap_or_else(|| DEFAULT_FORWARDED_USER_HEADER.to_string()),
142 allow_missing: self.allow_missing_identity,
143 },
144 }
145 }
146}
147
148impl Config {
149 pub fn load(path: &str) -> Result<Self, String> {
156 let p = std::path::Path::new(path);
157 if !p.exists() {
158 return Err(format!("config file not found at {}", p.display()));
159 }
160 let contents =
161 std::fs::read_to_string(p).map_err(|e| format!("reading config `{path}`: {e}"))?;
162 serde_yml::from_str(&contents).map_err(|e| format!("parsing config `{path}`: {e}"))
163 }
164
165 pub fn resolved_host(&self) -> &str {
167 self.host.as_deref().unwrap_or(DEFAULT_HOST)
168 }
169
170 pub fn resolved_port(&self) -> u16 {
172 self.port.unwrap_or(DEFAULT_PORT)
173 }
174
175 pub fn resolved_base_path(&self) -> String {
179 let raw = self.base_path.as_deref().unwrap_or(DEFAULT_BASE_PATH);
180 let trimmed = raw.trim().trim_matches('/');
181 if trimmed.is_empty() {
182 String::new()
183 } else {
184 format!("/{trimmed}")
185 }
186 }
187
188 pub fn health_url(&self) -> String {
191 let host = match self.resolved_host() {
192 "0.0.0.0" | "" | "::" => "127.0.0.1",
193 other => other,
194 };
195 format!("http://{host}:{}/health", self.resolved_port())
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn minimal_config_defaults() {
205 let yaml = r#"
206 upstream:
207 base_url: "http://uc:8080/api/2.1/unity-catalog/"
208 "#;
209 let cfg: Config = serde_yml::from_str(yaml).unwrap();
210 assert_eq!(cfg.resolved_host(), "0.0.0.0");
211 assert_eq!(cfg.resolved_port(), 8080);
212 assert_eq!(cfg.resolved_base_path(), "/storage-proxy");
213 assert!(cfg.upstream.token.is_none());
214 assert_eq!(cfg.auth.mode, AuthKind::Anonymous);
215 assert_eq!(cfg.auth.to_mode(), AuthMode::Anonymous);
216 }
217
218 #[test]
219 fn base_path_normalization() {
220 for raw in ["storage-proxy", "/storage-proxy", "/storage-proxy/"] {
221 let cfg = Config {
222 host: None,
223 port: None,
224 base_path: Some(raw.to_string()),
225 upstream: UpstreamConfig {
226 base_url: "http://uc/".into(),
227 token: None,
228 },
229 auth: AuthConfig::default(),
230 };
231 assert_eq!(cfg.resolved_base_path(), "/storage-proxy", "input {raw:?}");
232 }
233 let cfg = Config {
235 host: None,
236 port: None,
237 base_path: Some(String::new()),
238 upstream: UpstreamConfig {
239 base_url: "http://uc/".into(),
240 token: None,
241 },
242 auth: AuthConfig::default(),
243 };
244 assert_eq!(cfg.resolved_base_path(), "");
245 }
246
247 #[test]
248 fn reverse_proxy_auth_roundtrips_and_maps() {
249 let yaml = r#"
250 upstream:
251 base_url: "http://uc:8080/api/2.1/unity-catalog/"
252 token:
253 env: "UC_TOKEN"
254 auth:
255 mode: reverse-proxy
256 forwarded-user-header: "x-user"
257 allow-missing-identity: true
258 "#;
259 let cfg: Config = serde_yml::from_str(yaml).unwrap();
260 assert_eq!(
261 cfg.upstream.token,
262 Some(ConfigValue::Environment(EnvValue {
263 env: "UC_TOKEN".into()
264 }))
265 );
266 assert_eq!(
267 cfg.auth.to_mode(),
268 AuthMode::ReverseProxy {
269 header: "x-user".into(),
270 allow_missing: true
271 }
272 );
273
274 let reparsed: Config = serde_yml::from_str(&serde_yml::to_string(&cfg).unwrap()).unwrap();
276 assert_eq!(reparsed, cfg);
277 }
278
279 #[test]
280 fn reverse_proxy_defaults_to_standard_header() {
281 let yaml = r#"
282 upstream:
283 base_url: "http://uc/"
284 auth:
285 mode: reverse-proxy
286 "#;
287 let cfg: Config = serde_yml::from_str(yaml).unwrap();
288 assert_eq!(
289 cfg.auth.to_mode(),
290 AuthMode::ReverseProxy {
291 header: DEFAULT_FORWARDED_USER_HEADER.into(),
292 allow_missing: false
293 }
294 );
295 }
296
297 #[test]
298 fn health_url_maps_wildcard_host_to_loopback() {
299 let cfg = Config {
300 host: Some("0.0.0.0".into()),
301 port: Some(9000),
302 base_path: None,
303 upstream: UpstreamConfig {
304 base_url: "http://uc/".into(),
305 token: None,
306 },
307 auth: AuthConfig::default(),
308 };
309 assert_eq!(cfg.health_url(), "http://127.0.0.1:9000/health");
310 }
311}