Skip to main content

xianyun_core/variables/
resolver.rs

1//! Port of `internal/variables/resolver.go`.
2
3use std::collections::HashMap;
4
5use super::predefined::Options;
6
7/// Resolves a variable name to its value. Matches `variables.Lookup` in the
8/// Go original (used by `expand.go`'s `Lookup` function type).
9pub trait Lookup {
10    fn lookup(&self, name: &str) -> Option<String>;
11}
12
13/// Resolves variable names to string values. Immutable after construction and
14/// safe for concurrent use, once the eventual implementation lands (the Go
15/// original documents this contract on the `Resolver` interface).
16pub trait Resolver {
17    /// An `os.Expand`-compatible callback: returns the resolved value for
18    /// `name`, or "" when the name is neither known nor set in the environment.
19    fn expand(&self, name: &str) -> String;
20
21    /// Every predefined variable plus all user-defined static variables. Does
22    /// not include the (unbounded) environment.
23    fn all(&self) -> HashMap<String, String>;
24
25    /// The resolved value for `name` and whether `name` is known.
26    fn lookup(&self, name: &str) -> Option<String>;
27}
28
29/// Adapts a `&dyn Resolver` to [`Lookup`], since both traits share the exact
30/// `lookup(&self, name: &str) -> Option<String>` signature. Needed at the
31/// `config`↔`variables` module boundary: `variables::expand::expand_map` and
32/// `expand_definition` take `&dyn Lookup`, but `config::caproni`'s loader
33/// only has a `Box<dyn Resolver>` to offer them. A thin wrapper struct is
34/// used rather than `impl Lookup for dyn Resolver`, because Rust's `dyn`-to-
35/// `dyn` coercion only applies to supertrait upcasting, not an arbitrary
36/// blanket impl relating two unrelated traits.
37pub struct ResolverLookup<'a>(pub &'a dyn Resolver);
38
39impl Lookup for ResolverLookup<'_> {
40    fn lookup(&self, name: &str) -> Option<String> {
41        self.0.lookup(name)
42    }
43}
44
45struct ResolverImpl {
46    resolved: HashMap<String, String>,
47}
48
49impl Resolver for ResolverImpl {
50    fn expand(&self, name: &str) -> String {
51        match self.resolved.get(name) {
52            Some(v) => v.clone(),
53            None => std::env::var(name).unwrap_or_default(),
54        }
55    }
56
57    fn all(&self) -> HashMap<String, String> {
58        self.resolved.clone()
59    }
60
61    fn lookup(&self, name: &str) -> Option<String> {
62        match self.resolved.get(name) {
63            Some(v) => Some(v.clone()),
64            None => std::env::var(name).ok(),
65        }
66    }
67}
68
69/// Resolves all predefined, tool-version, and static variables up front and
70/// returns an immutable [`Resolver`]. Precedence, highest first: static
71/// variables, predefined variables, tool versions, then (via `expand`) the
72/// process environment.
73pub fn new_resolver(
74    opts: Options,
75    static_vars: Option<HashMap<String, String>>,
76    tool_versions: Option<HashMap<String, String>>,
77) -> Box<dyn Resolver> {
78    use super::predefined::{
79        CAPRONI_CLUSTER_DOMAIN, CAPRONI_CLUSTER_NAME, CAPRONI_PRIMARY_HOSTNAME,
80        XIANYUN_CLUSTER_DOMAIN, XIANYUN_CLUSTER_NAME, XIANYUN_PRIMARY_HOSTNAME, cluster_domain,
81        primary_hostname,
82    };
83
84    let mut resolved = tool_versions.unwrap_or_default();
85
86    // Both the primary XIANYUN_* name and the CAPRONI_* compatibility
87    // alias resolve to the same value, so `${XIANYUN_CLUSTER_NAME}` and
88    // `${CAPRONI_CLUSTER_NAME}` are interchangeable in config expansion.
89    resolved.insert(XIANYUN_CLUSTER_NAME.to_string(), opts.profile_name.clone());
90    resolved.insert(CAPRONI_CLUSTER_NAME.to_string(), opts.profile_name.clone());
91    resolved.insert(
92        XIANYUN_PRIMARY_HOSTNAME.to_string(),
93        primary_hostname(&opts),
94    );
95    resolved.insert(
96        CAPRONI_PRIMARY_HOSTNAME.to_string(),
97        primary_hostname(&opts),
98    );
99    if let Ok(domain) = cluster_domain(&opts) {
100        resolved.insert(XIANYUN_CLUSTER_DOMAIN.to_string(), domain.clone());
101        resolved.insert(CAPRONI_CLUSTER_DOMAIN.to_string(), domain);
102    }
103
104    if let Some(static_vars) = static_vars {
105        resolved.extend(static_vars);
106    }
107
108    Box::new(ResolverImpl { resolved })
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    // Port of TestResolverExpand.
116    #[test]
117    fn resolver_expand_cases() {
118        struct Case {
119            name: &'static str,
120            opts: Options,
121            static_vars: Option<HashMap<String, String>>,
122            env_vars: HashMap<&'static str, &'static str>,
123            var_name: &'static str,
124            want: &'static str,
125        }
126
127        let cases = vec![
128            Case {
129                name: "predefined resolves from options",
130                opts: Options {
131                    profile_name: "prod".into(),
132                    cluster: None,
133                },
134                static_vars: None,
135                env_vars: HashMap::new(),
136                var_name: "CAPRONI_CLUSTER_NAME",
137                want: "prod",
138            },
139            Case {
140                name: "predefined resolves from options via the xianyun primary name",
141                opts: Options {
142                    profile_name: "prod".into(),
143                    cluster: None,
144                },
145                static_vars: None,
146                env_vars: HashMap::new(),
147                var_name: "XIANYUN_CLUSTER_NAME",
148                want: "prod",
149            },
150            Case {
151                name: "static literal resolves",
152                opts: Options::default(),
153                static_vars: Some(HashMap::from([(
154                    "HOSTNAME".to_string(),
155                    "foo.example.com".to_string(),
156                )])),
157                env_vars: HashMap::new(),
158                var_name: "HOSTNAME",
159                want: "foo.example.com",
160            },
161            Case {
162                name: "static shadows predefined",
163                opts: Options {
164                    profile_name: "prod".into(),
165                    cluster: None,
166                },
167                static_vars: Some(HashMap::from([(
168                    "CAPRONI_CLUSTER_NAME".to_string(),
169                    "override".to_string(),
170                )])),
171                env_vars: HashMap::new(),
172                var_name: "CAPRONI_CLUSTER_NAME",
173                want: "override",
174            },
175            Case {
176                name: "unknown falls back to env",
177                opts: Options::default(),
178                static_vars: None,
179                env_vars: HashMap::from([("CUSTOM", "env-val")]),
180                var_name: "CUSTOM",
181                want: "env-val",
182            },
183            Case {
184                name: "unknown not in env is empty",
185                opts: Options::default(),
186                static_vars: None,
187                env_vars: HashMap::new(),
188                var_name: "MISSING",
189                want: "",
190            },
191        ];
192
193        for case in cases {
194            for (k, v) in &case.env_vars {
195                unsafe {
196                    std::env::set_var(k, v);
197                }
198            }
199
200            let r = new_resolver(case.opts, case.static_vars, None);
201            assert_eq!(r.expand(case.var_name), case.want, "{}", case.name);
202        }
203    }
204
205    // Port of TestResolverExpandReadsEnvLive.
206    #[test]
207    fn resolver_expand_reads_env_live() {
208        let r = new_resolver(Options::default(), None, None);
209
210        unsafe {
211            std::env::set_var("LIVE", "first");
212        }
213        assert_eq!(r.expand("LIVE"), "first");
214
215        unsafe {
216            std::env::set_var("LIVE", "second");
217        }
218        assert_eq!(r.expand("LIVE"), "second");
219    }
220
221    // Port of TestResolverExpandNilStaticVars.
222    #[test]
223    fn resolver_expand_nil_static_vars() {
224        let r = new_resolver(
225            Options {
226                profile_name: "prod".into(),
227                cluster: None,
228            },
229            None,
230            None,
231        );
232        assert_eq!(r.expand("CAPRONI_CLUSTER_NAME"), "prod");
233    }
234
235    // Port of TestResolverAll.
236    #[test]
237    fn resolver_all() {
238        let r = new_resolver(
239            Options {
240                profile_name: "my-cluster".into(),
241                cluster: None,
242            },
243            Some(HashMap::from([(
244                "HOSTNAME".to_string(),
245                "foo.example.com".to_string(),
246            )])),
247            None,
248        );
249
250        let all = r.all();
251        assert_eq!(
252            all.get("CAPRONI_CLUSTER_NAME"),
253            Some(&"my-cluster".to_string())
254        );
255        assert_eq!(all.get("HOSTNAME"), Some(&"foo.example.com".to_string()));
256    }
257
258    // Port of TestResolverAllStaticShadowsPredefined.
259    #[test]
260    fn resolver_all_static_shadows_predefined() {
261        let r = new_resolver(
262            Options {
263                profile_name: "prod".into(),
264                cluster: None,
265            },
266            Some(HashMap::from([(
267                "CAPRONI_CLUSTER_NAME".to_string(),
268                "override".to_string(),
269            )])),
270            None,
271        );
272
273        assert_eq!(
274            r.all().get("CAPRONI_CLUSTER_NAME"),
275            Some(&"override".to_string())
276        );
277    }
278
279    // Port of TestResolver_Lookup.
280    #[test]
281    fn resolver_lookup_cases() {
282        unsafe {
283            std::env::set_var("CAPRONI_TEST_ENV", "from-env");
284        }
285
286        let static_vars = HashMap::from([("STATIC".to_string(), "from-static".to_string())]);
287        let r = new_resolver(
288            Options {
289                profile_name: "demo".into(),
290                cluster: None,
291            },
292            Some(static_vars),
293            None,
294        );
295
296        let cases = [
297            ("static variable", "STATIC", Some("from-static")),
298            ("predefined variable", "CAPRONI_CLUSTER_NAME", Some("demo")),
299            ("environment variable", "CAPRONI_TEST_ENV", Some("from-env")),
300            ("unknown variable", "NOPE", None),
301        ];
302
303        for (name, var_name, want) in cases {
304            assert_eq!(r.lookup(var_name), want.map(str::to_string), "{name}");
305        }
306    }
307
308    // Port of TestResolver_ToolVersions.
309    #[test]
310    fn resolver_tool_versions_cases() {
311        {
312            let r = new_resolver(
313                Options::default(),
314                None,
315                Some(HashMap::from([(
316                    "GL_CERT_MANAGER_VERSION".to_string(),
317                    "v1.2.3".to_string(),
318                )])),
319            );
320            assert_eq!(r.expand("GL_CERT_MANAGER_VERSION"), "v1.2.3");
321        }
322
323        {
324            let static_vars = HashMap::from([(
325                "GL_CERT_MANAGER_VERSION".to_string(),
326                "from-static".to_string(),
327            )]);
328            let tool_versions = HashMap::from([(
329                "GL_CERT_MANAGER_VERSION".to_string(),
330                "from-tool".to_string(),
331            )]);
332            let r = new_resolver(Options::default(), Some(static_vars), Some(tool_versions));
333            assert_eq!(r.expand("GL_CERT_MANAGER_VERSION"), "from-static");
334        }
335
336        {
337            unsafe {
338                std::env::set_var("GL_CERT_MANAGER_VERSION", "from-env");
339            }
340            let tool_versions = HashMap::from([(
341                "GL_CERT_MANAGER_VERSION".to_string(),
342                "from-tool".to_string(),
343            )]);
344            let r = new_resolver(Options::default(), None, Some(tool_versions));
345            assert_eq!(r.expand("GL_CERT_MANAGER_VERSION"), "from-tool");
346        }
347
348        {
349            unsafe {
350                std::env::set_var("SOME_OTHER_VAR", "from-env");
351            }
352            let tool_versions =
353                HashMap::from([("GL_CERT_MANAGER_VERSION".to_string(), "v1.2.3".to_string())]);
354            let r = new_resolver(Options::default(), None, Some(tool_versions));
355            assert_eq!(r.expand("SOME_OTHER_VAR"), "from-env");
356        }
357    }
358}