Skip to main content

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}