cf_system_sdk_directory/grpc/
client.rs1use 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
25type AuthedChannel = InterceptedService<Channel, InternalAuthInterceptor>;
31
32fn 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
52fn 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
68pub struct DirectoryGrpcClient {
79 inner: DirectoryServiceClient<AuthedChannel>,
80}
81
82impl DirectoryGrpcClient {
83 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 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 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 pub async fn connect_no_retry(uri: impl Into<String>, cfg: &GrpcClientConfig) -> Result<Self> {
138 let uri_string = uri.into();
139
140 let endpoint = tonic::transport::Endpoint::from_shared(uri_string)?
142 .connect_timeout(cfg.connect_timeout)
143 .timeout(cfg.rpc_timeout);
144
145 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 #[must_use]
166 pub fn from_channel(channel: Channel) -> Self {
167 Self::from_channel_with_interceptor(channel, InternalAuthInterceptor::disabled())
168 }
169
170 #[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 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 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
377fn 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 grpc_services: Vec::new(),
404 labels: proto.labels.into_iter().collect::<BTreeMap<_, _>>(),
407 state: proto_state_to_domain(proto.state),
409 }
410}
411
412fn 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 let endpoint = tonic::transport::Endpoint::from_static("http://[::1]:50051");
449
450 let channel_result = endpoint.connect().await;
453
454 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 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 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 assert_eq!(
520 domain.endpoint.as_ref().map(|e| e.uri.as_str()),
521 Some("http://worker:7000")
522 );
523 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 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 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 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 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}