Skip to main content

host_identity/
resolver.rs

1//! The [`Resolver`] — a configurable chain of [`Source`]s.
2
3use crate::error::Error;
4use crate::hostid::{HostId, ResolveOutcome};
5use crate::source::{Probe, Source, SourceKind};
6use crate::sources;
7use crate::wrap::Wrap;
8
9const EMPTY_RAW_REASON: &str = "raw identifier is empty";
10
11/// A composable chain of identity sources.
12///
13/// Use [`Resolver::with_defaults`] for the platform-appropriate default
14/// chain, or [`Resolver::new`] to start empty and build your own order with
15/// [`Resolver::push`] / [`Resolver::prepend`].
16pub struct Resolver {
17    sources: Vec<Box<dyn Source>>,
18    wrap: Wrap,
19}
20
21impl Resolver {
22    /// Start with an empty chain. No sources are tried until you add some.
23    #[must_use]
24    pub fn new() -> Self {
25        Self {
26            sources: Vec::new(),
27            wrap: Wrap::default(),
28        }
29    }
30
31    /// Start with the default chain for the current platform.
32    ///
33    /// The chain begins with the `HOST_IDENTITY` environment variable
34    /// override, then — on Linux, when the `container` feature is on —
35    /// inserts the container source ahead of the host-level sources so
36    /// containers get their own identity, then walks the platform's native
37    /// sources in recommended order. See [`sources::default_chain`] for the
38    /// exact contents on each OS.
39    ///
40    /// This chain is strictly local: no source makes network calls.
41    #[must_use]
42    pub fn with_defaults() -> Self {
43        Self {
44            sources: sources::default_chain(),
45            wrap: Wrap::default(),
46        }
47    }
48
49    /// Default chain plus every cloud-metadata and Kubernetes source the
50    /// consumer's feature set enabled.
51    ///
52    /// Requires a caller-supplied [`crate::transport::HttpTransport`]; the
53    /// crate ships no HTTP client. The transport must be `Clone + 'static`
54    /// because each cloud source owns its own handle — wrap a non-cloneable
55    /// client in `Arc` if necessary.
56    ///
57    /// Source order (each step is only present when its feature is on):
58    ///
59    /// 1. `HOST_IDENTITY` env override.
60    /// 2. Kubernetes pod UID (feature `k8s`; returns `Ok(None)` off Linux).
61    /// 3. Container ID from `/proc/self/mountinfo` (feature `container`;
62    ///    Linux only).
63    /// 4. Cloud-metadata sources for every enabled cloud feature, in the
64    ///    declaration order: `aws`, `gcp`, `azure`, `digitalocean`,
65    ///    `hetzner`, `oci`. Each returns `Ok(None)` when its endpoint is
66    ///    unreachable so the chain falls through to the next.
67    /// 5. Platform-native local sources (machine-id, DMI, registry, …).
68    /// 6. Kubernetes service-account namespace (feature `k8s`) as a coarse
69    ///    last-ditch fallback below every per-host source.
70    ///
71    /// The ordering keeps per-pod identity above per-container above
72    /// per-instance above per-host software state.
73    #[cfg(feature = "_transport")]
74    #[must_use]
75    pub fn with_network_defaults<T>(transport: T) -> Self
76    where
77        T: crate::transport::HttpTransport + Clone + 'static,
78    {
79        Self {
80            sources: sources::network_default_chain(transport),
81            wrap: Wrap::default(),
82        }
83    }
84
85    /// Append a source to the end of the chain (lowest priority).
86    #[must_use]
87    pub fn push<S: Source + 'static>(mut self, source: S) -> Self {
88        self.sources.push(Box::new(source));
89        self
90    }
91
92    /// Append an already-boxed source. Use when you have `Box<dyn Source>`
93    /// already — for example when building a chain from runtime input via
94    /// [`crate::ids::resolver_from_ids`].
95    #[must_use]
96    pub fn push_boxed(mut self, source: Box<dyn Source>) -> Self {
97        self.sources.push(source);
98        self
99    }
100
101    /// Prepend a source to the front of the chain (highest priority).
102    ///
103    /// O(n) in the existing chain length — each call shifts every other
104    /// source. For a chain assembled from many prepends, build the full
105    /// list first and pass it to [`Resolver::with_sources`] instead.
106    #[must_use]
107    pub fn prepend<S: Source + 'static>(mut self, source: S) -> Self {
108        self.sources.insert(0, Box::new(source));
109        self
110    }
111
112    /// Replace the entire chain. All items must be the same concrete
113    /// `Source` type; for heterogeneous chains use
114    /// [`Resolver::with_boxed_sources`].
115    #[must_use]
116    pub fn with_sources<I, S>(self, sources: I) -> Self
117    where
118        I: IntoIterator<Item = S>,
119        S: Source + 'static,
120    {
121        self.with_boxed_sources(sources.into_iter().map(|s| Box::new(s) as Box<dyn Source>))
122    }
123
124    /// Drain the chain, returning its boxed sources in chain order.
125    ///
126    /// The wrap strategy is discarded — the caller reapplies one via
127    /// [`Resolver::with_wrap`] when rebuilding. Use when you need to
128    /// post-process the chain (for example, wrapping every source with
129    /// [`crate::sources::AppSpecific`]) and feed the sources back via
130    /// [`Resolver::with_boxed_sources`].
131    #[must_use]
132    pub fn into_boxed_sources(self) -> Vec<Box<dyn Source>> {
133        self.sources
134    }
135
136    /// Replace the entire chain with an already-boxed, heterogeneous
137    /// list. Use when you have sources of different concrete types —
138    /// `with_sources` requires a single concrete type for all items, so
139    /// a mixed chain has to be boxed first.
140    ///
141    /// ```
142    /// use host_identity::{Resolver, Source};
143    /// use host_identity::sources::{EnvOverride, FnSource};
144    /// # use host_identity::SourceKind;
145    ///
146    /// let chain: Vec<Box<dyn Source>> = vec![
147    ///     Box::new(EnvOverride::new("HOST_IDENTITY")),
148    ///     Box::new(FnSource::new(SourceKind::custom("x"), || Ok(None))),
149    /// ];
150    /// let resolver = Resolver::new().with_boxed_sources(chain);
151    /// # let _ = resolver;
152    /// ```
153    #[must_use]
154    pub fn with_boxed_sources<I>(mut self, sources: I) -> Self
155    where
156        I: IntoIterator<Item = Box<dyn Source>>,
157    {
158        self.sources = sources.into_iter().collect();
159        self
160    }
161
162    /// Set the UUID-wrapping strategy applied to the raw identifier.
163    ///
164    /// Defaults to [`Wrap::UuidV5Namespaced`].
165    #[must_use]
166    pub fn with_wrap(mut self, wrap: Wrap) -> Self {
167        self.wrap = wrap;
168        self
169    }
170
171    /// Inspect the configured chain — useful for tests, diagnostics, and
172    /// logging the resolver shape at startup.
173    #[must_use]
174    pub fn source_kinds(&self) -> Vec<SourceKind> {
175        self.source_kinds_iter().collect()
176    }
177
178    /// Non-allocating view of the chain's source kinds, in order.
179    ///
180    /// Use when you want to iterate without materialising a `Vec` —
181    /// e.g. constructing a log line, or checking whether a specific
182    /// kind is present. The returned iterator borrows `self` and must
183    /// not outlive the resolver.
184    #[allow(
185        clippy::redundant_closure_for_method_calls,
186        reason = "the suggested `Source::kind` reference requires explicit deref through `Box<dyn Source>` and reads worse than the closure"
187    )]
188    pub fn source_kinds_iter(&self) -> impl Iterator<Item = SourceKind> + '_ {
189        self.sources.iter().map(|s| s.kind())
190    }
191
192    /// Walk the chain and return the first successful identity.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`Error::NoSource`] if every source returned `Ok(None)`,
197    /// or [`Error::Malformed`] if the selected [`Wrap`] is
198    /// [`Wrap::Passthrough`] and the raw value is not a valid UUID. Other
199    /// [`Error`] variants bubble up from the source that produced them
200    /// (permission denied, sentinel value, platform-tool failure).
201    pub fn resolve(&self) -> Result<HostId, Error> {
202        for source in &self.sources {
203            if let Some(probe) = source.probe()? {
204                return self.probe_to_host_id(probe, detected_container());
205            }
206        }
207        let tried = self
208            .source_kinds_iter()
209            .map(SourceKind::as_str)
210            .collect::<Vec<_>>()
211            .join(",");
212        Err(Error::NoSource { tried })
213    }
214
215    /// Walk the entire chain without short-circuiting and return one
216    /// [`ResolveOutcome`] per source.
217    ///
218    /// Complements [`Resolver::resolve`]: the chain, wrap strategy, and
219    /// container-detection logic are identical — only the stopping
220    /// behaviour differs. Every source is consulted exactly once, in
221    /// chain order, and neither a success nor an error stops the walk.
222    ///
223    /// Use this to audit what each source would produce — operator
224    /// diagnostics, debugging, or test harnesses that want to confirm
225    /// that several sources agree. For normal resolution use
226    /// [`Resolver::resolve`], which stops at the first usable source.
227    ///
228    /// To run a caller-chosen subset of sources, build the resolver with
229    /// exactly those sources — the same builder that feeds `resolve()`:
230    ///
231    /// ```no_run
232    /// use host_identity::Resolver;
233    /// use host_identity::sources::{MachineIdFile, DmiProductUuid};
234    ///
235    /// let report = Resolver::new()
236    ///     .push(MachineIdFile::default())
237    ///     .push(DmiProductUuid::default())
238    ///     .resolve_all();
239    /// for outcome in report {
240    ///     println!("{:?} → {:?}", outcome.source(), outcome.host_id());
241    /// }
242    /// ```
243    #[must_use]
244    pub fn resolve_all(&self) -> Vec<ResolveOutcome> {
245        let in_container = detected_container();
246        self.sources
247            .iter()
248            .map(|source| {
249                let kind = source.kind();
250                match source.probe() {
251                    Ok(Some(probe)) => self.outcome_from_probe(kind, probe, in_container),
252                    Ok(None) => ResolveOutcome::Skipped(kind),
253                    Err(err) => ResolveOutcome::Errored(kind, err),
254                }
255            })
256            .collect()
257    }
258
259    fn outcome_from_probe(
260        &self,
261        source_kind: SourceKind,
262        probe: Probe,
263        in_container: bool,
264    ) -> ResolveOutcome {
265        debug_assert_eq!(
266            source_kind,
267            probe.kind(),
268            "source {source_kind:?} returned probe with kind {:?}",
269            probe.kind(),
270        );
271        match self.probe_to_host_id(probe, in_container) {
272            Ok(id) => ResolveOutcome::Found(id),
273            Err(err) => ResolveOutcome::Errored(source_kind, err),
274        }
275    }
276
277    fn probe_to_host_id(&self, probe: Probe, in_container: bool) -> Result<HostId, Error> {
278        let (kind, raw) = probe.into_parts();
279        if raw.trim().is_empty() {
280            return Err(malformed_empty(kind));
281        }
282        self.wrap
283            .apply(&raw)
284            .map(|uuid| HostId::new(uuid, kind, in_container))
285            .ok_or_else(|| malformed_invalid_uuid(kind, &raw))
286    }
287}
288
289fn malformed_empty(source_kind: SourceKind) -> Error {
290    Error::Malformed {
291        source_kind,
292        reason: EMPTY_RAW_REASON.to_owned(),
293    }
294}
295
296fn malformed_invalid_uuid(source_kind: SourceKind, raw: &str) -> Error {
297    Error::Malformed {
298        source_kind,
299        reason: format!("value is not a valid UUID: {raw}"),
300    }
301}
302
303impl Default for Resolver {
304    fn default() -> Self {
305        Self::with_defaults()
306    }
307}
308
309impl std::fmt::Debug for Resolver {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        f.debug_struct("Resolver")
312            .field("sources", &self.source_kinds())
313            .field("wrap", &self.wrap)
314            .finish()
315    }
316}
317
318#[cfg(target_os = "linux")]
319fn detected_container() -> bool {
320    sources::linux_in_container()
321}
322
323#[cfg(not(target_os = "linux"))]
324fn detected_container() -> bool {
325    false
326}