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}
99
100impl StaticEndhostApiDiscovery {
101    const GLOBAL_DISCOVERY_APIS: &[&'static str] = &["https://discovery.scion.anapaya.net"];
102
103    /// Creates a new `StaticEndhostApiDiscovery` with the given list of discovery API URLs.
104    #[must_use]
105    pub fn new(discovery_apis: Vec<Url>) -> Self {
106        Self { discovery_apis }
107    }
108
109    /// Creates a new `StaticEndhostApiDiscovery` with the global list of discovery API URLs.
110    #[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    /// Returns the available Endhost APIs.
124    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/// A static list of Endhost APIs which the stack can use.
137#[derive(Default)]
138pub struct StaticEndhostApis {
139    /// List of Endhost API groups to use
140    groups: Vec<EndhostApiGroup>,
141}
142
143impl StaticEndhostApis {
144    /// Creates a new empty `StaticEndhostApis`
145    #[must_use]
146    pub fn new() -> Self {
147        Self { groups: Vec::new() }
148    }
149
150    /// Adds a group of Endhost APIs to the list of available APIs.
151    ///
152    /// Endhost APIs in one group must offer the same data when queried. Meaning they should know
153    /// the same set of underlays and segments.
154    ///
155    /// The client can freely failover between APIs in the same group.
156    ///
157    /// Endhost APIs in different groups can differ in the data they offer, however the client must
158    /// close all open connections to failover between groups.
159    #[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    /// Returns the available Endhost APIs.
175    async fn endhost_apis(&self) -> Result<Vec<EndhostApiGroup>, EndhostApiSourceError> {
176        Ok(self.groups.clone())
177    }
178}
179
180/// Attempts to discover Endhost APIs using all provided discovery API URLs, returning the first
181/// successful result or an error if all discovery APIs fail.
182///
183/// On failure, returns the last error encountered or a generic error if no discovery APIs were
184/// provided.
185async 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        // Try all apis in order, return the first successful one
192        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                    // Track last error so we can return it if all discovery APIs fail
198                    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                // Track last error so we can return it if all discovery APIs fail
224                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    // If we exhausted all discovery APIs, return the last error we encountered or a generic
234    // error if we had no discovery APIs configured
235    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}