1mod crd;
5mod daemon;
6mod utils;
7
8pub use crd::{DynamoWorkerMetadata, DynamoWorkerMetadataSpec};
9pub 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#[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 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 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 let (watch_tx, watch_rx) = tokio::sync::watch::channel(Arc::new(MetadataSnapshot::empty()));
100
101 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 let mut metadata = self.metadata.write().await;
148
149 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 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 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 let mut metadata = self.metadata.write().await;
229
230 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 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 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 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 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 let mut watch_rx = self.metadata_watch.clone();
342
343 let (event_tx, event_rx) = mpsc::unbounded_channel();
345
346 let stream_id = uuid::Uuid::new_v4();
348
349 tokio::spawn(async move {
351 let initial_snapshot = watch_rx.borrow_and_update().clone();
355
356 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 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 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 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 let snapshot = watch_rx.borrow_and_update().clone();
421
422 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 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(¤t_keys)
450 .map(|&id| id.clone())
451 .collect();
452
453 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 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 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 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 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}