1use std::{borrow::Cow, sync::Arc};
23
24use anapaya_ead_client::client::{CrpcEndhostApiDiscoveryClient, EndhostApiDiscoveryClient};
25use anapaya_ead_models::{EndhostApiGroup, EndhostApiInfo};
26use snap_control::reexport::TokenSource;
27use url::Url;
28
29pub mod models {
31 pub use anapaya_ead_models::{EndhostApiGroup, EndhostApiInfo};
32}
33
34#[derive(Debug, thiserror::Error)]
39#[error("failed to retrieve endhost APIs: {message}")]
40#[non_exhaustive]
41pub struct EndhostApiSourceError {
42 message: Cow<'static, str>,
43 transient: bool,
44 #[source]
45 source: Option<Box<dyn std::error::Error + Send + Sync>>,
46}
47
48impl EndhostApiSourceError {
49 #[must_use]
53 pub fn new(message: impl Into<Cow<'static, str>>, transient: bool) -> Self {
54 Self {
55 message: message.into(),
56 transient,
57 source: None,
58 }
59 }
60
61 #[must_use]
65 pub fn with_source(
66 message: impl Into<Cow<'static, str>>,
67 transient: bool,
68 source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
69 ) -> Self {
70 Self {
71 message: message.into(),
72 transient,
73 source: Some(source.into()),
74 }
75 }
76
77 #[must_use]
79 pub fn is_transient(&self) -> bool {
80 self.transient
81 }
82}
83
84#[async_trait::async_trait]
89pub trait EndhostApiSource: Send + Sync + 'static {
90 async fn endhost_apis(&self) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError>;
92}
93
94pub struct StaticEndhostApiDiscovery {
97 discovery_apis: Vec<Url>,
98}
99
100impl StaticEndhostApiDiscovery {
101 const GLOBAL_DISCOVERY_APIS: &[&'static str] = &["https://discovery.scion.anapaya.net"];
102
103 #[must_use]
105 pub fn new(discovery_apis: Vec<Url>) -> Self {
106 Self { discovery_apis }
107 }
108
109 #[must_use]
111 pub fn global() -> Self {
112 let discovery_apis = Self::GLOBAL_DISCOVERY_APIS
113 .iter()
114 .map(|url_str| Url::parse(url_str).expect("Invalid URL in GLOBAL_DISCOVERY_APIS"))
115 .collect();
116
117 Self { discovery_apis }
118 }
119}
120
121#[async_trait::async_trait]
122impl EndhostApiSource for StaticEndhostApiDiscovery {
123 async fn endhost_apis(&self) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError> {
125 if self.discovery_apis.is_empty() {
126 return Err(EndhostApiSourceError::new(
127 "no endhost API discovery APIs configured in StaticEndhostApiDiscovery",
128 false,
129 ));
130 }
131
132 discover_endhost_apis(self.discovery_apis.clone(), None).await
133 }
134}
135
136#[derive(Default)]
138pub struct StaticEndhostApis {
139 groups: Vec<EndhostApiGroup>,
141}
142
143impl StaticEndhostApis {
144 #[must_use]
146 pub fn new() -> Self {
147 Self { groups: Vec::new() }
148 }
149
150 #[must_use]
160 pub fn add_group(mut self, group: Vec<Url>) -> Self {
161 self.groups.push(EndhostApiGroup {
162 apis: group
163 .into_iter()
164 .map(|url| EndhostApiInfo { address: url })
165 .collect(),
166 });
167
168 self
169 }
170}
171
172#[async_trait::async_trait]
173impl EndhostApiSource for StaticEndhostApis {
174 async fn endhost_apis(&self) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError> {
176 Ok(self.groups.clone())
177 }
178}
179
180async fn discover_endhost_apis(
186 discovery_apis: Vec<Url>,
187 token_source: Option<Arc<dyn TokenSource>>,
188) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError> {
189 let mut last_error = None;
190 for discovery_api in &discovery_apis {
191 let client = {
193 let mut client = match CrpcEndhostApiDiscoveryClient::new(discovery_api) {
194 Ok(client) => client,
195 Err(e) => {
196 tracing::warn!(%discovery_api, error = ?e, "Failed to create Endhost API discovery client");
197 last_error = Some(EndhostApiSourceError::with_source(
199 format!(
200 "failed to create endhost API discovery client for {discovery_api}"
201 ),
202 false,
203 e,
204 ));
205 continue;
206 }
207 };
208
209 if let Some(token_source) = token_source.clone() {
210 client.use_token_source(token_source);
211 }
212
213 client
214 };
215
216 match client.discover_endhost_apis().await {
217 Ok(discovered_apis) => {
218 tracing::debug!(%discovery_api, "Successfully discovered Endhost APIs");
219 return Ok(discovered_apis);
220 }
221 Err(e) => {
222 tracing::warn!(%discovery_api, error = ?e, "Failed to discover Endhost APIs");
223 last_error = Some(EndhostApiSourceError::with_source(
225 format!("failed to discover endhost APIs via {discovery_api}"),
226 true,
227 e,
228 ));
229 }
230 }
231 }
232
233 match last_error {
236 Some(e) => {
237 let transient = e.is_transient();
238 Err(EndhostApiSourceError::with_source(
239 "failed to discover endhost APIs using any configured discovery API",
240 transient,
241 e,
242 ))
243 }
244 None => {
245 Err(EndhostApiSourceError::new(
246 "attempted to discover endhost APIs with empty list of discovery APIs",
247 false,
248 ))
249 }
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn endhost_api_source_error_transient_classification() {
259 assert!(EndhostApiSourceError::new("boom", true).is_transient());
260 assert!(!EndhostApiSourceError::new("boom", false).is_transient());
261
262 let src = || std::io::Error::other("cause");
263 assert!(EndhostApiSourceError::with_source("boom", true, src()).is_transient());
264 assert!(!EndhostApiSourceError::with_source("boom", false, src()).is_transient());
265 }
266}