diff --git a/src/cluster/mod.rs b/src/cluster/mod.rs index 29a9a7b..34691c0 100644 --- a/src/cluster/mod.rs +++ b/src/cluster/mod.rs @@ -3,6 +3,10 @@ pub mod kubeconfig; use std::time::Duration; use gpui_kit::{App, BorrowAppContext as _, Global}; +use k8s_openapi::api::authorization::v1::{ + ResourceAttributes, SelfSubjectAccessReview, SelfSubjectAccessReviewSpec, +}; +use kube::api::{Api, PostParams}; use kube::{Client, Config}; use tokio::sync::watch; @@ -44,6 +48,9 @@ pub struct ClusterState { store: kubeconfig::ConfigStore, reachable: Option, namespaces: Vec, + /// Whether the current credentials may watch across all namespaces. This + /// is the Freelens `isGlobalWatchEnabled` equivalent. + global_watch: bool, context_epoch: u64, namespace_cancel: Option>, /// Kept alive for the app lifetime: the selector's handle on the shared @@ -92,6 +99,10 @@ impl ClusterState { pub fn namespaces(&self) -> &[String] { &self.namespaces } + + pub fn global_watch(&self) -> bool { + self.global_watch + } } /// Wire the global before the first window opens. @@ -167,6 +178,7 @@ pub fn init(cx: &mut App) { store, reachable: None, namespaces: Vec::new(), + global_watch: true, context_epoch: 0, namespace_cancel: None, namespace_subscription: None, @@ -184,7 +196,7 @@ pub fn init(cx: &mut App) { /// Start or restart the shared namespace subscription. The selector and the /// Namespaces resource page read from the same repository watcher, so -/// namespaces are fetched once per context and follow the saved poll rate. +/// namespaces are fetched once per context and kept live by the shared store. pub fn refresh_namespaces(cx: &mut App) { if cx.global::().client.is_none() { return; @@ -324,6 +336,52 @@ async fn build_client( Ok(client) } +/// Ask the API server whether this identity may watch every namespace. +/// +/// This is the krusty equivalent of Freelens's +/// `canI({ verb: "watch", resource: "*" })` check. If the review itself cannot +/// be created we stay optimistic and let the store's error state report any +/// real denial; a missing authorization.k8s.io API should not disable the +/// normal all-namespace path. +async fn probe_global_access(client: &Client, verb: &str) -> bool { + let review = SelfSubjectAccessReview { + spec: SelfSubjectAccessReviewSpec { + resource_attributes: Some(ResourceAttributes { + verb: Some(verb.to_string()), + resource: Some("*".to_string()), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + + let api: Api = Api::all(client.clone()); + match api.create(&PostParams::default(), &review).await { + Ok(review) => { + let allowed = review_allows_watch(&review); + tracing::debug!(verb, allowed, "cluster: global capability probed"); + allowed + } + Err(error) => { + tracing::warn!(verb, %error, "cluster: global capability probe failed, staying optimistic"); + true + } + } +} + +async fn probe_capabilities(client: &Client) -> bool { + probe_global_access(client, "watch").await +} + +fn review_allows_watch(review: &SelfSubjectAccessReview) -> bool { + review + .status + .as_ref() + .map(|status| status.allowed) + .unwrap_or(false) +} + /// Build the client for `context` in the background and publish it when ready. /// The epoch bump resubscribes every resource view; namespace polling starts /// here too. Stale builds (superseded by a newer switch) are discarded. @@ -338,9 +396,29 @@ fn spawn_client_build(cx: &mut App, context: ContextInfo) { }; let context_id = context.id.clone(); let started = std::time::Instant::now(); + let probe_tokio = tokio.clone(); let building = tokio.spawn(build_client(kubeconfig, context.source, store)); cx.spawn(async move |cx: &mut gpui_kit::AsyncApp| { let built = building.await; + let global_watch = match &built { + Ok(Ok(client)) => { + // The capability probe performs HTTP requests and must run on + // the cluster runtime, not on GPUI's executor: hyper's connect + // timeout needs the Tokio reactor. + let client = client.clone(); + match probe_tokio + .spawn(async move { probe_capabilities(&client).await }) + .await + { + Ok(allowed) => allowed, + Err(error) => { + tracing::warn!(%error, "cluster: capability probe task failed"); + true + } + } + } + _ => true, + }; cx.update(|cx| { let state = cx.global::(); if state.active_id() != Some(context_id.as_str()) { @@ -351,11 +429,13 @@ fn spawn_client_build(cx: &mut App, context: ContextInfo) { Ok(Ok(client)) => { tracing::info!( context = %context_id, + global_watch, elapsed_ms = started.elapsed().as_millis() as u64, "cluster: client ready" ); cx.update_global::(|state, _| { state.client = Some(client); + state.global_watch = global_watch; state.context_epoch = state.context_epoch.wrapping_add(1); }); refresh_namespaces(cx); @@ -552,6 +632,28 @@ users: assert!(client.is_ok()); } + #[test] + fn global_watch_review_requires_an_allowed_status() { + let mut review = SelfSubjectAccessReview::default(); + assert!(!review_allows_watch(&review)); + + review.status = Some( + k8s_openapi::api::authorization::v1::SubjectAccessReviewStatus { + allowed: false, + ..Default::default() + }, + ); + assert!(!review_allows_watch(&review)); + + review.status = Some( + k8s_openapi::api::authorization::v1::SubjectAccessReviewStatus { + allowed: true, + ..Default::default() + }, + ); + assert!(review_allows_watch(&review)); + } + #[test] fn parses_inline_kubeconfig() { let runtime = tokio::runtime::Runtime::new().expect("runtime"); diff --git a/src/models/cluster/mod.rs b/src/models/cluster/mod.rs index f2fc37b..d930bda 100644 --- a/src/models/cluster/mod.rs +++ b/src/models/cluster/mod.rs @@ -1,6 +1,6 @@ //! Namespace filter shared by repositories and view-models. -#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum NamespaceScope { #[default] All, diff --git a/src/models/mod.rs b/src/models/mod.rs index fea9336..ee92ec7 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -9,6 +9,6 @@ pub use cluster::NamespaceScope; pub use resources::{ ConfigMapsModel, DaemonSetsModel, DeploymentsModel, IngressesModel, Item, JobsModel, NodesModel, ObjectKey, PersistentVolumeClaimsModel, PersistentVolumesModel, PodsModel, - PollRate, ResourceDetail, ResourceDetailState, ResourceDetailUpdate, ResourceKind, - ResourceSnapshot, ResourceStore, SecretsModel, ServicesModel, SnapshotPhase, WatchMode, + ResourceDetail, ResourceDetailState, ResourceDetailUpdate, ResourceKind, ResourceSnapshot, + SecretsModel, ServicesModel, SnapshotPhase, WatchMode, }; diff --git a/src/models/resources/mod.rs b/src/models/resources/mod.rs index 789cda6..acb13b5 100644 --- a/src/models/resources/mod.rs +++ b/src/models/resources/mod.rs @@ -68,4 +68,4 @@ pub use service_accounts::ServiceAccountsModel; pub use services::ServicesModel; pub use stateful_sets::StatefulSetsModel; pub use storage_classes::StorageClassesModel; -pub use store::{ObjectKey, PollRate, ResourceSnapshot, ResourceStore, SnapshotPhase, WatchMode}; +pub use store::{ObjectKey, ResourceSnapshot, SnapshotPhase, WatchMode}; diff --git a/src/models/resources/overview.rs b/src/models/resources/overview.rs index 8a5ce12..e6e47ef 100644 --- a/src/models/resources/overview.rs +++ b/src/models/resources/overview.rs @@ -17,14 +17,23 @@ pub enum PodHealth { } impl PodHealth { + /// Case-insensitive without allocating: this runs for every pod on every + /// overview refresh, so a `to_ascii_lowercase` allocation per pod adds up + /// quickly on large clusters. + #[inline] pub fn from_status(status: &str) -> Self { - match status.to_ascii_lowercase().as_str() { - "running" => Self::Running, - "pending" => Self::Pending, - "succeeded" => Self::Succeeded, - "failed" => Self::Failed, - "crashloopbackoff" => Self::CrashLoopBackOff, - _ => Self::Unknown, + if status.eq_ignore_ascii_case("running") { + Self::Running + } else if status.eq_ignore_ascii_case("pending") { + Self::Pending + } else if status.eq_ignore_ascii_case("succeeded") { + Self::Succeeded + } else if status.eq_ignore_ascii_case("failed") { + Self::Failed + } else if status.eq_ignore_ascii_case("crashloopbackoff") { + Self::CrashLoopBackOff + } else { + Self::Unknown } } @@ -76,7 +85,13 @@ impl ClusterOverviewStats { for pod in pods { stats.pod_restarts += pod.restarts; if !pod.node.is_empty() { - *stats.pods_by_node.entry(pod.node.clone()).or_default() += 1; + // Only clone the node name when a new node appears; a cluster + // with 10k pods schedules most of them on a few hundred nodes. + if let Some(count) = stats.pods_by_node.get_mut(&pod.node) { + *count += 1; + } else { + stats.pods_by_node.insert(pod.node.clone(), 1); + } } match PodHealth::from_status(&pod.status) { PodHealth::Running => stats.pods_running += 1, @@ -118,6 +133,19 @@ mod tests { Arc::new(item) } + #[test] + fn normalizes_pod_status_without_allocating() { + assert_eq!(PodHealth::from_status("Running"), PodHealth::Running); + assert_eq!( + PodHealth::from_status("CrashLoopBackOff"), + PodHealth::CrashLoopBackOff + ); + assert_eq!( + PodHealth::from_status("containercreating"), + PodHealth::Unknown + ); + } + #[test] fn summarizes_node_readiness() { let nodes = vec![item("worker-a", "ready"), item("worker-b", "notready")]; diff --git a/src/models/resources/store.rs b/src/models/resources/store.rs index 4f67ba6..4c7fe74 100644 --- a/src/models/resources/store.rs +++ b/src/models/resources/store.rs @@ -1,11 +1,8 @@ -//! Authoritative, incrementally maintained collection for one resource kind. +//! Shared snapshot types for table views. //! -//! The store is owned by the watch task (never by the UI thread). Objects live in -//! a `BTreeMap` ordered by `(namespace, name)`, so rows are stable and sorted -//! without a separate sort pass. A UID index lets deletes/updates find the prior -//! key in O(1) even if metadata changes. +//! The authoritative object store lives in `repository::store`. These types are +//! the small, UI-facing snapshot contract that every resource view consumes. -use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use super::Item; @@ -23,10 +20,6 @@ impl ObjectKey { name: name.into(), } } - - pub fn from_item(item: &Item) -> Self { - Self::new(item.namespace.clone(), item.name.clone()) - } } /// Immutable view of a store handed to the UI. Cloning a snapshot is O(1). @@ -44,34 +37,10 @@ pub enum SnapshotPhase { Error(String), } -/// How the snapshot is kept up to date. `Poll` carries the cadence so the UI -/// can show and adjust it. +/// The store always uses a live watch stream. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WatchMode { - /// Live watch stream. Watch, - /// Watch is not permitted; the store is re-listed on this cadence. - Poll(PollRate), -} - -/// Automatic re-list cadence while polling. `Off` disables the timer; a -/// manual refresh still re-lists once. Persisted per resource kind. -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum PollRate { - /// No automatic polling. - Off, - /// Poll every this many seconds. - Every(u64), -} - -impl PollRate { - pub const DEFAULT: PollRate = PollRate::Every(5); -} - -impl Default for PollRate { - fn default() -> Self { - Self::DEFAULT - } } impl ResourceSnapshot { @@ -91,9 +60,10 @@ impl ResourceSnapshot { } } - pub fn with_mode(mut self, mode: WatchMode) -> Self { - self.mode = mode; - self + /// Build a snapshot from an already-filtered item set. Used by the shared + /// store views so views can reuse the existing table/rendering contract. + pub fn from_items(items: Arc<[Arc]>, phase: SnapshotPhase, mode: WatchMode) -> Self { + Self { items, phase, mode } } pub fn items(&self) -> &[Arc] { @@ -108,351 +78,3 @@ impl ResourceSnapshot { self.mode } } - -/// Accumulates watch events; the repository flushes snapshots at a bounded rate. -pub struct ResourceStore { - objects: BTreeMap>, - by_uid: HashMap, - pending: usize, - /// Keys seen since [`begin_sync`](Self::begin_sync). Present only while a - /// list is being merged into the store. - sync_seen: Option>, -} - -impl Default for ResourceStore { - fn default() -> Self { - Self::new() - } -} - -impl ResourceStore { - pub fn new() -> Self { - Self { - objects: BTreeMap::new(), - by_uid: HashMap::new(), - pending: 0, - sync_seen: None, - } - } - - pub fn len(&self) -> usize { - self.objects.len() - } - - pub fn pending(&self) -> usize { - self.pending - } - - #[cfg(test)] - pub fn reset(&mut self, items: I) - where - I: IntoIterator, - { - self.objects.clear(); - self.by_uid.clear(); - self.sync_seen = None; - for item in items { - self.upsert(item); - } - self.pending = 0; - } - - /// Start merging a full list into the store. The old objects stay visible - /// until [`finish_sync`](Self::finish_sync), so a relist does not blank the - /// UI while its pages arrive. - pub fn begin_sync(&mut self) { - self.sync_seen = Some(HashSet::new()); - } - - /// Remove objects that were absent from the list started by - /// [`begin_sync`](Self::begin_sync). Returns whether anything was removed. - pub fn finish_sync(&mut self) -> bool { - let Some(seen) = self.sync_seen.take() else { - return false; - }; - let stale: Vec = self - .objects - .keys() - .filter(|key| !seen.contains(*key)) - .cloned() - .collect(); - let mut changed = false; - for key in stale { - changed |= self.remove(&key); - } - changed - } - - /// Abandon an interrupted list without deleting objects it did not reach. - pub fn abort_sync(&mut self) { - self.sync_seen = None; - } - - /// Insert or replace an object. Returns whether the store actually changed: - /// a repeated `(key, uid, resourceVersion)` is a no-op, which makes poll - /// relists and duplicate watch events cheap. - pub fn upsert(&mut self, item: Item) -> bool { - let key = ObjectKey::from_item(&item); - if let Some(seen) = &mut self.sync_seen { - seen.insert(key.clone()); - } - - if let Some(existing) = self.objects.get(&key) - && !item.resource_version.is_empty() - && existing.uid == item.uid - && existing.resource_version == item.resource_version - { - return false; - } - - let item = Arc::new(item); - if let Some(existing) = self.objects.get(&key) - && !existing.uid.is_empty() - && existing.uid != item.uid - { - self.by_uid.remove(&existing.uid); - } - if !item.uid.is_empty() - && let Some(previous) = self.by_uid.insert(item.uid.clone(), key.clone()) - && previous != key - { - self.objects.remove(&previous); - } - self.objects.insert(key, item); - self.pending = self.pending.saturating_add(1); - true - } - - pub fn remove(&mut self, key: &ObjectKey) -> bool { - let Some(item) = self.objects.remove(key) else { - return false; - }; - if !item.uid.is_empty() { - self.by_uid.remove(&item.uid); - } - self.pending = self.pending.saturating_add(1); - true - } - - /// Remove the oldest objects until at most `max_items` remain. Used for - /// high-churn kinds such as Events, where the API can retain thousands. - /// Returns whether anything was removed. - pub fn trim_oldest(&mut self, max_items: usize) -> bool { - if self.objects.len() <= max_items { - return false; - } - let mut by_age: Vec<(ObjectKey, i64)> = self - .objects - .iter() - .map(|(key, item)| (key.clone(), item.created_at.unwrap_or(i64::MIN))) - .collect(); - by_age.sort_by_key(|(_, created_at)| *created_at); - let remove = self.objects.len() - max_items; - let mut changed = false; - for (key, _) in by_age.into_iter().take(remove) { - changed |= self.remove(&key); - } - changed - } - - pub fn snapshot(&mut self, phase: SnapshotPhase) -> ResourceSnapshot { - self.pending = 0; - ResourceSnapshot { - items: self.objects.values().cloned().collect(), - phase, - mode: WatchMode::Watch, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn pod(name: &str, namespace: &str, uid: &str) -> Item { - let mut item = Item::new(name.into(), Some(namespace.into())); - item.uid = uid.into(); - item - } - - fn versioned_pod(name: &str, namespace: &str, uid: &str, version: &str) -> Item { - let mut item = pod(name, namespace, uid); - item.resource_version = version.into(); - item - } - - #[test] - fn keeps_rows_sorted_and_replaces_by_key() { - let mut store = ResourceStore::new(); - store.upsert(pod("b", "ns-2", "1")); - store.upsert(pod("a", "ns-1", "2")); - store.upsert(pod("c", "ns-2", "3")); - - let mut updated = pod("b", "ns-2", "1"); - updated.status = "crashloopbackoff".into(); - store.upsert(updated); - - let snapshot = store.snapshot(SnapshotPhase::Ready); - let names: Vec<_> = snapshot.items().iter().map(|i| i.name.as_str()).collect(); - assert_eq!(names, ["a", "b", "c"]); - assert_eq!(snapshot.items()[1].status, "crashloopbackoff"); - } - - #[test] - fn removes_by_key_and_uid() { - let mut store = ResourceStore::new(); - store.upsert(pod("a", "ns", "uid-a")); - store.remove(&ObjectKey::new("ns", "a")); - assert_eq!(store.len(), 0); - assert!(store.by_uid.is_empty()); - } - - #[test] - fn handles_150_namespaces_with_100_pods_each() { - let mut store = ResourceStore::new(); - for namespace in 0..150 { - for index in 0..100 { - store.upsert(pod( - &format!("pod-{index:03}"), - &format!("namespace-{namespace:03}"), - &format!("uid-{namespace}-{index}"), - )); - } - } - assert_eq!(store.len(), 15_000); - - let mut updated = pod("pod-050", "namespace-075", "uid-75-50"); - updated.status = "crashloopbackoff".into(); - store.upsert(updated); - store.remove(&ObjectKey::new("namespace-000", "pod-000")); - - let snapshot = store.snapshot(SnapshotPhase::Ready); - assert_eq!(snapshot.items().len(), 14_999); - assert_eq!(snapshot.items()[0].namespace, "namespace-000"); - assert_eq!(snapshot.items()[0].name, "pod-001"); - assert!( - snapshot - .items() - .windows(2) - .all(|pair| ObjectKey::from_item(&pair[0]) <= ObjectKey::from_item(&pair[1])) - ); - } - - #[test] - fn reset_discards_objects_missing_from_a_relist() { - let mut store = ResourceStore::new(); - store.upsert(pod("a", "ns", "1")); - store.reset([pod("b", "ns", "2")]); - let snapshot = store.snapshot(SnapshotPhase::Ready); - assert_eq!(snapshot.items().len(), 1); - assert_eq!(snapshot.items()[0].name, "b"); - } - - #[test] - fn upsert_skips_an_unchanged_resource_version() { - let mut store = ResourceStore::new(); - store.upsert(versioned_pod("a", "ns", "uid-a", "10")); - let _ = store.snapshot(SnapshotPhase::Ready); - - let mut same = versioned_pod("a", "ns", "uid-a", "10"); - same.status = "running".into(); - assert!(!store.upsert(same)); - assert_eq!(store.pending(), 0); - assert_eq!(store.snapshot(SnapshotPhase::Ready).items()[0].status, ""); - } - - #[test] - fn sync_keeps_unchanged_items_and_removes_absent_ones() { - let mut store = ResourceStore::new(); - store.reset([ - versioned_pod("a", "ns", "uid-a", "1"), - versioned_pod("b", "ns", "uid-b", "1"), - versioned_pod("c", "ns", "uid-c", "1"), - ]); - let _ = store.snapshot(SnapshotPhase::Ready); - - store.begin_sync(); - assert!(!store.upsert(versioned_pod("a", "ns", "uid-a", "1"))); - assert!(store.upsert(versioned_pod("b", "ns", "uid-b", "2"))); - assert!(store.upsert(versioned_pod("d", "ns", "uid-d", "1"))); - store.finish_sync(); - - let snapshot = store.snapshot(SnapshotPhase::Ready); - let names: Vec<_> = snapshot.items().iter().map(|i| i.name.as_str()).collect(); - assert_eq!(names, ["a", "b", "d"]); - } - - #[test] - fn an_unchanged_sync_does_not_bump_pending() { - let mut store = ResourceStore::new(); - store.reset([versioned_pod("a", "ns", "uid-a", "1")]); - let _ = store.snapshot(SnapshotPhase::Ready); - - store.begin_sync(); - assert!(!store.upsert(versioned_pod("a", "ns", "uid-a", "1"))); - store.finish_sync(); - - assert_eq!(store.pending(), 0); - assert_eq!(store.snapshot(SnapshotPhase::Ready).items().len(), 1); - } - - #[test] - fn aborting_a_sync_does_not_delete_unseen_items() { - let mut store = ResourceStore::new(); - store.reset([ - versioned_pod("a", "ns", "uid-a", "1"), - versioned_pod("b", "ns", "uid-b", "1"), - ]); - let _ = store.snapshot(SnapshotPhase::Ready); - - store.begin_sync(); - store.upsert(versioned_pod("a", "ns", "uid-a", "2")); - store.abort_sync(); - - let snapshot = store.snapshot(SnapshotPhase::Ready); - assert_eq!(snapshot.items().len(), 2); - } - - #[test] - fn removing_an_unknown_key_is_a_no_op() { - let mut store = ResourceStore::new(); - let _ = store.snapshot(SnapshotPhase::Ready); - assert!(!store.remove(&ObjectKey::new("ns", "missing"))); - assert_eq!(store.pending(), 0); - } - - #[test] - fn trim_oldest_removes_the_oldest_objects_first() { - let mut store = ResourceStore::new(); - for (index, created_at) in [(0, 10), (1, 30), (2, 20), (3, 40)] { - let mut item = versioned_pod( - &format!("event-{index}"), - "ns", - &format!("uid-{index}"), - "1", - ); - item.created_at = Some(created_at); - store.upsert(item); - } - let _ = store.snapshot(SnapshotPhase::Ready); - - assert!(store.trim_oldest(2)); - let snapshot = store.snapshot(SnapshotPhase::Ready); - let names: Vec<_> = snapshot.items().iter().map(|i| i.name.as_str()).collect(); - assert_eq!(names, ["event-1", "event-3"]); - - assert!(!store.trim_oldest(2)); - assert_eq!(store.pending(), 0); - } - - #[test] - fn snapshots_carry_the_watch_mode() { - let mut store = ResourceStore::new(); - let snapshot = store.snapshot(SnapshotPhase::Ready); - assert_eq!(snapshot.mode(), WatchMode::Watch); - let snapshot = snapshot.with_mode(WatchMode::Poll(PollRate::Every(5))); - assert_eq!(snapshot.mode(), WatchMode::Poll(PollRate::Every(5))); - assert_eq!(ResourceSnapshot::loading().mode(), WatchMode::Watch); - assert_eq!(ResourceSnapshot::failed("x").mode(), WatchMode::Watch); - } -} diff --git a/src/repository/mod.rs b/src/repository/mod.rs index 9d964e4..7fa60c5 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -4,58 +4,70 @@ pub mod custom_resources; pub mod resources; pub mod retry; -pub mod watch_prefs; - -pub use watch_prefs::{WatchPreference, WatchPrefs}; +pub mod store; +pub mod terminal; +use std::any::Any; use std::collections::HashMap; -use std::sync::{Arc, Weak}; +use std::sync::Arc; -use gpui_kit::{App, BorrowAppContext as _, Global}; +use gpui_kit::{App, Global}; use tokio::sync::watch; -use crate::cluster::ClusterState; -use crate::models::{NamespaceScope, PollRate, ResourceKind, ResourceSnapshot, WatchMode}; - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -struct SubscriptionKey { - epoch: u64, - kind: ResourceKind, - scope: NamespaceScope, -} +use crate::models::{NamespaceScope, ResourceKind, ResourceSnapshot}; -struct Entry { - snapshots: watch::Sender>, - _cancel: watch::Sender, - refresh: watch::Sender, - prefs: watch::Sender, +pub(crate) struct StoreEntry { + pub epoch: u64, + /// Held strongly for the cluster lifetime. The watch itself still stops + /// when the last subscriber leaves; only the cached data is retained. + pub store: Arc, } pub struct Repository { - entries: HashMap>, - watch_prefs: watch_prefs::WatchPrefsStore, + pub(crate) stores: HashMap, + pub(crate) terminal: terminal::TerminalSessionStore, } impl Global for Repository {} pub fn init(cx: &mut App) { cx.set_global(Repository { - entries: HashMap::new(), - watch_prefs: watch_prefs::WatchPrefsStore::load(), + stores: HashMap::new(), + terminal: terminal::TerminalSessionStore::default(), }); } -/// A live list/watch subscription. Dropping the last handle for a key cancels -/// the background watch task. +/// A live store subscription. Dropping the last handle releases the view and +/// stops the shared watch when no other view needs it. +/// +/// The source is intentionally generic: views consume the same snapshot API +/// regardless of which store feeds them. #[derive(Clone)] pub struct Subscription { rx: watch::Receiver>, paused: watch::Sender, paused_rx: watch::Receiver, - _entry: Arc, + refresh: Arc, + _keep_alive: Arc, } impl Subscription { + pub(crate) fn new( + rx: watch::Receiver>, + paused: watch::Sender, + paused_rx: watch::Receiver, + refresh: Arc, + keep_alive: Arc, + ) -> Self { + Self { + rx, + paused, + paused_rx, + refresh, + _keep_alive: keep_alive, + } + } + pub fn snapshot(&self) -> Arc { self.rx.borrow().clone() } @@ -108,9 +120,7 @@ impl Subscription { /// Ask the shared watch task to perform a fresh list. pub fn refresh(&self) { - self._entry.refresh.send_modify(|generation| { - *generation = generation.wrapping_add(1); - }); + (self.refresh)(); } } @@ -129,126 +139,7 @@ pub(crate) fn subscribe_kind( kind: ResourceKind, scope: NamespaceScope, ) -> Result { - let state = cx.global::(); - let client = state.client().ok_or_else(|| "not connected".to_string())?; - let tokio = state.tokio(); - let scope = if kind.is_namespaced() { - scope - } else { - NamespaceScope::All - }; - let key = SubscriptionKey { - epoch: state.context_epoch(), - kind, - scope: scope.clone(), - }; - - Ok(cx.update_global::(|repository, _| { - repository - .entries - .retain(|_, entry| entry.strong_count() > 0); - - if let Some(entry) = repository.entries.get(&key).and_then(Weak::upgrade) { - tracing::trace!( - ?kind, - ?scope, - active = repository.entries.len(), - "repository: subscription reused" - ); - let (paused, paused_rx) = watch::channel(false); - return Subscription { - rx: entry.snapshots.subscribe(), - paused, - paused_rx, - _entry: entry, - }; - } - - let mut initial_prefs = repository.watch_prefs.prefs_for(kind); - if initial_prefs.mode == WatchPreference::Watch - && resources::watcher::watch_denied(key.epoch, kind) - { - // Watch is known to be denied for this kind: go straight to - // polling instead of re-trying the doomed watch on every - // namespace filter change. The saved preference stays `Watch`. - initial_prefs.mode = WatchPreference::Poll; - } - let initial_mode = match initial_prefs.mode { - WatchPreference::Watch => WatchMode::Watch, - WatchPreference::Poll => WatchMode::Poll(initial_prefs.rate), - }; - - let (snapshots, rx) = watch::channel(Arc::new( - ResourceSnapshot::loading().with_mode(initial_mode), - )); - let (cancel, cancel_rx) = watch::channel(false); - let (refresh, refresh_rx) = watch::channel(0u64); - let (prefs, prefs_rx) = watch::channel(initial_prefs); - let entry = Arc::new(Entry { - snapshots: snapshots.clone(), - _cancel: cancel, - refresh, - prefs, - }); - let scope_log = scope.clone(); - let watch_task = resources::watcher::run( - client, kind, scope, key.epoch, snapshots, cancel_rx, refresh_rx, prefs_rx, - ); - tokio.spawn(watch_task); - repository.entries.insert(key, Arc::downgrade(&entry)); - tracing::debug!( - ?kind, - scope = ?scope_log, - active = repository.entries.len(), - "repository: subscription created" - ); - let (paused, paused_rx) = watch::channel(false); - Subscription { - rx, - paused, - paused_rx, - _entry: entry, - } - })) -} - -/// Set whether a resource kind is watched live or polled. The saved poll rate -/// is kept. Applies to every running watch task for the kind and is saved. -pub fn set_watch_preference(cx: &mut App, kind: ResourceKind, mode: WatchPreference) { - if mode == WatchPreference::Watch { - // Explicit request: forget any RBAC denial so the watch is retried. - let epoch = cx.global::().context_epoch(); - resources::watcher::clear_watch_denied(epoch, kind); - } - update_watch_prefs(cx, kind, |prefs| prefs.mode = mode); -} - -/// Set a kind's poll rate and switch it to poll mode. Applies to every -/// running watch task for the kind and is saved. -pub fn set_poll_rate(cx: &mut App, kind: ResourceKind, rate: PollRate) { - update_watch_prefs(cx, kind, |prefs| { - prefs.mode = WatchPreference::Poll; - prefs.rate = rate; - }); -} - -fn update_watch_prefs(cx: &mut App, kind: ResourceKind, change: impl FnOnce(&mut WatchPrefs)) { - let epoch = cx.global::().context_epoch(); - cx.update_global::(|repository, _| { - repository.watch_prefs.update(kind, change); - if let Err(error) = repository.watch_prefs.save() { - tracing::warn!(%error, "repository: failed to save watch prefs"); - } - let prefs = repository.watch_prefs.prefs_for(kind); - for (key, entry) in &repository.entries { - if key.kind == kind - && key.epoch == epoch - && let Some(entry) = entry.upgrade() - { - entry.prefs.send_replace(prefs); - } - } - }); + store::subscribe(cx, kind, scope) } #[cfg(test)] @@ -261,22 +152,9 @@ mod tests { #[tokio::test] async fn a_paused_subscription_coalesces_until_resumed() { let (snapshots, rx) = watch::channel(Arc::new(ResourceSnapshot::loading())); - let (cancel, _) = watch::channel(false); - let (refresh, _) = watch::channel(0u64); - let (prefs, _) = watch::channel(WatchPrefs::default()); - let entry = Arc::new(Entry { - snapshots: snapshots.clone(), - _cancel: cancel, - refresh, - prefs, - }); let (paused, paused_rx) = watch::channel(false); - let mut subscription = Subscription { - rx, - paused, - paused_rx, - _entry: entry, - }; + let refresh: Arc = Arc::new(|| {}); + let mut subscription = Subscription::new(rx, paused, paused_rx, refresh, Arc::new(())); subscription.set_paused(true); snapshots.send_replace(Arc::new(ResourceSnapshot::failed("offline"))); diff --git a/src/repository/resources/cluster_roles/mod.rs b/src/repository/resources/cluster_roles/mod.rs deleted file mode 100644 index ffe232d..0000000 --- a/src/repository/resources/cluster_roles/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for ClusterRoles: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live ClusterRoles stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::ClusterRoles, scope) -} diff --git a/src/repository/resources/config_maps/mod.rs b/src/repository/resources/config_maps/mod.rs deleted file mode 100644 index a7bab02..0000000 --- a/src/repository/resources/config_maps/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for ConfigMaps: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live ConfigMaps stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::ConfigMaps, scope) -} diff --git a/src/repository/resources/cron_jobs/mod.rs b/src/repository/resources/cron_jobs/mod.rs deleted file mode 100644 index 873479c..0000000 --- a/src/repository/resources/cron_jobs/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for CronJobs: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live CronJobs stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::CronJobs, scope) -} diff --git a/src/repository/resources/daemon_sets/mod.rs b/src/repository/resources/daemon_sets/mod.rs deleted file mode 100644 index d0f09eb..0000000 --- a/src/repository/resources/daemon_sets/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for DaemonSets: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live DaemonSets stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::DaemonSets, scope) -} diff --git a/src/repository/resources/detail.rs b/src/repository/resources/detail.rs index e8b76a9..fecf673 100644 --- a/src/repository/resources/detail.rs +++ b/src/repository/resources/detail.rs @@ -2,24 +2,17 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock, Weak}; -use std::time::Duration; -use futures::TryStreamExt as _; -use kube::ResourceExt; -use kube::api::{Api, DynamicObject, WatchEvent, WatchParams}; +use kube::api::{Api, DynamicObject}; use kube::core::{ApiResource, GroupVersionKind}; use serde_json::Value; use tokio::sync::{broadcast, watch}; use crate::cluster::ClusterState; -use crate::models::ResourceKind; use crate::models::resources::detail::{ResourceDetail, ResourceDetailUpdate}; -use crate::repository::retry::{self, Retry, RetryAction}; +use crate::models::{ObjectKey, ResourceKind, SnapshotPhase}; use gpui_kit::App; -const DETAIL_WATCH_TIMEOUT_SECS: u32 = 280; -const DETAIL_RECONNECT_DELAY: Duration = Duration::from_millis(250); -const DETAIL_POLL_INTERVAL: Duration = Duration::from_secs(5); const DETAIL_BROADCAST_CAPACITY: usize = 8; #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -86,7 +79,10 @@ pub fn subscribe_detail( let state = cx.global::(); let client = state.client().ok_or_else(|| "not connected".to_string())?; let tokio = state.tokio(); - let key = DetailKey { + let key = ObjectKey::new(namespace, name); + let initial = crate::repository::store::cached_object(cx, kind, &key); + let snapshots = crate::repository::store::existing_store_snapshot(cx, kind); + let detail_key = DetailKey { epoch: state.context_epoch(), kind, namespace: namespace.to_string(), @@ -98,7 +94,7 @@ pub fn subscribe_detail( .map_err(|_| "detail registry is poisoned".to_string())?; registry.retain(|_, entry| entry.strong_count() > 0); - if let Some(entry) = registry.get(&key).and_then(Weak::upgrade) { + if let Some(entry) = registry.get(&detail_key).and_then(Weak::upgrade) { return Ok(subscription_for(entry)); } @@ -116,11 +112,13 @@ pub fn subscribe_detail( kind, namespace.to_string(), name.to_string(), + initial, + snapshots, updates, latest, cancel_rx, )); - registry.insert(key, Arc::downgrade(&entry)); + registry.insert(detail_key, Arc::downgrade(&entry)); drop(registry); let (sub_cancel, sub_cancel_rx) = watch::channel(false); @@ -146,39 +144,84 @@ fn subscription_for(entry: Arc) -> ResourceDetailSubscription { } } +#[allow(clippy::too_many_arguments)] async fn watch_detail( client: kube::Client, kind: ResourceKind, namespace: String, name: String, + initial: Option>, + mut snapshots: Option>>, updates: broadcast::Sender, latest: Arc>>, mut cancel: watch::Receiver, ) { - let mut retry = Retry::new(); - let mut resource_version = String::new(); - let mut need_get = true; + let key = ObjectKey::new(&namespace, &name); + let mut last_version = String::new(); + let mut saw_store_object = false; + let mut initial_get_done = false; + + if let Some(record) = initial.as_ref() { + last_version.clone_from(&record.resource_version); + if !send_record_detail(&updates, &latest, kind, &namespace, &name, record) { + return; + } + saw_store_object = true; + initial_get_done = true; + } loop { - let api = api_for(&client, kind, &namespace); - if need_get { + let snapshot = snapshots + .as_mut() + .map(|receiver| receiver.borrow_and_update().clone()); + + if let Some(snapshot) = snapshot.as_ref() { + if let Some(record) = snapshot.object(&key) { + if record.resource_version != last_version { + last_version.clone_from(&record.resource_version); + if !send_record_detail(&updates, &latest, kind, &namespace, &name, record) { + return; + } + saw_store_object = true; + initial_get_done = true; + } + } else if saw_store_object && matches!(snapshot.phase(), SnapshotPhase::Ready) { + publish( + &updates, + &latest, + ResourceDetailUpdate { + kind, + namespace: namespace.clone(), + name: name.clone(), + result: Err(format!("{} {} was deleted", kind.label(), name)), + }, + ); + return; + } + } + + let store_ready = snapshot.as_ref().is_none_or(|snapshot| { + matches!( + snapshot.phase(), + SnapshotPhase::Ready | SnapshotPhase::Error(_) + ) + }); + + if !initial_get_done && store_ready { + initial_get_done = true; + let api = api_for(&client, kind, &namespace); let fetched = tokio::select! { _ = cancel.changed() => return, result = api.get(&name) => result, }; match fetched { Ok(object) => { - retry.list_succeeded(); - if let Some(version) = object.resource_version() { - resource_version = version; - } if !send_detail(&updates, &latest, kind, &namespace, &name, &object) { return; } - need_get = false; } Err(error) => { - if !publish( + publish( &updates, &latest, ResourceDetailUpdate { @@ -190,202 +233,54 @@ async fn watch_detail( kind.label() )), }, - ) { - return; - } - match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - continue; - } - RetryAction::GiveUp => return, - } + ); + return; } } } - let params = WatchParams::default() - .timeout(DETAIL_WATCH_TIMEOUT_SECS) - .fields(&format!("metadata.name={name}")); - let watched = tokio::select! { + tokio::select! { _ = cancel.changed() => return, - result = retry::bounded(api.watch(¶ms, &resource_version)) => result, - }; - let mut stream = match watched { - Ok(stream) => { - retry.watch_succeeded(); - Box::pin(stream) - } - Err(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - continue; + changed = async { + match snapshots.as_mut() { + Some(receiver) => receiver.changed().await.is_ok(), + None => std::future::pending::().await, } - RetryAction::GiveUp => { - poll_detail( - &client, - kind, - &namespace, - &name, - &updates, - &latest, - &mut cancel, - ) - .await; + } => { + if !changed { return; } - }, - }; - - let mut relist = false; - loop { - tokio::select! { - _ = cancel.changed() => return, - event = stream.try_next() => match event { - Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { - retry.watch_succeeded(); - if let Some(version) = object.resource_version() { - resource_version = version; - } - if !send_detail(&updates, &latest, kind, &namespace, &name, &object) { - return; - } - } - Ok(Some(WatchEvent::Deleted(_))) => { - publish( - &updates, - &latest, - ResourceDetailUpdate { - kind, - namespace: namespace.clone(), - name: name.clone(), - result: Err(format!("{} {} was deleted", kind.label(), name)), - }, - ); - return; - } - Ok(Some(WatchEvent::Bookmark(bookmark))) => { - retry.watch_succeeded(); - resource_version = bookmark.metadata.resource_version; - } - Ok(Some(WatchEvent::Error(status))) => { - relist = status.code == 410 - || status.reason == "Expired" - || status.reason == "Gone"; - match retry.failed_status(status.code) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - poll_detail( - &client, - kind, - &namespace, - &name, - &updates, - &latest, - &mut cancel, - ) - .await; - return; - } - } - } - Ok(None) => break, - Err(error) => match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - poll_detail( - &client, - kind, - &namespace, - &name, - &updates, - &latest, - &mut cancel, - ) - .await; - return; - } - }, - } } } - - if relist { - need_get = true; - } - if !sleep_or_cancel(&mut cancel, DETAIL_RECONNECT_DELAY).await { - return; - } } } -/// Watch was denied (or the object is not watchable): keep the detail fresh -/// with a low-frequency GET instead of hammering the watch endpoint. -async fn poll_detail( - client: &kube::Client, +fn send_record_detail( + updates: &broadcast::Sender, + latest: &Arc>>, kind: ResourceKind, namespace: &str, name: &str, - updates: &broadcast::Sender, - latest: &Arc>>, - cancel: &mut watch::Receiver, -) { - let mut retry = Retry::new(); - let api = api_for(client, kind, namespace); - loop { - let fetched = tokio::select! { - _ = cancel.changed() => return, - result = api.get(name) => result, - }; - match fetched { - Ok(object) => { - retry.list_succeeded(); - if !send_detail(updates, latest, kind, namespace, name, &object) { - return; - } - } - Err(error) => { - if !publish( - updates, - latest, - ResourceDetailUpdate { - kind, - namespace: namespace.to_string(), - name: name.to_string(), - result: Err(format!("Could not load {} details: {error}", kind.label())), - }, - ) { - return; - } - match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(cancel, delay).await { - return; - } - continue; - } - RetryAction::GiveUp => return, - } - } - } - if !sleep_or_cancel(cancel, DETAIL_POLL_INTERVAL).await { - return; - } - } + record: &crate::repository::store::KubeObjectRecord, +) -> bool { + let mut value = record.to_json(); + sanitize_secret(kind, &mut value); + publish( + updates, + latest, + ResourceDetailUpdate { + kind, + namespace: namespace.to_string(), + name: name.to_string(), + result: Ok(Box::new(ResourceDetail { + kind, + namespace: namespace.to_string(), + name: name.to_string(), + raw_json: serde_json::to_string_pretty(&value).unwrap_or_default(), + value, + })), + }, + ) } fn fmt_detail( @@ -432,13 +327,6 @@ fn publish( updates.send(update).is_ok() } -async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { - tokio::select! { - _ = cancel.changed() => false, - _ = tokio::time::sleep(duration) => true, - } -} - fn api_for(client: &kube::Client, kind: ResourceKind, namespace: &str) -> Api { let resource = api_resource(kind); if kind.is_namespaced() { diff --git a/src/repository/resources/endpoint_slices/mod.rs b/src/repository/resources/endpoint_slices/mod.rs deleted file mode 100644 index 83ef98d..0000000 --- a/src/repository/resources/endpoint_slices/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for EndpointSlices: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live EndpointSlices stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::EndpointSlices, scope) -} diff --git a/src/repository/resources/endpoints/mod.rs b/src/repository/resources/endpoints/mod.rs deleted file mode 100644 index b87ed04..0000000 --- a/src/repository/resources/endpoints/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Endpoints: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Endpoints stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Endpoints, scope) -} diff --git a/src/repository/resources/events/mod.rs b/src/repository/resources/events/mod.rs deleted file mode 100644 index 7ebc246..0000000 --- a/src/repository/resources/events/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Events: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Events stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Events, scope) -} diff --git a/src/repository/resources/horizontal_pod_autoscalers/mod.rs b/src/repository/resources/horizontal_pod_autoscalers/mod.rs deleted file mode 100644 index f3a8d46..0000000 --- a/src/repository/resources/horizontal_pod_autoscalers/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for HorizontalPodAutoscalers: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live HorizontalPodAutoscalers stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::HorizontalPodAutoscalers, scope) -} diff --git a/src/repository/resources/ingresses/mod.rs b/src/repository/resources/ingresses/mod.rs deleted file mode 100644 index e2ef347..0000000 --- a/src/repository/resources/ingresses/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Ingresses: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Ingresses stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Ingresses, scope) -} diff --git a/src/repository/resources/jobs/mod.rs b/src/repository/resources/jobs/mod.rs deleted file mode 100644 index d79e1a9..0000000 --- a/src/repository/resources/jobs/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Jobs: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Jobs stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Jobs, scope) -} diff --git a/src/repository/resources/leases/mod.rs b/src/repository/resources/leases/mod.rs deleted file mode 100644 index 2ffa238..0000000 --- a/src/repository/resources/leases/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Leases: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Leases stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Leases, scope) -} diff --git a/src/repository/resources/mod.rs b/src/repository/resources/mod.rs index bf1748e..6f013e8 100644 --- a/src/repository/resources/mod.rs +++ b/src/repository/resources/mod.rs @@ -1,32 +1,6 @@ //! Repository streams for Kubernetes resources. -pub mod cluster_roles; -pub mod config_maps; -pub mod cron_jobs; -pub mod daemon_sets; pub mod deployments; pub mod detail; -pub mod endpoint_slices; -pub mod endpoints; -pub mod events; -pub mod horizontal_pod_autoscalers; -pub mod ingresses; -pub mod jobs; -pub mod leases; -pub mod namespaces; -pub mod network_policies; pub mod nodes; -pub mod persistent_volume_claims; -pub mod persistent_volumes; -pub mod pod_disruption_budgets; pub mod pods; -pub mod replica_sets; -pub mod replication_controllers; -pub mod role_bindings; -pub mod roles; -pub mod secrets; -pub mod service_accounts; -pub mod services; -pub mod stateful_sets; -pub mod storage_classes; -pub mod watcher; diff --git a/src/repository/resources/namespaces/mod.rs b/src/repository/resources/namespaces/mod.rs deleted file mode 100644 index 8f21372..0000000 --- a/src/repository/resources/namespaces/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Namespaces: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Namespaces stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App) -> Result { - subscribe_kind(cx, ResourceKind::Namespaces, NamespaceScope::All) -} diff --git a/src/repository/resources/network_policies/mod.rs b/src/repository/resources/network_policies/mod.rs deleted file mode 100644 index ba76fd2..0000000 --- a/src/repository/resources/network_policies/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for NetworkPolicies: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live NetworkPolicies stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::NetworkPolicies, scope) -} diff --git a/src/repository/resources/nodes/mod.rs b/src/repository/resources/nodes/mod.rs index df63642..625fed1 100644 --- a/src/repository/resources/nodes/mod.rs +++ b/src/repository/resources/nodes/mod.rs @@ -1,11 +1,11 @@ //! Repository for Nodes: live list/watch plus one-shot detail and metrics reads. use std::collections::BTreeMap; +use std::sync::Arc; use std::time::Duration; -use futures::TryStreamExt as _; use k8s_openapi::api::core::v1::Node; -use kube::api::{Api, DynamicObject, ListParams, WatchEvent, WatchParams}; +use kube::api::{Api, DynamicObject, ListParams}; use kube::core::{ApiResource, GroupVersionKind}; use kube::{Client, ResourceExt}; use tokio::sync::{mpsc, watch}; @@ -15,15 +15,12 @@ use crate::models::resources::node::detail::{ NodeAddress, NodeCondition, NodeDetail, NodeDetailUpdate, NodeImage, NodeSystemInfo, NodeTaint, NodeUsage, }; -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::retry::{self, Retry, RetryAction}; +use crate::models::{NamespaceScope, ObjectKey, ResourceKind, SnapshotPhase}; use gpui_kit::App; use crate::repository::{Subscription, subscribe_kind}; const METRICS_POLL_INTERVAL: Duration = Duration::from_secs(10); -const DETAIL_WATCH_TIMEOUT_SECS: u32 = 280; -const DETAIL_RECONNECT_DELAY: Duration = Duration::from_millis(250); /// Subscribe to the live Nodes stream. /// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. @@ -141,199 +138,137 @@ async fn fetch_all_usage(client: &Client) -> Result, Ok(usages) } -/// Start loading a node's full description and live usage samples. +/// Start loading a node's full description from the shared store, with one +/// `GET` fallback when the object is not cached. Usage samples remain on the +/// existing metrics-server cadence. pub fn subscribe_detail(cx: &mut App, node: &str) -> Result { let state = cx.global::(); let client = state.client().ok_or_else(|| "not connected".to_string())?; let tokio = state.tokio(); + let key = ObjectKey::new("", node); + let initial = crate::repository::store::cached_object(cx, ResourceKind::Nodes, &key); + let snapshots = crate::repository::store::existing_store_snapshot(cx, ResourceKind::Nodes); let (updates_tx, updates) = mpsc::unbounded_channel(); let (cancel, cancel_rx) = watch::channel(false); - tokio.spawn(watch_node_detail( + tokio.spawn(watch_node_detail_from_store( client, node.to_string(), + initial, + snapshots, updates_tx, cancel_rx, )); Ok(NodeDetailSubscription { updates, cancel }) } -async fn watch_node_detail( +#[allow(clippy::too_many_arguments)] +async fn watch_node_detail_from_store( client: Client, node: String, + initial: Option>, + mut snapshots: Option>>, updates: mpsc::UnboundedSender, mut cancel: watch::Receiver, ) { - let api: Api = Api::all(client.clone()); - let mut retry = Retry::new(); - let mut resource_version = String::new(); - let mut need_get = true; + let key = ObjectKey::new("", &node); + let mut last_version = String::new(); let mut cpu_capacity = None; let mut memory_capacity = None; + let mut saw_store_object = false; + let mut initial_get_done = false; let mut ticker = tokio::time::interval(METRICS_POLL_INTERVAL); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + if let Some(record) = initial.as_ref() + && let Some(object) = record.typed::() + { + last_version.clone_from(&record.resource_version); + let detail = detail_from_node(object); + (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); + if !send_node_detail(&updates, &node, detail) { + return; + } + saw_store_object = true; + initial_get_done = true; + } + loop { - if need_get { + let snapshot = snapshots + .as_mut() + .map(|receiver| receiver.borrow_and_update().clone()); + + if let Some(snapshot) = snapshot.as_ref() { + if let Some(record) = snapshot.object(&key) { + if let Some(object) = record.typed::() + && record.resource_version != last_version + { + last_version.clone_from(&record.resource_version); + let detail = detail_from_node(object); + (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); + if !send_node_detail(&updates, &node, detail) { + return; + } + saw_store_object = true; + initial_get_done = true; + } + } else if saw_store_object && matches!(snapshot.phase(), SnapshotPhase::Ready) { + let _ = updates.send(NodeDetailUpdate::Detail { + node: node.clone(), + result: Err("This node was deleted".to_string()), + }); + return; + } + } + + let store_ready = snapshot.as_ref().is_none_or(|snapshot| { + matches!( + snapshot.phase(), + SnapshotPhase::Ready | SnapshotPhase::Error(_) + ) + }); + + if !initial_get_done && store_ready { + initial_get_done = true; + let api: Api = Api::all(client.clone()); let fetched = tokio::select! { _ = cancel.changed() => return, result = api.get(&node) => result, }; - let object = match fetched { - Ok(object) => object, + match fetched { + Ok(object) => { + let detail = detail_from_node(&object); + (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); + if !send_node_detail(&updates, &node, detail) { + return; + } + } Err(error) => { let _ = updates.send(NodeDetailUpdate::Detail { - node, + node: node.clone(), result: Err(format!("Could not load node details: {error}")), }); return; } - }; - retry.list_succeeded(); - if let Some(version) = object.resource_version() { - resource_version = version; } - let detail = detail_from_node(&object); - (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); - if !send_node_detail(&updates, &node, detail) { - return; - } - need_get = false; } - let params = WatchParams::default() - .timeout(DETAIL_WATCH_TIMEOUT_SECS) - .fields(&format!("metadata.name={node}")); - let watched = tokio::select! { + tokio::select! { _ = cancel.changed() => return, - result = retry::bounded(api.watch(¶ms, &resource_version)) => result, - }; - let mut stream = match watched { - Ok(stream) => { - retry.watch_succeeded(); - Box::pin(stream) - } - Err(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - continue; + changed = async { + match snapshots.as_mut() { + Some(receiver) => receiver.changed().await.is_ok(), + None => std::future::pending::().await, } - RetryAction::GiveUp => { - poll_node_detail(&client, &node, &updates, &mut cancel).await; + } => { + if !changed { return; } - }, - }; - - let mut relist = false; - loop { - tokio::select! { - _ = cancel.changed() => return, - _ = ticker.tick() => { - let usage = fetch_usage(&client, &node, cpu_capacity, memory_capacity).await; - if updates - .send(NodeDetailUpdate::Usage { - node: node.clone(), - result: usage, - }) - .is_err() - { - return; - } - } - event = stream.try_next() => match event { - Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { - retry.watch_succeeded(); - if let Some(version) = object.resource_version() { - resource_version = version; - } - let detail = detail_from_node(&object); - (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); - if !send_node_detail(&updates, &node, detail) { - return; - } - } - Ok(Some(WatchEvent::Deleted(_))) => { - let _ = updates.send(NodeDetailUpdate::Detail { - node, - result: Err("This node was deleted".to_string()), - }); - return; - } - Ok(Some(WatchEvent::Bookmark(bookmark))) => { - retry.watch_succeeded(); - resource_version = bookmark.metadata.resource_version; - } - Ok(Some(WatchEvent::Error(status))) => { - relist = status.code == 410 - || status.reason == "Expired" - || status.reason == "Gone"; - match retry.failed_status(status.code) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - poll_node_detail(&client, &node, &updates, &mut cancel).await; - return; - } - } - } - Ok(None) => break, - Err(error) => match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - poll_node_detail(&client, &node, &updates, &mut cancel).await; - return; - } - }, - } } - } - - if relist { - need_get = true; - } - if !sleep_or_cancel(&mut cancel, DETAIL_RECONNECT_DELAY).await { - return; - } - } -} - -/// Watch is not permitted: keep both the object and its usage fresh with GETs. -async fn poll_node_detail( - client: &Client, - node: &str, - updates: &mpsc::UnboundedSender, - cancel: &mut watch::Receiver, -) { - let api: Api = Api::all(client.clone()); - let mut retry = Retry::new(); - loop { - let fetched = tokio::select! { - _ = cancel.changed() => return, - result = api.get(node) => result, - }; - match fetched { - Ok(object) => { - retry.list_succeeded(); - let detail = detail_from_node(&object); - let (cpu_capacity, memory_capacity) = capacity_from_detail(&detail); - if !send_node_detail(updates, node, detail) { - return; - } - let usage = fetch_usage(client, node, cpu_capacity, memory_capacity).await; + _ = ticker.tick() => { + let usage = fetch_usage(&client, &node, cpu_capacity, memory_capacity).await; if updates .send(NodeDetailUpdate::Usage { - node: node.to_string(), + node: node.clone(), result: usage, }) .is_err() @@ -341,29 +276,6 @@ async fn poll_node_detail( return; } } - Err(error) => { - if updates - .send(NodeDetailUpdate::Detail { - node: node.to_string(), - result: Err(format!("Could not load node details: {error}")), - }) - .is_err() - { - return; - } - match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(cancel, delay).await { - return; - } - continue; - } - RetryAction::GiveUp => return, - } - } - } - if !sleep_or_cancel(cancel, METRICS_POLL_INTERVAL).await { - return; } } } @@ -395,13 +307,6 @@ fn send_node_detail( .is_ok() } -async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { - tokio::select! { - _ = cancel.changed() => false, - _ = tokio::time::sleep(duration) => true, - } -} - async fn fetch_usage( client: &Client, node: &str, diff --git a/src/repository/resources/persistent_volume_claims/mod.rs b/src/repository/resources/persistent_volume_claims/mod.rs deleted file mode 100644 index e9d68bb..0000000 --- a/src/repository/resources/persistent_volume_claims/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for PersistentVolumeClaims: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live PersistentVolumeClaims stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::PersistentVolumeClaims, scope) -} diff --git a/src/repository/resources/persistent_volumes/mod.rs b/src/repository/resources/persistent_volumes/mod.rs deleted file mode 100644 index 6b3c0b6..0000000 --- a/src/repository/resources/persistent_volumes/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Repository for PersistentVolumes: subscribe to the live list/watch stream. - -use crate::models::ResourceKind; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live PersistentVolumes stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App) -> Result { - subscribe_kind( - cx, - ResourceKind::PersistentVolumes, - crate::models::NamespaceScope::All, - ) -} diff --git a/src/repository/resources/pod_disruption_budgets/mod.rs b/src/repository/resources/pod_disruption_budgets/mod.rs deleted file mode 100644 index cc379ec..0000000 --- a/src/repository/resources/pod_disruption_budgets/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for PodDisruptionBudgets: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live PodDisruptionBudgets stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::PodDisruptionBudgets, scope) -} diff --git a/src/repository/resources/pods/mod.rs b/src/repository/resources/pods/mod.rs index 32b87a6..38c8185 100644 --- a/src/repository/resources/pods/mod.rs +++ b/src/repository/resources/pods/mod.rs @@ -1,18 +1,16 @@ //! Repository for Pods: live list/watch plus one-shot detail and metrics reads. use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; use std::time::Duration; -use futures::{AsyncBufReadExt, SinkExt, TryStreamExt}; +use futures::{AsyncBufReadExt, TryStreamExt}; use k8s_openapi::api::core::v1::{ Container, ContainerState, ContainerStatus, EnvVar, Pod, PodStatus, Toleration, Volume, }; -use kube::api::{ - Api, AttachParams, DynamicObject, LogParams, TerminalSize, WatchEvent, WatchParams, -}; +use kube::api::{Api, DynamicObject, LogParams}; use kube::core::{ApiResource, GroupVersionKind}; use kube::{Client, ResourceExt}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::sync::{mpsc, watch}; use crate::cluster::ClusterState; @@ -20,15 +18,13 @@ use crate::models::resources::pod::detail::{ PodCondition, PodContainer, PodDetail, PodDetailUpdate, PodOwner, PodToleration, PodUsage, PodVolume, }; -use crate::models::{NamespaceScope, ResourceKind}; +use crate::models::{NamespaceScope, ObjectKey, ResourceKind, SnapshotPhase}; use gpui_kit::App; -use crate::repository::retry::{self, Retry, RetryAction}; +use crate::repository::retry::{Retry, RetryAction}; use crate::repository::{Subscription, subscribe_kind}; const METRICS_POLL_INTERVAL: Duration = Duration::from_secs(10); -const DETAIL_WATCH_TIMEOUT_SECS: u32 = 280; -const DETAIL_RECONNECT_DELAY: Duration = Duration::from_millis(250); /// Subscribe to the live Pods stream. /// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. @@ -222,424 +218,170 @@ async fn watch_logs( } } -/// An interactive TTY exec session for one pod container. -pub struct PodExecSession { - updates: mpsc::UnboundedReceiver, String>>, - input: mpsc::UnboundedSender>, - resize: mpsc::UnboundedSender<(u16, u16)>, - cancel: watch::Sender, -} - -impl PodExecSession { - pub async fn next(&mut self) -> Option, String>> { - self.updates.recv().await - } - - /// Clone the input channel so the UI can keep sending after the session - /// itself has been moved into a background task. - pub fn input_handle(&self) -> mpsc::UnboundedSender> { - self.input.clone() - } - - /// Notify the remote TTY about a new size in columns and rows. - pub fn resize_handle(&self) -> mpsc::UnboundedSender<(u16, u16)> { - self.resize.clone() - } - - pub fn cancel_handle(&self) -> watch::Sender { - self.cancel.clone() - } -} - -impl Drop for PodExecSession { - fn drop(&mut self) { - let _ = self.cancel.send(true); - } +/// Read the cached container names without creating a subscription or a +/// network request. Returns `None` when the pod is not cached yet. +pub fn cached_containers(cx: &App, namespace: &str, name: &str) -> Option> { + let key = ObjectKey::new(namespace, name); + let record = crate::repository::store::cached_object(cx, ResourceKind::Pods, &key)?; + let pod = record.typed::()?; + Some( + pod.spec + .as_ref()? + .containers + .iter() + .map(|container| container.name.clone()) + .collect(), + ) } -/// Start an interactive TTY shell in a running pod container. -pub fn exec_shell( +/// Start loading a pod's full description from the shared store, with one +/// `GET` fallback when the object is not cached yet. A detail pane never +/// starts a watch; it observes the store only when a list view already runs. +/// Usage samples still come from metrics-server on the existing cadence. +pub fn subscribe_detail( cx: &mut App, namespace: &str, name: &str, - container: &str, - shell: &str, -) -> Result { - if container.is_empty() { - return Err("This pod has no containers to exec into.".to_string()); - } +) -> Result { let state = cx.global::(); let client = state.client().ok_or_else(|| "not connected".to_string())?; let tokio = state.tokio(); + let key = ObjectKey::new(namespace, name); + let initial = crate::repository::store::cached_object(cx, ResourceKind::Pods, &key); + let snapshots = crate::repository::store::existing_store_snapshot(cx, ResourceKind::Pods); let (updates_tx, updates) = mpsc::unbounded_channel(); - let (input_tx, input_rx) = mpsc::unbounded_channel(); - let (resize_tx, resize_rx) = mpsc::unbounded_channel(); let (cancel, cancel_rx) = watch::channel(false); - tokio.spawn(run_exec( + tokio.spawn(watch_pod_detail_from_store( client, namespace.to_string(), name.to_string(), - container.to_string(), - shell.to_string(), + initial, + snapshots, updates_tx, - input_rx, - resize_rx, cancel_rx, )); - Ok(PodExecSession { - updates, - input: input_tx, - resize: resize_tx, - cancel, - }) + Ok(PodDetailSubscription { updates, cancel }) } #[allow(clippy::too_many_arguments)] -async fn run_exec( +async fn watch_pod_detail_from_store( client: Client, namespace: String, name: String, - container: String, - shell: String, - updates: mpsc::UnboundedSender, String>>, - mut input: mpsc::UnboundedReceiver>, - mut resize: mpsc::UnboundedReceiver<(u16, u16)>, + initial: Option>, + mut snapshots: Option>>, + updates: mpsc::UnboundedSender, mut cancel: watch::Receiver, ) { - let api: Api = Api::namespaced(client, &namespace); - let params = AttachParams { - container: Some(container.clone()), - stdin: true, - stdout: true, - stderr: false, - tty: true, - ..AttachParams::default() - }; - tracing::debug!( - namespace = %namespace, - pod = %name, - container = %container, - shell = %shell, - "pod exec: connecting" - ); - let mut process = match api.exec(&name, [shell.as_str()], ¶ms).await { - Ok(process) => process, - Err(error) => { - tracing::warn!(%error, pod = %name, container = %container, "pod exec: connect failed"); - let _ = updates.send(Err(format!("Could not start exec session: {error}"))); - return; - } - }; - tracing::debug!(pod = %name, container = %container, "pod exec: connected"); - let Some(mut stdin) = process.stdin() else { - let _ = updates.send(Err("Exec session has no stdin channel.".to_string())); - process.abort(); - return; - }; - let Some(stdout) = process.stdout() else { - let _ = updates.send(Err("Exec session has no stdout channel.".to_string())); - process.abort(); - return; - }; - let stderr = process.stderr(); - - let writer = tokio::spawn(async move { - while let Some(bytes) = input.recv().await { - if stdin.write_all(&bytes).await.is_err() || stdin.flush().await.is_err() { - break; - } - } - }); - let stdout_task = tokio::spawn(read_exec_output(stdout, updates.clone())); - let stderr_task = stderr.map(|stderr| tokio::spawn(read_exec_output(stderr, updates.clone()))); - - let mut resize_task = process.terminal_size().map(|size_sender| { - tokio::spawn(async move { - let mut size_sender = size_sender; - while let Some((cols, rows)) = resize.recv().await { - let size = TerminalSize { - width: cols, - height: rows, - }; - if size_sender.send(size).await.is_err() { - break; - } - } - }) - }); + let key = ObjectKey::new(&namespace, &name); + let mut last_version = String::new(); + let mut cpu_capacity = None; + let mut memory_capacity = None; + let mut saw_store_object = false; + let mut deleted_reported = false; + let mut initial_get_done = false; + let mut ticker = tokio::time::interval(METRICS_POLL_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let mut status_future = process.take_status(); - let status = tokio::select! { - _ = cancel.changed() => { - process.abort(); - writer.abort(); - stdout_task.abort(); - if let Some(task) = stderr_task { task.abort(); } - if let Some(task) = resize_task.take() { task.abort(); } + if let Some(record) = initial.as_ref() + && let Some(pod) = record.typed::() + { + last_version.clone_from(&record.resource_version); + (cpu_capacity, memory_capacity) = capacity_from_pod(pod); + if !send_pod_detail(&updates, &namespace, &name, pod) { return; } - status = async { - match status_future.take() { - Some(status) => status.await, - None => None, - } - } => status, - }; - process.abort(); - writer.abort(); - stdout_task.abort(); - if let Some(task) = stderr_task { - task.abort(); - } - if let Some(task) = resize_task.take() { - task.abort(); - } - match status { - Some(status) if status.status.as_deref() == Some("Success") => { - tracing::debug!(pod = %name, container = %container, "pod exec: session ended"); - let _ = updates.send(Ok(b"\r\n[session ended]\r\n".to_vec())); - } - Some(status) => { - let message = status - .message - .as_deref() - .filter(|message| !message.is_empty()) - .map(|message| format!("Exec session ended: {message}")) - .unwrap_or_else(|| "Exec session ended".to_string()); - tracing::warn!(%message, pod = %name, container = %container, "pod exec: session failed"); - let _ = updates.send(Err(message)); - } - None => { - tracing::debug!(pod = %name, container = %container, "pod exec: session closed"); - let _ = updates.send(Ok(b"\r\n[session closed]\r\n".to_vec())); - } + saw_store_object = true; + initial_get_done = true; } -} -async fn read_exec_output(mut reader: R, updates: mpsc::UnboundedSender, String>>) -where - R: AsyncRead + Unpin, -{ - let mut buffer = [0u8; 8192]; loop { - match reader.read(&mut buffer).await { - Ok(0) => return, - Ok(read) => { - if updates.send(Ok(buffer[..read].to_vec())).is_err() { - return; + let snapshot = snapshots + .as_mut() + .map(|receiver| receiver.borrow_and_update().clone()); + + if let Some(snapshot) = snapshot.as_ref() { + if let Some(record) = snapshot.object(&key) { + if let Some(pod) = record.typed::() + && record.resource_version != last_version + { + last_version.clone_from(&record.resource_version); + (cpu_capacity, memory_capacity) = capacity_from_pod(pod); + if !send_pod_detail(&updates, &namespace, &name, pod) { + return; + } + saw_store_object = true; + deleted_reported = false; + initial_get_done = true; } - } - Err(error) => { - let _ = updates.send(Err(format!("Exec stream error: {error}"))); - return; + } else if saw_store_object + && !deleted_reported + && matches!(snapshot.phase(), SnapshotPhase::Ready) + { + let _ = updates.send(PodDetailUpdate::Detail { + namespace: namespace.clone(), + name: name.clone(), + result: Err("This pod was deleted".to_string()), + }); + deleted_reported = true; } } - } -} - -/// Start loading a pod's full description and live usage samples. -pub fn subscribe_detail( - cx: &mut App, - namespace: &str, - name: &str, -) -> Result { - let state = cx.global::(); - let client = state.client().ok_or_else(|| "not connected".to_string())?; - let tokio = state.tokio(); - let (updates_tx, updates) = mpsc::unbounded_channel(); - let (cancel, cancel_rx) = watch::channel(false); - tokio.spawn(watch_pod_detail( - client, - namespace.to_string(), - name.to_string(), - updates_tx, - cancel_rx, - )); - Ok(PodDetailSubscription { updates, cancel }) -} - -async fn watch_pod_detail( - client: Client, - namespace: String, - name: String, - updates: mpsc::UnboundedSender, - mut cancel: watch::Receiver, -) { - let api: Api = Api::namespaced(client.clone(), &namespace); - let mut retry = Retry::new(); - let mut resource_version = String::new(); - let mut need_get = true; - let mut cpu_capacity = None; - let mut memory_capacity = None; - let mut ticker = tokio::time::interval(METRICS_POLL_INTERVAL); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - if need_get { + let store_ready = snapshot.as_ref().is_none_or(|snapshot| { + matches!( + snapshot.phase(), + SnapshotPhase::Ready | SnapshotPhase::Error(_) + ) + }); + + if !initial_get_done && store_ready { + // The store does not know this object yet (for example, a kubectl + // describe target outside the loaded namespace). A detail miss is + // one GET, never a watch. + initial_get_done = true; + let api: Api = Api::namespaced(client.clone(), &namespace); let fetched = tokio::select! { _ = cancel.changed() => return, result = api.get(&name) => result, }; - let pod = match fetched { - Ok(pod) => pod, + + match fetched { + Ok(pod) => { + (cpu_capacity, memory_capacity) = capacity_from_pod(&pod); + if !send_pod_detail(&updates, &namespace, &name, &pod) { + return; + } + } Err(error) => { let _ = updates.send(PodDetailUpdate::Detail { - namespace, - name, + namespace: namespace.clone(), + name: name.clone(), result: Err(format!("Could not load pod details: {error}")), }); return; } - }; - retry.list_succeeded(); - if let Some(version) = pod.resource_version() { - resource_version = version; - } - (cpu_capacity, memory_capacity) = capacity_from_pod(&pod); - if !send_pod_detail(&updates, &namespace, &name, &pod) { - return; } - need_get = false; } - let params = WatchParams::default() - .timeout(DETAIL_WATCH_TIMEOUT_SECS) - .fields(&format!("metadata.name={name}")); - let watched = tokio::select! { + tokio::select! { _ = cancel.changed() => return, - result = retry::bounded(api.watch(¶ms, &resource_version)) => result, - }; - let mut stream = match watched { - Ok(stream) => { - retry.watch_succeeded(); - Box::pin(stream) - } - Err(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - continue; + changed = async { + match snapshots.as_mut() { + Some(receiver) => receiver.changed().await.is_ok(), + None => std::future::pending::().await, } - RetryAction::GiveUp => { - poll_pod_detail(&client, &namespace, &name, &updates, &mut cancel).await; + } => { + if !changed { return; } - }, - }; - - let mut relist = false; - loop { - tokio::select! { - _ = cancel.changed() => return, - _ = ticker.tick() => { - let usage = fetch_usage(&client, &namespace, &name, cpu_capacity, memory_capacity).await; - if updates - .send(PodDetailUpdate::Usage { - namespace: namespace.clone(), - name: name.clone(), - result: usage, - }) - .is_err() - { - return; - } - } - event = stream.try_next() => match event { - Ok(Some(WatchEvent::Added(pod) | WatchEvent::Modified(pod))) => { - retry.watch_succeeded(); - if let Some(version) = pod.resource_version() { - resource_version = version; - } - (cpu_capacity, memory_capacity) = capacity_from_pod(&pod); - if !send_pod_detail(&updates, &namespace, &name, &pod) { - return; - } - } - Ok(Some(WatchEvent::Deleted(_))) => { - let _ = updates.send(PodDetailUpdate::Detail { - namespace, - name, - result: Err("This pod was deleted".to_string()), - }); - return; - } - Ok(Some(WatchEvent::Bookmark(bookmark))) => { - retry.watch_succeeded(); - resource_version = bookmark.metadata.resource_version; - } - Ok(Some(WatchEvent::Error(status))) => { - relist = status.code == 410 - || status.reason == "Expired" - || status.reason == "Gone"; - match retry.failed_status(status.code) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - poll_pod_detail(&client, &namespace, &name, &updates, &mut cancel) - .await; - return; - } - } - } - Ok(None) => break, - Err(error) => match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(&mut cancel, delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - poll_pod_detail(&client, &namespace, &name, &updates, &mut cancel).await; - return; - } - }, - } } - } - - if relist { - need_get = true; - } - if !sleep_or_cancel(&mut cancel, DETAIL_RECONNECT_DELAY).await { - return; - } - } -} - -/// Watch is not permitted: keep both the object and its usage fresh with GETs. -async fn poll_pod_detail( - client: &Client, - namespace: &str, - name: &str, - updates: &mpsc::UnboundedSender, - cancel: &mut watch::Receiver, -) { - let api: Api = Api::namespaced(client.clone(), namespace); - let mut retry = Retry::new(); - loop { - let fetched = tokio::select! { - _ = cancel.changed() => return, - result = api.get(name) => result, - }; - match fetched { - Ok(pod) => { - retry.list_succeeded(); - let (cpu_capacity, memory_capacity) = capacity_from_pod(&pod); - if !send_pod_detail(updates, namespace, name, &pod) { - return; - } + _ = ticker.tick() => { let usage = - fetch_usage(client, namespace, name, cpu_capacity, memory_capacity).await; + fetch_usage(&client, &namespace, &name, cpu_capacity, memory_capacity).await; if updates .send(PodDetailUpdate::Usage { - namespace: namespace.to_string(), - name: name.to_string(), + namespace: namespace.clone(), + name: name.clone(), result: usage, }) .is_err() @@ -647,30 +389,6 @@ async fn poll_pod_detail( return; } } - Err(error) => { - if updates - .send(PodDetailUpdate::Detail { - namespace: namespace.to_string(), - name: name.to_string(), - result: Err(format!("Could not load pod details: {error}")), - }) - .is_err() - { - return; - } - match retry.failed(&error) { - RetryAction::Retry(delay) => { - if !sleep_or_cancel(cancel, delay).await { - return; - } - continue; - } - RetryAction::GiveUp => return, - } - } - } - if !sleep_or_cancel(cancel, METRICS_POLL_INTERVAL).await { - return; } } } @@ -690,13 +408,6 @@ fn send_pod_detail( .is_ok() } -async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { - tokio::select! { - _ = cancel.changed() => false, - _ = tokio::time::sleep(duration) => true, - } -} - async fn fetch_usage( client: &Client, namespace: &str, diff --git a/src/repository/resources/replica_sets/mod.rs b/src/repository/resources/replica_sets/mod.rs deleted file mode 100644 index 85c8fa5..0000000 --- a/src/repository/resources/replica_sets/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for ReplicaSets: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live ReplicaSets stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::ReplicaSets, scope) -} diff --git a/src/repository/resources/replication_controllers/mod.rs b/src/repository/resources/replication_controllers/mod.rs deleted file mode 100644 index f9cc32e..0000000 --- a/src/repository/resources/replication_controllers/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for ReplicationControllers: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live ReplicationControllers stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::ReplicationControllers, scope) -} diff --git a/src/repository/resources/role_bindings/mod.rs b/src/repository/resources/role_bindings/mod.rs deleted file mode 100644 index e96620f..0000000 --- a/src/repository/resources/role_bindings/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for RoleBindings: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live RoleBindings stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::RoleBindings, scope) -} diff --git a/src/repository/resources/roles/mod.rs b/src/repository/resources/roles/mod.rs deleted file mode 100644 index e980eba..0000000 --- a/src/repository/resources/roles/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Roles: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Roles stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Roles, scope) -} diff --git a/src/repository/resources/secrets/mod.rs b/src/repository/resources/secrets/mod.rs deleted file mode 100644 index dd2c9c5..0000000 --- a/src/repository/resources/secrets/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Secrets: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Secrets stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Secrets, scope) -} diff --git a/src/repository/resources/service_accounts/mod.rs b/src/repository/resources/service_accounts/mod.rs deleted file mode 100644 index b230f98..0000000 --- a/src/repository/resources/service_accounts/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for ServiceAccounts: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live ServiceAccounts stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::ServiceAccounts, scope) -} diff --git a/src/repository/resources/services/mod.rs b/src/repository/resources/services/mod.rs deleted file mode 100644 index 36e67c5..0000000 --- a/src/repository/resources/services/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for Services: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live Services stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::Services, scope) -} diff --git a/src/repository/resources/stateful_sets/mod.rs b/src/repository/resources/stateful_sets/mod.rs deleted file mode 100644 index 1d42d43..0000000 --- a/src/repository/resources/stateful_sets/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for StatefulSets: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live StatefulSets stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App, scope: NamespaceScope) -> Result { - subscribe_kind(cx, ResourceKind::StatefulSets, scope) -} diff --git a/src/repository/resources/storage_classes/mod.rs b/src/repository/resources/storage_classes/mod.rs deleted file mode 100644 index ac5801c..0000000 --- a/src/repository/resources/storage_classes/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Repository for StorageClasses: subscribe to the live list/watch stream. - -use crate::models::{NamespaceScope, ResourceKind}; -use crate::repository::{Subscription, subscribe_kind}; -use gpui_kit::App; - -/// Subscribe to the live StorageClasses stream. -/// Returns a [`Subscription`] that delivers immutable snapshots as the cluster changes. -pub fn subscribe(cx: &mut App) -> Result { - subscribe_kind(cx, ResourceKind::StorageClasses, NamespaceScope::All) -} diff --git a/src/repository/resources/watcher.rs b/src/repository/resources/watcher.rs deleted file mode 100644 index 8b781a9..0000000 --- a/src/repository/resources/watcher.rs +++ /dev/null @@ -1,1540 +0,0 @@ -//! Kubernetes list + watch loop for one resource kind / namespace scope. -//! -//! This module runs entirely on the cluster tokio runtime. It owns the -//! [`ResourceStore`] and only publishes immutable snapshots to the UI, throttled -//! to `FLUSH_INTERVAL` or every `FLUSH_AT` events, whichever comes first. - -use std::collections::{HashMap, HashSet}; -use std::fmt::Debug; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::Duration; - -use futures::TryStreamExt as _; -use k8s_openapi::api::apps::v1::{DaemonSet, Deployment, ReplicaSet, StatefulSet}; -use k8s_openapi::api::autoscaling::v2::HorizontalPodAutoscaler; -use k8s_openapi::api::batch::v1::{CronJob, Job}; -use k8s_openapi::api::coordination::v1::Lease; -use k8s_openapi::api::core::v1::{ - ConfigMap, Endpoints, Event, Namespace, Node, PersistentVolume, PersistentVolumeClaim, Pod, - ReplicationController, Secret, Service, ServiceAccount, -}; -use k8s_openapi::api::discovery::v1::EndpointSlice; -use k8s_openapi::api::networking::v1::{Ingress, NetworkPolicy}; -use k8s_openapi::api::policy::v1::PodDisruptionBudget; -use k8s_openapi::api::rbac::v1::{ClusterRole, Role, RoleBinding}; -use k8s_openapi::api::storage::v1::StorageClass; -use kube::api::{Api, DynamicObject, ListParams, ResourceExt, WatchEvent, WatchParams}; -use kube::core::{ApiResource, GroupVersionKind, NamespaceResourceScope}; -use kube::{Client, Resource}; -use serde::de::DeserializeOwned; -use tokio::sync::watch; - -use crate::models::{ - Item, NamespaceScope, ObjectKey, PollRate, ResourceKind, ResourceSnapshot, ResourceStore, - SnapshotPhase, WatchMode, -}; -use crate::repository::retry::{self, RequestFailure, Retry, RetryAction}; -use crate::repository::watch_prefs::{WatchPreference, WatchPrefs}; - -const FLUSH_INTERVAL: Duration = Duration::from_millis(50); -const FLUSH_AT: usize = 256; - -/// How often an automatically denied watch is retried. RBAC can change without -/// an app restart, but re-probing too often would spam the API server. -const WATCH_REPROBE_INTERVAL: Duration = Duration::from_secs(600); - -/// Streaming lists (`sendInitialEvents`) bounds. A server that ignores the -/// option never sends the initial-end bookmark, so an idle stream falls back to -/// the list path quickly. A busy initial stream gets longer, but is capped. -const STREAM_INITIAL_IDLE_TIMEOUT: Duration = Duration::from_secs(5); -const STREAM_INITIAL_TOTAL_TIMEOUT: Duration = Duration::from_secs(60); - -/// Kubernetes version at which `sendInitialEvents` was introduced. 1.27 and -/// 1.28 may still have the WatchList feature gate disabled, so the stream is -/// still attempted and falls back to list if no initial-end bookmark arrives. -const STREAM_INITIAL_MIN_MINOR: u32 = 27; - -static STREAMING_SUPPORT: OnceLock>> = OnceLock::new(); - -fn streaming_support() -> &'static Mutex> { - STREAMING_SUPPORT.get_or_init(|| Mutex::new(HashMap::new())) -} - -async fn supports_streaming_initial(client: &Client, epoch: u64) -> bool { - if let Ok(cache) = streaming_support().lock() - && let Some(supported) = cache.get(&epoch) - { - return *supported; - } - - let supported = match retry::bounded(client.apiserver_version()).await { - Ok(info) => version_supports_streaming(&info.major, &info.minor), - Err(failure) => { - tracing::debug!(%failure, "repository: could not determine API server version"); - false - } - }; - if let Ok(mut cache) = streaming_support().lock() { - cache.insert(epoch, supported); - } - supported -} - -fn version_supports_streaming(major: &str, minor: &str) -> bool { - let major = major.parse::().unwrap_or(0); - let minor = minor - .trim_end_matches(|character: char| !character.is_ascii_digit()) - .parse::() - .unwrap_or(0); - major > 1 || (major == 1 && minor >= STREAM_INITIAL_MIN_MINOR) -} - -fn bookmark_ends_initial_events(annotations: &std::collections::BTreeMap) -> bool { - annotations - .get("k8s.io/initial-events-end") - .is_some_and(|value| value == "true") -} - -/// List chunk size. Big kinds (thousands of pods/events) render their first -/// chunk immediately instead of waiting for one huge response. -const LIST_CHUNK_SIZE: u32 = 500; - -/// Events are high-churn and grow without bound while the watch is open. -/// Keep the newest rows, matching Freelens' 1000-event initial list. -const EVENTS_LIMIT: usize = 1_000; - -/// Kinds whose watch the API server denied (RBAC) for the current context -/// epoch. New subscriptions for these kinds skip the doomed watch attempt and -/// start polling right away, so changing the namespace filter does not reset -/// polling back to a live-watch attempt. -static WATCH_DENIED: OnceLock>> = OnceLock::new(); - -fn denied_registry() -> &'static Mutex> { - WATCH_DENIED.get_or_init(|| Mutex::new(HashSet::new())) -} - -pub fn watch_denied(epoch: u64, kind: ResourceKind) -> bool { - denied_registry() - .lock() - .is_ok_and(|set| set.contains(&(epoch, kind))) -} - -/// Forget a denial: the user explicitly asked for live watch again. -pub fn clear_watch_denied(epoch: u64, kind: ResourceKind) { - if let Ok(mut set) = denied_registry().lock() { - set.remove(&(epoch, kind)); - } -} - -fn mark_watch_denied(epoch: u64, kind: ResourceKind) { - if let Ok(mut set) = denied_registry().lock() { - set.retain(|(stale_epoch, _)| *stale_epoch == epoch); - set.insert((epoch, kind)); - } -} - -/// Spawned once per subscription. Keep it generic over the store; the typed -/// kind dispatch lives in `run`. -#[allow(clippy::too_many_arguments)] -pub async fn run( - client: Client, - kind: ResourceKind, - scope: NamespaceScope, - epoch: u64, - tx: watch::Sender>, - cancel: watch::Receiver, - refresh: watch::Receiver, - prefs: watch::Receiver, -) { - let mut task = WatchTask { - client, - kind, - epoch, - tx, - cancel, - refresh, - prefs, - store: ResourceStore::new(), - mode: WatchMode::Watch, - ready: false, - stream_initial_disabled: false, - }; - match kind { - ResourceKind::Pods => task.run_namespaced::(scope, pod_item).await, - ResourceKind::Deployments => { - task.run_namespaced::(scope, deployment_item) - .await - } - ResourceKind::StatefulSets => { - task.run_namespaced::(scope, stateful_set_item) - .await - } - ResourceKind::ReplicaSets => { - task.run_namespaced::(scope, replica_set_item) - .await - } - ResourceKind::ReplicationControllers => { - task.run_namespaced::(scope, replication_controller_item) - .await - } - ResourceKind::DaemonSets => { - task.run_namespaced::(scope, daemon_set_item) - .await - } - ResourceKind::Jobs => task.run_namespaced::(scope, job_item).await, - ResourceKind::CronJobs => { - task.run_namespaced::(scope, cron_job_item) - .await - } - ResourceKind::HorizontalPodAutoscalers => { - task.run_namespaced::(scope, horizontal_pod_autoscaler_item) - .await - } - ResourceKind::PodDisruptionBudgets => { - task.run_namespaced::(scope, pod_disruption_budget_item) - .await - } - ResourceKind::Services => task.run_namespaced::(scope, service_item).await, - ResourceKind::Ingresses => task.run_namespaced::(scope, ingress_item).await, - ResourceKind::Endpoints => { - task.run_namespaced::(scope, endpoints_item) - .await - } - ResourceKind::EndpointSlices => { - task.run_namespaced::(scope, endpoint_slice_item) - .await - } - ResourceKind::NetworkPolicies => { - task.run_namespaced::(scope, network_policy_item) - .await - } - ResourceKind::PersistentVolumeClaims => { - task.run_namespaced::(scope, pvc_item) - .await - } - ResourceKind::StorageClasses => { - task.run_cluster::(storage_class_item) - .await - } - ResourceKind::ConfigMaps => { - task.run_namespaced::(scope, config_map_item) - .await - } - ResourceKind::Secrets => task.run_namespaced::(scope, secret_item).await, - ResourceKind::ServiceAccounts => { - task.run_namespaced::(scope, service_account_item) - .await - } - ResourceKind::PersistentVolumes => task.run_cluster::(pv_item).await, - ResourceKind::Namespaces => task.run_cluster::(namespace_item).await, - ResourceKind::Nodes => task.run_cluster::(node_item).await, - ResourceKind::Leases => task.run_namespaced::(scope, lease_item).await, - ResourceKind::Events => task.run_namespaced::(scope, event_item).await, - ResourceKind::ClusterRoles => task.run_cluster::(cluster_role_item).await, - ResourceKind::Roles => task.run_namespaced::(scope, role_item).await, - ResourceKind::RoleBindings => { - task.run_namespaced::(scope, role_binding_item) - .await - } - ResourceKind::Custom(index) => { - if let Some(def) = crate::models::resources::catalog::custom_def(index) { - let resource = ApiResource::from_gvk_with_plural( - &GroupVersionKind::gvk(&def.group, &def.version, &def.kind), - &def.plural, - ); - task.run_dynamic(resource, scope).await; - } - } - } -} - -struct WatchTask { - client: Client, - kind: ResourceKind, - epoch: u64, - tx: watch::Sender>, - cancel: watch::Receiver, - refresh: watch::Receiver, - prefs: watch::Receiver, - store: ResourceStore, - mode: WatchMode, - /// Whether a `Ready` snapshot has been published for this task. Used to - /// publish an empty first list without republishing unchanged poll results. - ready: bool, - /// Once streaming lists fall back, keep using the list path for this task. - stream_initial_disabled: bool, -} - -/// Why the poll loop exited. -enum PollOutcome { - /// Subscription cancelled or listing denied: stop the task. - Stop, - /// The user switched this kind back to watch mode. - ResumeWatch, -} - -/// Outcome of a chunked list. -enum ListOutcome { - /// Full list published; watch from this resource version. - Listed { - resource_version: String, - changed: bool, - }, - /// Subscription cancelled mid-list. - Cancelled, - /// A chunk request failed or timed out; the caller decides whether to retry. - Failed(RequestFailure), -} - -/// Outcome of attempting a Kubernetes streaming list. -enum InitialStream { - /// Initial events and the end-of-initial-events bookmark were received. - Listed { - resource_version: String, - changed: bool, - }, - /// Subscription cancelled. - Cancelled, - /// The server ignored/rejected streaming lists, the stream ended, or the - /// initial bookmark did not arrive in time. - Unsupported, -} - -impl WatchTask { - async fn run_namespaced(&mut self, scope: NamespaceScope, map: F) - where - K: Resource - + Clone - + Debug - + DeserializeOwned - + Send - + 'static, - F: Fn(&K) -> Item + Copy, - { - let api = match &scope { - NamespaceScope::All => Api::::all(self.client.clone()), - NamespaceScope::One(namespace) => Api::::namespaced(self.client.clone(), namespace), - }; - self.run_stream(api, map).await; - } - - async fn run_cluster(&mut self, map: F) - where - K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, - F: Fn(&K) -> Item + Copy, - { - let api = Api::::all(self.client.clone()); - self.run_stream(api, map).await; - } - - async fn run_dynamic(&mut self, resource: ApiResource, scope: NamespaceScope) { - let api = match &scope { - NamespaceScope::All => Api::::all_with(self.client.clone(), &resource), - NamespaceScope::One(namespace) => { - Api::::namespaced_with(self.client.clone(), namespace, &resource) - } - }; - self.run_stream(api, custom_item).await; - } - - async fn run_stream(&mut self, api: Api, map: F) - where - K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, - F: Fn(&K) -> Item + Copy, - { - let watch_params = WatchParams::default().timeout(280); - let mut resource_version = String::new(); - let mut need_list = true; - let mut retry = Retry::new(); - let mut ticker = tokio::time::interval(FLUSH_INTERVAL); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - ticker.tick().await; - - loop { - if self.cancel.has_changed().is_err() { - return; - } - // The user prefers polling for this kind: skip the watch entirely. - if self.prefs.borrow().mode == WatchPreference::Poll { - match self.poll_loop(&api, map).await { - PollOutcome::Stop => return, - PollOutcome::ResumeWatch => { - need_list = true; - retry = Retry::new(); - continue; - } - } - } - if need_list { - let mut streamed = false; - if !self.stream_initial_disabled - && !self.ready - && self.store.len() == 0 - && !watch_denied(self.epoch, self.kind) - && supports_streaming_initial(&self.client, self.epoch).await - { - match self.stream_initial(&api, map).await { - InitialStream::Listed { - resource_version: version, - changed, - } => { - tracing::debug!( - changed, - "repository: initial state came from a streaming list" - ); - retry.list_succeeded(); - resource_version = version; - need_list = false; - streamed = true; - } - InitialStream::Cancelled => return, - InitialStream::Unsupported => { - tracing::debug!( - "repository: streaming list unsupported or timed out, falling back to list" - ); - self.stream_initial_disabled = true; - } - } - } - - if !streamed { - match self.list_progressively(&api, map).await { - ListOutcome::Listed { - resource_version: version, - .. - } => { - retry.list_succeeded(); - resource_version = version; - need_list = false; - } - ListOutcome::Cancelled => return, - ListOutcome::Failed(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - tracing::warn!(%failure, ?api, delay_ms = delay.as_millis() as u64, "repository: list failed, retrying"); - self.publish(SnapshotPhase::Error(format!( - "list failed: {failure}" - ))); - if !self.sleep(delay).await { - return; - } - continue; - } - RetryAction::GiveUp => { - tracing::error!(%failure, ?api, "repository: list rejected, stopping watch"); - self.publish(SnapshotPhase::Error(format!( - "list rejected: {failure}" - ))); - return; - } - }, - } - } - } - - let watched = tokio::select! { - _ = self.cancel.changed() => return, - result = retry::bounded(api.watch(&watch_params, &resource_version)) => result, - }; - let mut stream = match watched { - Ok(stream) => { - clear_watch_denied(self.epoch, self.kind); - Box::pin(stream) - } - Err(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - tracing::warn!(%failure, delay_ms = delay.as_millis() as u64, "repository: watch setup failed, retrying"); - if !self.sleep(delay).await { - return; - } - need_list = true; - continue; - } - RetryAction::GiveUp => { - tracing::warn!(%failure, ?api, "repository: watch denied, polling instead"); - mark_watch_denied(self.epoch, self.kind); - match self.poll_loop(&api, map).await { - PollOutcome::Stop => return, - PollOutcome::ResumeWatch => { - need_list = true; - retry = Retry::new(); - continue; - } - } - } - }, - }; - - let mut relist = false; - loop { - tokio::select! { - _ = self.cancel.changed() => return, - _ = self.refresh.changed() => { - self.refresh.borrow_and_update(); - relist = true; - break; - } - _ = self.prefs.changed() => { - let prefs = *self.prefs.borrow_and_update(); - if prefs.mode == WatchPreference::Poll { - tracing::info!(?api, "repository: watch disabled by preference, polling instead"); - drop(stream); - match self.poll_loop(&api, map).await { - PollOutcome::Stop => return, - PollOutcome::ResumeWatch => { - relist = true; - retry = Retry::new(); - break; - } - } - } - } - _ = ticker.tick() => { - if self.store.pending() > 0 { - self.publish(SnapshotPhase::Ready); - } - } - event = stream.try_next() => { - match event { - Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { - retry.watch_succeeded(); - let item = map(&object); - if !item.resource_version.is_empty() { - resource_version.clone_from(&item.resource_version); - } - self.store.upsert(item); - } - Ok(Some(WatchEvent::Deleted(object))) => { - retry.watch_succeeded(); - if let Some(version) = object.resource_version() { - resource_version = version; - } - self.store.remove(&ObjectKey::new( - object.namespace().unwrap_or_default(), - object.name_any(), - )); - } - Ok(Some(WatchEvent::Bookmark(bookmark))) => { - retry.watch_succeeded(); - resource_version = bookmark.metadata.resource_version; - } - Ok(Some(WatchEvent::Error(status))) => { - relist = status.code == 410 - || status.reason == "Expired" - || status.reason == "Gone"; - match retry.failed_status(status.code) { - RetryAction::Retry(delay) => { - tracing::warn!( - code = status.code, - message = %status.message, - delay_ms = delay.as_millis() as u64, - "repository: watch error event, retrying" - ); - if !self.sleep(delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - tracing::warn!( - code = status.code, - message = %status.message, - "repository: watch denied, polling instead" - ); - mark_watch_denied(self.epoch, self.kind); - drop(stream); - match self.poll_loop(&api, map).await { - PollOutcome::Stop => return, - PollOutcome::ResumeWatch => { - relist = true; - retry = Retry::new(); - break; - } - } - } - } - } - Ok(None) => { - tracing::debug!("repository: watch stream ended, resuming"); - break; - } - Err(error) => match retry.failed(&error) { - RetryAction::Retry(delay) => { - tracing::warn!(%error, delay_ms = delay.as_millis() as u64, "repository: watch stream failed, retrying"); - if !self.sleep(delay).await { - return; - } - break; - } - RetryAction::GiveUp => { - tracing::warn!(%error, ?api, "repository: watch denied, polling instead"); - mark_watch_denied(self.epoch, self.kind); - drop(stream); - match self.poll_loop(&api, map).await { - PollOutcome::Stop => return, - PollOutcome::ResumeWatch => { - relist = true; - retry = Retry::new(); - break; - } - } - } - }, - } - self.trim_high_churn(); - if self.store.pending() >= FLUSH_AT { - self.publish(SnapshotPhase::Ready); - } - } - } - } - - if self.store.pending() > 0 { - self.publish(SnapshotPhase::Ready); - } - if relist { - need_list = true; - } else if !self.sleep(Duration::from_millis(250)).await { - return; - } - } - } - - fn trim_high_churn(&mut self) -> bool { - if self.kind == ResourceKind::Events { - return self.store.trim_oldest(EVENTS_LIMIT); - } - false - } - - fn publish(&mut self, phase: SnapshotPhase) { - self.ready = matches!(&phase, SnapshotPhase::Ready); - let snapshot = Arc::new(self.store.snapshot(phase).with_mode(self.mode)); - tracing::trace!( - objects = self.store.len(), - "repository: publishing snapshot" - ); - self.tx.send_replace(snapshot); - } - - /// Returns false when the subscription was cancelled. - async fn sleep(&mut self, duration: Duration) -> bool { - tokio::select! { - _ = self.cancel.changed() => false, - _ = tokio::time::sleep(duration) => true, - } - } - - /// Try Kubernetes 1.27+ streaming lists (`sendInitialEvents`) before the - /// chunked list path. Returns `Unsupported` for old API servers, disabled - /// feature gates, RBAC errors, or a stream that never emits the - /// `k8s.io/initial-events-end` bookmark. The caller then falls back to the - /// existing list+watch code. - async fn stream_initial(&mut self, api: &Api, map: F) -> InitialStream - where - K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, - F: Fn(&K) -> Item + Copy, - { - let params = WatchParams::streaming_lists().timeout(280); - let watched = tokio::select! { - _ = self.cancel.changed() => return InitialStream::Cancelled, - result = retry::bounded(api.watch(¶ms, "")) => result, - }; - let mut stream = match watched { - Ok(stream) => Box::pin(stream), - Err(failure) => { - tracing::debug!(%failure, ?api, "repository: streaming list setup failed"); - return InitialStream::Unsupported; - } - }; - - let mut resource_version = String::new(); - let mut changed = false; - let total_deadline = tokio::time::Instant::now() + STREAM_INITIAL_TOTAL_TIMEOUT; - let mut idle_deadline = tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; - let mut ticker = tokio::time::interval(FLUSH_INTERVAL); - ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - let deadline = total_deadline.min(idle_deadline); - tokio::select! { - _ = self.cancel.changed() => return InitialStream::Cancelled, - _ = ticker.tick() => { - if self.store.pending() > 0 { - self.publish(SnapshotPhase::Ready); - } - } - _ = tokio::time::sleep_until(deadline) => { - tracing::debug!( - ?api, - "repository: streaming list did not finish, falling back to list" - ); - return InitialStream::Unsupported; - } - event = stream.try_next() => match event { - Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { - idle_deadline = - tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; - let item = map(&object); - if !item.resource_version.is_empty() { - resource_version.clone_from(&item.resource_version); - } - changed |= self.store.upsert(item); - changed |= self.trim_high_churn(); - if self.store.pending() >= FLUSH_AT { - self.publish(SnapshotPhase::Ready); - } - } - Ok(Some(WatchEvent::Deleted(object))) => { - idle_deadline = - tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; - if let Some(version) = object.resource_version() { - resource_version = version; - } - changed |= self.store.remove(&ObjectKey::new( - object.namespace().unwrap_or_default(), - object.name_any(), - )); - changed |= self.trim_high_churn(); - } - Ok(Some(WatchEvent::Bookmark(bookmark))) => { - let initial_end = - bookmark_ends_initial_events(&bookmark.metadata.annotations); - if !bookmark.metadata.resource_version.is_empty() { - resource_version = bookmark.metadata.resource_version; - } - if !initial_end { - tracing::debug!( - ?api, - "repository: streaming list bookmark was not the initial-events-end" - ); - return InitialStream::Unsupported; - } - if self.store.pending() > 0 || !self.ready { - self.publish(SnapshotPhase::Ready); - } - return InitialStream::Listed { - resource_version, - changed, - }; - } - Ok(Some(WatchEvent::Error(status))) => { - tracing::debug!( - code = status.code, - message = %status.message, - ?api, - "repository: streaming list error, falling back to list" - ); - return InitialStream::Unsupported; - } - Ok(None) => { - tracing::debug!( - ?api, - "repository: streaming list ended early, falling back to list" - ); - return InitialStream::Unsupported; - } - Err(error) => { - tracing::debug!( - %error, - ?api, - "repository: streaming list failed, falling back to list" - ); - return InitialStream::Unsupported; - } - } - } - } - } - - /// Merge every object in the list into the store, publishing each chunk so - /// large kinds render progressively. Objects missing from the completed - /// list are removed at the end; unchanged objects are left untouched. - /// Returns the resource version to watch from. - async fn list_progressively(&mut self, api: &Api, map: F) -> ListOutcome - where - K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, - F: Fn(&K) -> Item + Copy, - { - let mut params = ListParams::default().limit(LIST_CHUNK_SIZE); - let mut resource_version = String::new(); - let mut changed = false; - self.store.begin_sync(); - loop { - let listed = tokio::select! { - _ = self.cancel.changed() => { - self.store.abort_sync(); - return ListOutcome::Cancelled; - } - result = retry::bounded(api.list(¶ms)) => result, - }; - let chunk = match listed { - Ok(chunk) => chunk, - Err(failure) => { - self.store.abort_sync(); - return ListOutcome::Failed(failure); - } - }; - // Every chunk carries the version of the same list snapshot. - if let Some(version) = chunk.metadata.resource_version { - resource_version = version; - } - for object in &chunk.items { - changed |= self.store.upsert(map(object)); - } - changed |= self.trim_high_churn(); - if self.store.pending() > 0 { - self.publish(SnapshotPhase::Ready); - } - match chunk.metadata.continue_ { - Some(token) if !token.is_empty() => { - params = params.continue_token(&token); - } - _ => break, - } - } - changed |= self.store.finish_sync(); - if self.store.pending() > 0 || !self.ready { - self.publish(SnapshotPhase::Ready); - } - ListOutcome::Listed { - resource_version, - changed, - } - } - - /// Poll instead of watching — because watch was denied (RBAC) or the user - /// prefers polling for this kind. Runs until the subscription is - /// cancelled, listing is denied, or the user switches back to watch mode. - /// `PollRate::Off` disables the timer; manual refresh still re-lists. - async fn poll_loop(&mut self, api: &Api, map: F) -> PollOutcome - where - K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, - F: Fn(&K) -> Item + Copy, - { - self.mode = WatchMode::Poll(self.prefs.borrow().rate); - // Reflect the mode switch in the UI immediately, not after the first - // poll roundtrip. - if self.store.len() > 0 { - self.publish(SnapshotPhase::Ready); - } - let mut retry = Retry::new(); - let mut unchanged_polls: u32 = 0; - let mut reprobe = tokio::time::interval(WATCH_REPROBE_INTERVAL); - reprobe.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - reprobe.tick().await; - - loop { - if self.cancel.has_changed().is_err() { - return PollOutcome::Stop; - } - - match self.list_progressively(api, map).await { - ListOutcome::Listed { changed, .. } => { - retry.list_succeeded(); - if changed { - unchanged_polls = 0; - } else { - unchanged_polls = unchanged_polls.saturating_add(1); - } - } - ListOutcome::Cancelled => return PollOutcome::Stop, - ListOutcome::Failed(failure) => match failure.action(&mut retry) { - RetryAction::Retry(delay) => { - tracing::warn!(%failure, ?api, delay_ms = delay.as_millis() as u64, "repository: poll list failed, retrying"); - self.publish(SnapshotPhase::Error(format!("list failed: {failure}"))); - if !self.sleep(delay).await { - return PollOutcome::Stop; - } - continue; - } - RetryAction::GiveUp => { - tracing::error!(%failure, ?api, "repository: poll list rejected, stopping watch"); - self.publish(SnapshotPhase::Error(format!("list rejected: {failure}"))); - return PollOutcome::Stop; - } - }, - } - - // Wait for the next tick. Refresh or a preference change re-list - // immediately; `Off` waits without a timer. Repeated unchanged - // polls back off up to one minute. - let rate = self.prefs.borrow().rate; - let delay = poll_delay(rate, unchanged_polls); - tokio::select! { - _ = self.cancel.changed() => return PollOutcome::Stop, - _ = self.refresh.changed() => { - self.refresh.borrow_and_update(); - unchanged_polls = 0; - } - _ = self.prefs.changed() => { - let prefs = *self.prefs.borrow_and_update(); - self.mode = WatchMode::Poll(prefs.rate); - unchanged_polls = 0; - if prefs.mode == WatchPreference::Watch { - self.mode = WatchMode::Watch; - self.publish(SnapshotPhase::Ready); - return PollOutcome::ResumeWatch; - } - // Show the new rate/off state instantly; the re-list - // happens on the next loop turn. - self.publish(SnapshotPhase::Ready); - } - _ = reprobe.tick() => { - // Only an automatic denial is re-probed; an explicit - // polling preference is left alone. - if self.prefs.borrow().mode == WatchPreference::Watch - && watch_denied(self.epoch, self.kind) - { - tracing::info!(?api, "repository: re-probing previously denied watch"); - clear_watch_denied(self.epoch, self.kind); - self.mode = WatchMode::Watch; - self.publish(SnapshotPhase::Ready); - return PollOutcome::ResumeWatch; - } - } - _ = async { - match delay { - Some(delay) => tokio::time::sleep(delay).await, - None => std::future::pending().await, - } - } => {} - } - } - } -} - -fn poll_delay(rate: PollRate, unchanged_polls: u32) -> Option { - match rate { - PollRate::Off => None, - PollRate::Every(secs) => { - let factor = 1u64 << unchanged_polls.min(3); - Some(Duration::from_secs( - secs.saturating_mul(factor).clamp(1, 60), - )) - } - } -} - -fn base_item(object: &K) -> Item -where - K: ResourceExt, -{ - let mut item = Item::new(object.name_any(), object.namespace()); - item.uid = object.uid().unwrap_or_default(); - item.resource_version = object.resource_version().unwrap_or_default(); - item.created_at = object.creation_timestamp().map(|time| time.0.as_second()); - item -} - -fn version_label(labels: Option<&std::collections::BTreeMap>) -> String { - labels - .and_then(|labels| labels.get("app.kubernetes.io/version").cloned()) - .unwrap_or_default() -} - -fn pod_item(pod: &Pod) -> Item { - let mut item = base_item(pod); - item.node = pod - .spec - .as_ref() - .and_then(|spec| spec.node_name.clone()) - .unwrap_or_default(); - let statuses = pod - .status - .as_ref() - .and_then(|status| status.container_statuses.as_ref()); - let total = statuses.map(|statuses| statuses.len()).unwrap_or(0); - let ready = statuses - .map(|statuses| statuses.iter().filter(|status| status.ready).count()) - .unwrap_or(0); - item.ready = format!("{ready}/{total}"); - item.restarts = statuses - .map(|statuses| { - statuses - .iter() - .map(|status| status.restart_count as usize) - .sum() - }) - .unwrap_or(0); - item.status = pod - .status - .as_ref() - .and_then(|status| status.phase.as_deref()) - .unwrap_or("unknown") - .to_lowercase(); - let crashing = statuses.is_some_and(|statuses| { - statuses.iter().any(|status| { - status - .state - .as_ref() - .and_then(|state| state.waiting.as_ref()) - .and_then(|waiting| waiting.reason.as_deref()) - .is_some_and(|reason| reason == "CrashLoopBackOff") - }) - }); - if crashing { - item.status = "crashloopbackoff".to_string(); - } - item -} - -fn deployment_item(deployment: &Deployment) -> Item { - let mut item = base_item(deployment); - let desired = deployment - .spec - .as_ref() - .and_then(|spec| spec.replicas) - .unwrap_or(0); - let ready = deployment - .status - .as_ref() - .and_then(|status| status.ready_replicas) - .unwrap_or(0); - item.ready = format!("{ready}/{desired}"); - item.capacity = desired.to_string(); - item.status = if ready >= desired { - "ready" - } else { - "progressing" - } - .to_string(); - item.version = version_label(deployment.metadata.labels.as_ref()); - item -} - -fn daemon_set_item(daemon_set: &DaemonSet) -> Item { - let mut item = base_item(daemon_set); - let desired = daemon_set - .status - .as_ref() - .map(|status| status.desired_number_scheduled) - .unwrap_or(0); - let ready = daemon_set - .status - .as_ref() - .map(|status| status.number_ready) - .unwrap_or(0); - item.ready = format!("{ready}/{desired}"); - item.capacity = desired.to_string(); - item.status = if ready >= desired { - "ready" - } else { - "progressing" - } - .to_string(); - item.version = version_label(daemon_set.metadata.labels.as_ref()); - item -} - -fn job_item(job: &Job) -> Item { - let mut item = base_item(job); - let want = job - .spec - .as_ref() - .and_then(|spec| spec.completions) - .unwrap_or(1); - let done = job - .status - .as_ref() - .and_then(|status| status.succeeded) - .unwrap_or(0); - item.ready = format!("{done}/{want}"); - item.capacity = want.to_string(); - item.status = if done >= want { "succeeded" } else { "running" }.to_string(); - item -} - -fn service_item(service: &Service) -> Item { - let mut item = base_item(service); - let spec = service.spec.as_ref(); - item.status = spec - .and_then(|spec| spec.type_.clone()) - .unwrap_or_else(|| "ClusterIP".to_string()) - .to_lowercase(); - item.version = spec - .and_then(|spec| spec.ports.as_ref()) - .and_then(|ports| ports.first()) - .map(|port| port.port.to_string()) - .unwrap_or_default(); - item -} - -fn ingress_item(ingress: &Ingress) -> Item { - let mut item = base_item(ingress); - item.status = "ingress".to_string(); - item.version = ingress - .spec - .as_ref() - .and_then(|spec| spec.rules.as_ref()) - .and_then(|rules| rules.first()) - .and_then(|rule| rule.host.clone()) - .unwrap_or_default(); - item -} - -fn pv_item(persistent_volume: &PersistentVolume) -> Item { - let mut item = base_item(persistent_volume); - item.capacity = persistent_volume - .spec - .as_ref() - .and_then(|spec| spec.capacity.as_ref()) - .and_then(|capacity| capacity.get("storage")) - .map(|quantity| quantity.0.clone()) - .unwrap_or_default(); - item.status = persistent_volume - .status - .as_ref() - .and_then(|status| status.phase.clone()) - .unwrap_or_default() - .to_lowercase(); - item -} - -fn pvc_item(persistent_volume_claim: &PersistentVolumeClaim) -> Item { - let mut item = base_item(persistent_volume_claim); - item.capacity = persistent_volume_claim - .status - .as_ref() - .and_then(|status| status.capacity.as_ref()) - .and_then(|capacity| capacity.get("storage")) - .or_else(|| { - persistent_volume_claim - .spec - .as_ref() - .and_then(|spec| spec.resources.as_ref()) - .and_then(|resources| resources.requests.as_ref()) - .and_then(|requests| requests.get("storage")) - }) - .map(|quantity| quantity.0.clone()) - .unwrap_or_default(); - item.status = persistent_volume_claim - .status - .as_ref() - .and_then(|status| status.phase.clone()) - .unwrap_or_default() - .to_lowercase(); - item -} - -fn config_map_item(config_map: &ConfigMap) -> Item { - let mut item = base_item(config_map); - let data_keys = config_map.data.as_ref().map(|data| data.len()).unwrap_or(0); - let binary_keys = config_map - .binary_data - .as_ref() - .map(|data| data.len()) - .unwrap_or(0); - item.status = "keys".to_string(); - item.capacity = (data_keys + binary_keys).to_string(); - item.version = config_map - .data - .as_ref() - .and_then(|data| data.keys().next().cloned()) - .unwrap_or_default(); - item -} - -fn secret_item(secret: &Secret) -> Item { - let mut item = base_item(secret); - item.status = "keys".to_string(); - item.capacity = secret - .data - .as_ref() - .map(|data| data.len()) - .unwrap_or(0) - .to_string(); - item.version = secret - .data - .as_ref() - .and_then(|data| data.keys().next().cloned()) - .unwrap_or_default(); - item -} - -fn stateful_set_item(stateful_set: &StatefulSet) -> Item { - let mut item = base_item(stateful_set); - let desired = stateful_set - .spec - .as_ref() - .and_then(|spec| spec.replicas) - .unwrap_or(0); - let ready = stateful_set - .status - .as_ref() - .and_then(|status| status.ready_replicas) - .unwrap_or(0); - item.ready = format!("{ready}/{desired}"); - item.capacity = desired.to_string(); - item.status = if ready >= desired { - "ready" - } else { - "progressing" - } - .to_string(); - item.version = version_label(stateful_set.metadata.labels.as_ref()); - item -} - -fn replica_set_item(replica_set: &ReplicaSet) -> Item { - let mut item = base_item(replica_set); - let desired = replica_set - .spec - .as_ref() - .and_then(|spec| spec.replicas) - .unwrap_or(0); - let ready = replica_set - .status - .as_ref() - .and_then(|status| status.ready_replicas) - .unwrap_or(0); - item.ready = format!("{ready}/{desired}"); - item.capacity = desired.to_string(); - item.status = if ready >= desired { - "ready" - } else { - "progressing" - } - .to_string(); - item.version = version_label(replica_set.metadata.labels.as_ref()); - item -} - -fn replication_controller_item(controller: &ReplicationController) -> Item { - let mut item = base_item(controller); - let desired = controller - .spec - .as_ref() - .and_then(|spec| spec.replicas) - .unwrap_or(0); - let ready = controller - .status - .as_ref() - .and_then(|status| status.ready_replicas) - .unwrap_or(0); - item.ready = format!("{ready}/{desired}"); - item.capacity = desired.to_string(); - item.status = if ready >= desired { - "ready" - } else { - "progressing" - } - .to_string(); - item.version = version_label(controller.metadata.labels.as_ref()); - item -} - -fn horizontal_pod_autoscaler_item(autoscaler: &HorizontalPodAutoscaler) -> Item { - let mut item = base_item(autoscaler); - let spec = autoscaler.spec.as_ref(); - let max = spec.map(|spec| spec.max_replicas).unwrap_or(0); - let min = spec.and_then(|spec| spec.min_replicas).unwrap_or(0); - let current = autoscaler - .status - .as_ref() - .and_then(|status| status.current_replicas) - .unwrap_or(0); - item.ready = format!("{current}/{min}"); - item.capacity = max.to_string(); - item.status = if current >= min { "active" } else { "idle" }.to_string(); - item.version = spec - .map(|spec| { - format!( - "{}/{}", - spec.scale_target_ref.kind, spec.scale_target_ref.name - ) - }) - .unwrap_or_default(); - item -} - -fn pod_disruption_budget_item(budget: &PodDisruptionBudget) -> Item { - let mut item = base_item(budget); - let status = budget.status.as_ref(); - let current = status.map(|status| status.current_healthy).unwrap_or(0); - let desired = status.map(|status| status.desired_healthy).unwrap_or(0); - let allowed = status.map(|status| status.disruptions_allowed).unwrap_or(0); - item.ready = format!("{current}/{desired}"); - item.capacity = allowed.to_string(); - item.status = if allowed > 0 { "ready" } else { "blocked" }.to_string(); - item -} - -fn lease_item(lease: &Lease) -> Item { - let mut item = base_item(lease); - let spec = lease.spec.as_ref(); - item.version = spec - .and_then(|spec| spec.holder_identity.clone()) - .unwrap_or_default(); - item.capacity = spec - .and_then(|spec| spec.lease_duration_seconds) - .map(|seconds| format!("{seconds}s")) - .unwrap_or_default(); - item.status = "active".to_string(); - item -} - -fn endpoints_item(endpoints: &Endpoints) -> Item { - let mut item = base_item(endpoints); - let addresses = endpoints - .subsets - .as_ref() - .map(|subsets| { - subsets - .iter() - .map(|subset| { - subset.addresses.as_ref().map(Vec::len).unwrap_or(0) - + subset - .not_ready_addresses - .as_ref() - .map(Vec::len) - .unwrap_or(0) - }) - .sum::() - }) - .unwrap_or(0); - item.capacity = addresses.to_string(); - item.status = "active".to_string(); - item -} - -fn endpoint_slice_item(slice: &EndpointSlice) -> Item { - let mut item = base_item(slice); - item.capacity = slice.endpoints.len().to_string(); - item.version = slice.address_type.clone(); - item.status = "active".to_string(); - item -} - -fn network_policy_item(policy: &NetworkPolicy) -> Item { - let mut item = base_item(policy); - item.capacity = policy - .spec - .as_ref() - .and_then(|spec| spec.policy_types.as_ref()) - .map(|types| types.join(", ")) - .unwrap_or_default(); - item.status = "active".to_string(); - item -} - -fn event_item(event: &Event) -> Item { - let mut item = base_item(event); - item.status = event - .type_ - .clone() - .unwrap_or_else(|| "normal".to_string()) - .to_lowercase(); - item.version = event.reason.clone().unwrap_or_default(); - item.capacity = event.count.unwrap_or(1).to_string(); - item -} - -fn cluster_role_item(cluster_role: &ClusterRole) -> Item { - let mut item = base_item(cluster_role); - item.capacity = cluster_role - .rules - .as_ref() - .map(|rules| rules.len()) - .unwrap_or(0) - .to_string(); - item.status = "active".to_string(); - item -} - -fn role_item(role: &Role) -> Item { - let mut item = base_item(role); - item.capacity = role - .rules - .as_ref() - .map(|rules| rules.len()) - .unwrap_or(0) - .to_string(); - item.status = "active".to_string(); - item -} - -fn role_binding_item(binding: &RoleBinding) -> Item { - let mut item = base_item(binding); - item.capacity = binding - .subjects - .as_ref() - .map(|subjects| subjects.len()) - .unwrap_or(0) - .to_string(); - item.version = format!("{}/{}", binding.role_ref.kind, binding.role_ref.name); - item.status = "active".to_string(); - item -} - -fn custom_item(object: &DynamicObject) -> Item { - let mut item = base_item(object); - let Some(status) = object.data.get("status") else { - return item; - }; - if let Some(phase) = status.get("phase").and_then(serde_json::Value::as_str) { - item.status = phase.to_lowercase(); - } else if let Some(conditions) = status - .get("conditions") - .and_then(serde_json::Value::as_array) - { - let ready = conditions.iter().find(|condition| { - condition.get("type").and_then(serde_json::Value::as_str) == Some("Ready") - }); - if let Some(ready) = ready { - item.status = - if ready.get("status").and_then(serde_json::Value::as_str) == Some("True") { - "ready" - } else { - "notready" - } - .to_string(); - } - } - item -} - -fn cron_job_item(cron_job: &CronJob) -> Item { - let mut item = base_item(cron_job); - let active = cron_job - .status - .as_ref() - .and_then(|status| status.active.as_ref()) - .map(|active| active.len()) - .unwrap_or(0); - let suspended = cron_job - .spec - .as_ref() - .and_then(|spec| spec.suspend) - .unwrap_or(false); - item.capacity = active.to_string(); - item.status = if suspended { "suspended" } else { "active" }.to_string(); - item.version = cron_job - .spec - .as_ref() - .map(|spec| spec.schedule.clone()) - .unwrap_or_default(); - item -} - -fn service_account_item(service_account: &ServiceAccount) -> Item { - let mut item = base_item(service_account); - let secrets = service_account.secrets.as_ref().map(Vec::len).unwrap_or(0); - item.status = "account".to_string(); - item.capacity = secrets.to_string(); - item.version = service_account - .secrets - .as_ref() - .and_then(|secrets| secrets.first()) - .and_then(|secret| secret.name.clone()) - .unwrap_or_default(); - item -} - -fn storage_class_item(storage_class: &StorageClass) -> Item { - let mut item = base_item(storage_class); - item.version = storage_class.provisioner.clone(); - item.capacity = storage_class.reclaim_policy.clone().unwrap_or_default(); - item.ready = storage_class - .volume_binding_mode - .clone() - .unwrap_or_default(); - item -} - -fn namespace_item(namespace: &Namespace) -> Item { - let mut item = base_item(namespace); - item.status = namespace - .status - .as_ref() - .and_then(|status| status.phase.clone()) - .unwrap_or_else(|| "unknown".to_string()) - .to_lowercase(); - item -} - -fn node_item(node: &Node) -> Item { - let mut item = base_item(node); - let status = node.status.as_ref(); - let ready = status - .and_then(|status| status.conditions.as_ref()) - .and_then(|conditions| { - conditions - .iter() - .find(|condition| condition.type_ == "Ready") - }) - .map(|condition| condition.status == "True") - .unwrap_or(false); - item.status = if ready { "ready" } else { "notready" }.to_string(); - item.capacity = status - .and_then(|status| status.allocatable.as_ref()) - .and_then(|allocatable| allocatable.get("cpu")) - .map(|quantity| quantity.0.clone()) - .unwrap_or_default(); - item.version = status - .and_then(|status| status.node_info.as_ref()) - .map(|info| info.kubelet_version.clone()) - .unwrap_or_default(); - item -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn watch_denied_registry_roundtrip() { - // Unique epoch so the process-global registry is not disturbed. - let epoch = u64::MAX; - assert!(!watch_denied(epoch, ResourceKind::Pods)); - mark_watch_denied(epoch, ResourceKind::Pods); - assert!(watch_denied(epoch, ResourceKind::Pods)); - assert!(!watch_denied(epoch, ResourceKind::Nodes)); - clear_watch_denied(epoch, ResourceKind::Pods); - assert!(!watch_denied(epoch, ResourceKind::Pods)); - } - - #[test] - fn poll_backoff_doubles_until_the_cap() { - assert_eq!( - poll_delay(PollRate::Every(5), 0), - Some(Duration::from_secs(5)) - ); - assert_eq!( - poll_delay(PollRate::Every(5), 1), - Some(Duration::from_secs(10)) - ); - assert_eq!( - poll_delay(PollRate::Every(5), 2), - Some(Duration::from_secs(20)) - ); - assert_eq!( - poll_delay(PollRate::Every(5), 3), - Some(Duration::from_secs(40)) - ); - assert_eq!( - poll_delay(PollRate::Every(10), 4), - Some(Duration::from_secs(60)) - ); - assert_eq!(poll_delay(PollRate::Off, 3), None); - } - - #[test] - fn streaming_lists_require_kubernetes_1_27() { - assert!(!version_supports_streaming("1", "26")); - assert!(version_supports_streaming("1", "27")); - assert!(version_supports_streaming("1", "37")); - assert!(version_supports_streaming("1", "37+")); - assert!(version_supports_streaming("2", "0")); - assert!(!version_supports_streaming("0", "99")); - } - - #[test] - fn only_the_initial_events_bookmark_ends_the_stream() { - let mut annotations = std::collections::BTreeMap::new(); - assert!(!bookmark_ends_initial_events(&annotations)); - annotations.insert("k8s.io/initial-events-end".into(), "false".into()); - assert!(!bookmark_ends_initial_events(&annotations)); - annotations.insert("k8s.io/initial-events-end".into(), "true".into()); - assert!(bookmark_ends_initial_events(&annotations)); - } -} diff --git a/src/repository/retry.rs b/src/repository/retry.rs index 3a41a48..b6217b2 100644 --- a/src/repository/retry.rs +++ b/src/repository/retry.rs @@ -28,51 +28,6 @@ pub enum RetryAction { GiveUp, } -/// Longest a single list/watch-establishment request may take before it feeds -/// the retry policy. kube leaves `read_timeout` unset, so without this a -/// stalled connection hangs until the OS gives up on the TCP connection -/// (~40s+). Watch streams and log follows are unaffected. -pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); - -/// Run one API request with a timeout so a stalled connection fails fast. -pub async fn bounded( - request: impl std::future::Future>, -) -> Result { - match tokio::time::timeout(REQUEST_TIMEOUT, request).await { - Ok(Ok(value)) => Ok(value), - Ok(Err(error)) => Err(RequestFailure::Api(error)), - Err(_) => Err(RequestFailure::TimedOut), - } -} - -/// A failed Kubernetes API request. -#[derive(Debug)] -pub enum RequestFailure { - /// The API server answered with an error status. - Api(kube::Error), - /// The request produced nothing within [`REQUEST_TIMEOUT`]. - TimedOut, -} - -impl RequestFailure { - /// Classify into the retry policy; timeouts are always soft. - pub fn action(&self, retry: &mut Retry) -> RetryAction { - match self { - Self::Api(error) => retry.failed(error), - Self::TimedOut => retry.failed_soft(), - } - } -} - -impl std::fmt::Display for RequestFailure { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Api(error) => write!(f, "{error}"), - Self::TimedOut => write!(f, "no response for {}s", REQUEST_TIMEOUT.as_secs()), - } - } -} - /// True when the HTTP status will never succeed on retry for a list/watch. pub fn is_hard_status(code: u16) -> bool { matches!(code, 400 | 401 | 403 | 404 | 405 | 422) @@ -118,6 +73,7 @@ impl Retry { } /// Decide what to do after a watch `Error` event carrying only a status. + #[allow(dead_code)] pub fn failed_status(&mut self, code: u16) -> RetryAction { self.fail(is_hard_status(code)) } @@ -229,17 +185,4 @@ mod tests { assert!(!is_hard_status(code), "{code} should be soft"); } } - - #[test] - fn timeouts_are_soft_and_back_off() { - let mut retry = Retry::new(); - assert_eq!( - RequestFailure::TimedOut.action(&mut retry), - RetryAction::Retry(Duration::from_millis(500)) - ); - assert_eq!( - RequestFailure::TimedOut.action(&mut retry), - RetryAction::Retry(Duration::from_secs(1)) - ); - } } diff --git a/src/repository/store/data.rs b/src/repository/store/data.rs new file mode 100644 index 0000000..d891823 --- /dev/null +++ b/src/repository/store/data.rs @@ -0,0 +1,534 @@ +//! Incrementally maintained full-object store for one resource kind. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; + +use crate::models::{ObjectKey, SnapshotPhase, WatchMode}; + +use super::record::KubeObjectRecord; + +/// Immutable store state published after a flush. +pub struct StoreSnapshot { + revision: u64, + phase: SnapshotPhase, + mode: WatchMode, + objects: Arc<[Arc]>, + /// Last revision at which each namespace changed. `All` views use the + /// global revision; `One(ns)` views can skip unrelated namespaces. + namespace_revisions: Arc>, +} + +impl Clone for StoreSnapshot { + fn clone(&self) -> Self { + Self { + revision: self.revision, + phase: self.phase.clone(), + mode: self.mode, + objects: self.objects.clone(), + namespace_revisions: self.namespace_revisions.clone(), + } + } +} + +impl std::fmt::Debug for StoreSnapshot { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("StoreSnapshot") + .field("revision", &self.revision) + .field("phase", &self.phase) + .field("mode", &self.mode) + .field("objects", &self.objects.len()) + .finish_non_exhaustive() + } +} + +impl StoreSnapshot { + pub fn phase(&self) -> &SnapshotPhase { + &self.phase + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn mode(&self) -> WatchMode { + self.mode + } + + pub fn objects(&self) -> &[Arc] { + &self.objects + } + + pub fn object(&self, key: &ObjectKey) -> Option<&Arc> { + let index = self + .objects + .binary_search_by(|record| record.key.cmp(key)) + .ok()?; + self.objects.get(index) + } + + pub fn namespace_revision(&self, namespace: &str) -> Option { + self.namespace_revisions.get(namespace).copied() + } + + #[cfg(test)] + pub fn summary_items(&self) -> Arc<[Arc]> { + self.objects + .iter() + .map(|record| record.summary.clone()) + .collect() + } +} + +/// Authoritative object collection for one kind. Owned by the store task and +/// mutated only from that task, so no shared lock is needed on the hot path. +#[derive(Debug, Default)] +pub struct StoreData { + objects: BTreeMap>, + by_uid: HashMap, + pending: usize, + revision: u64, + namespace_revisions: BTreeMap, + changed_namespaces: HashSet, +} + +impl StoreData { + pub fn new() -> Self { + Self::default() + } + + pub fn pending(&self) -> usize { + self.pending + } + + pub fn is_empty(&self) -> bool { + self.objects.is_empty() + } + + #[cfg(test)] + pub fn get_by_uid(&self, uid: &str) -> Option<&Arc> { + self.by_uid.get(uid).and_then(|key| self.objects.get(key)) + } + + /// Insert or replace an object. A repeated `(key, uid, resourceVersion)` is + /// a no-op, which makes poll relists and duplicate watch events cheap. + pub fn upsert(&mut self, record: KubeObjectRecord) -> bool { + let key = record.key.clone(); + if let Some(existing) = self.objects.get(&key) + && !record.resource_version.is_empty() + && existing.uid == record.uid + && existing.resource_version == record.resource_version + { + return false; + } + + let record = Arc::new(record); + if let Some(existing) = self.objects.get(&key) + && !existing.uid.is_empty() + && existing.uid != record.uid + { + self.by_uid.remove(&existing.uid); + } + + if !record.uid.is_empty() + && let Some(previous) = self.by_uid.insert(record.uid.clone(), key.clone()) + && previous != key + { + self.objects.remove(&previous); + } + + self.objects.insert(key.clone(), record); + self.note_changed(&key); + true + } + + pub fn remove(&mut self, key: &ObjectKey) -> bool { + let Some(record) = self.objects.remove(key) else { + return false; + }; + + if !record.uid.is_empty() { + self.by_uid.remove(&record.uid); + } + + self.note_changed(key); + true + } + + /// Replace one namespace with the result of a namespace-scoped list. + /// Objects in other namespaces are retained, matching Freelens `mergeItems`. + pub fn replace_namespace(&mut self, namespace: &str, records: I) -> bool + where + I: IntoIterator, + { + let mut seen = HashSet::new(); + let mut changed = false; + + for record in records { + seen.insert(record.key.clone()); + changed |= self.upsert(record); + } + + let stale: Vec = self + .objects + .range(namespace_range(namespace)) + .filter(|(key, _)| !seen.contains(*key)) + .map(|(key, _)| key.clone()) + .collect(); + + for key in stale { + changed |= self.remove(&key); + } + + if changed { + self.changed_namespaces.insert(namespace.to_string()); + } + changed + } + + /// Replace the whole store with the result of an all-namespace list. + pub fn replace_all(&mut self, records: I) -> bool + where + I: IntoIterator, + { + let mut seen = HashSet::new(); + let mut changed = false; + + for record in records { + seen.insert(record.key.clone()); + changed |= self.upsert(record); + } + + let stale: Vec = self + .objects + .keys() + .filter(|key| !seen.contains(*key)) + .cloned() + .collect(); + + for key in stale { + changed |= self.remove(&key); + } + + changed + } + + /// Remove the oldest objects until at most `max_items` remain. + pub fn trim_oldest(&mut self, max_items: usize) -> bool { + if self.objects.len() <= max_items { + return false; + } + + let mut by_age: Vec<(ObjectKey, i64)> = self + .objects + .iter() + .map(|(key, record)| (key.clone(), record.summary.created_at.unwrap_or(i64::MIN))) + .collect(); + by_age.sort_by_key(|(_, created_at)| *created_at); + + let remove = self.objects.len() - max_items; + let mut changed = false; + for (key, _) in by_age.into_iter().take(remove) { + changed |= self.remove(&key); + } + changed + } + + /// Produce an immutable snapshot and advance the revision if anything + /// changed since the previous snapshot. + pub fn snapshot(&mut self, phase: SnapshotPhase, mode: WatchMode) -> StoreSnapshot { + let changed = self.pending > 0; + if changed { + self.revision = self.revision.wrapping_add(1); + for namespace in self.changed_namespaces.drain() { + self.namespace_revisions.insert(namespace, self.revision); + } + } + + self.pending = 0; + StoreSnapshot { + revision: self.revision, + phase, + mode, + objects: self.objects.values().cloned().collect(), + namespace_revisions: Arc::new(self.namespace_revisions.clone()), + } + } + + fn note_changed(&mut self, key: &ObjectKey) { + self.pending = self.pending.saturating_add(1); + self.changed_namespaces.insert(key.namespace.clone()); + } +} + +fn namespace_range(namespace: &str) -> impl std::ops::RangeBounds { + use std::ops::Bound::{Excluded, Included}; + + ( + Included(ObjectKey::new(namespace, "")), + Excluded(ObjectKey::new(namespace, "\u{10FFFF}")), + ) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use kube::ResourceExt; + + use super::*; + use crate::models::Item; + use crate::models::{NamespaceScope, WatchMode}; + use crate::repository::store::StoreView; + + fn record(name: &str, namespace: &str, uid: &str, version: &str) -> KubeObjectRecord { + let object: kube::api::DynamicObject = serde_json::from_value(json!({ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": name, + "namespace": namespace, + "uid": uid, + "resourceVersion": version, + "labels": { "app": "demo" }, + "ownerReferences": [ + { "apiVersion": "apps/v1", "kind": "ReplicaSet", "name": "demo", "uid": "owner-1" } + ] + } + })) + .expect("dynamic object"); + + let mut item = Item::new(name.to_string(), Some(namespace.to_string())); + item.uid = uid.to_string(); + item.resource_version = version.to_string(); + item.status = "running".to_string(); + KubeObjectRecord::new(object, item) + } + + #[test] + fn stores_full_objects_and_summaries() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(snapshot.objects().len(), 1); + assert_eq!(snapshot.summary_items().len(), 1); + assert_eq!( + snapshot + .object(&ObjectKey::new("ns-a", "pod-a")) + .unwrap() + .uid, + "uid-a" + ); + assert_eq!( + snapshot + .object(&ObjectKey::new("ns-a", "pod-a")) + .unwrap() + .typed::() + .unwrap() + .name_any(), + "pod-a" + ); + let object = snapshot + .object(&ObjectKey::new("ns-a", "pod-a")) + .unwrap() + .typed::() + .unwrap(); + assert_eq!( + object + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get("app")) + .map(String::as_str), + Some("demo") + ); + } + + #[test] + fn unchanged_resource_version_is_a_no_op() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "10")); + let _ = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + + let mut same = record("pod-a", "ns-a", "uid-a", "10"); + same.summary = Arc::new(Item::new("pod-a".into(), Some("ns-a".into()))); + assert!(!data.upsert(same)); + assert_eq!(data.pending(), 0); + } + + #[test] + fn replacing_a_namespace_keeps_other_namespaces() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + data.upsert(record("pod-b", "ns-b", "uid-b", "1")); + data.upsert(record("pod-c", "ns-a", "uid-c", "1")); + + assert!(data.replace_namespace( + "ns-a", + [ + record("pod-a", "ns-a", "uid-a", "2"), + record("pod-d", "ns-a", "uid-d", "1") + ] + )); + + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + let names: Vec<_> = snapshot + .objects() + .iter() + .map(|record| (record.key.namespace.as_str(), record.key.name.as_str())) + .collect(); + assert_eq!( + names, + [("ns-a", "pod-a"), ("ns-a", "pod-d"), ("ns-b", "pod-b")] + ); + assert!( + snapshot.object(&ObjectKey::new("ns-a", "pod-c")).is_none(), + "objects absent from the namespace list are removed" + ); + } + + #[test] + fn replacing_all_marks_each_namespace_that_changed() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + + assert!(data.replace_all([ + record("pod-b", "ns-a", "uid-b", "1"), + record("pod-c", "ns-b", "uid-c", "1"), + ])); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + + assert_eq!(snapshot.namespace_revision("ns-a"), Some(1)); + assert_eq!(snapshot.namespace_revision("ns-b"), Some(1)); + + let mut ns_a = crate::repository::store::StoreView::new(NamespaceScope::One("ns-a".into())); + assert!(ns_a.apply(&snapshot).is_some()); + assert_eq!(ns_a.len(), 1); + } + + #[test] + fn replacing_all_removes_everything_absent() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + data.upsert(record("pod-b", "ns-b", "uid-b", "1")); + + assert!(data.replace_all([record("pod-c", "ns-c", "uid-c", "1")])); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(snapshot.objects().len(), 1); + assert_eq!(snapshot.objects()[0].key, ObjectKey::new("ns-c", "pod-c")); + } + + #[test] + fn uid_index_follows_replaced_objects() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-old", "1")); + assert!(data.get_by_uid("uid-old").is_some()); + + data.upsert(record("pod-a", "ns-a", "uid-new", "2")); + assert!(data.get_by_uid("uid-old").is_none()); + assert!(data.get_by_uid("uid-new").is_some()); + } + + #[test] + fn trims_the_oldest_objects_first() { + let mut data = StoreData::new(); + for (name, age) in [("a", 10), ("b", 30), ("c", 20)] { + let mut record = record(name, "ns", &format!("uid-{name}"), "1"); + record.summary = Arc::new({ + let mut item = Item::new(name.to_string(), Some("ns".to_string())); + item.created_at = Some(age); + item + }); + data.upsert(record); + } + + assert!(data.trim_oldest(2)); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + let names: Vec<_> = snapshot + .objects() + .iter() + .map(|record| record.key.name.as_str()) + .collect(); + assert_eq!(names, ["b", "c"]); + } + + #[test] + fn revision_advances_only_when_a_flush_changed_something() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + let first = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(first.revision(), 1); + + let second = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(second.revision(), 1); + assert_eq!(second.objects().len(), 1); + + data.upsert(record("pod-b", "ns-a", "uid-b", "1")); + let third = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(third.revision(), 2); + } + + #[test] + fn snapshot_carries_namespace_revisions() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + data.upsert(record("pod-b", "ns-b", "uid-b", "1")); + + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(snapshot.namespace_revision("ns-a"), Some(1)); + assert_eq!(snapshot.namespace_revision("ns-b"), Some(1)); + + data.upsert(record("pod-c", "ns-a", "uid-c", "1")); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(snapshot.namespace_revision("ns-a"), Some(2)); + assert_eq!(snapshot.namespace_revision("ns-b"), Some(1)); + } + + #[test] + fn snapshot_exposes_scope_and_summary() { + let mut data = StoreData::new(); + data.upsert(record("pod-a", "ns-a", "uid-a", "1")); + let snapshot = data.snapshot(SnapshotPhase::Error("offline".into()), WatchMode::Watch); + assert_eq!(snapshot.phase(), &SnapshotPhase::Error("offline".into())); + assert_eq!(snapshot.mode(), WatchMode::Watch); + assert_eq!(snapshot.summary_items().len(), 1); + } + + #[test] + fn namespace_range_contains_only_the_requested_namespace() { + let mut data = StoreData::new(); + data.upsert(record("a", "ns-1", "uid-1", "1")); + data.upsert(record("b", "ns-2", "uid-2", "1")); + data.upsert(record("c", "ns-10", "uid-3", "1")); + + assert!(data.replace_namespace("ns-1", [record("a", "ns-1", "uid-1", "2")])); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert_eq!(snapshot.objects().len(), 3); + assert_eq!( + snapshot + .object(&ObjectKey::new("ns-1", "a")) + .expect("ns-1 object") + .resource_version, + "2" + ); + } + + #[test] + fn store_view_filters_by_scope() { + let mut data = StoreData::new(); + data.upsert(record("a", "ns-1", "uid-1", "1")); + data.upsert(record("b", "ns-2", "uid-2", "1")); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + + let mut all = StoreView::new(NamespaceScope::All); + let mut one = StoreView::new(NamespaceScope::One("ns-2".into())); + let all = all.apply(&snapshot).unwrap(); + let one = one.apply(&snapshot).unwrap(); + assert_eq!(all.items().len(), 2); + assert_eq!(one.items().len(), 1); + assert_eq!(one.items()[0].name, "b"); + } +} diff --git a/src/repository/store/engine.rs b/src/repository/store/engine.rs new file mode 100644 index 0000000..2f474b7 --- /dev/null +++ b/src/repository/store/engine.rs @@ -0,0 +1,1048 @@ +//! Shared store engine for every built-in Kubernetes kind. +//! +//! The engine keeps the Freelens ownership model: one store per cluster epoch +//! and kind, a refcounted watch set, and 1-second coalesced snapshots. Typed +//! list/watch dispatch stays per kind; this module owns the shared lifecycle. + +use std::collections::{BTreeMap, HashMap}; +use std::fmt::Debug; +use std::sync::Arc; +use std::time::Duration; + +use futures::TryStreamExt as _; +use k8s_openapi::api::apps::v1::{DaemonSet, Deployment, ReplicaSet, StatefulSet}; +use k8s_openapi::api::autoscaling::v2::HorizontalPodAutoscaler; +use k8s_openapi::api::batch::v1::{CronJob, Job}; +use k8s_openapi::api::coordination::v1::Lease; +use k8s_openapi::api::core::v1::{ + ConfigMap, Endpoints, Event, Namespace, Node, PersistentVolume, PersistentVolumeClaim, Pod, + ReplicationController, Secret, Service, ServiceAccount, +}; +use k8s_openapi::api::discovery::v1::EndpointSlice; +use k8s_openapi::api::networking::v1::{Ingress, NetworkPolicy}; +use k8s_openapi::api::policy::v1::PodDisruptionBudget; +use k8s_openapi::api::rbac::v1::{ClusterRole, Role, RoleBinding}; +use k8s_openapi::api::storage::v1::StorageClass; +use kube::api::{Api, DynamicObject, ListParams, WatchEvent, WatchParams}; +use kube::core::{ApiResource, GroupVersionKind, NamespaceResourceScope}; +use kube::{Client, Resource, ResourceExt}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use tokio::sync::{mpsc, watch}; + +use crate::models::{Item, NamespaceScope, ObjectKey, ResourceKind, SnapshotPhase, WatchMode}; +use crate::repository::Subscription; +use crate::repository::store::mapping::{ + cluster_role_item, config_map_item, cron_job_item, custom_item, daemon_set_item, + deployment_item, endpoint_slice_item, endpoints_item, event_item, + horizontal_pod_autoscaler_item, ingress_item, job_item, lease_item, namespace_item, + network_policy_item, node_item, pod_disruption_budget_item, pod_item, pv_item, pvc_item, + replica_set_item, replication_controller_item, role_binding_item, role_item, secret_item, + service_account_item, service_item, stateful_set_item, storage_class_item, +}; +use crate::repository::store::{ + ClusterCapabilities, KubeObjectRecord, StoreData, StoreSnapshot, StoreView, StoreWatchPlan, + watch_plan, +}; + +const EVENT_FLUSH_INTERVAL: Duration = Duration::from_secs(1); +const WATCH_TIMEOUT_SECS: u32 = 280; +const RECONNECT_DELAY: Duration = Duration::from_millis(500); +const WATCH_DENIED_RETRY: Duration = Duration::from_secs(5); +const STREAM_INITIAL_IDLE_TIMEOUT: Duration = Duration::from_secs(5); +const STREAM_INITIAL_TOTAL_TIMEOUT: Duration = Duration::from_secs(60); +const STORE_ITEM_CAP: usize = 50_000; + +type ApiFactory = Arc Api + Send + Sync>; + +/// Handle used by the repository registry and by view subscriptions. +pub(crate) struct KubeObjectStoreHandle { + commands: mpsc::UnboundedSender, + snapshots: watch::Sender>, +} + +impl KubeObjectStoreHandle { + pub(crate) fn object(&self, key: &ObjectKey) -> Option> { + self.snapshots.borrow().object(key).cloned() + } + + pub(crate) fn snapshot_receiver(&self) -> watch::Receiver> { + self.snapshots.subscribe() + } + + pub(crate) fn subscribe( + &self, + scope: NamespaceScope, + tokio: &tokio::runtime::Handle, + ) -> Subscription { + let _ = self.commands.send(Command::Subscribe(scope.clone())); + + let mut view = StoreView::new(scope.clone()); + let current = self.snapshots.borrow().clone(); + let initial = match view.apply(¤t) { + Some(snapshot) => snapshot, + None => view.snapshot(), + }; + let (view_tx, view_rx) = watch::channel(initial); + let mut store_rx = self.snapshots.subscribe(); + let (cancel, mut cancel_rx) = watch::channel(false); + + let lease = Arc::new(StoreLease { + cancel, + commands: self.commands.clone(), + scope, + }); + let (paused, paused_rx) = watch::channel(false); + let refresh_commands = self.commands.clone(); + let refresh = Arc::new(move || { + let _ = refresh_commands.send(Command::Refresh); + }); + + tokio.spawn(async move { + loop { + tokio::select! { + _ = cancel_rx.changed() => break, + result = store_rx.changed() => { + if result.is_err() { + break; + } + let snapshot = store_rx.borrow_and_update().clone(); + if let Some(next) = view.apply(&snapshot) { + view_tx.send_replace(next); + } + } + } + } + }); + + Subscription::new(view_rx, paused, paused_rx, refresh, lease) + } +} + +struct StoreLease { + cancel: watch::Sender, + commands: mpsc::UnboundedSender, + scope: NamespaceScope, +} + +impl Drop for StoreLease { + fn drop(&mut self) { + let _ = self.cancel.send(true); + let _ = self.commands.send(Command::Unsubscribe(self.scope.clone())); + } +} + +enum Command { + Subscribe(NamespaceScope), + Unsubscribe(NamespaceScope), + Refresh, +} + +enum InitialStream { + Listed { resource_version: String }, + Unsupported, + Cancelled, +} + +enum StoreEvent { + Replace(NamespaceScope, Vec), + Upsert(KubeObjectRecord), + Remove(ObjectKey), + Error(NamespaceScope, String), + WatchError(NamespaceScope, String), +} + +struct StoreTask +where + K: Resource + Clone + Debug + DeserializeOwned + Serialize + Send + Sync + 'static, + F: Fn(&K) -> Item + Copy + Send + Sync + 'static, +{ + client: Client, + tokio: tokio::runtime::Handle, + epoch: u64, + kind: ResourceKind, + global_watch: bool, + data: StoreData, + scopes: HashMap, + plan: Option, + watches: Vec>, + events: mpsc::UnboundedSender, + event_rx: mpsc::UnboundedReceiver, + command_rx: mpsc::UnboundedReceiver, + snapshots: watch::Sender>, + phase: SnapshotPhase, + map: F, + make_api: ApiFactory, +} + +impl StoreTask +where + K: Resource + + ResourceExt + + Clone + + Debug + + DeserializeOwned + + Serialize + + Send + + Sync + + 'static + + crate::repository::store::StoredObject, + F: Fn(&K) -> Item + Copy + Send + Sync + 'static, +{ + async fn run(mut self) { + let mut ticker = tokio::time::interval(EVENT_FLUSH_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + ticker.tick().await; + + loop { + tokio::select! { + command = self.command_rx.recv() => { + let Some(command) = command else { break }; + self.handle_command(command); + } + event = self.event_rx.recv() => { + let Some(event) = event else { break }; + self.handle_event(event); + } + _ = ticker.tick() => { + self.flush(false); + } + } + } + + self.stop_watches(); + } + + fn handle_command(&mut self, command: Command) { + match command { + Command::Subscribe(scope) => { + *self.scopes.entry(scope).or_default() += 1; + self.reconcile_watches(); + } + Command::Unsubscribe(scope) => { + let remove = if let Some(count) = self.scopes.get_mut(&scope) { + if *count > 1 { + *count -= 1; + false + } else { + true + } + } else { + false + }; + if remove { + self.scopes.remove(&scope); + } + self.reconcile_watches(); + } + Command::Refresh => { + let plan = self.plan.clone().unwrap_or_else(|| self.compute_plan()); + self.restart_watches(plan); + } + } + } + + fn handle_event(&mut self, event: StoreEvent) { + match event { + StoreEvent::Replace(scope, records) => { + let changed = match &scope { + NamespaceScope::All => self.data.replace_all(records), + NamespaceScope::One(namespace) => { + self.data.replace_namespace(namespace, records) + } + }; + let trimmed = self.data.trim_oldest(STORE_ITEM_CAP); + tracing::trace!( + kind = ?self.kind, + ?scope, + changed, + trimmed, + "store: list replaced" + ); + self.phase = SnapshotPhase::Ready; + self.flush(true); + } + StoreEvent::Upsert(record) => { + self.data.upsert(record); + self.phase = SnapshotPhase::Ready; + self.flush(false); + } + StoreEvent::Remove(key) => { + self.data.remove(&key); + self.phase = SnapshotPhase::Ready; + self.flush(false); + } + StoreEvent::Error(scope, message) => { + tracing::warn!(kind = ?self.kind, ?scope, %message, "store: list error"); + self.phase = SnapshotPhase::Error(message); + self.flush(true); + } + StoreEvent::WatchError(scope, message) => { + tracing::warn!(kind = ?self.kind, ?scope, %message, "store: watch error"); + if self.data.is_empty() { + self.phase = SnapshotPhase::Error(message); + self.flush(true); + } + // With data from the initial list, watch failures are + // transient: keep serving the cached rows and retry. + } + } + } + + fn reconcile_watches(&mut self) { + let plan = self.compute_plan(); + + if self.scopes.is_empty() { + self.plan = Some(plan); + self.stop_watches(); + return; + } + + if self.plan.as_ref() != Some(&plan) { + self.plan = Some(plan.clone()); + self.restart_watches(plan); + } else if self.watches.is_empty() { + self.restart_watches(plan); + } + } + + fn compute_plan(&self) -> StoreWatchPlan { + let global = if !self.kind.is_namespaced() { + true + } else { + self.global_watch + }; + watch_plan( + self.scopes.keys().cloned(), + &ClusterCapabilities { + global_watch: global, + accessible_namespaces: Vec::new(), + }, + ) + } + + fn restart_watches(&mut self, plan: StoreWatchPlan) { + self.stop_watches(); + + match plan { + StoreWatchPlan::All => { + self.spawn_watch(NamespaceScope::All); + } + StoreWatchPlan::PerNamespace(namespaces) => { + for namespace in namespaces { + self.spawn_watch(NamespaceScope::One(namespace)); + } + } + StoreWatchPlan::UnavailableAll => { + self.phase = SnapshotPhase::Error(format!( + "Cluster-wide {} watch is not permitted; select a namespace.", + self.kind.label() + )); + self.flush(true); + } + } + } + + fn spawn_watch(&mut self, scope: NamespaceScope) { + let (cancel, cancel_rx) = watch::channel(false); + let events = self.events.clone(); + let client = self.client.clone(); + let map = self.map; + let make_api = self.make_api.clone(); + self.tokio + .spawn(run_watch(client, scope, events, cancel_rx, map, make_api)); + self.watches.push(cancel); + } + + fn stop_watches(&mut self) { + for cancel in self.watches.drain(..) { + let _ = cancel.send(true); + } + } + + fn flush(&mut self, force: bool) { + if !force && self.data.pending() == 0 { + return; + } + + let snapshot = Arc::new(self.data.snapshot(self.phase.clone(), WatchMode::Watch)); + tracing::trace!( + epoch = self.epoch, + kind = ?self.kind, + revision = snapshot.revision(), + objects = snapshot.objects().len(), + "store: flush" + ); + self.snapshots.send_replace(snapshot); + } +} + +fn bookmark_ends_initial_events(annotations: &BTreeMap) -> bool { + annotations + .get("k8s.io/initial-events-end") + .is_some_and(|value| value == "true") +} + +async fn stream_initial( + api: &Api, + scope: &NamespaceScope, + events: &mpsc::UnboundedSender, + cancel: &mut watch::Receiver, + map: F, +) -> InitialStream +where + K: Resource + + ResourceExt + + Clone + + Debug + + DeserializeOwned + + Serialize + + Send + + Sync + + 'static + + crate::repository::store::StoredObject, + F: Fn(&K) -> Item + Copy + Send + Sync + 'static, +{ + let params = WatchParams::streaming_lists().timeout(WATCH_TIMEOUT_SECS); + let watched = tokio::select! { + _ = cancel.changed() => return InitialStream::Cancelled, + result = api.watch(¶ms, "") => result, + }; + let mut stream = match watched { + Ok(stream) => Box::pin(stream), + Err(_) => return InitialStream::Unsupported, + }; + + let mut records: BTreeMap = BTreeMap::new(); + let mut resource_version = String::new(); + let total_deadline = tokio::time::Instant::now() + STREAM_INITIAL_TOTAL_TIMEOUT; + let mut idle_deadline = tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; + + loop { + let deadline = total_deadline.min(idle_deadline); + tokio::select! { + _ = cancel.changed() => return InitialStream::Cancelled, + _ = tokio::time::sleep_until(deadline) => { + return InitialStream::Unsupported; + } + event = stream.try_next() => match event { + Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { + idle_deadline = + tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; + if let Some(version) = object.resource_version() { + resource_version = version; + } + let record = KubeObjectRecord::new(object.clone(), map(&object)); + records.insert(record.key.clone(), record); + } + Ok(Some(WatchEvent::Deleted(object))) => { + idle_deadline = + tokio::time::Instant::now() + STREAM_INITIAL_IDLE_TIMEOUT; + if let Some(version) = object.resource_version() { + resource_version = version; + } + records.remove(&ObjectKey::new( + object.namespace().unwrap_or_default(), + object.name_any(), + )); + } + Ok(Some(WatchEvent::Bookmark(bookmark))) => { + if !bookmark.metadata.resource_version.is_empty() { + resource_version = bookmark.metadata.resource_version.clone(); + } + if !bookmark_ends_initial_events(&bookmark.metadata.annotations) { + return InitialStream::Unsupported; + } + if events + .send(StoreEvent::Replace( + scope.clone(), + records.into_values().collect(), + )) + .is_err() + { + return InitialStream::Cancelled; + } + return InitialStream::Listed { resource_version }; + } + Ok(Some(WatchEvent::Error(_))) => return InitialStream::Unsupported, + Ok(None) | Err(_) => return InitialStream::Unsupported, + } + } + } +} + +async fn run_watch( + client: Client, + scope: NamespaceScope, + events: mpsc::UnboundedSender, + mut cancel: watch::Receiver, + map: F, + make_api: ApiFactory, +) where + K: Resource + + ResourceExt + + Clone + + Debug + + DeserializeOwned + + Serialize + + Send + + Sync + + 'static + + crate::repository::store::StoredObject, + F: Fn(&K) -> Item + Copy + Send + Sync + 'static, +{ + let api = make_api(&client, &scope); + let mut resource_version = String::new(); + let mut need_list = true; + let mut stream_initial_disabled = false; + + loop { + if need_list { + let mut streamed = false; + if !stream_initial_disabled { + match stream_initial(&api, &scope, &events, &mut cancel, map).await { + InitialStream::Listed { + resource_version: version, + } => { + tracing::debug!(?scope, "store: initial state came from a streaming list"); + resource_version = version; + need_list = false; + streamed = true; + } + InitialStream::Unsupported => { + tracing::debug!( + ?scope, + "store: streaming list unsupported, falling back to list" + ); + stream_initial_disabled = true; + } + InitialStream::Cancelled => return, + } + } + + if !streamed { + let params = ListParams::default(); + let listed = tokio::select! { + _ = cancel.changed() => return, + result = api.list(¶ms) => result, + }; + + match listed { + Ok(list) => { + if let Some(version) = list.metadata.resource_version { + resource_version = version; + } + let records = list + .items + .iter() + .map(|object| KubeObjectRecord::new(object.clone(), map(object))) + .collect(); + if events + .send(StoreEvent::Replace(scope.clone(), records)) + .is_err() + { + return; + } + need_list = false; + } + Err(error) => { + let message = format!("Could not list resources: {error}"); + let _ = events.send(StoreEvent::Error(scope.clone(), message)); + if is_forbidden(&error) { + return; + } + if !sleep_or_cancel(&mut cancel, RECONNECT_DELAY).await { + return; + } + continue; + } + } + } + } + + let params = WatchParams::default().timeout(WATCH_TIMEOUT_SECS); + let watched = tokio::select! { + _ = cancel.changed() => return, + result = api.watch(¶ms, &resource_version) => result, + }; + + let mut stream = match watched { + Ok(stream) => Box::pin(stream), + Err(error) => { + let message = format!("Could not watch resources: {error}"); + let _ = events.send(StoreEvent::WatchError(scope.clone(), message)); + if is_forbidden(&error) { + return; + } + if !sleep_or_cancel(&mut cancel, WATCH_DENIED_RETRY).await { + return; + } + continue; + } + }; + + let mut relist = false; + loop { + let event = tokio::select! { + _ = cancel.changed() => return, + event = stream.try_next() => event, + }; + + match event { + Ok(Some(WatchEvent::Added(object) | WatchEvent::Modified(object))) => { + if let Some(version) = object.resource_version() { + resource_version = version; + } + if events + .send(StoreEvent::Upsert(KubeObjectRecord::new( + object.clone(), + map(&object), + ))) + .is_err() + { + return; + } + } + Ok(Some(WatchEvent::Deleted(object))) => { + if let Some(version) = object.resource_version() { + resource_version = version; + } + let key = + ObjectKey::new(object.namespace().unwrap_or_default(), object.name_any()); + if events.send(StoreEvent::Remove(key)).is_err() { + return; + } + } + Ok(Some(WatchEvent::Bookmark(bookmark))) => { + resource_version = bookmark.metadata.resource_version; + } + Ok(Some(WatchEvent::Error(status))) => { + relist = + status.code == 410 || status.reason == "Expired" || status.reason == "Gone"; + if relist { + tracing::debug!(code = status.code, "store: watch expired, relisting"); + } else if status.code == 403 { + let message = format!("Watch error (403): {}", status.message); + let _ = events.send(StoreEvent::WatchError(scope.clone(), message)); + return; + } else { + let message = format!("Watch error ({}): {}", status.code, status.message); + let _ = events.send(StoreEvent::WatchError(scope.clone(), message)); + } + break; + } + Ok(None) => break, + Err(error) => { + let _ = events.send(StoreEvent::WatchError( + scope.clone(), + format!("Watch stream failed: {error}"), + )); + break; + } + } + } + + if relist { + need_list = true; + } + + if !sleep_or_cancel(&mut cancel, RECONNECT_DELAY).await { + return; + } + } +} + +fn is_forbidden(error: &kube::Error) -> bool { + matches!(error, kube::Error::Api(response) if response.code == 403) +} + +async fn sleep_or_cancel(cancel: &mut watch::Receiver, duration: Duration) -> bool { + tokio::select! { + _ = cancel.changed() => false, + _ = tokio::time::sleep(duration) => true, + } +} + +/// Spawn the store for one kind. The typed match mirrors the existing watcher +/// dispatch and keeps built-in kinds on their concrete Kubernetes types. +pub(crate) fn spawn( + kind: ResourceKind, + client: Client, + tokio: tokio::runtime::Handle, + epoch: u64, + global_watch: bool, +) -> Option> { + match kind { + ResourceKind::Pods => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + pod_item, + namespaced_api::, + )), + ResourceKind::Deployments => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + deployment_item, + namespaced_api::, + )), + ResourceKind::StatefulSets => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + stateful_set_item, + namespaced_api::, + )), + ResourceKind::ReplicaSets => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + replica_set_item, + namespaced_api::, + )), + ResourceKind::ReplicationControllers => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + replication_controller_item, + namespaced_api::, + )), + ResourceKind::DaemonSets => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + daemon_set_item, + namespaced_api::, + )), + ResourceKind::Jobs => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + job_item, + namespaced_api::, + )), + ResourceKind::CronJobs => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + cron_job_item, + namespaced_api::, + )), + ResourceKind::HorizontalPodAutoscalers => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + horizontal_pod_autoscaler_item, + namespaced_api::, + )), + ResourceKind::PodDisruptionBudgets => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + pod_disruption_budget_item, + namespaced_api::, + )), + ResourceKind::Services => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + service_item, + namespaced_api::, + )), + ResourceKind::Ingresses => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + ingress_item, + namespaced_api::, + )), + ResourceKind::Endpoints => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + endpoints_item, + namespaced_api::, + )), + ResourceKind::EndpointSlices => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + endpoint_slice_item, + namespaced_api::, + )), + ResourceKind::NetworkPolicies => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + network_policy_item, + namespaced_api::, + )), + ResourceKind::PersistentVolumeClaims => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + pvc_item, + namespaced_api::, + )), + ResourceKind::StorageClasses => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + storage_class_item, + cluster_api::, + )), + ResourceKind::ConfigMaps => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + config_map_item, + namespaced_api::, + )), + ResourceKind::Secrets => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + secret_item, + namespaced_api::, + )), + ResourceKind::ServiceAccounts => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + service_account_item, + namespaced_api::, + )), + ResourceKind::PersistentVolumes => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + pv_item, + cluster_api::, + )), + ResourceKind::Namespaces => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + namespace_item, + cluster_api::, + )), + ResourceKind::Nodes => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + node_item, + cluster_api::, + )), + ResourceKind::Leases => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + lease_item, + namespaced_api::, + )), + ResourceKind::Events => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + event_item, + namespaced_api::, + )), + ResourceKind::ClusterRoles => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + cluster_role_item, + cluster_api::, + )), + ResourceKind::Roles => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + role_item, + namespaced_api::, + )), + ResourceKind::RoleBindings => Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + role_binding_item, + namespaced_api::, + )), + ResourceKind::Custom(index) => { + let def = crate::models::resources::catalog::custom_def(index)?; + let resource = ApiResource::from_gvk_with_plural( + &GroupVersionKind::gvk(&def.group, &def.version, &def.kind), + &def.plural, + ); + let make_api = move |client: &Client, scope: &NamespaceScope| match scope { + NamespaceScope::All => Api::::all_with(client.clone(), &resource), + NamespaceScope::One(namespace) => { + Api::::namespaced_with(client.clone(), namespace, &resource) + } + }; + Some(spawn_typed( + kind, + client, + tokio, + epoch, + global_watch, + custom_item, + make_api, + )) + } + } +} + +#[allow(clippy::too_many_arguments)] +fn spawn_typed( + kind: ResourceKind, + client: Client, + tokio: tokio::runtime::Handle, + epoch: u64, + global_watch: bool, + map: F, + make_api: M, +) -> Arc +where + K: Resource + + ResourceExt + + Clone + + Debug + + DeserializeOwned + + Serialize + + Send + + Sync + + 'static + + crate::repository::store::StoredObject, + F: Fn(&K) -> Item + Copy + Send + Sync + 'static, + M: Fn(&Client, &NamespaceScope) -> Api + Send + Sync + 'static, +{ + let mut data = StoreData::new(); + let initial = Arc::new(data.snapshot(SnapshotPhase::Loading, WatchMode::Watch)); + let (snapshots, _) = watch::channel(initial); + let (commands, command_rx) = mpsc::unbounded_channel(); + let (events, event_rx) = mpsc::unbounded_channel(); + + let handle = Arc::new(KubeObjectStoreHandle { + commands: commands.clone(), + snapshots: snapshots.clone(), + }); + + let task = StoreTask { + client, + tokio: tokio.clone(), + epoch, + kind, + global_watch, + data, + scopes: HashMap::new(), + plan: None, + watches: Vec::new(), + events: events.clone(), + event_rx, + command_rx, + snapshots, + phase: SnapshotPhase::Loading, + map, + make_api: Arc::new(make_api), + }; + tokio.spawn(task.run()); + + handle +} + +fn namespaced_api(client: &Client, scope: &NamespaceScope) -> Api +where + K: Resource + + Clone + + Debug + + DeserializeOwned + + Send + + 'static, +{ + match scope { + NamespaceScope::All => Api::::all(client.clone()), + NamespaceScope::One(namespace) => Api::::namespaced(client.clone(), namespace), + } +} + +fn cluster_api(client: &Client, _: &NamespaceScope) -> Api +where + K: Resource + Clone + Debug + DeserializeOwned + Send + 'static, +{ + Api::::all(client.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_initial_events_bookmark_ends_the_stream() { + let mut annotations = BTreeMap::new(); + assert!(!bookmark_ends_initial_events(&annotations)); + + annotations.insert("k8s.io/initial-events-end".into(), "false".into()); + assert!(!bookmark_ends_initial_events(&annotations)); + + annotations.insert("k8s.io/initial-events-end".into(), "true".into()); + assert!(bookmark_ends_initial_events(&annotations)); + } +} diff --git a/src/repository/store/mapping.rs b/src/repository/store/mapping.rs new file mode 100644 index 0000000..47894a2 --- /dev/null +++ b/src/repository/store/mapping.rs @@ -0,0 +1,584 @@ +//! Item summaries for each built-in resource kind. +//! +//! These mappers are shared by the store engine. They are pure conversions from +//! a typed Kubernetes object to the table row shown by the UI. + +use k8s_openapi::api::apps::v1::{DaemonSet, Deployment, ReplicaSet, StatefulSet}; +use k8s_openapi::api::autoscaling::v2::HorizontalPodAutoscaler; +use k8s_openapi::api::batch::v1::{CronJob, Job}; +use k8s_openapi::api::coordination::v1::Lease; +use k8s_openapi::api::core::v1::{ + ConfigMap, Endpoints, Event, Namespace, Node, PersistentVolume, PersistentVolumeClaim, Pod, + ReplicationController, Secret, Service, ServiceAccount, +}; +use k8s_openapi::api::discovery::v1::EndpointSlice; +use k8s_openapi::api::networking::v1::{Ingress, NetworkPolicy}; +use k8s_openapi::api::policy::v1::PodDisruptionBudget; +use k8s_openapi::api::rbac::v1::{ClusterRole, Role, RoleBinding}; +use k8s_openapi::api::storage::v1::StorageClass; +use kube::ResourceExt; +use kube::api::DynamicObject; + +use crate::models::Item; + +fn base_item(object: &K) -> Item +where + K: ResourceExt, +{ + let mut item = Item::new(object.name_any(), object.namespace()); + item.uid = object.uid().unwrap_or_default(); + item.resource_version = object.resource_version().unwrap_or_default(); + item.created_at = object.creation_timestamp().map(|time| time.0.as_second()); + item +} + +fn version_label(labels: Option<&std::collections::BTreeMap>) -> String { + labels + .and_then(|labels| labels.get("app.kubernetes.io/version").cloned()) + .unwrap_or_default() +} + +pub(crate) fn pod_item(pod: &Pod) -> Item { + let mut item = base_item(pod); + item.node = pod + .spec + .as_ref() + .and_then(|spec| spec.node_name.clone()) + .unwrap_or_default(); + let statuses = pod + .status + .as_ref() + .and_then(|status| status.container_statuses.as_ref()); + let total = statuses.map(|statuses| statuses.len()).unwrap_or(0); + let ready = statuses + .map(|statuses| statuses.iter().filter(|status| status.ready).count()) + .unwrap_or(0); + item.ready = format!("{ready}/{total}"); + item.restarts = statuses + .map(|statuses| { + statuses + .iter() + .map(|status| status.restart_count as usize) + .sum() + }) + .unwrap_or(0); + item.status = pod + .status + .as_ref() + .and_then(|status| status.phase.as_deref()) + .unwrap_or("unknown") + .to_lowercase(); + let crashing = statuses.is_some_and(|statuses| { + statuses.iter().any(|status| { + status + .state + .as_ref() + .and_then(|state| state.waiting.as_ref()) + .and_then(|waiting| waiting.reason.as_deref()) + .is_some_and(|reason| reason == "CrashLoopBackOff") + }) + }); + if crashing { + item.status = "crashloopbackoff".to_string(); + } + item +} + +pub(crate) fn deployment_item(deployment: &Deployment) -> Item { + let mut item = base_item(deployment); + let desired = deployment + .spec + .as_ref() + .and_then(|spec| spec.replicas) + .unwrap_or(0); + let ready = deployment + .status + .as_ref() + .and_then(|status| status.ready_replicas) + .unwrap_or(0); + item.ready = format!("{ready}/{desired}"); + item.capacity = desired.to_string(); + item.status = if ready >= desired { + "ready" + } else { + "progressing" + } + .to_string(); + item.version = version_label(deployment.metadata.labels.as_ref()); + item +} + +pub(crate) fn daemon_set_item(daemon_set: &DaemonSet) -> Item { + let mut item = base_item(daemon_set); + let desired = daemon_set + .status + .as_ref() + .map(|status| status.desired_number_scheduled) + .unwrap_or(0); + let ready = daemon_set + .status + .as_ref() + .map(|status| status.number_ready) + .unwrap_or(0); + item.ready = format!("{ready}/{desired}"); + item.capacity = desired.to_string(); + item.status = if ready >= desired { + "ready" + } else { + "progressing" + } + .to_string(); + item.version = version_label(daemon_set.metadata.labels.as_ref()); + item +} + +pub(crate) fn job_item(job: &Job) -> Item { + let mut item = base_item(job); + let want = job + .spec + .as_ref() + .and_then(|spec| spec.completions) + .unwrap_or(1); + let done = job + .status + .as_ref() + .and_then(|status| status.succeeded) + .unwrap_or(0); + item.ready = format!("{done}/{want}"); + item.capacity = want.to_string(); + item.status = if done >= want { "succeeded" } else { "running" }.to_string(); + item +} + +pub(crate) fn service_item(service: &Service) -> Item { + let mut item = base_item(service); + let spec = service.spec.as_ref(); + item.status = spec + .and_then(|spec| spec.type_.clone()) + .unwrap_or_else(|| "ClusterIP".to_string()) + .to_lowercase(); + item.version = spec + .and_then(|spec| spec.ports.as_ref()) + .and_then(|ports| ports.first()) + .map(|port| port.port.to_string()) + .unwrap_or_default(); + item +} + +pub(crate) fn ingress_item(ingress: &Ingress) -> Item { + let mut item = base_item(ingress); + item.status = "ingress".to_string(); + item.version = ingress + .spec + .as_ref() + .and_then(|spec| spec.rules.as_ref()) + .and_then(|rules| rules.first()) + .and_then(|rule| rule.host.clone()) + .unwrap_or_default(); + item +} + +pub(crate) fn pv_item(persistent_volume: &PersistentVolume) -> Item { + let mut item = base_item(persistent_volume); + item.capacity = persistent_volume + .spec + .as_ref() + .and_then(|spec| spec.capacity.as_ref()) + .and_then(|capacity| capacity.get("storage")) + .map(|quantity| quantity.0.clone()) + .unwrap_or_default(); + item.status = persistent_volume + .status + .as_ref() + .and_then(|status| status.phase.clone()) + .unwrap_or_default() + .to_lowercase(); + item +} + +pub(crate) fn pvc_item(persistent_volume_claim: &PersistentVolumeClaim) -> Item { + let mut item = base_item(persistent_volume_claim); + item.capacity = persistent_volume_claim + .status + .as_ref() + .and_then(|status| status.capacity.as_ref()) + .and_then(|capacity| capacity.get("storage")) + .or_else(|| { + persistent_volume_claim + .spec + .as_ref() + .and_then(|spec| spec.resources.as_ref()) + .and_then(|resources| resources.requests.as_ref()) + .and_then(|requests| requests.get("storage")) + }) + .map(|quantity| quantity.0.clone()) + .unwrap_or_default(); + item.status = persistent_volume_claim + .status + .as_ref() + .and_then(|status| status.phase.clone()) + .unwrap_or_default() + .to_lowercase(); + item +} + +pub(crate) fn config_map_item(config_map: &ConfigMap) -> Item { + let mut item = base_item(config_map); + let data_keys = config_map.data.as_ref().map(|data| data.len()).unwrap_or(0); + let binary_keys = config_map + .binary_data + .as_ref() + .map(|data| data.len()) + .unwrap_or(0); + item.status = "keys".to_string(); + item.capacity = (data_keys + binary_keys).to_string(); + item.version = config_map + .data + .as_ref() + .and_then(|data| data.keys().next().cloned()) + .unwrap_or_default(); + item +} + +pub(crate) fn secret_item(secret: &Secret) -> Item { + let mut item = base_item(secret); + item.status = "keys".to_string(); + item.capacity = secret + .data + .as_ref() + .map(|data| data.len()) + .unwrap_or(0) + .to_string(); + item.version = secret + .data + .as_ref() + .and_then(|data| data.keys().next().cloned()) + .unwrap_or_default(); + item +} + +pub(crate) fn stateful_set_item(stateful_set: &StatefulSet) -> Item { + let mut item = base_item(stateful_set); + let desired = stateful_set + .spec + .as_ref() + .and_then(|spec| spec.replicas) + .unwrap_or(0); + let ready = stateful_set + .status + .as_ref() + .and_then(|status| status.ready_replicas) + .unwrap_or(0); + item.ready = format!("{ready}/{desired}"); + item.capacity = desired.to_string(); + item.status = if ready >= desired { + "ready" + } else { + "progressing" + } + .to_string(); + item.version = version_label(stateful_set.metadata.labels.as_ref()); + item +} + +pub(crate) fn replica_set_item(replica_set: &ReplicaSet) -> Item { + let mut item = base_item(replica_set); + let desired = replica_set + .spec + .as_ref() + .and_then(|spec| spec.replicas) + .unwrap_or(0); + let ready = replica_set + .status + .as_ref() + .and_then(|status| status.ready_replicas) + .unwrap_or(0); + item.ready = format!("{ready}/{desired}"); + item.capacity = desired.to_string(); + item.status = if ready >= desired { + "ready" + } else { + "progressing" + } + .to_string(); + item.version = version_label(replica_set.metadata.labels.as_ref()); + item +} + +pub(crate) fn replication_controller_item(controller: &ReplicationController) -> Item { + let mut item = base_item(controller); + let desired = controller + .spec + .as_ref() + .and_then(|spec| spec.replicas) + .unwrap_or(0); + let ready = controller + .status + .as_ref() + .and_then(|status| status.ready_replicas) + .unwrap_or(0); + item.ready = format!("{ready}/{desired}"); + item.capacity = desired.to_string(); + item.status = if ready >= desired { + "ready" + } else { + "progressing" + } + .to_string(); + item.version = version_label(controller.metadata.labels.as_ref()); + item +} + +pub(crate) fn horizontal_pod_autoscaler_item(autoscaler: &HorizontalPodAutoscaler) -> Item { + let mut item = base_item(autoscaler); + let spec = autoscaler.spec.as_ref(); + let max = spec.map(|spec| spec.max_replicas).unwrap_or(0); + let min = spec.and_then(|spec| spec.min_replicas).unwrap_or(0); + let current = autoscaler + .status + .as_ref() + .and_then(|status| status.current_replicas) + .unwrap_or(0); + item.ready = format!("{current}/{min}"); + item.capacity = max.to_string(); + item.status = if current >= min { "active" } else { "idle" }.to_string(); + item.version = spec + .map(|spec| { + format!( + "{}/{}", + spec.scale_target_ref.kind, spec.scale_target_ref.name + ) + }) + .unwrap_or_default(); + item +} + +pub(crate) fn pod_disruption_budget_item(budget: &PodDisruptionBudget) -> Item { + let mut item = base_item(budget); + let status = budget.status.as_ref(); + let current = status.map(|status| status.current_healthy).unwrap_or(0); + let desired = status.map(|status| status.desired_healthy).unwrap_or(0); + let allowed = status.map(|status| status.disruptions_allowed).unwrap_or(0); + item.ready = format!("{current}/{desired}"); + item.capacity = allowed.to_string(); + item.status = if allowed > 0 { "ready" } else { "blocked" }.to_string(); + item +} + +pub(crate) fn lease_item(lease: &Lease) -> Item { + let mut item = base_item(lease); + let spec = lease.spec.as_ref(); + item.version = spec + .and_then(|spec| spec.holder_identity.clone()) + .unwrap_or_default(); + item.capacity = spec + .and_then(|spec| spec.lease_duration_seconds) + .map(|seconds| format!("{seconds}s")) + .unwrap_or_default(); + item.status = "active".to_string(); + item +} + +pub(crate) fn endpoints_item(endpoints: &Endpoints) -> Item { + let mut item = base_item(endpoints); + let addresses = endpoints + .subsets + .as_ref() + .map(|subsets| { + subsets + .iter() + .map(|subset| { + subset.addresses.as_ref().map(Vec::len).unwrap_or(0) + + subset + .not_ready_addresses + .as_ref() + .map(Vec::len) + .unwrap_or(0) + }) + .sum::() + }) + .unwrap_or(0); + item.capacity = addresses.to_string(); + item.status = "active".to_string(); + item +} + +pub(crate) fn endpoint_slice_item(slice: &EndpointSlice) -> Item { + let mut item = base_item(slice); + item.capacity = slice.endpoints.len().to_string(); + item.version = slice.address_type.clone(); + item.status = "active".to_string(); + item +} + +pub(crate) fn network_policy_item(policy: &NetworkPolicy) -> Item { + let mut item = base_item(policy); + item.capacity = policy + .spec + .as_ref() + .and_then(|spec| spec.policy_types.as_ref()) + .map(|types| types.join(", ")) + .unwrap_or_default(); + item.status = "active".to_string(); + item +} + +pub(crate) fn event_item(event: &Event) -> Item { + let mut item = base_item(event); + item.status = event + .type_ + .clone() + .unwrap_or_else(|| "normal".to_string()) + .to_lowercase(); + item.version = event.reason.clone().unwrap_or_default(); + item.capacity = event.count.unwrap_or(1).to_string(); + item +} + +pub(crate) fn cluster_role_item(cluster_role: &ClusterRole) -> Item { + let mut item = base_item(cluster_role); + item.capacity = cluster_role + .rules + .as_ref() + .map(|rules| rules.len()) + .unwrap_or(0) + .to_string(); + item.status = "active".to_string(); + item +} + +pub(crate) fn role_item(role: &Role) -> Item { + let mut item = base_item(role); + item.capacity = role + .rules + .as_ref() + .map(|rules| rules.len()) + .unwrap_or(0) + .to_string(); + item.status = "active".to_string(); + item +} + +pub(crate) fn role_binding_item(binding: &RoleBinding) -> Item { + let mut item = base_item(binding); + item.capacity = binding + .subjects + .as_ref() + .map(|subjects| subjects.len()) + .unwrap_or(0) + .to_string(); + item.version = format!("{}/{}", binding.role_ref.kind, binding.role_ref.name); + item.status = "active".to_string(); + item +} + +pub(crate) fn custom_item(object: &DynamicObject) -> Item { + let mut item = base_item(object); + let Some(status) = object.data.get("status") else { + return item; + }; + if let Some(phase) = status.get("phase").and_then(serde_json::Value::as_str) { + item.status = phase.to_lowercase(); + } else if let Some(conditions) = status + .get("conditions") + .and_then(serde_json::Value::as_array) + { + let ready = conditions.iter().find(|condition| { + condition.get("type").and_then(serde_json::Value::as_str) == Some("Ready") + }); + if let Some(ready) = ready { + item.status = + if ready.get("status").and_then(serde_json::Value::as_str) == Some("True") { + "ready" + } else { + "notready" + } + .to_string(); + } + } + item +} + +pub(crate) fn cron_job_item(cron_job: &CronJob) -> Item { + let mut item = base_item(cron_job); + let active = cron_job + .status + .as_ref() + .and_then(|status| status.active.as_ref()) + .map(|active| active.len()) + .unwrap_or(0); + let suspended = cron_job + .spec + .as_ref() + .and_then(|spec| spec.suspend) + .unwrap_or(false); + item.capacity = active.to_string(); + item.status = if suspended { "suspended" } else { "active" }.to_string(); + item.version = cron_job + .spec + .as_ref() + .map(|spec| spec.schedule.clone()) + .unwrap_or_default(); + item +} + +pub(crate) fn service_account_item(service_account: &ServiceAccount) -> Item { + let mut item = base_item(service_account); + let secrets = service_account.secrets.as_ref().map(Vec::len).unwrap_or(0); + item.status = "account".to_string(); + item.capacity = secrets.to_string(); + item.version = service_account + .secrets + .as_ref() + .and_then(|secrets| secrets.first()) + .and_then(|secret| secret.name.clone()) + .unwrap_or_default(); + item +} + +pub(crate) fn storage_class_item(storage_class: &StorageClass) -> Item { + let mut item = base_item(storage_class); + item.version = storage_class.provisioner.clone(); + item.capacity = storage_class.reclaim_policy.clone().unwrap_or_default(); + item.ready = storage_class + .volume_binding_mode + .clone() + .unwrap_or_default(); + item +} + +pub(crate) fn namespace_item(namespace: &Namespace) -> Item { + let mut item = base_item(namespace); + item.status = namespace + .status + .as_ref() + .and_then(|status| status.phase.clone()) + .unwrap_or_else(|| "unknown".to_string()) + .to_lowercase(); + item +} + +pub(crate) fn node_item(node: &Node) -> Item { + let mut item = base_item(node); + let status = node.status.as_ref(); + let ready = status + .and_then(|status| status.conditions.as_ref()) + .and_then(|conditions| { + conditions + .iter() + .find(|condition| condition.type_ == "Ready") + }) + .map(|condition| condition.status == "True") + .unwrap_or(false); + item.status = if ready { "ready" } else { "notready" }.to_string(); + item.capacity = status + .and_then(|status| status.allocatable.as_ref()) + .and_then(|allocatable| allocatable.get("cpu")) + .map(|quantity| quantity.0.clone()) + .unwrap_or_default(); + item.version = status + .and_then(|status| status.node_info.as_ref()) + .map(|info| info.kubelet_version.clone()) + .unwrap_or_default(); + item +} diff --git a/src/repository/store/mod.rs b/src/repository/store/mod.rs new file mode 100644 index 0000000..5ef72c8 --- /dev/null +++ b/src/repository/store/mod.rs @@ -0,0 +1,109 @@ +//! Shared, per-kind store primitives. +//! +//! This module is the Freelens-style replacement for the current +//! per-`(kind, scope)` watch subscriptions. A [`KubeObjectRecord`] keeps the +//! full typed Kubernetes object alongside the small [`Item`] row summary. +//! [`StoreData`] owns the authoritative object map and UID index, while +//! [`StoreView`] provides a namespace-filtered view for one screen. + +mod data; +mod engine; +mod mapping; +mod plan; +mod record; +mod view; + +pub use data::{StoreData, StoreSnapshot}; +pub(crate) use engine::KubeObjectStoreHandle; +pub use plan::{ClusterCapabilities, StoreWatchPlan, watch_plan}; +pub use record::{KubeObjectRecord, StoredObject}; +pub use view::StoreView; + +use std::sync::Arc; + +use gpui_kit::{App, BorrowAppContext as _}; +use tokio::sync::watch; + +use crate::cluster::ClusterState; +use crate::models::{NamespaceScope, ObjectKey, ResourceKind}; +use crate::repository::{Repository, StoreEntry, Subscription}; + +/// Subscribe a view to the shared store for `kind`. +pub(crate) fn subscribe( + cx: &mut App, + kind: ResourceKind, + scope: NamespaceScope, +) -> Result { + let (store, tokio) = ensure_store(cx, kind)?; + let scope = if kind.is_namespaced() { + scope + } else { + NamespaceScope::All + }; + Ok(store.subscribe(scope, &tokio)) +} + +/// Read a cached full object without creating a store or a network request. +pub(crate) fn cached_object( + cx: &App, + kind: ResourceKind, + key: &ObjectKey, +) -> Option> { + let repository = cx.global::(); + let epoch = cx.global::().context_epoch(); + let entry = repository.stores.get(&kind)?; + if entry.epoch != epoch { + return None; + } + entry.store.object(key) +} + +/// Observe an already-running store, if one exists. Detail panes use this for +/// live updates when a list view is already watching; they never start a store +/// or a per-object watch themselves. +pub(crate) fn existing_store_snapshot( + cx: &App, + kind: ResourceKind, +) -> Option>> { + let repository = cx.global::(); + let epoch = cx.global::().context_epoch(); + let entry = repository.stores.get(&kind)?; + if entry.epoch != epoch { + return None; + } + Some(entry.store.snapshot_receiver()) +} + +fn ensure_store( + cx: &mut App, + kind: ResourceKind, +) -> Result<(Arc, tokio::runtime::Handle), String> { + let state = cx.global::(); + let client = state.client().ok_or_else(|| "not connected".to_string())?; + let tokio = state.tokio(); + let epoch = state.context_epoch(); + let global_watch = state.global_watch(); + + let store = cx.update_global::(|repository, _| { + if let Some(entry) = repository.stores.get(&kind) + && entry.epoch == epoch + { + return Ok(entry.store.clone()); + } + + let Some(store) = engine::spawn(kind, client, tokio.clone(), epoch, global_watch) else { + return Err(format!("{} is not available in this build.", kind.label())); + }; + repository.stores.insert( + kind, + StoreEntry { + epoch, + store: store.clone(), + }, + ); + tracing::debug!(?kind, epoch, "store: created"); + Ok(store) + })?; + + Ok((store, tokio)) +} diff --git a/src/repository/store/plan.rs b/src/repository/store/plan.rs new file mode 100644 index 0000000..03afffa --- /dev/null +++ b/src/repository/store/plan.rs @@ -0,0 +1,137 @@ +//! Namespace watch planning, mirroring Freelens's selection model. + +use std::collections::BTreeSet; + +use crate::models::NamespaceScope; + +/// Cluster-level capabilities discovered when connecting. +/// +/// `global_watch` is populated from a `SelfSubjectAccessReview` for the +/// `watch` verb on `*`, the same probe Freelens performs as +/// `isGlobalWatchEnabled`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ClusterCapabilities { + pub global_watch: bool, + pub accessible_namespaces: Vec, +} + +/// How a store should keep its namespaces fresh. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StoreWatchPlan { + /// One watch for the whole cluster. + All, + /// One watch per namespace. + PerNamespace(Vec), + /// A view asked for all namespaces but cluster-wide watch is not allowed, + /// and no namespace-scoped subscription can be watched instead. + UnavailableAll, +} + +/// Compute the shared watch plan for the active subscription scopes. +/// +/// A cluster-wide watch is used only when all namespaces are requested *and* +/// the cluster permits global watch. Otherwise each requested namespace gets +/// its own watch. If an all-namespace subscription is present without global +/// watch permission but other namespace-scoped subscriptions exist, the plan +/// keeps the namespace watches; the all-namespace view itself reports the +/// permission error. +pub fn watch_plan(scopes: I, capabilities: &ClusterCapabilities) -> StoreWatchPlan +where + I: IntoIterator, +{ + let mut namespaces = BTreeSet::new(); + let mut requested_all = false; + + for scope in scopes { + match scope { + NamespaceScope::All => requested_all = true, + NamespaceScope::One(namespace) => { + namespaces.insert(namespace); + } + } + } + + if requested_all { + if capabilities.global_watch { + return StoreWatchPlan::All; + } + if namespaces.is_empty() { + return StoreWatchPlan::UnavailableAll; + } + } + + StoreWatchPlan::PerNamespace(namespaces.into_iter().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capabilities(global_watch: bool) -> ClusterCapabilities { + ClusterCapabilities { + global_watch, + accessible_namespaces: vec!["ns-1".into(), "ns-2".into()], + } + } + + #[test] + fn all_namespaces_use_one_global_watch() { + assert_eq!( + watch_plan([NamespaceScope::All], &capabilities(true)), + StoreWatchPlan::All + ); + } + + #[test] + fn all_namespaces_without_permission_are_unavailable() { + assert_eq!( + watch_plan([NamespaceScope::All], &capabilities(false)), + StoreWatchPlan::UnavailableAll + ); + } + + #[test] + fn namespace_scopes_are_deduplicated_and_sorted() { + assert_eq!( + watch_plan( + [ + NamespaceScope::One("ns-2".into()), + NamespaceScope::One("ns-1".into()), + NamespaceScope::One("ns-2".into()), + ], + &capabilities(false), + ), + StoreWatchPlan::PerNamespace(vec!["ns-1".into(), "ns-2".into()]) + ); + } + + #[test] + fn all_plus_namespace_falls_back_to_namespace_watches() { + assert_eq!( + watch_plan( + [NamespaceScope::All, NamespaceScope::One("ns-1".into()),], + &capabilities(false), + ), + StoreWatchPlan::PerNamespace(vec!["ns-1".into()]) + ); + } + + #[test] + fn all_plus_namespace_uses_global_watch_when_allowed() { + assert_eq!( + watch_plan( + [NamespaceScope::All, NamespaceScope::One("ns-1".into()),], + &capabilities(true), + ), + StoreWatchPlan::All + ); + } + + #[test] + fn no_scopes_produce_an_empty_namespace_plan() { + assert_eq!( + watch_plan([], &capabilities(true)), + StoreWatchPlan::PerNamespace(Vec::new()) + ); + } +} diff --git a/src/repository/store/record.rs b/src/repository/store/record.rs new file mode 100644 index 0000000..ce998c2 --- /dev/null +++ b/src/repository/store/record.rs @@ -0,0 +1,77 @@ +//! Full-object cache records. +//! +//! The typed Kubernetes object is retained behind a type-erased trait so one +//! registry can hold every built-in kind and custom resource. Details downcast +//! to the concrete kind; list tables use the precomputed `Item` summary. + +use std::any::Any; +use std::sync::Arc; + +use kube::ResourceExt; + +use crate::models::{Item, ObjectKey}; + +/// A Kubernetes object stored without knowing its concrete Rust type. +pub trait StoredObject: Any + Send + Sync { + fn as_any(&self) -> &dyn Any; + fn to_json(&self) -> serde_json::Value; +} + +impl StoredObject for T +where + T: Any + Send + Sync + serde::Serialize + ResourceExt, +{ + fn as_any(&self) -> &dyn Any { + self + } + + fn to_json(&self) -> serde_json::Value { + serde_json::to_value(self).unwrap_or(serde_json::Value::Null) + } +} + +/// One object in the shared store. +pub struct KubeObjectRecord { + pub key: ObjectKey, + pub uid: String, + pub resource_version: String, + pub summary: Arc, + raw: Arc, +} + +impl KubeObjectRecord { + pub fn new(object: K, summary: Item) -> Self + where + K: StoredObject + ResourceExt, + { + let key = ObjectKey::new(object.namespace().unwrap_or_default(), object.name_any()); + let uid = object.uid().unwrap_or_default(); + let resource_version = object.resource_version().unwrap_or_default(); + Self { + key, + uid, + resource_version, + summary: Arc::new(summary), + raw: Arc::new(object), + } + } + + pub fn typed(&self) -> Option<&T> { + self.raw.as_any().downcast_ref::() + } + + pub fn to_json(&self) -> serde_json::Value { + self.raw.to_json() + } +} + +impl std::fmt::Debug for KubeObjectRecord { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("KubeObjectRecord") + .field("key", &self.key) + .field("uid", &self.uid) + .field("resource_version", &self.resource_version) + .finish_non_exhaustive() + } +} diff --git a/src/repository/store/view.rs b/src/repository/store/view.rs new file mode 100644 index 0000000..54701ea --- /dev/null +++ b/src/repository/store/view.rs @@ -0,0 +1,233 @@ +//! Namespace-filtered view over a shared store. +//! +//! A `StoreView` is not a cache and owns no network. It keeps the sorted `Item` +//! rows for one screen scope and rebuilds them only when the store flush is +//! relevant to that scope. + +use std::sync::Arc; + +use crate::models::{Item, NamespaceScope, ResourceSnapshot, SnapshotPhase, WatchMode}; + +use super::data::StoreSnapshot; +use super::record::KubeObjectRecord; + +pub struct StoreView { + scope: NamespaceScope, + items: Arc<[Arc]>, + phase: SnapshotPhase, + mode: WatchMode, + last_revision: u64, + last_namespace_revision: Option, + initialized: bool, +} + +impl StoreView { + pub fn new(scope: NamespaceScope) -> Self { + Self { + scope, + items: Arc::from(Vec::>::new()), + phase: SnapshotPhase::Loading, + mode: WatchMode::Watch, + last_revision: 0, + last_namespace_revision: None, + initialized: false, + } + } + + #[cfg(test)] + pub fn items(&self) -> &[Arc] { + &self.items + } + + #[cfg(test)] + pub fn len(&self) -> usize { + self.items.len() + } + + #[cfg(test)] + pub fn phase(&self) -> &SnapshotPhase { + &self.phase + } + + pub fn snapshot(&self) -> Arc { + Arc::new(ResourceSnapshot::from_items( + self.items.clone(), + self.phase.clone(), + self.mode, + )) + } + + /// Apply a store snapshot. Returns the new view snapshot when the scope saw + /// a relevant change; `None` means the view is unchanged. + pub fn apply(&mut self, snapshot: &StoreSnapshot) -> Option> { + let phase_changed = self.phase != *snapshot.phase(); + let mode_changed = self.mode != snapshot.mode(); + + let namespace_changed = match &self.scope { + NamespaceScope::All => self.last_revision != snapshot.revision(), + NamespaceScope::One(namespace) => { + snapshot.namespace_revision(namespace) != self.last_namespace_revision + } + }; + + if self.initialized && !phase_changed && !mode_changed && !namespace_changed { + return None; + } + + let objects = visible_objects(snapshot.objects(), &self.scope); + self.items = objects + .iter() + .map(|record| record.summary.clone()) + .collect(); + self.phase = snapshot.phase().clone(); + self.mode = snapshot.mode(); + self.last_revision = snapshot.revision(); + self.last_namespace_revision = match &self.scope { + NamespaceScope::All => None, + NamespaceScope::One(namespace) => snapshot.namespace_revision(namespace), + }; + self.initialized = true; + + Some(self.snapshot()) + } +} + +fn visible_objects<'a>( + objects: &'a [Arc], + scope: &NamespaceScope, +) -> &'a [Arc] { + match scope { + NamespaceScope::All => objects, + NamespaceScope::One(namespace) => { + let start = objects.partition_point(|record| record.key.namespace < *namespace); + let end = objects.partition_point(|record| record.key.namespace <= *namespace); + &objects[start..end] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{NamespaceScope, ResourceSnapshot, SnapshotPhase, WatchMode}; + + fn view_for(scope: NamespaceScope, snapshot: &StoreSnapshot) -> StoreView { + let mut view = StoreView::new(scope); + view.apply(snapshot).expect("initial apply always emits"); + view + } + + #[test] + fn starts_loading_until_a_snapshot_arrives() { + let view = StoreView::new(NamespaceScope::All); + assert!(matches!(view.phase(), SnapshotPhase::Loading)); + assert_eq!(view.len(), 0); + assert_eq!(view.snapshot().mode(), WatchMode::Watch); + } + + #[test] + fn all_scope_includes_every_namespace() { + let mut data = super::super::data::StoreData::new(); + data.upsert(test_record("a", "ns-1", "uid-a", "1")); + data.upsert(test_record("b", "ns-2", "uid-b", "1")); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + + let view = view_for(NamespaceScope::All, &snapshot); + assert_eq!(view.len(), 2); + assert_eq!(view.snapshot().items().len(), 2); + } + + #[test] + fn namespace_scope_includes_only_that_namespace() { + let mut data = super::super::data::StoreData::new(); + data.upsert(test_record("a", "ns-1", "uid-a", "1")); + data.upsert(test_record("b", "ns-2", "uid-b", "1")); + data.upsert(test_record("c", "ns-10", "uid-c", "1")); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + + let view = view_for(NamespaceScope::One("ns-2".into()), &snapshot); + assert_eq!(view.len(), 1); + assert_eq!(view.items()[0].name, "b"); + } + + #[test] + fn unchanged_global_revision_does_not_emit() { + let mut data = super::super::data::StoreData::new(); + data.upsert(test_record("a", "ns-1", "uid-a", "1")); + let snapshot = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + + let mut view = view_for(NamespaceScope::All, &snapshot); + assert!(view.apply(&snapshot).is_none()); + } + + #[test] + fn unrelated_namespace_change_does_not_emit_for_a_namespace_view() { + let mut data = super::super::data::StoreData::new(); + data.upsert(test_record("a", "ns-1", "uid-a", "1")); + let first = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + let mut view = view_for(NamespaceScope::One("ns-1".into()), &first); + + data.upsert(test_record("b", "ns-2", "uid-b", "1")); + let second = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert!( + view.apply(&second).is_none(), + "ns-2 change must not repaint ns-1" + ); + + data.upsert(test_record("c", "ns-1", "uid-c", "1")); + let third = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + assert!(view.apply(&third).is_some()); + assert_eq!(view.len(), 2); + } + + #[test] + fn phase_changes_always_emit() { + let mut data = super::super::data::StoreData::new(); + let mut view = StoreView::new(NamespaceScope::All); + let ready = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + let first = view.apply(&ready).expect("initial emit"); + assert_eq!(first.mode(), WatchMode::Watch); + + let mut errored_data = super::super::data::StoreData::new(); + errored_data.upsert(test_record("a", "ns-1", "uid-a", "1")); + let errored = + errored_data.snapshot(SnapshotPhase::Error("offline".into()), WatchMode::Watch); + let emitted = view.apply(&errored).expect("phase change emits"); + assert!(matches!(emitted.phase(), SnapshotPhase::Error(message) if message == "offline")); + } + + #[test] + fn snapshot_is_a_resource_snapshot_for_existing_models() { + let mut data = super::super::data::StoreData::new(); + data.upsert(test_record("a", "ns-1", "uid-a", "1")); + let store = data.snapshot(SnapshotPhase::Ready, WatchMode::Watch); + let view = view_for(NamespaceScope::All, &store); + let snapshot: Arc = view.snapshot(); + assert_eq!(snapshot.items().len(), 1); + assert_eq!(snapshot.phase(), &SnapshotPhase::Ready); + } + + fn test_record( + name: &str, + namespace: &str, + uid: &str, + version: &str, + ) -> super::super::record::KubeObjectRecord { + let object: kube::api::DynamicObject = serde_json::from_value(serde_json::json!({ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": name, + "namespace": namespace, + "uid": uid, + "resourceVersion": version + } + })) + .expect("dynamic object"); + + let mut item = Item::new(name.to_string(), Some(namespace.to_string())); + item.uid = uid.to_string(); + item.resource_version = version.to_string(); + super::super::record::KubeObjectRecord::new(object, item) + } +} diff --git a/src/repository/terminal.rs b/src/repository/terminal.rs new file mode 100644 index 0000000..1e27c89 --- /dev/null +++ b/src/repository/terminal.rs @@ -0,0 +1,357 @@ +//! Persistent terminal sessions owned by the repository, not by a view. +//! +//! A terminal view attaches to a session and can detach without killing the +//! shell. Closing the terminal tab destroys the session explicitly. + +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use futures::SinkExt as _; +use gpui_kit::{App, BorrowAppContext as _}; +use k8s_openapi::api::core::v1::Pod; +use kube::Client; +use kube::api::{Api, AttachParams, TerminalSize}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +use tokio::sync::{broadcast, mpsc, watch}; + +use crate::cluster::ClusterState; +use crate::repository::Repository; + +const HISTORY_BYTES: usize = 1024 * 1024; +const OUTPUT_CAPACITY: usize = 256; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct TerminalKey { + epoch: u64, + namespace: String, + pod: String, + container: String, + shell: String, +} + +#[derive(Clone, Debug)] +pub(crate) enum TerminalEvent { + Data(Vec), + Error(String), + Ended, +} + +pub(crate) struct TerminalAttachment { + pub history: Vec, + pub updates: broadcast::Receiver, +} + +pub(crate) struct TerminalSession { + key: TerminalKey, + history: Mutex>, + output: broadcast::Sender, + input: mpsc::UnboundedSender>, + resize: mpsc::UnboundedSender<(u16, u16)>, + cancel: watch::Sender, + finished: AtomicBool, +} + +impl TerminalSession { + pub(crate) fn key(&self) -> &TerminalKey { + &self.key + } + + pub(crate) fn attach(&self) -> TerminalAttachment { + let history = self + .history + .lock() + .map(|history| history.iter().copied().collect()) + .unwrap_or_default(); + TerminalAttachment { + history, + updates: self.output.subscribe(), + } + } + + pub(crate) fn input_handle(&self) -> mpsc::UnboundedSender> { + self.input.clone() + } + + pub(crate) fn resize_handle(&self) -> mpsc::UnboundedSender<(u16, u16)> { + self.resize.clone() + } + + pub(crate) fn is_finished(&self) -> bool { + self.finished.load(Ordering::Relaxed) + } + + pub(crate) fn destroy(&self) { + let _ = self.cancel.send(true); + } + + fn push_data(&self, bytes: Vec) { + if let Ok(mut history) = self.history.lock() { + history.extend(bytes.iter().copied()); + let overflow = history.len().saturating_sub(HISTORY_BYTES); + if overflow > 0 { + history.drain(..overflow); + } + } + let _ = self.output.send(TerminalEvent::Data(bytes)); + } + + fn push_error(&self, message: String) { + let _ = self.output.send(TerminalEvent::Error(message)); + } + + fn finish(&self) { + self.finished.store(true, Ordering::Relaxed); + let _ = self.output.send(TerminalEvent::Ended); + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + let _ = self.cancel.send(true); + } +} + +#[derive(Default)] +pub(crate) struct TerminalSessionStore { + sessions: HashMap>, +} + +impl TerminalSessionStore { + fn retain_epoch(&mut self, epoch: u64) { + self.sessions.retain(|key, session| { + if key.epoch == epoch { + true + } else { + session.destroy(); + false + } + }); + } + + fn get(&self, key: &TerminalKey) -> Option> { + self.sessions.get(key).cloned() + } + + fn insert(&mut self, key: TerminalKey, session: Arc) { + self.sessions.insert(key, session); + } + + fn remove(&mut self, key: &TerminalKey) -> Option> { + self.sessions.remove(key) + } +} + +/// Open or reuse a terminal session for one pod container shell. +pub(crate) fn open( + cx: &mut App, + namespace: &str, + name: &str, + container: &str, + shell: &str, +) -> Result, String> { + if container.is_empty() { + return Err("This pod has no containers to exec into.".to_string()); + } + + let state = cx.global::(); + let client = state.client().ok_or_else(|| "not connected".to_string())?; + let tokio = state.tokio(); + let epoch = state.context_epoch(); + let key = TerminalKey { + epoch, + namespace: namespace.to_string(), + pod: name.to_string(), + container: container.to_string(), + shell: shell.to_string(), + }; + + if let Some(session) = cx.update_global::(|repository, _| { + repository.terminal.retain_epoch(epoch); + repository.terminal.get(&key) + }) && !session.is_finished() + { + return Ok(session); + } + + if let Some(old) = + cx.update_global::(|repository, _| repository.terminal.remove(&key)) + { + old.destroy(); + } + + let (output, _) = broadcast::channel(OUTPUT_CAPACITY); + let (input_tx, input_rx) = mpsc::unbounded_channel(); + let (resize_tx, resize_rx) = mpsc::unbounded_channel(); + let (cancel, cancel_rx) = watch::channel(false); + let session = Arc::new(TerminalSession { + key: key.clone(), + history: Mutex::new(VecDeque::new()), + output, + input: input_tx, + resize: resize_tx, + cancel, + finished: AtomicBool::new(false), + }); + + cx.update_global::(|repository, _| { + repository.terminal.insert(key.clone(), session.clone()); + }); + + tokio.spawn(run_session( + client, + key, + session.clone(), + input_rx, + resize_rx, + cancel_rx, + )); + Ok(session) +} + +/// Explicitly destroy the session for a key. Detaching a view must not call +/// this; closing the terminal tab or switching clusters must. +pub(crate) fn destroy(cx: &mut App, key: &TerminalKey) { + let session = + cx.update_global::(|repository, _| repository.terminal.remove(key)); + if let Some(session) = session { + session.destroy(); + } +} + +async fn run_session( + client: Client, + key: TerminalKey, + session: Arc, + mut input: mpsc::UnboundedReceiver>, + mut resize: mpsc::UnboundedReceiver<(u16, u16)>, + mut cancel: watch::Receiver, +) { + let api: Api = Api::namespaced(client, &key.namespace); + let params = AttachParams { + container: Some(key.container.clone()), + stdin: true, + stdout: true, + stderr: false, + tty: true, + ..AttachParams::default() + }; + tracing::debug!( + namespace = %key.namespace, + pod = %key.pod, + container = %key.container, + shell = %key.shell, + "terminal: connecting" + ); + let mut process = match api.exec(&key.pod, [key.shell.as_str()], ¶ms).await { + Ok(process) => process, + Err(error) => { + session.push_error(format!("Could not start exec session: {error}")); + session.finish(); + return; + } + }; + tracing::debug!(pod = %key.pod, container = %key.container, "terminal: connected"); + + let Some(mut stdin) = process.stdin() else { + session.push_error("Exec session has no stdin channel.".to_string()); + process.abort(); + session.finish(); + return; + }; + let Some(stdout) = process.stdout() else { + session.push_error("Exec session has no stdout channel.".to_string()); + process.abort(); + session.finish(); + return; + }; + let stderr = process.stderr(); + + let writer = tokio::spawn(async move { + while let Some(bytes) = input.recv().await { + if stdin.write_all(&bytes).await.is_err() || stdin.flush().await.is_err() { + break; + } + } + }); + let stdout_task = tokio::spawn(read_output(stdout, session.clone())); + let stderr_task = stderr.map(|stderr| tokio::spawn(read_output(stderr, session.clone()))); + + let mut resize_task = process.terminal_size().map(|size_sender| { + tokio::spawn(async move { + let mut size_sender = size_sender; + while let Some((cols, rows)) = resize.recv().await { + let size = TerminalSize { + width: cols, + height: rows, + }; + if size_sender.send(size).await.is_err() { + break; + } + } + }) + }); + + let mut status_future = process.take_status(); + let status = tokio::select! { + _ = cancel.changed() => { + process.abort(); + writer.abort(); + stdout_task.abort(); + if let Some(task) = stderr_task { task.abort(); } + if let Some(task) = resize_task.take() { task.abort(); } + session.finish(); + return; + } + status = async { + match status_future.take() { + Some(status) => status.await, + None => None, + } + } => status, + }; + process.abort(); + writer.abort(); + stdout_task.abort(); + if let Some(task) = stderr_task { + task.abort(); + } + if let Some(task) = resize_task.take() { + task.abort(); + } + match status { + Some(status) if status.status.as_deref() == Some("Success") => { + session.push_data(b"\r\n[session ended]\r\n".to_vec()); + } + Some(status) => { + let message = status + .message + .as_deref() + .filter(|message| !message.is_empty()) + .map(|message| format!("Exec session ended: {message}")) + .unwrap_or_else(|| "Exec session ended".to_string()); + session.push_error(message); + } + None => { + session.push_data(b"\r\n[session closed]\r\n".to_vec()); + } + } + session.finish(); +} + +async fn read_output(mut reader: R, session: Arc) +where + R: AsyncRead + Unpin, +{ + let mut buffer = [0u8; 8192]; + loop { + match reader.read(&mut buffer).await { + Ok(0) => return, + Ok(read) => session.push_data(buffer[..read].to_vec()), + Err(error) => { + session.push_error(format!("Exec stream error: {error}")); + return; + } + } + } +} diff --git a/src/repository/watch_prefs.rs b/src/repository/watch_prefs.rs deleted file mode 100644 index d5d4029..0000000 --- a/src/repository/watch_prefs.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! Persisted per-resource-kind update preferences: watch vs poll, and the -//! poll rate. -//! -//! Lives under the app config folder, e.g. `~/.config/krusty/poll-rates.json`. -//! Kinds without an entry use [`WatchPrefs::default`]: live watch with a -//! five-second poll fallback. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -use crate::models::{PollRate, ResourceKind}; - -/// How the user wants a resource kind kept up to date. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum WatchPreference { - /// Live watch stream; falls back to polling when the API denies watch. - #[default] - Watch, - /// Never watch; re-list on the configured poll rate. - Poll, -} - -/// The full per-kind update preference. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct WatchPrefs { - #[serde(default)] - pub mode: WatchPreference, - #[serde(default)] - pub rate: PollRate, -} - -#[derive(Serialize, Deserialize, Debug, Default)] -struct Store { - version: u32, - #[serde(default)] - kinds: HashMap, -} - -#[derive(Debug, Default)] -pub struct WatchPrefsStore { - kinds: HashMap, -} - -/// The factory preference for a kind. Namespaces, Events, and Leases default -/// to polling: many clusters deny `watch` on them and they are high-churn or -/// rarely change. Picking "Live watch" in the UI saves and sticks across -/// launches. -fn default_prefs_for(kind: ResourceKind) -> WatchPrefs { - match kind { - ResourceKind::Namespaces | ResourceKind::Events | ResourceKind::Leases => WatchPrefs { - mode: WatchPreference::Poll, - rate: PollRate::DEFAULT, - }, - _ => WatchPrefs::default(), - } -} - -impl WatchPrefsStore { - /// Path under the OS app config dir, e.g. ~/.config/krusty/poll-rates.json. - pub fn path() -> Option { - dirs::config_dir().map(|d| d.join("krusty").join("poll-rates.json")) - } - - pub fn load() -> Self { - let Some(path) = Self::path() else { - tracing::debug!("watch prefs: no config directory"); - return Self::default(); - }; - match std::fs::read(&path) { - Ok(bytes) => match serde_json::from_slice::(&bytes) { - Ok(store) => { - tracing::debug!( - path = %path.display(), - kinds = store.kinds.len(), - "watch prefs: loaded" - ); - Self { kinds: store.kinds } - } - Err(error) => { - tracing::warn!( - path = %path.display(), - %error, - "watch prefs: invalid config file, ignoring" - ); - Self::default() - } - }, - Err(error) => { - tracing::debug!( - path = %path.display(), - %error, - "watch prefs: no config file yet" - ); - Self::default() - } - } - } - - pub fn save(&self) -> Result<(), &'static str> { - let Some(path) = Self::path() else { - tracing::warn!("watch prefs: save failed, no config directory"); - return Err("no config directory"); - }; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|_| "failed to create config directory")?; - } - let store = Store { - version: 2, - kinds: self.kinds.clone(), - }; - let bytes = - serde_json::to_vec_pretty(&store).map_err(|_| "failed to serialize watch prefs")?; - std::fs::write(&path, bytes).map_err(|_| "failed to write watch prefs")?; - tracing::debug!(path = %path.display(), kinds = self.kinds.len(), "watch prefs: saved"); - Ok(()) - } - - /// The saved preference for a kind, or its factory default. - pub fn prefs_for(&self, kind: ResourceKind) -> WatchPrefs { - self.kinds - .get(kind.label()) - .copied() - .unwrap_or_else(|| default_prefs_for(kind)) - } - - /// Change a kind's preference. Entries equal to the kind's factory - /// default are dropped. - pub fn update(&mut self, kind: ResourceKind, change: impl FnOnce(&mut WatchPrefs)) { - let mut prefs = self.prefs_for(kind); - change(&mut prefs); - if prefs == default_prefs_for(kind) { - self.kinds.remove(kind.label()); - } else { - self.kinds.insert(kind.label().to_string(), prefs); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn defaults_to_live_watch_with_five_second_fallback() { - let store = WatchPrefsStore::default(); - assert_eq!( - store.prefs_for(ResourceKind::Pods), - WatchPrefs { - mode: WatchPreference::Watch, - rate: PollRate::Every(5), - } - ); - } - - #[test] - fn high_churn_kinds_default_to_polling_until_watch_is_chosen() { - let mut store = WatchPrefsStore::default(); - for kind in [ - ResourceKind::Namespaces, - ResourceKind::Events, - ResourceKind::Leases, - ] { - assert_eq!( - store.prefs_for(kind), - WatchPrefs { - mode: WatchPreference::Poll, - rate: PollRate::Every(5), - } - ); - } - - // Choosing live watch is saved and sticks. - store.update(ResourceKind::Namespaces, |prefs| { - prefs.mode = WatchPreference::Watch; - }); - assert_eq!( - store.prefs_for(ResourceKind::Namespaces).mode, - WatchPreference::Watch - ); - assert!(store.kinds.contains_key("Namespaces")); - - // Switching back to the factory default drops the entry. - store.update(ResourceKind::Namespaces, |prefs| { - prefs.mode = WatchPreference::Poll; - }); - assert!(!store.kinds.contains_key("Namespaces")); - } - - #[test] - fn remembers_mode_and_rate_independently() { - let mut store = WatchPrefsStore::default(); - store.update(ResourceKind::Pods, |prefs| prefs.rate = PollRate::Every(30)); - store.update(ResourceKind::Events, |prefs| { - prefs.mode = WatchPreference::Poll; - prefs.rate = PollRate::Off; - }); - - // A custom rate survives switching between watch and poll. - store.update(ResourceKind::Pods, |prefs| { - prefs.mode = WatchPreference::Poll - }); - assert_eq!( - store.prefs_for(ResourceKind::Pods), - WatchPrefs { - mode: WatchPreference::Poll, - rate: PollRate::Every(30), - } - ); - store.update(ResourceKind::Pods, |prefs| { - prefs.mode = WatchPreference::Watch; - }); - assert_eq!( - store.prefs_for(ResourceKind::Pods), - WatchPrefs { - mode: WatchPreference::Watch, - rate: PollRate::Every(30), - } - ); - - assert_eq!( - store.prefs_for(ResourceKind::Events), - WatchPrefs { - mode: WatchPreference::Poll, - rate: PollRate::Off, - } - ); - assert_eq!(store.prefs_for(ResourceKind::Nodes), WatchPrefs::default()); - } - - #[test] - fn resetting_to_default_drops_the_entry() { - let mut store = WatchPrefsStore::default(); - store.update(ResourceKind::Pods, |prefs| prefs.rate = PollRate::Every(30)); - store.update(ResourceKind::Pods, |prefs| prefs.rate = PollRate::DEFAULT); - assert!(!store.kinds.contains_key("Pods")); - } - - #[test] - fn serializes_as_kind_labels() { - let mut store = WatchPrefsStore::default(); - store.update(ResourceKind::Deployments, |prefs| { - prefs.mode = WatchPreference::Poll; - prefs.rate = PollRate::Every(10); - }); - let json = serde_json::to_string(&Store { - version: 2, - kinds: store.kinds.clone(), - }) - .expect("serialize"); - assert!(json.contains("\"Deployments\"")); - let parsed: Store = serde_json::from_str(&json).expect("deserialize"); - assert_eq!( - parsed.kinds["Deployments"], - WatchPrefs { - mode: WatchPreference::Poll, - rate: PollRate::Every(10), - } - ); - } -} diff --git a/src/ui/resources/overview.rs b/src/ui/resources/overview.rs index 7881ecb..c644e57 100644 --- a/src/ui/resources/overview.rs +++ b/src/ui/resources/overview.rs @@ -18,7 +18,7 @@ use gpui_kit::{ use crate::cluster::ClusterState; use crate::models::Item; use crate::models::resources::node::detail::NodeUsage; -use crate::models::resources::overview::{ClusterOverviewStats, PodHealth, is_unhealthy_pod}; +use crate::models::resources::overview::{ClusterOverviewStats, PodHealth}; use crate::view_models::ClusterOverviewViewModel; pub struct OverviewView { @@ -96,12 +96,18 @@ impl OverviewView { impl Render for OverviewView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let vm = self.vm.read(cx); - let stats = vm.stats().clone(); - let nodes = vm.nodes().to_vec(); - let pods = vm.pods().to_vec(); - let metrics = vm.metrics().clone(); - let metrics_error = vm.metrics_error().map(str::to_string); + // Clone cheap handles only: the VM already cached the derived pod + // preview, and node snapshots/metrics are `Arc`s. + let (stats, nodes, unhealthy, metrics, metrics_error) = { + let vm = self.vm.read(cx); + ( + vm.stats().clone(), + vm.node_snapshot(), + vm.unhealthy_pods(), + vm.metrics().clone(), + vm.metrics_error().map(str::to_string), + ) + }; let header = self.header(&stats, cx); let cards = h_flex() @@ -164,13 +170,13 @@ impl Render for OverviewView { .p_4() .child(cards) .child(node_section( - &nodes, + nodes.items(), &stats, - &metrics, + metrics.as_ref(), metrics_error.as_deref(), cx, )) - .child(pod_section(&stats, &pods, cx)); + .child(pod_section(&stats, &unhealthy, cx)); v_flex() .size_full() @@ -349,7 +355,7 @@ fn node_header_row(cx: &App) -> AnyElement { .into_any_element() } -fn pod_section(stats: &ClusterOverviewStats, pods: &[Arc], cx: &App) -> AnyElement { +fn pod_section(stats: &ClusterOverviewStats, unhealthy: &[Arc], cx: &App) -> AnyElement { let mut health = h_flex().gap_2().items_center(); for (label, count, color) in [ ("Running", stats.pods_running, cx.theme().success), @@ -370,18 +376,9 @@ fn pod_section(stats: &ClusterOverviewStats, pods: &[Arc], cx: &App) -> An health = health.child(health_chip(label, count, color, cx)); } - let mut unhealthy = pods - .iter() - .filter(|pod| is_unhealthy_pod(pod)) - .collect::>(); - unhealthy.sort_by(|left, right| { - left.namespace - .cmp(&right.namespace) - .then_with(|| left.name.cmp(&right.name)) - }); - + let unhealthy_total = stats.pods_unhealthy(); let mut body = v_flex().gap_3().child(health); - if unhealthy.is_empty() { + if unhealthy_total == 0 { body = body.child( div() .text_xs() @@ -394,7 +391,7 @@ fn pod_section(stats: &ClusterOverviewStats, pods: &[Arc], cx: &App) -> An .pt_1() .text_xs() .text_color(cx.theme().muted_foreground) - .child(format!("{} pods need attention", unhealthy.len())), + .child(format!("{unhealthy_total} pods need attention")), ); body = body.child( h_flex() @@ -409,7 +406,7 @@ fn pod_section(stats: &ClusterOverviewStats, pods: &[Arc], cx: &App) -> An .child(div().w(px(72.)).text_right().child("Restarts")) .child(div().w(px(56.)).text_right().child("Age")), ); - for pod in unhealthy.iter().take(8) { + for pod in unhealthy { let status = PodHealth::from_status(&pod.status); body = body.child( h_flex() @@ -461,13 +458,14 @@ fn pod_section(stats: &ClusterOverviewStats, pods: &[Arc], cx: &App) -> An ), ); } - if unhealthy.len() > 8 { + let hidden = unhealthy_total.saturating_sub(unhealthy.len()); + if hidden > 0 { body = body.child( div() .pt_1() .text_xs() .text_color(cx.theme().muted_foreground) - .child(format!("+{} more", unhealthy.len() - 8)), + .child(format!("+{hidden} more")), ); } } diff --git a/src/ui/resources/pod/mod.rs b/src/ui/resources/pod/mod.rs index 6541b6a..5564b5b 100644 --- a/src/ui/resources/pod/mod.rs +++ b/src/ui/resources/pod/mod.rs @@ -144,6 +144,13 @@ impl PodsView { return; } + if let Some(containers) = + repository::resources::pods::cached_containers(cx, &namespace, &name) + { + self.open_logs(namespace, name, containers, container, follow, cx); + return; + } + let mut subscription = match repository::resources::pods::subscribe_detail(cx, &namespace, &name) { Ok(subscription) => subscription, diff --git a/src/ui/resources/pod/sessions.rs b/src/ui/resources/pod/sessions.rs index 2668e5d..e060cd2 100644 --- a/src/ui/resources/pod/sessions.rs +++ b/src/ui/resources/pod/sessions.rs @@ -101,7 +101,7 @@ impl PodSession { pub fn shutdown(self, cx: &mut App) { match self { Self::Logs { vm, .. } => vm.update(cx, |vm, _| vm.shutdown()), - Self::Exec { vm, .. } => vm.update(cx, |vm, _| vm.shutdown()), + Self::Exec { vm, .. } => vm.update(cx, |vm, cx| vm.shutdown(cx)), } } } diff --git a/src/ui/resources/watch_mode.rs b/src/ui/resources/watch_mode.rs index 22490fe..9173b2d 100644 --- a/src/ui/resources/watch_mode.rs +++ b/src/ui/resources/watch_mode.rs @@ -1,41 +1,17 @@ -//! Update-mode indicator for resource list headers. -//! -//! Shows a small "Live" badge while the watch stream is healthy and "Polling" -//! when the store is re-listed on a timer — either because the API server -//! denied `watch` (RBAC) or because the user prefers polling. The dropdown -//! switches the kind between live watch and a poll rate (or off), and the -//! choice is saved per resource kind. +//! Live-watch indicator for resource list headers. use gpui_kit::base::h_flex; -use gpui_kit::component::{ - ActiveTheme as _, IconName, Sizable as _, - button::{Button, ButtonVariants as _}, - menu::{DropdownMenu as _, PopupMenuItem}, -}; +use gpui_kit::component::ActiveTheme as _; use gpui_kit::{AnyElement, App, IntoElement, ParentElement as _, Styled as _, div, px}; -use crate::models::{PollRate, ResourceKind, WatchMode}; -use crate::repository::WatchPreference; +use crate::models::{ResourceKind, WatchMode}; -/// Poll intervals offered in the rate menu, in seconds. -const POLL_RATES: [u64; 5] = [2, 5, 10, 30, 60]; - -pub(crate) fn render(mode: WatchMode, kind: ResourceKind, cx: &App) -> AnyElement { - let (color, label, button_label) = match mode { - WatchMode::Watch => (cx.theme().success, "Live".to_string(), "Live".to_string()), - WatchMode::Poll(PollRate::Every(secs)) => ( - cx.theme().warning, - format!("Polling · {secs}s"), - format!("{secs}s"), - ), - WatchMode::Poll(PollRate::Off) => ( - cx.theme().warning, - "Polling off".to_string(), - "Off".to_string(), - ), +pub(crate) fn render(mode: WatchMode, _kind: ResourceKind, cx: &App) -> AnyElement { + let (color, label) = match mode { + WatchMode::Watch => (cx.theme().success, "Live"), }; - let badge = h_flex() + h_flex() .gap_1() .items_center() .child(div().w(px(6.)).h(px(6.)).rounded_full().bg(color)) @@ -44,44 +20,6 @@ pub(crate) fn render(mode: WatchMode, kind: ResourceKind, cx: &App) -> AnyElemen .text_xs() .text_color(cx.theme().muted_foreground) .child(label), - ); - - let mode_button = Button::new("watch-mode") - .ghost() - .small() - .label(button_label) - .icon(IconName::ChevronsUpDown) - .tooltip( - "How this resource type stays up to date. \ - Watch needs the `watch` permission; otherwise krusty falls back \ - to polling. Saved per resource type.", ) - .dropdown_menu(move |mut menu, _, _| { - menu = menu.item( - PopupMenuItem::new("Live watch") - .checked(mode == WatchMode::Watch) - .on_click(move |_, _, cx| { - crate::repository::set_watch_preference(cx, kind, WatchPreference::Watch); - }), - ); - menu = menu.separator(); - for secs in POLL_RATES { - menu = menu.item( - PopupMenuItem::new(format!("Poll every {secs}s")) - .checked(mode == WatchMode::Poll(PollRate::Every(secs))) - .on_click(move |_, _, cx| { - crate::repository::set_poll_rate(cx, kind, PollRate::Every(secs)); - }), - ); - } - menu.separator().item( - PopupMenuItem::new("Polling off") - .checked(mode == WatchMode::Poll(PollRate::Off)) - .on_click(move |_, _, cx| { - crate::repository::set_poll_rate(cx, kind, PollRate::Off); - }), - ) - }); - - badge.child(mode_button).into_any_element() + .into_any_element() } diff --git a/src/view_models/resources/cluster_roles/mod.rs b/src/view_models/resources/cluster_roles/mod.rs index cad01bc..b1d5475 100644 --- a/src/view_models/resources/cluster_roles/mod.rs +++ b/src/view_models/resources/cluster_roles/mod.rs @@ -164,7 +164,7 @@ impl ClusterRolesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::cluster_roles::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::ClusterRoles, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/config_maps/mod.rs b/src/view_models/resources/config_maps/mod.rs index 84b1834..ade1484 100644 --- a/src/view_models/resources/config_maps/mod.rs +++ b/src/view_models/resources/config_maps/mod.rs @@ -160,7 +160,7 @@ impl ConfigMapsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::config_maps::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::ConfigMaps, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/cron_jobs/mod.rs b/src/view_models/resources/cron_jobs/mod.rs index 1e0e0c7..739f814 100644 --- a/src/view_models/resources/cron_jobs/mod.rs +++ b/src/view_models/resources/cron_jobs/mod.rs @@ -158,18 +158,18 @@ impl CronJobsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::cron_jobs::subscribe(cx, self.scope.clone()) - { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::CronJobs, self.scope.clone()) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/daemon_sets/mod.rs b/src/view_models/resources/daemon_sets/mod.rs index 8b27628..da57169 100644 --- a/src/view_models/resources/daemon_sets/mod.rs +++ b/src/view_models/resources/daemon_sets/mod.rs @@ -160,7 +160,7 @@ impl DaemonSetsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::daemon_sets::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::DaemonSets, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/endpoint_slices/mod.rs b/src/view_models/resources/endpoint_slices/mod.rs index 566a6be..7cf9dff 100644 --- a/src/view_models/resources/endpoint_slices/mod.rs +++ b/src/view_models/resources/endpoint_slices/mod.rs @@ -164,7 +164,7 @@ impl EndpointSlicesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::endpoint_slices::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::EndpointSlices, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/endpoints/mod.rs b/src/view_models/resources/endpoints/mod.rs index 6e8cffb..5460061 100644 --- a/src/view_models/resources/endpoints/mod.rs +++ b/src/view_models/resources/endpoints/mod.rs @@ -158,18 +158,18 @@ impl EndpointsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::endpoints::subscribe(cx, self.scope.clone()) - { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::Endpoints, self.scope.clone()) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/events/mod.rs b/src/view_models/resources/events/mod.rs index e8a5512..09846ad 100644 --- a/src/view_models/resources/events/mod.rs +++ b/src/view_models/resources/events/mod.rs @@ -158,7 +158,8 @@ impl EventsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::events::subscribe(cx, self.scope.clone()) { + let subscription = match repository::subscribe(cx, ResourceKind::Events, self.scope.clone()) + { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/horizontal_pod_autoscalers/mod.rs b/src/view_models/resources/horizontal_pod_autoscalers/mod.rs index d1959c6..2205fad 100644 --- a/src/view_models/resources/horizontal_pod_autoscalers/mod.rs +++ b/src/view_models/resources/horizontal_pod_autoscalers/mod.rs @@ -163,8 +163,9 @@ impl HorizontalPodAutoscalersViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::horizontal_pod_autoscalers::subscribe( + let subscription = match repository::subscribe( cx, + ResourceKind::HorizontalPodAutoscalers, self.scope.clone(), ) { Ok(s) => s, diff --git a/src/view_models/resources/ingresses/mod.rs b/src/view_models/resources/ingresses/mod.rs index 0263e39..58dc6d7 100644 --- a/src/view_models/resources/ingresses/mod.rs +++ b/src/view_models/resources/ingresses/mod.rs @@ -158,18 +158,18 @@ impl IngressesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::ingresses::subscribe(cx, self.scope.clone()) - { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::Ingresses, self.scope.clone()) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/jobs/mod.rs b/src/view_models/resources/jobs/mod.rs index 350b6ac..0ae7d96 100644 --- a/src/view_models/resources/jobs/mod.rs +++ b/src/view_models/resources/jobs/mod.rs @@ -158,7 +158,7 @@ impl JobsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::jobs::subscribe(cx, self.scope.clone()) { + let subscription = match repository::subscribe(cx, ResourceKind::Jobs, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/leases/mod.rs b/src/view_models/resources/leases/mod.rs index c4169a1..4869ee0 100644 --- a/src/view_models/resources/leases/mod.rs +++ b/src/view_models/resources/leases/mod.rs @@ -158,7 +158,8 @@ impl LeasesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::leases::subscribe(cx, self.scope.clone()) { + let subscription = match repository::subscribe(cx, ResourceKind::Leases, self.scope.clone()) + { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/namespaces/mod.rs b/src/view_models/resources/namespaces/mod.rs index bf4aa32..2b1ad8f 100644 --- a/src/view_models/resources/namespaces/mod.rs +++ b/src/view_models/resources/namespaces/mod.rs @@ -159,17 +159,18 @@ impl NamespacesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::namespaces::subscribe(cx) { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::Namespaces, NamespaceScope::All) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/network_policies/mod.rs b/src/view_models/resources/network_policies/mod.rs index 7150378..01fe464 100644 --- a/src/view_models/resources/network_policies/mod.rs +++ b/src/view_models/resources/network_policies/mod.rs @@ -164,7 +164,7 @@ impl NetworkPoliciesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::network_policies::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::NetworkPolicies, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/node/mod.rs b/src/view_models/resources/node/mod.rs index ed61fa1..efe5d82 100644 --- a/src/view_models/resources/node/mod.rs +++ b/src/view_models/resources/node/mod.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; use gpui_kit::component::table::{Column, TableDelegate, TableEvent, TableState}; use gpui_kit::px; @@ -17,6 +18,10 @@ use crate::models::resources::node::detail::{NodeDetailState, UsageHistory}; use crate::models::{Item, NamespaceScope, ResourceKind, SnapshotPhase, WatchMode}; use crate::repository; +/// Large clusters emit node updates continuously. Repainting the table for +/// every repository flush makes it feel sluggish, so coalesce snapshots. +const NODES_REFRESH_INTERVAL: Duration = Duration::from_millis(250); + pub struct NodesViewModel { active: Arc, scope: NamespaceScope, @@ -305,6 +310,7 @@ impl NodesViewModel { let model = self.model.downgrade(); let mut pump = subscription.clone(); cx.spawn(async move |_, cx: &mut gpui_kit::AsyncApp| { + let mut last_refresh: Option = None; loop { let snapshot = tokio::select! { _ = cancel_rx.changed() => break, @@ -313,6 +319,21 @@ impl NodesViewModel { let Some(snapshot) = snapshot else { break; }; + + // The watch channel keeps only the latest snapshot, so waiting + // here skips intermediate flushes instead of rendering them. + if let Some(last) = last_refresh { + let elapsed = last.elapsed(); + if elapsed < NODES_REFRESH_INTERVAL { + tokio::select! { + _ = cancel_rx.changed() => break, + _ = cx + .background_executor() + .timer(NODES_REFRESH_INTERVAL - elapsed) => {} + } + } + } + last_refresh = Some(Instant::now()); let applied = model.update(cx, |model, cx| { model.apply_snapshot(snapshot); cx.notify(); diff --git a/src/view_models/resources/overview.rs b/src/view_models/resources/overview.rs index 7fae225..1c85ccc 100644 --- a/src/view_models/resources/overview.rs +++ b/src/view_models/resources/overview.rs @@ -7,23 +7,34 @@ use std::collections::BTreeMap; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; use gpui_kit::{Context, Subscription, Window}; use tokio::sync::watch; use crate::cluster::ClusterState; use crate::models::resources::node::detail::NodeUsage; -use crate::models::resources::overview::ClusterOverviewStats; -use crate::models::{NamespaceScope, ResourceKind, ResourceSnapshot}; +use crate::models::resources::overview::{ClusterOverviewStats, is_unhealthy_pod}; +use crate::models::{Item, NamespaceScope, ResourceKind, ResourceSnapshot}; use crate::repository; +/// Pods change constantly on a busy cluster. The overview only shows +/// aggregate counts and a handful of rows, so coalesce watch snapshots instead +/// of recomputing and repainting for every 50 ms repository flush. +const OVERVIEW_REFRESH_INTERVAL: Duration = Duration::from_millis(250); +/// Rows shown in the "Pods needing attention" preview. +const UNHEALTHY_PREVIEW: usize = 8; + pub struct ClusterOverviewViewModel { active: Arc, epoch: u64, node_snapshot: Arc, pod_snapshot: Arc, stats: ClusterOverviewStats, - metrics: BTreeMap, + /// Sorted unhealthy pods, truncated to [`UNHEALTHY_PREVIEW`], derived once + /// per refresh so rendering does not rescan every pod. + unhealthy: Arc<[Arc]>, + metrics: Arc>, metrics_error: Option, metrics_generation: u64, nodes: Option, @@ -57,7 +68,8 @@ impl ClusterOverviewViewModel { node_snapshot: Arc::new(ResourceSnapshot::loading()), pod_snapshot: Arc::new(ResourceSnapshot::loading()), stats: ClusterOverviewStats::default(), - metrics: BTreeMap::new(), + unhealthy: Arc::from(Vec::>::new()), + metrics: Arc::new(BTreeMap::new()), metrics_error: None, metrics_generation: 0, nodes: None, @@ -75,15 +87,18 @@ impl ClusterOverviewViewModel { &self.stats } - pub fn nodes(&self) -> &[Arc] { - self.node_snapshot.items() + /// O(1) clone of the current nodes snapshot; the render path uses it + /// instead of copying every `Arc` into a temporary `Vec`. + pub fn node_snapshot(&self) -> Arc { + self.node_snapshot.clone() } - pub fn pods(&self) -> &[Arc] { - self.pod_snapshot.items() + /// Top unhealthy pods, already sorted and truncated for the preview. + pub fn unhealthy_pods(&self) -> Arc<[Arc]> { + self.unhealthy.clone() } - pub fn metrics(&self) -> &BTreeMap { + pub fn metrics(&self) -> &Arc> { &self.metrics } @@ -174,37 +189,62 @@ impl ClusterOverviewViewModel { let mut nodes = self.nodes.clone(); let mut pods = self.pods.clone(); cx.spawn(async move |this, cx| { + let mut last_refresh: Option = None; loop { - tokio::select! { + let update = tokio::select! { _ = cancel_rx.changed() => break, - snapshot = next_snapshot(&mut nodes) => { - let Some(snapshot) = snapshot else { + snapshot = next_snapshot(&mut nodes) => match snapshot { + Some(snapshot) => Some((false, snapshot)), + None => { nodes = None; - continue; - }; - let applied = this.update(cx, |this, cx| { - this.node_snapshot = snapshot; - this.recompute(); - cx.notify(); - }); - if applied.is_err() { - break; - } - } - snapshot = next_snapshot(&mut pods) => { - let Some(snapshot) = snapshot else { + None } + }, + snapshot = next_snapshot(&mut pods) => match snapshot { + Some(snapshot) => Some((true, snapshot)), + None => { pods = None; - continue; - }; - let applied = this.update(cx, |this, cx| { - this.pod_snapshot = snapshot; - this.recompute(); - cx.notify(); - }); - if applied.is_err() { - break; + None } + }, + }; + let Some((pod_update, snapshot)) = update else { + if nodes.is_none() && pods.is_none() { + break; + } + continue; + }; + + // Coalesce bursts: watch channels keep only the latest value, + // so waiting here drops intermediate snapshots instead of + // recomputing and repainting for each one. + if let Some(last) = last_refresh { + let elapsed = last.elapsed(); + if elapsed < OVERVIEW_REFRESH_INTERVAL { + tokio::select! { + _ = cancel_rx.changed() => break, + _ = cx + .background_executor() + .timer(OVERVIEW_REFRESH_INTERVAL - elapsed) => {} } + } + } + last_refresh = Some(Instant::now()); + + let applied = this.update(cx, |this, cx| { + if pod_update { + this.pod_snapshot = snapshot; + } else { + this.node_snapshot = snapshot; } + // A hidden tab may still finish one in-flight refresh. + // Keep the snapshot but skip the work until `set_active` + // recomputes on resume. + if this.is_active() { + this.recompute(); + cx.notify(); + } + }); + if applied.is_err() { + break; } } }) @@ -215,6 +255,21 @@ impl ClusterOverviewViewModel { fn recompute(&mut self) { self.stats = ClusterOverviewStats::from_items(self.node_snapshot.items(), self.pod_snapshot.items()); + + let mut unhealthy: Vec> = self + .pod_snapshot + .items() + .iter() + .filter(|pod| is_unhealthy_pod(pod)) + .cloned() + .collect(); + unhealthy.sort_by(|left, right| { + left.namespace + .cmp(&right.namespace) + .then_with(|| left.name.cmp(&right.name)) + }); + unhealthy.truncate(UNHEALTHY_PREVIEW); + self.unhealthy = Arc::from(unhealthy); } fn cancel_pump(&mut self) { @@ -229,7 +284,7 @@ impl ClusterOverviewViewModel { if let Some(cancel) = self.metrics_cancel.take() { let _ = cancel.send(true); } - self.metrics.clear(); + self.metrics = Arc::new(BTreeMap::new()); self.metrics_error = None; self.metrics_generation = self.metrics_generation.wrapping_add(1); } @@ -242,7 +297,7 @@ impl ClusterOverviewViewModel { if let Some(cancel) = self.metrics_cancel.take() { let _ = cancel.send(true); } - self.metrics.clear(); + self.metrics = Arc::new(BTreeMap::new()); self.metrics_error = None; self.metrics_generation = self.metrics_generation.wrapping_add(1); let generation = self.metrics_generation; @@ -264,11 +319,11 @@ impl ClusterOverviewViewModel { } match update { Ok(metrics) => { - this.metrics = metrics; + this.metrics = Arc::new(metrics); this.metrics_error = None; } Err(error) => { - this.metrics.clear(); + this.metrics = Arc::new(BTreeMap::new()); this.metrics_error = Some(error); } } diff --git a/src/view_models/resources/persistent_volume_claims/mod.rs b/src/view_models/resources/persistent_volume_claims/mod.rs index eef76f3..272fff0 100644 --- a/src/view_models/resources/persistent_volume_claims/mod.rs +++ b/src/view_models/resources/persistent_volume_claims/mod.rs @@ -163,8 +163,9 @@ impl PersistentVolumeClaimsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::persistent_volume_claims::subscribe( + let subscription = match repository::subscribe( cx, + ResourceKind::PersistentVolumeClaims, self.scope.clone(), ) { Ok(s) => s, diff --git a/src/view_models/resources/persistent_volumes/mod.rs b/src/view_models/resources/persistent_volumes/mod.rs index 7e9adeb..fe9084e 100644 --- a/src/view_models/resources/persistent_volumes/mod.rs +++ b/src/view_models/resources/persistent_volumes/mod.rs @@ -163,17 +163,18 @@ impl PersistentVolumesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::persistent_volumes::subscribe(cx) { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::PersistentVolumes, NamespaceScope::All) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/pod/exec.rs b/src/view_models/resources/pod/exec.rs index c1b6a58..c492e9b 100644 --- a/src/view_models/resources/pod/exec.rs +++ b/src/view_models/resources/pod/exec.rs @@ -1,11 +1,15 @@ //! ViewModel for an interactive TTY exec session in a pod container. +//! +//! The session itself lives in [`crate::repository::terminal`]. This view model +//! only attaches to it, so recreating the view does not kill the shell. use std::num::NonZeroU16; +use std::sync::Arc; use gpui_kit::Context; -use tokio::sync::{mpsc, watch}; +use tokio::sync::{broadcast, mpsc}; -use crate::repository::resources::pods; +use crate::repository::terminal::{self, TerminalEvent, TerminalSession}; const SCROLLBACK_LINES: usize = 5_000; const DEFAULT_COLS: u16 = 80; @@ -24,8 +28,8 @@ pub struct PodExecViewModel { error: Option, input_tx: Option>>, resize_tx: Option>, + session: Option>, generation: u64, - cancel: Option>, } impl PodExecViewModel { @@ -54,8 +58,8 @@ impl PodExecViewModel { error: None, input_tx: None, resize_tx: None, + session: None, generation: 0, - cancel: None, }; this.start_session(cx); this @@ -151,12 +155,14 @@ impl PodExecViewModel { cx.notify(); } - pub fn shutdown(&mut self) { - if let Some(cancel) = self.cancel.take() { - let _ = cancel.send(true); + /// Destroy the stored session. Called when the terminal tab is closed. + pub fn shutdown(&mut self, cx: &mut gpui_kit::App) { + if let Some(session) = self.session.take() { + terminal::destroy(cx, session.key()); } self.input_tx = None; self.resize_tx = None; + self.generation = self.generation.wrapping_add(1); } fn restart(&mut self, cx: &mut Context) { @@ -166,8 +172,8 @@ impl PodExecViewModel { } fn start_session(&mut self, cx: &mut Context) { - if let Some(cancel) = self.cancel.take() { - let _ = cancel.send(true); + if let Some(session) = self.session.take() { + terminal::destroy(cx, session.key()); } self.generation = self.generation.wrapping_add(1); let generation = self.generation; @@ -177,7 +183,7 @@ impl PodExecViewModel { self.status = format!("Starting {} in {}…", self.shell, self.container); cx.notify(); - let session = match pods::exec_shell( + let session = match terminal::open( cx, &self.namespace, &self.name, @@ -193,51 +199,63 @@ impl PodExecViewModel { return; } }; - self.cancel = Some(session.cancel_handle()); + + let attachment = session.attach(); + if !attachment.history.is_empty() { + self.parser.process(&attachment.history); + } self.input_tx = Some(session.input_handle()); let resize = session.resize_handle(); let (rows, cols) = self.parser.screen().size(); let _ = resize.send((cols.get(), rows.get())); self.resize_tx = Some(resize); + self.session = Some(session); self.status = format!("Connecting · {} · {}", self.container, self.shell); + let mut updates = attachment.updates; cx.spawn(async move |this, cx| { - let mut session = session; - while let Some(update) = session.next().await { + loop { + let event = match updates.recv().await { + Ok(event) => event, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + }; + let current = this.update(cx, |this, cx| { if this.generation != generation { return false; } - match update { - Ok(bytes) => { + match event { + TerminalEvent::Data(bytes) => { this.parser.process(&bytes); this.error = None; this.status = format!("Connected · {} · {}", this.container, this.shell); } - Err(error) => { + TerminalEvent::Error(error) => { this.process_error(&error); this.error = Some(error); this.status = "Exec session error".to_string(); } + TerminalEvent::Ended => { + if this.error.is_none() { + this.parser.process(b"\r\n[session ended]\r\n"); + this.status = "Exec session ended".to_string(); + } + this.input_tx = None; + this.resize_tx = None; + this.session = None; + cx.notify(); + return false; + } } cx.notify(); true }); - match current { - Ok(true) => {} - _ => break, + if !matches!(current, Ok(true)) { + break; } } - let _ = this.update(cx, |this, cx| { - if this.generation == generation { - this.parser.process(b"\r\n[session ended]\r\n"); - this.status = "Exec session ended".to_string(); - this.input_tx = None; - this.resize_tx = None; - cx.notify(); - } - }); }) .detach(); } @@ -250,8 +268,9 @@ impl PodExecViewModel { impl Drop for PodExecViewModel { fn drop(&mut self) { - if let Some(cancel) = self.cancel.take() { - let _ = cancel.send(true); - } + // Detaching from the view must not destroy the stored session. Closing + // the tab calls `shutdown`, which performs the explicit destroy. + self.input_tx = None; + self.resize_tx = None; } } diff --git a/src/view_models/resources/pod_disruption_budgets/mod.rs b/src/view_models/resources/pod_disruption_budgets/mod.rs index ef52404..261c17e 100644 --- a/src/view_models/resources/pod_disruption_budgets/mod.rs +++ b/src/view_models/resources/pod_disruption_budgets/mod.rs @@ -163,20 +163,19 @@ impl PodDisruptionBudgetsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::pod_disruption_budgets::subscribe( - cx, - self.scope.clone(), - ) { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::PodDisruptionBudgets, self.scope.clone()) + { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/replica_sets/mod.rs b/src/view_models/resources/replica_sets/mod.rs index 87aeaa7..b3a11ef 100644 --- a/src/view_models/resources/replica_sets/mod.rs +++ b/src/view_models/resources/replica_sets/mod.rs @@ -164,7 +164,7 @@ impl ReplicaSetsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::replica_sets::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::ReplicaSets, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/replication_controllers/mod.rs b/src/view_models/resources/replication_controllers/mod.rs index 0d46a2c..29bdd4b 100644 --- a/src/view_models/resources/replication_controllers/mod.rs +++ b/src/view_models/resources/replication_controllers/mod.rs @@ -163,19 +163,21 @@ impl ReplicationControllersViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = - match repository::resources::replication_controllers::subscribe(cx, self.scope.clone()) - { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = match repository::subscribe( + cx, + ResourceKind::ReplicationControllers, + self.scope.clone(), + ) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/role_bindings/mod.rs b/src/view_models/resources/role_bindings/mod.rs index f86597c..43bbadb 100644 --- a/src/view_models/resources/role_bindings/mod.rs +++ b/src/view_models/resources/role_bindings/mod.rs @@ -164,7 +164,7 @@ impl RoleBindingsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::role_bindings::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::RoleBindings, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/roles/mod.rs b/src/view_models/resources/roles/mod.rs index 07d22cc..521568e 100644 --- a/src/view_models/resources/roles/mod.rs +++ b/src/view_models/resources/roles/mod.rs @@ -158,7 +158,8 @@ impl RolesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::roles::subscribe(cx, self.scope.clone()) { + let subscription = match repository::subscribe(cx, ResourceKind::Roles, self.scope.clone()) + { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/secrets/mod.rs b/src/view_models/resources/secrets/mod.rs index c5cd142..230d1c4 100644 --- a/src/view_models/resources/secrets/mod.rs +++ b/src/view_models/resources/secrets/mod.rs @@ -158,17 +158,18 @@ impl SecretsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::secrets::subscribe(cx, self.scope.clone()) { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::Secrets, self.scope.clone()) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/service_accounts/mod.rs b/src/view_models/resources/service_accounts/mod.rs index a7ce13e..290c5b6 100644 --- a/src/view_models/resources/service_accounts/mod.rs +++ b/src/view_models/resources/service_accounts/mod.rs @@ -164,7 +164,7 @@ impl ServiceAccountsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::service_accounts::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::ServiceAccounts, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/services/mod.rs b/src/view_models/resources/services/mod.rs index 11a3c52..548e8cc 100644 --- a/src/view_models/resources/services/mod.rs +++ b/src/view_models/resources/services/mod.rs @@ -158,18 +158,18 @@ impl ServicesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::services::subscribe(cx, self.scope.clone()) - { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::Services, self.scope.clone()) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active(); diff --git a/src/view_models/resources/stateful_sets/mod.rs b/src/view_models/resources/stateful_sets/mod.rs index 741c40a..816bf3d 100644 --- a/src/view_models/resources/stateful_sets/mod.rs +++ b/src/view_models/resources/stateful_sets/mod.rs @@ -164,7 +164,7 @@ impl StatefulSetsViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); let subscription = - match repository::resources::stateful_sets::subscribe(cx, self.scope.clone()) { + match repository::subscribe(cx, ResourceKind::StatefulSets, self.scope.clone()) { Ok(s) => s, Err(error) => { use crate::models::ResourceSnapshot; diff --git a/src/view_models/resources/storage_classes/mod.rs b/src/view_models/resources/storage_classes/mod.rs index 00b188e..8dd7cf5 100644 --- a/src/view_models/resources/storage_classes/mod.rs +++ b/src/view_models/resources/storage_classes/mod.rs @@ -163,17 +163,18 @@ impl StorageClassesViewModel { fn subscribe(&mut self, cx: &mut Context) { self.cancel_pump(); - let subscription = match repository::resources::storage_classes::subscribe(cx) { - Ok(s) => s, - Err(error) => { - use crate::models::ResourceSnapshot; - self.model.update(cx, |model, cx| { - model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); - cx.notify(); - }); - return; - } - }; + let subscription = + match repository::subscribe(cx, ResourceKind::StorageClasses, NamespaceScope::All) { + Ok(s) => s, + Err(error) => { + use crate::models::ResourceSnapshot; + self.model.update(cx, |model, cx| { + model.apply_snapshot(Arc::new(ResourceSnapshot::failed(error))); + cx.notify(); + }); + return; + } + }; subscription.set_paused(!self.is_active()); let initial = subscription.snapshot(); let notify_now = self.is_active();