nap_core/grpc_client.rs
1//! gRPC client for lore-server's revision service (branch ref sync).
2//!
3//! The lore-server exposes its mutable state — branch pointers, revision
4//! pointers — exclusively over gRPC, whereas content-addressed blob data
5//! is transferred via HTTP / the `lore` CLI. This module implements the
6//! gRPC half of the push/pull protocol.
7//!
8//! # Architecture
9//!
10//! ```text
11//! LoreBackend::push / pull (sync, on tokio runtime)
12//! │
13//! ▼
14//! block_on_grpc(…) (spawns dedicated OS thread)
15//! │
16//! ▼
17//! LoreGrpcClient (tonic RevisionServiceClient wrapper)
18//! │
19//! ▼
20//! lore-server gRPC endpoint
21//! ├── RevisionService.BranchGet → fetch remote tip
22//! └── RevisionService.BranchPush → advance remote tip
23//! ```
24//!
25//! # Sync/Async Bridge
26//!
27//! The [`VcsBackend`] trait is synchronous. gRPC is inherently async.
28//! Rather than changing the trait (which would break every implementation),
29//! we bridge via [`block_on_grpc`]: a dedicated OS thread hosts a shared
30//! single-threaded tokio runtime that executes the async gRPC call. This
31//! avoids the "Cannot start a runtime from within a runtime" panic that
32//! would occur if we called `Runtime::block_on` directly inside axum
33//! request handlers.
34
35// ---------------------------------------------------------------------------
36// Generated proto modules — must nest exactly as prost expects for
37// cross-package references (lore.revision.v1 → lore.model.v1)
38// ---------------------------------------------------------------------------
39
40/// Generated gRPC service and message types.
41///
42/// Two packages are compiled:
43/// - `lore.model.v1` — Branch, BranchPoint, etc.
44/// - `lore.revision.v1` — RevisionService, BranchGetRequest, etc.
45pub mod proto_gen {
46 #![allow(unreachable_pub)]
47 pub mod lore {
48 pub mod model {
49 pub mod v1 {
50 tonic::include_proto!("lore.model.v1");
51 }
52 }
53 pub mod revision {
54 pub mod v1 {
55 tonic::include_proto!("lore.revision.v1");
56 }
57 }
58 }
59}
60
61// Re-export the types callers need most frequently.
62pub use proto_gen::lore::model::v1::Branch;
63pub use proto_gen::lore::revision::v1::branch_get_request;
64pub use proto_gen::lore::revision::v1::revision_service_client::RevisionServiceClient;
65pub use proto_gen::lore::revision::v1::{BranchGetRequest, BranchPushRequest};
66
67use std::future::Future;
68use std::sync::LazyLock;
69use std::thread;
70use std::time::Duration;
71
72use tonic::codegen::InterceptedService;
73use tonic::metadata::{BinaryMetadataValue, MetadataValue};
74use tonic::service::Interceptor;
75use tonic::transport::{Channel, Endpoint};
76
77use crate::error::NapError;
78
79// ===========================================================================
80// Auth interceptor
81// ===========================================================================
82
83/// Injects JWT bearer token and repository-scope metadata into every
84/// outgoing gRPC request.
85///
86/// The token is sent as `Authorization: Bearer <token>` with
87/// `set_sensitive(true)` so proxy logs do not leak it.
88///
89/// The repository ID is sent as binary metadata (keys with `-bin` suffix)
90/// matching the lore-client's `inject_repository()` protocol.
91#[derive(Clone)]
92struct GrpcAuthInterceptor {
93 token: Option<String>,
94 repository_id_bytes: Vec<u8>,
95}
96
97impl Interceptor for GrpcAuthInterceptor {
98 fn call(
99 &mut self,
100 mut request: tonic::Request<()>,
101 ) -> Result<tonic::Request<()>, tonic::Status> {
102 // ── Authorization header ──────────────────────────────────────
103 if let Some(ref token) = self.token
104 && !token.is_empty()
105 {
106 let mut value: MetadataValue<_> = format!("Bearer {token}")
107 .parse()
108 .map_err(|e| tonic::Status::invalid_argument(format!("bad token metadata: {e}")))?;
109 value.set_sensitive(true);
110 request.metadata_mut().insert("authorization", value);
111 }
112
113 // ── Repository-scope binary metadata ──────────────────────────
114 if !self.repository_id_bytes.is_empty() {
115 let bin_val = BinaryMetadataValue::from_bytes(&self.repository_id_bytes);
116 request
117 .metadata_mut()
118 .insert_bin("lore-partition", bin_val.clone());
119 request
120 .metadata_mut()
121 .insert_bin("urc-repository-id", bin_val);
122 }
123
124 Ok(request)
125 }
126}
127
128// ===========================================================================
129// LoreGrpcClient
130// ===========================================================================
131
132/// A gRPC client for lore-server's [`RevisionService`].
133///
134/// This client handles **only** lightweight metadata operations:
135///
136/// | Operation | RPC | Purpose |
137/// |-----------|-----|---------|
138/// | `get_branch_by_name` | `BranchGet` | Fetch remote branch tip before pull |
139/// | `push_branch` | `BranchPush` | Advance remote branch tip after push |
140///
141/// Blob transfer (the heavy payload) remains on the `lore` CLI / HTTP.
142///
143/// [`RevisionService`]: proto_gen::lore::revision::v1::revision_service_client::RevisionServiceClient
144#[derive(Debug, Clone)]
145pub struct LoreGrpcClient {
146 channel: Channel,
147 token: Option<String>,
148 repository_id_bytes: Vec<u8>,
149}
150
151impl LoreGrpcClient {
152 /// Return a builder for fine-grained configuration.
153 pub fn builder() -> Builder {
154 Builder::default()
155 }
156
157 // ── Public RPC methods ───────────────────────────────────────────
158
159 /// Look up a branch by its human-readable name.
160 ///
161 /// Returns the [`Branch`] record containing `id` (binary UUID),
162 /// `name`, `latest` (tip signature), and other metadata.
163 pub async fn get_branch_by_name(&self, name: &str) -> Result<Branch, NapError> {
164 let mut client = self.make_client();
165 let response = client
166 .branch_get(BranchGetRequest {
167 query: Some(branch_get_request::Query::Name(name.to_string())),
168 })
169 .await
170 .map_err(|status| map_grpc_status("BranchGet", status))?;
171
172 response.into_inner().branch.ok_or_else(|| {
173 NapError::GrpcError(format!("BranchGet({name}) returned empty branch record"))
174 })
175 }
176
177 /// Push a revision as the new tip of a branch.
178 ///
179 /// * `branch_id` — binary branch UUID (obtained from
180 /// [`get_branch_by_name`]).
181 /// * `revision_signature` — raw content hash of the revision to set as
182 /// the new tip.
183 /// * `force` — if `true`, bypasses fast-forward checks on the server.
184 /// When `false`, the server requires the new tip to descend from the
185 /// current tip (or performs a fast-forward merge).
186 pub async fn push_branch(
187 &self,
188 branch_id: bytes::Bytes,
189 revision_signature: bytes::Bytes,
190 force: bool,
191 ) -> Result<(), NapError> {
192 let mut client = self.make_client();
193 client
194 .branch_push(BranchPushRequest {
195 id: branch_id,
196 revision_signature,
197 force,
198 fast_forward_merge: !force,
199 })
200 .await
201 .map_err(|status| map_grpc_status("BranchPush", status))?;
202 Ok(())
203 }
204
205 // ── Internal helpers ─────────────────────────────────────────────
206
207 /// Convenience constructor that reads all configuration from environment
208 /// variables. Returns `Ok(None)` when `NAP_LORE_GRPC_ENDPOINT` is not
209 /// set, allowing callers to gracefully skip gRPC integration.
210 ///
211 /// See [`Builder::from_env`] for the list of recognised variables.
212 pub fn builder_from_env() -> Result<Option<Self>, NapError> {
213 Builder::from_env()
214 }
215
216 /// Build a fresh client with the interceptor wired in.
217 fn make_client(
218 &self,
219 ) -> RevisionServiceClient<InterceptedService<Channel, GrpcAuthInterceptor>> {
220 RevisionServiceClient::with_interceptor(
221 self.channel.clone(),
222 GrpcAuthInterceptor {
223 token: self.token.clone(),
224 repository_id_bytes: self.repository_id_bytes.clone(),
225 },
226 )
227 }
228}
229
230// ===========================================================================
231// Builder
232// ===========================================================================
233
234/// Configuration builder for [`LoreGrpcClient`].
235///
236/// # Environment variables
237///
238/// | Variable | Required | Default | Description |
239/// |----------|----------|---------|-------------|
240/// | `NAP_LORE_GRPC_ENDPOINT` | Yes | — | gRPC endpoint URL |
241/// | `NAP_LORE_GRPC_TOKEN` | No | — | JWT bearer token |
242/// | `NAP_LORE_GRPC_RID` | No | — | Repository ID (hex-encoded binary) |
243/// | `NAP_LORE_GRPC_INSECURE` | No | `0` | Skip TLS verification when `1` |
244#[derive(Default)]
245pub struct Builder {
246 endpoint: Option<String>,
247 token: Option<String>,
248 repository_id_bytes: Vec<u8>,
249 insecure: bool,
250}
251
252impl Builder {
253 /// Set the gRPC endpoint URL.
254 ///
255 /// Format: `https://host:port` (TLS) or `http://host:port` (plain).
256 pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
257 self.endpoint = Some(endpoint.into());
258 self
259 }
260
261 /// Set a JWT bearer token for authenticated requests.
262 pub fn token(mut self, token: impl Into<String>) -> Self {
263 self.token = Some(token.into());
264 self
265 }
266
267 /// Set the repository ID to inject as binary metadata.
268 ///
269 /// This should match the repository / partition UUID the lore-server
270 /// expects. Pass the raw bytes (not hex-encoded).
271 pub fn repository_id(mut self, id: impl Into<Vec<u8>>) -> Self {
272 self.repository_id_bytes = id.into();
273 self
274 }
275
276 /// When `true`, skip TLS certificate validation.
277 ///
278 /// Use this in development environments where the lore-server uses
279 /// self-signed certificates.
280 pub fn insecure(mut self, insecure: bool) -> Self {
281 self.insecure = insecure;
282 self
283 }
284
285 /// Build the [`LoreGrpcClient`].
286 ///
287 /// Connection is deferred via [`Endpoint::connect_lazy`]; the
288 /// first RPC will establish the TCP + TLS handshake.
289 pub fn build(self) -> Result<LoreGrpcClient, NapError> {
290 let endpoint_str = self.endpoint.ok_or_else(|| {
291 NapError::GrpcError(
292 "gRPC endpoint is required — set via .endpoint() or NAP_LORE_GRPC_ENDPOINT"
293 .to_string(),
294 )
295 })?;
296
297 // In insecure mode, downgrade https:// → http:// to skip TLS
298 // verification entirely (self-signed certs in development).
299 // In secure mode, Endpoint::from_shared auto-configures TLS with
300 // native roots for https:// URLs — no explicit tls_config needed.
301 let effective_url = if self.insecure {
302 endpoint_str
303 .strip_prefix("https://")
304 .map(|rest| format!("http://{rest}"))
305 .unwrap_or_else(|| endpoint_str.clone())
306 } else {
307 endpoint_str.clone()
308 };
309
310 let channel = Endpoint::from_shared(effective_url)
311 .map_err(|e| {
312 NapError::GrpcError(format!("invalid gRPC endpoint '{endpoint_str}': {e}"))
313 })?
314 .http2_keep_alive_interval(Duration::from_secs(30))
315 .keep_alive_timeout(Duration::from_secs(20))
316 .user_agent(concat!("nap-core/", env!("CARGO_PKG_VERSION")))
317 .map_err(|e| NapError::GrpcError(format!("user-agent configuration error: {e}")))?
318 .connect_lazy();
319
320 Ok(LoreGrpcClient {
321 channel,
322 token: self.token,
323 repository_id_bytes: self.repository_id_bytes,
324 })
325 }
326
327 /// Build from environment variables.
328 ///
329 /// Returns `Ok(None)` when `NAP_LORE_GRPC_ENDPOINT` is not set
330 /// (allowing the caller to skip gRPC integration gracefully).
331 pub fn from_env() -> Result<Option<LoreGrpcClient>, NapError> {
332 let endpoint = match std::env::var("NAP_LORE_GRPC_ENDPOINT") {
333 Ok(v) => v,
334 Err(_) => return Ok(None),
335 };
336
337 let token = std::env::var("NAP_LORE_GRPC_TOKEN").ok();
338 let insecure = std::env::var("NAP_LORE_GRPC_INSECURE")
339 .ok()
340 .is_some_and(|v| v == "1" || v == "true" || v == "yes");
341
342 let repository_id_bytes = std::env::var("NAP_LORE_GRPC_RID")
343 .ok()
344 .map(|hex| {
345 hex::decode(&hex).map_err(|e| {
346 NapError::GrpcError(format!("invalid NAP_LORE_GRPC_RID hex '{hex}': {e}"))
347 })
348 })
349 .transpose()?
350 .unwrap_or_default();
351
352 let mut builder = Builder::default().endpoint(endpoint).insecure(insecure);
353
354 if let Some(t) = token {
355 builder = builder.token(t);
356 }
357 if !repository_id_bytes.is_empty() {
358 builder = builder.repository_id(repository_id_bytes);
359 }
360
361 builder.build().map(Some)
362 }
363}
364
365// ===========================================================================
366// Sync→async bridge
367// ===========================================================================
368
369/// Execute an async gRPC operation from a synchronous context.
370///
371/// # Why a dedicated thread?
372///
373/// The [`VcsBackend`] trait methods (`push`, `pull`) are synchronous.
374/// gRPC client calls are async. If we called `Runtime::block_on` directly
375/// from within an axum HTTP handler (which already runs on a tokio runtime),
376/// tokio would panic with "Cannot start a runtime from within a runtime".
377///
378/// This function spawns a **dedicated OS thread** that hosts the future
379/// on a shared single-threaded tokio runtime. The runtime is created once
380/// and reused across all gRPC calls, preserving HTTP/2 keepalive state and
381/// TLS session tickets.
382///
383/// # Type bounds
384///
385/// * `F` must be `Send + 'static` because it crosses a thread boundary.
386/// * `T` must be `Send + 'static` for the same reason.
387/// * The closure return type is `Result<T, NapError>` so that error
388/// propagation through the thread join is straightforward.
389///
390/// [`VcsBackend`]: crate::vcs::VcsBackend
391pub fn block_on_grpc<F, T>(f: F) -> Result<T, NapError>
392where
393 F: Future<Output = Result<T, NapError>> + Send + 'static,
394 T: Send + 'static,
395{
396 static RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
397 tokio::runtime::Builder::new_current_thread()
398 .enable_all()
399 .build()
400 .expect("failed to build gRPC tokio runtime")
401 });
402
403 // `&'static Runtime` is both `Send` and `Sync` because the static
404 // reference lives forever. It is safe to pass to a spawned thread.
405 let rt: &'static tokio::runtime::Runtime = &RUNTIME;
406
407 thread::Builder::new()
408 .name("nap-grpc".into())
409 .spawn(move || rt.block_on(f))
410 .expect("failed to spawn gRPC worker thread")
411 .join()
412 .map_err(|panic_payload| {
413 NapError::GrpcError(format!("gRPC thread panicked: {panic_payload:?}"))
414 })?
415}
416
417// ===========================================================================
418// Error mapping
419// ===========================================================================
420
421/// Map a [`tonic::Status`] to a structured [`NapError`].
422fn map_grpc_status(context: &str, status: tonic::Status) -> NapError {
423 let code = status.code();
424 let message = status.message();
425 match code {
426 tonic::Code::NotFound => NapError::RefNotFound(format!("{context}: {message}")),
427 tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => {
428 NapError::PermissionDenied(format!("{context}: {message}"))
429 }
430 _ => NapError::GrpcError(format!("{context} ({code}): {message}")),
431 }
432}