Skip to main content

codex_helper_core/
control_plane_client.rs

1use std::time::Duration;
2
3use anyhow::{Context, Result, anyhow};
4use reqwest::{Client, Url};
5use serde::Deserialize;
6use thiserror::Error;
7
8use crate::dashboard_core::{
9    ApiV1Capabilities, ApiV1OperatorSummary, ApiV1Snapshot, ProviderOption,
10};
11use crate::fleet::FleetSnapshot;
12use crate::proxy::{ADMIN_TOKEN_ENV_VAR, ADMIN_TOKEN_HEADER, RuntimeStatusResponse};
13
14const ADMIN_DISCOVERY_PATH: &str = "/.well-known/codex-helper-admin";
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ControlPlaneEndpoint {
18    pub admin_base_url: String,
19    pub admin_token_env: Option<String>,
20}
21
22#[derive(Debug, Clone)]
23pub struct ControlPlaneClient {
24    endpoint: ControlPlaneEndpoint,
25    client: Client,
26}
27
28#[derive(Debug, Error)]
29pub enum ControlPlaneError {
30    #[error("admin API not reachable at {base_url}: {source}")]
31    Transport {
32        base_url: String,
33        #[source]
34        source: reqwest::Error,
35    },
36    #[error("admin API returned {status}: {body}")]
37    HttpStatus { status: u16, body: String },
38    #[error("admin API response is not valid JSON: {source}")]
39    Decode {
40        #[source]
41        source: reqwest::Error,
42    },
43}
44
45#[derive(Debug, Clone, Deserialize)]
46pub struct AdminDiscoveryDocument {
47    pub api_version: u32,
48    pub service_name: String,
49    pub admin_base_url: String,
50}
51
52impl ControlPlaneEndpoint {
53    pub fn new(
54        admin_base_url: impl Into<String>,
55        admin_token_env: Option<impl Into<String>>,
56    ) -> Result<Self> {
57        let admin_base_url = normalize_base_url(&admin_base_url.into())
58            .ok_or_else(|| anyhow!("admin base URL is required"))?;
59        Ok(Self {
60            admin_base_url,
61            admin_token_env: admin_token_env.map(Into::into),
62        })
63    }
64}
65
66impl ControlPlaneClient {
67    pub fn new(endpoint: ControlPlaneEndpoint) -> Result<Self> {
68        let client = Client::builder()
69            .timeout(Duration::from_millis(1200))
70            .build()
71            .context("failed to build control-plane client")?;
72        Ok(Self { endpoint, client })
73    }
74
75    pub fn endpoint(&self) -> &ControlPlaneEndpoint {
76        &self.endpoint
77    }
78
79    pub async fn discover_admin_base_url(proxy_base_url: &str) -> Result<AdminDiscoveryDocument> {
80        let proxy_base_url = normalize_base_url(proxy_base_url)
81            .ok_or_else(|| anyhow!("proxy base URL is required"))?;
82        let url = format!("{proxy_base_url}{ADMIN_DISCOVERY_PATH}");
83        let response = Client::builder()
84            .timeout(Duration::from_millis(1200))
85            .build()
86            .context("failed to build discovery client")?
87            .get(url)
88            .send()
89            .await
90            .context("admin discovery request failed")?;
91        let status = response.status();
92        if !status.is_success() {
93            let body = response.text().await.unwrap_or_default();
94            anyhow::bail!("admin discovery returned {status}: {}", body.trim());
95        }
96        response
97            .json::<AdminDiscoveryDocument>()
98            .await
99            .context("admin discovery response is not valid JSON")
100    }
101
102    pub async fn fetch_json<T>(&self, path: &str) -> Result<T>
103    where
104        T: serde::de::DeserializeOwned,
105    {
106        self.fetch_json_classified(path).await.map_err(Into::into)
107    }
108
109    pub async fn fetch_json_classified<T>(&self, path: &str) -> Result<T, ControlPlaneError>
110    where
111        T: serde::de::DeserializeOwned,
112    {
113        let url = format!("{}{}", self.endpoint.admin_base_url, path);
114        let mut request = self.client.get(url);
115        if let Some(token) = self.admin_token() {
116            request = request.header(ADMIN_TOKEN_HEADER, token);
117        }
118
119        let response = request
120            .send()
121            .await
122            .map_err(|source| ControlPlaneError::Transport {
123                base_url: self.endpoint.admin_base_url.clone(),
124                source,
125            })?;
126        let status = response.status();
127        if !status.is_success() {
128            let body = response.text().await.unwrap_or_default();
129            return Err(ControlPlaneError::HttpStatus {
130                status: status.as_u16(),
131                body: body.trim().to_string(),
132            });
133        }
134        response
135            .json::<T>()
136            .await
137            .map_err(|source| ControlPlaneError::Decode { source })
138    }
139
140    pub async fn runtime_status(&self) -> Result<RuntimeStatusResponse> {
141        self.fetch_json("/__codex_helper/api/v1/runtime/status")
142            .await
143    }
144
145    pub async fn capabilities(&self) -> Result<ApiV1Capabilities> {
146        self.fetch_json("/__codex_helper/api/v1/capabilities").await
147    }
148
149    pub async fn operator_summary(&self) -> Result<ApiV1OperatorSummary> {
150        self.fetch_json("/__codex_helper/api/v1/operator/summary")
151            .await
152    }
153
154    pub async fn snapshot(&self, recent_limit: usize, stats_days: usize) -> Result<ApiV1Snapshot> {
155        self.fetch_json(&format!(
156            "/__codex_helper/api/v1/snapshot?recent_limit={recent_limit}&stats_days={stats_days}"
157        ))
158        .await
159    }
160
161    pub async fn fleet_snapshot(&self) -> Result<FleetSnapshot, ControlPlaneError> {
162        self.fetch_json_classified("/__codex_helper/api/v1/fleet/snapshot")
163            .await
164    }
165
166    pub async fn providers(&self) -> Result<Vec<ProviderOption>> {
167        self.fetch_json("/__codex_helper/api/v1/providers").await
168    }
169
170    fn admin_token(&self) -> Option<String> {
171        let env_name = self
172            .endpoint
173            .admin_token_env
174            .as_deref()
175            .unwrap_or(ADMIN_TOKEN_ENV_VAR);
176        std::env::var(env_name)
177            .ok()
178            .map(|value| value.trim().to_string())
179            .filter(|value| !value.is_empty())
180    }
181}
182
183pub fn normalize_base_url(value: &str) -> Option<String> {
184    let value = value.trim().trim_end_matches('/');
185    if value.is_empty() {
186        return None;
187    }
188    let url = Url::parse(value).ok()?;
189    match url.scheme() {
190        "http" | "https" => Some(value.to_string()),
191        _ => None,
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn control_plane_endpoint_normalizes_admin_base_url() {
201        let endpoint = ControlPlaneEndpoint::new(" http://nas.local:4211/ ", Some("TOKEN_ENV"))
202            .expect("endpoint");
203
204        assert_eq!(endpoint.admin_base_url, "http://nas.local:4211");
205        assert_eq!(endpoint.admin_token_env.as_deref(), Some("TOKEN_ENV"));
206    }
207
208    #[test]
209    fn control_plane_endpoint_rejects_non_http_url() {
210        let err = ControlPlaneEndpoint::new("file:///tmp/socket", None::<String>)
211            .expect_err("non-http admin url should fail");
212
213        assert!(err.to_string().contains("admin base URL"));
214    }
215}