Skip to main content

tatara_engine/kindling_bridge/
client.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use tracing::{debug, warn};
4
5/// HTTP client for the kindling daemon API.
6///
7/// Speaks to kindling's REST API (`/api/v1/*`) to fetch
8/// identity, reports, platform info, nix status, store info, etc.
9pub struct KindlingClient {
10    base_url: String,
11    http: reqwest::Client,
12}
13
14// ── Response types (mirrors kindling's API types) ──
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct NixStatus {
18    pub installed: bool,
19    pub version: Option<String>,
20    pub nix_path: Option<String>,
21    pub install_method: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PlatformInfo {
26    pub os: String,
27    pub arch: String,
28    pub target_triple: String,
29    pub is_wsl: bool,
30    pub has_systemd: bool,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct StoreInfo {
35    pub store_dir: String,
36    pub store_size_bytes: Option<u64>,
37    pub path_count: Option<u64>,
38    pub roots_count: Option<u64>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct NixConfig {
43    pub substituters: Vec<String>,
44    pub trusted_public_keys: Vec<String>,
45    pub max_jobs: Option<String>,
46    pub cores: Option<String>,
47    pub experimental_features: Vec<String>,
48    pub sandbox: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct GcStatus {
53    pub auto_gc_enabled: bool,
54    pub schedule_secs: u64,
55    pub last_gc_at: Option<String>,
56    pub last_gc_freed_bytes: Option<u64>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct GcResult {
61    pub freed_bytes: u64,
62    pub freed_paths: u64,
63    pub duration_secs: f64,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct OptimiseResult {
68    pub deduplicated_bytes: u64,
69    pub duration_secs: f64,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CacheInfo {
74    pub substituter: String,
75    pub reachable: bool,
76    pub latency_ms: Option<u64>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct DaemonHealth {
81    pub version: String,
82    pub uptime_secs: u64,
83    pub platform: PlatformInfo,
84    pub nix: NixStatus,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct NodeIdentity {
89    #[serde(default)]
90    pub version: String,
91    #[serde(default)]
92    pub profile: String,
93    #[serde(default)]
94    pub hostname: String,
95    #[serde(default)]
96    pub hardware: serde_json::Value,
97    #[serde(default)]
98    pub network: serde_json::Value,
99    #[serde(default)]
100    pub fleet: serde_json::Value,
101    #[serde(default)]
102    pub kubernetes: serde_json::Value,
103    #[serde(default)]
104    pub nix: serde_json::Value,
105    #[serde(default)]
106    pub services: serde_json::Value,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct StoredReport {
111    pub checksum: String,
112    pub collected_at: String,
113    pub collector_version: String,
114    pub report: serde_json::Value,
115}
116
117impl KindlingClient {
118    pub fn new(base_url: &str) -> Self {
119        let http = reqwest::Client::builder()
120            .timeout(std::time::Duration::from_secs(10))
121            .build()
122            .expect("Failed to build HTTP client");
123
124        Self {
125            base_url: base_url.trim_end_matches('/').to_string(),
126            http,
127        }
128    }
129
130    /// Check if kindling daemon is reachable.
131    pub async fn is_healthy(&self) -> bool {
132        match self.http.get(self.url("/health")).send().await {
133            Ok(resp) => resp.status().is_success(),
134            Err(_) => false,
135        }
136    }
137
138    // ── Status & Health ──
139
140    pub async fn health(&self) -> Result<DaemonHealth> {
141        self.get("/health").await
142    }
143
144    pub async fn nix_status(&self) -> Result<NixStatus> {
145        self.get("/api/v1/status").await
146    }
147
148    pub async fn platform(&self) -> Result<PlatformInfo> {
149        self.get("/api/v1/platform").await
150    }
151
152    // ── Store & Config ──
153
154    pub async fn store_info(&self) -> Result<StoreInfo> {
155        self.get("/api/v1/store").await
156    }
157
158    pub async fn nix_config(&self) -> Result<NixConfig> {
159        self.get("/api/v1/config").await
160    }
161
162    pub async fn caches(&self) -> Result<Vec<CacheInfo>> {
163        self.get("/api/v1/caches").await
164    }
165
166    // ── Garbage Collection ──
167
168    pub async fn gc_status(&self) -> Result<GcStatus> {
169        self.get("/api/v1/gc").await
170    }
171
172    pub async fn run_gc(&self) -> Result<GcResult> {
173        self.post("/api/v1/gc/run").await
174    }
175
176    // ── Store Optimization ──
177
178    pub async fn optimise_store(&self) -> Result<OptimiseResult> {
179        self.post("/api/v1/store/optimise").await
180    }
181
182    // ── Identity & Reports ──
183
184    pub async fn identity(&self) -> Result<Option<NodeIdentity>> {
185        match self.http.get(self.url("/api/v1/identity")).send().await {
186            Ok(resp) if resp.status().as_u16() == 404 => Ok(None),
187            Ok(resp) if resp.status().is_success() => {
188                let identity = resp.json().await.context("Failed to parse identity")?;
189                Ok(Some(identity))
190            }
191            Ok(resp) => {
192                anyhow::bail!("Kindling identity request failed: {}", resp.status())
193            }
194            Err(e) => Err(e).context("Failed to reach kindling daemon"),
195        }
196    }
197
198    pub async fn report(&self) -> Result<Option<StoredReport>> {
199        match self.http.get(self.url("/api/v1/report")).send().await {
200            Ok(resp) if resp.status().as_u16() == 503 => Ok(None),
201            Ok(resp) if resp.status().is_success() => {
202                let report = resp.json().await.context("Failed to parse report")?;
203                Ok(Some(report))
204            }
205            Ok(resp) => {
206                anyhow::bail!("Kindling report request failed: {}", resp.status())
207            }
208            Err(e) => Err(e).context("Failed to reach kindling daemon"),
209        }
210    }
211
212    pub async fn refresh_report(&self) -> Result<StoredReport> {
213        self.post("/api/v1/report/refresh").await
214    }
215
216    // ── Internal helpers ──
217
218    fn url(&self, path: &str) -> String {
219        format!("{}{}", self.base_url, path)
220    }
221
222    async fn get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
223        let url = self.url(path);
224        debug!(url = %url, "Kindling API GET");
225
226        let resp = self
227            .http
228            .get(&url)
229            .send()
230            .await
231            .with_context(|| format!("Failed to reach kindling daemon at {}", url))?;
232
233        if !resp.status().is_success() {
234            anyhow::bail!("Kindling API {} returned {}", path, resp.status());
235        }
236
237        resp.json()
238            .await
239            .with_context(|| format!("Failed to parse response from {}", path))
240    }
241
242    async fn post<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
243        let url = self.url(path);
244        debug!(url = %url, "Kindling API POST");
245
246        let resp = self
247            .http
248            .post(&url)
249            .send()
250            .await
251            .with_context(|| format!("Failed to reach kindling daemon at {}", url))?;
252
253        if !resp.status().is_success() {
254            anyhow::bail!("Kindling API {} returned {}", path, resp.status());
255        }
256
257        resp.json()
258            .await
259            .with_context(|| format!("Failed to parse response from {}", path))
260    }
261}
262
263/// Attempt to connect to kindling and log what we find.
264/// Non-fatal — tatara works fine without kindling.
265pub async fn probe_kindling(addr: &str) -> Option<KindlingClient> {
266    let client = KindlingClient::new(addr);
267
268    if !client.is_healthy().await {
269        warn!(
270            addr = addr,
271            "Kindling daemon not reachable — running without it"
272        );
273        return None;
274    }
275
276    match client.health().await {
277        Ok(health) => {
278            tracing::info!(
279                version = %health.version,
280                uptime = health.uptime_secs,
281                nix_installed = health.nix.installed,
282                nix_version = ?health.nix.version,
283                platform = %health.platform.os,
284                arch = %health.platform.arch,
285                "Connected to kindling daemon"
286            );
287            Some(client)
288        }
289        Err(e) => {
290            warn!(error = %e, "Kindling daemon responded but health check failed");
291            None
292        }
293    }
294}