diff --git a/.githooks/pre-commit b/.githooks/pre-commit old mode 100644 new mode 100755 diff --git a/.githooks/pre-commit-user b/.githooks/pre-commit-user old mode 100644 new mode 100755 index 0745b91a..d8ce58e6 --- a/.githooks/pre-commit-user +++ b/.githooks/pre-commit-user @@ -8,10 +8,17 @@ if ! git diff --cached --check; then exit 1 fi -staged_files=$(git diff --cached --name-only --diff-filter=ACMR) +# -z disables path quoting so `git show` gets the exact index path. +newline_count=$(git diff --cached --name-only -z --diff-filter=ACMR | tr -cd '\n' | wc -c) +if [ "$newline_count" -ne 0 ]; then + echo "pre-commit: a staged path contains a newline; rename it so the credential scan can read it." + exit 1 +fi +staged_files=$(git diff --cached --name-only -z --diff-filter=ACMR | tr '\0' '\n') if [ -n "$staged_files" ]; then - for file in $staged_files; do + # Read newline-separated paths so filenames with spaces are scanned. + while IFS= read -r file; do case "$file" in .githooks/pre-commit|.githooks/pre-commit-user) continue @@ -36,7 +43,9 @@ if [ -n "$staged_files" ]; then echo "Move credentials out of the repo or add a narrow exception after review." exit 1 fi - done + done < { expect(server.url).toBeNull(); expect(server.unavailableReason).toBeNull(); expect(server.endpoint.servicePort).toBeGreaterThan(0); + if (!server.targetPod) throw new Error("argocd-server Service tunnel has no discovered target Pod"); const password = Buffer.from(await runKubectl(["get", "secret", "argocd-initial-admin-secret", "-n", "argocd", "-o", "jsonpath={.data.password}"]), "base64").toString("utf8"); if (!password) throw new Error("Argo CD initial admin password is empty"); @@ -288,6 +290,7 @@ describe("native Kind command boundary", () => { clusterContext: cluster, kubeconfigEnvVar: e2eKubeconfigSource, workspaceId, + confirmedTarget: { namespace: server.endpoint.namespace, serviceName: server.endpoint.serviceName, podName: server.targetPod }, }); argoConnections.push(connectionId); expect(connected).toMatchObject({ connected: true, profile: { rememberCredential: false, endpoint: { kind: "serviceTunnel", serviceName: "argocd-server", servicePort: server.endpoint.servicePort, scheme: "http" } } }); diff --git a/scripts/audit-dependencies.ts b/scripts/audit-dependencies.ts index 10fb346d..00698377 100644 --- a/scripts/audit-dependencies.ts +++ b/scripts/audit-dependencies.ts @@ -11,6 +11,8 @@ const ignoredAdvisories = new Map([ ["GHSA-jmr9-qjv8-65gv", "extract-zip"], // TODO(Timpan4): Remove once WebdriverIO accepts brace-expansion 5.0.8+. ["GHSA-mh99-v99m-4gvg", "brace-expansion"], + // TODO(Timpan4): Remove once braces ships a patch or WebdriverIO's mocha drops chokidar 3. + ["GHSA-vfj7-8cjw-p6xm", "braces"], ]); function advisoryId(advisory: AuditAdvisory): string { @@ -49,7 +51,10 @@ async function runAudit(productionOnly: boolean): Promise { ]); if (stderr) process.stderr.write(stderr); - if (!stdout.trim()) process.exit(exitCode); + if (!stdout.trim()) { + if (exitCode !== 0) process.exit(exitCode); + return {}; + } try { // SAFETY: `bun audit --json` owns this versioned report payload. return JSON.parse(stdout) as AuditReport; diff --git a/scripts/tauri.ts b/scripts/tauri.ts index a2cacd64..bc2dd01c 100644 --- a/scripts/tauri.ts +++ b/scripts/tauri.ts @@ -21,6 +21,8 @@ export function tauriEnvironment( ); } + if (environment.KUBECOVE_DEVTOOLS !== "1") return environment; + const currentArguments = environment[WEBVIEW2_ARGUMENTS]?.trim() ?? ""; if (/(?:^|\s)--remote-debugging-port(?:=|\s)/.test(currentArguments)) { return environment; diff --git a/src-tauri/src/commands/argo/connected.rs b/src-tauri/src/commands/argo/connected.rs index 2e29e407..beb7af9a 100644 --- a/src-tauri/src/commands/argo/connected.rs +++ b/src-tauri/src/commands/argo/connected.rs @@ -2,7 +2,10 @@ pub(crate) use super::transport::{api_delete, api_get, api_post, redact_secret_f use super::transport::{ argo_url, http_client, normalize_endpoint, response_json, safe_http_error, url, }; -use super::{scope::scoped_connection, tunnel::ArgoServiceTunnel}; +use super::{ + scope::scoped_connection, + tunnel::{verify_confirmed_target, ArgoServiceTunnel}, +}; use crate::commands::{ gitops_crd::{client_for_context, find_api_resource, get_crd_object}, kubeconfig::KubeconfigSource, @@ -11,8 +14,9 @@ use crate::commands::{ use crate::models::AppErrorKind; use crate::models::{ AppError, ArgoApplicationHistory, ArgoApplicationInspector, ArgoApplicationRef, - ArgoConnectionProfile, ArgoConnectionStatus, ArgoManagedResource, ArgoResourceComparison, - ArgoServerCapability, ArgoServerEndpoint, ArgoServiceTunnelUnavailableReason, + ArgoConfirmedTarget, ArgoConnectionProfile, ArgoConnectionStatus, ArgoManagedResource, + ArgoResourceComparison, ArgoServerCapability, ArgoServerEndpoint, + ArgoServiceTunnelUnavailableReason, }; use k8s_openapi::api::core::v1::Service; use kube::{ @@ -365,6 +369,8 @@ fn unavailable_capability( endpoint: None, unavailable_reason: Some(message.into()), unavailable: Some(reason), + target_pod: None, + argo_labeled: false, } } @@ -446,11 +452,22 @@ fn servicetunnel_capabilities(service: &Service) -> Vec { }), unavailable_reason: None, unavailable: None, + target_pod: None, + argo_labeled: false, } }) .collect() } +fn is_argo_labeled(service: &Service) -> bool { + service + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get("app.kubernetes.io/part-of")) + .is_some_and(|value| value == "argocd") +} + fn tunnel_target_unavailable(error: &AppError) -> ArgoServiceTunnelUnavailableReason { match error.kind { AppErrorKind::LiveSessionTargetUnavailable => { @@ -482,6 +499,7 @@ pub async fn discover_argo_servers( let mut capabilities = Vec::new(); for service in list.items { for mut capability in servicetunnel_capabilities(&service) { + capability.argo_labeled = is_argo_labeled(&service); if let Some(ArgoServerEndpoint::ServiceTunnel { namespace, service_name, @@ -489,17 +507,20 @@ pub async fn discover_argo_servers( .. }) = capability.endpoint.as_ref() { - if let Err(error) = - crate::commands::sessions::service::resolve_service_target( - client.clone(), - namespace, - service_name, - *service_port, - ) - .await + match crate::commands::sessions::service::resolve_service_target( + client.clone(), + namespace, + service_name, + *service_port, + ) + .await { - capability.unavailable = Some(tunnel_target_unavailable(&error)); - capability.unavailable_reason = Some(error.message); + Ok(target) => capability.target_pod = Some(target.pod_name), + Err(error) => { + capability.unavailable = + Some(tunnel_target_unavailable(&error)); + capability.unavailable_reason = Some(error.message); + } } } capabilities.push(capability); @@ -526,6 +547,7 @@ pub async fn connect_argo_server( cluster_context: Option, kubeconfig_env_var: Option, workspace_id: Option, + confirmed_target: Option, ) -> Result { let connection_epoch = store.connection_epoch(); let kubeconfig_source_key = kubeconfig_source_key(kubeconfig_env_var.as_deref())?; @@ -564,6 +586,12 @@ pub async fn connect_argo_server( *service_port, ) .await?; + verify_confirmed_target( + confirmed_target.as_ref(), + namespace, + service_name, + started.pod_name(), + )?; let host = argo_url(&profile.url)? .host_str() .expect("normalized service endpoint has a host") @@ -749,9 +777,23 @@ pub(crate) fn managed_resource(value: &Value) -> ArgoManagedResource { pub(crate) fn state(value: Option<&Value>, redact: bool) -> Option { value .and_then(|state| match state { - Value::String(text) => serde_json::from_str(text) + Value::String(text) => serde_json::from_str::(text) .ok() - .or_else(|| Some(Value::String(text.clone()))), + // A JSON string literal is still unstructured text, so it takes the text path below. + .filter(|parsed| !parsed.is_string()) + .or_else(|| { + serde_yaml::from_str::(text) + .ok() + .filter(|parsed| parsed.is_object() || parsed.is_array()) + }) + .or_else(|| { + // Unparseable text cannot be redacted structurally; withhold it if it may be a Secret. + Some(if redact && text.contains("Secret") { + Value::String("[REDACTED]".into()) + } else { + Value::String(text.clone()) + }) + }), _ => Some(state.clone()), }) .map(|mut state| { @@ -860,12 +902,18 @@ fn kubernetes_comparison(resource: ArgoManagedResource) -> ArgoResourceCompariso } fn connected_comparison(value: &Value) -> ArgoResourceComparison { + let is_secret = value.get("kind").and_then(Value::as_str) == Some("Secret"); + // Unstructured Secret state cannot be redacted field by field, so it is withheld whole. + let redacted_state = |key: &str| match state(value.get(key), true) { + Some(Value::String(_)) if is_secret => Some(Value::String("[REDACTED]".into())), + other => other, + }; ArgoResourceComparison { resource: managed_resource(value), - target_state: state(value.get("targetState"), true), - live_state: state(value.get("liveState"), true), - normalized_live_state: state(value.get("normalizedLiveState"), true), - predicted_live_state: state(value.get("predictedLiveState"), true), + target_state: redacted_state("targetState"), + live_state: redacted_state("liveState"), + normalized_live_state: redacted_state("normalizedLiveState"), + predicted_live_state: redacted_state("predictedLiveState"), modified: value.get("modified").and_then(Value::as_bool), exact: Some(true), provenance: Some("argocd-managed-resource".into()), @@ -1717,6 +1765,15 @@ mod tests { assert!(!value.to_string().contains("plaintext")); } + #[test] + fn non_json_secret_state_is_redacted() { + let yaml = serde_json::json!("kind: Secret\ndata:\n password: plaintext\n"); + let redacted = state(Some(&yaml), true).unwrap(); + assert_eq!(redacted["data"]["password"], "[REDACTED]"); + let broken = serde_json::json!("kind: Secret\n\tdata: [plaintext"); + assert_eq!(state(Some(&broken), true).unwrap(), "[REDACTED]"); + } + #[test] fn kubernetes_comparisons_do_not_imply_desired_state() { let comparison = kubernetes_comparison(ArgoManagedResource { @@ -1732,7 +1789,7 @@ mod tests { ); assert!(comparison.target_state.is_none()); assert!(comparison.live_state.is_none()); - assert!(comparison.available_actions.is_empty()); + assert_eq!(comparison.available_actions.len(), 0); } #[test] diff --git a/src-tauri/src/commands/argo/operations.rs b/src-tauri/src/commands/argo/operations.rs index 2e653fb6..96fe1b72 100644 --- a/src-tauri/src/commands/argo/operations.rs +++ b/src-tauri/src/commands/argo/operations.rs @@ -1,7 +1,7 @@ use super::connected::{api_delete, api_get, api_post, ConnectedArgo}; use super::scope::{acquire_connection_lease, scoped_connection, ConnectionLease}; use super::session::{consume, issue, peek, OperationSession, SessionSnapshot}; -use crate::commands::gitops_crd::{client_for_context, find_api_resource}; +use crate::commands::gitops_crd::{fail_closed_client_for_context, find_api_resource}; use crate::commands::kubeconfig::KubeconfigSource; use crate::models::AppErrorKind; use crate::models::{ @@ -171,7 +171,8 @@ async fn fallback_allowed(request: &ArgoOperationRequest) -> Result<(), AppError AppErrorKind::ArgoOperationUnavailable, )); } - let client = client_for_context(context, request.kubeconfig_env_var.clone()).await?; + let client = + fail_closed_client_for_context(context, request.kubeconfig_env_var.clone()).await?; let review = SelfSubjectAccessReview { metadata: ObjectMeta::default(), spec: SelfSubjectAccessReviewSpec { @@ -372,7 +373,8 @@ async fn revalidate_session( } fallback_allowed(request).await?; let context = request.cluster_context.as_deref().expect("validated"); - let client = client_for_context(context, request.kubeconfig_env_var.clone()).await?; + let client = + fail_closed_client_for_context(context, request.kubeconfig_env_var.clone()).await?; let resource = find_api_resource(&client, "argoproj.io", "Application") .await? .ok_or_else(|| AppError::new("Application CRD not found", AppErrorKind::Cluster))?; @@ -623,6 +625,7 @@ async fn kubernetes_operation( // ADR 0009: a confirmed write must not be cancelled by workspace client // rotation after the API server may already have applied it. let client = KubeconfigSource::new(request.kubeconfig_env_var.clone())? + .fail_closed() .operation_client_for_context(request.cluster_context.as_deref().expect("validated")) .await?; let api = Api::::namespaced_with( @@ -767,7 +770,8 @@ async fn resolve_request( AppErrorKind::ArgoOperationUnavailable, ) })?; - let client = client_for_context(context, request.kubeconfig_env_var.clone()).await?; + let client = + fail_closed_client_for_context(context, request.kubeconfig_env_var.clone()).await?; let ar = find_api_resource(&client, "argoproj.io", "Application") .await? .ok_or_else(|| AppError::new("Application CRD not found", AppErrorKind::Cluster))?; diff --git a/src-tauri/src/commands/argo/operations_execution_tests.rs b/src-tauri/src/commands/argo/operations_execution_tests.rs index 2ebe2987..c230cf75 100644 --- a/src-tauri/src/commands/argo/operations_execution_tests.rs +++ b/src-tauri/src/commands/argo/operations_execution_tests.rs @@ -148,10 +148,8 @@ async fn confirmed_kubernetes_operation_survives_workspace_client_rotation() { env::remove_var(&env_var); let _ = fs::remove_file(&kubeconfig); - assert!( - received - .starts_with(b"PATCH /apis/argoproj.io/v1alpha1/namespaces/argocd/applications/demo") - ); + assert!(received + .starts_with(b"PATCH /apis/argoproj.io/v1alpha1/namespaces/argocd/applications/demo")); assert!( result .expect("workspace rotation must not cancel a confirmed write") diff --git a/src-tauri/src/commands/argo/tunnel.rs b/src-tauri/src/commands/argo/tunnel.rs index cf5c6c0b..e348e40f 100644 --- a/src-tauri/src/commands/argo/tunnel.rs +++ b/src-tauri/src/commands/argo/tunnel.rs @@ -5,7 +5,7 @@ use crate::{ service::resolve_service_target, target::client_for_context, }, - models::AppError, + models::{AppError, ArgoConfirmedTarget}, }; use k8s_openapi::api::core::v1::Pod; use kube::{api::Api, Client}; @@ -21,6 +21,7 @@ use tokio::{ pub(crate) struct ArgoServiceTunnel { local_port: u16, + pod_name: String, state: Arc, } @@ -55,6 +56,7 @@ impl ArgoServiceTunnel { let client = client_for_context(cluster_context, kubeconfig_env_var).await?; let target = resolve_service_target(client.clone(), &namespace, &service_name, service_port).await?; + let pod_name = target.pod_name.clone(); verify_port_forward( client.clone(), &target.namespace, @@ -90,10 +92,12 @@ impl ArgoServiceTunnel { namespace, service_name, service_port, + pod_name: pod_name.clone(), }), )); Ok(Self { local_port, + pod_name, state: Arc::new(TunnelState { shutdown: std::sync::Mutex::new(Some(shutdown)), task: std::sync::Mutex::new(Some(task)), @@ -105,6 +109,10 @@ impl ArgoServiceTunnel { self.local_port } + pub(crate) fn pod_name(&self) -> &str { + &self.pod_name + } + pub(crate) fn close(&self) { self.state.close(); } @@ -113,6 +121,7 @@ impl ArgoServiceTunnel { pub(crate) fn test_tunnel(shutdown: oneshot::Sender<()>) -> Self { Self { local_port: 0, + pod_name: String::new(), state: Arc::new(TunnelState { shutdown: std::sync::Mutex::new(Some(shutdown)), task: std::sync::Mutex::new(None), @@ -127,11 +136,39 @@ impl Drop for ArgoServiceTunnel { } } +/// Credentials may only cross a tunnel whose resolved target is exactly what the user confirmed. +pub(crate) fn verify_confirmed_target( + confirmed: Option<&ArgoConfirmedTarget>, + namespace: &str, + service_name: &str, + pod_name: &str, +) -> Result<(), AppError> { + let confirmed = confirmed.ok_or_else(|| { + AppError::new( + "confirm the Argo CD Service tunnel target before sending credentials", + AppErrorKind::ArgoConnection, + ) + })?; + let pod_matches = confirmed + .pod_name + .as_deref() + .is_some_and(|confirmed_pod| confirmed_pod == pod_name); + if confirmed.namespace == namespace && confirmed.service_name == service_name && pod_matches { + return Ok(()); + } + Err(AppError::new( + "Argo CD Service tunnel target changed since it was confirmed; refresh discovery and confirm again", + AppErrorKind::ArgoConnection, + )) +} + #[derive(Clone)] struct ServiceRoute { namespace: String, service_name: String, service_port: u16, + /// The Pod the user confirmed; credentials never reach any other Pod. + pod_name: String, } async fn verify_port_forward( @@ -189,6 +226,12 @@ async fn run_tunnel( route.service_port, ) .await?; + if target.pod_name != route.pod_name { + return Err(AppError::new( + "Argo CD Service now resolves to a different Pod than the confirmed one; reconnect and confirm the new target", + AppErrorKind::ArgoTunnel, + )); + } forward_pod_connection( client, target.namespace, @@ -235,6 +278,7 @@ mod tests { let (shutdown, receiver) = oneshot::channel(); let tunnel = ArgoServiceTunnel { local_port: 0, + pod_name: String::new(), state: Arc::new(TunnelState { shutdown: std::sync::Mutex::new(Some(shutdown)), task: std::sync::Mutex::new(None), @@ -247,6 +291,40 @@ mod tests { state.close(); } + #[test] + fn credentials_require_the_exact_confirmed_target() { + let confirmed = |namespace: &str, service: &str, pod: Option<&str>| ArgoConfirmedTarget { + namespace: namespace.into(), + service_name: service.into(), + pod_name: pod.map(Into::into), + }; + let check = |target: Option| { + verify_confirmed_target(target.as_ref(), "argocd", "argocd-server", "pod-a").is_ok() + }; + assert!(check(Some(confirmed( + "argocd", + "argocd-server", + Some("pod-a") + )))); + assert!(!check(None)); + assert!(!check(Some(confirmed( + "evil", + "argocd-server", + Some("pod-a") + )))); + assert!(!check(Some(confirmed( + "argocd", + "argo-cd-argocd-server", + Some("pod-a") + )))); + assert!(!check(Some(confirmed( + "argocd", + "argocd-server", + Some("pod-b") + )))); + assert!(!check(Some(confirmed("argocd", "argocd-server", None)))); + } + #[test] fn forbidden_port_forward_is_actionable_and_redacted() { let error = port_forward_error(kube::Error::Api(Box::new(kube::core::Status { diff --git a/src-tauri/src/commands/diagnostics.rs b/src-tauri/src/commands/diagnostics.rs index d72d375b..9486e566 100644 --- a/src-tauri/src/commands/diagnostics.rs +++ b/src-tauri/src/commands/diagnostics.rs @@ -173,7 +173,7 @@ mod tests { 12, vec![diagnostic_field("rows", 5)], ); - assert!(stored_backend_diagnostics().is_empty()); + assert_eq!(stored_backend_diagnostics().len(), 0); set_backend_diagnostics_enabled(true); for index in 0..505 { @@ -194,7 +194,7 @@ mod tests { ); clear_stored_diagnostics(); - assert!(stored_backend_diagnostics().is_empty()); + assert_eq!(stored_backend_diagnostics().len(), 0); record_backend_result( "tracked_success", diff --git a/src-tauri/src/commands/events.rs b/src-tauri/src/commands/events.rs index e525a9ae..a5b3270e 100644 --- a/src-tauri/src/commands/events.rs +++ b/src-tauri/src/commands/events.rs @@ -56,6 +56,7 @@ fn event_matches_resource( kind: &str, name: &str, namespace: Option<&str>, + uid: Option<&str>, ) -> bool { event.involved_object.kind.as_deref() == Some(kind) && event.involved_object.name.as_deref() == Some(name) @@ -63,6 +64,10 @@ fn event_matches_resource( Some(ns) => event.involved_object.namespace.as_deref() == Some(ns), None => true, } + && match (uid, event.involved_object.uid.as_deref()) { + (Some(expected), Some(actual)) => expected == actual, + _ => true, + } } fn summarize_event(event: &k8s_openapi::api::core::v1::Event) -> ResourceEventSummary { @@ -83,6 +88,7 @@ pub async fn resource_events_from( kind: String, name: String, namespace: Option, + uid: Option, kubeconfig_env_var: Option, ) -> Result, AppError> { let source = KubeconfigSource::new(kubeconfig_env_var)?; @@ -104,7 +110,9 @@ pub async fn resource_events_from( .await .map_err(AppError::from)? .into_iter() - .filter(|event| event_matches_resource(event, &kind, &name, namespace.as_deref())) + .filter(|event| { + event_matches_resource(event, &kind, &name, namespace.as_deref(), uid.as_deref()) + }) .collect(); events.sort_by_key(event_timestamp); @@ -119,6 +127,7 @@ pub async fn list_resource_events( kind: String, name: String, namespace: Option, + uid: Option, kubeconfig_env_var: Option, request_id: Option, cancel_scope: Option, @@ -138,6 +147,7 @@ pub async fn list_resource_events( kind.clone(), name.clone(), namespace.clone(), + uid, kubeconfig_env_var, ), ) @@ -222,13 +232,15 @@ mod tests { &event, "Pod", "api-0", - Some("payments") + Some("payments"), + None )); assert!(!event_matches_resource( &event, "Pod", "api-1", - Some("payments") + Some("payments"), + None )); assert_eq!(event_source(&event), "deployment-controller"); assert_eq!(event_timestamp(&event).unwrap().timestamp(), 1_700_000_100); @@ -268,13 +280,49 @@ mod tests { ..Default::default() }; - assert!(event_matches_resource(&event, "Node", "kind-worker", None)); + assert!(event_matches_resource( + &event, + "Node", + "kind-worker", + None, + None + )); assert!(!event_matches_resource( &event, "Node", "kind-worker", Some("default"), + None, + )); + } + + #[test] + fn event_matching_requires_matching_uid_when_both_present() { + let event = Event { + involved_object: ObjectReference { + kind: Some("Pod".to_string()), + name: Some("api-0".to_string()), + uid: Some("real".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + assert!(event_matches_resource( + &event, + "Pod", + "api-0", + None, + Some("real") + )); + assert!(!event_matches_resource( + &event, + "Pod", + "api-0", + None, + Some("forged") )); + assert!(event_matches_resource(&event, "Pod", "api-0", None, None)); } #[test] diff --git a/src-tauri/src/commands/gitops_crd.rs b/src-tauri/src/commands/gitops_crd.rs index 34c1ff99..dc1db00b 100644 --- a/src-tauri/src/commands/gitops_crd.rs +++ b/src-tauri/src/commands/gitops_crd.rs @@ -18,6 +18,16 @@ pub(crate) async fn client_for_context( source.client_for_context(cluster_context).await } +/// Client for cluster-changing flows: errors instead of falling back to the +/// default kubeconfig when configured sources do not load. +pub(crate) async fn fail_closed_client_for_context( + cluster_context: &str, + kubeconfig_env_var: Option, +) -> Result { + let source = KubeconfigSource::new(kubeconfig_env_var)?.fail_closed(); + source.client_for_context(cluster_context).await +} + pub(crate) async fn discover_api_resources(client: &Client) -> Result, AppError> { let discovery = Discovery::new(client.clone()) .run_aggregated() diff --git a/src-tauri/src/commands/helm/redaction.rs b/src-tauri/src/commands/helm/redaction.rs index 761b8c22..52580849 100644 --- a/src-tauri/src/commands/helm/redaction.rs +++ b/src-tauri/src/commands/helm/redaction.rs @@ -1,11 +1,8 @@ +use crate::commands::helpers::redact_secret; use k8s_openapi::api::core::v1::{ConfigMap, Secret}; pub(super) fn redact_secret_release(secret: &mut Secret) { - if let Some(data) = secret.data.as_mut() { - if let Some(release) = data.get_mut("release") { - release.0 = b"REDACTED".to_vec(); - } - } + redact_secret(secret); } pub(super) fn redact_configmap_release(configmap: &mut ConfigMap) { diff --git a/src-tauri/src/commands/helm/storage.rs b/src-tauri/src/commands/helm/storage.rs index 7d640417..28c4dc75 100644 --- a/src-tauri/src/commands/helm/storage.rs +++ b/src-tauri/src/commands/helm/storage.rs @@ -230,10 +230,10 @@ async fn release_storage_object( )); } let record = secret_record(cluster_context, &mut secret)?; + redact_secret_release(&mut secret); let metadata = serde_json::to_value(&secret.metadata).map_err(|e| { AppError::new(e.to_string(), AppErrorKind::Serialization).with_source(e) })?; - redact_secret_release(&mut secret); let yaml = serialize_resource_document(&secret, yaml_view_mode, yaml_encoding)?; Ok((record, metadata, yaml)) } diff --git a/src-tauri/src/commands/helm/storage_tests.rs b/src-tauri/src/commands/helm/storage_tests.rs index 30fd931e..676ec697 100644 --- a/src-tauri/src/commands/helm/storage_tests.rs +++ b/src-tauri/src/commands/helm/storage_tests.rs @@ -31,7 +31,7 @@ fn values_summary_treats_explicit_null_as_empty() { assert!(!summary.has_values); assert_eq!(summary.value_count, 0); - assert!(summary.top_level_keys.is_empty()); + assert_eq!(summary.top_level_keys.len(), 0); } #[test] diff --git a/src-tauri/src/commands/incidents.rs b/src-tauri/src/commands/incidents.rs index 8ac5a2c4..acef0ab9 100644 --- a/src-tauri/src/commands/incidents.rs +++ b/src-tauri/src/commands/incidents.rs @@ -179,7 +179,10 @@ async fn list_warning_events( } else { Api::all(client) }; - (namespace, api.list(&list_params()).await) + ( + namespace, + api.list(&list_params().fields("type=Warning")).await, + ) } }); for (namespace, result) in join_all(fetches).await { diff --git a/src-tauri/src/commands/kubeconfig.rs b/src-tauri/src/commands/kubeconfig.rs index 2ba9b922..ea5f827d 100644 --- a/src-tauri/src/commands/kubeconfig.rs +++ b/src-tauri/src/commands/kubeconfig.rs @@ -80,6 +80,7 @@ pub struct KubeconfigSource { app_paths: Vec, show_source_labels: bool, read_env: bool, + fail_closed: bool, } impl KubeconfigSource { @@ -115,6 +116,7 @@ impl KubeconfigSource { .collect(), show_source_labels, read_env: true, + fail_closed: false, }) } @@ -125,9 +127,27 @@ impl KubeconfigSource { app_paths: vec![path], show_source_labels: false, read_env: false, + fail_closed: false, } } + /// Cluster-changing paths use this: when configured sources exist but none + /// load, they error instead of falling back to the default kubeconfig. + #[must_use] + pub fn fail_closed(mut self) -> Self { + self.fail_closed = true; + self + } + + // Checked before client-cache lookups so a client cached from the default + // fallback is never reused by a fail-closed source. + pub(super) fn ensure_configured_sources_load(&self) -> Result<(), AppError> { + if self.fail_closed && self.configured_paths()?.is_some() { + self.read_configured_kubeconfig()?; + } + Ok(()) + } + pub fn key(&self) -> String { let mut hasher = DefaultHasher::new(); self.env_var.hash(&mut hasher); @@ -213,10 +233,21 @@ impl KubeconfigSource { // discovery when that selection is empty, followed by app-added paths. pub(super) fn effective_kubeconfig_paths(&self) -> Result, AppError> { if let Some(paths) = self.configured_paths()? { - return Ok(paths + let mut paths = paths .into_iter() .map(|configured| configured.path) - .collect()); + .collect::>(); + // read_configured_kubeconfig falls back to the default kubeconfig + // when no configured path loads, so changes to it must invalidate + // cached clients too. + if self.read_env { + if let Some(default) = default_kubeconfig_path() { + if !paths.contains(&default) { + paths.push(default); + } + } + } + return Ok(paths); } Ok(standard_kubeconfig_paths() .into_iter() @@ -304,6 +335,20 @@ impl KubeconfigSource { )); } + if self.fail_closed { + return Err(AppError::new( + format!( + "none of the configured kubeconfig sources could be loaded; refusing to fall back to the default kubeconfig for a cluster-changing action: {}", + warnings + .iter() + .map(|warning| warning.message.as_str()) + .collect::>() + .join("; ") + ), + AppErrorKind::Kubeconfig, + )); + } + read_default_kubeconfig_without_env() .map(|kubeconfig| (kubeconfig, warnings)) .map_err(|err| { diff --git a/src-tauri/src/commands/kubeconfig_clients.rs b/src-tauri/src/commands/kubeconfig_clients.rs index c08d6944..397cddc7 100644 --- a/src-tauri/src/commands/kubeconfig_clients.rs +++ b/src-tauri/src/commands/kubeconfig_clients.rs @@ -31,6 +31,7 @@ impl KubeconfigSource { &self, cluster_context: &str, ) -> Result<(Client, String), AppError> { + self.ensure_configured_sources_load()?; let fingerprint = client_cache::fingerprint_files(&self.effective_kubeconfig_paths()?); let source_key = self.key(); let generation = client_cache::finite_client_generation(); @@ -65,6 +66,7 @@ impl KubeconfigSource { &self, cluster_context: &str, ) -> Result { + self.ensure_configured_sources_load()?; let fingerprint = client_cache::fingerprint_files(&self.effective_kubeconfig_paths()?); let source_key = self.key(); if let Some(client) = @@ -95,6 +97,7 @@ impl KubeconfigSource { &self, cluster_context: &str, ) -> Result<(Client, String), AppError> { + self.ensure_configured_sources_load()?; let fingerprint = client_cache::fingerprint_files(&self.effective_kubeconfig_paths()?); let source_key = self.key(); if let Some(cached) = diff --git a/src-tauri/src/commands/kubeconfig_tests.rs b/src-tauri/src/commands/kubeconfig_tests.rs index 47e943fb..e93eb1c4 100644 --- a/src-tauri/src/commands/kubeconfig_tests.rs +++ b/src-tauri/src/commands/kubeconfig_tests.rs @@ -307,3 +307,39 @@ fn e2e_source_ignores_inherited_override() { let _ = fs::remove_file(override_path); let _ = fs::remove_file(standard_path); } + +#[test] +fn missing_app_path_fails_closed_for_cluster_changing_sources() { + let _env_lock = ENV_LOCK.lock().expect("environment lock"); + let home = env::temp_dir().join(unique_env_var("FAIL_CLOSED_HOME")); + let default_path = home.join(".kube").join("config"); + fs::create_dir_all(default_path.parent().expect("default kubeconfig parent")) + .expect("create default kubeconfig directory"); + let default_source = write_kubeconfig("default-context"); + fs::copy(&default_source, &default_path).expect("copy default kubeconfig"); + let missing = env::temp_dir().join(unique_env_var("FAIL_CLOSED_MISSING")); + let env_var = unique_env_var("FAIL_CLOSED_ENV"); + let _kubeconfig = EnvVarGuard::set(&env_var, &missing); + let _home = EnvVarGuard::set("HOME", &home); + let source = KubeconfigSource::from_settings(Some(&env_var), Vec::new(), true).expect("source"); + + let (kubeconfig, _) = source + .read_configured_kubeconfig() + .expect("read-only paths fall back to the default kubeconfig"); + assert_eq!( + kubeconfig.current_context.as_deref(), + Some("default-context") + ); + + let source = source.fail_closed(); + + let error = source + .read_configured_kubeconfig() + .expect_err("must not fall back to the default kubeconfig"); + assert_eq!(error.kind, AppErrorKind::Kubeconfig); + assert!(error.message.contains("refusing to fall back")); + assert!(source.ensure_configured_sources_load().is_err()); + + let _ = fs::remove_dir_all(home); + let _ = fs::remove_file(default_source); +} diff --git a/src-tauri/src/commands/operations.rs b/src-tauri/src/commands/operations.rs index 1d619e88..4a06856d 100644 --- a/src-tauri/src/commands/operations.rs +++ b/src-tauri/src/commands/operations.rs @@ -141,6 +141,7 @@ async fn operation_client_for( kubeconfig_env_var: Option, ) -> Result { KubeconfigSource::new(kubeconfig_env_var)? + .fail_closed() .operation_client_for_context(&target.cluster_context) .await } @@ -150,6 +151,7 @@ async fn client_for( kubeconfig_env_var: Option, ) -> Result { KubeconfigSource::new(kubeconfig_env_var)? + .fail_closed() .client_for_context(&target.cluster_context) .await } @@ -193,6 +195,18 @@ fn validate_target(target: &ClusterOperationTarget, supported: &[&str]) -> Resul AppErrorKind::UnsupportedOperation, )); } + if let Some(api_version) = target.api_version.as_deref() { + let expected = builtin_kind(&target.kind).map(BuiltinKind::api_version); + if expected.as_deref() != Some(api_version) { + return Err(AppError::new( + format!( + "{} is not the built-in {} resource", + api_version, target.kind + ), + AppErrorKind::UnsupportedOperation, + )); + } + } Ok(()) } @@ -274,6 +288,7 @@ mod tests { namespace: Some("default".to_string()), kind: kind.to_string(), name: "api".to_string(), + api_version: None, } } diff --git a/src-tauri/src/commands/pod_exec/runner.rs b/src-tauri/src/commands/pod_exec/runner.rs index 9c69b9c1..54597a22 100644 --- a/src-tauri/src/commands/pod_exec/runner.rs +++ b/src-tauri/src/commands/pod_exec/runner.rs @@ -376,11 +376,11 @@ mod tests { let first = take_utf8_prefix(&mut pending).expect("first chunk"); assert_eq!(first, "h"); - assert!(!pending.is_empty()); + assert_ne!(pending.len(), 0); pending.append(&mut remainder); assert_eq!(take_utf8_prefix(&mut pending).expect("rest"), "éllo"); - assert!(pending.is_empty()); + assert_eq!(pending.len(), 0); } #[test] @@ -389,7 +389,7 @@ mod tests { assert_eq!(take_utf8_prefix(&mut pending).expect("chunk"), "a\u{FFFD}"); assert_eq!(take_utf8_prefix(&mut pending).expect("rest"), "b"); - assert!(pending.is_empty()); + assert_eq!(pending.len(), 0); } #[test] @@ -401,6 +401,6 @@ mod tests { pending.push(0xA9); assert_eq!(take_utf8_prefix(&mut pending).expect("complete"), "é"); - assert!(pending.is_empty()); + assert_eq!(pending.len(), 0); } } diff --git a/src-tauri/src/commands/pod_exec/validation.rs b/src-tauri/src/commands/pod_exec/validation.rs index 1c4b1f71..bf21df63 100644 --- a/src-tauri/src/commands/pod_exec/validation.rs +++ b/src-tauri/src/commands/pod_exec/validation.rs @@ -135,6 +135,6 @@ pub(super) async fn client_for_context( cluster_context: &str, kubeconfig_env_var: Option, ) -> Result { - let source = KubeconfigSource::new(kubeconfig_env_var)?; + let source = KubeconfigSource::new(kubeconfig_env_var)?.fail_closed(); source.live_client_for_context(cluster_context).await } diff --git a/src-tauri/src/commands/rbac_inventory.rs b/src-tauri/src/commands/rbac_inventory.rs index 6535070e..ba7a391a 100644 --- a/src-tauri/src/commands/rbac_inventory.rs +++ b/src-tauri/src/commands/rbac_inventory.rs @@ -1,7 +1,7 @@ use super::{binding_summary, role_summary, service_account_summary}; use crate::{ commands::helpers::list_params, - models::{AppError, RbacBindingSummary, RbacRoleSummary, ServiceAccountSummary}, + models::{AppError, AppErrorKind, RbacBindingSummary, RbacRoleSummary, ServiceAccountSummary}, }; use k8s_openapi::api::{ core::v1::ServiceAccount, @@ -42,6 +42,8 @@ where { let mut items = Vec::new(); let mut token = None; + // Any recurring token means the server is cycling, not just repeating the last one. + let mut seen_tokens = std::collections::HashSet::new(); loop { let params = token.as_deref().map_or_else( || list_params().limit(500), @@ -49,8 +51,21 @@ where ); match api.list(¶ms).await { Ok(page) => { - token = page.metadata.continue_; + let next = page.metadata.continue_; items.extend(page.items); + if next + .as_deref() + .is_some_and(|value| !value.is_empty() && !seen_tokens.insert(value.to_owned())) + { + return InventoryLoad::partial( + items, + AppError::new( + "Kubernetes API returned a repeated continue token", + AppErrorKind::Cluster, + ), + ); + } + token = next; } Err(error) => return InventoryLoad::partial(items, AppError::from(error)), } diff --git a/src-tauri/src/commands/resources/apply.rs b/src-tauri/src/commands/resources/apply.rs index 83ebcf4d..1dc7ec20 100644 --- a/src-tauri/src/commands/resources/apply.rs +++ b/src-tauri/src/commands/resources/apply.rs @@ -14,7 +14,7 @@ use validation::validate_yaml_apply; #[tauri::command] pub async fn prepare_yaml_apply(request: YamlApplyRequest) -> Result { let validated = validate_yaml_apply(request)?; - let source = KubeconfigSource::new(validated.request.kubeconfig_env_var.clone())?; + let source = KubeconfigSource::new(validated.request.kubeconfig_env_var.clone())?.fail_closed(); let client = source .client_for_context(&validated.request.cluster_context) .await?; @@ -24,7 +24,7 @@ pub async fn prepare_yaml_apply(request: YamlApplyRequest) -> Result Result { let validated = validate_yaml_apply(request)?; - let source = KubeconfigSource::new(validated.request.kubeconfig_env_var.clone())?; + let source = KubeconfigSource::new(validated.request.kubeconfig_env_var.clone())?.fail_closed(); let client = source .operation_client_for_context(&validated.request.cluster_context) .await?; diff --git a/src-tauri/src/commands/resources/dynamic.rs b/src-tauri/src/commands/resources/dynamic.rs index 3b0c9b46..6271c93f 100644 --- a/src-tauri/src/commands/resources/dynamic.rs +++ b/src-tauri/src/commands/resources/dynamic.rs @@ -11,9 +11,10 @@ use crate::commands::{ }; use crate::models::AppErrorKind; use crate::models::{ - argo_health_assessment, evaluate_health, AppError, DiscoveredResourceKind, HealthAssessment, - HealthAssessmentEvidence, HealthAssessmentInput, HealthAssessmentSource, HealthAssessmentState, - ResourceDetailsFull, ResourceHealth, ResourceSummary, YamlEncoding, YamlViewMode, + argo_health_assessment, condition_evidence, evaluate_health, AppError, DiscoveredResourceKind, + HealthAssessment, HealthAssessmentEvidence, HealthAssessmentInput, HealthAssessmentSource, + HealthAssessmentState, ResourceDetailsFull, ResourceHealth, ResourceSummary, YamlEncoding, + YamlViewMode, }; use chrono::{TimeZone, Utc}; use kube::{ @@ -149,7 +150,7 @@ fn dynamic_health_assessment(data: &Value) -> HealthAssessment { matches!(kind, "Ready" | "Healthy" | "Reconciling" | "Stalled").then(|| { HealthAssessmentEvidence { source: HealthAssessmentSource::Kubernetes, - raw: condition.clone(), + raw: condition_evidence(condition), state: Some(state), current: true, reason: format!("Kubernetes {kind} condition is {status}"), diff --git a/src-tauri/src/commands/resources/ingress_status.rs b/src-tauri/src/commands/resources/ingress_status.rs index efcc7734..e3b79b6b 100644 --- a/src-tauri/src/commands/resources/ingress_status.rs +++ b/src-tauri/src/commands/resources/ingress_status.rs @@ -21,9 +21,12 @@ fn has_load_balancer_address(status: &IngressStatus) -> bool { .as_ref() .and_then(|load_balancer| load_balancer.ingress.as_ref()) .is_some_and(|ingress| { - ingress - .iter() - .any(|entry| entry.ip.is_some() || entry.hostname.is_some()) + ingress.iter().any(|entry| { + [&entry.ip, &entry.hostname] + .into_iter() + .flatten() + .any(|address| !address.trim().is_empty()) + }) }) } @@ -107,4 +110,23 @@ mod tests { assert_eq!(summary.status.as_deref(), Some("Pending")); assert_eq!(summary.ready.as_deref(), Some("false")); } + + #[test] + fn ingress_with_blank_load_balancer_address_is_pending() { + let status = IngressStatus { + load_balancer: Some(IngressLoadBalancerStatus { + ingress: Some(vec![IngressLoadBalancerIngress { + ip: Some(String::new()), + hostname: Some(" ".to_string()), + ..Default::default() + }]), + }), + }; + let mut summary = summary(); + + apply_ingress_status(&mut summary, Some(&status)); + + assert_eq!(summary.status.as_deref(), Some("Pending")); + assert_eq!(summary.ready.as_deref(), Some("false")); + } } diff --git a/src-tauri/src/commands/resources/revisions.rs b/src-tauri/src/commands/resources/revisions.rs index 65403a57..2cba700d 100644 --- a/src-tauri/src/commands/resources/revisions.rs +++ b/src-tauri/src/commands/resources/revisions.rs @@ -39,11 +39,12 @@ async fn deployment_revisions_from( AppErrorKind::Cluster, ) })?; + let mut params = ListParams::default(); + if let Some(selector) = deployment_selector(&deployment) { + params = params.labels(&selector); + } let replica_sets: Api = Api::namespaced(client, namespace); - let replica_sets = replica_sets - .list(&ListParams::default()) - .await - .map_err(AppError::from)?; + let replica_sets = replica_sets.list(¶ms).await.map_err(AppError::from)?; Ok(deployment_revisions_from_replica_sets( replica_sets.items, @@ -51,6 +52,20 @@ async fn deployment_revisions_from( )) } +fn deployment_selector(deployment: &Deployment) -> Option { + let labels = deployment.spec.as_ref()?.selector.match_labels.as_ref()?; + if labels.is_empty() { + return None; + } + Some( + labels + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join(","), + ) +} + fn deployment_revisions_from_replica_sets( replica_sets: Vec, deployment_uid: &str, @@ -158,6 +173,6 @@ mod tests { assert_eq!(revisions[2].revision, None); let serialized = serde_json::to_value(&revisions[2]).unwrap(); assert!(serialized.get("revision").is_none()); - assert!(!revisions[0].pod_template_yaml.is_empty()); + assert_ne!(revisions[0].pod_template_yaml.len(), 0); } } diff --git a/src-tauri/src/commands/resources/scope.rs b/src-tauri/src/commands/resources/scope.rs index 1fb9bc81..a7db03d7 100644 --- a/src-tauri/src/commands/resources/scope.rs +++ b/src-tauri/src/commands/resources/scope.rs @@ -382,7 +382,7 @@ mod tests { let groups = group_requests(vec![pod_request(None), pod_request(Some("default"))]).expect("groups"); - assert!(ownership_namespaces(&groups).is_empty()); + assert_eq!(ownership_namespaces(&groups).len(), 0); } #[test] diff --git a/src-tauri/src/commands/resources/topology_collection.rs b/src-tauri/src/commands/resources/topology_collection.rs index 82005638..9cd87bd6 100644 --- a/src-tauri/src/commands/resources/topology_collection.rs +++ b/src-tauri/src/commands/resources/topology_collection.rs @@ -21,7 +21,7 @@ use k8s_openapi::api::{ }; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use k8s_openapi::{ClusterResourceScope, NamespaceResourceScope}; -use kube::{Api, Client, Error as KubeError}; +use kube::{core::PartialObjectMeta, Api, Client, Error as KubeError}; const MAX_TOPOLOGY_LIST_CONCURRENCY: usize = 16; const DEPLOYMENT_REVISION_ANNOTATION: &str = "deployment.kubernetes.io/revision"; @@ -155,6 +155,65 @@ where Ok((rows, warnings)) } +/// Lists only object metadata so Secret/ConfigMap payloads never reach the backend. +async fn list_namespaced_metadata_with_warnings( + client: Client, + namespaces: &[String], +) -> Result<(Vec>, Vec), AppError> +where + T: Clone + + std::fmt::Debug + + serde::de::DeserializeOwned + + kube::Resource + + k8s_openapi::Resource + + Send + + Sync + + 'static, +{ + let scopes: Vec> = if namespaces.is_empty() { + vec![None] + } else { + namespaces.iter().cloned().map(Some).collect() + }; + let outcomes = stream::iter(scopes) + .map(|namespace| { + let api: Api = match &namespace { + Some(namespace) => Api::namespaced(client.clone(), namespace), + None => Api::all(client.clone()), + }; + async move { + let rows = api.list_metadata(&list_params()).await; + (namespace, rows.map(|rows| rows.items)) + } + }) + .buffered(MAX_TOPOLOGY_LIST_CONCURRENCY) + .collect::>() + .await; + + let mut out = Vec::new(); + let mut warnings = Vec::new(); + for (namespace, outcome) in outcomes { + match outcome { + Ok(rows) => out.extend(rows), + Err(error) if is_optional_topology_list_error(&error) => { + push_topology_list_warning::(&mut warnings, namespace.as_deref(), &error); + } + Err(error) => return Err(AppError::from(error)), + } + } + Ok((out, warnings)) +} + +pub(super) fn inputs_from_partial_metadata( + cluster_context: &str, + items: Vec>, +) -> Vec { + items + .iter() + .map(|item| input_from_metadata(cluster_context, T::KIND, T::API_VERSION, &item.metadata)) + .collect() +} + async fn list_cluster_with_warnings(client: Client) -> Result<(Vec, Vec), AppError> where T: Clone @@ -390,8 +449,8 @@ async fn collect_support_topology_inputs( list_namespaced_with_warnings::(client.clone(), namespaces), list_namespaced_with_warnings::(client.clone(), namespaces), list_namespaced_with_warnings::(client.clone(), namespaces), - list_namespaced_with_warnings::(client.clone(), namespaces), - list_namespaced_with_warnings::(client.clone(), namespaces), + list_namespaced_metadata_with_warnings::(client.clone(), namespaces), + list_namespaced_metadata_with_warnings::(client.clone(), namespaces), list_cluster_with_warnings::(client), )?; @@ -407,8 +466,8 @@ async fn collect_support_topology_inputs( inputs.extend(inputs_from_metadata(cluster_context, pvcs)); inputs.extend(inputs_from_metadata(cluster_context, services)); inputs.extend(inputs_from_metadata(cluster_context, ingresses)); - inputs.extend(inputs_from_metadata(cluster_context, configmaps)); - inputs.extend(inputs_from_metadata(cluster_context, secrets)); + inputs.extend(inputs_from_partial_metadata(cluster_context, configmaps)); + inputs.extend(inputs_from_partial_metadata(cluster_context, secrets)); inputs.extend(inputs_from_metadata(cluster_context, storageclasses)); Ok(TopologyInputCollection { diff --git a/src-tauri/src/commands/resources/topology_dynamic.rs b/src-tauri/src/commands/resources/topology_dynamic.rs index df11feb2..12597b0b 100644 --- a/src-tauri/src/commands/resources/topology_dynamic.rs +++ b/src-tauri/src/commands/resources/topology_dynamic.rs @@ -1,13 +1,11 @@ use super::{ - api_resource_from_discovered, dynamic_resource_summary, - topology::{input_from_metadata, TopologyInputResource}, + api_resource_from_discovered, dynamic_resource_summary, topology::TopologyInputResource, }; use crate::commands::helpers::{extract_owner_ref_summary, list_params}; use crate::models::AppErrorKind; use crate::models::{AppError, DiscoveredResourceKind}; use futures_util::{stream, StreamExt}; use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use kube::{ api::{Api, DynamicObject}, Client, Error as KubeError, @@ -20,23 +18,6 @@ const MAX_DYNAMIC_TOPOLOGY_KIND_CONCURRENCY: usize = 48; const MAX_DYNAMIC_TOPOLOGY_LIST_CONCURRENCY: usize = 32; type DynamicListLimiter = Arc; -fn inputs_from_metadata(cluster_context: &str, items: Vec) -> Vec -where - T: k8s_openapi::Metadata, -{ - items - .iter() - .map(|item| { - input_from_metadata( - cluster_context, - ::KIND, - ::API_VERSION, - item.metadata(), - ) - }) - .collect() -} - fn is_optional_app_error(error: &AppError) -> bool { matches!(error.kind, AppErrorKind::Forbidden | AppErrorKind::NotFound) } @@ -81,16 +62,19 @@ pub(super) async fn list_crd_definition_inputs( warnings: &mut Vec, ) -> Result, AppError> { let api: Api = Api::all(client); - match api.list(&list_params()).await { - Ok(rows) => Ok(inputs_from_metadata(cluster_context, rows.items) - .into_iter() - .filter(|input| { - namespaces.is_empty() - || input.summary.git_ops_owner.is_some() - || input.summary.helm_release.is_some() - || input.owner.is_some() - }) - .collect()), + match api.list_metadata(&list_params()).await { + Ok(rows) => Ok(super::topology_collection::inputs_from_partial_metadata( + cluster_context, + rows.items, + ) + .into_iter() + .filter(|input| { + namespaces.is_empty() + || input.summary.git_ops_owner.is_some() + || input.summary.helm_release.is_some() + || input.owner.is_some() + }) + .collect()), Err(error) if is_optional_topology_list_error(&error) => { warnings.push(format!( "Skipped CustomResourceDefinition across namespaces in topology: {error}" diff --git a/src-tauri/src/commands/resources/topology_tests.rs b/src-tauri/src/commands/resources/topology_tests.rs index f49119d0..55d21fe7 100644 --- a/src-tauri/src/commands/resources/topology_tests.rs +++ b/src-tauri/src/commands/resources/topology_tests.rs @@ -368,7 +368,7 @@ fn builds_deployment_to_replicaset_to_pod_edges_from_owner_uids() { assert!(topology.edges.iter().any(|edge| { edge.source == rs_id && edge.target == pod_id && edge.relation == TopologyRelation::Owns })); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } #[test] @@ -412,7 +412,7 @@ fn builds_custom_resource_to_workload_edges_from_owner_uids() { && edge.target == statefulset_id && edge.relation == TopologyRelation::Owns })); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } #[test] @@ -452,7 +452,7 @@ fn builds_daemonset_to_pod_edges_from_owner_uids() { && edge.target == pod_id && edge.relation == TopologyRelation::Owns })); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } #[test] @@ -503,7 +503,7 @@ fn builds_cronjob_to_job_to_pod_edges_from_owner_uids() { assert!(topology.edges.iter().any(|edge| { edge.source == job_id && edge.target == pod_id && edge.relation == TopologyRelation::Owns })); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } #[test] @@ -638,7 +638,7 @@ fn builds_network_flow_from_ingress_to_service_slice_and_pod() { && edge.target == pod_id && edge.relation == TopologyRelation::Targets })); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } #[test] @@ -699,7 +699,7 @@ fn network_flow_skips_missing_endpoint_warning_for_external_name_service() { assert_eq!(topology.nodes.len(), 1); assert_eq!(topology.nodes[0].kind, "Service"); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } #[test] @@ -739,5 +739,5 @@ fn network_flow_skips_missing_pod_warning_for_selectorless_endpoint_slices() { && edge.target == slice_id && edge.relation == TopologyRelation::Targets })); - assert!(topology.warnings.is_empty()); + assert_eq!(topology.warnings.len(), 0); } diff --git a/src-tauri/src/commands/sessions/target.rs b/src-tauri/src/commands/sessions/target.rs index 32d4d5be..9f1b09dc 100644 --- a/src-tauri/src/commands/sessions/target.rs +++ b/src-tauri/src/commands/sessions/target.rs @@ -146,6 +146,6 @@ pub(crate) async fn client_for_context( cluster_context: &str, kubeconfig_env_var: Option, ) -> Result { - let source = KubeconfigSource::new(kubeconfig_env_var)?; + let source = KubeconfigSource::new(kubeconfig_env_var)?.fail_closed(); source.live_client_for_context(cluster_context).await } diff --git a/src-tauri/src/commands/streams.rs b/src-tauri/src/commands/streams.rs index 2eff6f87..689a6eba 100644 --- a/src-tauri/src/commands/streams.rs +++ b/src-tauri/src/commands/streams.rs @@ -59,6 +59,14 @@ fn validate_event_watch_target( Ok(()) } +/// Mirrors `MAX_RETAINED_LOG_LINES` in `src/features/resource-detail/log-helpers.ts`; +/// the frontend never keeps more lines than this, so the backend never requests more. +const MAX_LOG_TAIL_LINES: i64 = 1_000; + +fn clamp_tail_lines(tail_lines: Option) -> i64 { + tail_lines.unwrap_or(200).min(MAX_LOG_TAIL_LINES) +} + fn validate_pod_log_stream_request(request: &PodLogStreamRequest) -> Result<(), AppError> { if request.cluster_context.trim().is_empty() || request.namespace.trim().is_empty() diff --git a/src-tauri/src/commands/streams/aggregate_logs.rs b/src-tauri/src/commands/streams/aggregate_logs.rs index 91a9444c..1cb58c94 100644 --- a/src-tauri/src/commands/streams/aggregate_logs.rs +++ b/src-tauri/src/commands/streams/aggregate_logs.rs @@ -76,7 +76,7 @@ pub(super) async fn run_aggregated_log_stream( }; let pods: Api = Api::namespaced(client.clone(), &request.namespace); let options = LogStreamOptions { - tail_lines: Some(request.tail_lines.unwrap_or(200)), + tail_lines: Some(super::clamp_tail_lines(request.tail_lines)), since_seconds: request.since_seconds, }; let mut source_streams = SourceStreams::default(); diff --git a/src-tauri/src/commands/streams/logs.rs b/src-tauri/src/commands/streams/logs.rs index bbc33471..c8d0a569 100644 --- a/src-tauri/src/commands/streams/logs.rs +++ b/src-tauri/src/commands/streams/logs.rs @@ -32,7 +32,7 @@ pub(super) async fn run_pod_log_stream( let params = LogParams { container: request.container.clone(), follow: true, - tail_lines: Some(request.tail_lines.unwrap_or(200)), + tail_lines: Some(super::clamp_tail_lines(request.tail_lines)), since_seconds: request.since_seconds, timestamps: true, ..LogParams::default() diff --git a/src-tauri/src/commands/streams/watch.rs b/src-tauri/src/commands/streams/watch.rs index 55cadf3c..e97a4b6a 100644 --- a/src-tauri/src/commands/streams/watch.rs +++ b/src-tauri/src/commands/streams/watch.rs @@ -14,7 +14,9 @@ use kube::{ core::Status, Client, }; -use std::time::Duration; +use std::time::{Duration, Instant}; + +const WATCH_TIMEOUT_SECS: u32 = 30; fn event_action(event: &WatchEvent) -> String { match event { @@ -181,7 +183,8 @@ async fn run_resource_watch_with_client( } }; let api = scoped_dynamic_api(client, &key, namespaced, &api_resource); - let params = WatchParams::default().timeout(30); + let params = WatchParams::default().timeout(WATCH_TIMEOUT_SECS); + let watch_started = Instant::now(); match api.watch(¶ms, &resource_version).await { Ok(stream) => { @@ -192,7 +195,13 @@ async fn run_resource_watch_with_client( loop { let Some(event) = stream.next().await else { // Kubernetes ends watches normally at timeoutSeconds. Resume from - // the last version without reporting an outage or delaying renewal. + // the last version without reporting an outage. An earlier EOF keeps + // the reconnect delay so a misbehaving server cannot cause a request loop. + if watch_started.elapsed() + < Duration::from_secs(u64::from(WATCH_TIMEOUT_SECS)) + { + tokio::time::sleep(Duration::from_secs(2)).await; + } continue 'watch; }; match event { diff --git a/src-tauri/src/models/argo.rs b/src-tauri/src/models/argo.rs index 18582549..350f30c2 100644 --- a/src-tauri/src/models/argo.rs +++ b/src-tauri/src/models/argo.rs @@ -383,6 +383,22 @@ pub struct ArgoServerCapability { pub unavailable_reason: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub unavailable: Option, + /// Pod discovery resolved for the Service; shown to the user for target confirmation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_pod: Option, + /// Whether the Service carries `app.kubernetes.io/part-of=argocd`. A hint, not identity. + #[serde(default)] + pub argo_labeled: bool, +} + +/// Exact private tunnel target the user confirmed before credentials are sent. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArgoConfirmedTarget { + pub namespace: String, + pub service_name: String, + #[serde(default)] + pub pod_name: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/src-tauri/src/models/health.rs b/src-tauri/src/models/health.rs index f0d641c6..3a23d993 100644 --- a/src-tauri/src/models/health.rs +++ b/src-tauri/src/models/health.rs @@ -81,6 +81,17 @@ pub struct HealthAssessmentInput { pub evidence: Vec, } +/// Keeps only the fields health evidence needs from a status condition. +pub fn condition_evidence(condition: &Value) -> Value { + let mut kept = serde_json::Map::new(); + for key in ["type", "status", "reason", "message"] { + if let Some(value) = condition.get(key) { + kept.insert(key.to_string(), value.clone()); + } + } + Value::Object(kept) +} + pub fn evaluate_health(mut input: HealthAssessmentInput) -> HealthAssessment { let completeness = if input.provider_available { HealthAssessmentCompleteness::Complete @@ -253,7 +264,7 @@ pub fn argo_application_set_health_assessment( let condition_status = condition.get("status").and_then(Value::as_str); evidence.push(HealthAssessmentEvidence { source: HealthAssessmentSource::ArgoHealth, - raw: condition.clone(), + raw: condition_evidence(condition), state: if condition_status == Some("True") { match condition_type { Some("ErrorOccurred") => Some(HealthAssessmentState::Degraded), diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index c0ef55b6..d23eef8d 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -23,10 +23,10 @@ pub use argo::{ ArgoAppProjectDetails, ArgoAppProjectSummary, ArgoApplicationDetails, ArgoApplicationHistory, ArgoApplicationInspector, ArgoApplicationRef, ArgoApplicationSetDetails, ArgoApplicationSetSummary, ArgoApplicationSourceSummary, ArgoApplicationSummary, - ArgoConnectedFallback, ArgoConnectionProfile, ArgoConnectionStatus, ArgoInspectionFailure, - ArgoManagedResource, ArgoOperationConfirmation, ArgoOperationPreflight, ArgoOperationRequest, - ArgoOperationResult, ArgoResourceComparison, ArgoServerCapability, ArgoServerEndpoint, - ArgoServiceTunnelUnavailableReason, + ArgoConfirmedTarget, ArgoConnectedFallback, ArgoConnectionProfile, ArgoConnectionStatus, + ArgoInspectionFailure, ArgoManagedResource, ArgoOperationConfirmation, ArgoOperationPreflight, + ArgoOperationRequest, ArgoOperationResult, ArgoResourceComparison, ArgoServerCapability, + ArgoServerEndpoint, ArgoServiceTunnelUnavailableReason, }; pub use cancellation::{CancelBackendRequestsResult, CancelWorkspaceRequestsResult}; pub use cluster::ClusterContext; @@ -43,8 +43,8 @@ pub use flux::{ FluxResourceSummary, }; pub use health::{ - argo_application_set_health_assessment, argo_health_assessment, evaluate_health, - HealthAssessment, HealthAssessmentCompleteness, HealthAssessmentEvidence, + argo_application_set_health_assessment, argo_health_assessment, condition_evidence, + evaluate_health, HealthAssessment, HealthAssessmentCompleteness, HealthAssessmentEvidence, HealthAssessmentInput, HealthAssessmentSource, HealthAssessmentState, }; pub use helm::{ diff --git a/src-tauri/src/models/operations.rs b/src-tauri/src/models/operations.rs index ff9c968d..58b4313a 100644 --- a/src-tauri/src/models/operations.rs +++ b/src-tauri/src/models/operations.rs @@ -7,6 +7,8 @@ pub struct ClusterOperationTarget { pub kind: String, pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub namespace: Option, } diff --git a/src/app/svelte/ArgoConnectionSettings.svelte b/src/app/svelte/ArgoConnectionSettings.svelte index 23d50556..7d6d43ce 100644 --- a/src/app/svelte/ArgoConnectionSettings.svelte +++ b/src/app/svelte/ArgoConnectionSettings.svelte @@ -35,7 +35,7 @@ discoverArgoServers, forgetArgoCredential, } from "@/lib/tauri"; - import type { ArgoServerEndpoint } from "@/lib/types"; + import type { ArgoConfirmedTarget, ArgoServerEndpoint } from "@/lib/types"; let { clusterContext, workspaceId, kubeconfigEnvVar }: { clusterContext?: string; @@ -64,6 +64,12 @@ let busy = $state(false); let error = $state(null); let connected = $state(null); + let pendingConfirmation = $state<{ + saved?: (typeof settings.argoProfiles)[number]; + endpoint: ArgoServerEndpoint; + target: ArgoConfirmedTarget; + argoLabeled: boolean; + } | null>(null); const matchingProfiles = $derived( clusterContext && workspaceId ? eligibleArgoProfiles( @@ -145,6 +151,15 @@ return () => finiteReadCleanup.schedule(cancelScope, queryKey); }); + // A confirmation belongs to one cluster scope; any scope or selection change discards it. + $effect(() => { + void selectedCapabilityId; + void clusterContext; + void kubeconfigEnvVar; + void workspaceId; + pendingConfirmation = null; + }); + $effect(() => { connected = connectionStatuses.data?.find(([, status]) => status.connected)?.[0] ?? null; }); @@ -165,9 +180,45 @@ return `argo:${workspaceId ?? "global"}:${clusterContext ?? "global"}:${kubeconfigEnvVar ?? "global"}:${argoEndpointIdentity(endpoint)}`; } - async function connect(saved?: (typeof settings.argoProfiles)[number]) { + // Credentials never cross a private tunnel until the user confirms the exact discovered target. + function requestConnect(saved?: (typeof settings.argoProfiles)[number]) { const endpoint = saved?.endpoint ?? draftEndpoint; if (!endpoint) return; + if (endpoint.kind !== "serviceTunnel") return void connect(saved); + const capability = discovered.data?.find( + (server) => + server.endpoint?.kind === "serviceTunnel" && + server.endpoint.namespace === endpoint.namespace && + server.endpoint.serviceName === endpoint.serviceName && + server.endpoint.servicePort === endpoint.servicePort && + !server.unavailableReason, + ); + if (!capability?.targetPod) { + error = "This Service tunnel target was not discovered. Refresh discovery and try again."; + return; + } + error = null; + pendingConfirmation = { + saved, + endpoint, + target: { + namespace: endpoint.namespace, + serviceName: endpoint.serviceName, + podName: capability.targetPod, + }, + argoLabeled: capability.argoLabeled, + }; + } + + async function connect( + saved?: (typeof settings.argoProfiles)[number], + confirmedTarget?: ArgoConfirmedTarget, + confirmedEndpoint?: ArgoServerEndpoint, + ) { + // A confirmed tunnel connects only to the endpoint shown in the confirmation, even if the form changed since. + const endpoint = confirmedEndpoint ?? saved?.endpoint ?? draftEndpoint; + if (!endpoint) return; + pendingConfirmation = null; busy = true; error = null; try { @@ -185,6 +236,7 @@ clusterContext, kubeconfigEnvVar, workspaceId, + confirmedTarget, }); if (result.profile) { const profile = result.profile; @@ -207,6 +259,8 @@ void queryClient.invalidateQueries({ queryKey: ["argo-connection-status"] }); } catch (caught) { error = caught instanceof Error ? caught.message : String(caught); + // A rejected tunnel target is usually a replaced Pod; refetch so the next confirmation shows the current one. + if (confirmedTarget) void queryClient.invalidateQueries({ queryKey: discoveryQueryKey }); } finally { // Credentials never persist in component state after submit. token = ""; @@ -272,7 +326,7 @@ Only discovered selector-backed TCP Services can be connected. @@ -303,8 +357,30 @@ {#if insecureTls} Insecure sessionCertificate validation is disabled and never saved. {/if} + {#if pendingConfirmation} + {@const pending = pendingConfirmation} + + + Confirm tunnel target + +

