codex_helper_core/
control_plane_client.rs1use 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}