From e1de441bd73977463321e519c9639807c8122249 Mon Sep 17 00:00:00 2001 From: Daniel Guns Date: Thu, 16 Jul 2026 19:01:00 -0300 Subject: [PATCH] discover extraobjects Signed-off-by: Daniel Guns --- CHANGELOG.md | 9 + README.md | 1 + docs/content/configuration/_index.md | 35 ++ docs/content/user-guide/_index.md | 5 + .../simple/00-discovered-crd.yaml | 38 ++ scripts/dev-manifests/simple/widgets.yaml | 34 ++ src/cli/config.rs | 377 ++++++++++++------ src/config/mod.rs | 54 ++- src/config/schema.rs | 42 ++ src/kube/api.rs | 8 +- src/models/extra_kinds.rs | 294 ++++++++++++++ src/models/mod.rs | 3 + src/operations.rs | 12 +- src/services/cluster_session.rs | 8 +- src/tui/app/core.rs | 18 + src/tui/app/events.rs | 85 +++- src/tui/commands.rs | 16 + src/tui/mod.rs | 28 ++ src/tui/views/help.rs | 8 + src/watcher/mod.rs | 221 ++++++++++ tests/live_tests.rs | 70 ++++ tests/snapshot_tests.rs | 1 + 22 files changed, 1241 insertions(+), 126 deletions(-) create mode 100644 scripts/dev-manifests/simple/00-discovered-crd.yaml create mode 100644 scripts/dev-manifests/simple/widgets.yaml create mode 100644 src/models/extra_kinds.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b166a9..db064e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Opt-in dynamic CRD discovery (#197): with `discoverFluxResources: true`, + flux9s watches CRDs labeled `app.kubernetes.io/part-of=flux` — the same + label the Flux Operator's FluxReport reads — and shows their resources + live: unified list with readiness from standard conditions, `:` commands + from the CRD's own names and short names, `y`/`d`, filtering, and pulse + counts. Kinds register and deregister dynamically as CRDs are labeled or + deleted. Discovered kinds are strictly view-only (mutating operations are + gated to built-in Flux kinds), built-in CRDs are excluded from discovery, + and with the flag off (default) no CRD watch runs at all. - Cluster pulse dashboard (#195): `:pulse` answers "is my GitOps pipeline healthy?" at a glance — ready/failed/suspended totals and per-kind counts for the current namespace scope, the most recent failures with their diff --git a/README.md b/README.md index f079e89..04d01a3 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ A [K9s](https://github.com/derailed/k9s)-inspired terminal UI for monitoring Flu - **Controller logs** - Stream any Flux controller pod's logs live with `:logs` (follow, search, bounded buffer) - **Workload drill-down** - Open a graph workload group to walk Deployments/StatefulSets/DaemonSets: rollout status, containers, pods, events, and pod logs - **Pulse dashboard** - `:pulse` shows cluster health at a glance: per-kind counts, recent failures, and Flux distribution info +- **CRD discovery (opt-in)** - `discoverFluxResources: true` dynamically shows any CRD labeled `app.kubernetes.io/part-of=flux` (Flagger, tofu-controller, ExternalArtifact-SDK controllers), view-only with generic columns - **Graph visualization** - Visualize resource relationships and dependencies (Kustomization, HelmRelease, etc.) - **Reconciliation history** - View reconciliation history for resources that track it - **Favorites** - Mark frequently accessed resources for quick access diff --git a/docs/content/configuration/_index.md b/docs/content/configuration/_index.md index 3fdd152..c7fe96a 100644 --- a/docs/content/configuration/_index.md +++ b/docs/content/configuration/_index.md @@ -32,6 +32,7 @@ flux9s config path | `defaultControllerNamespace` | string | `flux-system` | Namespace where Flux controllers run | | `defaultResourceFilter` | string | *(none)* | Resource type shown at startup (e.g., `Kustomization`) | | `connectTimeoutSeconds` | integer | `10` | Startup Kubernetes API health-check timeout in seconds | +| `discoverFluxResources` | boolean | `false` | Opt-in dynamic discovery of Flux-adjacent CRDs (see below) | | `ui.enableMouse` | bool | `false` | Enable mouse support | | `ui.headless` | bool | `false` | Hide the header bar | | `ui.noIcons` | bool | `false` | Disable Unicode icons for terminal compatibility | @@ -126,6 +127,40 @@ You can also change the filter interactively during a session by typing `:ks`, ` --- +### Discovering Flux-Adjacent Resource Kinds + +```yaml +discoverFluxResources: true +``` + +Off by default. When enabled, flux9s watches CustomResourceDefinitions +labeled `app.kubernetes.io/part-of=flux` — the **same label the Flux +Operator's FluxReport uses** to enumerate reconcilers — and shows their +resources alongside the built-in kinds: they appear in the unified list with +generic columns (readiness from standard conditions), get `:` commands from +the CRD's own names (`:widget`, its plural, and any `kubectl` short names), +and support `y`/`d`. Kinds appear and disappear live as CRDs are labeled or +removed — no restart needed. + +To register a kind (for example Flagger's Canary), label its CRD once: + +```bash +kubectl label crd canaries.flagger.app app.kubernetes.io/part-of=flux +``` + +That single label registers it with both the Flux Operator's report and +flux9s. + +Guard rails: + +- Discovered kinds are **view-only**: suspend, resume, reconcile, and delete + never apply to them +- Built-in Flux kinds are excluded from discovery (the FluxInstance labels + its own CRDs, and those are already watched natively) +- Cluster-scoped CRDs are skipped +- When the flag is off (the default), no CRD watch runs and no extra API + calls are made + ### Kubernetes API Connection Timeout At startup, flux9s probes the Kubernetes API server before starting watchers. If the kubeconfig, context, credentials, network, or API server is not working, flux9s shows a connection error screen instead of hanging indefinitely. diff --git a/docs/content/user-guide/_index.md b/docs/content/user-guide/_index.md index 1861a43..fbcf2cf 100644 --- a/docs/content/user-guide/_index.md +++ b/docs/content/user-guide/_index.md @@ -124,6 +124,11 @@ You can filter by resource type using commands like: All resource type commands support autocomplete with `Tab` key. +With [`discoverFluxResources`](../configuration/#discovering-flux-adjacent-resource-kinds) +enabled, CRDs labeled `app.kubernetes.io/part-of=flux` (the Flux Operator's +convention) also get commands here — the kind name, plural, and `kubectl` +short names all work, and the kinds appear in the unified list (view-only). + ## Interactive Submenus Some commands open interactive selection menus when used without arguments, providing an easier way to select from available options. diff --git a/scripts/dev-manifests/simple/00-discovered-crd.yaml b/scripts/dev-manifests/simple/00-discovered-crd.yaml new file mode 100644 index 0000000..db0c562 --- /dev/null +++ b/scripts/dev-manifests/simple/00-discovered-crd.yaml @@ -0,0 +1,38 @@ +# A minimal custom CRD labeled part-of=flux — the Flux Operator convention +# that flux9s's opt-in CRD discovery (#197, discoverFluxResources) reads. +# Applied first (00- prefix) so the Widget CRs later in the glob order find +# an established CRD. +# +# No status subresource on purpose: the fixture CRs carry their own +# status.conditions so readiness extraction is testable without a controller. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: widgets.example.com + labels: + app.kubernetes.io/part-of: flux + app.kubernetes.io/managed-by: flux9s-dev +spec: + group: example.com + scope: Namespaced + names: + kind: Widget + plural: widgets + singular: widget + shortNames: [wd] + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + size: + type: string + status: + type: object + x-kubernetes-preserve-unknown-fields: true diff --git a/scripts/dev-manifests/simple/widgets.yaml b/scripts/dev-manifests/simple/widgets.yaml new file mode 100644 index 0000000..4fe9175 --- /dev/null +++ b/scripts/dev-manifests/simple/widgets.yaml @@ -0,0 +1,34 @@ +# Widget CRs for the discovered-kind fixture (see 00-discovered-crd.yaml). +# One healthy, one failing — statuses are set in the manifest (the CRD has +# no status subresource), so readiness renders without a real controller. +apiVersion: example.com/v1 +kind: Widget +metadata: + name: widget-healthy + namespace: flux-resources + labels: {app.kubernetes.io/managed-by: flux9s-dev} +spec: + size: large +status: + conditions: + - type: Ready + status: "True" + reason: Reconciled + message: Widget is operational + lastTransitionTime: "2026-07-16T00:00:00Z" +--- +apiVersion: example.com/v1 +kind: Widget +metadata: + name: widget-broken + namespace: flux-resources + labels: {app.kubernetes.io/managed-by: flux9s-dev} +spec: + size: small +status: + conditions: + - type: Ready + status: "False" + reason: ProcessingError + message: widget assembly failed + lastTransitionTime: "2026-07-16T00:00:00Z" diff --git a/src/cli/config.rs b/src/cli/config.rs index 1bcfb7a..4a83025 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -232,130 +232,273 @@ pub async fn handle_config_command(cmd: ConfigSubcommand) -> Result<()> { Ok(()) } -/// Returns " # (default)" suffix when the value matches its default, empty string otherwise -fn default_marker(current: &T, default: &T) -> &'static str { - if current == default { - " # (default)" - } else { - "" +/// Display configuration with all fields visible, annotating defaults. +fn display_config_with_defaults(config: &Config) { + print!("{}", render_config_listing(config)); + print!("{}", reference_text()); +} + +/// Render the full configuration as annotated YAML. +/// +/// The field set comes from [`Config::fully_populated`] — serde serialization +/// of the *real* config omits `skip_serializing_if` fields, so walking the +/// fully-populated skeleton is what guarantees every field (present and +/// future) appears in the listing. Values come from the user's config; +/// fields it omitted render as their empty form with a `# (default)` marker. +fn render_config_listing(config: &Config) -> String { + let current = serde_yaml::to_value(config).unwrap_or_default(); + let defaults = serde_yaml::to_value(Config::default()).unwrap_or_default(); + let skeleton = serde_yaml::to_value(Config::fully_populated()).unwrap_or_default(); + + let mut out = String::new(); + if let Some(skel) = skeleton.as_mapping() { + render_section( + &mut out, + skel, + current.as_mapping(), + defaults.as_mapping(), + 0, + ); } + out } -/// Display configuration with all fields visible, annotating which values are defaults -fn display_config_with_defaults(config: &Config) { - let d = Config::default(); - - println!( - "readOnly: {}{}", - config.read_only, - default_marker(&config.read_only, &d.read_only) - ); - println!( - "defaultNamespace: {}{}", - config.default_namespace, - default_marker(&config.default_namespace, &d.default_namespace) - ); - println!( - "defaultControllerNamespace: {}{}", - config.default_controller_namespace, - default_marker( - &config.default_controller_namespace, - &d.default_controller_namespace - ) - ); - match &config.default_resource_filter { - Some(f) => println!("defaultResourceFilter: {}", f), - None => println!("defaultResourceFilter: ~ # (default: none, shows all resource types)"), +fn render_section( + out: &mut String, + skeleton: &serde_yaml::Mapping, + current: Option<&serde_yaml::Mapping>, + defaults: Option<&serde_yaml::Mapping>, + indent: usize, +) { + use serde_yaml::Value; + + let pad = " ".repeat(indent); + for (key, skel_val) in skeleton { + let key_str = key.as_str().unwrap_or_default(); + let cur = current.and_then(|m| m.get(key)); + let def = defaults.and_then(|m| m.get(key)); + // A field the user's config omitted is at its (empty) default. + let marker = if normalize(cur, skel_val) == normalize(def, skel_val) { + " # (default)" + } else { + "" + }; + + match skel_val { + // Struct sections (e.g. `ui`) recurse over the skeleton's keys so + // omitted subfields still show. Free-form user maps (contextSkins, + // cluster) render the user's actual entries — the skeleton only + // holds sample data for those. The defaults config tells them + // apart: struct sections serialize their fields, user maps are + // empty-and-skipped by default. + Value::Mapping(skel_inner) => { + let struct_like = def + .and_then(Value::as_mapping) + .is_some_and(|m| !m.is_empty()); + if struct_like { + out.push_str(&format!("{pad}{key_str}:\n")); + render_section( + out, + skel_inner, + cur.and_then(Value::as_mapping), + def.and_then(Value::as_mapping), + indent + 1, + ); + } else { + match cur.and_then(Value::as_mapping).filter(|m| !m.is_empty()) { + Some(m) => { + out.push_str(&format!("{pad}{key_str}:\n")); + let yaml = serde_yaml::to_string(&Value::Mapping(m.clone())) + .unwrap_or_default(); + for line in yaml.lines() { + out.push_str(&format!("{pad} {line}\n")); + } + } + None => out.push_str(&format!("{pad}{key_str}: {{}}{marker}\n")), + } + } + } + Value::Sequence(_) => { + match cur.and_then(Value::as_sequence).filter(|s| !s.is_empty()) { + Some(seq) => { + out.push_str(&format!("{pad}{key_str}:{marker}\n")); + for item in seq { + out.push_str(&format!("{pad} - {}\n", scalar_str(item))); + } + } + None => out.push_str(&format!("{pad}{key_str}: []{marker}\n")), + } + } + _ => { + let rendered = cur.map(scalar_str).unwrap_or_else(|| "~".to_string()); + out.push_str(&format!("{pad}{key_str}: {rendered}{marker}\n")); + } + } + } +} + +/// A field missing from a serialized config was skipped because it is empty; +/// resolve it to the empty value of its type (per the skeleton) so it +/// compares equal to an equally-empty default. +fn normalize(value: Option<&serde_yaml::Value>, skeleton: &serde_yaml::Value) -> serde_yaml::Value { + use serde_yaml::Value; + match value { + Some(v) => v.clone(), + None => match skeleton { + Value::Mapping(_) => Value::Mapping(serde_yaml::Mapping::new()), + Value::Sequence(_) => Value::Sequence(Vec::new()), + _ => Value::Null, + }, } - println!( - "connectTimeoutSeconds: {}{}", - config.connect_timeout_seconds, - default_marker(&config.connect_timeout_seconds, &d.connect_timeout_seconds) - ); - println!(); - - println!("ui:"); - println!( - " enableMouse: {}{}", - config.ui.enable_mouse, - default_marker(&config.ui.enable_mouse, &d.ui.enable_mouse) - ); - println!( - " headless: {}{}", - config.ui.headless, - default_marker(&config.ui.headless, &d.ui.headless) - ); - println!( - " noIcons: {}{}", - config.ui.no_icons, - default_marker(&config.ui.no_icons, &d.ui.no_icons) - ); - println!( - " skin: {}{}", - config.ui.skin, - default_marker(&config.ui.skin, &d.ui.skin) - ); - if let Some(ref skin_ro) = config.ui.skin_read_only { - println!(" skinReadOnly: {}", skin_ro); - } else { - println!(" skinReadOnly: ~ # (default: uses 'skin' when readOnly=true)"); +} + +fn scalar_str(value: &serde_yaml::Value) -> String { + use serde_yaml::Value; + match value { + Value::Null => "~".to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::String(s) => s.clone(), + other => serde_yaml::to_string(other) + .unwrap_or_default() + .trim_end() + .to_string(), } - println!( - " splashless: {}{}", - config.ui.splashless, - default_marker(&config.ui.splashless, &d.ui.splashless) - ); - println!(); - - if config.namespace_hotkeys.is_empty() { - println!("namespaceHotkeys: [] # (default: auto-discover at startup)"); - } else { - println!("namespaceHotkeys:"); - for (idx, ns) in config.namespace_hotkeys.iter().enumerate() { - println!(" - {} # Hotkey {}", ns, idx); +} + +/// The configuration reference appended to `config list`. Every schema field +/// must be mentioned here — enforced by `reference_docs_cover_every_field`. +fn reference_text() -> String { + let mut s = String::from("\n# Configuration Reference:\n"); + for line in [ + "readOnly - Disable modification operations (default: true)", + "defaultNamespace - Starting namespace (default: flux-system)", + "defaultControllerNamespace - Flux controller namespace (default: flux-system)", + "discoverFluxResources - Discover CRDs labeled app.kubernetes.io/part-of=flux as view-only kinds (default: false)", + "defaultResourceFilter - Resource type filter at startup, e.g. \"Kustomization\" (default: none, shows all)", + "connectTimeoutSeconds - Startup Kubernetes API health-check timeout in seconds (default: 10)", + "ui.enableMouse - Enable mouse support (default: false)", + "ui.headless - Hide header (default: false)", + "ui.noIcons - Disable Unicode icons (default: false)", + "ui.skin - Default skin name (default: default)", + "ui.skinReadOnly - Skin for readonly mode, overrides ui.skin when readOnly=true", + "ui.splashless - Skip startup splash (default: false)", + "namespaceHotkeys - Array of namespace names for 0-9 hotkeys (max 10, default: auto-discover)", + "contextSkins - Map of context name to skin name (default: empty)", + "cluster - Map of cluster name to cluster-specific settings (default: empty)", + "favorites - List of favorited resource keys, e.g. \"Kustomization:flux-system:my-app\" (default: empty)", + ] { + s.push_str(&format!("# {line}\n")); + } + s.push_str("#\n# Environment Variables (override config):\n"); + for line in [ + "FLUX9S_SKIN - Override skin (highest priority)", + "FLUX9S_READ_ONLY - Override readonly mode", + "FLUX9S_DEFAULT_NAMESPACE - Override default namespace", + "FLUX9S_DEFAULT_RESOURCE_FILTER - Override default resource filter", + "FLUX9S_CONNECT_TIMEOUT - Override Kubernetes API connect timeout in seconds", + ] { + s.push_str(&format!("# {line}\n")); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Dotted camelCase paths for every config field, from the fully + /// populated skeleton (a serialized real config hides skipped fields). + fn all_field_paths() -> Vec { + let skeleton = serde_yaml::to_value(Config::fully_populated()).unwrap(); + let defaults = serde_yaml::to_value(Config::default()).unwrap(); + let mut paths = Vec::new(); + collect_paths( + skeleton.as_mapping().unwrap(), + defaults.as_mapping(), + "", + &mut paths, + ); + paths + } + + fn collect_paths( + skeleton: &serde_yaml::Mapping, + defaults: Option<&serde_yaml::Mapping>, + prefix: &str, + out: &mut Vec, + ) { + use serde_yaml::Value; + for (key, skel_val) in skeleton { + let key_str = key.as_str().unwrap(); + let path = if prefix.is_empty() { + key_str.to_string() + } else { + format!("{prefix}.{key_str}") + }; + let def = defaults.and_then(|m| m.get(key)); + // Recurse into struct sections only — free-form maps hold sample + // data, not fields (same rule as render_section). + match skel_val { + Value::Mapping(inner) + if def + .and_then(Value::as_mapping) + .is_some_and(|m| !m.is_empty()) => + { + collect_paths(inner, def.and_then(Value::as_mapping), &path, out); + } + _ => out.push(path), + } + } + } + + #[test] + fn listing_shows_every_field_even_when_unset() { + // A default config skips every optional field when serialized — the + // listing must show them all anyway (the bug this guards against: + // discoverFluxResources missing from `config list`). + let listing = render_config_listing(&Config::default()); + for path in all_field_paths() { + let leaf = path.rsplit('.').next().unwrap(); + assert!( + listing.contains(&format!("{leaf}:")), + "config list output is missing field '{path}'" + ); } + // Everything in a default config is at its default. + assert!(listing.contains("readOnly: true # (default)")); + assert!(listing.contains("favorites: [] # (default)")); + assert!(listing.contains("contextSkins: {} # (default)")); + assert!(listing.contains("defaultResourceFilter: ~ # (default)")); } - println!(); - - if config.context_skins.is_empty() { - println!("contextSkins: {{}} # (default: empty, no context-specific skins)"); - } else { - println!("contextSkins:"); - for (context, skin) in &config.context_skins { - println!(" {}: {}", context, skin); + + #[test] + fn listing_marks_only_defaults() { + let mut config = Config { + discover_flux_resources: true, + favorites: vec!["Kustomization:flux-system:apps".to_string()], + ..Config::default() + }; + config.ui.skin = "nord".to_string(); + + let listing = render_config_listing(&config); + assert!(listing.contains("discoverFluxResources: true\n")); + assert!(listing.contains("skin: nord\n")); + assert!(listing.contains("- Kustomization:flux-system:apps")); + assert!(!listing.contains("discoverFluxResources: true # (default)")); + assert!(!listing.contains("skin: nord # (default)")); + } + + #[test] + fn reference_docs_cover_every_field() { + // Adding a schema field first breaks Config::fully_populated (a + // compile error), then this test until the reference documents it. + let reference = reference_text(); + for path in all_field_paths() { + assert!( + reference.contains(&path), + "configuration reference is missing an entry for '{path}'" + ); } } - println!(); - println!(); - - println!("# Configuration Reference:"); - println!("# readOnly - Disable modification operations (default: true)"); - println!("# defaultNamespace - Starting namespace (default: flux-system)"); - println!("# defaultControllerNamespace - Flux controller namespace (default: flux-system)"); - println!( - "# defaultResourceFilter - Resource type filter at startup, e.g. \"Kustomization\" (default: none, shows all)" - ); - println!( - "# connectTimeoutSeconds - Startup Kubernetes API health-check timeout in seconds (default: 10)" - ); - println!("# ui.enableMouse - Enable mouse support (default: false)"); - println!("# ui.headless - Hide header (default: false)"); - println!("# ui.noIcons - Disable Unicode icons (default: false)"); - println!("# ui.skin - Default skin name (default: default)"); - println!("# ui.skinReadOnly - Skin for readonly mode, overrides ui.skin when readOnly=true"); - println!("# ui.splashless - Skip startup splash (default: false)"); - println!( - "# namespaceHotkeys - Array of namespace names for 0-9 hotkeys (max 10, default: auto-discover)" - ); - println!("# contextSkins - Map of context name to skin name (default: empty)"); - println!( - "# favorites - List of favorited resource keys, e.g. \"Kustomization:flux-system:my-app\" (default: empty)" - ); - println!("#"); - println!("# Environment Variables (override config):"); - println!("# FLUX9S_SKIN - Override skin (highest priority)"); - println!("# FLUX9S_READ_ONLY - Override readonly mode"); - println!("# FLUX9S_DEFAULT_NAMESPACE - Override default namespace"); - println!("# FLUX9S_DEFAULT_RESOURCE_FILTER - Override default resource filter"); - println!("# FLUX9S_CONNECT_TIMEOUT - Override Kubernetes API connect timeout in seconds"); } diff --git a/src/config/mod.rs b/src/config/mod.rs index cdde9f6..5325dec 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -39,7 +39,32 @@ pub fn get_config_value(config: &schema::Config, key: &str) -> anyhow::Result Ok(config.default_resource_filter.clone().unwrap_or_default()), "connectTimeoutSeconds" => Ok(config.connect_timeout_seconds.to_string()), - _ => Err(anyhow::anyhow!("Unknown configuration key: {}", key)), + "discoverFluxResources" => Ok(config.discover_flux_resources.to_string()), + // Any field the arms above don't special-case is resolved from the + // serialized config, so new schema fields are gettable without a new + // arm. `skip_serializing_if` hides empty fields from the serialized + // config — the fully-populated skeleton tells "empty" apart from + // "not a config key at all". + _ => { + let resolve = |value: &serde_yaml::Value| -> Option { + key.split('.') + .try_fold(value.clone(), |node, part| node.get(part).cloned()) + }; + let current = serde_yaml::to_value(config) + .map_err(|e| anyhow::anyhow!("Failed to serialize configuration: {}", e))?; + if let Some(node) = resolve(¤t) { + return serde_yaml::to_string(&node) + .map(|s| s.trim_end().to_string()) + .map_err(|e| anyhow::anyhow!("Failed to serialize {}: {}", key, e)); + } + let skeleton = serde_yaml::to_value(schema::Config::fully_populated()) + .map_err(|e| anyhow::anyhow!("Failed to serialize configuration: {}", e))?; + if resolve(&skeleton).is_some() { + Ok(String::new()) // real field, currently empty/unset + } else { + Err(anyhow::anyhow!("Unknown configuration key: {}", key)) + } + } } } @@ -58,6 +83,11 @@ pub fn set_config_value(config: &mut schema::Config, key: &str, value: &str) -> "defaultControllerNamespace" => { config.default_controller_namespace = value.to_string(); } + "discoverFluxResources" => { + config.discover_flux_resources = value + .parse() + .context("discoverFluxResources must be 'true' or 'false'")?; + } "ui.enableMouse" => { config.ui.enable_mouse = value .parse() @@ -175,4 +205,26 @@ mod tests { assert!(err.to_string().contains("positive integer")); } + + #[test] + fn get_resolves_fields_without_dedicated_arms() { + // Fields with no match arm (and any future field) resolve through the + // serialized-config fallback instead of "Unknown configuration key". + let mut config = schema::Config::default(); + config + .context_skins + .insert("prod".to_string(), "dracula".to_string()); + + let skins = get_config_value(&config, "contextSkins").unwrap(); + assert!(skins.contains("prod: dracula")); + + // Empty-and-skipped fields are real keys that answer with nothing. + assert_eq!(get_config_value(&config, "favorites").unwrap(), ""); + assert_eq!(get_config_value(&config, "cluster").unwrap(), ""); + + let err = get_config_value(&config, "noSuchKey").unwrap_err(); + assert!(err.to_string().contains("Unknown configuration key")); + let err = get_config_value(&config, "ui.noSuchKey").unwrap_err(); + assert!(err.to_string().contains("Unknown configuration key")); + } } diff --git a/src/config/schema.rs b/src/config/schema.rs index ab0fa2a..fb93bbb 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -21,6 +21,14 @@ pub struct Config { #[serde(default = "default_namespace")] pub default_controller_namespace: String, + /// Opt-in dynamic discovery of Flux-adjacent CRDs (#197). When true, + /// flux9s watches CustomResourceDefinitions labeled + /// `app.kubernetes.io/part-of=` — the same label the Flux + /// Operator's FluxReport uses — and shows their resources with generic + /// columns (view-only). Default false: no CRD watch, no extra API calls. + #[serde(default)] + pub discover_flux_resources: bool, + /// UI configuration #[serde(default)] pub ui: UiConfig, @@ -57,6 +65,39 @@ pub struct Config { } impl Config { + /// A config with every field populated — the field-enumeration source for + /// `config list` and the completeness tests, since `skip_serializing_if` + /// hides empty fields from a serialized real config. + /// + /// Deliberately constructed without `..Default::default()`: adding a + /// field to the schema fails compilation here, forcing the reference + /// docs, `config get`/`set`, and the docs site to be updated with it. + pub fn fully_populated() -> Self { + Self { + read_only: true, + default_namespace: "flux-system".to_string(), + default_controller_namespace: "flux-system".to_string(), + discover_flux_resources: true, + ui: UiConfig { + enable_mouse: true, + headless: true, + no_icons: true, + skin: "default".to_string(), + skin_read_only: Some("default".to_string()), + splashless: true, + }, + namespace_hotkeys: vec!["flux-system".to_string()], + context_skins: HashMap::from([("my-context".to_string(), "default".to_string())]), + cluster: HashMap::from([( + "my-cluster".to_string(), + serde_yaml::Value::String("value".to_string()), + )]), + favorites: vec!["Kustomization:flux-system:my-app".to_string()], + default_resource_filter: Some("Kustomization".to_string()), + connect_timeout_seconds: default_connect_timeout_seconds(), + } + } + /// Resolve which skin to use for the given context. /// /// Priority order: @@ -150,6 +191,7 @@ impl Default for Config { read_only: default_read_only(), default_namespace: default_namespace(), default_controller_namespace: default_namespace(), + discover_flux_resources: false, ui: UiConfig::default(), namespace_hotkeys: Vec::new(), // Empty means use auto-discovered defaults context_skins: HashMap::new(), diff --git a/src/kube/api.rs b/src/kube/api.rs index f0a78b0..c87de04 100644 --- a/src/kube/api.rs +++ b/src/kube/api.rs @@ -132,9 +132,13 @@ pub fn get_gvk_for_resource_type(resource_type: &str) -> Result<(String, String, resource_type.to_lowercase() + "es", )); } - // For all other resources (including CRDs), return an error - // The caller should handle this by using DynamicObject with proper discovery + // For all other resources: consult the dynamically + // discovered kinds (#197) before giving up. The registry is + // empty unless discoverFluxResources is enabled. _ => { + if let Some(extra) = crate::models::extra_kinds::global().get(resource_type) { + return Ok(extra.gvk()); + } return Err(anyhow::anyhow!( "Resource type '{}' is not a Flux or Kubernetes built-in resource. \ For CRDs and custom resources, use DynamicObject with API discovery.", diff --git a/src/models/extra_kinds.rs b/src/models/extra_kinds.rs new file mode 100644 index 0000000..3ad644b --- /dev/null +++ b/src/models/extra_kinds.rs @@ -0,0 +1,294 @@ +//! Dynamically discovered extra resource kinds (#197). +//! +//! When `discoverFluxResources` is enabled, flux9s watches +//! CustomResourceDefinitions labeled `app.kubernetes.io/part-of=` — +//! the same label the Flux Operator's FluxReport uses to enumerate +//! reconcilers — and registers their kinds here. Discovered kinds get the +//! generic treatment (watched, listed, filterable, `y`/`d`) and are +//! **view-only**: every richer capability (operations, graph, trace, +//! history) answers through [`crate::models::FluxResourceKind`], which +//! discovered kinds are deliberately not part of, so privileges stay +//! deny-by-default. + +use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; +use std::collections::HashMap; +use std::sync::{Arc, OnceLock, RwLock}; + +use crate::models::FluxResourceKind; + +/// The CRD label that marks a kind as part of the Flux instance — the Flux +/// Operator's own convention (`FluxReport` reconciler discovery uses it). +pub const PART_OF_LABEL: &str = "app.kubernetes.io/part-of"; + +/// The conventional FluxInstance name the label value must match. The +/// operator matches its instance's name; "flux" is the documented default +/// everywhere in the Flux Operator ecosystem. +pub const PART_OF_VALUE: &str = "flux"; + +/// A discovered kind, reduced to what the dynamic watcher and the kind→GVK +/// resolution need. Plural and short names come from the CRD itself, so +/// `:` command aliases match what `kubectl` accepts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtraKind { + pub kind: String, + pub group: String, + /// The CRD's storage version (last stored version, like the operator). + pub version: String, + pub plural: String, + /// CRD short names (e.g. `tf` for Terraform) — become command aliases. + pub short_names: Vec, +} + +impl ExtraKind { + /// Build from a CRD, applying the guard rails: + /// - built-in [`FluxResourceKind`]s are excluded (the FluxInstance labels + /// its own CRDs `part-of=` too — never double-watch them) + /// - cluster-scoped CRDs are skipped in v1 (the watch and key formats + /// assume namespaces) + /// - a CRD without a stored/storage version is skipped + pub fn from_crd(crd: &CustomResourceDefinition) -> Option { + let spec = &crd.spec; + let kind = spec.names.kind.clone(); + + if FluxResourceKind::parse_optional(&kind).is_some() { + tracing::debug!("Skipping discovered CRD for built-in kind {}", kind); + return None; + } + if spec.scope != "Namespaced" { + tracing::info!( + "Skipping discovered cluster-scoped CRD {} (only namespaced kinds are supported)", + kind + ); + return None; + } + + let version = crd + .status + .as_ref() + .and_then(|s| s.stored_versions.as_ref()) + .and_then(|v| v.last().cloned()) + .or_else(|| { + spec.versions + .iter() + .find(|v| v.storage) + .map(|v| v.name.clone()) + })?; + + Some(Self { + kind, + group: spec.group.clone(), + version, + plural: spec.names.plural.clone(), + short_names: spec.names.short_names.clone().unwrap_or_default(), + }) + } + + /// `(group, version, plural)` in the shape + /// [`crate::kube::get_gvk_for_resource_type`] resolves to. + pub fn gvk(&self) -> (String, String, String) { + ( + self.group.clone(), + self.version.clone(), + self.plural.clone(), + ) + } +} + +/// Shared, dynamic registry of discovered kinds. +/// +/// `Arc` is earned here: the CRD watcher task inserts/removes, fetch +/// tasks resolve kinds, and the UI thread lists/filters — all concurrently, +/// and the contents change at runtime as CRDs are labeled or deleted. +#[derive(Debug, Clone, Default)] +pub struct ExtraKindRegistry { + kinds: Arc>>, +} + +impl ExtraKindRegistry { + /// Register a discovered kind. Returns false when it was already present. + pub fn insert(&self, extra: ExtraKind) -> bool { + self.kinds + .write() + .expect("extra kind registry poisoned") + .insert(extra.kind.clone(), extra) + .is_none() + } + + /// Remove a kind (its CRD was deleted or unlabeled). Returns the removed + /// entry so the caller can stop its watcher. + pub fn remove(&self, kind: &str) -> Option { + self.kinds + .write() + .expect("extra kind registry poisoned") + .remove(kind) + } + + /// Look up a kind by its exact name. + pub fn get(&self, kind: &str) -> Option { + self.kinds + .read() + .expect("extra kind registry poisoned") + .get(kind) + .cloned() + } + + /// Resolve a `:` command token (kind, plural, or short name — case + /// insensitive) to the kind name. + pub fn resolve_command(&self, token: &str) -> Option { + let token = token.to_lowercase(); + self.kinds + .read() + .expect("extra kind registry poisoned") + .values() + .find(|extra| { + extra.kind.to_lowercase() == token + || extra.plural.to_lowercase() == token + || extra + .short_names + .iter() + .any(|short| short.to_lowercase() == token) + }) + .map(|extra| extra.kind.clone()) + } + + /// All registered kind names, sorted. + pub fn kind_names(&self) -> Vec { + let mut names: Vec = self + .kinds + .read() + .expect("extra kind registry poisoned") + .keys() + .cloned() + .collect(); + names.sort(); + names + } + + pub fn is_empty(&self) -> bool { + self.kinds + .read() + .expect("extra kind registry poisoned") + .is_empty() + } + + /// Drop every discovered kind. Called when the discovery watcher stops + /// (context switch, namespace restart) so kinds from one cluster never + /// leak into another; re-discovery repopulates the registry. + pub fn clear(&self) { + self.kinds + .write() + .expect("extra kind registry poisoned") + .clear(); + } +} + +/// The process-wide registry. Always present but empty unless discovery is +/// enabled and CRDs are found — an empty registry means every lookup misses, +/// which is exactly the disabled behavior. +pub fn global() -> &'static ExtraKindRegistry { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(ExtraKindRegistry::default) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn crd(kind: &str, scope: &str, stored: Option<&str>) -> CustomResourceDefinition { + let crd_json = serde_json::json!({ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": {"name": format!("{}s.example.com", kind.to_lowercase())}, + "spec": { + "group": "example.com", + "scope": scope, + "names": { + "kind": kind, + "plural": format!("{}s", kind.to_lowercase()), + "shortNames": ["wd"] + }, + "versions": [ + {"name": "v1alpha1", "served": true, "storage": false, "schema": {"openAPIV3Schema": {"type": "object"}}}, + {"name": "v1", "served": true, "storage": true, "schema": {"openAPIV3Schema": {"type": "object"}}} + ] + }, + "status": stored.map(|v| serde_json::json!({ + "storedVersions": ["v1alpha1", v], + "acceptedNames": {"kind": kind, "plural": format!("{}s", kind.to_lowercase())}, + "conditions": [] + })).unwrap_or(serde_json::json!(null)) + }); + serde_json::from_value(crd_json).expect("test CRD should deserialize") + } + + #[test] + fn from_crd_extracts_kind_plural_shortnames_and_version() { + let extra = ExtraKind::from_crd(&crd("Widget", "Namespaced", Some("v1"))).unwrap(); + assert_eq!(extra.kind, "Widget"); + assert_eq!(extra.group, "example.com"); + assert_eq!(extra.plural, "widgets"); + assert_eq!(extra.short_names, ["wd"]); + assert_eq!(extra.version, "v1", "last stored version wins"); + assert_eq!( + extra.gvk(), + ( + "example.com".to_string(), + "v1".to_string(), + "widgets".to_string() + ) + ); + } + + #[test] + fn from_crd_falls_back_to_storage_version_without_status() { + let extra = ExtraKind::from_crd(&crd("Widget", "Namespaced", None)).unwrap(); + assert_eq!(extra.version, "v1", "spec storage version fallback"); + } + + #[test] + fn from_crd_guards_built_ins_and_cluster_scope() { + // The FluxInstance labels its own CRDs part-of=, so the + // built-in exclusion is what prevents double-watching Kustomizations. + assert!(ExtraKind::from_crd(&crd("Kustomization", "Namespaced", Some("v1"))).is_none()); + assert!(ExtraKind::from_crd(&crd("Widget", "Cluster", Some("v1"))).is_none()); + } + + #[test] + fn registry_insert_remove_and_command_resolution() { + let registry = ExtraKindRegistry::default(); + assert!(registry.is_empty()); + + let extra = ExtraKind::from_crd(&crd("Widget", "Namespaced", Some("v1"))).unwrap(); + assert!(registry.insert(extra.clone())); + assert!(!registry.insert(extra), "re-insert reports already present"); + + assert!(registry.get("Widget").is_some()); + assert_eq!(registry.kind_names(), ["Widget"]); + // Kind, plural, and CRD short name all resolve, case-insensitively + for token in ["widget", "WIDGETS", "wd"] { + assert_eq!( + registry.resolve_command(token).as_deref(), + Some("Widget"), + "{token} should resolve" + ); + } + assert!(registry.resolve_command("ks").is_none()); + + let removed = registry.remove("Widget").unwrap(); + assert_eq!(removed.kind, "Widget"); + assert!(registry.is_empty()); + } + + #[test] + fn clear_forgets_all_kinds() { + // Context switches clear the registry via ResourceWatcher::stop() so + // one cluster's aliases never resolve against another cluster. + let registry = ExtraKindRegistry::default(); + registry.insert(ExtraKind::from_crd(&crd("Widget", "Namespaced", Some("v1"))).unwrap()); + assert!(!registry.is_empty()); + + registry.clear(); + assert!(registry.is_empty()); + assert!(registry.resolve_command("wd").is_none()); + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index fbcbbe7..85ba934 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -13,6 +13,9 @@ pub mod _generated; // Manual extensions pub mod extensions; +// Dynamically discovered extra resource kinds (#197) +pub mod extra_kinds; + // Flux resource kind definitions pub mod flux_resource_kind; diff --git a/src/operations.rs b/src/operations.rs index e3ee1a0..8860df0 100644 --- a/src/operations.rs +++ b/src/operations.rs @@ -363,8 +363,10 @@ impl FluxOperation for DeleteOperation { "Delete" } - fn is_valid_for(&self, _resource_type: &str) -> bool { - true // Delete works for all resources + fn is_valid_for(&self, resource_type: &str) -> bool { + // All built-in Flux kinds; dynamically discovered kinds (#197) are + // view-only, so mutations never apply to them. + crate::models::FluxResourceKind::parse_optional(resource_type).is_some() } } @@ -464,8 +466,10 @@ impl FluxOperation for ReconcileOperation { "Reconcile" } - fn is_valid_for(&self, _resource_type: &str) -> bool { - true // Reconcile works for all Flux resources + fn is_valid_for(&self, resource_type: &str) -> bool { + // All built-in Flux kinds; dynamically discovered kinds (#197) are + // view-only, so the reconcile annotation never applies to them. + crate::models::FluxResourceKind::parse_optional(resource_type).is_some() } } diff --git a/src/services/cluster_session.rs b/src/services/cluster_session.rs index 9e50175..469eb29 100644 --- a/src/services/cluster_session.rs +++ b/src/services/cluster_session.rs @@ -77,6 +77,7 @@ impl ClusterSession { client.clone(), namespace.clone(), controller_namespace.to_string(), + false, // headless sessions don't run CRD discovery ); watcher @@ -127,6 +128,7 @@ impl ClusterSession { client.clone(), namespace.clone(), config.default_controller_namespace.clone(), + false, // headless sessions don't run CRD discovery ); watcher @@ -180,6 +182,7 @@ impl ClusterSession { client.clone(), namespace.clone(), config.default_controller_namespace.clone(), + false, // headless sessions don't run CRD discovery ); watcher @@ -329,7 +332,9 @@ impl ClusterSession { | WatchEvent::PodDeleted(_) | WatchEvent::DeploymentApplied(_) | WatchEvent::KubeEventApplied(_) - | WatchEvent::KubeEventDeleted(_) => {} + | WatchEvent::KubeEventDeleted(_) + | WatchEvent::ExtraKindDiscovered(_) + | WatchEvent::ExtraKindRemoved(_) => {} } } @@ -372,6 +377,7 @@ impl ClusterSession { new_client.clone(), self.namespace.clone(), controller_namespace.to_string(), + false, // headless sessions don't run CRD discovery ); new_watcher diff --git a/src/tui/app/core.rs b/src/tui/app/core.rs index a817d10..e49e801 100644 --- a/src/tui/app/core.rs +++ b/src/tui/app/core.rs @@ -343,6 +343,23 @@ impl App { resources } + /// Drop every watched resource of a kind (a discovered CRD was deleted). + pub(crate) fn purge_kind(&mut self, kind: &str) { + let prefix = format!("{}:", kind); + let keys: Vec = self + .state + .all() + .iter() + .filter(|r| r.resource_type == kind) + .map(|r| crate::watcher::resource_key(&r.namespace, &r.name, &r.resource_type)) + .collect(); + for key in keys { + self.state.remove(&key); + } + self.resource_objects + .retain(|key, _| !key.starts_with(&prefix)); + } + /// The FluxReport object, when the Flux Operator publishes one. pub(crate) fn flux_report_object(&self) -> Option<&serde_json::Value> { self.resource_objects @@ -890,6 +907,7 @@ mod tests { read_only: false, default_namespace: "".to_string(), default_controller_namespace: "".to_string(), + discover_flux_resources: false, namespace_hotkeys: vec![], ui: UiConfig { enable_mouse: false, diff --git a/src/tui/app/events.rs b/src/tui/app/events.rs index 3e140d2..d465cf1 100644 --- a/src/tui/app/events.rs +++ b/src/tui/app/events.rs @@ -1489,11 +1489,17 @@ impl App { } } - // Fallback: a resource-type command (e.g. `:ks`, `:hr`), else unknown. + // Fallback: a resource-type command (e.g. `:ks`, `:hr`), else a + // dynamically discovered kind (#197), else unknown. if let Some(display_name) = crate::watcher::get_display_name_for_command(&cmd_lower) { self.view_state.selected_resource_type = Some(display_name.to_string()); self.reset_list_position(); self.invalidate_layout_cache(); // Resource type filter affects header display + } else if let Some(kind) = crate::models::extra_kinds::global().resolve_command(&cmd_lower) + { + self.view_state.selected_resource_type = Some(kind); + self.reset_list_position(); + self.invalidate_layout_cache(); } else if !cmd.is_empty() { self.set_status_message(( format!( @@ -1823,6 +1829,7 @@ mod tests { read_only, default_namespace: "".to_string(), default_controller_namespace: "".to_string(), + discover_flux_resources: false, namespace_hotkeys: vec![], ui: UiConfig { enable_mouse: false, @@ -2883,4 +2890,80 @@ mod tests { app.handle_key(make_key(KeyCode::Esc)); assert_eq!(app.view_state.current_view, View::ResourceList); } + + /// #197: without discovery, unknown kinds stay unknown (zero impact); + /// once a kind is registered, its CRD-derived aliases resolve like + /// built-in resource commands. Uses a unique kind name because the + /// registry is process-global. + #[test] + fn discovered_kind_commands_resolve_only_when_registered() { + use crate::models::extra_kinds::{ExtraKind, global}; + + let mut app = create_test_app(false); + + // Zero impact: unregistered kind is an unknown command + app.ui_state.command_buffer = "zephyrtest".to_string(); + app.execute_command(); + assert!(app.view_state.selected_resource_type.is_none()); + assert!( + app.ui_state + .status_message + .as_ref() + .is_some_and(|(msg, is_err)| *is_err && msg.contains("Unknown command")) + ); + + global().insert(ExtraKind { + kind: "ZephyrTest".to_string(), + group: "example.com".to_string(), + version: "v1".to_string(), + plural: "zephyrtests".to_string(), + short_names: vec!["zt9".to_string()], + }); + + // Kind, plural, and short name now resolve to the type filter + for token in ["zephyrtest", "zephyrtests", "zt9"] { + app.view_state.selected_resource_type = None; + app.ui_state.command_buffer = token.to_string(); + app.execute_command(); + assert_eq!( + app.view_state.selected_resource_type.as_deref(), + Some("ZephyrTest"), + "{token} should resolve" + ); + } + + // Autocomplete offers the discovered aliases + let matches = crate::tui::commands::find_matching_commands("zephyr"); + assert!(matches.contains(&"zephyrtest".to_string())); + assert!(matches.contains(&"zephyrtests".to_string())); + + // GVK resolution works for read-only fetches (y/d) + let gvk = crate::kube::get_gvk_for_resource_type("ZephyrTest").unwrap(); + assert_eq!( + gvk, + ( + "example.com".to_string(), + "v1".to_string(), + "zephyrtests".to_string() + ) + ); + + // Discovered kinds stay view-only: no operation considers them valid + let registry = crate::tui::OperationRegistry::new(); + for key in ['s', 'r', 'R', 'W'] { + if let Some(op) = registry.get_by_keybinding(key) { + assert!( + !op.is_valid_for("ZephyrTest"), + "operation '{key}' must not apply to discovered kinds" + ); + } + } + + // Cleanup the process-global registry + global().remove("ZephyrTest"); + assert!( + crate::kube::get_gvk_for_resource_type("ZephyrTest").is_err(), + "removal restores the unknown-kind error" + ); + } } diff --git a/src/tui/commands.rs b/src/tui/commands.rs index d9351e0..ba19371 100644 --- a/src/tui/commands.rs +++ b/src/tui/commands.rs @@ -134,6 +134,22 @@ pub fn find_matching_commands(prefix: &str) -> Vec { } } + // Dynamically discovered kinds (#197): kind names, plurals, and CRD + // short names complete like built-in resource commands. The registry is + // empty unless discovery is enabled, so this adds nothing by default. + for extra in crate::models::extra_kinds::global().kind_names() { + if let Some(kind) = crate::models::extra_kinds::global().get(&extra) { + for alias in std::iter::once(kind.kind.to_lowercase()) + .chain(std::iter::once(kind.plural.to_lowercase())) + .chain(kind.short_names.iter().map(|s| s.to_lowercase())) + { + if alias.starts_with(&prefix_lower) && !crd_matches.contains(&alias) { + crd_matches.push(alias); + } + } + } + } + // Sort matches alphabetically crd_matches.sort(); app_matches.sort(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 0c88624..06c7200 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -172,6 +172,7 @@ pub async fn run_tui_with_async_init( let kubeconfig_path_clone = kubeconfig_path.map(|p| p.to_path_buf()); let controller_namespace = config.default_controller_namespace.clone(); let controller_namespace_for_init = controller_namespace.clone(); + let discovery_enabled = config.discover_flux_resources; let (kube_init_tx, mut kube_init_rx) = tokio::sync::oneshot::channel(); tokio::spawn(async move { tracing::debug!("Starting async Kubernetes initialization"); @@ -271,6 +272,7 @@ pub async fn run_tui_with_async_init( client.clone(), default_namespace.clone(), controller_namespace_for_init, + discovery_enabled, ); // Start watching all Flux resources @@ -693,6 +695,7 @@ pub async fn run_tui_with_async_init( new_client.clone(), new_default_namespace.clone(), controller_namespace.clone(), + app.config.discover_flux_resources, ); // Start watching all resources with the new watcher @@ -928,6 +931,31 @@ pub async fn run_tui_with_async_init( crate::watcher::WatchEvent::KubeEventDeleted(uid) => { app.kube_events.remove(&uid); } + crate::watcher::WatchEvent::ExtraKindDiscovered(extra) => { + // Register (idempotent) and (re)start the dynamic + // watcher — a no-op when it is already running, + // which self-heals after namespace/context restarts. + if crate::models::extra_kinds::global().insert(extra.clone()) { + tracing::info!( + "Discovered Flux-labeled kind {} ({}/{})", + extra.kind, + extra.group, + extra.version + ); + } + if let Some(ref mut w) = app.watcher { + w.watch_extra(&extra); + } + } + crate::watcher::WatchEvent::ExtraKindRemoved(kind) => { + if crate::models::extra_kinds::global().remove(&kind).is_some() { + tracing::info!("Discovered kind {} removed (CRD deleted)", kind); + if let Some(ref mut w) = app.watcher { + w.stop_extra(&kind); + } + app.purge_kind(&kind); + } + } } } } diff --git a/src/tui/views/help.rs b/src/tui/views/help.rs index b1834c3..fb11be9 100644 --- a/src/tui/views/help.rs +++ b/src/tui/views/help.rs @@ -61,6 +61,14 @@ pub fn render_help(f: &mut Frame, area: Rect, theme: &Theme, namespace_hotkeys: (":logs [pod]", "Stream controller logs"), (":q", "Quit application"), ]; + // Discovered kinds (#197) get a dynamic entry only when discovery has + // actually registered something — no noise for normal users. + let discovered = crate::models::extra_kinds::global().kind_names(); + let discovered_entry = format!(":{} …", discovered.join(", :").to_lowercase()); + let mut general_items = general_items; + if !crate::models::extra_kinds::global().is_empty() { + general_items.push((discovered_entry.as_str(), "Discovered kinds (CRD)")); + } render_help_column(f, column_chunks[1], "GENERAL", &general_items, theme); // NAVIGATION column diff --git a/src/watcher/mod.rs b/src/watcher/mod.rs index fbbb64a..981d504 100644 --- a/src/watcher/mod.rs +++ b/src/watcher/mod.rs @@ -96,6 +96,12 @@ pub enum WatchEvent { KubeEventApplied(serde_json::Value), // event_json /// A Kubernetes Event was deleted (TTL expiry) KubeEventDeleted(String), // event uid + /// A part-of-labeled CRD was discovered (#197); the main loop registers + /// the kind and starts its dynamic watcher + ExtraKindDiscovered(crate::models::extra_kinds::ExtraKind), + /// A previously discovered CRD was deleted; the main loop deregisters + /// the kind, stops its watcher, and purges its resources + ExtraKindRemoved(String), // kind name } /// Trait for watchable Flux resources @@ -134,6 +140,12 @@ pub struct ResourceWatcher { /// tearing down the resource watchers, since Events are the churniest /// resource in a cluster and shouldn't be paid for while unused. kube_events_handle: Option>, + /// Opt-in CRD discovery (#197). Off by default: when false, neither the + /// CRD watcher nor any dynamic kind watcher ever starts. + discovery_enabled: bool, + /// Dynamic watchers for discovered kinds, keyed by kind name so a + /// deleted CRD can stop exactly its own watcher. + extra_handles: std::collections::HashMap>, } impl ResourceWatcher { @@ -145,6 +157,7 @@ impl ResourceWatcher { client: Client, namespace: Option, controller_namespace: String, + discovery_enabled: bool, ) -> (Self, mpsc::UnboundedReceiver) { let (tx, rx) = mpsc::unbounded_channel(); ( @@ -155,6 +168,8 @@ impl ResourceWatcher { event_tx: tx, handles: Vec::new(), kube_events_handle: None, + discovery_enabled, + extra_handles: std::collections::HashMap::new(), }, rx, ) @@ -747,6 +762,12 @@ impl ResourceWatcher { // Flux Controller Deployments (for bundle version tracking) self.watch_flux_deployments()?; + // Opt-in CRD discovery (#197): watches part-of-labeled CRDs and + // reports them for dynamic registration. No-op unless enabled. + if self.discovery_enabled { + self.watch_crd_discovery()?; + } + tracing::debug!("All watchers started ({} total)", self.handles.len()); Ok(()) } @@ -851,6 +872,194 @@ impl ResourceWatcher { } } + /// Watch part-of-labeled CRDs and report kinds for dynamic registration + /// (#197). Uses the same label the Flux Operator's FluxReport reads, so + /// labeling a CRD once makes it visible to both. Guard rails (built-in + /// exclusion, namespaced-only) are applied by `ExtraKind::from_crd`. + fn watch_crd_discovery(&mut self) -> Result<()> { + use crate::models::extra_kinds::{ExtraKind, PART_OF_LABEL, PART_OF_VALUE}; + use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; + + let client = self.client.clone(); + let event_tx = self.event_tx.clone(); + + let handle = tokio::spawn(async move { + let api: Api = Api::all(client); + let config = + watcher::Config::default().labels(&format!("{}={}", PART_OF_LABEL, PART_OF_VALUE)); + let mut w = Box::pin(watcher(api, config).backoff(CappedBackoff::new())); + let mut error_count = 0u32; + + const WATCHER_NAME: &str = "CRD discovery"; + tracing::debug!( + "Starting CRD discovery watcher ({}={})", + PART_OF_LABEL, + PART_OF_VALUE + ); + + while let Some(event) = w.next().await { + let ev = match event { + Ok(ev) => ev, + Err(e) => { + if is_forbidden_error(&format!("{}", e)) { + if error_count > 0 { + let _ = event_tx + .send(WatchEvent::WatcherRecovered(WATCHER_NAME.to_string())); + } + let _ = event_tx.send(WatchEvent::Error( + "CRD discovery forbidden by RBAC (discoverFluxResources needs CRD read access)" + .to_string(), + )); + tracing::info!("CRD discovery forbidden by RBAC, stopping: {}", e); + break; + } + error_count += 1; + if error_count == 1 { + let _ = event_tx + .send(WatchEvent::WatcherDegraded(WATCHER_NAME.to_string())); + } + if error_count == 1 || error_count.is_multiple_of(10) { + tracing::warn!("CRD discovery error ({}): {}", error_count, e); + } + continue; + } + }; + + if error_count > 0 && !matches!(ev, watcher::Event::Init) { + error_count = 0; + let _ = event_tx.send(WatchEvent::WatcherRecovered(WATCHER_NAME.to_string())); + } + + match ev { + watcher::Event::InitApply(crd) | watcher::Event::Apply(crd) => { + if let Some(extra) = ExtraKind::from_crd(&crd) { + let _ = event_tx.send(WatchEvent::ExtraKindDiscovered(extra)); + } + } + watcher::Event::Delete(crd) => { + let kind = crd.spec.names.kind.clone(); + let _ = event_tx.send(WatchEvent::ExtraKindRemoved(kind)); + } + watcher::Event::Init | watcher::Event::InitDone => { + tracing::debug!("CRD discovery watcher initialized"); + } + } + } + }); + + self.handles.push(handle); + Ok(()) + } + + /// Start a dynamic watcher for a discovered kind (#197). A no-op when a + /// watcher for the kind is already running, so re-discovery after + /// namespace/context restarts self-heals. + pub fn watch_extra(&mut self, extra: &crate::models::extra_kinds::ExtraKind) { + if !self.discovery_enabled { + return; + } + if self + .extra_handles + .get(&extra.kind) + .is_some_and(|handle| !handle.is_finished()) + { + return; + } + + let client = self.client.clone(); + let namespace = self.current_namespace.clone(); + let event_tx = self.event_tx.clone(); + let resource_type = extra.kind.clone(); + let api_resource = kube::core::ApiResource { + group: extra.group.clone(), + version: extra.version.clone(), + api_version: format!("{}/{}", extra.group, extra.version), + kind: extra.kind.clone(), + plural: extra.plural.clone(), + }; + + let handle = tokio::spawn(async move { + let api: Api = match namespace { + Some(ref ns) => Api::namespaced_with(client, ns, &api_resource), + None => Api::all_with(client, &api_resource), + }; + let mut w = + Box::pin(watcher(api, watcher::Config::default()).backoff(CappedBackoff::new())); + let mut error_count = 0u32; + tracing::debug!("Starting discovered-kind watcher for {}", resource_type); + + while let Some(event) = w.next().await { + let ev = match event { + Ok(ev) => ev, + Err(e) => { + if is_forbidden_error(&format!("{}", e)) { + if error_count > 0 { + let _ = event_tx + .send(WatchEvent::WatcherRecovered(resource_type.clone())); + } + tracing::info!( + "{} watcher forbidden by RBAC, stopping: {}", + resource_type, + e + ); + break; + } + error_count += 1; + if error_count == 1 { + let _ = + event_tx.send(WatchEvent::WatcherDegraded(resource_type.clone())); + } + if error_count == 1 || error_count.is_multiple_of(10) { + tracing::warn!( + "{} watcher error ({}): {}", + resource_type, + error_count, + e + ); + } + continue; + } + }; + + if error_count > 0 && !matches!(ev, watcher::Event::Init) { + error_count = 0; + let _ = event_tx.send(WatchEvent::WatcherRecovered(resource_type.clone())); + } + + match ev { + watcher::Event::InitApply(obj) | watcher::Event::Apply(obj) => { + let name = obj.name_any(); + let ns = obj.namespace().unwrap_or_default(); + if let Ok(obj_json) = serde_json::to_value(&obj) { + let _ = event_tx.send(WatchEvent::Applied( + resource_type.clone(), + ns, + name, + obj_json, + )); + } + } + watcher::Event::Delete(obj) => { + let name = obj.name_any(); + let ns = obj.namespace().unwrap_or_default(); + let _ = event_tx.send(WatchEvent::Deleted(resource_type.clone(), ns, name)); + } + watcher::Event::Init | watcher::Event::InitDone => {} + } + } + }); + + self.extra_handles.insert(extra.kind.clone(), handle); + } + + /// Stop the dynamic watcher for one discovered kind (its CRD is gone). + pub fn stop_extra(&mut self, kind: &str) { + if let Some(handle) = self.extra_handles.remove(kind) { + tracing::debug!("Stopping discovered-kind watcher for {}", kind); + handle.abort(); + } + } + /// Abort all watcher tasks pub fn stop(&mut self) { tracing::debug!("Stopping {} watchers", self.handles.len()); @@ -858,6 +1067,18 @@ impl ResourceWatcher { handle.abort(); } self.handles.clear(); + for (_, handle) in self.extra_handles.drain() { + handle.abort(); + } + // Discovered kinds belong to the cluster this watcher was pointed at. + // Clearing here covers every teardown path — context switch (old + // watcher dropped), namespace restart, shutdown — so `:` aliases and + // the help entry never outlive their cluster. The restarted discovery + // watcher re-emits `ExtraKindDiscovered` for whatever the new target + // actually has. + if self.discovery_enabled { + crate::models::extra_kinds::global().clear(); + } self.stop_kube_events(); } } diff --git a/tests/live_tests.rs b/tests/live_tests.rs index 21e7f6d..7d560d3 100644 --- a/tests/live_tests.rs +++ b/tests/live_tests.rs @@ -287,3 +287,73 @@ async fn workload_drilldown_fetches_pods_and_containers() { "events lookup should succeed" ); } + +/// #197: a part-of-labeled CRD on the cluster parses through the real +/// discovery path, and its instances are listable via the derived +/// ApiResource — the exact plumbing the dynamic watcher uses. The fixture +/// (widgets.example.com + two Widget CRs) is planted by dev-clusters.sh. +#[tokio::test] +#[ignore = "requires the kind-flux9s-simple dev cluster"] +async fn labeled_crd_discovers_and_lists_instances() { + use flux9s::models::extra_kinds::{ExtraKind, PART_OF_LABEL, PART_OF_VALUE}; + use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; + use kube::core::DynamicObject; + + let client = client_for(&simple_context()).await; + + let crds: kube::Api = kube::Api::all(client.clone()); + let crd = eventually("the labeled widgets CRD", 60, || { + let crds = crds.clone(); + async move { crds.get("widgets.example.com").await.ok() } + }) + .await; + + // The fixture carries the operator's part-of label — the discovery + // watcher's selector would match it + assert_eq!( + crd.metadata + .labels + .as_ref() + .and_then(|l| l.get(PART_OF_LABEL)) + .map(String::as_str), + Some(PART_OF_VALUE), + ); + + // The real discovery parse (guard rails included) + let extra = ExtraKind::from_crd(&crd).expect("labeled namespaced CRD should register"); + assert_eq!(extra.kind, "Widget"); + assert_eq!(extra.plural, "widgets"); + assert_eq!(extra.short_names, ["wd"]); + + // Instances list through the derived ApiResource — the same construction + // the dynamic watcher and y/d fetches use + let (group, version, plural) = extra.gvk(); + let api_resource = kube::core::ApiResource { + api_version: format!("{}/{}", group, version), + group, + version, + kind: extra.kind.clone(), + plural, + }; + let widgets: kube::Api = + kube::Api::namespaced_with(client, "flux-resources", &api_resource); + let list = eventually("widget fixtures", 60, || { + let widgets = widgets.clone(); + async move { + let list = widgets.list(&Default::default()).await.ok()?; + (list.items.len() >= 2).then_some(list) + } + }) + .await; + + // Readiness extraction works on the fixtures' standard conditions + let mut ready_states = std::collections::HashMap::new(); + for item in &list.items { + let json = serde_json::to_value(item).unwrap(); + let (_, ready, _, _) = flux9s::watcher::extract_status_fields(&json); + ready_states.insert(item.name_any(), ready); + } + use kube::ResourceExt; + assert_eq!(ready_states.get("widget-healthy"), Some(&Some(true))); + assert_eq!(ready_states.get("widget-broken"), Some(&Some(false))); +} diff --git a/tests/snapshot_tests.rs b/tests/snapshot_tests.rs index 5bd297f..2526f79 100644 --- a/tests/snapshot_tests.rs +++ b/tests/snapshot_tests.rs @@ -26,6 +26,7 @@ fn create_test_config() -> Config { read_only: false, default_namespace: "".to_string(), default_controller_namespace: "".to_string(), + discover_flux_resources: false, namespace_hotkeys: vec![], ui: UiConfig { enable_mouse: false,