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::api::{
11    DirectoryClient, DirectoryInvalidArgument, DirectoryNotFound, RegisterInstanceInfo,
12    ServiceEndpoint, ServiceInstanceInfo,
13};
14use toolkit_transport_grpc::InternalAuthInterceptor;
15use toolkit_transport_grpc::client::{GrpcClientConfig, connect_with_retry};
16
17use crate::{
18    DeregisterInstanceRequest, DirectoryServiceClient, GetOpenApiSpecRequest, GrpcServiceEndpoint,
19    HeartbeatRequest, InstanceInfo, ListAllInstancesRequest, ListInstancesRequest,
20    RegisterInstanceRequest, ResolveGrpcServiceRequest, ResolveRestServiceRequest,
21};
22
23/// The directory channel wrapped with the platform-plane
24/// [`InternalAuthInterceptor`], which attaches the gear's internal token
25/// (`x-toolkit-internal-token`) to every outbound system call. A
26/// [`disabled`](InternalAuthInterceptor::disabled) interceptor attaches
27/// nothing (Profile 1 / no platform-plane credential).
28type AuthedChannel = InterceptedService<Channel, InternalAuthInterceptor>;
29
30/// Map a lookup RPC's `tonic::Status` onto the directory's typed sentinels.
31///
32/// The status code is the only thing distinguishing "this name is not
33/// registered" from "the directory is unreachable", and stringifying the status
34/// throws it away. Callers downcast the result: `DirectoryEndpointResolver`
35/// turns `DirectoryNotFound` into `Ok(None)` (a provider that has not come up
36/// yet — routine during startup) and anything else into a real error.
37fn lookup_error(resource: &str, status: &tonic::Status) -> anyhow::Error {
38    match status.code() {
39        tonic::Code::NotFound => DirectoryNotFound::new(resource.to_owned()).into(),
40        tonic::Code::InvalidArgument => {
41            DirectoryInvalidArgument::new(status.message().to_owned()).into()
42        }
43        code => anyhow::anyhow!(
44            "directory lookup for {resource} failed: gRPC {code:?}: {}",
45            status.message()
46        ),
47    }
48}
49
50/// Map a mutating RPC's `tonic::Status`, preserving the code in the message.
51///
52/// These calls have no not-found semantics worth typing, but a bare
53/// `"gRPC call failed"` hides whether the directory was unreachable
54/// (`Unavailable`) or rejected the request (`Internal`).
55fn call_error(op: &str, status: &tonic::Status) -> anyhow::Error {
56    anyhow::anyhow!(
57        "directory {op} failed: gRPC {:?}: {}",
58        status.code(),
59        status.message()
60    )
61}
62
63/// gRPC client for Directory API
64///
65/// This client connects to a remote `DirectoryService` via gRPC and provides
66/// typed access to service discovery functionality. It includes:
67/// - Configurable timeouts and retries via transport stack
68/// - Automatic proto ↔ domain type conversions
69/// - Distributed tracing and metrics
70/// - Platform-plane credential attachment via an [`InternalAuthInterceptor`]
71///   (defaults to attaching nothing; supply one via the `*_with_interceptor`
72///   constructors for Profile-3 / shared-secret deployments)
73pub struct DirectoryGrpcClient {
74    inner: DirectoryServiceClient<AuthedChannel>,
75}
76
77impl DirectoryGrpcClient {
78    /// Connect to a directory service using default configuration with retries.
79    ///
80    /// Uses exponential backoff retry logic for reliable connection establishment.
81    /// This is the recommended method for `OoP` gears connecting to the master host.
82    ///
83    /// # Errors
84    /// It will return an error when it fails
85    pub async fn connect(uri: impl Into<String>) -> Result<Self> {
86        let cfg = GrpcClientConfig::new("directory");
87        Self::connect_with_retry(uri, &cfg).await
88    }
89
90    /// Connect with default configuration + retries, attaching `interceptor`'s
91    /// platform-plane credential to every outbound call.
92    ///
93    /// This is the Profile-3 / shared-secret entry point: the interceptor is
94    /// typically built from a
95    /// [`ServiceAccountTokenReader`](toolkit_transport_grpc::ServiceAccountTokenReader)
96    /// (rotating SA token) or
97    /// [`InternalAuthInterceptor::from_token`] (static shared secret).
98    ///
99    /// # Errors
100    /// It will return an error when it fails
101    pub async fn connect_with_interceptor(
102        uri: impl Into<String>,
103        interceptor: InternalAuthInterceptor,
104    ) -> Result<Self> {
105        let cfg = GrpcClientConfig::new("directory");
106        let channel: Channel = connect_with_retry(uri, &cfg).await?;
107        Ok(Self::from_channel_with_interceptor(channel, interceptor))
108    }
109
110    /// Connect to a directory service with custom configuration and retry logic.
111    ///
112    /// Uses exponential backoff based on `cfg.max_retries`, `cfg.base_backoff`,
113    /// and `cfg.max_backoff` settings.
114    ///
115    /// # Errors
116    /// It will return an error when it fails
117    pub async fn connect_with_retry(
118        uri: impl Into<String>,
119        cfg: &GrpcClientConfig,
120    ) -> Result<Self> {
121        let channel: Channel = connect_with_retry(uri, cfg).await?;
122        Ok(Self::from_channel(channel))
123    }
124
125    /// Connect to a directory service without retry logic.
126    ///
127    /// This method attempts a single connection. Use `connect` or `connect_with_retry`
128    /// for production scenarios where the directory service may not be immediately available.
129    ///
130    /// # Errors
131    /// It will return an error when it fails
132    pub async fn connect_no_retry(uri: impl Into<String>, cfg: &GrpcClientConfig) -> Result<Self> {
133        let uri_string = uri.into();
134
135        // Create endpoint with timeouts from config
136        let endpoint = tonic::transport::Endpoint::from_shared(uri_string)?
137            .connect_timeout(cfg.connect_timeout)
138            .timeout(cfg.rpc_timeout);
139
140        // Connect to the service
141        let channel = endpoint.connect().await?;
142
143        if cfg.enable_tracing {
144            tracing::debug!(
145                service_name = cfg.service_name,
146                connect_timeout_ms = cfg.connect_timeout.as_millis(),
147                rpc_timeout_ms = cfg.rpc_timeout.as_millis(),
148                "directory gRPC client connected"
149            );
150        }
151
152        Ok(Self::from_channel(channel))
153    }
154
155    /// Create from an existing channel (useful for testing or custom setup).
156    ///
157    /// Attaches no platform-plane credential; use
158    /// [`from_channel_with_interceptor`](Self::from_channel_with_interceptor)
159    /// to attach one.
160    #[must_use]
161    pub fn from_channel(channel: Channel) -> Self {
162        Self::from_channel_with_interceptor(channel, InternalAuthInterceptor::disabled())
163    }
164
165    /// Create from an existing channel, attaching `interceptor`'s platform-plane
166    /// credential to every outbound call.
167    #[must_use]
168    pub fn from_channel_with_interceptor(
169        channel: Channel,
170        interceptor: InternalAuthInterceptor,
171    ) -> Self {
172        Self {
173            inner: DirectoryServiceClient::with_interceptor(channel, interceptor),
174        }
175    }
176}
177
178#[async_trait]
179impl DirectoryClient for DirectoryGrpcClient {
180    async fn resolve_grpc_service(&self, service_name: &str) -> Result<ServiceEndpoint> {
181        let mut client = self.inner.clone();
182        let request = tonic::Request::new(ResolveGrpcServiceRequest {
183            service_name: service_name.to_owned(),
184        });
185
186        let response = client
187            .resolve_grpc_service(request)
188            .await
189            .map_err(|e| lookup_error(&format!("service {service_name}"), &e))?;
190
191        let proto_response = response.into_inner();
192        Ok(ServiceEndpoint::new(proto_response.endpoint_uri))
193    }
194
195    async fn resolve_rest_service(&self, gear_name: &str) -> Result<ServiceEndpoint> {
196        let mut client = self.inner.clone();
197        let request = tonic::Request::new(ResolveRestServiceRequest {
198            gear_name: gear_name.to_owned(),
199        });
200
201        let response = client
202            .resolve_rest_service(request)
203            .await
204            .map_err(|e| lookup_error(&format!("gear {gear_name}"), &e))?;
205
206        let proto_response = response.into_inner();
207        Ok(ServiceEndpoint::new(proto_response.endpoint_uri))
208    }
209
210    async fn get_openapi_spec(&self, gear_name: &str) -> Result<String> {
211        let mut client = self.inner.clone();
212        let request = tonic::Request::new(GetOpenApiSpecRequest {
213            gear_name: gear_name.to_owned(),
214        });
215
216        let response = client
217            .get_open_api_spec(request)
218            .await
219            .map_err(|e| lookup_error(&format!("openapi spec for gear {gear_name}"), &e))?;
220
221        Ok(response.into_inner().openapi_spec)
222    }
223
224    async fn list_instances(&self, gear: &str) -> Result<Vec<ServiceInstanceInfo>> {
225        let mut client = self.inner.clone();
226        let request = tonic::Request::new(ListInstancesRequest {
227            gear_name: gear.to_owned(),
228        });
229
230        let response = client
231            .list_instances(request)
232            .await
233            .map_err(|e| lookup_error(&format!("instances of gear {gear}"), &e))?;
234
235        let instances = response
236            .into_inner()
237            .instances
238            .into_iter()
239            .map(proto_instance_to_domain)
240            .collect();
241
242        Ok(instances)
243    }
244
245    async fn list_all_instances(&self) -> Result<Vec<ServiceInstanceInfo>> {
246        let mut client = self.inner.clone();
247        let response = client
248            .list_all_instances(tonic::Request::new(ListAllInstancesRequest {}))
249            .await
250            .map_err(|e| lookup_error("all instances", &e))?;
251
252        let instances = response
253            .into_inner()
254            .instances
255            .into_iter()
256            .map(|proto| {
257                let mut info = proto_instance_to_domain(proto);
258                info.openapi_spec = None;
259                info
260            })
261            .collect();
262
263        Ok(instances)
264    }
265
266    async fn register_instance(&self, info: RegisterInstanceInfo) -> Result<()> {
267        let mut client = self.inner.clone();
268
269        // Convert gRPC service endpoints
270        let grpc_services = info
271            .grpc_services
272            .into_iter()
273            .map(|(name, ep)| GrpcServiceEndpoint {
274                service_name: name,
275                endpoint_uri: ep.uri,
276            })
277            .collect();
278
279        let req = RegisterInstanceRequest {
280            gear_name: info.gear,
281            instance_id: info.instance_id,
282            grpc_services,
283            version: info.version.unwrap_or_default(),
284            rest_endpoint_uri: info.rest_endpoint.map(|ep| ep.uri),
285            openapi_spec: info.openapi_spec,
286        };
287
288        client
289            .register_instance(tonic::Request::new(req))
290            .await
291            .map_err(|e| call_error("register_instance", &e))?;
292
293        Ok(())
294    }
295
296    async fn deregister_instance(&self, gear: &str, instance_id: &str) -> Result<()> {
297        let mut client = self.inner.clone();
298
299        let req = DeregisterInstanceRequest {
300            gear_name: gear.to_owned(),
301            instance_id: instance_id.to_owned(),
302        };
303
304        client
305            .deregister_instance(tonic::Request::new(req))
306            .await
307            .map_err(|e| call_error("deregister_instance", &e))?;
308
309        Ok(())
310    }
311
312    async fn send_heartbeat(&self, gear: &str, instance_id: &str) -> Result<()> {
313        let mut client = self.inner.clone();
314
315        let req = HeartbeatRequest {
316            gear_name: gear.to_owned(),
317            instance_id: instance_id.to_owned(),
318        };
319
320        client
321            .heartbeat(tonic::Request::new(req))
322            .await
323            .map_err(|e| call_error("heartbeat", &e))?;
324
325        Ok(())
326    }
327}
328
329/// Convert a proto `InstanceInfo` into the domain [`ServiceInstanceInfo`].
330fn proto_instance_to_domain(proto: InstanceInfo) -> ServiceInstanceInfo {
331    ServiceInstanceInfo {
332        gear: proto.gear_name,
333        instance_id: proto.instance_id,
334        endpoint: ServiceEndpoint::new(proto.endpoint_uri),
335        version: if proto.version.is_empty() {
336            None
337        } else {
338            Some(proto.version)
339        },
340        rest_endpoint: proto.rest_endpoint_uri.map(ServiceEndpoint::new),
341        openapi_spec: proto.openapi_spec,
342        openapi_spec_hash: proto.openapi_spec_hash,
343        // The list-instances proto response does not break the instance down
344        // per gRPC service, so nothing to carry back over the OoP directory
345        // transport. The in-process `LocalDirectoryClient` populates this from
346        // the live `GearInstance`.
347        grpc_services: Vec::new(),
348    }
349}
350
351#[cfg(test)]
352#[cfg_attr(coverage_nightly, coverage(off))]
353mod tests {
354    use super::*;
355
356    #[tokio::test]
357    async fn test_grpc_client_can_be_constructed() {
358        // Smoke test to ensure types compile and connect
359        let endpoint = tonic::transport::Endpoint::from_static("http://[::1]:50051");
360
361        // We can't actually connect without a server, but we can construct the client type
362        // This ensures the API is correct
363        let channel_result = endpoint.connect().await;
364
365        // It's expected to fail since there's no server, but if it does somehow succeed:
366        if let Ok(channel) = channel_result {
367            let _client = DirectoryGrpcClient::from_channel(channel);
368        }
369    }
370
371    #[tokio::test]
372    async fn from_channel_constructs_without_connecting() {
373        // `connect_lazy` yields a Channel without a live server, so both the
374        // default (no-credential) and interceptor-bearing constructors can be
375        // exercised offline.
376        let channel = Channel::from_static("http://[::1]:50051").connect_lazy();
377        let _default = DirectoryGrpcClient::from_channel(channel.clone());
378        let _authed = DirectoryGrpcClient::from_channel_with_interceptor(
379            channel,
380            InternalAuthInterceptor::disabled(),
381        );
382    }
383
384    #[test]
385    fn proto_instance_maps_all_fields_to_domain() {
386        let proto = InstanceInfo {
387            gear_name: "calc".to_owned(),
388            instance_id: "calc-1".to_owned(),
389            endpoint_uri: "http://calc:8080".to_owned(),
390            version: "1.2.3".to_owned(),
391            rest_endpoint_uri: Some("http://calc:8080".to_owned()),
392            openapi_spec: Some("{\"openapi\":\"3.1.0\"}".to_owned()),
393            openapi_spec_hash: None,
394        };
395        let domain = proto_instance_to_domain(proto);
396        assert_eq!(domain.gear, "calc");
397        assert_eq!(domain.instance_id, "calc-1");
398        assert_eq!(domain.endpoint.uri, "http://calc:8080");
399        assert_eq!(domain.version.as_deref(), Some("1.2.3"));
400        assert_eq!(
401            domain.rest_endpoint.map(|e| e.uri),
402            Some("http://calc:8080".to_owned())
403        );
404        assert!(domain.openapi_spec.is_some());
405    }
406
407    #[test]
408    fn proto_instance_maps_empty_version_to_none() {
409        let proto = InstanceInfo {
410            gear_name: "worker".to_owned(),
411            instance_id: "worker-1".to_owned(),
412            endpoint_uri: "http://worker:7000".to_owned(),
413            version: String::new(),
414            rest_endpoint_uri: None,
415            openapi_spec: None,
416            openapi_spec_hash: None,
417        };
418        let domain = proto_instance_to_domain(proto);
419        // An empty proto version string maps to `None` rather than an empty string.
420        assert!(domain.version.is_none());
421        assert!(domain.rest_endpoint.is_none());
422        assert!(domain.openapi_spec.is_none());
423    }
424}