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