Skip to main content

dynamo_runtime/discovery/
kube.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4mod crd;
5mod daemon;
6mod utils;
7
8pub use crd::{DynamoWorkerMetadata, DynamoWorkerMetadataSpec};
9// hash_pod_name/hash_container_name are used by C bindings and the Rust EPP
10// for pod- and container-level worker ID mapping.
11pub 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    // Once started, persistence and the matching local commit must outlive request cancellation.
57    // Dropping the JoinHandle detaches this task instead of cancelling the remote-commit/local-
58    // state critical section.
59    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        // Persist even a local no-op. This repairs an authoritative CR that may differ after an
65        // earlier commit/ack ambiguity instead of trusting potentially stale local metadata.
66        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/// Kubernetes-based discovery client
75#[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    /// The daemon's readiness; `list` and `list_and_watch` wait for `Ready` on it.
82    daemon_state: watch::Receiver<DaemonState>,
83    kube_client: KubeClient,
84    pod_info: PodInfo,
85}
86
87impl KubeDiscoveryClient {
88    /// Waits until the daemon holds its first complete view of the cluster.
89    ///
90    /// Fails when the daemon stopped or failed first, or when `cancel_token` fires.
91    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    /// Create a new Kubernetes discovery client
116    ///
117    /// # Arguments
118    /// * `metadata` - Shared metadata store (also used by system server)
119    /// * `cancel_token` - Cancellation token for shutdown
120    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        // In container mode, delete any stale CR from a previous incarnation of this container.
143        // In failover pods, the pod stays alive when a container crashes and restarts,
144        // so the old CR persists. Deleting it ensures the daemon doesn't see stale data.
145        // In pod mode this is unnecessary — pod restart creates a new pod (and new CR name).
146        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        // Check Kubernetes API connectivity and permission to list DynamoWorkerMetadata.
192        // Request metadata for at most one object to keep the response small.
193        // An empty collection is valid before registration; API errors propagate.
194        // This does not verify discovery watch health or local cache synchronization.
195        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        // Write to local metadata and persist to CR
223        // IMPORTANT: Hold the write lock across the CR write to prevent race conditions
224        let mut metadata = self.metadata.write().await;
225
226        // Clone state for rollback in case CR persistence fails
227        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        // Build and apply the CR with the updated metadata
279        // This persists the metadata to Kubernetes for other pods to discover
280        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            // Rollback local state on CR persistence failure
290            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        // Write to local metadata and persist to CR
336        // IMPORTANT: Hold the write lock across the CR write to prevent race conditions
337        let mut metadata = self.metadata.write().await;
338
339        // Clone state for rollback in case CR persistence fails
340        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        // Build and apply the CR with the updated metadata
389        // This persists the removal to Kubernetes for other pods to see
390        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            // Rollback local state on CR persistence failure
400            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        // Before the initial sync, list_state is empty whatever the cluster holds.
417        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        // Before the initial sync, the snapshot would be a false empty set.
445        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        // Acquire read lock, subscribe to broadcast, then read initial state.
450        // The write lock (held by the daemon while updating list_state and sending events)
451        // is mutually exclusive with our read lock, so no events can slip between
452        // our subscription point and our initial state read.
453        // This runs before the return, so a caller that lists afterwards cannot observe an
454        // instance that a removal deletes before the subscription.
455        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                            // The daemon publishes incremental events only.
538                            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    // Regression: suppressing NotFound reports healthy for a missing discovery
596    // API, even though discovery cannot list or watch any workers.
597    #[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    /// A client over `instances` with no cluster behind it, and the sender for its daemon
688    /// state, which starts `Pending`.
689    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}