1use anyhow::{Context, Result};
5use async_trait::async_trait;
6use futures::Stream;
7use serde::{Deserialize, Serialize};
8
9use crate::protocols::EndpointId;
10use std::collections::{HashMap, HashSet};
11use std::pin::Pin;
12use tokio_util::sync::CancellationToken;
13
14mod metadata;
15pub use metadata::{DiscoveryMetadata, MetadataSnapshot};
16
17mod registration;
18pub use registration::EndpointRegistrationLease;
19pub(crate) use registration::EndpointRegistrationManager;
20
21mod mock;
22pub use mock::{MockDiscovery, SharedMockRegistry};
23mod kv_store;
24pub use kv_store::KVStoreDiscovery;
25
26mod kube;
27pub use kube::{KubeDiscoveryClient, hash_container_name, hash_pod_name};
28
29pub mod utils;
30use crate::{
31 component::{DeviceType, Instance, TransportType},
32 pipeline::network::RequestPlanePayloadCodec,
33};
34pub use utils::watch_and_extract_field;
35
36pub(crate) const MAX_JSON_SAFE_PUBLISHER_ID: u64 = (1 << 53) - 1;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum EventTransportKind {
46 Nats,
48 #[default]
50 Zmq,
51}
52
53impl EventTransportKind {
54 pub fn from_env() -> Result<Self> {
64 match std::env::var(crate::config::environment_names::event_plane::DYN_EVENT_PLANE)
65 .as_deref()
66 {
67 Ok("nats") => Ok(Self::Nats),
68 Ok("zmq") | Ok("") | Err(_) => Ok(Self::Zmq),
69 Ok(other) => anyhow::bail!(
70 "Invalid DYN_EVENT_PLANE value '{}'. Valid values: 'nats', 'zmq'",
71 other
72 ),
73 }
74 }
75
76 pub fn from_env_or_default() -> Self {
78 Self::from_env().unwrap_or_else(|e| {
79 tracing::warn!("{e}, defaulting to ZMQ");
80 Self::Zmq
81 })
82 }
83
84 pub fn default_codec(&self) -> EventCodecKind {
87 match self {
88 Self::Nats => EventCodecKind::Json,
89 Self::Zmq => EventCodecKind::Msgpack,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum EventCodecKind {
100 Json,
102 Msgpack,
104}
105
106impl EventCodecKind {
107 pub fn from_env() -> Result<Option<Self>> {
111 match std::env::var(crate::config::environment_names::event_plane::DYN_EVENT_PLANE_CODEC)
112 .as_deref()
113 {
114 Err(_) => Ok(None), Ok("") => Ok(None), Ok("json") => Ok(Some(Self::Json)),
117 Ok("msgpack") => Ok(Some(Self::Msgpack)),
118 Ok(other) => anyhow::bail!(
119 "Invalid DYN_EVENT_PLANE_CODEC value '{}'. Valid values: 'json', 'msgpack'",
120 other
121 ),
122 }
123 }
124
125 pub fn from_env_or_transport_default(transport: EventTransportKind) -> Self {
128 Self::from_env()
129 .unwrap_or_else(|e| {
130 tracing::warn!(
131 "{}, defaulting to {:?} for {:?}",
132 e,
133 transport.default_codec(),
134 transport
135 );
136 None
137 })
138 .unwrap_or_else(|| transport.default_codec())
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
147#[serde(tag = "kind", content = "config")]
148pub enum EventTransport {
149 Nats {
151 subject_prefix: String,
153 },
154 Zmq {
156 endpoint: String,
158 },
159 ZmqBroker {
161 xsub_endpoints: Vec<String>,
163 xpub_endpoints: Vec<String>,
165 },
166}
167
168impl EventTransport {
169 pub fn kind(&self) -> EventTransportKind {
171 match self {
172 Self::Nats { .. } => EventTransportKind::Nats,
173 Self::Zmq { .. } | Self::ZmqBroker { .. } => EventTransportKind::Zmq,
174 }
175 }
176
177 pub fn nats(subject_prefix: impl Into<String>) -> Self {
179 Self::Nats {
180 subject_prefix: subject_prefix.into(),
181 }
182 }
183
184 pub fn zmq(endpoint: impl Into<String>) -> Self {
186 Self::Zmq {
187 endpoint: endpoint.into(),
188 }
189 }
190
191 pub fn address(&self) -> &str {
194 match self {
195 Self::Nats { subject_prefix } => subject_prefix,
196 Self::Zmq { endpoint } => endpoint,
197 Self::ZmqBroker { xsub_endpoints, .. } => {
198 xsub_endpoints.first().map(|s| s.as_str()).unwrap_or("")
199 }
200 }
201 }
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Hash)]
207pub enum DiscoveryQuery {
208 AllEndpoints,
210 NamespacedEndpoints {
212 namespace: String,
213 },
214 ComponentEndpoints {
216 namespace: String,
217 component: String,
218 },
219 Endpoint {
221 namespace: String,
222 component: String,
223 endpoint: String,
224 },
225 AllModels,
226 NamespacedModels {
227 namespace: String,
228 },
229 ComponentModels {
230 namespace: String,
231 component: String,
232 },
233 EndpointModels {
234 namespace: String,
235 component: String,
236 endpoint: String,
237 },
238 EventChannels(EventChannelQuery),
240 EventSources(EventSourceQuery),
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
249#[serde(tag = "kind", rename_all = "snake_case")]
250pub enum EventScope {
251 Namespace {
252 name: String,
253 },
254 Component {
255 namespace: String,
256 component: String,
257 },
258 Endpoint {
259 endpoint: EndpointId,
260 },
261}
262
263impl EventScope {
264 pub fn namespace(&self) -> &str {
265 match self {
266 Self::Namespace { name } => name,
267 Self::Component { namespace, .. } => namespace,
268 Self::Endpoint { endpoint } => &endpoint.namespace,
269 }
270 }
271
272 pub fn component(&self) -> Option<&str> {
273 match self {
274 Self::Namespace { .. } => None,
275 Self::Component { component, .. } => Some(component),
276 Self::Endpoint { endpoint } => Some(&endpoint.component),
277 }
278 }
279
280 pub fn endpoint(&self) -> Option<&EndpointId> {
281 match self {
282 Self::Endpoint { endpoint } => Some(endpoint),
283 Self::Namespace { .. } | Self::Component { .. } => None,
284 }
285 }
286
287 pub fn subject_prefix(&self) -> String {
289 match self {
290 Self::Namespace { name } => {
291 format!("namespace.{}", encode_event_segment(name))
292 }
293 Self::Component {
294 namespace,
295 component,
296 } => format!(
297 "namespace.{}.component.{}",
298 encode_event_segment(namespace),
299 encode_event_segment(component)
300 ),
301 Self::Endpoint { endpoint } => format!(
302 "namespace.{}.component.{}.endpoint.{}",
303 encode_event_segment(&endpoint.namespace),
304 encode_event_segment(&endpoint.component),
305 encode_event_segment(&endpoint.name)
306 ),
307 }
308 }
309
310 pub fn subject(&self, topic: &str) -> String {
312 format!("{}.{}", self.subject_prefix(), encode_event_segment(topic))
313 }
314
315 pub(crate) fn path_prefix(&self) -> String {
316 match self {
317 Self::Namespace { name } => {
318 format!("namespace/{}", encode_event_segment(name))
319 }
320 Self::Component {
321 namespace,
322 component,
323 } => format!(
324 "namespace/{}/component/{}",
325 encode_event_segment(namespace),
326 encode_event_segment(component)
327 ),
328 Self::Endpoint { endpoint } => format!(
329 "namespace/{}/component/{}/endpoint/{}",
330 encode_event_segment(&endpoint.namespace),
331 encode_event_segment(&endpoint.component),
332 encode_event_segment(&endpoint.name)
333 ),
334 }
335 }
336}
337
338pub(crate) fn encode_event_segment(value: &str) -> String {
342 let mut encoded = String::with_capacity(value.len());
343 for byte in value.bytes() {
344 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') {
345 encoded.push(char::from(byte));
346 } else {
347 use std::fmt::Write as _;
348 write!(encoded, "%{byte:02X}").expect("writing to String cannot fail");
349 }
350 }
351 encoded
352}
353
354fn decode_event_segment(value: &str) -> Result<String> {
355 let bytes = value.as_bytes();
356 let mut decoded = Vec::with_capacity(bytes.len());
357 let mut index = 0;
358 while index < bytes.len() {
359 if bytes[index] != b'%' {
360 decoded.push(bytes[index]);
361 index += 1;
362 continue;
363 }
364 if index + 2 >= bytes.len() {
365 anyhow::bail!("invalid percent-encoded event segment: {value}");
366 }
367 let hex = std::str::from_utf8(&bytes[index + 1..index + 3])?;
368 decoded
369 .push(u8::from_str_radix(hex, 16).map_err(|error| {
370 anyhow::anyhow!("invalid event segment escape %{hex}: {error}")
371 })?);
372 index += 3;
373 }
374 String::from_utf8(decoded)
375 .map_err(|error| anyhow::anyhow!("event segment is not valid UTF-8: {error}"))
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Hash)]
380pub struct EventChannelQuery {
381 scope: Option<EventScope>,
383 topic: Option<String>,
384}
385
386impl EventChannelQuery {
387 pub fn all() -> Self {
389 Self {
390 scope: None,
391 topic: None,
392 }
393 }
394
395 pub fn namespace(namespace: impl Into<String>) -> Self {
397 Self {
398 scope: Some(EventScope::Namespace {
399 name: namespace.into(),
400 }),
401 topic: None,
402 }
403 }
404
405 pub fn namespace_topic(namespace: impl Into<String>, topic: impl Into<String>) -> Self {
406 Self {
407 scope: Some(EventScope::Namespace {
408 name: namespace.into(),
409 }),
410 topic: Some(topic.into()),
411 }
412 }
413
414 pub fn component(namespace: impl Into<String>, component: impl Into<String>) -> Self {
416 Self {
417 scope: Some(EventScope::Component {
418 namespace: namespace.into(),
419 component: component.into(),
420 }),
421 topic: None,
422 }
423 }
424
425 pub fn topic(
427 namespace: impl Into<String>,
428 component: impl Into<String>,
429 topic: impl Into<String>,
430 ) -> Self {
431 Self {
432 scope: Some(EventScope::Component {
433 namespace: namespace.into(),
434 component: component.into(),
435 }),
436 topic: Some(topic.into()),
437 }
438 }
439
440 pub fn endpoint(endpoint: EndpointId) -> Self {
441 Self {
442 scope: Some(EventScope::Endpoint { endpoint }),
443 topic: None,
444 }
445 }
446
447 pub fn endpoint_topic(endpoint: EndpointId, topic: impl Into<String>) -> Self {
448 Self {
449 scope: Some(EventScope::Endpoint { endpoint }),
450 topic: Some(topic.into()),
451 }
452 }
453
454 pub fn scope_level(&self) -> u8 {
456 if self.topic.is_some() {
457 2
458 } else if self.scope.is_some() {
459 1
460 } else {
461 0
462 }
463 }
464}
465
466#[derive(Debug, Clone, PartialEq, Eq, Hash)]
468pub struct EventSourceQuery {
469 scope: Option<EventScope>,
471 topic: Option<String>,
472}
473
474impl EventSourceQuery {
475 pub fn all() -> Self {
476 Self {
477 scope: None,
478 topic: None,
479 }
480 }
481
482 pub fn namespace(namespace: impl Into<String>) -> Self {
483 Self {
484 scope: Some(EventScope::Namespace {
485 name: namespace.into(),
486 }),
487 topic: None,
488 }
489 }
490
491 pub fn namespace_topic(namespace: impl Into<String>, topic: impl Into<String>) -> Self {
492 Self {
493 scope: Some(EventScope::Namespace {
494 name: namespace.into(),
495 }),
496 topic: Some(topic.into()),
497 }
498 }
499
500 pub fn component(namespace: impl Into<String>, component: impl Into<String>) -> Self {
501 Self {
502 scope: Some(EventScope::Component {
503 namespace: namespace.into(),
504 component: component.into(),
505 }),
506 topic: None,
507 }
508 }
509
510 pub fn topic(
511 namespace: impl Into<String>,
512 component: impl Into<String>,
513 topic: impl Into<String>,
514 ) -> Self {
515 Self {
516 scope: Some(EventScope::Component {
517 namespace: namespace.into(),
518 component: component.into(),
519 }),
520 topic: Some(topic.into()),
521 }
522 }
523
524 pub fn endpoint(endpoint: EndpointId) -> Self {
525 Self {
526 scope: Some(EventScope::Endpoint { endpoint }),
527 topic: None,
528 }
529 }
530
531 pub fn endpoint_topic(endpoint: EndpointId, topic: impl Into<String>) -> Self {
532 Self {
533 scope: Some(EventScope::Endpoint { endpoint }),
534 topic: Some(topic.into()),
535 }
536 }
537
538 pub fn scope_level(&self) -> u8 {
540 if self.topic.is_some() {
541 2
542 } else if self.scope.is_some() {
543 1
544 } else {
545 0
546 }
547 }
548}
549
550#[derive(Debug, Clone, PartialEq, Eq)]
553pub enum DiscoverySpec {
554 Endpoint {
556 namespace: String,
557 component: String,
558 endpoint: String,
559 transport: TransportType,
561 device_type: Option<DeviceType>,
564 request_plane_codec: Option<RequestPlanePayloadCodec>,
567 },
568 Model {
569 namespace: String,
570 component: String,
571 endpoint: String,
572 card_json: serde_json::Value,
576 model_suffix: Option<String>,
579 },
580 EventChannel {
583 scope: EventScope,
584 topic: String,
586 publisher_id: u64,
591 transport: EventTransport,
593 },
594 EventSource {
596 scope: EventScope,
597 topic: String,
598 publisher_id: u64,
600 metadata: serde_json::Value,
602 },
603}
604
605impl DiscoverySpec {
606 pub fn from_model<T>(
609 namespace: String,
610 component: String,
611 endpoint: String,
612 card: &T,
613 ) -> Result<Self>
614 where
615 T: Serialize,
616 {
617 Self::from_model_with_suffix(namespace, component, endpoint, card, None)
618 }
619
620 pub fn from_model_with_suffix<T>(
623 namespace: String,
624 component: String,
625 endpoint: String,
626 card: &T,
627 model_suffix: Option<String>,
628 ) -> Result<Self>
629 where
630 T: Serialize,
631 {
632 let card_json = serde_json::to_value(card)?;
633 Ok(Self::Model {
634 namespace,
635 component,
636 endpoint,
637 card_json,
638 model_suffix,
639 })
640 }
641
642 pub fn into_instance(self, default_instance_id: u64) -> DiscoveryInstance {
648 match self {
649 Self::Endpoint {
650 namespace,
651 component,
652 endpoint,
653 transport,
654 device_type,
655 request_plane_codec,
656 } => DiscoveryInstance::Endpoint(crate::component::Instance {
657 namespace,
658 component,
659 endpoint,
660 instance_id: default_instance_id,
661 transport,
662 device_type,
663 request_plane_codec,
664 }),
665 Self::Model {
666 namespace,
667 component,
668 endpoint,
669 card_json,
670 model_suffix,
671 } => DiscoveryInstance::Model {
672 namespace,
673 component,
674 endpoint,
675 instance_id: default_instance_id,
676 card_json,
677 model_suffix,
678 },
679 Self::EventChannel {
680 scope,
681 topic,
682 publisher_id,
683 transport,
684 } => DiscoveryInstance::EventChannel {
685 scope,
686 topic,
687 instance_id: publisher_id,
688 transport,
689 },
690 Self::EventSource {
691 scope,
692 topic,
693 publisher_id,
694 metadata,
695 } => DiscoveryInstance::EventSource {
696 scope,
697 topic,
698 publisher_id,
699 metadata,
700 },
701 }
702 }
703
704 pub fn with_instance_id(self, default_instance_id: u64) -> DiscoveryInstance {
706 self.into_instance(default_instance_id)
707 }
708}
709
710#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
713#[serde(tag = "type")]
714pub enum DiscoveryInstance {
715 Endpoint(crate::component::Instance),
717 Model {
718 namespace: String,
719 component: String,
720 endpoint: String,
721 instance_id: u64,
722 card_json: serde_json::Value,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
727 model_suffix: Option<String>,
728 },
729 EventChannel {
731 scope: EventScope,
732 topic: String,
734 instance_id: u64,
735 transport: EventTransport,
737 },
738 EventSource {
740 scope: EventScope,
741 topic: String,
742 publisher_id: u64,
743 metadata: serde_json::Value,
744 },
745}
746
747pub(crate) fn validate_event_source_reregistration(
753 existing: &DiscoveryInstance,
754 candidate: &DiscoveryInstance,
755) -> Result<()> {
756 let DiscoveryInstanceId::EventSource(existing_id) = existing.id() else {
757 anyhow::bail!("existing discovery record is not an event source")
758 };
759 if candidate.id() != DiscoveryInstanceId::EventSource(existing_id.clone()) {
760 anyhow::bail!("event source re-registration changed its identity")
761 }
762 if existing != candidate {
763 anyhow::bail!(
764 "Event source incarnation '{}' cannot change its descriptor",
765 existing_id.to_path()
766 )
767 }
768 Ok(())
769}
770
771impl DiscoveryInstance {
772 pub fn instance_id(&self) -> u64 {
774 match self {
775 Self::Endpoint(inst) => inst.instance_id,
776 Self::Model { instance_id, .. } => *instance_id,
777 Self::EventChannel { instance_id, .. } => *instance_id,
778 Self::EventSource { publisher_id, .. } => *publisher_id,
779 }
780 }
781
782 pub fn deserialize_model<T>(&self) -> Result<T>
785 where
786 T: for<'de> Deserialize<'de>,
787 {
788 match self {
789 Self::Model { card_json, .. } => Ok(serde_json::from_value(card_json.clone())?),
790 Self::Endpoint(_) => {
791 anyhow::bail!("Cannot deserialize model from Endpoint instance")
792 }
793 Self::EventChannel { .. } => {
794 anyhow::bail!("Cannot deserialize model from EventChannel instance")
795 }
796 Self::EventSource { .. } => {
797 anyhow::bail!("Cannot deserialize model from EventSource instance")
798 }
799 }
800 }
801
802 pub fn id(&self) -> DiscoveryInstanceId {
805 match self {
806 Self::Endpoint(inst) => DiscoveryInstanceId::Endpoint(EndpointInstanceId {
807 namespace: inst.namespace.clone(),
808 component: inst.component.clone(),
809 endpoint: inst.endpoint.clone(),
810 instance_id: inst.instance_id,
811 }),
812 Self::Model {
813 namespace,
814 component,
815 endpoint,
816 instance_id,
817 model_suffix,
818 ..
819 } => DiscoveryInstanceId::Model(ModelCardInstanceId {
820 namespace: namespace.clone(),
821 component: component.clone(),
822 endpoint: endpoint.clone(),
823 instance_id: *instance_id,
824 model_suffix: model_suffix.clone(),
825 }),
826 Self::EventChannel {
827 scope,
828 topic,
829 instance_id,
830 ..
831 } => DiscoveryInstanceId::EventChannel(EventChannelInstanceId {
832 scope: scope.clone(),
833 topic: topic.clone(),
834 instance_id: *instance_id,
835 }),
836 Self::EventSource {
837 scope,
838 topic,
839 publisher_id,
840 ..
841 } => DiscoveryInstanceId::EventSource(EventSourceInstanceId {
842 scope: scope.clone(),
843 topic: topic.clone(),
844 publisher_id: *publisher_id,
845 }),
846 }
847 }
848}
849
850#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
852pub struct EndpointInstanceId {
853 pub namespace: String,
854 pub component: String,
855 pub endpoint: String,
856 pub instance_id: u64,
857}
858
859impl EndpointInstanceId {
860 pub fn to_path(&self) -> String {
862 format!(
863 "{}/{}/{}/{:x}",
864 self.namespace, self.component, self.endpoint, self.instance_id
865 )
866 }
867
868 pub fn from_path(path: &str) -> Result<Self> {
870 let parts: Vec<&str> = path.split('/').collect();
871 if parts.len() != 4 {
872 anyhow::bail!(
873 "Invalid EndpointInstanceId path: expected 4 parts, got {}",
874 parts.len()
875 );
876 }
877 Ok(Self {
878 namespace: parts[0].to_string(),
879 component: parts[1].to_string(),
880 endpoint: parts[2].to_string(),
881 instance_id: u64::from_str_radix(parts[3], 16)
882 .map_err(|e| anyhow::anyhow!("Invalid instance_id hex: {}", e))?,
883 })
884 }
885}
886
887#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
890pub struct ModelCardInstanceId {
891 pub namespace: String,
892 pub component: String,
893 pub endpoint: String,
894 pub instance_id: u64,
895 pub model_suffix: Option<String>,
897}
898
899#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
901pub struct EventChannelInstanceId {
902 pub scope: EventScope,
903 pub topic: String,
905 pub instance_id: u64,
906}
907
908impl EventChannelInstanceId {
909 pub fn to_path(&self) -> String {
911 format!(
912 "{}/topic/{}/{:x}",
913 self.scope.path_prefix(),
914 encode_event_segment(&self.topic),
915 self.instance_id
916 )
917 }
918
919 pub fn from_path(path: &str) -> Result<Self> {
921 let parts: Vec<&str> = path.split('/').collect();
922 let (scope, topic_index, instance_index) = match parts.as_slice() {
923 ["namespace", namespace, "topic", _, _] => (
924 EventScope::Namespace {
925 name: decode_event_segment(namespace)?,
926 },
927 3,
928 4,
929 ),
930 [
931 "namespace",
932 namespace,
933 "component",
934 component,
935 "topic",
936 _,
937 _,
938 ] => (
939 EventScope::Component {
940 namespace: decode_event_segment(namespace)?,
941 component: decode_event_segment(component)?,
942 },
943 5,
944 6,
945 ),
946 [
947 "namespace",
948 namespace,
949 "component",
950 component,
951 "endpoint",
952 endpoint,
953 "topic",
954 _,
955 _,
956 ] => (
957 EventScope::Endpoint {
958 endpoint: EndpointId {
959 namespace: decode_event_segment(namespace)?,
960 component: decode_event_segment(component)?,
961 name: decode_event_segment(endpoint)?,
962 },
963 },
964 7,
965 8,
966 ),
967 _ => anyhow::bail!("invalid EventChannelInstanceId path: {path}"),
968 };
969 Ok(Self {
970 scope,
971 topic: decode_event_segment(parts[topic_index])?,
972 instance_id: u64::from_str_radix(parts[instance_index], 16)
973 .map_err(|e| anyhow::anyhow!("Invalid instance_id hex: {}", e))?,
974 })
975 }
976}
977
978#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
980pub struct EventSourceInstanceId {
981 pub scope: EventScope,
982 pub topic: String,
983 pub publisher_id: u64,
984}
985
986impl EventSourceInstanceId {
987 pub fn to_path(&self) -> String {
989 format!(
990 "{}/topic/{}/{:x}",
991 self.scope.path_prefix(),
992 encode_event_segment(&self.topic),
993 self.publisher_id
994 )
995 }
996
997 pub fn from_path(path: &str) -> Result<Self> {
999 let channel_id = EventChannelInstanceId::from_path(path)
1000 .with_context(|| format!("invalid EventSourceInstanceId path: {path}"))?;
1001 Ok(Self {
1002 scope: channel_id.scope,
1003 topic: channel_id.topic,
1004 publisher_id: channel_id.instance_id,
1005 })
1006 }
1007}
1008
1009impl ModelCardInstanceId {
1010 pub fn to_path(&self) -> String {
1012 match &self.model_suffix {
1013 Some(suffix) => format!(
1014 "{}/{}/{}/{:x}/{}",
1015 self.namespace, self.component, self.endpoint, self.instance_id, suffix
1016 ),
1017 None => format!(
1018 "{}/{}/{}/{:x}",
1019 self.namespace, self.component, self.endpoint, self.instance_id
1020 ),
1021 }
1022 }
1023
1024 pub fn from_path(path: &str) -> Result<Self> {
1026 let parts: Vec<&str> = path.split('/').collect();
1027 if parts.len() < 4 || parts.len() > 5 {
1028 anyhow::bail!(
1029 "Invalid ModelCardInstanceId path: expected 4 or 5 parts, got {}",
1030 parts.len()
1031 );
1032 }
1033 Ok(Self {
1034 namespace: parts[0].to_string(),
1035 component: parts[1].to_string(),
1036 endpoint: parts[2].to_string(),
1037 instance_id: u64::from_str_radix(parts[3], 16)
1038 .map_err(|e| anyhow::anyhow!("Invalid instance_id hex: {}", e))?,
1039 model_suffix: parts.get(4).map(|s| s.to_string()),
1040 })
1041 }
1042}
1043
1044#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1046pub enum DiscoveryInstanceId {
1047 Endpoint(EndpointInstanceId),
1048 Model(ModelCardInstanceId),
1049 EventChannel(EventChannelInstanceId),
1050 EventSource(EventSourceInstanceId),
1051}
1052
1053impl DiscoveryInstanceId {
1054 pub fn instance_id(&self) -> u64 {
1056 match self {
1057 Self::Endpoint(eid) => eid.instance_id,
1058 Self::Model(mid) => mid.instance_id,
1059 Self::EventChannel(ecid) => ecid.instance_id,
1060 Self::EventSource(esid) => esid.publisher_id,
1061 }
1062 }
1063
1064 pub fn extract_endpoint_id(&self) -> Result<&EndpointInstanceId> {
1066 match self {
1067 Self::Endpoint(eid) => Ok(eid),
1068 Self::Model(_) => anyhow::bail!("Expected Endpoint variant, got Model"),
1069 Self::EventChannel(_) => anyhow::bail!("Expected Endpoint variant, got EventChannel"),
1070 Self::EventSource(_) => anyhow::bail!("Expected Endpoint variant, got EventSource"),
1071 }
1072 }
1073
1074 pub fn extract_model_id(&self) -> Result<&ModelCardInstanceId> {
1076 match self {
1077 Self::Model(mid) => Ok(mid),
1078 Self::Endpoint(_) => anyhow::bail!("Expected Model variant, got Endpoint"),
1079 Self::EventChannel(_) => anyhow::bail!("Expected Model variant, got EventChannel"),
1080 Self::EventSource(_) => anyhow::bail!("Expected Model variant, got EventSource"),
1081 }
1082 }
1083
1084 pub fn extract_event_channel_id(&self) -> Result<&EventChannelInstanceId> {
1086 match self {
1087 Self::EventChannel(ecid) => Ok(ecid),
1088 Self::Endpoint(_) => anyhow::bail!("Expected EventChannel variant, got Endpoint"),
1089 Self::Model(_) => anyhow::bail!("Expected EventChannel variant, got Model"),
1090 Self::EventSource(_) => {
1091 anyhow::bail!("Expected EventChannel variant, got EventSource")
1092 }
1093 }
1094 }
1095
1096 pub fn extract_event_source_id(&self) -> Result<&EventSourceInstanceId> {
1098 match self {
1099 Self::EventSource(esid) => Ok(esid),
1100 Self::Endpoint(_) => anyhow::bail!("Expected EventSource variant, got Endpoint"),
1101 Self::Model(_) => anyhow::bail!("Expected EventSource variant, got Model"),
1102 Self::EventChannel(_) => {
1103 anyhow::bail!("Expected EventSource variant, got EventChannel")
1104 }
1105 }
1106 }
1107}
1108
1109#[derive(Debug, Clone, PartialEq, Eq)]
1111pub enum DiscoveryEvent {
1112 Added(DiscoveryInstance),
1117 ModelTaintsUpdated(ModelTaintsUpdate),
1119 Removed(DiscoveryInstanceId),
1121}
1122
1123#[derive(Debug, Clone, PartialEq, Eq)]
1125pub struct ModelTaintsUpdate {
1126 pub id: ModelCardInstanceId,
1127 pub taints: Vec<String>,
1128}
1129
1130pub type DiscoveryStream = Pin<Box<dyn Stream<Item = Result<DiscoveryEvent>> + Send>>;
1132
1133#[derive(Clone, Debug, PartialEq, Eq)]
1134struct ModelRegistrationIdentity {
1135 display_name: String,
1136 aliases: Vec<String>,
1137 source_path: Option<String>,
1138 is_lora: bool,
1139}
1140
1141impl ModelRegistrationIdentity {
1142 fn base_identity(&self) -> &str {
1143 self.source_path.as_deref().unwrap_or(&self.display_name)
1144 }
1145
1146 fn is_compatible_with(&self, other: &Self) -> bool {
1147 if self.is_lora != other.is_lora {
1148 let (adapter, base) = if self.is_lora {
1149 (self, other)
1150 } else {
1151 (other, self)
1152 };
1153 adapter.base_identity() == base.base_identity()
1154 && adapter.display_name != base.display_name
1155 && !base.aliases.contains(&adapter.display_name)
1156 } else if self.is_lora {
1157 self.base_identity() == other.base_identity()
1158 } else {
1159 self.display_name == other.display_name
1161 || self.source_path.as_deref().is_some_and(|source| {
1162 !source.is_empty()
1163 && other.source_path.as_deref() == Some(source)
1164 && !self.aliases.contains(&other.display_name)
1165 && !other.aliases.contains(&self.display_name)
1166 && !self
1167 .aliases
1168 .iter()
1169 .any(|alias| other.aliases.contains(alias))
1170 })
1171 }
1172 }
1173}
1174
1175fn extract_model_registration_identity(
1176 card_json: &serde_json::Value,
1177 model_suffix: Option<&str>,
1178) -> Result<ModelRegistrationIdentity> {
1179 let display_name = card_json
1180 .get("display_name")
1181 .and_then(serde_json::Value::as_str)
1182 .map(str::to_owned)
1183 .ok_or_else(|| {
1184 anyhow::anyhow!("failed to deserialize model display_name from card_json")
1185 })?;
1186 let source_path = card_json
1187 .get("source_path")
1188 .and_then(serde_json::Value::as_str)
1189 .map(str::to_owned);
1190 let aliases = card_json
1191 .get("aliases")
1192 .and_then(serde_json::Value::as_array)
1193 .into_iter()
1194 .flatten()
1195 .filter_map(serde_json::Value::as_str)
1196 .map(str::to_owned)
1197 .collect();
1198 let is_lora =
1199 model_suffix.is_some() || card_json.get("lora").is_some_and(|value| !value.is_null());
1200
1201 Ok(ModelRegistrationIdentity {
1202 display_name,
1203 aliases,
1204 source_path,
1205 is_lora,
1206 })
1207}
1208
1209fn find_conflicting_model_name(
1210 instances: &[DiscoveryInstance],
1211 requested_identity: &ModelRegistrationIdentity,
1212) -> Result<Option<String>> {
1213 for instance in instances {
1214 if let DiscoveryInstance::Model {
1215 card_json,
1216 model_suffix,
1217 ..
1218 } = instance
1219 {
1220 let existing_identity =
1221 extract_model_registration_identity(card_json, model_suffix.as_deref())?;
1222 if !requested_identity.is_compatible_with(&existing_identity) {
1223 return Ok(Some(existing_identity.display_name));
1224 }
1225 }
1226 }
1227
1228 Ok(None)
1229}
1230
1231const TOPOLOGY_TAINT_PREFIX: &str = "dynamo.topology/";
1232
1233fn model_card_without_taints(
1234 instance: &DiscoveryInstance,
1235) -> Result<(serde_json::Value, HashSet<String>)> {
1236 let DiscoveryInstance::Model { card_json, .. } = instance else {
1237 anyhow::bail!("model update requires a model discovery instance")
1238 };
1239
1240 let mut card = card_json.clone();
1241 let runtime_config = card
1242 .get_mut("runtime_config")
1243 .and_then(serde_json::Value::as_object_mut)
1244 .context("model card is missing runtime_config")?;
1245 let taints = runtime_config
1246 .remove("taints")
1247 .unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
1248 let taints = taints
1249 .as_array()
1250 .context("model card runtime_config.taints must be an array")?
1251 .iter()
1252 .map(|value| {
1253 value
1254 .as_str()
1255 .map(str::to_owned)
1256 .context("model card runtime_config.taints entries must be strings")
1257 })
1258 .collect::<Result<HashSet<_>>>()?;
1259
1260 Ok((card, taints))
1261}
1262
1263fn expected_topology_taints(card: &serde_json::Value) -> Result<HashSet<String>> {
1264 let Some(domains) = card
1265 .pointer("/runtime_config/topology_domains")
1266 .and_then(serde_json::Value::as_object)
1267 else {
1268 return Ok(HashSet::new());
1269 };
1270
1271 domains
1272 .iter()
1273 .map(|(domain, value)| {
1274 let value = value
1275 .as_str()
1276 .context("model card runtime_config.topology_domains values must be strings")?;
1277 Ok(format!(
1278 "{TOPOLOGY_TAINT_PREFIX}{}={}",
1279 domain.trim(),
1280 value.trim()
1281 ))
1282 })
1283 .collect()
1284}
1285
1286#[derive(Debug)]
1292struct ValidatedModelTaintUpdate {
1293 existing_taints: HashSet<String>,
1294 candidate_taints: HashSet<String>,
1295}
1296
1297fn validate_model_taint_update(
1298 existing: &DiscoveryInstance,
1299 candidate: &DiscoveryInstance,
1300) -> Result<ValidatedModelTaintUpdate> {
1301 if existing.id() != candidate.id() {
1302 anyhow::bail!("model update cannot change discovery identity")
1303 }
1304
1305 let (existing_card, existing_taints) = model_card_without_taints(existing)?;
1306 let (candidate_card, candidate_taints) = model_card_without_taints(candidate)?;
1307 if existing_card != candidate_card {
1308 anyhow::bail!("model update can only change runtime_config.taints")
1309 }
1310
1311 let expected_topology = expected_topology_taints(&candidate_card)?;
1312 let actual_topology = candidate_taints
1313 .iter()
1314 .filter(|taint| taint.starts_with(TOPOLOGY_TAINT_PREFIX))
1315 .cloned()
1316 .collect::<HashSet<_>>();
1317 if actual_topology != expected_topology {
1318 anyhow::bail!(
1319 "reserved {TOPOLOGY_TAINT_PREFIX} taints must match runtime_config.topology_domains"
1320 )
1321 }
1322
1323 Ok(ValidatedModelTaintUpdate {
1324 existing_taints,
1325 candidate_taints,
1326 })
1327}
1328
1329pub(crate) fn validate_model_reregistration(
1331 existing: &DiscoveryInstance,
1332 candidate: &DiscoveryInstance,
1333) -> Result<()> {
1334 validate_model_taint_update(existing, candidate).map(|_| ())
1335}
1336
1337fn sorted_taints(taints: HashSet<String>) -> Vec<String> {
1338 let mut taints = taints.into_iter().collect::<Vec<_>>();
1339 taints.sort_unstable();
1340 taints
1341}
1342
1343pub(crate) fn classify_discovery_change(
1349 existing: Option<&DiscoveryInstance>,
1350 candidate: &DiscoveryInstance,
1351) -> Result<Option<DiscoveryEvent>> {
1352 let Some(existing) = existing else {
1353 return Ok(Some(DiscoveryEvent::Added(candidate.clone())));
1354 };
1355
1356 if existing == candidate {
1357 return Ok(None);
1358 }
1359
1360 if matches!(existing, DiscoveryInstance::Model { .. })
1361 && matches!(candidate, DiscoveryInstance::Model { .. })
1362 {
1363 let ValidatedModelTaintUpdate {
1364 existing_taints,
1365 candidate_taints,
1366 } = validate_model_taint_update(existing, candidate)?;
1367 if existing_taints == candidate_taints {
1368 return Ok(None);
1369 }
1370
1371 let DiscoveryInstanceId::Model(id) = candidate.id() else {
1372 unreachable!("model discovery instance must have a model id")
1373 };
1374 return Ok(Some(DiscoveryEvent::ModelTaintsUpdated(
1375 ModelTaintsUpdate {
1376 id,
1377 taints: sorted_taints(candidate_taints),
1378 },
1379 )));
1380 }
1381
1382 if matches!(candidate, DiscoveryInstance::Endpoint(_)) {
1383 Ok(Some(DiscoveryEvent::Added(candidate.clone())))
1384 } else {
1385 Ok(None)
1386 }
1387}
1388
1389pub(crate) fn reconcile_discovery_snapshot(
1392 known: &HashMap<DiscoveryInstanceId, DiscoveryInstance>,
1393 current: HashMap<DiscoveryInstanceId, DiscoveryInstance>,
1394) -> (
1395 Vec<DiscoveryEvent>,
1396 HashMap<DiscoveryInstanceId, DiscoveryInstance>,
1397) {
1398 let mut events = known
1399 .keys()
1400 .filter(|id| !current.contains_key(*id))
1401 .cloned()
1402 .map(DiscoveryEvent::Removed)
1403 .collect::<Vec<_>>();
1404 let mut next = HashMap::with_capacity(current.len());
1405
1406 for (id, candidate) in current {
1407 match classify_discovery_change(known.get(&id), &candidate) {
1408 Ok(Some(event)) => {
1409 events.push(event);
1410 next.insert(id, candidate);
1411 }
1412 Ok(None) => {
1413 let retained = known.get(&id).cloned().unwrap_or(candidate);
1414 next.insert(id, retained);
1415 }
1416 Err(error) => {
1417 tracing::error!(
1418 ?id,
1419 %error,
1420 "Rejecting immutable discovery model-card mutation"
1421 );
1422 if let Some(existing) = known.get(&id) {
1423 next.insert(id, existing.clone());
1424 }
1425 }
1426 }
1427 }
1428
1429 (events, next)
1430}
1431
1432fn model_with_updated_taints(
1433 existing: &DiscoveryInstance,
1434 mut taints: HashSet<String>,
1435) -> Result<DiscoveryInstance> {
1436 if let Some(taint) = taints
1437 .iter()
1438 .find(|taint| taint.starts_with(TOPOLOGY_TAINT_PREFIX))
1439 {
1440 anyhow::bail!("taint '{taint}' uses reserved prefix '{TOPOLOGY_TAINT_PREFIX}'")
1441 }
1442
1443 let (card_without_taints, existing_taints) = model_card_without_taints(existing)?;
1444 taints.extend(expected_topology_taints(&card_without_taints)?);
1445 if taints == existing_taints {
1446 return Ok(existing.clone());
1447 }
1448
1449 let mut candidate = existing.clone();
1450 let DiscoveryInstance::Model { card_json, .. } = &mut candidate else {
1451 anyhow::bail!("model taint update requires a model discovery instance")
1452 };
1453 let runtime_config = card_json
1454 .get_mut("runtime_config")
1455 .and_then(serde_json::Value::as_object_mut)
1456 .context("model card is missing runtime_config")?;
1457 runtime_config.insert(
1458 "taints".to_string(),
1459 serde_json::Value::Array(
1460 sorted_taints(taints)
1461 .into_iter()
1462 .map(serde_json::Value::String)
1463 .collect(),
1464 ),
1465 );
1466 Ok(candidate)
1467}
1468
1469#[async_trait]
1471pub trait Discovery: Send + Sync {
1472 fn instance_id(&self) -> u64;
1477
1478 async fn register(&self, spec: DiscoverySpec) -> Result<DiscoveryInstance> {
1480 let (namespace, component, endpoint, requested_identity) = match &spec {
1481 DiscoverySpec::Model {
1482 namespace,
1483 component,
1484 endpoint,
1485 card_json,
1486 model_suffix,
1487 ..
1488 } => (
1489 namespace.clone(),
1490 component.clone(),
1491 endpoint.clone(),
1492 extract_model_registration_identity(card_json, model_suffix.as_deref())?,
1493 ),
1494 _ => return self.register_internal(spec).await,
1495 };
1496
1497 let query = DiscoveryQuery::EndpointModels {
1498 namespace: namespace.clone(),
1499 component: component.clone(),
1500 endpoint: endpoint.clone(),
1501 };
1502
1503 if let Some(conflicting_name) =
1504 find_conflicting_model_name(&self.list(query.clone()).await?, &requested_identity)?
1505 {
1506 let requested_name = &requested_identity.display_name;
1507 anyhow::bail!(
1508 "Cannot register model '{requested_name}' on endpoint '{namespace}/{component}/{endpoint}': a different model '{conflicting_name}' is already registered there"
1509 );
1510 }
1511
1512 let instance = self.register_internal(spec).await?;
1513
1514 if let Some(conflicting_name) =
1515 find_conflicting_model_name(&self.list(query).await?, &requested_identity)?
1516 {
1517 let requested_name = &requested_identity.display_name;
1518 if let Err(unregister_err) = self.unregister(instance.clone()).await {
1519 return Err(anyhow::anyhow!(
1520 "Cannot register model '{requested_name}' on endpoint '{namespace}/{component}/{endpoint}': a different model '{conflicting_name}' is already registered there"
1521 ))
1522 .context(format!(
1523 "failed to roll back conflicting model registration for instance {instance_id}: {unregister_err}",
1524 instance_id = instance.instance_id()
1525 ));
1526 }
1527
1528 anyhow::bail!(
1529 "Cannot register model '{requested_name}' on endpoint '{namespace}/{component}/{endpoint}': a different model '{conflicting_name}' is already registered there"
1530 );
1531 }
1532
1533 Ok(instance)
1534 }
1535
1536 async fn register_internal(&self, spec: DiscoverySpec) -> Result<DiscoveryInstance>;
1538
1539 async fn update_model_taints(
1541 &self,
1542 id: ModelCardInstanceId,
1543 taints: HashSet<String>,
1544 ) -> Result<()> {
1545 if id.instance_id != self.instance_id() {
1546 anyhow::bail!(
1547 "cannot update model taints for worker {}; this discovery client owns worker {}",
1548 id.instance_id,
1549 self.instance_id()
1550 )
1551 }
1552 if id.model_suffix.is_some() {
1553 anyhow::bail!("model taint updates are supported only for base model cards")
1554 }
1555 self.update_model_taints_internal(id, taints).await
1556 }
1557
1558 async fn update_model_taints_internal(
1560 &self,
1561 _id: ModelCardInstanceId,
1562 _taints: HashSet<String>,
1563 ) -> Result<()> {
1564 anyhow::bail!("model taint updates are not supported by this discovery backend")
1565 }
1566
1567 async fn unregister(&self, instance: DiscoveryInstance) -> Result<()>;
1569
1570 async fn list(&self, query: DiscoveryQuery) -> Result<Vec<DiscoveryInstance>>;
1573
1574 async fn list_and_watch(
1577 &self,
1578 query: DiscoveryQuery,
1579 cancel_token: Option<CancellationToken>,
1580 ) -> Result<DiscoveryStream>;
1581
1582 fn shutdown(&self) {}
1586}
1587
1588#[cfg(test)]
1589mod tests {
1590 use super::*;
1591
1592 #[test]
1593 fn endpoint_channel_id_path_round_trips_reserved_segments() {
1594 let id = EventChannelInstanceId {
1595 scope: EventScope::Endpoint {
1596 endpoint: EndpointId {
1597 namespace: "ns.with/slash".to_string(),
1598 component: "component.*".to_string(),
1599 name: "endpoint.>/%".to_string(),
1600 },
1601 },
1602 topic: "kv.events/>".to_string(),
1603 instance_id: 0xfeed,
1604 };
1605
1606 let path = id.to_path();
1607 assert!(!path.contains("ns.with/slash"));
1608 assert_eq!(EventChannelInstanceId::from_path(&path).unwrap(), id);
1609 }
1610
1611 #[test]
1612 fn endpoint_source_id_path_round_trips_reserved_segments() {
1613 let id = EventSourceInstanceId {
1614 scope: EventScope::Endpoint {
1615 endpoint: EndpointId {
1616 namespace: "ns.with/slash".to_string(),
1617 component: "component.*".to_string(),
1618 name: "endpoint.>/%".to_string(),
1619 },
1620 },
1621 topic: "kv.events/>".to_string(),
1622 publisher_id: 0xfeed,
1623 };
1624
1625 let path = id.to_path();
1626 assert!(!path.contains("ns.with/slash"));
1627 assert_eq!(EventSourceInstanceId::from_path(&path).unwrap(), id);
1628 }
1629
1630 #[test]
1631 fn endpoint_codec_metadata_round_trips_and_defaults_when_omitted() {
1632 let instance = DiscoverySpec::Endpoint {
1633 namespace: "default".to_string(),
1634 component: "worker".to_string(),
1635 endpoint: "generate".to_string(),
1636 transport: TransportType::Nats("worker.generate".to_string()),
1637 device_type: None,
1638 request_plane_codec: Some(RequestPlanePayloadCodec::Msgpack),
1639 }
1640 .into_instance(42);
1641
1642 let mut metadata = serde_json::to_value(&instance).unwrap();
1643 assert_eq!(metadata["request_plane_codec"], "msgpack");
1644 let round_trip: DiscoveryInstance = serde_json::from_value(metadata.clone()).unwrap();
1645 match round_trip {
1646 DiscoveryInstance::Endpoint(instance) => assert_eq!(
1647 instance.request_plane_codec,
1648 Some(RequestPlanePayloadCodec::Msgpack)
1649 ),
1650 _ => panic!("expected endpoint discovery metadata"),
1651 }
1652
1653 metadata
1654 .as_object_mut()
1655 .unwrap()
1656 .remove("request_plane_codec");
1657 let legacy: DiscoveryInstance = serde_json::from_value(metadata).unwrap();
1658 match legacy {
1659 DiscoveryInstance::Endpoint(instance) => {
1660 assert_eq!(instance.request_plane_codec, None)
1661 }
1662 _ => panic!("expected endpoint discovery metadata"),
1663 }
1664 }
1665}
1666
1667#[cfg(test)]
1668mod model_taint_update_tests {
1669 use super::*;
1670
1671 fn model_instance(taints: &[&str]) -> DiscoveryInstance {
1672 DiscoveryInstance::Model {
1673 namespace: "ns".to_string(),
1674 component: "worker".to_string(),
1675 endpoint: "generate".to_string(),
1676 instance_id: 7,
1677 card_json: serde_json::json!({
1678 "display_name": "model",
1679 "runtime_config": {
1680 "taints": taints,
1681 "topology_domains": {"zone": "west"}
1682 }
1683 }),
1684 model_suffix: None,
1685 }
1686 }
1687
1688 #[test]
1689 fn model_update_accepts_only_caller_managed_taint_changes() {
1690 let existing = model_instance(&["old", "dynamo.topology/zone=west"]);
1691 let candidate = model_instance(&["new", "dynamo.topology/zone=west"]);
1692
1693 validate_model_taint_update(&existing, &candidate).unwrap();
1694 }
1695
1696 #[test]
1697 fn model_update_rejects_immutable_card_changes() {
1698 let existing = model_instance(&["dynamo.topology/zone=west"]);
1699 let mut candidate = model_instance(&["dynamo.topology/zone=west"]);
1700 let DiscoveryInstance::Model { card_json, .. } = &mut candidate else {
1701 unreachable!()
1702 };
1703 card_json["display_name"] = serde_json::json!("other-model");
1704
1705 let error = validate_model_taint_update(&existing, &candidate).unwrap_err();
1706 assert!(
1707 error
1708 .to_string()
1709 .contains("can only change runtime_config.taints")
1710 );
1711 }
1712
1713 #[test]
1714 fn model_update_rejects_reserved_topology_taint_changes() {
1715 let existing = model_instance(&["dynamo.topology/zone=west"]);
1716 let candidate = model_instance(&["dynamo.topology/zone=east"]);
1717
1718 let error = validate_model_taint_update(&existing, &candidate).unwrap_err();
1719 assert!(
1720 error
1721 .to_string()
1722 .contains("must match runtime_config.topology_domains")
1723 );
1724 }
1725
1726 #[test]
1727 fn changed_taints_are_classified_as_a_scoped_normalized_event() {
1728 let existing = model_instance(&["old", "dynamo.topology/zone=west"]);
1729 let candidate = model_instance(&["gpu", "blue", "dynamo.topology/zone=west"]);
1730 let DiscoveryInstanceId::Model(id) = candidate.id() else {
1731 unreachable!()
1732 };
1733
1734 assert_eq!(
1735 classify_discovery_change(Some(&existing), &candidate).unwrap(),
1736 Some(DiscoveryEvent::ModelTaintsUpdated(ModelTaintsUpdate {
1737 id,
1738 taints: vec![
1739 "blue".to_string(),
1740 "dynamo.topology/zone=west".to_string(),
1741 "gpu".to_string(),
1742 ],
1743 }))
1744 );
1745 }
1746
1747 #[test]
1748 fn taint_order_only_changes_are_no_ops() {
1749 let existing = model_instance(&["gpu", "dynamo.topology/zone=west"]);
1750 let candidate = model_instance(&["dynamo.topology/zone=west", "gpu"]);
1751
1752 assert_eq!(
1753 classify_discovery_change(Some(&existing), &candidate).unwrap(),
1754 None
1755 );
1756 }
1757
1758 #[test]
1759 fn update_api_derives_topology_taints_and_rejects_reserved_input() {
1760 let existing = model_instance(&["old", "dynamo.topology/zone=west"]);
1761 let updated =
1762 model_with_updated_taints(&existing, HashSet::from(["new".to_string()])).unwrap();
1763 let (_, taints) = model_card_without_taints(&updated).unwrap();
1764 assert_eq!(
1765 taints,
1766 HashSet::from(["new".to_string(), "dynamo.topology/zone=west".to_string()])
1767 );
1768 assert!(
1769 model_with_updated_taints(
1770 &existing,
1771 HashSet::from(["dynamo.topology/zone=east".to_string()])
1772 )
1773 .is_err()
1774 );
1775 }
1776}