Your Argo CD credentials will be sent to this cluster workload. Any workload with this name can capture them.

+
+
Namespace
{pending.target.namespace}
+
Service
{pending.target.serviceName}
+
Pod
{pending.target.podName}
+
+ {#if !pending.argoLabeled} +

This Service does not carry the standard Argo CD label (app.kubernetes.io/part-of=argocd). Only continue if you recognise it as your Argo CD server.

+ {/if} +
+ + +
+
+
+ {/if} {#if error}Connection failed{error}{/if} - + {:else}

Open Settings from a workspace to discover or connect an Argo CD server.

@@ -312,7 +388,7 @@ {#if matchingProfiles.length > 0}

Saved server profiles

{#each matchingProfiles as profile} -
{endpointLabel(profile.endpoint)}
{#if clusterContext}{/if}
+
{endpointLabel(profile.endpoint)}
{#if clusterContext}{/if}
{/each}
{/if} diff --git a/src/components/sidebar-tree-helpers.ts b/src/components/sidebar-tree-helpers.ts index cbdadcb1..140c310d 100644 --- a/src/components/sidebar-tree-helpers.ts +++ b/src/components/sidebar-tree-helpers.ts @@ -16,8 +16,8 @@ export function buildShallowNamespaceTreeNode(namespace: string): TreeNode { export function extraDiscoveredKinds( resourceKinds: DiscoveredResourceKind[], ): DiscoveredResourceKind[] { - return resourceKinds - .toSorted((left, right) => { + return [...resourceKinds] + .sort((left, right) => { return ( left.kind.localeCompare(right.kind) || left.apiVersion.localeCompare(right.apiVersion) || @@ -59,13 +59,13 @@ export function buildCustomResourceGroupNodes({ kindsByGroup.set(resourceKind.group, groupKinds); } return [...kindsByGroup.entries()] - .toSorted(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => left.localeCompare(right)) .map(([group, kinds]): TreeNode => ({ id: { type: "group", section, namespace, group }, label: group, selectable: false, children: kinds - .toSorted((left, right) => left.kind.localeCompare(right.kind)) + .sort((left, right) => left.kind.localeCompare(right.kind)) .map((resourceKind): TreeNode => { const shortNames = resourceKind.shortNames ?? []; return { diff --git a/src/features/incidents/guidance.ts b/src/features/incidents/guidance.ts index b99a5443..38fbfbbd 100644 --- a/src/features/incidents/guidance.ts +++ b/src/features/incidents/guidance.ts @@ -111,7 +111,7 @@ function detailedEvidence( ): IncidentEvidence[] { if (!details) return []; const conditions = getConditionRows(details.status); - const containers = getContainerStatusRows(details.status); + const containers = getContainerStatusRows(details.status, details.summary.kind); return buildIncidentSignals(details.summary, conditions, [], containers).map((signal) => ({ id: `detail:${signal.id}`, label: signal.label, diff --git a/src/features/incidents/incident-actions.ts b/src/features/incidents/incident-actions.ts index 14da50a5..a5c0fab6 100644 --- a/src/features/incidents/incident-actions.ts +++ b/src/features/incidents/incident-actions.ts @@ -5,6 +5,7 @@ import type { } from "@/lib/types"; import { findResourceIndex, looseResourceKey } from "@/lib/resource-identity"; import { incidentState } from "./model"; +import { isBuiltinOperationTarget } from "../resource-detail/operations-model"; export type IncidentEnrichmentState = "idle" | "loading" | "ready" | "error"; @@ -82,7 +83,7 @@ export function resolveIncidentOwner( const chain = [...current.chain, ownerNode.summary]; directOwner ??= ownerNode.summary; if (firstChain.length === 0) firstChain = chain; - if (ACTIONABLE_WORKLOAD_KINDS.has(ownerNode.summary.kind)) { + if (ACTIONABLE_WORKLOAD_KINDS.has(ownerNode.summary.kind) && isBuiltinOperationTarget(ownerNode.summary)) { return { directOwner, workloadOwner: ownerNode.summary, @@ -110,6 +111,7 @@ function workloadActions( ): IncidentAvailableAction[] { const subject = prefix === "owner" ? `owning ${resource.kind}` : `this ${resource.kind}`; const actions: IncidentAvailableAction[] = []; + if (!isBuiltinOperationTarget(resource)) return actions; if (resource.kind === "Deployment" || resource.kind === "StatefulSet") { actions.push( { diff --git a/src/features/live-sessions/LiveSessionsSurface.svelte b/src/features/live-sessions/LiveSessionsSurface.svelte index c9367fc7..9fa76e16 100644 --- a/src/features/live-sessions/LiveSessionsSurface.svelte +++ b/src/features/live-sessions/LiveSessionsSurface.svelte @@ -89,7 +89,7 @@ function portForwardSessionResolution(session: PortForwardSessionSummary): string { if (session.targetKind === "Service") { - return `Resolved Pod: ${session.resolvedPodName}:${session.resolvedPodPort}`; + return `Latest Pod used: ${session.resolvedPodName}:${session.resolvedPodPort} (each connection may use a different ready Pod)`; } return `Pod: ${session.resolvedPodName}:${session.resolvedPodPort}`; } diff --git a/src/features/live-sessions/helpers.ts b/src/features/live-sessions/helpers.ts index 32516928..63a72583 100644 --- a/src/features/live-sessions/helpers.ts +++ b/src/features/live-sessions/helpers.ts @@ -52,7 +52,7 @@ export function portForwardSessionKey( export function sortPortForwardSessions( sessions: PortForwardSessionSummary[], ): PortForwardSessionSummary[] { - return sessions.toSorted((a, b) => { + return [...sessions].sort((a, b) => { const targetCompare = portForwardSessionKey(a).localeCompare( portForwardSessionKey(b), ); diff --git a/src/features/live-sessions/portForwardForms.ts b/src/features/live-sessions/portForwardForms.ts index dd562703..6843ecf3 100644 --- a/src/features/live-sessions/portForwardForms.ts +++ b/src/features/live-sessions/portForwardForms.ts @@ -109,7 +109,7 @@ export function extractServicePortOptions(yaml: string | undefined): ServicePort const protocol = port.protocol?.toUpperCase() ?? "TCP"; return isNumber(port.port) && port.port > 0 && protocol === "TCP"; }) - .toSorted((a, b) => a.port - b.port || (a.name ?? "").localeCompare(b.name ?? "")); + .sort((a, b) => a.port - b.port || (a.name ?? "").localeCompare(b.name ?? "")); } export function parsePortForwardForm( diff --git a/src/features/resource-detail/ExecTab.svelte b/src/features/resource-detail/ExecTab.svelte index b254062f..c50d3132 100644 --- a/src/features/resource-detail/ExecTab.svelte +++ b/src/features/resource-detail/ExecTab.svelte @@ -147,6 +147,12 @@ }; }); + // Container or Pod changes from the Logs tab or detail loading must not reuse an earlier acknowledgement. + $effect(() => { + void [selectedContainer, resource.cluster, resource.namespace, resource.name, kubeconfigSourceKey]; + confirmed = false; + }); + $effect(() => { if (!active) return; const frame = window.requestAnimationFrame(() => fitTerminal()); diff --git a/src/features/resource-detail/OperationsTab.svelte b/src/features/resource-detail/OperationsTab.svelte index b035ef01..b93b83c5 100644 --- a/src/features/resource-detail/OperationsTab.svelte +++ b/src/features/resource-detail/OperationsTab.svelte @@ -26,7 +26,7 @@ let result = $state(""); let error = $state(null); let busy = $state(false); - const target = $derived({ clusterContext: resource.cluster, namespace: resource.namespace, kind: resource.kind, name: resource.name }); + const target = $derived({ clusterContext: resource.cluster, namespace: resource.namespace, kind: resource.kind, name: resource.name, apiVersion: resource.apiVersion }); const operations = $derived(guardedOperations(resource)); const errorBlocker = $derived(error ? guardedOperationBlocker(error) : null); diff --git a/src/features/resource-detail/PortForwardTab.svelte b/src/features/resource-detail/PortForwardTab.svelte index 8da4e477..51be0b6a 100644 --- a/src/features/resource-detail/PortForwardTab.svelte +++ b/src/features/resource-detail/PortForwardTab.svelte @@ -214,7 +214,7 @@ function sessionResolution(session: PortForwardSessionSummary): string { if (session.targetKind === "Service") { - return `Resolved to Pod/${session.resolvedPodName}:${session.resolvedPodPort}`; + return `Latest Pod used: Pod/${session.resolvedPodName}:${session.resolvedPodPort} (each connection may use a different ready Pod)`; } return `Pod/${session.resolvedPodName}:${session.resolvedPodPort}`; } diff --git a/src/features/resource-detail/ResourceDetailPanel.svelte b/src/features/resource-detail/ResourceDetailPanel.svelte index f66eac52..9c8b62d3 100644 --- a/src/features/resource-detail/ResourceDetailPanel.svelte +++ b/src/features/resource-detail/ResourceDetailPanel.svelte @@ -234,7 +234,7 @@ const currentDetailsCancelScope = detailsCancelScope; const currentDetailsQueryKey = detailsQueryKey; const currentEventsCancelScope = eventsCancelScope; - const currentEventsQueryKey = eventsQueryKey; + const currentEventsQueryKey = scopedEventsQueryKey; finiteReadCleanup.cancelPending(currentDetailsCancelScope); finiteReadCleanup.cancelPending(currentEventsCancelScope); return () => { @@ -279,8 +279,18 @@ retry: false, staleTime: 30_000, })); + const eventsUid = $derived.by(() => { + const uid = detailsQuery.data?.metadata?.uid; + return String(uid) === uid ? uid : undefined; + }); + // Keyed by UID so results fetched without one (or for a replaced resource) are never reused. + const scopedEventsQueryKey = $derived([...eventsQueryKey, eventsUid ?? ""] as const); + // Events are matched by UID, so wait for the details read that supplies it. + const eventsReady = $derived( + !detailsEnabled || detailsQuery.isSuccess || detailsQuery.isError, + ); const eventsQuery = createQuery(() => ({ - queryKey: eventsQueryKey, + queryKey: scopedEventsQueryKey, queryFn: async () => { try { return await runDetailFetch("events", "resource-events", () => @@ -292,6 +302,7 @@ resource.namespace ?? undefined, kubeconfigSourceKey, createFiniteReadRequest(eventsCancelScope, "events"), + eventsUid, ), ); } catch (error) { @@ -301,14 +312,14 @@ throw error; } }, - enabled: eventsEnabled, + enabled: eventsEnabled && eventsReady, retry: false, staleTime: 30_000, })); const detailResource = $derived(detailsQuery.data?.summary ?? resource); const conditionRows = $derived(getConditionRows(detailsQuery.data?.status)); - const containerRows = $derived(getContainerStatusRows(detailsQuery.data?.status)); + const containerRows = $derived(getContainerStatusRows(detailsQuery.data?.status, detailResource.kind)); const containerOptions = $derived.by(() => { const regularContainers = containerRows.filter((container) => container.type !== "init"); return (regularContainers.length > 0 ? regularContainers : containerRows).map( diff --git a/src/features/resource-detail/ResourceYamlPane.svelte b/src/features/resource-detail/ResourceYamlPane.svelte index 8346fd37..7fae6e06 100644 --- a/src/features/resource-detail/ResourceYamlPane.svelte +++ b/src/features/resource-detail/ResourceYamlPane.svelte @@ -93,6 +93,8 @@ let yamlLintError = $state(""); let yamlPreparing = $state(false); let yamlApplying = $state(false); + // Only the apply that took the lock may release it, even after Cancel lets a newer apply start. + let yamlApplyLockOwner: object | null = null; let yamlFormatError = $state(""); let yamlPrepareRawError = $state(null); let yamlPrepareError = $state(""); @@ -419,6 +421,8 @@ return; } const applyRevision = yamlApplyRevision; + const lockOwner = {}; + yamlApplyLockOwner = lockOwner; yamlApplying = true; yamlApplyRawError = null; yamlApplyError = ""; @@ -439,7 +443,11 @@ yamlApplyRawError = error; yamlApplyError = getErrorMessage(error); } finally { - if (applyRevision === yamlApplyRevision) yamlApplying = false; + // Released even when a mid-apply draft edit discarded the result, so the pane cannot stay stuck applying. + if (yamlApplyLockOwner === lockOwner) { + yamlApplyLockOwner = null; + yamlApplying = false; + } } } diff --git a/src/features/resource-detail/helpers.ts b/src/features/resource-detail/helpers.ts index 3fe70577..67ed0f1d 100644 --- a/src/features/resource-detail/helpers.ts +++ b/src/features/resource-detail/helpers.ts @@ -142,8 +142,9 @@ function getStatusList(status: JsonObject, key: string): JsonObject[] { export function getContainerStatusRows( status: JsonObject | undefined, + kind: string, ): ContainerStatusRow[] { - if (!status) return []; + if (!status || kind !== "Pod") return []; const containers = [ ...getStatusList(status, "initContainerStatuses").map((container) => ({ container, diff --git a/src/features/resource-detail/operations-model.ts b/src/features/resource-detail/operations-model.ts index 5dda4042..7d90fcce 100644 --- a/src/features/resource-detail/operations-model.ts +++ b/src/features/resource-detail/operations-model.ts @@ -32,9 +32,28 @@ export function guardedOperationBlocker( return "operation support"; } +const BUILTIN_API_VERSION = new Map([ + ["Deployment", "apps/v1"], + ["StatefulSet", "apps/v1"], + ["DaemonSet", "apps/v1"], + ["Pod", "v1"], + ["ConfigMap", "v1"], +]); + +/** True only for the built-in resource the backend operates on, not a CRD sharing its kind. */ +export function isBuiltinOperationTarget(resource: ResourceSummary): boolean { + return ( + !resource.dynamic && + (resource.apiVersion === undefined || resource.apiVersion === BUILTIN_API_VERSION.get(resource.kind)) + ); +} + export function guardedOperations(resource: ResourceSummary): GuardedOperations { const scope = (action: string) => `${action} this exact selected ${resource.kind} resource only.`; const available: GuardedOperation[] = []; + if (!isBuiltinOperationTarget(resource)) { + return { available, blocker: `Blocker: ${resource.kind} resource is not a built-in Kubernetes workload or core resource.` }; + } if (resource.kind === "Deployment" || resource.kind === "StatefulSet") { available.push( { diff --git a/src/features/resources/ResourceBrowser.svelte b/src/features/resources/ResourceBrowser.svelte index 81ed58fb..3874222b 100644 --- a/src/features/resources/ResourceBrowser.svelte +++ b/src/features/resources/ResourceBrowser.svelte @@ -828,14 +828,14 @@ async function refreshView() { await refreshCurrentView({ client, queryClient, clusterContext, kubeconfigEnvVar: kubeconfigSourceKey, - keys: mergeWatchKeys(watchKeysFromFetchKeys(fetchKeys), topologyWatchKeys(topologyNamespaces), focusedArgoWatchKeys), + keys: mergeWatchKeys(watchKeysFromFetchKeys(fetchKeys), mapPanelOpen ? topologyWatchKeys(topologyNamespaces) : [], focusedArgoWatchKeys), namespaces: selectedNamespaces, }); } $effect(() => { if (!sourceReady || !clusterContext || fetchKeys.length === 0) return; - const watchKeys = mergeWatchKeys(watchKeysFromFetchKeys(fetchKeys), topologyWatchKeys(topologyNamespaces), focusedArgoWatchKeys); + const watchKeys = mergeWatchKeys(watchKeysFromFetchKeys(fetchKeys), mapPanelOpen ? topologyWatchKeys(topologyNamespaces) : [], focusedArgoWatchKeys); const resourceKey = resourceQueryKey; const topologyKey = topologyQueryKey; const argoScope = focusedArgoApplicationScope; diff --git a/src/features/resources/resourceBrowserModel.ts b/src/features/resources/resourceBrowserModel.ts index afb3ffa3..9026d5b4 100644 --- a/src/features/resources/resourceBrowserModel.ts +++ b/src/features/resources/resourceBrowserModel.ts @@ -65,8 +65,8 @@ export function nextNamespaceSelection( export function allKindOptions( discoveredKinds: DiscoveredResourceKind[], ): ResourceKindSelection[] { - const discovered = discoveredKinds - .toSorted((left, right) => left.kind.localeCompare(right.kind)) + const discovered = [...discoveredKinds] + .sort((left, right) => left.kind.localeCompare(right.kind)) return [...SUPPORTED_KINDS, ...CLUSTER_SCOPED_KINDS, ...discovered]; } diff --git a/src/features/resources/resourceBrowserReadSpecs.ts b/src/features/resources/resourceBrowserReadSpecs.ts index 1f2e01b3..00ab4970 100644 --- a/src/features/resources/resourceBrowserReadSpecs.ts +++ b/src/features/resources/resourceBrowserReadSpecs.ts @@ -31,7 +31,7 @@ export function buildResourceBrowserReadSpecs({ .filter((key) => key.kind instanceof Object) .map((key) => resourceKindFetchKey(key.kind)), )] - .toSorted() + .sort() .join(","); const resourceQueryKey = queryKeys.resources( clusterContext, diff --git a/src/features/resources/resourceTableModel.ts b/src/features/resources/resourceTableModel.ts index 897b50bc..cf3cb6c6 100644 --- a/src/features/resources/resourceTableModel.ts +++ b/src/features/resources/resourceTableModel.ts @@ -109,7 +109,7 @@ function gitOpsGroupedRows( rows: ResourceSummary[], preferredGitOpsResourceKeys?: ReadonlySet, ): ResourceSummary[] { - const preferredRows = rows.toSorted((left, right) => { + const preferredRows = [...rows].sort((left, right) => { const preferredPriority = Number(isPreferredGitOpsResource(left, preferredGitOpsResourceKeys)) - Number(isPreferredGitOpsResource(right, preferredGitOpsResourceKeys)); diff --git a/src/features/resources/topology-layout.ts b/src/features/resources/topology-layout.ts index 7d7530c0..f6303c8b 100644 --- a/src/features/resources/topology-layout.ts +++ b/src/features/resources/topology-layout.ts @@ -218,10 +218,7 @@ function buildPrimaryChildren( .sort(compareNodes); const primaryParent = parentNodes[0]; if (!primaryParent) continue; - primaryChildren.set(primaryParent.id, [ - ...(primaryChildren.get(primaryParent.id) ?? []), - node.id, - ]); + pushMapValue(primaryChildren, primaryParent.id, node.id); } for (const [parentId, childIds] of primaryChildren.entries()) { diff --git a/src/features/workspaces/workspace-sharing.ts b/src/features/workspaces/workspace-sharing.ts index 31534aa4..b93aa869 100644 --- a/src/features/workspaces/workspace-sharing.ts +++ b/src/features/workspaces/workspace-sharing.ts @@ -1,6 +1,7 @@ import { createSavedPortForward, createWorkspaceRecord, + reconcileSavedPortForwardsForScope, type SavedWorkspace, type SavePortForwardInput, workspaceScopeContexts, @@ -176,8 +177,13 @@ function sharedToSavedWorkspace( rbacReviews: (replace?.rbacReviews ?? []).filter((review) => clusterContexts.includes(review.clusterContext), ), - portForwards: workspace.portForwards.map((forward) => - createSavedPortForward(forward satisfies SavePortForwardInput, now), + portForwards: reconcileSavedPortForwardsForScope( + workspace.portForwards + .filter( + ({ namespace }) => scope.namespaces.length === 0 || scope.namespaces.includes(namespace), + ) + .map((forward) => createSavedPortForward(forward satisfies SavePortForwardInput, now)), + scope, ), }; } diff --git a/src/lib/finite-read-lifecycle.ts b/src/lib/finite-read-lifecycle.ts index edbbfb60..af905329 100644 --- a/src/lib/finite-read-lifecycle.ts +++ b/src/lib/finite-read-lifecycle.ts @@ -69,7 +69,10 @@ export function finiteReadMeta( export function configureFiniteReadQueryDefaults(queryClient: QueryClient): void { for (const root of FINITE_READ_QUERY_ROOTS) { - queryClient.setQueryDefaults([root], { meta: finiteReadMeta() }); + queryClient.setQueryDefaults([root], { + ...queryClient.getQueryDefaults([root]), + meta: finiteReadMeta(), + }); } } diff --git a/src/lib/gitops-types.ts b/src/lib/gitops-types.ts index 4fe7311f..621762bc 100644 --- a/src/lib/gitops-types.ts +++ b/src/lib/gitops-types.ts @@ -88,6 +88,14 @@ export interface ArgoServerCapability { endpoint: ArgoServerEndpoint | null; unavailableReason: string | null; unavailable?: ArgoServiceTunnelUnavailableReason | null; + targetPod?: string | null; + argoLabeled: boolean; +} + +export interface ArgoConfirmedTarget { + namespace: string; + serviceName: string; + podName: string; } export interface ArgoApplicationRef { diff --git a/src/lib/query-retention.test.ts b/src/lib/query-retention.test.ts index f4b937e7..eab6c741 100644 --- a/src/lib/query-retention.test.ts +++ b/src/lib/query-retention.test.ts @@ -1,4 +1,5 @@ import { QueryClient, QueryObserver } from "@tanstack/svelte-query"; +import { configureFiniteReadQueryDefaults, isFiniteReadQuery } from "./finite-read-lifecycle"; import { configureLargeQueryRetention, LARGE_QUERY_GC_TIME_MS, @@ -102,3 +103,19 @@ describe("large query retention", () => { queryClient.clear(); }); }); + +describe("large query retention with finite-read defaults", () => { + // Failure mode: setting gcTime replaces the finiteRead meta set earlier for the same root. + test("keeps finite-read metadata on large roots", () => { + const queryClient = new QueryClient(); + configureFiniteReadQueryDefaults(queryClient); + configureLargeQueryRetention(queryClient); + + for (const root of LARGE_QUERY_ROOTS) { + queryClient.setQueryData([root, "k"], "v"); + const query = queryClient.getQueryCache().find({ queryKey: [root, "k"] }); + expect(query ? isFiniteReadQuery(query) : false).toBe(true); + } + queryClient.clear(); + }); +}); diff --git a/src/lib/query-retention.ts b/src/lib/query-retention.ts index d24307bf..593a5bcb 100644 --- a/src/lib/query-retention.ts +++ b/src/lib/query-retention.ts @@ -10,6 +10,9 @@ export const LARGE_QUERY_GC_TIME_MS = 300_000; export function configureLargeQueryRetention(queryClient: QueryClient): void { for (const root of LARGE_QUERY_ROOTS) { - queryClient.setQueryDefaults([root], { gcTime: LARGE_QUERY_GC_TIME_MS }); + queryClient.setQueryDefaults([root], { + ...queryClient.getQueryDefaults([root]), + gcTime: LARGE_QUERY_GC_TIME_MS, + }); } } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 94e1fb1d..bfdd80e7 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -303,7 +303,7 @@ export const useSettingsState = createStore()( debugModeEnabled: false, autoStartSavedPortForwards: false, keepLiveSessionsOnWorkspaceSwitch: false, - allowYamlForceConflicts: true, + allowYamlForceConflicts: false, timestampTimezone: "local", yamlViewModeDefault: "kubectl", yamlEncodingDefault: "yaml", diff --git a/src/lib/tauri-argo.ts b/src/lib/tauri-argo.ts index 0142c7a8..07d28ac0 100644 --- a/src/lib/tauri-argo.ts +++ b/src/lib/tauri-argo.ts @@ -5,6 +5,7 @@ import type { ArgoApplicationInspector, ArgoApplicationRef, ArgoConnectionProfile, + ArgoConfirmedTarget, ArgoConnectionStatus, ArgoManagedResource, ArgoOperationConfirmation, @@ -45,6 +46,7 @@ export async function connectArgoServer( clusterContext?: string; kubeconfigEnvVar?: string; workspaceId?: string; + confirmedTarget?: ArgoConfirmedTarget; }, ): Promise { const { kubeconfigEnvVar, ...args } = request; diff --git a/src/lib/tauri-dev-mocks.ts b/src/lib/tauri-dev-mocks.ts index c306478d..28595287 100644 --- a/src/lib/tauri-dev-mocks.ts +++ b/src/lib/tauri-dev-mocks.ts @@ -164,16 +164,16 @@ const handlers = { list_resource_metrics: () => metrics(), detect_argocd: () => true, discover_argo_servers: () => [ - { id: "service:argocd:argocd-server:443", name: "argocd-server", namespace: "argocd", url: null, transport: "serviceTunnel", endpoint: { kind: "serviceTunnel", namespace: "argocd", serviceName: "argocd-server", servicePort: 443, scheme: "https" }, unavailableReason: null }, - { id: "service:argocd:argocd-server:80", name: "argocd-server", namespace: "argocd", url: null, transport: "serviceTunnel", endpoint: { kind: "serviceTunnel", namespace: "argocd", serviceName: "argocd-server", servicePort: 80, scheme: "https" }, unavailableReason: null }, - { id: "service:argocd:external", name: "argocd-external", namespace: "argocd", url: null, transport: "serviceTunnel", endpoint: null, unavailableReason: "ExternalName Services cannot be port-forwarded" }, + { id: "service:argocd:argocd-server:443", name: "argocd-server", namespace: "argocd", url: null, transport: "serviceTunnel", endpoint: { kind: "serviceTunnel", namespace: "argocd", serviceName: "argocd-server", servicePort: 443, scheme: "https" }, unavailableReason: null, targetPod: "argocd-server-7d9f8b6c5-x2k4q", argoLabeled: true }, + { id: "service:argocd:argocd-server:80", name: "argocd-server", namespace: "argocd", url: null, transport: "serviceTunnel", endpoint: { kind: "serviceTunnel", namespace: "argocd", serviceName: "argocd-server", servicePort: 80, scheme: "https" }, unavailableReason: null, targetPod: "argocd-server-7d9f8b6c5-x2k4q", argoLabeled: true }, + { id: "service:argocd:external", name: "argocd-external", namespace: "argocd", url: null, transport: "serviceTunnel", endpoint: null, unavailableReason: "ExternalName Services cannot be port-forwarded", argoLabeled: false }, ], connect_argo_server: (args) => { const serverUrl = args?.serverUrl ?? "https://argocd.example.test"; const endpoint = args?.endpoint ?? { kind: "externalHttps", url: serverUrl }; const profile: ArgoConnectionProfile = { id: args?.id ?? "mock-profile", endpoint, url: serverUrl, clusterContext: args?.clusterContext ?? null, workspaceId: args?.workspaceId ?? null, transport: "connected", rememberCredential: Boolean(args?.rememberCredential) }; argoConnections.set(profile.id, profile); - return { profile, connected: true, username: args?.username ?? "mock-user", unavailableReason: null }; + return { profile, connected: true, username: args?.username ?? "mock-user", unavailableReason: null, targetPod: "argocd-server-7d9f8b6c5-x2k4q", argoLabeled: true }; }, get_argo_connection_status: (args) => { const profile = args?.id ? argoConnections.get(args.id) ?? null : null; diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 216459bd..70ba3039 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -502,12 +502,14 @@ export async function listResourceEvents( namespace?: string, kubeconfigEnvVar?: string, cancellable?: CancellableRequest, + uid?: string, ): Promise { return client.invoke("list_resource_events", { clusterContext, kind, name, namespace, + uid, ...kubeconfigArg(kubeconfigEnvVar), ...cancellableArg(cancellable), }); diff --git a/src/lib/types.ts b/src/lib/types.ts index 772fd277..adfb59ac 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -153,6 +153,7 @@ export interface ClusterOperationTarget { clusterContext: string; kind: string; name: string; + apiVersion?: string; namespace?: string | null; } @@ -177,10 +178,7 @@ export interface ClusterOperationPreview { effect: string; } -export interface ClusterOperationResult { - target: ClusterOperationTarget; - effect: string; -} +export type ClusterOperationResult = ClusterOperationPreview; export type ResourceHealth = | "healthy" diff --git a/tests/tauri-launcher.test.ts b/tests/tauri-launcher.test.ts index 46e70ba7..56fa3e46 100644 --- a/tests/tauri-launcher.test.ts +++ b/tests/tauri-launcher.test.ts @@ -5,18 +5,23 @@ const webviewArguments = "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"; const cargoTargetDirectory = "CARGO_TARGET_DIR"; describe("Tauri development launcher", () => { - test("enables a localhost WebView2 debug endpoint for Windows development", () => { - const environment = tauriEnvironment(["dev"], {}, "win32"); + test("enables a localhost WebView2 debug endpoint only when KUBECOVE_DEVTOOLS=1", () => { + const environment = tauriEnvironment(["dev"], { KUBECOVE_DEVTOOLS: "1" }, "win32"); expect(environment[webviewArguments]).toBe( "--remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --remote-allow-origins=http://127.0.0.1:9222,http://localhost:9222", ); }); + test("does not enable WebView2 debugging by default", () => { + expect(tauriEnvironment(["dev"], {}, "win32")[webviewArguments]).toBeUndefined(); + }); + test("keeps existing WebView2 arguments and supports a custom port", () => { const environment = tauriEnvironment( ["dev"], { + KUBECOVE_DEVTOOLS: "1", KUBECOVE_DEVTOOLS_PORT: "9333", [webviewArguments]: "--disable-features=ExampleFeature", }, @@ -35,7 +40,7 @@ describe("Tauri development launcher", () => { const existing = "--remote-debugging-port=9444 --disable-gpu"; const environment = tauriEnvironment( ["dev"], - { [webviewArguments]: existing }, + { KUBECOVE_DEVTOOLS: "1", [webviewArguments]: existing }, "win32", ); diff --git a/tests/tauri.test.ts b/tests/tauri.test.ts index 9b6ba2ec..473e5f7c 100644 --- a/tests/tauri.test.ts +++ b/tests/tauri.test.ts @@ -303,7 +303,7 @@ describe("getContainerStatusRows", () => { }, }, ], - }), + }, "Pod"), ).toEqual([ { name: "api", diff --git a/tests/yaml-encoding.test.ts b/tests/yaml-encoding.test.ts index 57cec151..8962d01f 100644 --- a/tests/yaml-encoding.test.ts +++ b/tests/yaml-encoding.test.ts @@ -148,11 +148,11 @@ describe("YAML encoding", () => { ]); }); - test("settings allow YAML force-conflicts by default and can disable them", () => { - expect(useSettingsState.getState().allowYamlForceConflicts).toBe(true); + test("settings disallow YAML force-conflicts by default and can enable them", () => { + expect(useSettingsState.getState().allowYamlForceConflicts).toBe(false); - useSettingsState.getState().setAllowYamlForceConflicts(false); + useSettingsState.getState().setAllowYamlForceConflicts(true); - expect(useSettingsState.getState().allowYamlForceConflicts).toBe(false); + expect(useSettingsState.getState().allowYamlForceConflicts).toBe(true); }); });