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 is used by C bindings (EPP) for pod-level worker ID mapping.
10pub use utils::hash_pod_name;
11
12use crd::{apply_cr, build_cr};
13use daemon::DiscoveryDaemon;
14use utils::{KubeDiscoveryMode, PodInfo};
15
16use crate::CancellationToken;
17use crate::discovery::{
18    Discovery, DiscoveryEvent, DiscoveryInstance, DiscoveryInstanceId, DiscoveryMetadata,
19    DiscoveryQuery, DiscoverySpec, DiscoveryStream, MAX_JSON_SAFE_PUBLISHER_ID, MetadataSnapshot,
20};
21use anyhow::Result;
22use async_trait::async_trait;
23use kube::{Api, Client as KubeClient, api::DeleteParams};
24use std::collections::HashSet;
25use std::sync::Arc;
26use tokio::sync::RwLock;
27
28fn validate_kubernetes_publisher_id(publisher_id: u64) -> Result<()> {
29    if publisher_id > MAX_JSON_SAFE_PUBLISHER_ID {
30        anyhow::bail!(
31            "Kubernetes discovery publisher ID {publisher_id} exceeds the JSON-safe maximum \
32             {MAX_JSON_SAFE_PUBLISHER_ID}"
33        );
34    }
35
36    Ok(())
37}
38
39/// Kubernetes-based discovery client
40#[derive(Clone)]
41pub struct KubeDiscoveryClient {
42    instance_id: u64,
43    metadata: Arc<RwLock<DiscoveryMetadata>>,
44    metadata_watch: tokio::sync::watch::Receiver<Arc<MetadataSnapshot>>,
45    kube_client: KubeClient,
46    pod_info: PodInfo,
47}
48
49impl KubeDiscoveryClient {
50    /// Create a new Kubernetes discovery client
51    ///
52    /// # Arguments
53    /// * `metadata` - Shared metadata store (also used by system server)
54    /// * `cancel_token` - Cancellation token for shutdown
55    pub async fn new(
56        metadata: Arc<RwLock<DiscoveryMetadata>>,
57        cancel_token: CancellationToken,
58    ) -> Result<Self> {
59        let pod_info = PodInfo::from_env()?;
60        let instance_id = pod_info.target.instance_id();
61        let cr_name = pod_info.target.cr_name();
62
63        tracing::info!(
64            "Initializing KubeDiscoveryClient: mode={:?}, target={:?}, cr_name={}, instance_id={:x}, namespace={}, pod_uid={}",
65            pod_info.mode,
66            pod_info.target,
67            cr_name,
68            instance_id,
69            pod_info.pod_namespace,
70            pod_info.pod_uid
71        );
72
73        let kube_client = KubeClient::try_default()
74            .await
75            .map_err(|e| anyhow::anyhow!("Failed to create Kubernetes client: {}", e))?;
76
77        // In container mode, delete any stale CR from a previous incarnation of this container.
78        // In failover pods, the pod stays alive when a container crashes and restarts,
79        // so the old CR persists. Deleting it ensures the daemon doesn't see stale data.
80        // In pod mode this is unnecessary — pod restart creates a new pod (and new CR name).
81        if pod_info.mode == KubeDiscoveryMode::Container {
82            let cr_api: Api<DynamoWorkerMetadata> =
83                Api::namespaced(kube_client.clone(), &pod_info.pod_namespace);
84            match cr_api.delete(&cr_name, &DeleteParams::default()).await {
85                Ok(_) => tracing::info!("Deleted stale CR: {}", cr_name),
86                Err(kube::Error::Api(err_resp)) if err_resp.code == 404 => {
87                    tracing::debug!("No stale CR to delete: {}", cr_name);
88                }
89                Err(e) => {
90                    panic!(
91                        "Failed to clear stale CR '{}': {} — cannot start with stale discovery state",
92                        cr_name, e
93                    );
94                }
95            }
96        }
97
98        // Create watch channel with initial empty snapshot
99        let (watch_tx, watch_rx) = tokio::sync::watch::channel(Arc::new(MetadataSnapshot::empty()));
100
101        // Create and spawn daemon
102        let daemon = DiscoveryDaemon::new(kube_client.clone(), pod_info.clone(), cancel_token)?;
103
104        tokio::spawn(async move {
105            if let Err(e) = daemon.run(watch_tx).await {
106                tracing::error!("Discovery daemon failed: {e}");
107            }
108        });
109
110        tracing::info!("Discovery daemon started");
111
112        Ok(Self {
113            instance_id,
114            metadata,
115            metadata_watch: watch_rx,
116            kube_client,
117            pod_info,
118        })
119    }
120}
121
122#[async_trait]
123impl Discovery for KubeDiscoveryClient {
124    fn instance_id(&self) -> u64 {
125        self.instance_id
126    }
127
128    async fn register_internal(&self, spec: DiscoverySpec) -> Result<DiscoveryInstance> {
129        match &spec {
130            DiscoverySpec::EventChannel { publisher_id, .. }
131            | DiscoverySpec::EventSource { publisher_id, .. } => {
132                validate_kubernetes_publisher_id(*publisher_id)?;
133            }
134            _ => {}
135        }
136        let instance = spec.into_instance(self.instance_id());
137        let instance_id = instance.instance_id();
138
139        tracing::debug!(
140            "Registering discovery instance: {:?}, instance_id={:x}",
141            instance,
142            instance_id
143        );
144
145        // Write to local metadata and persist to CR
146        // IMPORTANT: Hold the write lock across the CR write to prevent race conditions
147        let mut metadata = self.metadata.write().await;
148
149        // Clone state for rollback in case CR persistence fails
150        let original_state = metadata.clone();
151
152        match &instance {
153            DiscoveryInstance::Endpoint(inst) => {
154                tracing::info!(
155                    "Registering endpoint: namespace={}, component={}, endpoint={}, instance_id={:x}",
156                    inst.namespace,
157                    inst.component,
158                    inst.endpoint,
159                    instance_id
160                );
161                metadata.register_endpoint(instance.clone())?;
162            }
163            DiscoveryInstance::Model {
164                namespace,
165                component,
166                endpoint,
167                ..
168            } => {
169                tracing::info!(
170                    "Registering model card: namespace={}, component={}, endpoint={}, instance_id={:x}",
171                    namespace,
172                    component,
173                    endpoint,
174                    instance_id
175                );
176                metadata.register_model_card(instance.clone())?;
177            }
178            DiscoveryInstance::EventChannel { scope, topic, .. } => {
179                tracing::info!(
180                    "Registering event channel: scope={:?}, topic={}, instance_id={:x}",
181                    scope,
182                    topic,
183                    instance_id
184                );
185                metadata.register_event_channel(instance.clone())?;
186            }
187            DiscoveryInstance::EventSource { scope, topic, .. } => {
188                tracing::info!(
189                    "Registering event source: scope={:?}, topic={}, publisher_id={:x}",
190                    scope,
191                    topic,
192                    instance_id
193                );
194                metadata.register_event_source(instance.clone())?;
195            }
196        }
197
198        // Build and apply the CR with the updated metadata
199        // This persists the metadata to Kubernetes for other pods to discover
200        let cr_name = self.pod_info.target.cr_name();
201        let cr = build_cr(
202            &cr_name,
203            &self.pod_info.pod_name,
204            &self.pod_info.pod_uid,
205            &metadata,
206        )?;
207
208        if let Err(e) = apply_cr(&self.kube_client, &self.pod_info.pod_namespace, &cr).await {
209            // Rollback local state on CR persistence failure
210            tracing::warn!(
211                "Failed to persist metadata to CR, rolling back local state: {}",
212                e
213            );
214            *metadata = original_state;
215            return Err(e);
216        }
217
218        tracing::debug!("Persisted metadata to DynamoWorkerMetadata CR");
219
220        Ok(instance)
221    }
222
223    async fn unregister(&self, instance: DiscoveryInstance) -> Result<()> {
224        let instance_id = instance.instance_id();
225
226        // Write to local metadata and persist to CR
227        // IMPORTANT: Hold the write lock across the CR write to prevent race conditions
228        let mut metadata = self.metadata.write().await;
229
230        // Clone state for rollback in case CR persistence fails
231        let original_state = metadata.clone();
232
233        match &instance {
234            DiscoveryInstance::Endpoint(inst) => {
235                tracing::info!(
236                    "Unregistering endpoint: namespace={}, component={}, endpoint={}, instance_id={:x}",
237                    inst.namespace,
238                    inst.component,
239                    inst.endpoint,
240                    instance_id
241                );
242                metadata.unregister_endpoint(&instance)?;
243            }
244            DiscoveryInstance::Model {
245                namespace,
246                component,
247                endpoint,
248                ..
249            } => {
250                tracing::info!(
251                    "Unregistering model card: namespace={}, component={}, endpoint={}, instance_id={:x}",
252                    namespace,
253                    component,
254                    endpoint,
255                    instance_id
256                );
257                metadata.unregister_model_card(&instance)?;
258            }
259            DiscoveryInstance::EventChannel { scope, topic, .. } => {
260                tracing::info!(
261                    "Unregistering event channel: scope={:?}, topic={}, instance_id={:x}",
262                    scope,
263                    topic,
264                    instance_id
265                );
266                metadata.unregister_event_channel(&instance)?;
267            }
268            DiscoveryInstance::EventSource { scope, topic, .. } => {
269                tracing::info!(
270                    "Unregistering event source: scope={:?}, topic={}, publisher_id={:x}",
271                    scope,
272                    topic,
273                    instance_id
274                );
275                metadata.unregister_event_source(&instance)?;
276            }
277        }
278
279        // Build and apply the CR with the updated metadata
280        // This persists the removal to Kubernetes for other pods to see
281        let cr_name = self.pod_info.target.cr_name();
282        let cr = build_cr(
283            &cr_name,
284            &self.pod_info.pod_name,
285            &self.pod_info.pod_uid,
286            &metadata,
287        )?;
288
289        if let Err(e) = apply_cr(&self.kube_client, &self.pod_info.pod_namespace, &cr).await {
290            // Rollback local state on CR persistence failure
291            tracing::warn!(
292                "Failed to persist metadata removal to CR, rolling back local state: {}",
293                e
294            );
295            *metadata = original_state;
296            return Err(e);
297        }
298
299        tracing::debug!("Persisted metadata removal to DynamoWorkerMetadata CR");
300
301        Ok(())
302    }
303
304    async fn list(&self, query: DiscoveryQuery) -> Result<Vec<DiscoveryInstance>> {
305        tracing::debug!("KubeDiscoveryClient::list called with query={:?}", query);
306
307        // Get current snapshot (may be empty if daemon hasn't fetched yet)
308        let snapshot = self.metadata_watch.borrow().clone();
309
310        tracing::debug!(
311            "List using snapshot seq={} with {} instances",
312            snapshot.sequence,
313            snapshot.instances.len()
314        );
315
316        // Filter snapshot by query
317        let instances = snapshot.filter(&query);
318
319        tracing::info!(
320            "KubeDiscoveryClient::list returning {} instances for query={:?}",
321            instances.len(),
322            query
323        );
324
325        Ok(instances)
326    }
327
328    async fn list_and_watch(
329        &self,
330        query: DiscoveryQuery,
331        cancel_token: Option<CancellationToken>,
332    ) -> Result<DiscoveryStream> {
333        use tokio::sync::mpsc;
334
335        tracing::info!(
336            "KubeDiscoveryClient::list_and_watch started for query={:?}",
337            query
338        );
339
340        // Clone the watch receiver
341        let mut watch_rx = self.metadata_watch.clone();
342
343        // Create output stream
344        let (event_tx, event_rx) = mpsc::unbounded_channel();
345
346        // Generate unique stream identifier for tracing
347        let stream_id = uuid::Uuid::new_v4();
348
349        // Spawn task to process snapshots
350        tokio::spawn(async move {
351            // Initialize from current snapshot state
352            // This is critical: watch_rx.changed() only fires on FUTURE changes,
353            // so we must capture the current state first to detect removals correctly
354            let initial_snapshot = watch_rx.borrow_and_update().clone();
355
356            // Build initial map: DiscoveryInstanceId -> DiscoveryInstance
357            let initial: std::collections::HashMap<DiscoveryInstanceId, DiscoveryInstance> =
358                initial_snapshot
359                    .instances
360                    .values()
361                    .flat_map(|metadata| metadata.filter(&query))
362                    .map(|instance| (instance.id(), instance))
363                    .collect();
364
365            tracing::debug!(
366                stream_id = %stream_id,
367                initial_count = initial.len(),
368                "Watch started for query={:?}",
369                query
370            );
371
372            // Emit initial Added events (the "list" part of list_and_watch)
373            for instance in initial.values() {
374                tracing::info!(
375                    stream_id = %stream_id,
376                    instance_id = format!("{:x}", instance.instance_id()),
377                    "Emitting initial Added event"
378                );
379                if event_tx
380                    .send(Ok(DiscoveryEvent::Added(instance.clone())))
381                    .is_err()
382                {
383                    tracing::debug!(
384                        stream_id = %stream_id,
385                        "Watch receiver dropped during initial sync"
386                    );
387                    return;
388                }
389            }
390
391            // Track known instances by their unique ID
392            let mut known: HashSet<DiscoveryInstanceId> = initial.into_keys().collect();
393
394            loop {
395                tracing::trace!(
396                    stream_id = %stream_id,
397                    known_count = known.len(),
398                    "Watch loop waiting for changes"
399                );
400
401                // Wait for next snapshot or cancellation
402                let watch_result = if let Some(ref token) = cancel_token {
403                    tokio::select! {
404                        result = watch_rx.changed() => result,
405                        _ = token.cancelled() => {
406                            tracing::info!(
407                                stream_id = %stream_id,
408                                "Watch cancelled via cancel token"
409                            );
410                            break;
411                        }
412                    }
413                } else {
414                    watch_rx.changed().await
415                };
416
417                match watch_result {
418                    Ok(()) => {
419                        // Get latest snapshot
420                        let snapshot = watch_rx.borrow_and_update().clone();
421
422                        // Build current map: DiscoveryInstanceId -> DiscoveryInstance
423                        let current: std::collections::HashMap<
424                            DiscoveryInstanceId,
425                            DiscoveryInstance,
426                        > = snapshot
427                            .instances
428                            .values()
429                            .flat_map(|metadata| metadata.filter(&query))
430                            .map(|instance| (instance.id(), instance))
431                            .collect();
432
433                        tracing::debug!(
434                            stream_id = %stream_id,
435                            seq = snapshot.sequence,
436                            current_count = current.len(),
437                            known_count = known.len(),
438                            "Watch received snapshot update"
439                        );
440
441                        // Compute diff using keys
442                        let current_keys: HashSet<&DiscoveryInstanceId> = current.keys().collect();
443                        let known_keys: HashSet<&DiscoveryInstanceId> = known.iter().collect();
444
445                        let added: Vec<&DiscoveryInstanceId> =
446                            current_keys.difference(&known_keys).copied().collect();
447
448                        let removed: Vec<DiscoveryInstanceId> = known_keys
449                            .difference(&current_keys)
450                            .map(|&id| id.clone())
451                            .collect();
452
453                        // Log diff results (even if empty, for debugging)
454                        if added.is_empty() && removed.is_empty() {
455                            tracing::debug!(
456                                stream_id = %stream_id,
457                                seq = snapshot.sequence,
458                                "Watch snapshot received but no diff detected"
459                            );
460                        } else {
461                            tracing::debug!(
462                                stream_id = %stream_id,
463                                seq = snapshot.sequence,
464                                added = added.len(),
465                                removed = removed.len(),
466                                total = current.len(),
467                                "Watch detected changes"
468                            );
469                        }
470
471                        // Emit Added events
472                        for id in added {
473                            if let Some(instance) = current.get(id) {
474                                tracing::info!(
475                                    stream_id = %stream_id,
476                                    instance_id = format!("{:x}", instance.instance_id()),
477                                    "Emitting Added event"
478                                );
479                                if event_tx
480                                    .send(Ok(DiscoveryEvent::Added(instance.clone())))
481                                    .is_err()
482                                {
483                                    tracing::debug!(
484                                        stream_id = %stream_id,
485                                        "Watch receiver dropped"
486                                    );
487                                    return;
488                                }
489                            }
490                        }
491
492                        // Emit Removed events
493                        for id in removed {
494                            tracing::info!(
495                                stream_id = %stream_id,
496                                id = ?id,
497                                "Emitting Removed event"
498                            );
499                            if event_tx.send(Ok(DiscoveryEvent::Removed(id))).is_err() {
500                                tracing::debug!(stream_id = %stream_id, "Watch receiver dropped");
501                                return;
502                            }
503                        }
504
505                        // Update known set
506                        known = current.into_keys().collect();
507                    }
508                    Err(_) => {
509                        tracing::info!(
510                            stream_id = %stream_id,
511                            "Watch channel closed (daemon stopped)"
512                        );
513                        break;
514                    }
515                }
516            }
517        });
518
519        // Convert receiver to stream
520        let stream = tokio_stream::wrappers::UnboundedReceiverStream::new(event_rx);
521        Ok(Box::pin(stream))
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn publisher_ids_must_fit_kubernetes_json_safe_range() {
531        assert!(validate_kubernetes_publisher_id(MAX_JSON_SAFE_PUBLISHER_ID).is_ok());
532        assert!(validate_kubernetes_publisher_id(MAX_JSON_SAFE_PUBLISHER_ID + 1).is_err());
533        assert!(validate_kubernetes_publisher_id(u64::MAX).is_err());
534    }
535}