Skip to main content

tatara_engine/nix_eval/
sui_client.rs

1//! Sui daemon client — uses sui's REST API for builds and evaluation
2//! instead of shelling out to the `nix` CLI.
3//!
4//! When `sui_daemon_addr` is configured, tatara uses sui-daemon for:
5//! - `nix eval` → `POST /api/v1/eval`
6//! - `nix build` → `POST /api/v1/build`
7//! - Cache push → `POST /api/v1/cache/push`
8
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11use tracing::{debug, info};
12
13/// Client for the sui daemon REST API.
14pub struct SuiClient {
15    client: reqwest::Client,
16    base_url: String,
17}
18
19#[derive(Debug, Serialize)]
20struct BuildRequest {
21    flake_ref: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    system: Option<String>,
24    #[serde(skip_serializing_if = "Vec::is_empty")]
25    extra_args: Vec<String>,
26}
27
28#[derive(Debug, Deserialize)]
29struct BuildResponse {
30    store_path: String,
31    #[serde(default)]
32    build_time_ms: u64,
33}
34
35#[derive(Debug, Serialize)]
36struct EvalRequest {
37    expression: String,
38}
39
40#[derive(Debug, Deserialize)]
41struct EvalResponse {
42    result: serde_json::Value,
43}
44
45#[derive(Debug, Serialize)]
46struct CachePushRequest {
47    store_path: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    cache_name: Option<String>,
50}
51
52impl SuiClient {
53    /// Create a new sui client pointing at the given daemon address.
54    pub fn new(daemon_addr: &str) -> Self {
55        let base_url = if daemon_addr.starts_with("http") {
56            daemon_addr.to_string()
57        } else {
58            format!("http://{daemon_addr}")
59        };
60
61        let client = reqwest::Client::builder()
62            .timeout(std::time::Duration::from_secs(300))
63            .build()
64            .unwrap_or_default();
65
66        Self { client, base_url }
67    }
68
69    /// Build a derivation via sui-daemon.
70    pub async fn build(
71        &self,
72        flake_ref: &str,
73        system: Option<&str>,
74        extra_args: Vec<String>,
75    ) -> Result<String> {
76        let url = format!("{}/api/v1/build", self.base_url);
77        info!(flake_ref, "building via sui-daemon");
78
79        let resp = self
80            .client
81            .post(&url)
82            .json(&BuildRequest {
83                flake_ref: flake_ref.to_string(),
84                system: system.map(String::from),
85                extra_args,
86            })
87            .send()
88            .await
89            .context("sui-daemon build request failed")?;
90
91        if !resp.status().is_success() {
92            let status = resp.status();
93            let body = resp.text().await.unwrap_or_default();
94            anyhow::bail!("sui-daemon build failed ({status}): {body}");
95        }
96
97        let result: BuildResponse = resp.json().await?;
98        debug!(store_path = %result.store_path, build_time_ms = result.build_time_ms, "build complete");
99        Ok(result.store_path)
100    }
101
102    /// Evaluate a Nix expression via sui-daemon.
103    pub async fn eval_json(&self, expr: &str) -> Result<serde_json::Value> {
104        let url = format!("{}/api/v1/eval", self.base_url);
105        debug!(expr_len = expr.len(), "evaluating via sui-daemon");
106
107        let resp = self
108            .client
109            .post(&url)
110            .json(&EvalRequest {
111                expression: expr.to_string(),
112            })
113            .send()
114            .await
115            .context("sui-daemon eval request failed")?;
116
117        if !resp.status().is_success() {
118            let status = resp.status();
119            let body = resp.text().await.unwrap_or_default();
120            anyhow::bail!("sui-daemon eval failed ({status}): {body}");
121        }
122
123        let result: EvalResponse = resp.json().await?;
124        Ok(result.result)
125    }
126
127    /// Push a store path to the binary cache via sui-daemon.
128    pub async fn push_to_cache(&self, store_path: &str, cache_name: Option<&str>) -> Result<()> {
129        let url = format!("{}/api/v1/cache/push", self.base_url);
130        info!(store_path, "pushing to sui-cache");
131
132        let resp = self
133            .client
134            .post(&url)
135            .json(&CachePushRequest {
136                store_path: store_path.to_string(),
137                cache_name: cache_name.map(String::from),
138            })
139            .send()
140            .await
141            .context("sui-daemon cache push failed")?;
142
143        if !resp.status().is_success() {
144            let status = resp.status();
145            let body = resp.text().await.unwrap_or_default();
146            anyhow::bail!("sui-daemon cache push failed ({status}): {body}");
147        }
148
149        Ok(())
150    }
151
152    /// Check if the sui-daemon is reachable.
153    pub async fn health_check(&self) -> bool {
154        let url = format!("{}/health", self.base_url);
155        self.client
156            .get(&url)
157            .send()
158            .await
159            .map(|r| r.status().is_success())
160            .unwrap_or(false)
161    }
162}