tatara_engine/kindling_bridge/
client.rs1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use tracing::{debug, warn};
4
5pub struct KindlingClient {
10 base_url: String,
11 http: reqwest::Client,
12}
13
14#[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 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 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 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 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 pub async fn optimise_store(&self) -> Result<OptimiseResult> {
179 self.post("/api/v1/store/optimise").await
180 }
181
182 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 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
263pub 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}