cf_system_sdk_directory/api.rs
1//! Directory API - contract for service discovery and instance resolution
2//!
3//! This gear defines the core traits and types for the directory service API.
4
5use anyhow::Result;
6use async_trait::async_trait;
7use std::collections::BTreeMap;
8
9/// Represents an endpoint where a service can be reached
10#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
11pub struct ServiceEndpoint {
12 pub uri: String,
13}
14
15impl ServiceEndpoint {
16 pub fn new(uri: impl Into<String>) -> Self {
17 Self { uri: uri.into() }
18 }
19
20 #[must_use]
21 pub fn http(host: &str, port: u16) -> Self {
22 Self {
23 uri: format!("{}://{}:{}", "http", host, port),
24 }
25 }
26
27 #[must_use]
28 pub fn https(host: &str, port: u16) -> Self {
29 Self {
30 uri: format!("https://{host}:{port}"),
31 }
32 }
33
34 pub fn uds(path: impl AsRef<std::path::Path>) -> Self {
35 Self {
36 uri: format!("unix://{}", path.as_ref().display()),
37 }
38 }
39}
40
41/// Label-equality selector over instance labels (k8s `matchLabels` style).
42///
43/// Matching is equality-AND: an instance matches iff it carries **every**
44/// `(key, value)` pair in `match_labels`. An empty selector matches every
45/// instance. This is the selector consumed by
46/// [`DirectoryClient::resolve_by_labels`].
47#[derive(Clone, Debug, Default, PartialEq, Eq)]
48pub struct LabelSelector {
49 /// Required label equalities; all must match.
50 pub match_labels: BTreeMap<String, String>,
51}
52
53impl LabelSelector {
54 /// An empty selector (matches every instance).
55 #[must_use]
56 pub fn new() -> Self {
57 Self::default()
58 }
59
60 /// Build a selector from an existing `matchLabels` map.
61 #[must_use]
62 pub fn from_match_labels(match_labels: BTreeMap<String, String>) -> Self {
63 Self { match_labels }
64 }
65
66 /// Add one required label equality, returning `self` for chaining.
67 #[must_use]
68 pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
69 self.match_labels.insert(key.into(), value.into());
70 self
71 }
72
73 /// Whether this selector constrains nothing (matches every instance).
74 #[must_use]
75 pub fn is_empty(&self) -> bool {
76 self.match_labels.is_empty()
77 }
78
79 /// Whether `labels` satisfies every required equality in this selector.
80 #[must_use]
81 pub fn matches(&self, labels: &BTreeMap<String, String>) -> bool {
82 self.match_labels
83 .iter()
84 .all(|(k, v)| labels.get(k) == Some(v))
85 }
86}
87
88/// Serving state of a directory instance, projected from the directory's live
89/// registration/heartbeat tracking.
90///
91/// Only [`Ready`](Self::Ready) / [`Healthy`](Self::Healthy) are **serving**;
92/// [`Draining`](Self::Draining) is a graceful shutdown (callers should avoid
93/// sending new work) and [`Quarantined`](Self::Quarantined) is stale (missed
94/// heartbeats). [`Registered`](Self::Registered) is the pre-serving baseline.
95/// Exposed on [`ServiceInstanceInfo`] so [`DirectoryClient::resolve_by_labels`]
96/// callers can apply their own health policy from a single resolve.
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
98pub enum InstanceState {
99 /// Registered but not yet serving (no heartbeat / readiness signal).
100 #[default]
101 Registered,
102 /// Dependencies resolved and marked ready.
103 Ready,
104 /// Serving and heartbeating.
105 Healthy,
106 /// Missed its heartbeat deadline; not serving, pending eviction.
107 Quarantined,
108 /// Gracefully shutting down; upstreams should stop routing new work.
109 Draining,
110 /// State could not be determined.
111 Unknown,
112}
113
114impl InstanceState {
115 /// Whether an instance in this state should receive new traffic
116 /// (`Ready` / `Healthy`). All other states are non-serving.
117 #[must_use]
118 pub fn is_serving(self) -> bool {
119 matches!(self, InstanceState::Ready | InstanceState::Healthy)
120 }
121}
122
123/// Information about a service instance
124#[derive(Debug, Clone, Default)]
125#[non_exhaustive]
126pub struct ServiceInstanceInfo {
127 /// Gear name this instance belongs to
128 pub gear: String,
129 /// Unique instance identifier
130 pub instance_id: String,
131 /// Primary endpoint for the instance
132 pub endpoint: Option<ServiceEndpoint>,
133 /// Optional version string
134 pub version: Option<String>,
135 /// REST endpoint (HTTP base URL) for this instance, or `None` if the gear
136 /// exposes no REST API. This is the address the edge reverse-proxy actually
137 /// dials for REST routing.
138 pub rest_endpoint: Option<ServiceEndpoint>,
139 /// Stable content token for the published `OpenAPI` spec, if any.
140 ///
141 /// Enumeration is deliberately spec-free: only this hash rides along, never
142 /// the full document (fetched out-of-band via
143 /// [`DirectoryClient::get_openapi_spec`]). The hash lets a consumer detect a
144 /// spec change without inlining N copies of the document.
145 pub openapi_spec_hash: Option<String>,
146 /// Published gRPC services as `(service name, endpoint)` pairs.
147 ///
148 /// Kept as an ordered list rather than a map so it round-trips a repeated
149 /// proto field without reordering; service names are unique per instance.
150 /// Carried back by `list_instances` so a subsequent `register_instance`
151 /// (which replaces the entry wholesale) can augment — rather than clobber —
152 /// the previously-registered gRPC services when adding a REST endpoint.
153 pub grpc_services: Vec<(String, ServiceEndpoint)>,
154 /// Stable addressing labels published by this instance (k8s `matchLabels`
155 /// style). Used by [`DirectoryClient::resolve_by_labels`] to select shards
156 /// or peers within a directory name.
157 pub labels: BTreeMap<String, String>,
158 /// Live serving state, so a `resolve_by_labels` caller can apply its own
159 /// health policy without a second call. Defaults to
160 /// [`InstanceState::Registered`].
161 pub state: InstanceState,
162}
163
164impl ServiceInstanceInfo {
165 /// Start a projection for `gear` / `instance_id` with no primary endpoint
166 /// and no optional metadata. Chain the `with_*` builders to populate it.
167 /// `#[non_exhaustive]` means callers must use this builder rather than a
168 /// struct literal, so future fields stay additive.
169 #[must_use]
170 pub fn new(gear: impl Into<String>, instance_id: impl Into<String>) -> Self {
171 Self {
172 gear: gear.into(),
173 instance_id: instance_id.into(),
174 ..Self::default()
175 }
176 }
177
178 /// Set the primary endpoint (`None` for an instance with no primary
179 /// endpoint).
180 #[must_use]
181 pub fn with_endpoint(mut self, endpoint: Option<ServiceEndpoint>) -> Self {
182 self.endpoint = endpoint;
183 self
184 }
185
186 /// Set the optional version string.
187 #[must_use]
188 pub fn with_version(mut self, version: Option<String>) -> Self {
189 self.version = version;
190 self
191 }
192
193 /// Set the optional REST endpoint.
194 #[must_use]
195 pub fn with_rest_endpoint(mut self, rest_endpoint: Option<ServiceEndpoint>) -> Self {
196 self.rest_endpoint = rest_endpoint;
197 self
198 }
199
200 /// Set the optional `OpenAPI` spec content token.
201 #[must_use]
202 pub fn with_openapi_spec_hash(mut self, openapi_spec_hash: Option<String>) -> Self {
203 self.openapi_spec_hash = openapi_spec_hash;
204 self
205 }
206
207 /// Set the published gRPC services.
208 #[must_use]
209 pub fn with_grpc_services(mut self, grpc_services: Vec<(String, ServiceEndpoint)>) -> Self {
210 self.grpc_services = grpc_services;
211 self
212 }
213
214 /// Set the stable addressing labels.
215 #[must_use]
216 pub fn with_labels(mut self, labels: BTreeMap<String, String>) -> Self {
217 self.labels = labels;
218 self
219 }
220
221 /// Set the live serving state.
222 #[must_use]
223 pub fn with_state(mut self, state: InstanceState) -> Self {
224 self.state = state;
225 self
226 }
227
228 /// Drop the stable addressing labels, returning `self`.
229 ///
230 /// The shared transform every [`DirectoryClient::list_all_instances`]
231 /// implementation applies so the cross-gear snapshot omits labels
232 /// identically across transports — see that method's doc for why.
233 #[must_use]
234 pub fn without_labels(mut self) -> Self {
235 self.labels.clear();
236 self
237 }
238}
239
240/// Information for registering a new gear instance
241#[derive(Debug, Clone, Default)]
242#[non_exhaustive]
243pub struct RegisterInstanceInfo {
244 /// Gear name
245 pub gear: String,
246 /// Unique instance identifier
247 pub instance_id: String,
248 /// Published gRPC services as `(service name, endpoint)` pairs (service
249 /// names are unique per instance; ordered to mirror the proto repeated
250 /// field rather than reordered into a map).
251 pub grpc_services: Vec<(String, ServiceEndpoint)>,
252 /// Optional version string
253 pub version: Option<String>,
254 /// Optional REST endpoint (HTTP base URL) exposed by the gear.
255 pub rest_endpoint: Option<ServiceEndpoint>,
256 /// Optional `OpenAPI` spec (JSON) published by the gear.
257 pub openapi_spec: Option<String>,
258 /// Stable addressing labels for this instance (k8s `matchLabels` style).
259 pub labels: BTreeMap<String, String>,
260}
261
262impl RegisterInstanceInfo {
263 /// Start a registration for `gear` / `instance_id` with no endpoints,
264 /// version, spec, or labels. Chain the `with_*` builders to populate it.
265 #[must_use]
266 pub fn new(gear: impl Into<String>, instance_id: impl Into<String>) -> Self {
267 Self {
268 gear: gear.into(),
269 instance_id: instance_id.into(),
270 ..Self::default()
271 }
272 }
273
274 /// Set the published gRPC services.
275 #[must_use]
276 pub fn with_grpc_services(mut self, grpc_services: Vec<(String, ServiceEndpoint)>) -> Self {
277 self.grpc_services = grpc_services;
278 self
279 }
280
281 /// Set the version string.
282 #[must_use]
283 pub fn with_version(mut self, version: impl Into<String>) -> Self {
284 self.version = Some(version.into());
285 self
286 }
287
288 /// Set the REST endpoint (HTTP base URL).
289 #[must_use]
290 pub fn with_rest_endpoint(mut self, rest_endpoint: ServiceEndpoint) -> Self {
291 self.rest_endpoint = Some(rest_endpoint);
292 self
293 }
294
295 /// Set the published `OpenAPI` spec (JSON).
296 #[must_use]
297 pub fn with_openapi_spec(mut self, openapi_spec: impl Into<String>) -> Self {
298 self.openapi_spec = Some(openapi_spec.into());
299 self
300 }
301
302 /// Set the stable addressing labels.
303 ///
304 /// Re-registration replaces the directory entry, but an **empty** label
305 /// set is treated as "preserve the stored labels", not "clear them" (see
306 /// `GearManager::with_metadata_of`, which treats an absent REST endpoint,
307 /// `OpenAPI` spec or gRPC service set the same way): the periodic self-heal
308 /// and REST augmentation re-register without labels and must not erase a
309 /// shard's identity. A non-empty set replaces the stored one. Clearing labels
310 /// outright is therefore intentionally not expressible — an instance's
311 /// labels are its static shard/peer identity for its lifetime; changing
312 /// them means restarting with new configuration.
313 #[must_use]
314 pub fn with_labels(mut self, labels: BTreeMap<String, String>) -> Self {
315 self.labels = labels;
316 self
317 }
318}
319
320/// A resolved gRPC service and the endpoint it is reachable at.
321#[derive(Clone, Debug, PartialEq, Eq, Hash)]
322pub struct GrpcServiceInfo {
323 /// Fully-qualified gRPC service name (e.g. `payment.v1.PaymentApi`).
324 pub service_name: String,
325 /// Endpoint the service is reachable at.
326 pub endpoint: ServiceEndpoint,
327}
328
329impl GrpcServiceInfo {
330 pub fn new(service_name: impl Into<String>, endpoint: ServiceEndpoint) -> Self {
331 Self {
332 service_name: service_name.into(),
333 endpoint,
334 }
335 }
336}
337
338/// Sentinel error wrapped via `anyhow::Error` to signal "the requested gear or
339/// service is not registered (or has no live instance)" through the
340/// [`DirectoryClient`] trait. Consumers downcast to this type to distinguish a
341/// not-ready provider (eventual readiness) from a directory-backend failure —
342/// see `toolkit::discovery::DirectoryEndpointResolver`.
343#[derive(Debug, Clone, PartialEq, Eq)]
344pub struct DirectoryNotFound {
345 /// What was being looked up — e.g. `"gear foo"` or `"service foo.Bar"`.
346 pub resource: String,
347}
348
349impl DirectoryNotFound {
350 pub fn new(resource: impl Into<String>) -> Self {
351 Self {
352 resource: resource.into(),
353 }
354 }
355}
356
357impl std::fmt::Display for DirectoryNotFound {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 write!(f, "directory: not found: {}", self.resource)
360 }
361}
362
363impl std::error::Error for DirectoryNotFound {}
364
365/// Sentinel error wrapped via `anyhow::Error` to signal "client-supplied
366/// argument is malformed" (e.g. invalid UUID) through the [`DirectoryClient`]
367/// trait. Allows the gRPC server boundary to return `Status::invalid_argument`
368/// instead of mislabeling a client bug as an internal failure.
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct DirectoryInvalidArgument {
371 /// Human-readable description of what was invalid.
372 pub message: String,
373}
374
375impl DirectoryInvalidArgument {
376 pub fn new(message: impl Into<String>) -> Self {
377 Self {
378 message: message.into(),
379 }
380 }
381}
382
383impl std::fmt::Display for DirectoryInvalidArgument {
384 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385 write!(f, "directory: invalid argument: {}", self.message)
386 }
387}
388
389impl std::error::Error for DirectoryInvalidArgument {}
390
391/// Sentinel error (wrapped via `anyhow::Error`) signalling a **permanent**
392/// authorization refusal: retrying the identical request can never turn a "no"
393/// into a "yes". Carries the gRPC `PermissionDenied` code across the
394/// [`DirectoryClient`] boundary (otherwise lost when a `tonic::Status` is
395/// stringified) so the presence loop stops retrying and logs loudly instead of
396/// spinning at `warn!`. Reached when the peer is not authorized for the gear, its
397/// namespace / trust domain is not allowlisted, or a service name is *pinned* to
398/// another gear (a non-recoverable [`DirectoryServiceNameConflict`]; a recoverable
399/// one is `FailedPrecondition`).
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct DirectoryPermissionDenied {
402 /// Human-readable description of why the call was refused.
403 pub message: String,
404}
405
406impl DirectoryPermissionDenied {
407 pub fn new(message: impl Into<String>) -> Self {
408 Self {
409 message: message.into(),
410 }
411 }
412}
413
414impl std::fmt::Display for DirectoryPermissionDenied {
415 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416 write!(f, "directory: permission denied: {}", self.message)
417 }
418}
419
420impl std::error::Error for DirectoryPermissionDenied {}
421
422/// Sentinel error signalling "a gRPC service name in this registration is already
423/// owned by a *different* gear" (single-gear ownership is enforced atomically in
424/// `GearManager::register_instance`). The gRPC boundary logs the conflicting
425/// `service_name` / `owner` server-side and returns a static-message status whose
426/// code depends on [`recoverable`](Self::recoverable):
427///
428/// - `recoverable` → `Status::failed_precondition`: another gear merely
429/// *currently advertises* the name; it clears when that gear deregisters, so
430/// the registrant retries.
431/// - not `recoverable` → `Status::permission_denied`: the name is pinned to
432/// another gear by the ownership map; retrying can never reassign it.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct DirectoryServiceNameConflict {
435 /// The gRPC service name that is already owned.
436 pub service_name: String,
437 /// The gear that currently owns `service_name`.
438 pub owner: String,
439 /// Whether waiting could clear the conflict (see the type docs). `true` for
440 /// a current-advertiser conflict, `false` for a pinned-ownership conflict.
441 pub recoverable: bool,
442}
443
444impl std::fmt::Display for DirectoryServiceNameConflict {
445 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
446 write!(
447 f,
448 "directory: gRPC service name '{}' already owned by gear '{}'",
449 self.service_name, self.owner
450 )
451 }
452}
453
454impl std::error::Error for DirectoryServiceNameConflict {}
455
456/// Directory API trait for service discovery and instance management
457///
458/// This trait defines the contract for interacting with the gear directory.
459/// It can be implemented by:
460/// - A local implementation that delegates to `GearManager`
461/// - A gRPC client for out-of-process gears
462#[async_trait]
463pub trait DirectoryClient: Send + Sync {
464 /// Resolve a gRPC service by its logical name to an endpoint
465 async fn resolve_grpc_service(&self, service_name: &str) -> Result<ServiceEndpoint>;
466
467 /// Resolve a REST endpoint (HTTP base URL) for a gear by its name.
468 ///
469 /// Returns the base URL (e.g. `http://billing:8080`) that callers use to
470 /// make REST requests to the resolved gear.
471 async fn resolve_rest_service(&self, gear_name: &str) -> Result<ServiceEndpoint>;
472
473 /// Retrieve the `OpenAPI` spec (JSON) published by a gear.
474 async fn get_openapi_spec(&self, gear_name: &str) -> Result<String>;
475
476 /// List all service instances for a given gear.
477 ///
478 /// Entries are **spec-free**: only the `openapi_spec_hash` is carried, never
479 /// the full `OpenAPI` document, so a multi-instance response stays
480 /// bounded regardless of spec size. Callers fetch the document out-of-band
481 /// via [`get_openapi_spec`](Self::get_openapi_spec); the hash lets a consumer
482 /// detect a spec change without inlining N copies of the document.
483 ///
484 /// An endpoint-less instance is still returned (`endpoint`/`rest_endpoint` =
485 /// `None`, never an empty-URI sentinel): *no-endpoint, not an error*. Only
486 /// [`list_all_instances`](Self::list_all_instances), which needs a dialable
487 /// URI, drops such entries.
488 async fn list_instances(&self, gear: &str) -> Result<Vec<ServiceInstanceInfo>>;
489
490 /// Resolve instances of `gear` whose labels satisfy `selector`.
491 ///
492 /// Matching is equality-AND (k8s `matchLabels`): an instance matches iff
493 /// it carries **every** `(key, value)` pair in the selector; an empty
494 /// selector matches every instance of the name.
495 ///
496 /// The returned set is **not** health-filtered — matched instances are
497 /// returned regardless of readiness/heartbeat state, each carrying its
498 /// `instance_id`, `labels`, endpoints, and live
499 /// [`state`](ServiceInstanceInfo::state). Applying liveness policy (e.g.
500 /// keeping only [`InstanceState::is_serving`]) is the caller's
501 /// responsibility.
502 ///
503 /// Nor is it **endpoint-filtered**: a matched instance that advertises no
504 /// endpoint yet is still returned (`endpoint`/`rest_endpoint` = `None`) —
505 /// *no-endpoint, not an error* — so the caller can fall back rather than
506 /// have the match silently hidden.
507 ///
508 /// Returned entries are **spec-free** on every implementation — the full
509 /// `OpenAPI` document is omitted (only its hash is carried), so the
510 /// polled resolve payload stays bounded; the caller fetches documents via
511 /// [`get_openapi_spec`](Self::get_openapi_spec). This holds regardless of
512 /// whether the selector is empty. The default body enumerates via
513 /// [`list_instances`](Self::list_instances) and filters in-process;
514 /// transport-backed clients (e.g. the gRPC client) override it to push the
515 /// selector server-side.
516 ///
517 /// Label-based selection is effectively **out-of-process only**. Labels are
518 /// published from the `OoP` serve path's configuration (`oop_http.labels`);
519 /// the in-process registration path (grpc-hub start phase /
520 /// `run_directory_register_phase`) has no label source, so an in-process
521 /// gear carries no labels and a non-empty selector never matches it. This
522 /// matches the deployment model: shards/peers are distinct instances
523 /// (separate pods/processes), which is inherently the `OoP` topology.
524 async fn resolve_by_labels(
525 &self,
526 gear: &str,
527 selector: &LabelSelector,
528 ) -> Result<Vec<ServiceInstanceInfo>> {
529 // cancel-safe: the single await precedes any mutation; cancelling here
530 // just drops the in-flight list and leaves no partial state.
531 let instances = self.list_instances(gear).await?;
532 // Entries are spec-free by construction — `ServiceInstanceInfo` carries
533 // only the `openapi_spec_hash`, never the full OpenAPI document.
534 Ok(instances
535 .into_iter()
536 .filter(|i| selector.matches(&i.labels))
537 .collect())
538 }
539
540 /// List every service instance across all registered gears.
541 ///
542 /// Used by the edge gateway to discover which gears (and their REST
543 /// endpoints) to reverse-proxy. This is a lightweight discovery snapshot.
544 /// Like every enumeration path, it is **spec-free**: entries carry only the
545 /// `openapi_spec_hash`, never the full `OpenAPI` document, even when the
546 /// backing store holds a stored specification. The edge fetches a gear's
547 /// document once, on first discovery, via
548 /// [`get_openapi_spec`](Self::get_openapi_spec).
549 ///
550 /// The returned instances also do **not** carry `labels`: every
551 /// implementation applies [`ServiceInstanceInfo::without_labels`] so the
552 /// snapshot omits them identically regardless of transport. Labels drive
553 /// the targeted [`resolve_by_labels`](Self::resolve_by_labels) path, not
554 /// this snapshot.
555 async fn list_all_instances(&self) -> Result<Vec<ServiceInstanceInfo>>;
556
557 /// Register a new gear instance with the directory
558 async fn register_instance(&self, info: RegisterInstanceInfo) -> Result<()>;
559
560 /// Deregister a gear instance (for graceful shutdown)
561 async fn deregister_instance(&self, gear: &str, instance_id: &str) -> Result<()>;
562
563 /// Send a heartbeat for a gear instance to indicate it's still alive
564 async fn send_heartbeat(&self, gear: &str, instance_id: &str) -> Result<()>;
565}
566
567#[cfg(test)]
568#[cfg_attr(coverage_nightly, coverage(off))]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn test_service_endpoint_creation() {
574 let http_ep = ServiceEndpoint::http("localhost", 8080);
575 assert_eq!(http_ep.uri, concat!("http", "://localhost:8080"));
576
577 let https_endpoint = ServiceEndpoint::https("localhost", 8443);
578 assert_eq!(https_endpoint.uri, "https://localhost:8443");
579
580 let uds_ep = ServiceEndpoint::uds("/tmp/socket.sock");
581 assert!(uds_ep.uri.starts_with("unix://"));
582 assert!(uds_ep.uri.contains("socket.sock"));
583
584 let custom_ep = ServiceEndpoint::new(concat!("http", "://example.com"));
585 assert_eq!(custom_ep.uri, concat!("http", "://example.com"));
586 }
587
588 #[test]
589 fn test_register_instance_info() {
590 let info = RegisterInstanceInfo::new("test_gear", "instance1")
591 .with_grpc_services(vec![(
592 "test.Service".to_owned(),
593 ServiceEndpoint::http("127.0.0.1", 8001),
594 )])
595 .with_version("1.0.0");
596
597 assert_eq!(info.gear, "test_gear");
598 assert_eq!(info.instance_id, "instance1");
599 assert_eq!(info.grpc_services.len(), 1);
600 assert!(info.rest_endpoint.is_none());
601 assert!(info.openapi_spec.is_none());
602 assert!(info.labels.is_empty());
603 }
604
605 #[test]
606 fn test_register_instance_info_with_rest() {
607 let info = RegisterInstanceInfo {
608 gear: "billing".to_owned(),
609 instance_id: "instance1".to_owned(),
610 grpc_services: vec![],
611 version: Some("2.0.0".to_owned()),
612 labels: BTreeMap::new(),
613 rest_endpoint: Some(ServiceEndpoint::http("billing", 8080)),
614 openapi_spec: Some("{\"openapi\":\"3.1.0\"}".to_owned()),
615 };
616
617 assert_eq!(info.gear, "billing");
618 assert_eq!(
619 info.rest_endpoint.as_ref().unwrap().uri,
620 concat!("http", "://billing:8080")
621 );
622 assert!(info.openapi_spec.is_some());
623 }
624
625 fn labels(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
626 pairs
627 .iter()
628 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
629 .collect()
630 }
631
632 #[test]
633 fn label_selector_equality_and_matches() {
634 let selector = LabelSelector::new()
635 .with("shard", "7")
636 .with("role", "ingest");
637
638 // Superset of the required labels matches.
639 assert!(selector.matches(&labels(&[
640 ("shard", "7"),
641 ("role", "ingest"),
642 ("extra", "x"),
643 ])));
644 // Missing one required key fails.
645 assert!(!selector.matches(&labels(&[("shard", "7")])));
646 // Wrong value on a required key fails.
647 assert!(!selector.matches(&labels(&[("shard", "8"), ("role", "ingest")])));
648 }
649
650 #[test]
651 fn empty_label_selector_matches_everything() {
652 let selector = LabelSelector::new();
653 assert!(selector.is_empty());
654 assert!(selector.matches(&BTreeMap::new()));
655 assert!(selector.matches(&labels(&[("shard", "7")])));
656 }
657
658 struct StaticDirectory {
659 instances: Vec<ServiceInstanceInfo>,
660 }
661
662 #[async_trait]
663 impl DirectoryClient for StaticDirectory {
664 async fn resolve_grpc_service(&self, service_name: &str) -> Result<ServiceEndpoint> {
665 Err(DirectoryNotFound::new(format!("service {service_name}")).into())
666 }
667 async fn resolve_rest_service(&self, gear_name: &str) -> Result<ServiceEndpoint> {
668 Err(DirectoryNotFound::new(format!("gear {gear_name}")).into())
669 }
670 async fn get_openapi_spec(&self, gear_name: &str) -> Result<String> {
671 Err(DirectoryNotFound::new(format!("spec {gear_name}")).into())
672 }
673 async fn list_instances(&self, gear: &str) -> Result<Vec<ServiceInstanceInfo>> {
674 Ok(self
675 .instances
676 .iter()
677 .filter(|i| i.gear == gear)
678 .cloned()
679 .collect())
680 }
681 async fn list_all_instances(&self) -> Result<Vec<ServiceInstanceInfo>> {
682 Ok(self.instances.clone())
683 }
684 async fn register_instance(&self, _info: RegisterInstanceInfo) -> Result<()> {
685 Ok(())
686 }
687 async fn deregister_instance(&self, _gear: &str, _instance_id: &str) -> Result<()> {
688 Ok(())
689 }
690 async fn send_heartbeat(&self, _gear: &str, _instance_id: &str) -> Result<()> {
691 Ok(())
692 }
693 }
694
695 fn instance(id: &str, labels_pairs: &[(&str, &str)]) -> ServiceInstanceInfo {
696 ServiceInstanceInfo {
697 gear: "worker".to_owned(),
698 instance_id: id.to_owned(),
699 labels: labels(labels_pairs),
700 ..Default::default()
701 }
702 }
703
704 #[tokio::test]
705 async fn resolve_by_labels_default_filters_by_equality_and() {
706 let dir = StaticDirectory {
707 instances: vec![
708 instance("a", &[("shard", "7")]),
709 instance("b", &[("shard", "8")]),
710 instance("c", &[("shard", "7"), ("role", "ingest")]),
711 ],
712 };
713
714 let selector = LabelSelector::new().with("shard", "7");
715 let mut matched = dir
716 .resolve_by_labels("worker", &selector)
717 .await
718 .unwrap()
719 .into_iter()
720 .map(|i| i.instance_id)
721 .collect::<Vec<_>>();
722 matched.sort();
723 assert_eq!(matched, vec!["a".to_owned(), "c".to_owned()]);
724
725 // An empty selector returns every instance of the name.
726 let all = dir
727 .resolve_by_labels("worker", &LabelSelector::new())
728 .await
729 .unwrap();
730 assert_eq!(all.len(), 3);
731 }
732}