Skip to main content

engenho_diff/
target.rs

1//! The two ends of the diff — the [`DiffTarget`] trait and its concrete
2//! impls: [`EngenhoTarget`] (booted in-process, the r7 seam) and
3//! [`K3sTarget`] (the live reference oracle from a kubeconfig).
4
5use std::path::Path;
6use std::sync::Arc;
7use std::time::Duration;
8
9use engenho_apiserver::{ApiServer, ResourceHandler, StoreBackedHandler};
10use engenho_kube_client::{Connection, Kubeconfig, ReqwestKubeClient};
11use engenho_store::{InProcessRouter, StoreMesh, default_config};
12use engenho_types::auth::KubeAuth;
13use serde_json::Value;
14
15use crate::observe::{DiffError, HttpMethod, Observation};
16use crate::verdict::{Side, Verdict};
17
18/// A cluster the harness can fire operations at.
19///
20/// **Why `raw()` and not a typed `client()`:** the typed CRUD trait
21/// `engenho_types::client::KubeClient` is NOT object-safe — its methods are
22/// generic over `R: KubeResource`, so `&dyn KubeClient` does not compile
23/// (there are zero `dyn KubeClient` uses in the whole engenho tree). A
24/// trait-level `client() -> &dyn KubeClient`, as the design sketched, is
25/// therefore not expressible in Rust. The reuse of the client is instead
26/// STRUCTURAL: every real target is built over the same [`Connection`] that
27/// also powers `raw()`, and exposes a concrete [`ReqwestKubeClient`] via
28/// `kube_client()`. The differ's workhorse is `raw()` because it captures
29/// the status code + full JSON body that the typed CRUD surface erases into
30/// `Result<R, KubeError>`.
31#[async_trait::async_trait]
32pub trait DiffTarget: Send + Sync {
33    /// Which side this target is.
34    fn side(&self) -> Side;
35
36    /// Issue `method path` (+ optional body) and return the typed
37    /// observation. Reaches the discovery / status-code / error-body
38    /// surfaces the typed CRUD trait cannot.
39    ///
40    /// # Errors
41    ///
42    /// [`DiffError::Transport`] if the request fails, or [`DiffError::Kube`]
43    /// if the auth header can't be built.
44    async fn raw(
45        &self,
46        method: HttpMethod,
47        path: &str,
48        body: Option<&[u8]>,
49        content_type: Option<&str>,
50    ) -> Result<Observation, DiffError>;
51}
52
53async fn http_raw(
54    conn: &Connection,
55    side: Side,
56    method: HttpMethod,
57    path: &str,
58    body: Option<&[u8]>,
59    content_type: Option<&str>,
60) -> Result<Observation, DiffError> {
61    let mut url = String::from(conn.server());
62    url.push_str(path);
63    let mut rb = conn.http().request(method.as_reqwest(), &url);
64    if let Some(ct) = content_type {
65        rb = rb.header("Content-Type", ct);
66    }
67    if let Some(b) = body {
68        rb = rb.body(b.to_vec());
69    }
70    let rb = conn
71        .auth_header(rb)
72        .map_err(|e| DiffError::Kube(e.to_string()))?;
73    let resp = rb.send().await.map_err(|source| DiffError::Transport {
74        side: side.label(),
75        source,
76    })?;
77    let status = resp.status().as_u16();
78    let content_type = resp
79        .headers()
80        .get(reqwest::header::CONTENT_TYPE)
81        .and_then(|v| v.to_str().ok())
82        .map(str::to_string);
83    let text = resp.text().await.map_err(|source| DiffError::Transport {
84        side: side.label(),
85        source,
86    })?;
87    let json = if text.trim().is_empty() {
88        Value::Null
89    } else {
90        serde_json::from_str(&text).unwrap_or(Value::String(text))
91    };
92    Ok(Observation {
93        status,
94        content_type,
95        json,
96    })
97}
98
99/// engenho booted IN-PROCESS: a real `StoreMesh` (Raft-of-1 + MVCC) plus an
100/// `ApiServer` on an ephemeral loopback port, plaintext (no TLS, no VM) —
101/// milliseconds to stand up. Copies the r7 boot seam.
102pub struct EngenhoTarget {
103    _store: Arc<StoreMesh>,
104    server: ApiServer,
105    conn: Connection,
106    client: ReqwestKubeClient,
107}
108
109impl EngenhoTarget {
110    /// Boot engenho in-process with store-backed handlers for `kinds`
111    /// (scope auto-derived from the resource catalog via `for_kind`).
112    ///
113    /// # Errors
114    ///
115    /// [`DiffError::Boot`] if the store, leadership, a handler, or the
116    /// server fails to come up.
117    pub async fn boot_in_process(kinds: &[&str]) -> Result<Self, DiffError> {
118        let router = InProcessRouter::new();
119        let cfg = default_config("engenho-diff").map_err(|e| DiffError::Boot(format!("{e:?}")))?;
120        let store = Arc::new(
121            StoreMesh::start(1, "in-process://1".into(), router, cfg)
122                .await
123                .map_err(|e| DiffError::Boot(format!("{e:?}")))?,
124        );
125        store
126            .initialize_singleton()
127            .await
128            .map_err(|e| DiffError::Boot(format!("{e:?}")))?;
129        if !store.wait_for_leadership(Duration::from_secs(5)).await {
130            return Err(DiffError::Boot("store did not acquire leadership".into()));
131        }
132        let mut handlers: Vec<Arc<dyn ResourceHandler>> = Vec::with_capacity(kinds.len());
133        for k in kinds {
134            let h = StoreBackedHandler::for_kind(store.clone(), k)
135                .ok_or_else(|| DiffError::Boot(format!("for_kind {k}: not a cataloged kind")))?;
136            handlers.push(Arc::new(h));
137        }
138        let addr = "127.0.0.1:0"
139            .parse()
140            .map_err(|e| DiffError::Boot(format!("addr parse: {e}")))?;
141        let server = ApiServer::start(addr, handlers, None)
142            .await
143            .map_err(|e| DiffError::Boot(format!("{e:?}")))?;
144        let mut base = String::from("http://");
145        base.push_str(&server.local_addr().to_string());
146        let conn = Connection::new(&base, KubeAuth::Anonymous, None)
147            .map_err(|e| DiffError::Boot(format!("{e:?}")))?;
148        let client = ReqwestKubeClient::new(conn.clone());
149        Ok(Self {
150            _store: store,
151            server,
152            conn,
153            client,
154        })
155    }
156
157    /// The concrete typed CRUD client over the same connection (structural
158    /// reuse — see [`DiffTarget`]).
159    #[must_use]
160    pub fn kube_client(&self) -> &ReqwestKubeClient {
161        &self.client
162    }
163
164    /// Gracefully stop the in-process server.
165    ///
166    /// # Errors
167    ///
168    /// Propagates the server's shutdown error (currently never `Err`).
169    pub async fn shutdown(self) -> Result<(), DiffError> {
170        self.server
171            .shutdown()
172            .await
173            .map_err(|e| DiffError::Boot(e.to_string()))
174    }
175}
176
177#[async_trait::async_trait]
178impl DiffTarget for EngenhoTarget {
179    fn side(&self) -> Side {
180        Side::Engenho
181    }
182
183    async fn raw(
184        &self,
185        method: HttpMethod,
186        path: &str,
187        body: Option<&[u8]>,
188        content_type: Option<&str>,
189    ) -> Result<Observation, DiffError> {
190        http_raw(&self.conn, Side::Engenho, method, path, body, content_type).await
191    }
192}
193
194/// The live k3s reference oracle, resolved from a kubeconfig.
195pub struct K3sTarget {
196    conn: Connection,
197    client: ReqwestKubeClient,
198}
199
200impl K3sTarget {
201    /// Build from a kubeconfig file (its current-context cluster + user).
202    /// Honors client-cert auth + the CA; the server URL's host (127.0.0.1)
203    /// is verified against the cert's IP SAN — so the CLI's
204    /// `--tls-server-name 127.0.0.1` need not be threaded (the SNI host IS
205    /// 127.0.0.1).
206    ///
207    /// # Errors
208    ///
209    /// [`DiffError::Kube`] if the kubeconfig can't be parsed or resolved.
210    pub fn from_kubeconfig(path: &Path) -> Result<Self, DiffError> {
211        let kc = Kubeconfig::load(path).map_err(|e| DiffError::Kube(e.to_string()))?;
212        let conn = kc
213            .resolve_connection()
214            .map_err(|e| DiffError::Kube(e.to_string()))?;
215        let client = ReqwestKubeClient::new(conn.clone());
216        Ok(Self { conn, client })
217    }
218
219    /// The concrete typed CRUD client (structural reuse — see [`DiffTarget`]).
220    #[must_use]
221    pub fn kube_client(&self) -> &ReqwestKubeClient {
222        &self.client
223    }
224
225    /// Preflight: confirm the oracle answers `GET /api/v1` with 2xx. Returns
226    /// `Some(Verdict::ReferenceUnreachable)` on failure — the fail-loud
227    /// signal the runner must honor, never a silent skip.
228    pub async fn preflight(&self) -> Option<Verdict> {
229        match self.raw(HttpMethod::Get, "/api/v1", None, None).await {
230            Ok(obs) if obs.is_success() => None,
231            _ => Some(Verdict::ReferenceUnreachable),
232        }
233    }
234}
235
236#[async_trait::async_trait]
237impl DiffTarget for K3sTarget {
238    fn side(&self) -> Side {
239        Side::K3s
240    }
241
242    async fn raw(
243        &self,
244        method: HttpMethod,
245        path: &str,
246        body: Option<&[u8]>,
247        content_type: Option<&str>,
248    ) -> Result<Observation, DiffError> {
249        http_raw(&self.conn, Side::K3s, method, path, body, content_type).await
250    }
251}