Skip to main content

cf_system_sdk_directory/grpc/
client.rs

1//! gRPC client implementation of Directory API
2//!
3//! This client allows remote gears to discover and resolve services via gRPC.
4
5use anyhow::Result;
6use async_trait::async_trait;
7use tonic::service::interceptor::InterceptedService;
8use tonic::transport::Channel;
9
10use crate::ProtoInstanceState;
11use crate::api::{
12    DirectoryClient, DirectoryInvalidArgument, DirectoryNotFound, InstanceState, LabelSelector,
13    RegisterInstanceInfo, ServiceEndpoint, ServiceInstanceInfo,
14};
15use std::collections::BTreeMap;
16use toolkit_transport_grpc::InternalAuthInterceptor;
17use toolkit_transport_grpc::client::{GrpcClientConfig, connect_with_retry};
18
19use crate::{
20    DeregisterInstanceRequest, DirectoryServiceClient, GetOpenApiSpecRequest, GrpcServiceEndpoint,
21    HeartbeatRequest, InstanceInfo, ListAllInstancesRequest, ListInstancesRequest,
22    RegisterInstanceRequest, ResolveGrpcServiceRequest, ResolveRestServiceRequest,
23};
24
25/// The directory channel wrapped with the platform-plane
26/// [`InternalAuthInterceptor`], which attaches the gear's internal token
27/// (`x-toolkit-internal-token`) to every outbound system call. A
28/// [`disabled`](InternalAuthInterceptor::disabled) interceptor attaches
29/// nothing (Profile 1 / no platform-plane credential).
30type AuthedChannel = InterceptedService<Channel, InternalAuthInterceptor>;
31
32/// Map a lookup RPC's `tonic::Status` onto the directory's typed sentinels.
33///
34/// The status code is the only thing distinguishing "this name is not
35/// registered" from "the directory is unreachable", and stringifying the status
36/// throws it away. Callers downcast the result: `DirectoryEndpointResolver`
37/// turns `DirectoryNotFound` into `Ok(None)` (a provider that has not come up
38/// yet — routine during startup) and anything else into a real error.
39fn lookup_error(resource: &str, status: &tonic::Status) -> anyhow::Error {
40    match status.code() {
41        tonic::Code::NotFound => DirectoryNotFound::new(resource.to_owned()).into(),
42        tonic::Code::InvalidArgument => {
43            DirectoryInvalidArgument::new(status.message().to_owned()).into()
44        }
45        code => anyhow::anyhow!(
46            "directory lookup for {resource} failed: gRPC {code:?}: {}",
47            status.message()
48        ),
49    }
50}
51
52/// Map a mutating RPC's `tonic::Status`, preserving the code in the message.
53///
54/// A bare `"gRPC call failed"` hides whether the directory was unreachable
55/// (`Unavailable`, transient) or rejected the request (`InvalidArgument`,
56/// permanent). `InvalidArgument` is typed as [`DirectoryInvalidArgument`] so a
57/// caller retrying a mutation (e.g. the presence loop) can distinguish a
58/// permanent rejection — which retrying can never fix — from a transient one.
59fn call_error(op: &str, status: &tonic::Status) -> anyhow::Error {
60    match status.code() {
61        tonic::Code::InvalidArgument => {
62            DirectoryInvalidArgument::new(status.message().to_owned()).into()
63        }
64        code => anyhow::anyhow!("directory {op} failed: gRPC {code:?}: {}", status.message()),
65    }
66}
67
68/// gRPC client for Directory API
69///
70/// This client connects to a remote `DirectoryService` via gRPC and provides
71/// typed access to service discovery functionality. It includes:
72/// - Configurable timeouts and retries via transport stack
73/// - Automatic proto ↔ domain type conversions
74/// - Distributed tracing and metrics
75/// - Platform-plane credential attachment via an [`InternalAuthInterceptor`]
76///   (defaults to attaching nothing; supply one via the `*_with_interceptor`
77///   constructors for Profile-3 / shared-secret deployments)
78pub struct DirectoryGrpcClient {
79    inner: DirectoryServiceClient<AuthedChannel>,
80}
81
82impl DirectoryGrpcClient {
83    /// Connect to a directory service using default configuration with retries.
84    ///
85    /// Uses exponential backoff retry logic for reliable connection establishment.
86    /// This is the recommended method for `OoP` gears connecting to the master host.
87    ///
88    /// # Errors
89    /// It will return an error when it fails
90    pub async fn connect(uri: impl Into<String>) -> Result<Self> {
91        let cfg = GrpcClientConfig::new("directory");
92        Self::connect_with_retry(uri, &cfg).await
93    }
94
95    /// Connect with default configuration + retries, attaching `interceptor`'s
96    /// platform-plane credential to every outbound call.
97    ///
98    /// This is the Profile-3 / shared-secret entry point: the interceptor is
99    /// typically built from a
100    /// [`ServiceAccountTokenReader`](toolkit_transport_grpc::ServiceAccountTokenReader)
101    /// (rotating SA token) or
102    /// [`InternalAuthInterceptor::from_token`] (static shared secret).
103    ///
104    /// # Errors
105    /// It will return an error when it fails
106    pub async fn connect_with_interceptor(
107        uri: impl Into<String>,
108        interceptor: InternalAuthInterceptor,
109    ) -> Result<Self> {
110        let cfg = GrpcClientConfig::new("directory");
111        let channel: Channel = connect_with_retry(uri, &cfg).await?;
112        Ok(Self::from_channel_with_interceptor(channel, interceptor))
113    }
114
115    /// Connect to a directory service with custom configuration and retry logic.
116    ///
117    /// Uses exponential backoff based on `cfg.max_retries`, `cfg.base_backoff`,
118    /// and `cfg.max_backoff` settings.
119    ///
120    /// # Errors
121    /// It will return an error when it fails
122    pub async fn connect_with_retry(
123        uri: impl Into<String>,
124        cfg: &GrpcClientConfig,
125    ) -> Result<Self> {
126        let channel: Channel = connect_with_retry(uri, cfg).await?;
127        Ok(Self::from_channel(channel))
128    }
129
130    /// Connect to a directory service without retry logic.
131    ///
132    /// This method attempts a single connection. Use `connect` or `connect_with_retry`
133    /// for production scenarios where the directory service may not be immediately available.
134    ///
135    /// # Errors
136    /// It will return an error when it fails
137    pub async fn connect_no_retry(uri: impl Into<String>, cfg: &GrpcClientConfig) -> Result<Self> {
138        let uri_string = uri.into();
139
140        // Create endpoint with timeouts from config
141        let endpoint = tonic::transport::Endpoint::from_shared(uri_string)?
142            .connect_timeout(cfg.connect_timeout)
143            .timeout(cfg.rpc_timeout);
144
145        // Connect to the service
146        let channel = endpoint.connect().await?;
147
148        if cfg.enable_tracing {
149            tracing::debug!(
150                service_name = cfg.service_name,
151                connect_timeout_ms = cfg.connect_timeout.as_millis(),
152                rpc_timeout_ms = cfg.rpc_timeout.as_millis(),
153                "directory gRPC client connected"
154            );
155        }
156
157        Ok(Self::from_channel(channel))
158    }
159
160    /// Create from an existing channel (useful for testing or custom setup).
161    ///
162    /// Attaches no platform-plane credential; use
163    /// [`from_channel_with_interceptor`](Self::from_channel_with_interceptor)
164    /// to attach one.
165    #[must_use]
166    pub fn from_channel(channel: Channel) -> Self {
167        Self::from_channel_with_interceptor(channel, InternalAuthInterceptor::disabled())
168    }
169
170    /// Create from an existing channel, attaching `interceptor`'s platform-plane
171    /// credential to every outbound call.
172    #[must_use]
173    pub fn from_channel_with_interceptor(
174        channel: Channel,
175        interceptor: InternalAuthInterceptor,
176    ) -> Self {
177        Self {
178            inner: DirectoryServiceClient::with_interceptor(channel, interceptor),
179        }
180    }
181}
182
183#[async_trait]
184impl DirectoryClient for DirectoryGrpcClient {
185    async fn resolve_grpc_service(&self, service_name: &str) -> Result<ServiceEndpoint> {
186        let mut client = self.inner.clone();
187        let request = tonic::Request::new(ResolveGrpcServiceRequest {
188            service_name: service_name.to_owned(),
189        });
190
191        let response = client
192            .resolve_grpc_service(request)
193            .await
194            .map_err(|e| lookup_error(&format!("service {service_name}"), &e))?;
195
196        let proto_response = response.into_inner();
197        Ok(ServiceEndpoint::new(proto_response.endpoint_uri))
198    }
199
200    async fn resolve_rest_service(&self, gear_name: &str) -> Result<ServiceEndpoint> {
201        let mut client = self.inner.clone();
202        let request = tonic::Request::new(ResolveRestServiceRequest {
203            gear_name: gear_name.to_owned(),
204        });
205
206        let response = client
207            .resolve_rest_service(request)
208            .await
209            .map_err(|e| lookup_error(&format!("gear {gear_name}"), &e))?;
210
211        let proto_response = response.into_inner();
212        Ok(ServiceEndpoint::new(proto_response.endpoint_uri))
213    }
214
215    async fn get_openapi_spec(&self, gear_name: &str) -> Result<String> {
216        let mut client = self.inner.clone();
217        let request = tonic::Request::new(GetOpenApiSpecRequest {
218            gear_name: gear_name.to_owned(),
219        });
220
221        let response = client
222            .get_open_api_spec(request)
223            .await
224            .map_err(|e| lookup_error(&format!("openapi spec for gear {gear_name}"), &e))?;
225
226        Ok(response.into_inner().openapi_spec)
227    }
228
229    async fn list_instances(&self, gear: &str) -> Result<Vec<ServiceInstanceInfo>> {
230        let mut client = self.inner.clone();
231        let request = tonic::Request::new(ListInstancesRequest {
232            gear_name: gear.to_owned(),
233            match_labels: std::collections::HashMap::new(),
234        });
235
236        let response = client
237            .list_instances(request)
238            .await
239            .map_err(|e| lookup_error(&format!("instances of gear {gear}"), &e))?;
240
241        let instances = response
242            .into_inner()
243            .instances
244            .into_iter()
245            .map(proto_instance_to_domain)
246            .collect();
247
248        Ok(instances)
249    }
250
251    async fn resolve_by_labels(
252        &self,
253        gear: &str,
254        selector: &LabelSelector,
255    ) -> Result<Vec<ServiceInstanceInfo>> {
256        // Push the selector server-side so the directory returns only matching
257        // instances. Every `list_instances` response is spec-free (only the
258        // `openapi_spec_hash` rides along; the document is fetched via
259        // `GetOpenApiSpec`), so an empty (match-all) selector never leaks a full
260        // OpenAPI document over the wire.
261        //
262        // cancel-safe: the single await is the unary `list_instances` RPC, which
263        // precedes any local state change; cancelling it just drops the in-flight
264        // response and leaves nothing partially applied.
265        let mut client = self.inner.clone();
266        let match_labels = selector
267            .match_labels
268            .iter()
269            .map(|(k, v)| (k.clone(), v.clone()))
270            .collect();
271        let request = tonic::Request::new(ListInstancesRequest {
272            gear_name: gear.to_owned(),
273            match_labels,
274        });
275
276        let response = client
277            .list_instances(request)
278            .await
279            .map_err(|e| lookup_error(&format!("instances of gear {gear}"), &e))?;
280
281        let instances = response
282            .into_inner()
283            .instances
284            .into_iter()
285            .map(proto_instance_to_domain)
286            .filter(|i| selector.matches(&i.labels))
287            .collect();
288
289        Ok(instances)
290    }
291
292    async fn list_all_instances(&self) -> Result<Vec<ServiceInstanceInfo>> {
293        let mut client = self.inner.clone();
294        let response = client
295            .list_all_instances(tonic::Request::new(ListAllInstancesRequest {}))
296            .await
297            .map_err(|e| lookup_error("all instances", &e))?;
298
299        let instances = response
300            .into_inner()
301            .instances
302            .into_iter()
303            .map(|proto| {
304                let mut info = proto_instance_to_domain(proto).without_labels();
305                info.openapi_spec = None;
306                info
307            })
308            .collect();
309
310        Ok(instances)
311    }
312
313    async fn register_instance(&self, info: RegisterInstanceInfo) -> Result<()> {
314        let mut client = self.inner.clone();
315
316        // Convert gRPC service endpoints
317        let grpc_services = info
318            .grpc_services
319            .into_iter()
320            .map(|(name, ep)| GrpcServiceEndpoint {
321                service_name: name,
322                endpoint_uri: ep.uri,
323            })
324            .collect();
325
326        let req = RegisterInstanceRequest {
327            gear_name: info.gear,
328            instance_id: info.instance_id,
329            grpc_services,
330            version: info.version.unwrap_or_default(),
331            rest_endpoint_uri: info.rest_endpoint.map(|ep| ep.uri),
332            openapi_spec: info.openapi_spec,
333            labels: info.labels.into_iter().collect(),
334        };
335
336        client
337            .register_instance(tonic::Request::new(req))
338            .await
339            .map_err(|e| call_error("register_instance", &e))?;
340
341        Ok(())
342    }
343
344    async fn deregister_instance(&self, gear: &str, instance_id: &str) -> Result<()> {
345        let mut client = self.inner.clone();
346
347        let req = DeregisterInstanceRequest {
348            gear_name: gear.to_owned(),
349            instance_id: instance_id.to_owned(),
350        };
351
352        client
353            .deregister_instance(tonic::Request::new(req))
354            .await
355            .map_err(|e| call_error("deregister_instance", &e))?;
356
357        Ok(())
358    }
359
360    async fn send_heartbeat(&self, gear: &str, instance_id: &str) -> Result<()> {
361        let mut client = self.inner.clone();
362
363        let req = HeartbeatRequest {
364            gear_name: gear.to_owned(),
365            instance_id: instance_id.to_owned(),
366        };
367
368        client
369            .heartbeat(tonic::Request::new(req))
370            .await
371            .map_err(|e| call_error("heartbeat", &e))?;
372
373        Ok(())
374    }
375}
376
377/// Convert a proto `InstanceInfo` into the domain [`ServiceInstanceInfo`].
378fn proto_instance_to_domain(proto: InstanceInfo) -> ServiceInstanceInfo {
379    ServiceInstanceInfo {
380        gear: proto.gear_name,
381        instance_id: proto.instance_id,
382        endpoint: if proto.endpoint_uri.is_empty() {
383            None
384        } else {
385            Some(ServiceEndpoint::new(proto.endpoint_uri))
386        },
387        version: if proto.version.is_empty() {
388            None
389        } else {
390            Some(proto.version)
391        },
392        rest_endpoint: proto.rest_endpoint_uri.map(ServiceEndpoint::new),
393        openapi_spec: proto.openapi_spec,
394        openapi_spec_hash: proto.openapi_spec_hash,
395        // The `InstanceInfo` proto message carries no per-service gRPC
396        // breakdown, so nothing to reconstruct over the OoP directory transport;
397        // only the in-process `LocalDirectoryClient` populates this (from the
398        // live `GearInstance`). No consumer reads `grpc_services` off a
399        // gRPC-obtained instance today — a single gRPC endpoint is available via
400        // the primary `endpoint`. When a label-targeted gRPC client needs the
401        // full per-service map remotely (the TopologyView work), add a
402        // `repeated GrpcServiceEndpoint grpc_services` field to `InstanceInfo`.
403        grpc_services: Vec::new(),
404        // Stable addressing labels cross the wire (ordering is normalized into a
405        // BTreeMap for deterministic selector matching).
406        labels: proto.labels.into_iter().collect::<BTreeMap<_, _>>(),
407        // Live serving state so label-targeted callers can filter on health.
408        state: proto_state_to_domain(proto.state),
409    }
410}
411
412/// Map the proto `InstanceState` (an open enum carried as `i32`) onto the
413/// domain [`InstanceState`].
414///
415/// `UNSPECIFIED` (a peer that never set the field, e.g. an older server) and
416/// any discriminant this build does not recognise map to the non-serving
417/// [`InstanceState::Unknown`] — kept distinct from
418/// [`InstanceState::Registered`] so "the state is unknown" is not silently read
419/// as a known pre-serving baseline. An unrecognised discriminant is logged so
420/// the version skew is observable rather than swallowed.
421fn proto_state_to_domain(state: i32) -> InstanceState {
422    match ProtoInstanceState::try_from(state) {
423        Ok(ProtoInstanceState::Ready) => InstanceState::Ready,
424        Ok(ProtoInstanceState::Healthy) => InstanceState::Healthy,
425        Ok(ProtoInstanceState::Quarantined) => InstanceState::Quarantined,
426        Ok(ProtoInstanceState::Draining) => InstanceState::Draining,
427        Ok(ProtoInstanceState::Registered) => InstanceState::Registered,
428        Ok(ProtoInstanceState::Unspecified) => InstanceState::Unknown,
429        Err(_) => {
430            tracing::warn!(
431                raw_state = state,
432                "directory returned an unrecognised InstanceState discriminant; \
433                 treating as Unknown (non-serving)"
434            );
435            InstanceState::Unknown
436        }
437    }
438}
439
440#[cfg(test)]
441#[cfg_attr(coverage_nightly, coverage(off))]
442mod tests {
443    use super::*;
444
445    #[tokio::test]
446    async fn test_grpc_client_can_be_constructed() {
447        // Smoke test to ensure types compile and connect
448        let endpoint = tonic::transport::Endpoint::from_static("http://[::1]:50051");
449
450        // We can't actually connect without a server, but we can construct the client type
451        // This ensures the API is correct
452        let channel_result = endpoint.connect().await;
453
454        // It's expected to fail since there's no server, but if it does somehow succeed:
455        if let Ok(channel) = channel_result {
456            let _client = DirectoryGrpcClient::from_channel(channel);
457        }
458    }
459
460    #[tokio::test]
461    async fn from_channel_constructs_without_connecting() {
462        // `connect_lazy` yields a Channel without a live server, so both the
463        // default (no-credential) and interceptor-bearing constructors can be
464        // exercised offline.
465        let channel = Channel::from_static("http://[::1]:50051").connect_lazy();
466        let _default = DirectoryGrpcClient::from_channel(channel.clone());
467        let _authed = DirectoryGrpcClient::from_channel_with_interceptor(
468            channel,
469            InternalAuthInterceptor::disabled(),
470        );
471    }
472
473    #[test]
474    fn proto_instance_maps_all_fields_to_domain() {
475        let proto = InstanceInfo {
476            gear_name: "calc".to_owned(),
477            instance_id: "calc-1".to_owned(),
478            endpoint_uri: "http://calc:8080".to_owned(),
479            version: "1.2.3".to_owned(),
480            rest_endpoint_uri: Some("http://calc:8080".to_owned()),
481            openapi_spec: Some("{\"openapi\":\"3.1.0\"}".to_owned()),
482            openapi_spec_hash: None,
483            labels: [("shard".to_owned(), "7".to_owned())].into_iter().collect(),
484            state: ProtoInstanceState::Healthy as i32,
485        };
486        let domain = proto_instance_to_domain(proto);
487        assert_eq!(domain.gear, "calc");
488        assert_eq!(domain.state, InstanceState::Healthy);
489        assert_eq!(domain.instance_id, "calc-1");
490        assert_eq!(
491            domain.endpoint.as_ref().map(|e| e.uri.as_str()),
492            Some("http://calc:8080")
493        );
494        assert_eq!(domain.version.as_deref(), Some("1.2.3"));
495        assert_eq!(
496            domain.rest_endpoint.map(|e| e.uri),
497            Some("http://calc:8080".to_owned())
498        );
499        assert!(domain.openapi_spec.is_some());
500        // Labels cross the wire and land in a BTreeMap for deterministic matching.
501        assert_eq!(domain.labels.get("shard"), Some(&"7".to_owned()));
502    }
503
504    #[test]
505    fn proto_instance_maps_empty_version_to_none() {
506        let proto = InstanceInfo {
507            gear_name: "worker".to_owned(),
508            instance_id: "worker-1".to_owned(),
509            endpoint_uri: "http://worker:7000".to_owned(),
510            version: String::new(),
511            rest_endpoint_uri: None,
512            openapi_spec: None,
513            openapi_spec_hash: None,
514            labels: std::collections::HashMap::new(),
515            state: ProtoInstanceState::Unspecified as i32,
516        };
517        let domain = proto_instance_to_domain(proto);
518        // A non-empty proto endpoint_uri maps to `Some`.
519        assert_eq!(
520            domain.endpoint.as_ref().map(|e| e.uri.as_str()),
521            Some("http://worker:7000")
522        );
523        // An empty proto version string maps to `None` rather than an empty string.
524        assert!(domain.version.is_none());
525        assert!(domain.rest_endpoint.is_none());
526        assert!(domain.openapi_spec.is_none());
527        assert!(domain.labels.is_empty());
528        // An unset proto state (`UNSPECIFIED`) maps to the non-serving Unknown
529        // sentinel — distinct from the pre-serving Registered baseline.
530        assert_eq!(domain.state, InstanceState::Unknown);
531        assert!(!domain.state.is_serving());
532    }
533
534    #[test]
535    fn proto_instance_maps_empty_endpoint_to_none() {
536        // proto3 carries an absent primary endpoint as an empty string; it must
537        // map back to `None`, not an empty-URI sentinel a dialer could mistake
538        // for a real address.
539        let proto = InstanceInfo {
540            gear_name: "grpc-only".to_owned(),
541            instance_id: "g-1".to_owned(),
542            endpoint_uri: String::new(),
543            version: String::new(),
544            rest_endpoint_uri: None,
545            openapi_spec: None,
546            openapi_spec_hash: None,
547            labels: std::collections::HashMap::new(),
548            state: ProtoInstanceState::Ready as i32,
549        };
550        let domain = proto_instance_to_domain(proto);
551        assert!(
552            domain.endpoint.is_none(),
553            "an empty proto endpoint_uri must map to None"
554        );
555    }
556
557    #[test]
558    fn proto_state_unspecified_and_unrecognised_map_to_unknown() {
559        // `UNSPECIFIED` (peer never set the field) and any discriminant this
560        // build does not know (e.g. a newer server) both collapse to the
561        // non-serving Unknown sentinel rather than being read as Registered.
562        assert_eq!(
563            proto_state_to_domain(ProtoInstanceState::Unspecified as i32),
564            InstanceState::Unknown
565        );
566        assert_eq!(proto_state_to_domain(9999), InstanceState::Unknown);
567        assert!(!proto_state_to_domain(9999).is_serving());
568
569        // Known discriminants still map through unchanged.
570        assert_eq!(
571            proto_state_to_domain(ProtoInstanceState::Registered as i32),
572            InstanceState::Registered
573        );
574        assert_eq!(
575            proto_state_to_domain(ProtoInstanceState::Healthy as i32),
576            InstanceState::Healthy
577        );
578    }
579}