1mod crd;
5mod daemon;
6mod utils;
7
8pub use crd::{DynamoWorkerMetadata, DynamoWorkerMetadataSpec};
9pub use utils::{hash_container_name, hash_pod_name};
12
13use crd::{apply_cr, build_cr};
14use daemon::{DaemonOutputs, DaemonState, DiscoveryDaemon, ListState};
15use utils::{KubeDiscoveryMode, PodInfo};
16
17use crate::CancellationToken;
18use crate::discovery::{
19 Discovery, DiscoveryEvent, DiscoveryInstance, DiscoveryInstanceId, DiscoveryMetadata,
20 DiscoveryQuery, DiscoverySpec, DiscoveryStream, MAX_JSON_SAFE_PUBLISHER_ID,
21 ModelCardInstanceId, reconcile_discovery_snapshot, resync_discovery_events,
22};
23use anyhow::Result;
24use async_trait::async_trait;
25use kube::{
26 Api, Client as KubeClient,
27 api::{DeleteParams, ListParams},
28};
29use std::collections::{HashMap, HashSet};
30use std::future::Future;
31use std::sync::Arc;
32use tokio::sync::{RwLock, broadcast, watch};
33
34fn validate_kubernetes_publisher_id(publisher_id: u64) -> Result<()> {
35 if publisher_id > MAX_JSON_SAFE_PUBLISHER_ID {
36 anyhow::bail!(
37 "Kubernetes discovery publisher ID {publisher_id} exceeds the JSON-safe maximum \
38 {MAX_JSON_SAFE_PUBLISHER_ID}"
39 );
40 }
41
42 Ok(())
43}
44
45async fn update_model_taints_and_persist<F, Fut>(
46 metadata: &Arc<RwLock<DiscoveryMetadata>>,
47 id: ModelCardInstanceId,
48 taints: HashSet<String>,
49 persist: F,
50) -> Result<bool>
51where
52 F: FnOnce(DiscoveryMetadata) -> Fut + Send + 'static,
53 Fut: Future<Output = Result<DiscoveryMetadata>> + Send + 'static,
54{
55 let metadata = Arc::clone(metadata);
56 tokio::spawn(async move {
60 let mut metadata = metadata.write().await;
61 let mut candidate = metadata.clone();
62 let changed = candidate.update_model_taints(&id, taints)?;
63
64 let persisted = persist(candidate).await?;
67 *metadata = persisted;
68 Ok(changed)
69 })
70 .await
71 .map_err(|error| anyhow::anyhow!("model taint persistence task failed: {error}"))?
72}
73
74#[derive(Clone)]
76pub struct KubeDiscoveryClient {
77 instance_id: u64,
78 metadata: Arc<RwLock<DiscoveryMetadata>>,
79 list_state: ListState,
80 event_tx: broadcast::Sender<DiscoveryEvent>,
81 daemon_state: watch::Receiver<DaemonState>,
83 kube_client: KubeClient,
84 pod_info: PodInfo,
85}
86
87impl KubeDiscoveryClient {
88 async fn await_daemon_ready(&self, cancel_token: Option<&CancellationToken>) -> Result<()> {
92 let mut daemon_state = self.daemon_state.clone();
93 let settled = daemon_state.wait_for(|state| *state != DaemonState::Pending);
94 let state = match cancel_token {
95 Some(token) => token.run_until_cancelled(settled).await.ok_or_else(|| {
96 anyhow::anyhow!(
97 "the watch was cancelled before the Kubernetes discovery daemon was ready"
98 )
99 })?,
100 None => settled.await,
101 }
102 .map_err(|_| {
103 anyhow::anyhow!("the Kubernetes discovery daemon ended without reporting its state")
104 })?;
105 match &*state {
106 DaemonState::Ready => Ok(()),
107 DaemonState::Stopped => anyhow::bail!("the Kubernetes discovery daemon is stopped"),
108 DaemonState::Failed(reason) => {
109 anyhow::bail!("the Kubernetes discovery daemon failed: {reason}")
110 }
111 DaemonState::Pending => unreachable!("wait_for returns once the daemon left Pending"),
112 }
113 }
114
115 pub async fn new(
121 metadata: Arc<RwLock<DiscoveryMetadata>>,
122 cancel_token: CancellationToken,
123 ) -> Result<Self> {
124 let pod_info = PodInfo::from_env()?;
125 let instance_id = pod_info.target.instance_id();
126 let cr_name = pod_info.target.cr_name();
127
128 tracing::info!(
129 "Initializing KubeDiscoveryClient: mode={:?}, target={:?}, cr_name={}, instance_id={:x}, namespace={}, pod_uid={}",
130 pod_info.mode,
131 pod_info.target,
132 cr_name,
133 instance_id,
134 pod_info.pod_namespace,
135 pod_info.pod_uid
136 );
137
138 let kube_client = KubeClient::try_default()
139 .await
140 .map_err(|e| anyhow::anyhow!("Failed to create Kubernetes client: {}", e))?;
141
142 if pod_info.mode == KubeDiscoveryMode::Container {
147 let cr_api: Api<DynamoWorkerMetadata> =
148 Api::namespaced(kube_client.clone(), &pod_info.pod_namespace);
149 match cr_api.delete(&cr_name, &DeleteParams::default()).await {
150 Ok(_) => tracing::info!("Deleted stale CR: {}", cr_name),
151 Err(kube::Error::Api(err_resp)) if err_resp.code == 404 => {
152 tracing::debug!("No stale CR to delete: {}", cr_name);
153 }
154 Err(e) => {
155 panic!(
156 "Failed to clear stale CR '{}': {} — cannot start with stale discovery state",
157 cr_name, e
158 );
159 }
160 }
161 }
162
163 let list_state: ListState = Arc::new(RwLock::new(HashMap::new()));
164 let (event_tx, _) = broadcast::channel::<DiscoveryEvent>(4096);
165 let (state_tx, daemon_state) = watch::channel(DaemonState::Pending);
166
167 let daemon = DiscoveryDaemon::new(kube_client.clone(), pod_info.clone(), cancel_token)?;
168 tokio::spawn(daemon.run(DaemonOutputs {
169 list_state: list_state.clone(),
170 event_tx: event_tx.clone(),
171 state_tx,
172 }));
173
174 tracing::info!("Discovery daemon started");
175
176 Ok(Self {
177 instance_id,
178 metadata,
179 list_state,
180 event_tx,
181 daemon_state,
182 kube_client,
183 pod_info,
184 })
185 }
186}
187
188#[async_trait]
189impl Discovery for KubeDiscoveryClient {
190 async fn check_connection(&self) -> Result<()> {
191 let api: Api<DynamoWorkerMetadata> =
196 Api::namespaced(self.kube_client.clone(), &self.pod_info.pod_namespace);
197 api.list_metadata(&ListParams::default().limit(1)).await?;
198 Ok(())
199 }
200
201 fn instance_id(&self) -> u64 {
202 self.instance_id
203 }
204
205 async fn register_internal(&self, spec: DiscoverySpec) -> Result<DiscoveryInstance> {
206 match &spec {
207 DiscoverySpec::EventChannel { publisher_id, .. }
208 | DiscoverySpec::EventSource { publisher_id, .. } => {
209 validate_kubernetes_publisher_id(*publisher_id)?;
210 }
211 _ => {}
212 }
213 let instance = spec.into_instance(self.instance_id());
214 let instance_id = instance.instance_id();
215
216 tracing::debug!(
217 "Registering discovery instance: {:?}, instance_id={:x}",
218 instance,
219 instance_id
220 );
221
222 let mut metadata = self.metadata.write().await;
225
226 let original_state = metadata.clone();
228
229 let registered_instance = match &instance {
230 DiscoveryInstance::Endpoint(inst) => {
231 tracing::info!(
232 "Registering endpoint: namespace={}, component={}, endpoint={}, instance_id={:x}",
233 inst.namespace,
234 inst.component,
235 inst.endpoint,
236 instance_id
237 );
238 metadata.register_endpoint(instance.clone())?;
239 instance.clone()
240 }
241 DiscoveryInstance::Model {
242 namespace,
243 component,
244 endpoint,
245 ..
246 } => {
247 tracing::info!(
248 "Registering model card: namespace={}, component={}, endpoint={}, instance_id={:x}",
249 namespace,
250 component,
251 endpoint,
252 instance_id
253 );
254 metadata.register_model_card(instance.clone())?
255 }
256 DiscoveryInstance::EventChannel { scope, topic, .. } => {
257 tracing::info!(
258 "Registering event channel: scope={:?}, topic={}, instance_id={:x}",
259 scope,
260 topic,
261 instance_id
262 );
263 metadata.register_event_channel(instance.clone())?;
264 instance.clone()
265 }
266 DiscoveryInstance::EventSource { scope, topic, .. } => {
267 tracing::info!(
268 "Registering event source: scope={:?}, topic={}, publisher_id={:x}",
269 scope,
270 topic,
271 instance_id
272 );
273 metadata.register_event_source(instance.clone())?;
274 instance.clone()
275 }
276 };
277
278 let cr_name = self.pod_info.target.cr_name();
281 let cr = build_cr(
282 &cr_name,
283 &self.pod_info.pod_name,
284 &self.pod_info.pod_uid,
285 &metadata,
286 )?;
287
288 if let Err(e) = apply_cr(&self.kube_client, &self.pod_info.pod_namespace, &cr).await {
289 tracing::warn!(
291 "Failed to persist metadata to CR, rolling back local state: {}",
292 e
293 );
294 *metadata = original_state;
295 return Err(e);
296 }
297
298 tracing::debug!("Persisted metadata to DynamoWorkerMetadata CR");
299
300 Ok(registered_instance)
301 }
302
303 async fn update_model_taints_internal(
304 &self,
305 id: ModelCardInstanceId,
306 taints: HashSet<String>,
307 ) -> Result<()> {
308 let kube_client = self.kube_client.clone();
309 let pod_namespace = self.pod_info.pod_namespace.clone();
310 let cr_name = self.pod_info.target.cr_name();
311 let pod_name = self.pod_info.pod_name.clone();
312 let pod_uid = self.pod_info.pod_uid.clone();
313 let changed = update_model_taints_and_persist(
314 &self.metadata,
315 id,
316 taints,
317 move |candidate| async move {
318 let cr = build_cr(&cr_name, &pod_name, &pod_uid, &candidate)?;
319 apply_cr(&kube_client, &pod_namespace, &cr).await?;
320 Ok(candidate)
321 },
322 )
323 .await?;
324 if !changed {
325 return Ok(());
326 }
327
328 tracing::debug!("Persisted model taint update to DynamoWorkerMetadata CR");
329 Ok(())
330 }
331
332 async fn unregister(&self, instance: DiscoveryInstance) -> Result<()> {
333 let instance_id = instance.instance_id();
334
335 let mut metadata = self.metadata.write().await;
338
339 let original_state = metadata.clone();
341
342 match &instance {
343 DiscoveryInstance::Endpoint(inst) => {
344 tracing::info!(
345 "Unregistering endpoint: namespace={}, component={}, endpoint={}, instance_id={:x}",
346 inst.namespace,
347 inst.component,
348 inst.endpoint,
349 instance_id
350 );
351 metadata.unregister_endpoint(&instance)?;
352 }
353 DiscoveryInstance::Model {
354 namespace,
355 component,
356 endpoint,
357 ..
358 } => {
359 tracing::info!(
360 "Unregistering model card: namespace={}, component={}, endpoint={}, instance_id={:x}",
361 namespace,
362 component,
363 endpoint,
364 instance_id
365 );
366 metadata.unregister_model_card(&instance)?;
367 }
368 DiscoveryInstance::EventChannel { scope, topic, .. } => {
369 tracing::info!(
370 "Unregistering event channel: scope={:?}, topic={}, instance_id={:x}",
371 scope,
372 topic,
373 instance_id
374 );
375 metadata.unregister_event_channel(&instance)?;
376 }
377 DiscoveryInstance::EventSource { scope, topic, .. } => {
378 tracing::info!(
379 "Unregistering event source: scope={:?}, topic={}, publisher_id={:x}",
380 scope,
381 topic,
382 instance_id
383 );
384 metadata.unregister_event_source(&instance)?;
385 }
386 }
387
388 let cr_name = self.pod_info.target.cr_name();
391 let cr = build_cr(
392 &cr_name,
393 &self.pod_info.pod_name,
394 &self.pod_info.pod_uid,
395 &metadata,
396 )?;
397
398 if let Err(e) = apply_cr(&self.kube_client, &self.pod_info.pod_namespace, &cr).await {
399 tracing::warn!(
401 "Failed to persist metadata removal to CR, rolling back local state: {}",
402 e
403 );
404 *metadata = original_state;
405 return Err(e);
406 }
407
408 tracing::debug!("Persisted metadata removal to DynamoWorkerMetadata CR");
409
410 Ok(())
411 }
412
413 async fn list(&self, query: DiscoveryQuery) -> Result<Vec<DiscoveryInstance>> {
414 tracing::debug!("KubeDiscoveryClient::list called with query={:?}", query);
415
416 self.await_daemon_ready(None).await?;
418 let state = self.list_state.read().await;
419 let instances: Vec<DiscoveryInstance> =
420 state.values().flat_map(|m| m.filter(&query)).collect();
421
422 tracing::info!(
423 "KubeDiscoveryClient::list returning {} instances for query={:?}",
424 instances.len(),
425 query
426 );
427
428 Ok(instances)
429 }
430
431 async fn list_and_watch(
432 &self,
433 query: DiscoveryQuery,
434 cancel_token: Option<CancellationToken>,
435 ) -> Result<DiscoveryStream> {
436 use broadcast::error::RecvError;
437 use tokio::sync::mpsc;
438
439 tracing::info!(
440 "KubeDiscoveryClient::list_and_watch started for query={:?}",
441 query
442 );
443
444 self.await_daemon_ready(cancel_token.as_ref()).await?;
446 let (out_tx, out_rx) = mpsc::unbounded_channel();
447 let stream_id = uuid::Uuid::new_v4();
448
449 let (initial_instances, mut broadcast_rx) = {
456 let state = self.list_state.read().await;
457 let rx = self.event_tx.subscribe();
458 let initial = state
459 .values()
460 .flat_map(|m| m.filter(&query))
461 .collect::<Vec<_>>();
462 (initial, rx)
463 };
464 let list_state = self.list_state.clone();
465
466 tokio::spawn(async move {
467 tracing::debug!(
468 stream_id = %stream_id,
469 initial_count = initial_instances.len(),
470 "Watch started for query={:?}",
471 query
472 );
473
474 let mut known: HashMap<DiscoveryInstanceId, DiscoveryInstance> = initial_instances
475 .iter()
476 .map(|i| (i.id(), i.clone()))
477 .collect();
478
479 for instance in &initial_instances {
480 tracing::info!(
481 stream_id = %stream_id,
482 instance_id = format!("{:x}", instance.instance_id()),
483 "Emitting initial Added event"
484 );
485 if out_tx
486 .send(Ok(DiscoveryEvent::Added(instance.clone())))
487 .is_err()
488 {
489 return;
490 }
491 }
492 if out_tx
493 .send(Ok(DiscoveryEvent::Resync(initial_instances)))
494 .is_err()
495 {
496 return;
497 }
498
499 loop {
500 let recv_result = if let Some(ref token) = cancel_token {
501 tokio::select! {
502 result = broadcast_rx.recv() => result,
503 _ = token.cancelled() => {
504 tracing::info!(stream_id = %stream_id, "Watch cancelled via cancel token");
505 break;
506 }
507 }
508 } else {
509 broadcast_rx.recv().await
510 };
511
512 match recv_result {
513 Ok(event) => {
514 let forwarded = match &event {
515 DiscoveryEvent::Added(instance) => {
516 if instance.matches(&query) {
517 let id = instance.id();
518 if known.get(&id) != Some(instance) {
519 known.insert(id.clone(), instance.clone());
520 Some(("added", id))
521 } else {
522 None
523 }
524 } else {
525 None
526 }
527 }
528 DiscoveryEvent::Removed(id) => {
529 known.remove(id).is_some().then(|| ("removed", id.clone()))
530 }
531 DiscoveryEvent::ModelTaintsUpdated(update) => {
532 let id = DiscoveryInstanceId::Model(update.id.clone());
533 known
534 .contains_key(&id)
535 .then_some(("model_taints_updated", id))
536 }
537 DiscoveryEvent::Resync(_) => None,
539 };
540 if let Some((event_kind, instance_id)) = forwarded {
541 tracing::info!(
542 stream_id = %stream_id,
543 event_kind,
544 ?instance_id,
545 "Emitting discovery event"
546 );
547 if out_tx.send(Ok(event)).is_err() {
548 return;
549 }
550 }
551 }
552 Err(RecvError::Lagged(n)) => {
553 tracing::warn!(
554 stream_id = %stream_id,
555 dropped = n,
556 "Broadcast receiver lagged, reconciling from list_state"
557 );
558 let state = list_state.read().await;
559 let current: HashMap<DiscoveryInstanceId, DiscoveryInstance> = state
560 .values()
561 .flat_map(|m| m.filter(&query))
562 .map(|i| (i.id(), i))
563 .collect();
564 drop(state);
565 for event in resync_discovery_events(&mut known, current) {
566 if out_tx.send(Ok(event)).is_err() {
567 return;
568 }
569 }
570 }
571 Err(RecvError::Closed) => {
572 tracing::info!(
573 stream_id = %stream_id,
574 "Broadcast channel closed (daemon stopped)"
575 );
576 break;
577 }
578 }
579 }
580 });
581
582 Ok(Box::pin(
583 tokio_stream::wrappers::UnboundedReceiverStream::new(out_rx),
584 ))
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::component::TransportType;
592 use crate::discovery::startup_contract as contract;
593 use crate::discovery::{EventScope, EventTransport, ModelTaintsUpdate};
594
595 #[tokio::test]
598 async fn connectivity_requires_resource_but_not_registered_workers() {
599 use axum::{
600 body::Body,
601 http::{Request, Response, StatusCode},
602 };
603 use std::sync::atomic::{AtomicBool, Ordering};
604
605 let available = Arc::new(AtomicBool::new(false));
606 let resource_available = available.clone();
607 let service = tower::service_fn(move |request: Request<kube::client::Body>| {
608 assert_eq!(request.method(), "GET");
609 assert_eq!(
610 request.uri().path(),
611 "/apis/nvidia.com/v1alpha1/namespaces/default/dynamoworkermetadatas"
612 );
613 assert!(
614 request
615 .uri()
616 .query()
617 .unwrap()
618 .split('&')
619 .any(|part| part == "limit=1")
620 );
621 assert_eq!(
622 request.headers()["accept"],
623 "application/json;as=PartialObjectMetadataList;g=meta.k8s.io;v=v1"
624 );
625 let available = resource_available.load(Ordering::Acquire);
626 async move {
627 let response = if available {
628 Response::new(Body::from(
629 r#"{"apiVersion":"meta.k8s.io/v1","kind":"PartialObjectMetadataList","metadata":{"resourceVersion":"1"},"items":[]}"#,
630 ))
631 } else {
632 Response::builder().status(StatusCode::NOT_FOUND).body(Body::from(
633 r#"{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"NotFound","code":404,"message":"resource not found"}"#,
634 )).unwrap()
635 };
636 Ok::<_, std::convert::Infallible>(response)
637 }
638 });
639 let discovery = KubeDiscoveryClient {
640 instance_id: 1,
641 metadata: Arc::new(RwLock::new(DiscoveryMetadata::new())),
642 list_state: Arc::new(RwLock::new(HashMap::new())),
643 event_tx: broadcast::channel(1).0,
644 daemon_state: watch::channel(DaemonState::Pending).1,
645 kube_client: KubeClient::new(service, "default"),
646 pod_info: PodInfo {
647 pod_name: "worker".into(),
648 pod_namespace: "default".into(),
649 pod_uid: "uid".into(),
650 system_port: 0,
651 mode: KubeDiscoveryMode::Pod,
652 target: utils::KubeDiscoveryTarget::Pod("worker".into()),
653 },
654 };
655 assert!(discovery.check_connection().await.is_err());
656 available.store(true, Ordering::Release);
657 assert!(discovery.check_connection().await.is_ok());
658 available.store(false, Ordering::Release);
659 assert!(discovery.check_connection().await.is_err());
660 }
661
662 fn endpoint_instance(instance_id: u64, transport: &str) -> DiscoveryInstance {
663 DiscoveryInstance::Endpoint(crate::component::Instance {
664 namespace: "ns".to_string(),
665 component: "component".to_string(),
666 endpoint: "endpoint".to_string(),
667 instance_id,
668 transport: TransportType::Tcp(transport.to_string()),
669 device_type: None,
670 request_plane_codec: None,
671 })
672 }
673
674 fn model_with_taint(taint: &str) -> DiscoveryInstance {
675 DiscoveryInstance::Model {
676 namespace: "ns".to_string(),
677 component: "worker".to_string(),
678 endpoint: "generate".to_string(),
679 instance_id: 7,
680 card_json: serde_json::json!({
681 "runtime_config": {"taints": [taint]}
682 }),
683 model_suffix: None,
684 }
685 }
686
687 fn client_with(
690 instances: &[DiscoveryInstance],
691 ) -> (KubeDiscoveryClient, watch::Sender<DaemonState>) {
692 let kube_client =
693 KubeClient::try_from(kube::Config::new("http://127.0.0.1:1".parse().unwrap())).unwrap();
694 let list_state = instances
695 .iter()
696 .map(|instance| {
697 let mut metadata = DiscoveryMetadata::new();
698 metadata.register_endpoint(instance.clone()).unwrap();
699 (instance.instance_id(), Arc::new(metadata))
700 })
701 .collect();
702 let (event_tx, _) = broadcast::channel(16);
703 let (state_tx, daemon_state) = watch::channel(DaemonState::Pending);
704 let client = KubeDiscoveryClient {
705 instance_id: 1,
706 metadata: Arc::new(RwLock::new(DiscoveryMetadata::new())),
707 list_state: Arc::new(RwLock::new(list_state)),
708 event_tx,
709 daemon_state,
710 kube_client,
711 pod_info: PodInfo {
712 pod_name: "worker-a".to_string(),
713 pod_namespace: "ns".to_string(),
714 pod_uid: "pod-uid".to_string(),
715 system_port: 0,
716 mode: KubeDiscoveryMode::Pod,
717 target: utils::KubeDiscoveryTarget::Pod("worker-a".to_string()),
718 },
719 };
720 (client, state_tx)
721 }
722
723 #[tokio::test]
724 async fn watch_reports_the_initial_set_as_added_events_then_one_resync() {
725 let first = endpoint_instance(1, "127.0.0.1:8000");
726 let second = endpoint_instance(2, "127.0.0.1:9000");
727 let (client, state_tx) = client_with(&[first.clone(), second.clone()]);
728 state_tx.send_replace(DaemonState::Ready);
729 let mut events = client
730 .list_and_watch(DiscoveryQuery::AllEndpoints, None)
731 .await
732 .unwrap();
733
734 let mut added = HashSet::new();
735 for _ in 0..2 {
736 let DiscoveryEvent::Added(instance) = contract::next(&mut events).await else {
737 panic!("expected an initial Added event");
738 };
739 added.insert(instance.id());
740 }
741 assert_eq!(added, HashSet::from([first.id(), second.id()]));
742 let DiscoveryEvent::Resync(snapshot) = contract::next(&mut events).await else {
743 panic!("expected the establishment snapshot after the Added burst");
744 };
745 assert_eq!(
746 snapshot
747 .iter()
748 .map(DiscoveryInstance::id)
749 .collect::<HashSet<_>>(),
750 HashSet::from([first.id(), second.id()])
751 );
752
753 let third = endpoint_instance(3, "127.0.0.1:9100");
754 client
755 .event_tx
756 .send(DiscoveryEvent::Added(third.clone()))
757 .unwrap();
758 assert_eq!(
759 contract::next(&mut events).await,
760 DiscoveryEvent::Added(third)
761 );
762 }
763
764 #[tokio::test]
765 async fn list_and_watch_waits_for_ready_unless_cancelled_or_the_daemon_ended() {
766 let (client, state_tx) = client_with(&[]);
767 let client = Arc::new(client);
768 let open_watch = |token: Option<CancellationToken>| {
769 let client = client.clone();
770 tokio::spawn(async move {
771 client
772 .list_and_watch(DiscoveryQuery::AllEndpoints, token)
773 .await
774 })
775 };
776 let finish = |task| tokio::time::timeout(std::time::Duration::from_secs(1), task);
777
778 let token = CancellationToken::new();
779 let cancelled = open_watch(Some(token.clone()));
780 tokio::task::yield_now().await;
781 assert!(
782 !cancelled.is_finished(),
783 "a pending daemon must hold the watch"
784 );
785 token.cancel();
786 let Err(error) = finish(cancelled)
787 .await
788 .expect("cancellation must release the wait")
789 .unwrap()
790 else {
791 panic!("a watch cancelled before Ready must fail");
792 };
793 assert!(
794 error.to_string().contains("cancelled"),
795 "unexpected error: {error}"
796 );
797
798 let waiting = open_watch(None);
799 tokio::task::yield_now().await;
800 assert!(
801 !waiting.is_finished(),
802 "a pending daemon must hold the watch"
803 );
804 state_tx.send_replace(DaemonState::Ready);
805 let mut events = finish(waiting)
806 .await
807 .expect("Ready must release the wait")
808 .unwrap()
809 .unwrap();
810 contract::expect_empty_snapshot(&mut events).await;
811
812 for (state, expected) in [
813 (
814 DaemonState::Failed("reflector stopped".to_string()),
815 "daemon failed: reflector stopped",
816 ),
817 (DaemonState::Stopped, "daemon is stopped"),
818 ] {
819 state_tx.send_replace(state);
820 let Err(error) = client
821 .list_and_watch(DiscoveryQuery::AllEndpoints, None)
822 .await
823 else {
824 panic!("a daemon that ended before Ready must fail the watch");
825 };
826 assert!(
827 error.to_string().contains(expected),
828 "unexpected error: {error}"
829 );
830 let error = client
831 .list(DiscoveryQuery::AllEndpoints)
832 .await
833 .expect_err("a daemon that ended before Ready must fail the list");
834 assert!(
835 error.to_string().contains(expected),
836 "unexpected error: {error}"
837 );
838 }
839 }
840
841 #[test]
842 fn publisher_ids_must_fit_kubernetes_json_safe_range() {
843 assert!(validate_kubernetes_publisher_id(MAX_JSON_SAFE_PUBLISHER_ID).is_ok());
844 assert!(validate_kubernetes_publisher_id(MAX_JSON_SAFE_PUBLISHER_ID + 1).is_err());
845 assert!(validate_kubernetes_publisher_id(u64::MAX).is_err());
846 }
847
848 #[test]
849 fn snapshot_diff_emits_updated_instance_when_transport_changes() {
850 let original = endpoint_instance(1, "127.0.0.1:8000");
851 let updated = endpoint_instance(1, "127.0.0.1:9000");
852 let known = HashMap::from([(original.id(), original)]);
853 let current = HashMap::from([(updated.id(), updated.clone())]);
854
855 let (events, reconciled) = reconcile_discovery_snapshot(&known, current);
856
857 assert_eq!(events, vec![DiscoveryEvent::Added(updated.clone())]);
858 assert_eq!(reconciled.get(&updated.id()), Some(&updated));
859 }
860
861 #[test]
862 fn snapshot_diff_ignores_same_id_event_channel_changes() {
863 let event_channel = |endpoint: &str| DiscoveryInstance::EventChannel {
864 scope: EventScope::Namespace {
865 name: "ns".to_string(),
866 },
867 topic: "topic".to_string(),
868 instance_id: 1,
869 transport: EventTransport::zmq(endpoint),
870 };
871 let original = event_channel("tcp://127.0.0.1:8000");
872 let updated = event_channel("tcp://127.0.0.1:9000");
873 let known = HashMap::from([(original.id(), original.clone())]);
874 let current = HashMap::from([(updated.id(), updated)]);
875
876 let (events, reconciled) = reconcile_discovery_snapshot(&known, current);
877
878 assert!(events.is_empty());
879 assert_eq!(reconciled.get(&original.id()), Some(&original));
880 }
881
882 #[test]
883 fn snapshot_diff_emits_added_and_removed_instances() {
884 let removed_instance = endpoint_instance(1, "127.0.0.1:8000");
885 let added_instance = endpoint_instance(2, "127.0.0.1:9000");
886 let removed_id = removed_instance.id();
887 let added_id = added_instance.id();
888 let known = HashMap::from([(removed_id.clone(), removed_instance)]);
889 let current = HashMap::from([(added_id.clone(), added_instance.clone())]);
890
891 let (events, reconciled) = reconcile_discovery_snapshot(&known, current);
892
893 assert_eq!(events.len(), 2);
894 assert!(events.contains(&DiscoveryEvent::Removed(removed_id.clone())));
895 assert!(events.contains(&DiscoveryEvent::Added(added_instance.clone())));
896 assert!(!reconciled.contains_key(&removed_id));
897 assert_eq!(reconciled.get(&added_id), Some(&added_instance));
898 }
899 #[test]
900 fn changed_model_taints_emit_scoped_event() {
901 let old = model_with_taint("old");
902 let updated = model_with_taint("updated");
903 let known = HashMap::from([(old.id(), old)]);
904 let current = HashMap::from([(updated.id(), updated.clone())]);
905
906 let (events, reconciled) = reconcile_discovery_snapshot(&known, current);
907
908 let DiscoveryInstanceId::Model(id) = updated.id() else {
909 unreachable!()
910 };
911 assert_eq!(
912 events,
913 vec![DiscoveryEvent::ModelTaintsUpdated(ModelTaintsUpdate {
914 id,
915 taints: vec!["updated".to_string()],
916 })]
917 );
918 assert_eq!(reconciled.get(&updated.id()), Some(&updated));
919 }
920
921 #[tokio::test]
922 async fn model_taint_persistence_completes_after_caller_cancellation() {
923 let model = model_with_taint("old");
924 let DiscoveryInstanceId::Model(id) = model.id() else {
925 unreachable!()
926 };
927 let mut initial = DiscoveryMetadata::new();
928 initial.register_model_card(model).unwrap();
929 let metadata = Arc::new(RwLock::new(initial));
930 let task_metadata = metadata.clone();
931 let remote = Arc::new(RwLock::new(DiscoveryMetadata::new()));
932 let task_remote = remote.clone();
933 let (remote_committed_tx, remote_committed_rx) = tokio::sync::oneshot::channel();
934 let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
935
936 let task = tokio::spawn(async move {
937 update_model_taints_and_persist(
938 &task_metadata,
939 id,
940 HashSet::from(["new".to_string()]),
941 move |candidate| async move {
942 *task_remote.write().await = candidate.clone();
943 remote_committed_tx.send(()).unwrap();
944 ack_rx.await.unwrap();
945 Ok(candidate)
946 },
947 )
948 .await
949 });
950
951 remote_committed_rx.await.unwrap();
952 task.abort();
953 assert!(task.await.unwrap_err().is_cancelled());
954 ack_tx.send(()).unwrap();
955
956 let stored = tokio::time::timeout(std::time::Duration::from_secs(1), async {
957 loop {
958 let stored = metadata.read().await.get_all_model_cards().pop().unwrap();
959 let DiscoveryInstance::Model { card_json, .. } = &stored else {
960 unreachable!()
961 };
962 if card_json["runtime_config"]["taints"] == serde_json::json!(["new"]) {
963 break stored;
964 }
965 tokio::task::yield_now().await;
966 }
967 })
968 .await
969 .expect("detached persistence did not commit local metadata");
970 let DiscoveryInstance::Model { card_json, .. } = stored else {
971 unreachable!()
972 };
973 assert_eq!(
974 card_json["runtime_config"]["taints"],
975 serde_json::json!(["new"])
976 );
977 let remote = remote.read().await.get_all_model_cards().pop().unwrap();
978 let DiscoveryInstance::Model { card_json, .. } = remote else {
979 unreachable!()
980 };
981 assert_eq!(
982 card_json["runtime_config"]["taints"],
983 serde_json::json!(["new"])
984 );
985 }
986
987 #[tokio::test]
988 async fn local_noop_reapplies_authoritative_model_taints() {
989 let local_model = model_with_taint("old");
990 let DiscoveryInstanceId::Model(id) = local_model.id() else {
991 unreachable!()
992 };
993 let mut initial = DiscoveryMetadata::new();
994 initial.register_model_card(local_model).unwrap();
995 let metadata = Arc::new(RwLock::new(initial));
996 let persisted = Arc::new(RwLock::new(None));
997 let task_persisted = persisted.clone();
998
999 let changed = update_model_taints_and_persist(
1000 &metadata,
1001 id,
1002 HashSet::from(["old".to_string()]),
1003 move |candidate| async move {
1004 *task_persisted.write().await = Some(candidate.clone());
1005 Ok(candidate)
1006 },
1007 )
1008 .await
1009 .unwrap();
1010
1011 assert!(!changed);
1012 let reapplied = persisted
1013 .read()
1014 .await
1015 .clone()
1016 .expect("no-op was not persisted");
1017 let DiscoveryInstance::Model { card_json, .. } =
1018 reapplied.get_all_model_cards().pop().unwrap()
1019 else {
1020 unreachable!()
1021 };
1022 assert_eq!(
1023 card_json["runtime_config"]["taints"],
1024 serde_json::json!(["old"])
1025 );
1026 }
1027}