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}