Skip to main content

scion_stack/
ea_source.rs

1// Copyright 2026 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Endhost API Source.
16//!
17//! This module defines the `EndhostApiSource` trait, which is responsible for discovering and
18//! providing access to the Endhost API. Implementations of this trait can be used to discover the
19//! API in different environments, such as through environment variables, configuration files, or
20//! network discovery.
21
22use 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
29/// Explicit re-exports of the Endhost API discovery models used by this module's public API.
30pub mod models {
31    pub use anapaya_ead_models::{EndhostApiGroup, EndhostApiInfo};
32}
33
34/// Error type for failures to retrieve Endhost APIs from an [`EndhostApiSource`].
35///
36/// The underlying cause (if any) is available through [`std::error::Error::source`]; the concrete
37/// source type is intentionally not exposed, so it can change without breaking the public API.
38#[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    /// Creates an error with a human-readable message.
50    ///
51    /// `transient` indicates whether the operation may succeed on retry.
52    #[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    /// Creates an error wrapping an underlying `source`.
62    ///
63    /// `transient` indicates whether the operation may succeed on retry.
64    #[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    /// Returns whether the error is transient and the operation may be retried.
78    #[must_use]
79    pub fn is_transient(&self) -> bool {
80        self.transient
81    }
82}
83
84/// Returns available Endhost APIs for the client to use
85///
86/// Endhost APIs are grouped into `EndhostApiGroup`s.
87/// The client should attempt to use the first Group
88#[async_trait::async_trait]
89pub trait EndhostApiSource: Send + Sync + 'static {
90    /// Returns the available Endhost APIs.
91    async fn endhost_apis(&self) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError>;
92}
93
94/// A static list of Endhost API discovery services which the stack can use to discover Endhost
95/// APIs.
96pub struct StaticEndhostApiDiscovery {
97    discovery_apis: Vec<Url>,
98    http_client: Option<reqwest::Client>,
99}
100
101impl StaticEndhostApiDiscovery {
102    const GLOBAL_DISCOVERY_APIS: &[&'static str] = &["https://discovery.scion.anapaya.net"];
103
104    /// Creates a new `StaticEndhostApiDiscovery` with the given list of discovery API URLs.
105    #[must_use]
106    pub fn new(discovery_apis: Vec<Url>) -> Self {
107        Self {
108            discovery_apis,
109            http_client: None,
110        }
111    }
112
113    /// Creates a new `StaticEndhostApiDiscovery` with the global list of discovery API URLs.
114    #[must_use]
115    pub fn global() -> Self {
116        let discovery_apis = Self::GLOBAL_DISCOVERY_APIS
117            .iter()
118            .map(|url_str| Url::parse(url_str).expect("Invalid URL in GLOBAL_DISCOVERY_APIS"))
119            .collect();
120
121        Self::new(discovery_apis)
122    }
123
124    /// Sends the discovery requests through the given `http_client` instead of creating a new one.
125    #[must_use]
126    pub fn with_http_client(mut self, http_client: reqwest::Client) -> Self {
127        self.http_client = Some(http_client);
128        self
129    }
130}
131
132#[async_trait::async_trait]
133impl EndhostApiSource for StaticEndhostApiDiscovery {
134    /// Returns the available Endhost APIs.
135    async fn endhost_apis(&self) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError> {
136        if self.discovery_apis.is_empty() {
137            return Err(EndhostApiSourceError::new(
138                "no endhost API discovery APIs configured in StaticEndhostApiDiscovery",
139                false,
140            ));
141        }
142
143        discover_endhost_apis(&self.discovery_apis, self.http_client.as_ref(), None).await
144    }
145}
146
147/// A static list of Endhost APIs which the stack can use.
148#[derive(Default)]
149pub struct StaticEndhostApis {
150    /// List of Endhost API groups to use
151    groups: Vec<EndhostApiGroup>,
152}
153
154impl StaticEndhostApis {
155    /// Creates a new empty `StaticEndhostApis`
156    #[must_use]
157    pub fn new() -> Self {
158        Self { groups: Vec::new() }
159    }
160
161    /// Adds a group of Endhost APIs to the list of available APIs.
162    ///
163    /// Endhost APIs in one group must offer the same data when queried. Meaning they should know
164    /// the same set of underlays and segments.
165    ///
166    /// The client can freely failover between APIs in the same group.
167    ///
168    /// Endhost APIs in different groups can differ in the data they offer, however the client must
169    /// close all open connections to failover between groups.
170    #[must_use]
171    pub fn add_group(mut self, group: Vec<Url>) -> Self {
172        self.groups.push(EndhostApiGroup {
173            apis: group
174                .into_iter()
175                .map(|url| EndhostApiInfo { address: url })
176                .collect(),
177        });
178
179        self
180    }
181}
182
183#[async_trait::async_trait]
184impl EndhostApiSource for StaticEndhostApis {
185    /// Returns the available Endhost APIs.
186    async fn endhost_apis(&self) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError> {
187        Ok(self.groups.clone())
188    }
189}
190
191/// Attempts to discover Endhost APIs using all provided discovery API URLs, returning the first
192/// successful result or an error if all discovery APIs fail.
193///
194/// On failure, returns the last error encountered or a generic error if no discovery APIs were
195/// provided.
196async fn discover_endhost_apis(
197    discovery_apis: &[Url],
198    http_client: Option<&reqwest::Client>,
199    token_source: Option<Arc<dyn TokenSource>>,
200) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError> {
201    let mut last_error = None;
202    for discovery_api in discovery_apis {
203        // Try all apis in order, return the first successful one
204        let client = {
205            let client = match http_client {
206                Some(http_client) => {
207                    CrpcEndhostApiDiscoveryClient::new_with_client(
208                        discovery_api,
209                        http_client.clone(),
210                    )
211                }
212                None => CrpcEndhostApiDiscoveryClient::new(discovery_api),
213            };
214            let mut client = match client {
215                Ok(client) => client,
216                Err(e) => {
217                    tracing::warn!(%discovery_api, error = ?e, "Failed to create Endhost API discovery client");
218                    // Track last error so we can return it if all discovery APIs fail
219                    last_error = Some(EndhostApiSourceError::with_source(
220                        format!(
221                            "failed to create endhost API discovery client for {discovery_api}"
222                        ),
223                        false,
224                        e,
225                    ));
226                    continue;
227                }
228            };
229
230            if let Some(token_source) = token_source.clone() {
231                client.use_token_source(token_source);
232            }
233
234            client
235        };
236
237        match client.discover_endhost_apis().await {
238            Ok(discovered_apis) => {
239                tracing::debug!(%discovery_api, "Successfully discovered Endhost APIs");
240                return Ok(discovered_apis);
241            }
242            Err(e) => {
243                tracing::warn!(%discovery_api, error = ?e, "Failed to discover Endhost APIs");
244                // Track last error so we can return it if all discovery APIs fail
245                last_error = Some(EndhostApiSourceError::with_source(
246                    format!("failed to discover endhost APIs via {discovery_api}"),
247                    true,
248                    e,
249                ));
250            }
251        }
252    }
253
254    // If we exhausted all discovery APIs, return the last error we encountered or a generic
255    // error if we had no discovery APIs configured
256    match last_error {
257        Some(e) => {
258            let transient = e.is_transient();
259            Err(EndhostApiSourceError::with_source(
260                "failed to discover endhost APIs using any configured discovery API",
261                transient,
262                e,
263            ))
264        }
265        None => {
266            Err(EndhostApiSourceError::new(
267                "attempted to discover endhost APIs with empty list of discovery APIs",
268                false,
269            ))
270        }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn endhost_api_source_error_transient_classification() {
280        assert!(EndhostApiSourceError::new("boom", true).is_transient());
281        assert!(!EndhostApiSourceError::new("boom", false).is_transient());
282
283        let src = || std::io::Error::other("cause");
284        assert!(EndhostApiSourceError::with_source("boom", true, src()).is_transient());
285        assert!(!EndhostApiSourceError::with_source("boom", false, src()).is_transient());
286    }
287}