From 67e9165b60ff3f6502c6ea328e8d7ab7e8a7b0eb Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Fri, 16 Jan 2026 09:19:03 -0500 Subject: [PATCH 1/8] fix: extract node IP from context endpoints for get members Fixes 06d9d46 which removed hardcoded 127.0.0.1 but left talosctl without a target node when nodes: is unset in talosconfig. --- crates/talos-rs/src/error.rs | 4 ++++ crates/talos-rs/src/talosctl.rs | 34 ++++++++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/talos-rs/src/error.rs b/crates/talos-rs/src/error.rs index 4f7f9ed..76c3ac5 100644 --- a/crates/talos-rs/src/error.rs +++ b/crates/talos-rs/src/error.rs @@ -21,6 +21,10 @@ pub enum TalosError { #[error("Context not found: {0}")] ContextNotFound(String), + /// No endpoints configured for context + #[error("No endpoints configured for context: {0}")] + NoEndpoints(String), + /// Base64 decoding error #[error("Base64 decode error: {0}")] Base64Decode(#[from] base64::DecodeError), diff --git a/crates/talos-rs/src/talosctl.rs b/crates/talos-rs/src/talosctl.rs index bfeae32..34d2f4b 100644 --- a/crates/talos-rs/src/talosctl.rs +++ b/crates/talos-rs/src/talosctl.rs @@ -157,15 +157,43 @@ pub fn get_discovery_members(node: &str) -> Result, TalosEr /// Get discovery members for a context (async, non-blocking) /// -/// Executes: talosctl --context get members -o yaml +/// Executes: talosctl --context -n get members -o yaml /// /// This version uses the context name to get the correct certificates and endpoint, /// and uses tokio async process to avoid blocking the runtime. +/// It extracts a node IP from the context's endpoints to target the query. pub async fn get_discovery_members_for_context( context: &str, ) -> Result, TalosError> { - let output = - exec_talosctl_async(&["--context", context, "get", "members", "-o", "yaml"]).await?; + // Load config to get an endpoint IP to use as the node target + // talosctl requires -n flag if nodes: is not set in the config + let config = crate::TalosConfig::load_default()?; + let ctx = config + .contexts + .get(context) + .ok_or_else(|| TalosError::ContextNotFound(context.to_string()))?; + + // Get the first endpoint and extract the IP (remove port if present) + let node_ip = ctx + .endpoints + .first() + .ok_or_else(|| TalosError::NoEndpoints(context.to_string()))? + .split(':') + .next() + .unwrap_or("") + .to_string(); + + if node_ip.is_empty() { + return Err(TalosError::NoEndpoints(context.to_string())); + } + + let output = exec_talosctl_async(&[ + "--context", context, + "-n", &node_ip, + "get", "members", + "-o", "yaml", + ]) + .await?; parse_discovery_members_yaml(&output) } From 5ccc93540e56657fd264124f7de33c2fcb16fdbf Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Fri, 16 Jan 2026 09:57:40 -0500 Subject: [PATCH 2/8] fix: pass data from --config and --context flags Closes #9 --- crates/talos-pilot-tui/src/app.rs | 21 +++-- .../talos-pilot-tui/src/components/cluster.rs | 82 +++++++++++++++---- .../src/components/diagnostics/core.rs | 12 ++- .../src/components/diagnostics/mod.rs | 15 +++- .../src/components/lifecycle.rs | 34 ++++++-- .../src/components/security.rs | 40 +++++++-- crates/talos-rs/src/talosctl.rs | 23 ++++-- src/main.rs | 7 +- 8 files changed, 182 insertions(+), 52 deletions(-) diff --git a/crates/talos-pilot-tui/src/app.rs b/crates/talos-pilot-tui/src/app.rs index da665ba..1dc783c 100644 --- a/crates/talos-pilot-tui/src/app.rs +++ b/crates/talos-pilot-tui/src/app.rs @@ -65,6 +65,8 @@ pub struct App { action_rx: mpsc::UnboundedReceiver, #[allow(dead_code)] // Will be used for background log streaming action_tx: mpsc::UnboundedSender, + /// Custom config file path (from --config flag) + config_path: Option, } /// Results from async operations @@ -79,17 +81,17 @@ enum AsyncResult { impl Default for App { fn default() -> Self { - Self::new(None, 500) + Self::new(None, None, 500) } } impl App { - pub fn new(context: Option, tail_lines: i32) -> Self { + pub fn new(config_path: Option, context: Option, tail_lines: i32) -> Self { let (action_tx, action_rx) = mpsc::unbounded_channel(); Self { should_quit: false, view: View::Cluster, - cluster: ClusterComponent::new(context), + cluster: ClusterComponent::new(config_path.clone(), context), multi_logs: None, etcd: None, processes: None, @@ -104,6 +106,7 @@ impl App { tick_rate: Duration::from_millis(100), action_rx, action_tx, + config_path, } } @@ -579,7 +582,12 @@ impl App { ); // Create diagnostics component - let mut diagnostics = DiagnosticsComponent::new(hostname, address.clone(), role); + let mut diagnostics = DiagnosticsComponent::new( + hostname, + address.clone(), + role, + self.config_path.clone(), + ); // Set the control plane endpoint for worker nodes to fetch kubeconfig diagnostics.set_controlplane_endpoint(cp_endpoint); @@ -685,7 +693,7 @@ impl App { tracing::info!("Viewing security/certificates"); // Create security component - let mut security = SecurityComponent::new(String::new()); + let mut security = SecurityComponent::new(String::new(), self.config_path.clone()); // Set the client and refresh data if let Some(client) = self.cluster.client() { @@ -705,7 +713,8 @@ impl App { tracing::info!("Viewing lifecycle/versions"); // Create lifecycle component - let mut lifecycle = LifecycleComponent::new(String::new()); + let mut lifecycle = + LifecycleComponent::new(String::new(), self.config_path.clone()); // Set the client and refresh data if let Some(client) = self.cluster.client() { diff --git a/crates/talos-pilot-tui/src/components/cluster.rs b/crates/talos-pilot-tui/src/components/cluster.rs index f3966df..7d5f9b9 100644 --- a/crates/talos-pilot-tui/src/components/cluster.rs +++ b/crates/talos-pilot-tui/src/components/cluster.rs @@ -161,16 +161,20 @@ pub struct ClusterComponent { last_auto_refresh: Option, /// Currently selected item in the node list selected_item: NodeListItem, + /// Custom config file path (from --config flag) + config_path: Option, + /// Specific context to use (from --context flag) + context_filter: Option, } impl Default for ClusterComponent { fn default() -> Self { - Self::new(None) + Self::new(None, None) } } impl ClusterComponent { - pub fn new(_context: Option) -> Self { + pub fn new(config_path: Option, context_filter: Option) -> Self { Self { clusters: Vec::new(), active_cluster: 0, @@ -181,6 +185,8 @@ impl ClusterComponent { auto_refresh: true, last_auto_refresh: None, selected_item: NodeListItem::ClusterHeader(0), + config_path, + context_filter, } } @@ -329,17 +335,44 @@ impl ClusterComponent { // Install crypto provider (needed for rustls) let _ = rustls::crypto::ring::default_provider().install_default(); - // Load talosconfig to get all contexts - let config = match TalosConfig::load_default() { - Ok(c) => c, - Err(e) => { - tracing::error!("Failed to load talosconfig: {}", e); - return Ok(()); + // Load talosconfig - use custom path if provided via --config flag + let config = match &self.config_path { + Some(path) => { + let path_buf = std::path::PathBuf::from(path); + match TalosConfig::load_from(&path_buf) { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to load talosconfig from {}: {}", path, e); + return Ok(()); + } + } } + None => match TalosConfig::load_default() { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to load talosconfig: {}", e); + return Ok(()); + } + }, }; - // Get all context names - let context_names: Vec = config.contexts.keys().cloned().collect(); + // Determine which contexts to load based on --context flag + let context_names: Vec = match &self.context_filter { + Some(ctx_name) => { + // Verify the context exists + if config.contexts.contains_key(ctx_name) { + vec![ctx_name.clone()] + } else { + tracing::error!( + "Context '{}' not found in talosconfig. Available contexts: {:?}", + ctx_name, + config.contexts.keys().collect::>() + ); + return Ok(()); + } + } + None => config.contexts.keys().cloned().collect(), + }; // Create ClusterData for each context self.clusters.clear(); @@ -352,12 +385,18 @@ impl ClusterComponent { ..Default::default() }; - // Try to connect to each cluster - match TalosClient::from_named_context(name).await { - Ok(client) => { - cluster.client = Some(client); - cluster.connected = true; - } + // Try to connect to each cluster using the loaded config + match config.get_context(name) { + Ok(ctx) => match TalosClient::from_context(ctx).await { + Ok(client) => { + cluster.client = Some(client); + cluster.connected = true; + } + Err(e) => { + cluster.error = Some(e.to_string()); + cluster.connected = false; + } + }, Err(e) => { cluster.error = Some(e.to_string()); cluster.connected = false; @@ -422,7 +461,7 @@ impl ClusterComponent { // Try to get discovery members (ALL nodes including workers) // Use context-aware async function to avoid blocking and to use correct certificates let context_name = cluster.name.clone(); - match get_discovery_members_for_context(&context_name).await { + match get_discovery_members_for_context(&context_name, self.config_path.as_deref()).await { Ok(members) => { cluster.node_ips.clear(); for member in &members { @@ -1673,7 +1712,14 @@ impl ClusterComponent { .border_style(Style::default().fg(border_color)); // Get control plane IP from talosconfig endpoints - let cp_ip = talos_rs::TalosConfig::load_default() + let config_result = match &self.config_path { + Some(path) => { + let path_buf = std::path::PathBuf::from(path); + TalosConfig::load_from(&path_buf) + } + None => TalosConfig::load_default(), + }; + let cp_ip = config_result .ok() .and_then(|config| { config diff --git a/crates/talos-pilot-tui/src/components/diagnostics/core.rs b/crates/talos-pilot-tui/src/components/diagnostics/core.rs index abb8cb1..5c7daa8 100644 --- a/crates/talos-pilot-tui/src/components/diagnostics/core.rs +++ b/crates/talos-pilot-tui/src/components/diagnostics/core.rs @@ -269,11 +269,19 @@ pub async fn check_cni_health(client: &TalosClient) -> (bool, Option) { pub async fn run_certificate_checks( client: &TalosClient, _ctx: &DiagnosticContext, + config_path: Option<&str>, ) -> Vec { let mut checks = Vec::new(); - // Load talosconfig and check certificates - match talos_rs::TalosConfig::load_default() { + // Load talosconfig - use custom path if provided + let config_result = match config_path { + Some(path) => { + let path_buf = std::path::PathBuf::from(path); + talos_rs::TalosConfig::load_from(&path_buf) + } + None => talos_rs::TalosConfig::load_default(), + }; + match config_result { Ok(config) => { if let Some(context) = config.current_context() { // Check client certificate diff --git a/crates/talos-pilot-tui/src/components/diagnostics/mod.rs b/crates/talos-pilot-tui/src/components/diagnostics/mod.rs index b9c4b13..e322af0 100644 --- a/crates/talos-pilot-tui/src/components/diagnostics/mod.rs +++ b/crates/talos-pilot-tui/src/components/diagnostics/mod.rs @@ -102,16 +102,23 @@ pub struct DiagnosticsComponent { client: Option, /// Control plane endpoint for fetching kubeconfig (used for worker nodes) controlplane_endpoint: Option, + /// Custom config file path (from --config flag) + config_path: Option, } impl Default for DiagnosticsComponent { fn default() -> Self { - Self::new("".to_string(), "".to_string(), "".to_string()) + Self::new("".to_string(), "".to_string(), "".to_string(), None) } } impl DiagnosticsComponent { - pub fn new(hostname: String, address: String, node_role: String) -> Self { + pub fn new( + hostname: String, + address: String, + node_role: String, + config_path: Option, + ) -> Self { let mut table_state = TableState::default(); table_state.select(Some(0)); @@ -144,6 +151,7 @@ impl DiagnosticsComponent { auto_refresh: true, client: None, controlplane_endpoint: None, + config_path, } } @@ -531,7 +539,8 @@ impl DiagnosticsComponent { let service_checks = core::run_service_checks(&client, &context).await; // Run certificate checks and add to system checks - let cert_checks = core::run_certificate_checks(&client, &context).await; + let cert_checks = + core::run_certificate_checks(&client, &context, self.config_path.as_deref()).await; system_checks.extend(cert_checks); // Run CNI-specific checks diff --git a/crates/talos-pilot-tui/src/components/lifecycle.rs b/crates/talos-pilot-tui/src/components/lifecycle.rs index f4f6e3c..bfaba54 100644 --- a/crates/talos-pilot-tui/src/components/lifecycle.rs +++ b/crates/talos-pilot-tui/src/components/lifecycle.rs @@ -157,16 +157,19 @@ pub struct LifecycleComponent { /// K8s client for pod/PDB checks (reusable, not part of loaded data) k8s_client: Option, + + /// Custom config file path (from --config flag) + config_path: Option, } impl Default for LifecycleComponent { fn default() -> Self { - Self::new("".to_string()) + Self::new("".to_string(), None) } } impl LifecycleComponent { - pub fn new(context_name: String) -> Self { + pub fn new(context_name: String, config_path: Option) -> Self { let mut table_state = TableState::default(); table_state.select(Some(0)); @@ -184,6 +187,7 @@ impl LifecycleComponent { auto_refresh: true, client: None, k8s_client: None, + config_path, } } @@ -225,7 +229,14 @@ impl LifecycleComponent { // Get context name from first node if !versions.is_empty() && data.context_name.is_empty() { // Try to get from talosconfig - if let Ok(config) = talos_rs::TalosConfig::load_default() { + let config_result = match &self.config_path { + Some(path) => { + let path_buf = std::path::PathBuf::from(path); + TalosConfig::load_from(&path_buf) + } + None => TalosConfig::load_default(), + }; + if let Ok(config) = config_result { data.context_name = config.context; } } @@ -256,14 +267,23 @@ impl LifecycleComponent { // Fetch discovery members using context-aware async version let context_name = if !data.context_name.is_empty() { data.context_name.clone() - } else if let Ok(config) = TalosConfig::load_default() { - config.context } else { - String::new() + let config_result = match &self.config_path { + Some(path) => { + let path_buf = std::path::PathBuf::from(path); + TalosConfig::load_from(&path_buf) + } + None => TalosConfig::load_default(), + }; + config_result + .map(|config| config.context) + .unwrap_or_default() }; if !context_name.is_empty() { - match get_discovery_members_for_context(&context_name).await { + match get_discovery_members_for_context(&context_name, self.config_path.as_deref()) + .await + { Ok(members) => { data.discovery_members = members; } diff --git a/crates/talos-pilot-tui/src/components/security.rs b/crates/talos-pilot-tui/src/components/security.rs index 8848dff..4423347 100644 --- a/crates/talos-pilot-tui/src/components/security.rs +++ b/crates/talos-pilot-tui/src/components/security.rs @@ -50,6 +50,9 @@ pub struct SecurityComponent { /// Client for API calls client: Option, + + /// Custom config file path (from --config flag) + config_path: Option, } /// A selectable item in the security view @@ -98,12 +101,12 @@ impl ItemStatus { impl Default for SecurityComponent { fn default() -> Self { - Self::new("".to_string()) + Self::new("".to_string(), None) } } impl SecurityComponent { - pub fn new(context_name: String) -> Self { + pub fn new(context_name: String, config_path: Option) -> Self { // Initialize with context name in the data let initial_data = SecurityData { context_name, @@ -117,6 +120,7 @@ impl SecurityComponent { selected: 0, auto_refresh: true, client: None, + config_path, } } @@ -143,12 +147,12 @@ impl SecurityComponent { let mut data = self.state.take_data().unwrap_or_default(); // Load PKI status from talosconfig - Self::load_pki_status_into(&mut data).await; + Self::load_pki_status_into(&mut data, self.config_path.as_deref()).await; // Load kubeconfig certs and encryption status (if client available) if let Some(client) = &self.client { Self::load_kubeconfig_certs(&mut data, client).await; - Self::load_encryption_status_into(&mut data, client).await; + Self::load_encryption_status_into(&mut data, self.config_path.as_deref(), client).await; } // Build display items from loaded data @@ -169,11 +173,18 @@ impl SecurityComponent { } /// Load PKI status from talosconfig (static method) - async fn load_pki_status_into(data: &mut SecurityData) { + async fn load_pki_status_into(data: &mut SecurityData, config_path: Option<&str>) { let mut pki = PkiStatus::default(); - // Load talosconfig - match talos_rs::TalosConfig::load_default() { + // Load talosconfig - use custom path if provided + let config_result = match config_path { + Some(path) => { + let path_buf = std::path::PathBuf::from(path); + talos_rs::TalosConfig::load_from(&path_buf) + } + None => talos_rs::TalosConfig::load_default(), + }; + match config_result { Ok(config) => { // Get context name data.context_name = config.context.clone(); @@ -253,9 +264,20 @@ impl SecurityComponent { } /// Load encryption status from node via talosctl (static method) - async fn load_encryption_status_into(data: &mut SecurityData, _client: &TalosClient) { + async fn load_encryption_status_into( + data: &mut SecurityData, + config_path: Option<&str>, + _client: &TalosClient, + ) { // Get the first node from talosconfig to query - let node = match talos_rs::TalosConfig::load_default() { + let config_result = match config_path { + Some(path) => { + let path_buf = std::path::PathBuf::from(path); + talos_rs::TalosConfig::load_from(&path_buf) + } + None => talos_rs::TalosConfig::load_default(), + }; + let node = match config_result { Ok(config) => { config.current_context().and_then(|ctx| { // Prefer nodes if set, otherwise use first endpoint diff --git a/crates/talos-rs/src/talosctl.rs b/crates/talos-rs/src/talosctl.rs index 34d2f4b..8eb13e7 100644 --- a/crates/talos-rs/src/talosctl.rs +++ b/crates/talos-rs/src/talosctl.rs @@ -162,12 +162,21 @@ pub fn get_discovery_members(node: &str) -> Result, TalosEr /// This version uses the context name to get the correct certificates and endpoint, /// and uses tokio async process to avoid blocking the runtime. /// It extracts a node IP from the context's endpoints to target the query. +/// +/// If `config_path` is provided, loads config from that path instead of the default. pub async fn get_discovery_members_for_context( context: &str, + config_path: Option<&str>, ) -> Result, TalosError> { // Load config to get an endpoint IP to use as the node target // talosctl requires -n flag if nodes: is not set in the config - let config = crate::TalosConfig::load_default()?; + let config = match config_path { + Some(path) => { + let path_buf = std::path::PathBuf::from(path); + crate::TalosConfig::load_from(&path_buf)? + } + None => crate::TalosConfig::load_default()?, + }; let ctx = config .contexts .get(context) @@ -188,10 +197,14 @@ pub async fn get_discovery_members_for_context( } let output = exec_talosctl_async(&[ - "--context", context, - "-n", &node_ip, - "get", "members", - "-o", "yaml", + "--context", + context, + "-n", + &node_ip, + "get", + "members", + "-o", + "yaml", ]) .await?; parse_discovery_members_yaml(&output) diff --git a/src/main.rs b/src/main.rs index e04b17d..8c8bc82 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,9 +60,12 @@ async fn main() -> Result<()> { if let Some(ctx) = &cli.context { tracing::info!("Using context: {}", ctx); } + if let Some(cfg) = &cli.config { + tracing::info!("Using config: {}", cfg); + } - // Run the TUI with the specified context and tail limit - let mut app = App::new(cli.context, cli.tail); + // Run the TUI with the specified config, context and tail limit + let mut app = App::new(cli.config, cli.context, cli.tail); app.run().await?; tracing::info!("Goodbye!"); From 4e70ad13c578576d12deacf8f464aed21fea43ac Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Fri, 16 Jan 2026 10:29:35 -0500 Subject: [PATCH 3/8] feat: storage screen --- crates/talos-pilot-tui/src/action.rs | 2 + crates/talos-pilot-tui/src/app.rs | 72 +- .../talos-pilot-tui/src/components/cluster.rs | 42 +- crates/talos-pilot-tui/src/components/mod.rs | 2 + .../talos-pilot-tui/src/components/storage.rs | 634 ++++++++++++++++++ crates/talos-rs/src/config.rs | 43 +- crates/talos-rs/src/lib.rs | 7 +- crates/talos-rs/src/talosctl.rs | 356 +++++++++- 8 files changed, 1121 insertions(+), 37 deletions(-) create mode 100644 crates/talos-pilot-tui/src/components/storage.rs diff --git a/crates/talos-pilot-tui/src/action.rs b/crates/talos-pilot-tui/src/action.rs index 473a5be..cc8d7c0 100644 --- a/crates/talos-pilot-tui/src/action.rs +++ b/crates/talos-pilot-tui/src/action.rs @@ -44,6 +44,8 @@ pub enum Action { ShowLifecycle, /// Show workload health view ShowWorkloads, + /// Show storage/disks view for a node: (hostname, address) + ShowStorage(String, String), /// Show node operations overlay: (hostname, address, is_controlplane) ShowNodeOperations(String, String, bool), /// Show rolling operations overlay with node list: Vec<(hostname, address, is_controlplane)> diff --git a/crates/talos-pilot-tui/src/app.rs b/crates/talos-pilot-tui/src/app.rs index 1dc783c..c6965c1 100644 --- a/crates/talos-pilot-tui/src/app.rs +++ b/crates/talos-pilot-tui/src/app.rs @@ -5,7 +5,7 @@ use crate::components::rolling_operations::RollingNodeInfo; use crate::components::{ ClusterComponent, Component, DiagnosticsComponent, EtcdComponent, LifecycleComponent, MultiLogsComponent, NetworkStatsComponent, NodeOperationsComponent, ProcessesComponent, - RollingOperationsComponent, SecurityComponent, WorkloadHealthComponent, + RollingOperationsComponent, SecurityComponent, StorageComponent, WorkloadHealthComponent, }; use crate::tui::{self, Tui}; use color_eyre::Result; @@ -25,6 +25,7 @@ enum View { Security, Lifecycle, Workloads, + Storage, NodeOperations, RollingOperations, } @@ -53,6 +54,8 @@ pub struct App { lifecycle: Option, /// Workload health component (created when viewing workloads) workloads: Option, + /// Storage component (created when viewing disks/volumes) + storage: Option, /// Node operations component (overlay for node operations) node_operations: Option, /// Rolling operations component (overlay for multi-node operations) @@ -100,6 +103,7 @@ impl App { security: None, lifecycle: None, workloads: None, + storage: None, node_operations: None, rolling_operations: None, tail_lines, @@ -180,6 +184,11 @@ impl App { let _ = workloads.draw(frame, area); } } + View::Storage => { + if let Some(storage) = &mut self.storage { + let _ = storage.draw(frame, area); + } + } View::NodeOperations => { // Draw cluster in background, then overlay let _ = self.cluster.draw(frame, area); @@ -259,6 +268,13 @@ impl App { None } } + View::Storage => { + if let Some(storage) = &mut self.storage { + storage.handle_key_event(key)? + } else { + None + } + } View::NodeOperations => { if let Some(node_ops) = &mut self.node_operations { node_ops.handle_key_event(key)? @@ -354,6 +370,9 @@ impl App { View::Workloads => { self.workloads = None; } + View::Storage => { + self.storage = None; + } View::NodeOperations => { self.node_operations = None; } @@ -433,6 +452,13 @@ impl App { Box::pin(self.handle_action(next_action)).await?; } } + View::Storage => { + if let Some(storage) = &mut self.storage + && let Some(next_action) = storage.update(Action::Tick)? + { + Box::pin(self.handle_action(next_action)).await?; + } + } View::NodeOperations => { if let Some(node_ops) = &mut self.node_operations && let Some(next_action) = node_ops.update(Action::Tick)? @@ -522,6 +548,13 @@ impl App { workloads.set_error(e.to_string()); } } + View::Storage => { + if let Some(storage) = &mut self.storage + && let Err(e) = storage.refresh().await + { + storage.set_error(e.to_string()); + } + } View::NodeOperations => { if let Some(node_ops) = &mut self.node_operations && let Err(e) = node_ops.refresh().await @@ -758,6 +791,36 @@ impl App { self.workloads = Some(workloads); self.view = View::Workloads; } + Action::ShowStorage(hostname, address) => { + // Switch to storage view for a node + tracing::info!( + "ShowStorage: hostname='{}', address='{}'", + hostname, + address + ); + + // Get context and config from cluster component + let context = self.cluster.current_context_name().map(|s| s.to_string()); + let config_path = self.cluster.config_path().map(|s| s.to_string()); + + // Create storage component with context for authentication + let mut storage = + StorageComponent::new(hostname, address.clone(), context, config_path); + + // Set the client and refresh data + if let Some(client) = self.cluster.client() { + // Create a client configured for this specific node + let node_client = client.with_node(&address); + storage.set_client(node_client); + if let Err(e) = storage.refresh().await { + tracing::error!("Storage refresh error: {:?}", e); + storage.set_error(e.to_string()); + } + } + + self.storage = Some(storage); + self.view = View::Storage; + } Action::ShowNodeOperations(hostname, address, is_controlplane) => { // Show node operations overlay tracing::info!("Viewing node operations for: {} ({})", hostname, address); @@ -878,6 +941,13 @@ impl App { Box::pin(self.handle_action(next_action)).await?; } } + View::Storage => { + if let Some(storage) = &mut self.storage + && let Some(next_action) = storage.update(action)? + { + Box::pin(self.handle_action(next_action)).await?; + } + } View::NodeOperations => { if let Some(node_ops) = &mut self.node_operations && let Some(next_action) = node_ops.update(action)? diff --git a/crates/talos-pilot-tui/src/components/cluster.rs b/crates/talos-pilot-tui/src/components/cluster.rs index 7d5f9b9..8488117 100644 --- a/crates/talos-pilot-tui/src/components/cluster.rs +++ b/crates/talos-pilot-tui/src/components/cluster.rs @@ -43,6 +43,7 @@ pub enum NavMenuItem { Logs, Etcd, Network, + Storage, Processes, Diagnostics, Certs, @@ -51,10 +52,11 @@ pub enum NavMenuItem { } impl NavMenuItem { - const ALL: [NavMenuItem; 8] = [ + const ALL: [NavMenuItem; 9] = [ NavMenuItem::Logs, NavMenuItem::Etcd, NavMenuItem::Network, + NavMenuItem::Storage, NavMenuItem::Processes, NavMenuItem::Diagnostics, NavMenuItem::Certs, @@ -67,6 +69,7 @@ impl NavMenuItem { NavMenuItem::Logs => "Logs", NavMenuItem::Etcd => "etcd", NavMenuItem::Network => "Net", + NavMenuItem::Storage => "Stor", NavMenuItem::Processes => "Proc", NavMenuItem::Diagnostics => "Diag", NavMenuItem::Certs => "Certs", @@ -80,6 +83,7 @@ impl NavMenuItem { NavMenuItem::Logs => "L", NavMenuItem::Etcd => "e", NavMenuItem::Network => "n", + NavMenuItem::Storage => "s", NavMenuItem::Processes => "p", NavMenuItem::Diagnostics => "d", NavMenuItem::Certs => "c", @@ -731,6 +735,18 @@ impl ClusterComponent { self.clusters.get(self.active_cluster)?.client.as_ref() } + /// Get context name for active cluster + pub fn current_context_name(&self) -> Option<&str> { + self.clusters + .get(self.active_cluster) + .map(|c| c.name.as_str()) + } + + /// Get config path + pub fn config_path(&self) -> Option<&str> { + self.config_path.as_deref() + } + /// Get node_ips for active cluster fn node_ips(&self) -> &HashMap { static EMPTY: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -835,6 +851,18 @@ impl ClusterComponent { Ok(None) } } + NavMenuItem::Storage => { + if let Some(node_name) = self.current_node_name() { + let node_ip = self + .node_ips() + .get(&node_name) + .cloned() + .unwrap_or(node_name.clone()); + Ok(Some(Action::ShowStorage(node_name, node_ip))) + } else { + Ok(None) + } + } NavMenuItem::Processes => { if let Some(node_name) = self.current_node_name() { let node_ip = self @@ -1063,6 +1091,18 @@ impl Component for ClusterComponent { Ok(None) } } + KeyCode::Char('s') => { + if let Some(node_name) = self.current_node_name() { + let node_ip = self + .node_ips() + .get(&node_name) + .cloned() + .unwrap_or(node_name.clone()); + Ok(Some(Action::ShowStorage(node_name, node_ip))) + } else { + Ok(None) + } + } KeyCode::Char('d') => { if let Some(node_name) = self.current_node_name() { let node_ip = self diff --git a/crates/talos-pilot-tui/src/components/mod.rs b/crates/talos-pilot-tui/src/components/mod.rs index 61f5ead..21e69fb 100644 --- a/crates/talos-pilot-tui/src/components/mod.rs +++ b/crates/talos-pilot-tui/src/components/mod.rs @@ -14,6 +14,7 @@ pub mod node_operations; pub mod processes; pub mod rolling_operations; pub mod security; +pub mod storage; pub mod workloads; pub use cluster::ClusterComponent; @@ -28,6 +29,7 @@ pub use node_operations::NodeOperationsComponent; pub use processes::ProcessesComponent; pub use rolling_operations::RollingOperationsComponent; pub use security::SecurityComponent; +pub use storage::StorageComponent; pub use workloads::WorkloadHealthComponent; use crate::action::Action; diff --git a/crates/talos-pilot-tui/src/components/storage.rs b/crates/talos-pilot-tui/src/components/storage.rs new file mode 100644 index 0000000..f544004 --- /dev/null +++ b/crates/talos-pilot-tui/src/components/storage.rs @@ -0,0 +1,634 @@ +//! Storage component - displays disk and volume information +//! +//! Shows physical disks and Talos volume status for a node. + +use crate::action::Action; +use crate::components::Component; +use color_eyre::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}, +}; +use std::time::Duration; +use talos_pilot_core::{AsyncState, format_bytes}; +use talos_rs::{ + DiskInfo, TalosClient, VolumeStatus, get_disks_for_node, get_volume_status_for_node, +}; + +/// Auto-refresh interval in seconds +const AUTO_REFRESH_INTERVAL_SECS: u64 = 30; + +/// View mode for the storage component +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StorageViewMode { + #[default] + Disks, + Volumes, +} + +impl StorageViewMode { + pub fn next(&self) -> Self { + match self { + StorageViewMode::Disks => StorageViewMode::Volumes, + StorageViewMode::Volumes => StorageViewMode::Disks, + } + } + + pub fn label(&self) -> &'static str { + match self { + StorageViewMode::Disks => "Disks", + StorageViewMode::Volumes => "Volumes", + } + } +} + +/// Loaded storage data (wrapped by AsyncState) +#[derive(Debug, Clone, Default)] +pub struct StorageData { + /// Node hostname + pub hostname: String, + /// Node address + pub address: String, + /// Physical disks + pub disks: Vec, + /// Volume status + pub volumes: Vec, +} + +/// Storage component for viewing disk and volume information +pub struct StorageComponent { + /// Async state wrapping all storage data + state: AsyncState, + + /// Current view mode (Disks or Volumes) + view_mode: StorageViewMode, + + /// Table state for disk list + disk_table_state: TableState, + + /// Table state for volume list + volume_table_state: TableState, + + /// Auto-refresh enabled + auto_refresh: bool, + + /// Client for API calls (unused but kept for consistency) + #[allow(dead_code)] + client: Option, + + /// Node address for talosctl commands + node_address: Option, + + /// Context name for authentication + context: Option, + + /// Config path for authentication + config_path: Option, +} + +impl Default for StorageComponent { + fn default() -> Self { + Self::new("".to_string(), "".to_string(), None, None) + } +} + +impl StorageComponent { + pub fn new( + hostname: String, + address: String, + context: Option, + config_path: Option, + ) -> Self { + let mut disk_table_state = TableState::default(); + disk_table_state.select(Some(0)); + let mut volume_table_state = TableState::default(); + volume_table_state.select(Some(0)); + + let initial_data = StorageData { + hostname, + address: address.clone(), + ..Default::default() + }; + + let node_address = if address.is_empty() { + None + } else { + // Extract IP from address (remove port if present) + Some(address.split(':').next().unwrap_or(&address).to_string()) + }; + + Self { + state: AsyncState::with_data(initial_data), + view_mode: StorageViewMode::Disks, + disk_table_state, + volume_table_state, + auto_refresh: true, + client: None, + node_address, + context, + config_path, + } + } + + /// Set the client for API calls + pub fn set_client(&mut self, client: TalosClient) { + self.client = Some(client); + } + + /// Set error message + pub fn set_error(&mut self, error: String) { + self.state.set_error(error); + } + + /// Helper to get data reference + fn data(&self) -> Option<&StorageData> { + self.state.data() + } + + /// Refresh storage data + pub async fn refresh(&mut self) -> Result<()> { + self.state.start_loading(); + + let Some(node) = &self.node_address else { + self.state.set_error("No node address configured"); + return Ok(()); + }; + + let Some(context) = &self.context else { + self.state.set_error("No context configured"); + return Ok(()); + }; + + // Get or create data + let mut data = self.state.take_data().unwrap_or_default(); + + // Fetch disk information using context-aware async function + match get_disks_for_node(context, node, self.config_path.as_deref()).await { + Ok(disks) => { + data.disks = disks; + } + Err(e) => { + tracing::warn!("Failed to fetch disks: {}", e); + data.disks.clear(); + } + } + + // Fetch volume status using context-aware async function + match get_volume_status_for_node(context, node, self.config_path.as_deref()).await { + Ok(volumes) => { + data.volumes = volumes; + } + Err(e) => { + tracing::warn!("Failed to fetch volumes: {}", e); + data.volumes.clear(); + } + } + + // Store the data + self.state.set_data(data); + Ok(()) + } + + /// Get selected disk index + fn selected_disk_index(&self) -> usize { + self.disk_table_state.selected().unwrap_or(0) + } + + /// Get selected volume index + fn selected_volume_index(&self) -> usize { + self.volume_table_state.selected().unwrap_or(0) + } + + /// Move selection up + fn select_prev(&mut self) { + match self.view_mode { + StorageViewMode::Disks => { + if let Some(data) = self.data() + && !data.disks.is_empty() + { + let i = self.selected_disk_index(); + let new_i = if i == 0 { data.disks.len() - 1 } else { i - 1 }; + self.disk_table_state.select(Some(new_i)); + } + } + StorageViewMode::Volumes => { + if let Some(data) = self.data() + && !data.volumes.is_empty() + { + let i = self.selected_volume_index(); + let new_i = if i == 0 { + data.volumes.len() - 1 + } else { + i - 1 + }; + self.volume_table_state.select(Some(new_i)); + } + } + } + } + + /// Move selection down + fn select_next(&mut self) { + match self.view_mode { + StorageViewMode::Disks => { + if let Some(data) = self.data() + && !data.disks.is_empty() + { + let i = self.selected_disk_index(); + let new_i = (i + 1) % data.disks.len(); + self.disk_table_state.select(Some(new_i)); + } + } + StorageViewMode::Volumes => { + if let Some(data) = self.data() + && !data.volumes.is_empty() + { + let i = self.selected_volume_index(); + let new_i = (i + 1) % data.volumes.len(); + self.volume_table_state.select(Some(new_i)); + } + } + } + } + + /// Draw the disks view + fn draw_disks_view(&mut self, frame: &mut Frame, area: Rect) { + let chunks = Layout::vertical([ + Constraint::Min(5), // Table + Constraint::Length(5), // Detail section + ]) + .split(area); + + // Draw disk table + let header = Row::new(vec![ + Cell::from("DEVICE").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("SIZE").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("TYPE").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("TRANSPORT").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("MODEL").style(Style::default().add_modifier(Modifier::BOLD)), + ]) + .height(1); + + let rows: Vec = if let Some(data) = self.data() { + data.disks + .iter() + .map(|disk| { + let disk_type = if disk.cdrom { + "CD-ROM" + } else if disk.rotational { + "HDD" + } else { + "SSD" + }; + + let type_color = if disk.cdrom { + Color::Magenta + } else if disk.rotational { + Color::Yellow + } else { + Color::Green + }; + + Row::new(vec![ + Cell::from(disk.dev_path.clone()), + Cell::from(disk.size_pretty.clone()), + Cell::from(disk_type).style(Style::default().fg(type_color)), + Cell::from(disk.transport.clone().unwrap_or_default()), + Cell::from(disk.model.clone().unwrap_or_default()), + ]) + }) + .collect() + } else { + vec![] + }; + + let widths = [ + Constraint::Length(14), + Constraint::Length(10), + Constraint::Length(8), + Constraint::Length(12), + Constraint::Min(20), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Disks ") + .title_style(Style::default().fg(Color::Cyan)), + ) + .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + frame.render_stateful_widget(table, chunks[0], &mut self.disk_table_state); + + // Draw detail section + self.draw_disk_detail(frame, chunks[1]); + } + + /// Draw disk detail section + fn draw_disk_detail(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Details ") + .title_style(Style::default().fg(Color::Yellow)); + + let content = if let Some(data) = self.data() { + if let Some(disk) = data.disks.get(self.selected_disk_index()) { + let mut lines = vec![ + Line::from(vec![ + Span::styled("Device: ", Style::default().fg(Color::Gray)), + Span::raw(&disk.dev_path), + Span::raw(" "), + Span::styled("Size: ", Style::default().fg(Color::Gray)), + Span::raw(format!( + "{} ({})", + &disk.size_pretty, + format_bytes(disk.size) + )), + ]), + Line::from(vec![ + Span::styled("Serial: ", Style::default().fg(Color::Gray)), + Span::raw(disk.serial.clone().unwrap_or_else(|| "N/A".to_string())), + Span::raw(" "), + Span::styled("WWID: ", Style::default().fg(Color::Gray)), + Span::raw( + disk.wwid + .clone() + .map(|w| { + if w.len() > 30 { + format!("{}...", &w[..30]) + } else { + w + } + }) + .unwrap_or_else(|| "N/A".to_string()), + ), + ]), + ]; + + if disk.readonly { + lines.push(Line::from(vec![Span::styled( + " [READ-ONLY]", + Style::default().fg(Color::Red), + )])); + } + + lines + } else { + vec![Line::from("No disk selected")] + } + } else { + vec![Line::from("Loading...")] + }; + + let paragraph = Paragraph::new(content).block(block); + frame.render_widget(paragraph, area); + } + + /// Draw the volumes view + fn draw_volumes_view(&mut self, frame: &mut Frame, area: Rect) { + let chunks = Layout::vertical([ + Constraint::Min(5), // Table + Constraint::Length(5), // Detail section + ]) + .split(area); + + // Draw volume table + let header = Row::new(vec![ + Cell::from("VOLUME").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("SIZE").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("PHASE").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("FS").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("ENCRYPTION").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("MOUNT").style(Style::default().add_modifier(Modifier::BOLD)), + ]) + .height(1); + + let rows: Vec = if let Some(data) = self.data() { + data.volumes + .iter() + .map(|vol| { + let phase_color = match vol.phase.as_str() { + "ready" => Color::Green, + "waiting" => Color::Yellow, + _ => Color::Red, + }; + + let encryption = vol + .encryption_provider + .clone() + .unwrap_or_else(|| "none".to_string()); + let encryption_color = if encryption == "none" { + Color::Yellow + } else { + Color::Green + }; + + Row::new(vec![ + Cell::from(vol.id.clone()), + Cell::from(vol.size.clone()), + Cell::from(vol.phase.clone()).style(Style::default().fg(phase_color)), + Cell::from(vol.filesystem.clone().unwrap_or_default()), + Cell::from(encryption).style(Style::default().fg(encryption_color)), + Cell::from(vol.mount_location.clone().unwrap_or_default()), + ]) + }) + .collect() + } else { + vec![] + }; + + let widths = [ + Constraint::Length(12), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(6), + Constraint::Length(12), + Constraint::Min(15), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Volumes ") + .title_style(Style::default().fg(Color::Cyan)), + ) + .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + + frame.render_stateful_widget(table, chunks[0], &mut self.volume_table_state); + + // Draw detail section + self.draw_volume_detail(frame, chunks[1]); + } + + /// Draw volume detail section + fn draw_volume_detail(&self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Details ") + .title_style(Style::default().fg(Color::Yellow)); + + let content = if let Some(data) = self.data() { + if let Some(vol) = data.volumes.get(self.selected_volume_index()) { + vec![ + Line::from(vec![ + Span::styled("Volume: ", Style::default().fg(Color::Gray)), + Span::raw(&vol.id), + Span::raw(" "), + Span::styled("Size: ", Style::default().fg(Color::Gray)), + Span::raw(&vol.size), + ]), + Line::from(vec![ + Span::styled("Mount: ", Style::default().fg(Color::Gray)), + Span::raw( + vol.mount_location + .clone() + .unwrap_or_else(|| "N/A".to_string()), + ), + Span::raw(" "), + Span::styled("Filesystem: ", Style::default().fg(Color::Gray)), + Span::raw(vol.filesystem.clone().unwrap_or_else(|| "N/A".to_string())), + ]), + Line::from(vec![ + Span::styled("Encryption: ", Style::default().fg(Color::Gray)), + Span::raw( + vol.encryption_provider + .clone() + .unwrap_or_else(|| "none".to_string()), + ), + ]), + ] + } else { + vec![Line::from("No volume selected")] + } + } else { + vec![Line::from("Loading...")] + }; + + let paragraph = Paragraph::new(content).block(block); + frame.render_widget(paragraph, area); + } + + /// Draw tab bar + fn draw_tabs(&self, frame: &mut Frame, area: Rect) { + let tabs = [StorageViewMode::Disks, StorageViewMode::Volumes]; + + let tab_spans: Vec = tabs + .iter() + .map(|tab| { + let style = if *tab == self.view_mode { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + Span::styled(format!(" [{}] ", tab.label()), style) + }) + .collect(); + + let hostname = self.data().map(|d| d.hostname.clone()).unwrap_or_default(); + + let mut line_spans = tab_spans; + line_spans.push(Span::raw(" ")); + line_spans.push(Span::styled( + format!("Node: {}", hostname), + Style::default().fg(Color::DarkGray), + )); + + let tabs_line = Line::from(line_spans); + let paragraph = Paragraph::new(tabs_line); + frame.render_widget(paragraph, area); + } +} + +impl Component for StorageComponent { + fn handle_key_event(&mut self, key: KeyEvent) -> Result> { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + return Ok(Some(Action::Back)); + } + KeyCode::Tab => { + self.view_mode = self.view_mode.next(); + } + KeyCode::Up | KeyCode::Char('k') => { + self.select_prev(); + } + KeyCode::Down | KeyCode::Char('j') => { + self.select_next(); + } + KeyCode::Char('r') => { + return Ok(Some(Action::Refresh)); + } + _ => {} + } + Ok(None) + } + + fn update(&mut self, action: Action) -> Result> { + if let Action::Tick = action { + // Check for auto-refresh using AsyncState + let interval = Duration::from_secs(AUTO_REFRESH_INTERVAL_SECS); + if self.state.should_auto_refresh(self.auto_refresh, interval) { + return Ok(Some(Action::Refresh)); + } + } + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + // Check loading state + if self.state.is_loading() && !self.state.has_data() { + let loading = Paragraph::new("Loading storage info...") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(loading, area); + return Ok(()); + } + + if let Some(err) = self.state.error() { + let error = + Paragraph::new(format!("Error: {}", err)).style(Style::default().fg(Color::Red)); + frame.render_widget(error, area); + return Ok(()); + } + + let chunks = Layout::vertical([ + Constraint::Length(1), // Tabs + Constraint::Min(0), // Content + Constraint::Length(1), // Help + ]) + .split(area); + + // Draw tabs + self.draw_tabs(frame, chunks[0]); + + // Draw content based on view mode + match self.view_mode { + StorageViewMode::Disks => self.draw_disks_view(frame, chunks[1]), + StorageViewMode::Volumes => self.draw_volumes_view(frame, chunks[1]), + } + + // Draw help line + let help = Line::from(vec![ + Span::styled(" Tab", Style::default().fg(Color::Cyan)), + Span::raw(" switch view "), + Span::styled("↑/↓", Style::default().fg(Color::Cyan)), + Span::raw(" navigate "), + Span::styled("r", Style::default().fg(Color::Cyan)), + Span::raw(" refresh "), + Span::styled("Esc", Style::default().fg(Color::Cyan)), + Span::raw(" back"), + ]); + let help_paragraph = Paragraph::new(help).style(Style::default().fg(Color::DarkGray)); + frame.render_widget(help_paragraph, chunks[2]); + + Ok(()) + } +} diff --git a/crates/talos-rs/src/config.rs b/crates/talos-rs/src/config.rs index 029f436..0d7a606 100644 --- a/crates/talos-rs/src/config.rs +++ b/crates/talos-rs/src/config.rs @@ -345,51 +345,38 @@ contexts: assert_eq!(ctx.endpoint_url(), None); } + // Note: These tests manipulate environment variables and must run sequentially. + // They are combined into a single test to avoid race conditions in parallel execution. #[test] - fn test_default_path_with_env_var() { - // Set TALOSCONFIG environment variable - unsafe { - std::env::set_var("TALOSCONFIG", "/custom/path/to/talosconfig"); - } + fn test_default_path_env_handling() { + use std::sync::Mutex; + static ENV_MUTEX: Mutex<()> = Mutex::new(()); - let path = TalosConfig::default_path().unwrap(); - assert_eq!(path, PathBuf::from("/custom/path/to/talosconfig")); + let _guard = ENV_MUTEX.lock().unwrap(); - // Clean up + // Test 1: With env var set unsafe { - std::env::remove_var("TALOSCONFIG"); - } - } - - #[test] - fn test_default_path_without_env_var() { - // Ensure TALOSCONFIG is not set - unsafe { - std::env::remove_var("TALOSCONFIG"); + std::env::set_var("TALOSCONFIG", "/custom/path/to/talosconfig"); } - let path = TalosConfig::default_path().unwrap(); - let home = dirs_next::home_dir().unwrap(); - let expected = home.join(".talos").join("config"); - - assert_eq!(path, expected); - } + assert_eq!(path, PathBuf::from("/custom/path/to/talosconfig")); - #[test] - fn test_default_path_env_var_takes_precedence() { - // Set TALOSCONFIG to a custom path + // Test 2: Env var takes precedence (different path) let custom_path = "/tmp/my-talos-config.yaml"; unsafe { std::env::set_var("TALOSCONFIG", custom_path); } - let path = TalosConfig::default_path().unwrap(); assert_eq!(path, PathBuf::from(custom_path)); assert!(!path.to_string_lossy().contains(".talos")); - // Clean up + // Test 3: Without env var - falls back to default unsafe { std::env::remove_var("TALOSCONFIG"); } + let path = TalosConfig::default_path().unwrap(); + let home = dirs_next::home_dir().unwrap(); + let expected = home.join(".talos").join("config"); + assert_eq!(path, expected); } } diff --git a/crates/talos-rs/src/lib.rs b/crates/talos-rs/src/lib.rs index 8a7b0ce..3196d09 100644 --- a/crates/talos-rs/src/lib.rs +++ b/crates/talos-rs/src/lib.rs @@ -107,7 +107,8 @@ pub use client::{ pub use config::{Context, TalosConfig}; pub use error::TalosError; pub use talosctl::{ - AddressStatus, DiscoveryMember, KubeSpanPeerStatus, MachineConfigInfo, VolumeStatus, - get_address_status, get_discovery_members, get_discovery_members_for_context, - get_kubespan_peers, get_machine_config, get_volume_status, is_kubespan_enabled, + AddressStatus, DiscoveryMember, DiskInfo, KubeSpanPeerStatus, MachineConfigInfo, VolumeStatus, + get_address_status, get_discovery_members, get_discovery_members_for_context, get_disks, + get_disks_for_context, get_disks_for_node, get_kubespan_peers, get_machine_config, + get_volume_status, get_volume_status_for_node, is_kubespan_enabled, }; diff --git a/crates/talos-rs/src/talosctl.rs b/crates/talos-rs/src/talosctl.rs index 8eb13e7..5457d35 100644 --- a/crates/talos-rs/src/talosctl.rs +++ b/crates/talos-rs/src/talosctl.rs @@ -61,6 +61,35 @@ pub struct VolumeStatus { pub mount_location: Option, } +/// Disk information from Disks.block.talos.dev resource +#[derive(Debug, Clone)] +pub struct DiskInfo { + /// Disk ID (e.g., "sda", "nvme0n1") + pub id: String, + /// Device path (e.g., "/dev/sda") + pub dev_path: String, + /// Size in bytes + pub size: u64, + /// Human-readable size (e.g., "500 GB") + pub size_pretty: String, + /// Disk model + pub model: Option, + /// Disk serial number + pub serial: Option, + /// Transport type (e.g., "sata", "nvme", "virtio", "usb") + pub transport: Option, + /// Whether the disk is rotational (HDD) or not (SSD/NVMe) + pub rotational: bool, + /// Whether the disk is read-only + pub readonly: bool, + /// Whether the disk is a CD-ROM + pub cdrom: bool, + /// World Wide ID + pub wwid: Option, + /// Bus path + pub bus_path: Option, +} + /// Machine config info from MachineConfig resource #[derive(Debug, Clone)] pub struct MachineConfigInfo { @@ -131,6 +160,110 @@ pub fn get_volume_status(node: &str) -> Result, TalosError> { parse_volume_status_yaml(&output) } +/// Get volume status for a specific node using context authentication (async, non-blocking) +/// +/// Executes: talosctl --context [--talosconfig ] -n get volumestatus -o yaml +pub async fn get_volume_status_for_node( + context: &str, + node_ip: &str, + config_path: Option<&str>, +) -> Result, TalosError> { + let mut args = vec!["--context", context]; + + // Add talosconfig path if provided + let config_path_string; + if let Some(path) = config_path { + config_path_string = path.to_string(); + args.push("--talosconfig"); + args.push(&config_path_string); + } + + args.extend_from_slice(&["-n", node_ip, "get", "volumestatus", "-o", "yaml"]); + + let output = exec_talosctl_async(&args).await?; + parse_volume_status_yaml(&output) +} + +/// Get disk information for a node +/// +/// Executes: talosctl get disks --nodes -o yaml +pub fn get_disks(node: &str) -> Result, TalosError> { + let output = exec_talosctl(&["get", "disks", "--nodes", node, "-o", "yaml"])?; + parse_disks_yaml(&output) +} + +/// Get disk information for a specific node using context authentication (async, non-blocking) +/// +/// Executes: talosctl --context [--talosconfig ] -n get disks -o yaml +pub async fn get_disks_for_node( + context: &str, + node_ip: &str, + config_path: Option<&str>, +) -> Result, TalosError> { + let mut args = vec!["--context", context]; + + // Add talosconfig path if provided + let config_path_string; + if let Some(path) = config_path { + config_path_string = path.to_string(); + args.push("--talosconfig"); + args.push(&config_path_string); + } + + args.extend_from_slice(&["-n", node_ip, "get", "disks", "-o", "yaml"]); + + let output = exec_talosctl_async(&args).await?; + parse_disks_yaml(&output) +} + +/// Get disk information for a context (async, non-blocking) +/// +/// Executes: talosctl --context -n get disks -o yaml +pub async fn get_disks_for_context( + context: &str, + config_path: Option<&str>, +) -> Result, TalosError> { + // Load config to get an endpoint IP to use as the node target + let config = match config_path { + Some(path) => { + let path_buf = std::path::PathBuf::from(path); + crate::TalosConfig::load_from(&path_buf)? + } + None => crate::TalosConfig::load_default()?, + }; + let ctx = config + .contexts + .get(context) + .ok_or_else(|| TalosError::ContextNotFound(context.to_string()))?; + + // Get the first endpoint and extract the IP (remove port if present) + let node_ip = ctx + .endpoints + .first() + .ok_or_else(|| TalosError::NoEndpoints(context.to_string()))? + .split(':') + .next() + .unwrap_or("") + .to_string(); + + if node_ip.is_empty() { + return Err(TalosError::NoEndpoints(context.to_string())); + } + + let output = exec_talosctl_async(&[ + "--context", + context, + "-n", + &node_ip, + "get", + "disks", + "-o", + "yaml", + ]) + .await?; + parse_disks_yaml(&output) +} + /// Get machine config info for a node /// /// Executes: talosctl get machineconfig --nodes -o yaml @@ -282,21 +415,49 @@ fn parse_volume_status_yaml(yaml_str: &str) -> Result, TalosEr .unwrap_or("unknown") .to_string(); + // Try prettySize first, fall back to showing volume type if not available let size = spec .and_then(|s| s.get("prettySize")) .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); + .map(|s| s.to_string()) + .or_else(|| { + // If no size, show the volume type (directory, partition, etc.) + spec.and_then(|s| s.get("type")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_default(); + // Try filesystem first, fall back to volume type (directory, partition, symlink, etc.) let filesystem = spec .and_then(|s| s.get("filesystem")) .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + .map(|s| s.to_string()) + .or_else(|| { + spec.and_then(|s| s.get("type")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }); + // Try mountLocation first, then spec.mountSpec.targetPath, then use the id if it's a path let mount_location = spec .and_then(|s| s.get("mountLocation")) .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + .map(|s| s.to_string()) + .or_else(|| { + spec.and_then(|s| s.get("mountSpec")) + .and_then(|m| m.get("targetPath")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .or_else(|| { + // If id starts with /, it's likely a mount path + if id.starts_with('/') { + Some(id.clone()) + } else { + None + } + }); volumes.push(VolumeStatus { id, @@ -311,6 +472,119 @@ fn parse_volume_status_yaml(yaml_str: &str) -> Result, TalosEr Ok(volumes) } +/// Parse disks YAML output from talosctl +fn parse_disks_yaml(yaml_str: &str) -> Result, TalosError> { + let mut disks = Vec::new(); + + // Split by YAML document separator and parse each + for doc_str in yaml_str.split("\n---") { + let doc_str = doc_str.trim(); + if doc_str.is_empty() { + continue; + } + + let doc: serde_yaml::Value = match serde_yaml::from_str(doc_str) { + Ok(v) => v, + Err(_) => continue, + }; + + // Get metadata.id (e.g., "sda", "nvme0n1") + let id = doc + .get("metadata") + .and_then(|m| m.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Skip if no id + if id.is_empty() { + continue; + } + + // Get spec fields + let spec = doc.get("spec"); + + let dev_path = spec + .and_then(|s| s.get("dev_path")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let size = spec + .and_then(|s| s.get("size")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let size_pretty = spec + .and_then(|s| s.get("human_size").or_else(|| s.get("pretty_size"))) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let model = spec + .and_then(|s| s.get("model")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let serial = spec + .and_then(|s| s.get("serial")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let transport = spec + .and_then(|s| s.get("transport")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let rotational = spec + .and_then(|s| s.get("rotational")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let readonly = spec + .and_then(|s| s.get("readonly")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let cdrom = spec + .and_then(|s| s.get("cdrom")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let wwid = spec + .and_then(|s| s.get("wwid")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let bus_path = spec + .and_then(|s| s.get("bus_path")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + disks.push(DiskInfo { + id, + dev_path, + size, + size_pretty, + model, + serial, + transport, + rotational, + readonly, + cdrom, + wwid, + bus_path, + }); + } + + Ok(disks) +} + /// Parse machine config YAML output from talosctl fn parse_machine_config_yaml(yaml_str: &str) -> Result { let doc: serde_yaml::Value = serde_yaml::from_str(yaml_str) @@ -658,4 +932,78 @@ spec: assert_eq!(config.version, "5"); assert_eq!(config.machine_type, Some("controlplane".to_string())); } + + #[test] + fn test_parse_disks() { + let yaml = r#" +node: 172.20.0.5 +metadata: + namespace: runtime + type: Disks.block.talos.dev + id: sda + version: 1 + owner: block.DisksController + phase: running +spec: + dev_path: /dev/sda + size: 10485760000 + human_size: 10 GB + io_size: 512 + sector_size: 512 + readonly: false + cdrom: false + model: QEMU HARDDISK + modalias: scsi:t-0x00 + bus_path: /pci0000:00/0000:00:07.0/virtio4/host1/target1:0:0/1:0:0:0 + sub_system: /sys/class/block + transport: virtio + rotational: true +--- +node: 172.20.0.5 +metadata: + namespace: runtime + type: Disks.block.talos.dev + id: nvme0n1 + version: 1 + owner: block.DisksController + phase: running +spec: + dev_path: /dev/nvme0n1 + size: 256060514304 + human_size: 256 GB + io_size: 4096 + sector_size: 512 + readonly: false + cdrom: false + model: Samsung SSD 970 EVO Plus + serial: S4EVNG0N123456 + wwid: nvme.144d-5334455... + bus_path: /pci0000:00/0000:00:1d.0/0000:3d:00.0/nvme/nvme0/nvme0n1 + sub_system: /sys/class/block + transport: nvme + rotational: false +"#; + + let disks = parse_disks_yaml(yaml).unwrap(); + assert_eq!(disks.len(), 2); + + // First disk - virtio HDD + assert_eq!(disks[0].id, "sda"); + assert_eq!(disks[0].dev_path, "/dev/sda"); + assert_eq!(disks[0].size, 10485760000); + assert_eq!(disks[0].size_pretty, "10 GB"); + assert_eq!(disks[0].model, Some("QEMU HARDDISK".to_string())); + assert_eq!(disks[0].transport, Some("virtio".to_string())); + assert!(disks[0].rotational); + assert!(!disks[0].readonly); + + // Second disk - NVMe SSD + assert_eq!(disks[1].id, "nvme0n1"); + assert_eq!(disks[1].dev_path, "/dev/nvme0n1"); + assert_eq!(disks[1].size_pretty, "256 GB"); + assert_eq!(disks[1].model, Some("Samsung SSD 970 EVO Plus".to_string())); + assert_eq!(disks[1].serial, Some("S4EVNG0N123456".to_string())); + assert_eq!(disks[1].transport, Some("nvme".to_string())); + assert!(!disks[1].rotational); + } } From 94b2609bf8b9c5ced28c26222da273738a356d97 Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Fri, 16 Jan 2026 11:33:16 -0500 Subject: [PATCH 4/8] feat: qemu test cluster --- .gitignore | 5 + test-clusters/README.md | 69 ++++- test-clusters/scripts/test-cluster-qemu.sh | 340 +++++++++++++++++++++ 3 files changed, 413 insertions(+), 1 deletion(-) create mode 100755 test-clusters/scripts/test-cluster-qemu.sh diff --git a/.gitignore b/.gitignore index 5b7a2a7..9dae7cb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ test-clusters/output/* !test-clusters/output/.gitkeep +# Generated Talos machine configs (contain secrets) +controlplane.yaml +worker.yaml +talosconfig + # Generated by Cargo # will have compiled files and executables debug diff --git a/test-clusters/README.md b/test-clusters/README.md index 2755b3e..9af7437 100644 --- a/test-clusters/README.md +++ b/test-clusters/README.md @@ -1,6 +1,73 @@ # Test Clusters -Docker-based Talos clusters for testing talos-pilot features. +Test Talos clusters for developing and testing talos-pilot features. + +## Cluster Types + +| Type | Use Case | Physical Disks | Setup Time | +|------|----------|----------------|------------| +| Docker | Most development | No | ~30 seconds | +| QEMU | Storage/Disks view testing | Yes | ~2 minutes | + +--- + +## QEMU Clusters (Physical Disks) + +QEMU-based clusters run real VMs with virtual disks that appear as physical devices (`/dev/sda`, etc.). **Required for testing the Storage/Disks view.** + +### Quick Start + +```bash +# 1. Stop Docker clusters (they use ports 50000/6443) +sudo systemctl stop docker + +# 2. Create the QEMU cluster (runs in foreground) +./test-clusters/scripts/test-cluster-qemu.sh create + +# 3. In another terminal, wait for "maintenance mode" then: +./test-clusters/scripts/test-cluster-qemu.sh apply + +# 4. Wait for install to complete (watch QEMU window), then: +./test-clusters/scripts/test-cluster-qemu.sh bootstrap + +# 5. Test in talos-pilot +cargo run +# Switch to 'talos-qemu' context, select node, press 's' for Storage + +# 6. Destroy when done +./test-clusters/scripts/test-cluster-qemu.sh destroy + +# 7. Restart Docker +sudo systemctl start docker +``` + +### Commands + +| Command | Description | +|---------|-------------| +| `create` | Create VM, download ISO, generate config, start in foreground | +| `create-bg` | Same as create but runs VM in background | +| `apply` | Apply config to VM in maintenance mode | +| `bootstrap` | Bootstrap the cluster after config is applied | +| `status` | Show cluster status and connection info | +| `destroy` | Stop VM, delete files, remove talosconfig context | +| `connect` | Show connection info and useful commands | + +### Prerequisites + +- `qemu-system-x86_64` installed (`sudo apt install qemu-system-x86`) +- KVM enabled (`/dev/kvm` accessible, user in `kvm` group) +- Ports 50000 and 6443 available (stop Docker clusters first) + +### Why Not `talosctl cluster create qemu`? + +The official command has TLS issues in maintenance mode. Our script works around this by running QEMU directly with user-mode networking. + +--- + +## Docker Clusters (Quick Setup) + +Docker-based Talos clusters for testing most features. ## Quick Start diff --git a/test-clusters/scripts/test-cluster-qemu.sh b/test-clusters/scripts/test-cluster-qemu.sh new file mode 100755 index 0000000..20fb3ee --- /dev/null +++ b/test-clusters/scripts/test-cluster-qemu.sh @@ -0,0 +1,340 @@ +#!/bin/bash +# Test cluster management script for QEMU-based Talos clusters +# Used for testing features that require physical disks (Storage view) +# +# This script uses direct QEMU instead of talosctl cluster create +# because talosctl has TLS issues in maintenance mode. + +set -e + +# Configuration +CLUSTER_NAME="talos-qemu" +WORK_DIR="/tmp/talos-qemu-test" +DISK_SIZE="20G" +MEMORY="2048" +CPUS="2" +TALOS_VERSION="v1.12.1" +ISO_URL="https://factory.talos.dev/image/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/${TALOS_VERSION}/metal-amd64.iso" + +# Port mappings (host:guest) +TALOS_API_PORT="50000" +K8S_API_PORT="6443" + +usage() { + cat << EOF +Usage: $0 + +Commands: + create - Create QEMU Talos cluster (runs in foreground) + create-bg - Create QEMU Talos cluster (runs in background) + destroy - Destroy the cluster and clean up + status - Show cluster status + apply - Apply config to running VM in maintenance mode + bootstrap - Bootstrap the cluster (after apply) + connect - Show connection info + +This script creates a QEMU VM with a real disk for testing the Storage/Disks view. + +Prerequisites: + - qemu-system-x86_64 installed + - KVM enabled (/dev/kvm accessible) + - Ports $TALOS_API_PORT and $K8S_API_PORT available + +EOF + exit 1 +} + +check_prereqs() { + if ! command -v qemu-system-x86_64 &>/dev/null; then + echo "Error: qemu-system-x86_64 not found. Install with: sudo apt install qemu-system-x86" + exit 1 + fi + + if [[ ! -r /dev/kvm ]]; then + echo "Error: /dev/kvm not accessible. Add yourself to kvm group: sudo usermod -aG kvm \$USER" + exit 1 + fi + + if lsof -i :$TALOS_API_PORT &>/dev/null; then + echo "Error: Port $TALOS_API_PORT is in use. Stop the process using it first." + exit 1 + fi + + if lsof -i :$K8S_API_PORT &>/dev/null; then + echo "Error: Port $K8S_API_PORT is in use. Stop Docker clusters or other services." + exit 1 + fi +} + +download_iso() { + local iso_path="$WORK_DIR/talos.iso" + + if [[ -f "$iso_path" ]]; then + echo "ISO already exists: $iso_path" + return + fi + + echo "Downloading Talos ISO..." + curl -L -o "$iso_path" "$ISO_URL" + echo "Downloaded: $iso_path" +} + +create_disk() { + local disk_path="$WORK_DIR/talos-disk.raw" + + if [[ -f "$disk_path" ]]; then + echo "Disk already exists: $disk_path" + echo "Run '$0 destroy' first to recreate." + exit 1 + fi + + echo "Creating ${DISK_SIZE} disk image..." + qemu-img create -f raw "$disk_path" "$DISK_SIZE" +} + +generate_config() { + echo "Generating Talos configuration..." + talosctl gen config "$CLUSTER_NAME" "https://127.0.0.1:$K8S_API_PORT" \ + --additional-sans 127.0.0.1 \ + --output-dir "$WORK_DIR" \ + --force + + echo "Merging config into ~/.talos/config..." + talosctl config merge "$WORK_DIR/talosconfig" + talosctl --context "$CLUSTER_NAME" config endpoint 127.0.0.1 + talosctl --context "$CLUSTER_NAME" config node 127.0.0.1 +} + +start_vm() { + local background="$1" + + echo "Starting QEMU VM..." + echo " Memory: ${MEMORY}MB" + echo " CPUs: $CPUS" + echo " Disk: $WORK_DIR/talos-disk.raw" + echo "" + echo "Port mappings:" + echo " localhost:$TALOS_API_PORT -> Talos API" + echo " localhost:$K8S_API_PORT -> Kubernetes API" + echo "" + + local qemu_cmd=( + qemu-system-x86_64 + -m "$MEMORY" + -smp "$CPUS" + -cpu host + -enable-kvm + -drive "file=$WORK_DIR/talos-disk.raw,format=raw,if=ide" + -cdrom "$WORK_DIR/talos.iso" + -boot d + -netdev "user,id=net0,hostfwd=tcp::${TALOS_API_PORT}-:50000,hostfwd=tcp::${K8S_API_PORT}-:6443" + -device virtio-net-pci,netdev=net0 + ) + + if [[ "$background" == "true" ]]; then + echo "Starting in background..." + "${qemu_cmd[@]}" -display none -daemonize -pidfile "$WORK_DIR/qemu.pid" + echo "VM started. PID file: $WORK_DIR/qemu.pid" + echo "" + echo "Next steps:" + echo " 1. Wait for maintenance mode (check with: $0 status)" + echo " 2. Apply config: $0 apply" + echo " 3. Wait for install to complete" + echo " 4. Bootstrap: $0 bootstrap" + else + echo "Starting in foreground (Ctrl+C to stop)..." + echo "" + "${qemu_cmd[@]}" + fi +} + +create_cluster() { + local background="${1:-false}" + + check_prereqs + + mkdir -p "$WORK_DIR" + + download_iso + create_disk + generate_config + + echo "" + echo "=========================================" + echo "VM will boot into maintenance mode." + echo "" + echo "After boot, run in another terminal:" + echo " $0 apply # Apply configuration" + echo " $0 bootstrap # Bootstrap cluster" + echo "=========================================" + echo "" + + start_vm "$background" +} + +apply_config() { + if [[ ! -f "$WORK_DIR/controlplane.yaml" ]]; then + echo "Error: Config not found. Run '$0 create' first." + exit 1 + fi + + echo "Applying configuration to VM..." + if talosctl apply-config --insecure --nodes 127.0.0.1 --file "$WORK_DIR/controlplane.yaml"; then + echo "" + echo "Config applied! The VM will install Talos and reboot." + echo "Watch the QEMU window for progress." + echo "" + echo "Once healthy, run: $0 bootstrap" + else + echo "Failed to apply config. Is the VM in maintenance mode?" + fi +} + +bootstrap_cluster() { + echo "Checking connection..." + if ! talosctl --context "$CLUSTER_NAME" version &>/dev/null; then + echo "Error: Cannot connect to VM. Is it running and configured?" + exit 1 + fi + + echo "Bootstrapping cluster..." + talosctl --context "$CLUSTER_NAME" bootstrap + + echo "" + echo "Bootstrap initiated!" + echo "" + echo "Check cluster status with:" + echo " talosctl --context $CLUSTER_NAME get disks" + echo " talosctl --context $CLUSTER_NAME get members" + echo "" + echo "Test in talos-pilot:" + echo " cargo run" + echo " # Switch to '$CLUSTER_NAME' context, select node, press 's'" +} + +destroy_cluster() { + echo "Destroying QEMU cluster..." + + # Kill QEMU process + if [[ -f "$WORK_DIR/qemu.pid" ]]; then + local pid=$(cat "$WORK_DIR/qemu.pid") + if kill -0 "$pid" 2>/dev/null; then + echo "Killing QEMU process (PID: $pid)..." + kill "$pid" 2>/dev/null || true + fi + fi + + # Also try pkill as backup + pkill -f "qemu.*talos-disk.raw" 2>/dev/null || true + + # Remove work directory + if [[ -d "$WORK_DIR" ]]; then + echo "Removing $WORK_DIR..." + rm -rf "$WORK_DIR" + fi + + # Remove talosconfig context + if talosctl config contexts 2>/dev/null | grep -q "$CLUSTER_NAME"; then + echo "Removing talosconfig context '$CLUSTER_NAME'..." + talosctl config remove "$CLUSTER_NAME" --noconfirm 2>/dev/null || true + fi + + echo "Done." +} + +show_status() { + echo "QEMU Cluster Status" + echo "===================" + echo "" + + # Check if VM is running + if pgrep -f "qemu.*talos-disk.raw" &>/dev/null; then + echo "VM: Running" + else + echo "VM: Not running" + fi + + # Check files + echo "" + echo "Files:" + [[ -f "$WORK_DIR/talos-disk.raw" ]] && echo " Disk: $WORK_DIR/talos-disk.raw" || echo " Disk: Not created" + [[ -f "$WORK_DIR/talos.iso" ]] && echo " ISO: $WORK_DIR/talos.iso" || echo " ISO: Not downloaded" + [[ -f "$WORK_DIR/controlplane.yaml" ]] && echo " Config: $WORK_DIR/controlplane.yaml" || echo " Config: Not generated" + + # Check context + echo "" + echo "Talosconfig context:" + if talosctl config contexts 2>/dev/null | grep -q "$CLUSTER_NAME"; then + talosctl config contexts | grep "$CLUSTER_NAME" + else + echo " Not configured" + fi + + # Try to connect + echo "" + echo "Connection test:" + if talosctl --context "$CLUSTER_NAME" version 2>/dev/null; then + echo "" + echo "Disks:" + talosctl --context "$CLUSTER_NAME" get disks 2>/dev/null || echo " Cannot get disks" + else + # Try maintenance mode + if talosctl version --insecure --nodes 127.0.0.1 2>&1 | grep -q "maintenance"; then + echo " VM is in maintenance mode. Run: $0 apply" + else + echo " Cannot connect (VM not running or not ready)" + fi + fi +} + +show_connect_info() { + cat << EOF +QEMU Cluster Connection Info +============================ + +Talos API: 127.0.0.1:$TALOS_API_PORT +Kubernetes API: 127.0.0.1:$K8S_API_PORT + +talosctl commands: + talosctl --context $CLUSTER_NAME version + talosctl --context $CLUSTER_NAME get disks + talosctl --context $CLUSTER_NAME dashboard + +kubectl (after bootstrap): + talosctl --context $CLUSTER_NAME kubeconfig + kubectl --context admin@$CLUSTER_NAME get nodes + +talos-pilot: + cargo run + # Switch to '$CLUSTER_NAME' context, select node, press 's' for Storage view + +EOF +} + +# Main +case "${1:-}" in + create) + create_cluster false + ;; + create-bg) + create_cluster true + ;; + destroy) + destroy_cluster + ;; + status) + show_status + ;; + apply) + apply_config + ;; + bootstrap) + bootstrap_cluster + ;; + connect) + show_connect_info + ;; + *) + usage + ;; +esac From f1766261cd36557b2175ff34c420093ab2fad63c Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Fri, 16 Jan 2026 11:47:26 -0500 Subject: [PATCH 5/8] feat: insecure mode for viewing storage --- crates/talos-pilot-tui/src/app.rs | 80 ++- .../src/components/insecure.rs | 547 ++++++++++++++++++ crates/talos-pilot-tui/src/components/mod.rs | 2 + crates/talos-rs/src/lib.rs | 10 +- crates/talos-rs/src/talosctl.rs | 84 +++ src/main.rs | 40 +- 6 files changed, 746 insertions(+), 17 deletions(-) create mode 100644 crates/talos-pilot-tui/src/components/insecure.rs diff --git a/crates/talos-pilot-tui/src/app.rs b/crates/talos-pilot-tui/src/app.rs index c6965c1..6b32650 100644 --- a/crates/talos-pilot-tui/src/app.rs +++ b/crates/talos-pilot-tui/src/app.rs @@ -3,9 +3,10 @@ use crate::action::Action; use crate::components::rolling_operations::RollingNodeInfo; use crate::components::{ - ClusterComponent, Component, DiagnosticsComponent, EtcdComponent, LifecycleComponent, - MultiLogsComponent, NetworkStatsComponent, NodeOperationsComponent, ProcessesComponent, - RollingOperationsComponent, SecurityComponent, StorageComponent, WorkloadHealthComponent, + ClusterComponent, Component, DiagnosticsComponent, EtcdComponent, InsecureComponent, + LifecycleComponent, MultiLogsComponent, NetworkStatsComponent, NodeOperationsComponent, + ProcessesComponent, RollingOperationsComponent, SecurityComponent, StorageComponent, + WorkloadHealthComponent, }; use crate::tui::{self, Tui}; use color_eyre::Result; @@ -70,6 +71,10 @@ pub struct App { action_tx: mpsc::UnboundedSender, /// Custom config file path (from --config flag) config_path: Option, + /// Whether running in insecure mode (no TLS) + insecure: bool, + /// Endpoint for insecure mode + insecure_endpoint: Option, } /// Results from async operations @@ -84,12 +89,18 @@ enum AsyncResult { impl Default for App { fn default() -> Self { - Self::new(None, None, 500) + Self::new(None, None, 500, false, None) } } impl App { - pub fn new(config_path: Option, context: Option, tail_lines: i32) -> Self { + pub fn new( + config_path: Option, + context: Option, + tail_lines: i32, + insecure: bool, + insecure_endpoint: Option, + ) -> Self { let (action_tx, action_rx) = mpsc::unbounded_channel(); Self { should_quit: false, @@ -111,6 +122,8 @@ impl App { action_rx, action_tx, config_path, + insecure, + insecure_endpoint, } } @@ -122,8 +135,12 @@ impl App { // Initialize terminal let mut terminal = tui::init()?; - // Main loop - let result = self.main_loop(&mut terminal).await; + // Main loop - choose based on mode + let result = if self.insecure { + self.insecure_loop(&mut terminal).await + } else { + self.main_loop(&mut terminal).await + }; // Restore terminal tui::restore()?; @@ -131,6 +148,55 @@ impl App { result } + /// Insecure mode event loop - simplified for maintenance mode nodes + async fn insecure_loop(&mut self, terminal: &mut Tui) -> Result<()> { + let endpoint = self + .insecure_endpoint + .clone() + .expect("Insecure mode requires endpoint"); + + let mut insecure = InsecureComponent::new(endpoint); + + // Connect on startup + insecure.connect().await?; + + loop { + // Draw + terminal.draw(|frame| { + let _ = insecure.draw(frame, frame.area()); + })?; + + // Handle events with timeout + if event::poll(self.tick_rate)? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if let Some(action) = insecure.handle_key_event(key)? { + match action { + Action::Quit => { + self.should_quit = true; + } + Action::Refresh => { + insecure.refresh().await?; + } + _ => {} + } + } + } + Event::Resize(_, _) => { + // Terminal will automatically resize on next draw + } + _ => {} + } + } + + if self.should_quit { + break; + } + } + + Ok(()) + } + /// Main event loop async fn main_loop(&mut self, terminal: &mut Tui) -> Result<()> { // Connect on startup diff --git a/crates/talos-pilot-tui/src/components/insecure.rs b/crates/talos-pilot-tui/src/components/insecure.rs new file mode 100644 index 0000000..9e11b2b --- /dev/null +++ b/crates/talos-pilot-tui/src/components/insecure.rs @@ -0,0 +1,547 @@ +//! Insecure mode component - for connecting to maintenance mode nodes +//! +//! This component provides a simplified UI for nodes that haven't been +//! bootstrapped yet. It shows disk and volume information without requiring +//! TLS client certificates. + +use crate::action::Action; +use crate::components::Component; +use color_eyre::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}, +}; +use talos_pilot_core::AsyncState; +use talos_rs::{ + DiskInfo, InsecureVersionInfo, VolumeStatus, get_disks_insecure, get_version_insecure, + get_volume_status_insecure, +}; + +/// View mode for the insecure component +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InsecureViewMode { + #[default] + Disks, + Volumes, +} + +impl InsecureViewMode { + pub fn next(&self) -> Self { + match self { + InsecureViewMode::Disks => InsecureViewMode::Volumes, + InsecureViewMode::Volumes => InsecureViewMode::Disks, + } + } + + pub fn label(&self) -> &'static str { + match self { + InsecureViewMode::Disks => "Disks", + InsecureViewMode::Volumes => "Volumes", + } + } +} + +/// Data loaded in insecure mode +#[derive(Debug, Clone, Default)] +pub struct InsecureData { + /// Endpoint address + pub endpoint: String, + /// Version info (if available) + pub version: Option, + /// Physical disks + pub disks: Vec, + /// Volume status + pub volumes: Vec, + /// Whether connected + pub connected: bool, +} + +/// Insecure mode component for maintenance mode nodes +pub struct InsecureComponent { + /// Async state wrapping all data + state: AsyncState, + + /// Endpoint to connect to + endpoint: String, + + /// Current view mode (Disks or Volumes) + view_mode: InsecureViewMode, + + /// Table state for disk list + disk_table_state: TableState, + + /// Table state for volume list + volume_table_state: TableState, +} + +impl InsecureComponent { + pub fn new(endpoint: String) -> Self { + let mut disk_table_state = TableState::default(); + disk_table_state.select(Some(0)); + let mut volume_table_state = TableState::default(); + volume_table_state.select(Some(0)); + + let initial_data = InsecureData { + endpoint: endpoint.clone(), + ..Default::default() + }; + + Self { + state: AsyncState::with_data(initial_data), + endpoint, + view_mode: InsecureViewMode::Disks, + disk_table_state, + volume_table_state, + } + } + + /// Extract just the IP/hostname from endpoint (strip port if present) + fn endpoint_for_talosctl(endpoint: &str) -> String { + // talosctl -n expects just IP, not IP:port + if let Some(idx) = endpoint.rfind(':') { + // Check if this is an IPv6 address (contains multiple colons) + if endpoint.matches(':').count() > 1 { + // IPv6 - return as-is (talosctl handles it) + endpoint.to_string() + } else { + // IPv4 with port - strip the port + endpoint[..idx].to_string() + } + } else { + endpoint.to_string() + } + } + + /// Connect and load data + pub async fn connect(&mut self) -> Result<()> { + self.state.start_loading(); + + let endpoint = Self::endpoint_for_talosctl(&self.endpoint); + let mut data = InsecureData { + endpoint: endpoint.clone(), + ..Default::default() + }; + + // Try to get version info (may fail in maintenance mode) + match get_version_insecure(&endpoint).await { + Ok(version) => { + data.version = Some(version); + } + Err(e) => { + tracing::debug!("Version info not available: {}", e); + } + } + + // Fetch disks + match get_disks_insecure(&endpoint).await { + Ok(disks) => { + data.disks = disks; + data.connected = true; + } + Err(e) => { + self.state + .set_error(format!("Failed to connect: {}", e)); + return Ok(()); + } + } + + // Fetch volumes + match get_volume_status_insecure(&endpoint).await { + Ok(volumes) => { + data.volumes = volumes; + } + Err(e) => { + tracing::debug!("Volume info not available: {}", e); + } + } + + self.state.set_data(data); + Ok(()) + } + + /// Refresh data + pub async fn refresh(&mut self) -> Result<()> { + self.connect().await + } + + /// Helper to get data reference + fn data(&self) -> Option<&InsecureData> { + self.state.data() + } + + /// Get selected disk index + fn selected_disk_index(&self) -> usize { + self.disk_table_state.selected().unwrap_or(0) + } + + /// Get selected volume index + fn selected_volume_index(&self) -> usize { + self.volume_table_state.selected().unwrap_or(0) + } + + /// Move selection up + fn select_prev(&mut self) { + match self.view_mode { + InsecureViewMode::Disks => { + if let Some(data) = self.data() + && !data.disks.is_empty() + { + let i = self.selected_disk_index(); + let new_i = if i == 0 { data.disks.len() - 1 } else { i - 1 }; + self.disk_table_state.select(Some(new_i)); + } + } + InsecureViewMode::Volumes => { + if let Some(data) = self.data() + && !data.volumes.is_empty() + { + let i = self.selected_volume_index(); + let new_i = if i == 0 { + data.volumes.len() - 1 + } else { + i - 1 + }; + self.volume_table_state.select(Some(new_i)); + } + } + } + } + + /// Move selection down + fn select_next(&mut self) { + match self.view_mode { + InsecureViewMode::Disks => { + if let Some(data) = self.data() + && !data.disks.is_empty() + { + let i = self.selected_disk_index(); + let new_i = (i + 1) % data.disks.len(); + self.disk_table_state.select(Some(new_i)); + } + } + InsecureViewMode::Volumes => { + if let Some(data) = self.data() + && !data.volumes.is_empty() + { + let i = self.selected_volume_index(); + let new_i = (i + 1) % data.volumes.len(); + self.volume_table_state.select(Some(new_i)); + } + } + } + } + + /// Draw the warning banner + fn draw_warning_banner(&self, frame: &mut Frame, area: Rect) { + let warning = Paragraph::new(Line::from(vec![ + Span::styled( + " ⚠ INSECURE MODE ", + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" Connected without TLS authentication - "), + Span::styled("Limited functionality", Style::default().fg(Color::Yellow)), + ])) + .style(Style::default().bg(Color::DarkGray)); + + frame.render_widget(warning, area); + } + + /// Draw connection info + fn draw_connection_info(&self, frame: &mut Frame, area: Rect) { + let data = self.data(); + + let status = if let Some(d) = data { + if d.connected { + let version_str = d + .version + .as_ref() + .map(|v| { + if v.maintenance_mode { + "Maintenance Mode".to_string() + } else { + format!("Talos {}", v.tag) + } + }) + .unwrap_or_else(|| "Maintenance Mode".to_string()); + + Line::from(vec![ + Span::styled("Endpoint: ", Style::default().fg(Color::DarkGray)), + Span::styled(&d.endpoint, Style::default().fg(Color::White)), + Span::raw(" │ "), + Span::styled("Status: ", Style::default().fg(Color::DarkGray)), + Span::styled(version_str, Style::default().fg(Color::Green)), + ]) + } else { + Line::from(vec![ + Span::styled("Endpoint: ", Style::default().fg(Color::DarkGray)), + Span::styled(&self.endpoint, Style::default().fg(Color::White)), + Span::raw(" │ "), + Span::styled("Status: ", Style::default().fg(Color::DarkGray)), + Span::styled("Disconnected", Style::default().fg(Color::Red)), + ]) + } + } else if self.state.is_loading() { + Line::from(vec![ + Span::styled("Connecting to ", Style::default().fg(Color::DarkGray)), + Span::styled(&self.endpoint, Style::default().fg(Color::White)), + Span::raw("..."), + ]) + } else { + Line::from(vec![Span::styled( + "Not connected", + Style::default().fg(Color::Red), + )]) + }; + + let info = Paragraph::new(status); + frame.render_widget(info, area); + } + + /// Draw tabs for switching between Disks and Volumes + fn draw_tabs(&self, frame: &mut Frame, area: Rect) { + let tabs = Line::from(vec![ + Span::raw(" "), + if self.view_mode == InsecureViewMode::Disks { + Span::styled( + " Disks ", + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + } else { + Span::styled(" Disks ", Style::default().fg(Color::DarkGray)) + }, + Span::raw(" "), + if self.view_mode == InsecureViewMode::Volumes { + Span::styled( + " Volumes ", + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + } else { + Span::styled(" Volumes ", Style::default().fg(Color::DarkGray)) + }, + Span::raw(" "), + Span::styled("[Tab]", Style::default().fg(Color::DarkGray)), + Span::styled(" switch view", Style::default().fg(Color::DarkGray)), + ]); + + frame.render_widget(Paragraph::new(tabs), area); + } + + /// Draw disk table + fn draw_disks(&mut self, frame: &mut Frame, area: Rect) { + let data = self.data(); + let empty_disks: Vec = vec![]; + let disks = data.map(|d| &d.disks).unwrap_or(&empty_disks); + + let header = Row::new(vec![ + Cell::from("Device").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Size").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Type").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Transport").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Model").style(Style::default().add_modifier(Modifier::BOLD)), + ]) + .height(1) + .style(Style::default().fg(Color::Cyan)); + + let rows: Vec = disks + .iter() + .map(|disk| { + let disk_type = if disk.readonly { + ("CD-ROM", Color::Magenta) + } else if disk.rotational { + ("HDD", Color::Yellow) + } else { + ("SSD", Color::Green) + }; + + Row::new(vec![ + Cell::from(disk.dev_path.clone()), + Cell::from(disk.size_pretty.clone()), + Cell::from(disk_type.0).style(Style::default().fg(disk_type.1)), + Cell::from(disk.transport.clone().unwrap_or_default()), + Cell::from(disk.model.clone().unwrap_or_default()), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(15), + Constraint::Length(10), + Constraint::Length(8), + Constraint::Length(12), + Constraint::Fill(1), + ], + ) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Disks ({}) ", disks.len())), + ) + .row_highlight_style(Style::default().bg(Color::DarkGray)); + + frame.render_stateful_widget(table, area, &mut self.disk_table_state); + } + + /// Draw volume table + fn draw_volumes(&mut self, frame: &mut Frame, area: Rect) { + let data = self.data(); + let empty_volumes: Vec = vec![]; + let volumes = data.map(|d| &d.volumes).unwrap_or(&empty_volumes); + + let header = Row::new(vec![ + Cell::from("Volume").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Size").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Phase").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Location").style(Style::default().add_modifier(Modifier::BOLD)), + ]) + .height(1) + .style(Style::default().fg(Color::Cyan)); + + let rows: Vec = volumes + .iter() + .map(|vol| { + let phase_color = match vol.phase.as_str() { + "ready" => Color::Green, + "waiting" => Color::Yellow, + _ => Color::Red, + }; + + Row::new(vec![ + Cell::from(vol.id.clone()), + Cell::from(vol.size.clone()), + Cell::from(vol.phase.clone()).style(Style::default().fg(phase_color)), + Cell::from(vol.mount_location.clone().unwrap_or_default()), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(20), + Constraint::Length(12), + Constraint::Length(10), + Constraint::Fill(1), + ], + ) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Volumes ({}) ", volumes.len())), + ) + .row_highlight_style(Style::default().bg(Color::DarkGray)); + + frame.render_stateful_widget(table, area, &mut self.volume_table_state); + } + + /// Draw help text + fn draw_help(&self, frame: &mut Frame, area: Rect) { + let help = Line::from(vec![ + Span::styled(" [Tab] ", Style::default().fg(Color::Cyan)), + Span::raw("Switch view"), + Span::raw(" "), + Span::styled(" [↑/↓] ", Style::default().fg(Color::Cyan)), + Span::raw("Navigate"), + Span::raw(" "), + Span::styled(" [r] ", Style::default().fg(Color::Cyan)), + Span::raw("Refresh"), + Span::raw(" "), + Span::styled(" [q] ", Style::default().fg(Color::Cyan)), + Span::raw("Quit"), + ]); + + frame.render_widget(Paragraph::new(help), area); + } +} + +impl Component for InsecureComponent { + fn handle_key_event(&mut self, key: KeyEvent) -> Result> { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => Ok(Some(Action::Quit)), + KeyCode::Char('r') => Ok(Some(Action::Refresh)), + KeyCode::Tab => { + self.view_mode = self.view_mode.next(); + Ok(None) + } + KeyCode::Up | KeyCode::Char('k') => { + self.select_prev(); + Ok(None) + } + KeyCode::Down | KeyCode::Char('j') => { + self.select_next(); + Ok(None) + } + _ => Ok(None), + } + } + + fn update(&mut self, _action: Action) -> Result> { + // No tick-based updates needed for insecure mode + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + // Layout: warning banner, connection info, tabs, content, help + let layout = Layout::vertical([ + Constraint::Length(1), // Warning banner + Constraint::Length(1), // Connection info + Constraint::Length(1), // Tabs + Constraint::Fill(1), // Content + Constraint::Length(1), // Help + ]) + .split(area); + + // Draw warning banner + self.draw_warning_banner(frame, layout[0]); + + // Draw connection info + self.draw_connection_info(frame, layout[1]); + + // Draw tabs + self.draw_tabs(frame, layout[2]); + + // Draw content based on state + if self.state.is_loading() && !self.state.has_data() { + let loading = Paragraph::new("Connecting...") + .style(Style::default().fg(Color::Yellow)) + .block(Block::default().borders(Borders::ALL)); + frame.render_widget(loading, layout[3]); + } else if let Some(error) = self.state.error() { + let error_widget = Paragraph::new(error.to_string()) + .style(Style::default().fg(Color::Red)) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Error ") + .border_style(Style::default().fg(Color::Red)), + ); + frame.render_widget(error_widget, layout[3]); + } else { + match self.view_mode { + InsecureViewMode::Disks => self.draw_disks(frame, layout[3]), + InsecureViewMode::Volumes => self.draw_volumes(frame, layout[3]), + } + } + + // Draw help + self.draw_help(frame, layout[4]); + + Ok(()) + } +} diff --git a/crates/talos-pilot-tui/src/components/mod.rs b/crates/talos-pilot-tui/src/components/mod.rs index 21e69fb..16505c2 100644 --- a/crates/talos-pilot-tui/src/components/mod.rs +++ b/crates/talos-pilot-tui/src/components/mod.rs @@ -6,6 +6,7 @@ pub mod cluster; pub mod diagnostics; pub mod etcd; pub mod home; +pub mod insecure; pub mod lifecycle; pub mod logs; pub mod multi_logs; @@ -21,6 +22,7 @@ pub use cluster::ClusterComponent; pub use diagnostics::DiagnosticsComponent; pub use etcd::EtcdComponent; pub use home::HomeComponent; +pub use insecure::InsecureComponent; pub use lifecycle::LifecycleComponent; pub use logs::LogsComponent; pub use multi_logs::MultiLogsComponent; diff --git a/crates/talos-rs/src/lib.rs b/crates/talos-rs/src/lib.rs index 3196d09..113d7f8 100644 --- a/crates/talos-rs/src/lib.rs +++ b/crates/talos-rs/src/lib.rs @@ -107,8 +107,10 @@ pub use client::{ pub use config::{Context, TalosConfig}; pub use error::TalosError; pub use talosctl::{ - AddressStatus, DiscoveryMember, DiskInfo, KubeSpanPeerStatus, MachineConfigInfo, VolumeStatus, - get_address_status, get_discovery_members, get_discovery_members_for_context, get_disks, - get_disks_for_context, get_disks_for_node, get_kubespan_peers, get_machine_config, - get_volume_status, get_volume_status_for_node, is_kubespan_enabled, + AddressStatus, DiscoveryMember, DiskInfo, InsecureVersionInfo, KubeSpanPeerStatus, + MachineConfigInfo, VolumeStatus, check_insecure_connection, get_address_status, + get_discovery_members, get_discovery_members_for_context, get_disks, get_disks_for_context, + get_disks_for_node, get_disks_insecure, get_kubespan_peers, get_machine_config, + get_version_insecure, get_volume_status, get_volume_status_for_node, + get_volume_status_insecure, is_kubespan_enabled, }; diff --git a/crates/talos-rs/src/talosctl.rs b/crates/talos-rs/src/talosctl.rs index 5457d35..9b61109 100644 --- a/crates/talos-rs/src/talosctl.rs +++ b/crates/talos-rs/src/talosctl.rs @@ -264,6 +264,90 @@ pub async fn get_disks_for_context( parse_disks_yaml(&output) } +// ============================================================================ +// Insecure Mode Functions +// ============================================================================ +// These functions connect to Talos nodes without TLS client certificates. +// Used for maintenance mode nodes that haven't been bootstrapped yet. + +/// Get disks from a node in insecure mode (no TLS client auth) +/// +/// Executes: talosctl get disks --insecure -n -o yaml +/// +/// This is useful for pre-bootstrap scenarios where you want to see +/// what disks are available before writing the machine configuration. +pub async fn get_disks_insecure(endpoint: &str) -> Result, TalosError> { + let output = + exec_talosctl_async(&["get", "disks", "--insecure", "-n", endpoint, "-o", "yaml"]).await?; + parse_disks_yaml(&output) +} + +/// Get volume status from a node in insecure mode (no TLS client auth) +/// +/// Executes: talosctl get volumestatus --insecure -n -o yaml +pub async fn get_volume_status_insecure(endpoint: &str) -> Result, TalosError> { + let output = + exec_talosctl_async(&["get", "volumestatus", "--insecure", "-n", endpoint, "-o", "yaml"]) + .await?; + parse_volume_status_yaml(&output) +} + +/// Version info returned from insecure mode +#[derive(Debug, Clone)] +pub struct InsecureVersionInfo { + /// Talos version tag (e.g., "v1.12.1") + pub tag: String, + /// Whether the node is in maintenance mode + pub maintenance_mode: bool, +} + +/// Get version info from a node in insecure mode (no TLS client auth) +/// +/// Executes: talosctl version --insecure -n +/// +/// Note: In maintenance mode, the full version API is not available, +/// so this may return limited info or fail gracefully. +pub async fn get_version_insecure(endpoint: &str) -> Result { + let output = exec_talosctl_async(&["version", "--insecure", "-n", endpoint]).await; + + match output { + Ok(text) => { + // Parse version output - look for "Tag:" line + let tag = text + .lines() + .find(|l| l.trim().starts_with("Tag:")) + .and_then(|l| l.split(':').nth(1)) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + Ok(InsecureVersionInfo { + tag, + maintenance_mode: false, + }) + } + Err(e) => { + // Check if it's a "not implemented in maintenance mode" error + let err_str = e.to_string(); + if err_str.contains("maintenance mode") || err_str.contains("not implemented") { + Ok(InsecureVersionInfo { + tag: "unknown".to_string(), + maintenance_mode: true, + }) + } else { + Err(e) + } + } + } +} + +/// Check if a node is reachable in insecure mode +/// +/// Returns true if we can connect to the maintenance API +pub async fn check_insecure_connection(endpoint: &str) -> bool { + // Try to get disks - this works in maintenance mode + get_disks_insecure(endpoint).await.is_ok() +} + /// Get machine config info for a node /// /// Executes: talosctl get machineconfig --nodes -o yaml diff --git a/src/main.rs b/src/main.rs index 8c8bc82..24cc744 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,14 @@ struct Cli { /// Number of log lines to fetch (default: 500) #[arg(short, long, default_value = "500")] tail: i32, + + /// Connect without TLS client certificates (for maintenance mode nodes) + #[arg(short, long)] + insecure: bool, + + /// Endpoint to connect to in insecure mode (e.g., 192.168.1.100 or 192.168.1.100:50000) + #[arg(short, long, requires = "insecure")] + endpoint: Option, } #[tokio::main] @@ -57,15 +65,35 @@ async fn main() -> Result<()> { tracing::info!("Starting talos-pilot"); - if let Some(ctx) = &cli.context { - tracing::info!("Using context: {}", ctx); + // Validate insecure mode requires endpoint + if cli.insecure && cli.endpoint.is_none() { + eprintln!("Error: --insecure requires --endpoint "); + eprintln!("Usage: talos-pilot --insecure --endpoint 192.168.1.100"); + std::process::exit(1); } - if let Some(cfg) = &cli.config { - tracing::info!("Using config: {}", cfg); + + if cli.insecure { + tracing::info!("Insecure mode enabled"); + if let Some(ep) = &cli.endpoint { + tracing::info!("Endpoint: {}", ep); + } + } else { + if let Some(ctx) = &cli.context { + tracing::info!("Using context: {}", ctx); + } + if let Some(cfg) = &cli.config { + tracing::info!("Using config: {}", cfg); + } } - // Run the TUI with the specified config, context and tail limit - let mut app = App::new(cli.config, cli.context, cli.tail); + // Run the TUI + let mut app = App::new( + cli.config, + cli.context, + cli.tail, + cli.insecure, + cli.endpoint, + ); app.run().await?; tracing::info!("Goodbye!"); From 6f37e860a28beb9029ce26ea6015beb550ff4770 Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Fri, 16 Jan 2026 13:18:45 -0500 Subject: [PATCH 6/8] feat: wizard for bootstrapping chore: juice --- crates/talos-pilot-tui/src/action.rs | 20 + crates/talos-pilot-tui/src/app.rs | 305 +++- .../src/components/insecure.rs | 620 +++++++- crates/talos-pilot-tui/src/components/mod.rs | 2 + .../talos-pilot-tui/src/components/wizard.rs | 1328 +++++++++++++++++ crates/talos-rs/src/lib.rs | 7 +- crates/talos-rs/src/talosctl.rs | 126 +- test-clusters/scripts/test-cluster-qemu.sh | 209 ++- 8 files changed, 2521 insertions(+), 96 deletions(-) create mode 100644 crates/talos-pilot-tui/src/components/wizard.rs diff --git a/crates/talos-pilot-tui/src/action.rs b/crates/talos-pilot-tui/src/action.rs index cc8d7c0..26ef1f3 100644 --- a/crates/talos-pilot-tui/src/action.rs +++ b/crates/talos-pilot-tui/src/action.rs @@ -59,4 +59,24 @@ pub enum Action { // Effects StartFadeIn, StartFadeOut, + + // Insecure mode actions (legacy) + /// Generate Talos config: (cluster_name, k8s_endpoint, output_dir) + InsecureGenConfig(String, String, String), + /// Apply config to node: (config_path) + InsecureApplyConfig(String), + + // Wizard actions + /// Generate config in wizard + WizardGenConfig, + /// Apply config in wizard + WizardApplyConfig, + /// Bootstrap cluster in wizard + WizardBootstrap, + /// Retry after error in wizard + WizardRetry, + /// Wizard complete - transition to secure mode (context_name) + WizardComplete(Option), + /// Tick for wizard polling + WizardTick, } diff --git a/crates/talos-pilot-tui/src/app.rs b/crates/talos-pilot-tui/src/app.rs index 6b32650..80b2052 100644 --- a/crates/talos-pilot-tui/src/app.rs +++ b/crates/talos-pilot-tui/src/app.rs @@ -2,11 +2,11 @@ use crate::action::Action; use crate::components::rolling_operations::RollingNodeInfo; +use crate::components::wizard::{WizardComponent, WizardState}; use crate::components::{ - ClusterComponent, Component, DiagnosticsComponent, EtcdComponent, InsecureComponent, - LifecycleComponent, MultiLogsComponent, NetworkStatsComponent, NodeOperationsComponent, - ProcessesComponent, RollingOperationsComponent, SecurityComponent, StorageComponent, - WorkloadHealthComponent, + ClusterComponent, Component, DiagnosticsComponent, EtcdComponent, LifecycleComponent, + MultiLogsComponent, NetworkStatsComponent, NodeOperationsComponent, ProcessesComponent, + RollingOperationsComponent, SecurityComponent, StorageComponent, WorkloadHealthComponent, }; use crate::tui::{self, Tui}; use color_eyre::Result; @@ -148,35 +148,65 @@ impl App { result } - /// Insecure mode event loop - simplified for maintenance mode nodes + /// Insecure mode event loop - Bootstrap Wizard async fn insecure_loop(&mut self, terminal: &mut Tui) -> Result<()> { let endpoint = self .insecure_endpoint .clone() .expect("Insecure mode requires endpoint"); - let mut insecure = InsecureComponent::new(endpoint); + let mut wizard = WizardComponent::new(endpoint); // Connect on startup - insecure.connect().await?; + wizard.connect().await?; + + // Polling interval for wait states + let poll_interval = Duration::from_secs(5); + let mut last_poll = std::time::Instant::now(); + + // Spinner animation interval + let spinner_interval = Duration::from_millis(100); + let mut last_spinner = std::time::Instant::now(); loop { + // Advance spinner for animations + if last_spinner.elapsed() >= spinner_interval { + last_spinner = std::time::Instant::now(); + wizard.data_mut().advance_spinner(); + } + // Draw terminal.draw(|frame| { - let _ = insecure.draw(frame, frame.area()); + let _ = wizard.draw(frame, frame.area()); })?; // Handle events with timeout if event::poll(self.tick_rate)? { match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => { - if let Some(action) = insecure.handle_key_event(key)? { + if let Some(action) = wizard.handle_key_event(key)? { match action { Action::Quit => { self.should_quit = true; } - Action::Refresh => { - insecure.refresh().await?; + Action::WizardGenConfig => { + self.wizard_generate_config(&mut wizard).await; + } + Action::WizardApplyConfig => { + self.wizard_apply_config(&mut wizard).await; + } + Action::WizardBootstrap => { + self.wizard_bootstrap(&mut wizard).await; + } + Action::WizardRetry => { + wizard.connect().await?; + } + Action::WizardComplete(context) => { + // Exit wizard - print instructions + self.should_quit = true; + if let Some(ctx) = context { + tracing::info!("Wizard complete. Context: {}", ctx); + } } _ => {} } @@ -189,6 +219,12 @@ impl App { } } + // Polling for wait states + if last_poll.elapsed() >= poll_interval { + last_poll = std::time::Instant::now(); + self.wizard_poll(&mut wizard).await; + } + if self.should_quit { break; } @@ -197,6 +233,253 @@ impl App { Ok(()) } + /// Generate config in wizard + async fn wizard_generate_config(&self, wizard: &mut WizardComponent) { + use talos_rs::gen_config; + + // Extract data we need before any mutations + let cluster_name = wizard.data().cluster_name.clone(); + let k8s_endpoint = wizard.data().k8s_endpoint.clone(); + let output_dir = wizard.data().output_dir.clone(); + let endpoint = wizard.data().endpoint.clone(); + // TODO: Use disk with gen_config_with_disk when --install-disk flag support is added + let _disk = wizard + .data() + .selected_disk + .as_ref() + .map(|d| d.dev_path.clone()); + + // Build additional SANs + let sans: Vec<&str> = vec![&endpoint, "127.0.0.1"]; + + // Generate config + // Note: For now we use gen_config without disk selection + // TODO: Add gen_config_with_disk that uses --install-disk flag + match gen_config(&cluster_name, &k8s_endpoint, &output_dir, Some(&sans), true).await { + Ok(result) => { + // Merge talosconfig and set endpoint/node + let merge_success = self + .wizard_merge_config(&result.talosconfig_path, &cluster_name, &endpoint) + .await; + + if merge_success { + wizard.data_mut().config_result = Some(result); + wizard.data_mut().context_name = Some(cluster_name); + wizard.transition(WizardState::ConfigReady); + } else { + wizard.set_error("Failed to merge talosconfig".to_string()); + } + } + Err(e) => { + wizard.set_error(format!("Failed to generate config: {}", e)); + } + } + } + + /// Merge talosconfig into user's config + async fn wizard_merge_config( + &self, + config_path: &str, + context_name: &str, + endpoint: &str, + ) -> bool { + use tokio::process::Command; + + // Run: talosctl config merge + let merge_output = Command::new("talosctl") + .args(["config", "merge", config_path]) + .output() + .await; + + if let Err(e) = &merge_output { + tracing::warn!("Failed to run talosctl config merge: {}", e); + return false; + } + + if let Ok(out) = &merge_output { + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + tracing::warn!("Failed to merge config: {}", stderr); + return false; + } + } + + // Set the endpoint on the context + let endpoint_output = Command::new("talosctl") + .args(["--context", context_name, "config", "endpoint", endpoint]) + .output() + .await; + + if let Err(e) = &endpoint_output { + tracing::warn!("Failed to set endpoint: {}", e); + return false; + } + + // Set the node on the context + let node_output = Command::new("talosctl") + .args(["--context", context_name, "config", "node", endpoint]) + .output() + .await; + + if let Err(e) = &node_output { + tracing::warn!("Failed to set node: {}", e); + return false; + } + + true + } + + /// Apply config in wizard + async fn wizard_apply_config(&self, wizard: &mut WizardComponent) { + use std::time::Instant; + use talos_rs::apply_config_insecure; + + wizard.transition(WizardState::Applying); + + // Extract values we need before mutating + let endpoint = wizard.data().endpoint.clone(); + let cluster_name = wizard.data().cluster_name.clone(); + let config_path = { + let data = wizard.data(); + data.config_result.as_ref().map(|r| match data.node_type { + crate::components::wizard::NodeType::Controlplane => r.controlplane_path.clone(), + crate::components::wizard::NodeType::Worker => r.worker_path.clone(), + }) + }; + + if let Some(path) = config_path { + match apply_config_insecure(&endpoint, &path).await { + Ok(result) => { + if result.success { + // Start waiting for reboot + wizard.data_mut().wait_started = Some(Instant::now()); + wizard.data_mut().context_name = Some(cluster_name); + wizard.transition(WizardState::WaitingReboot); + } else { + wizard.set_error(result.message); + } + } + Err(e) => { + wizard.set_error(format!("Failed to apply config: {}", e)); + } + } + } else { + wizard.set_error("No config generated".to_string()); + } + } + + /// Bootstrap cluster in wizard + async fn wizard_bootstrap(&self, wizard: &mut WizardComponent) { + use std::time::Instant; + use tokio::process::Command; + + wizard.transition(WizardState::Bootstrapping); + + let context = wizard.data().context_name.clone(); + + if let Some(ctx) = context { + let output = Command::new("talosctl") + .args(["--context", &ctx, "bootstrap"]) + .output() + .await; + + match output { + Ok(out) if out.status.success() => { + wizard.data_mut().wait_started = Some(Instant::now()); + wizard.transition(WizardState::WaitingHealthy); + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + wizard.set_error(format!("Bootstrap failed: {}", stderr)); + } + Err(e) => { + wizard.set_error(format!("Failed to run bootstrap: {}", e)); + } + } + } else { + wizard.set_error("No context available for bootstrap".to_string()); + } + } + + /// Poll for state changes in wait states + async fn wizard_poll(&self, wizard: &mut WizardComponent) { + use tokio::process::Command; + + match wizard.state() { + WizardState::WaitingReboot => { + // Increment poll attempts + wizard.data_mut().poll_attempts += 1; + + // Check if node is back online (with TLS) + if let Some(ctx) = wizard.data().context_name.clone() { + let output = Command::new("talosctl") + .args(["--context", &ctx, "version"]) + .output() + .await; + + match output { + Ok(out) if out.status.success() => { + wizard.data_mut().last_poll_error = None; + wizard.transition(WizardState::ReadyToBootstrap); + } + Ok(out) => { + // Command ran but failed - capture error + let stderr = String::from_utf8_lossy(&out.stderr); + wizard.data_mut().last_poll_error = + Some(stderr.lines().next().unwrap_or("Unknown error").to_string()); + } + Err(e) => { + wizard.data_mut().last_poll_error = Some(e.to_string()); + } + } + } + + // Check for timeout (5 minutes) + if let Some(started) = wizard.data().wait_started + && started.elapsed().as_secs() > 300 + { + wizard.set_error("Timeout waiting for node to reboot".to_string()); + } + } + WizardState::WaitingHealthy => { + // Increment poll attempts + wizard.data_mut().poll_attempts += 1; + + // Check if cluster is healthy + if let Some(ctx) = wizard.data().context_name.clone() { + // Check etcd health + let output = Command::new("talosctl") + .args(["--context", &ctx, "etcd", "status"]) + .output() + .await; + + match output { + Ok(out) if out.status.success() => { + wizard.data_mut().last_poll_error = None; + wizard.transition(WizardState::Complete); + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + wizard.data_mut().last_poll_error = + Some(stderr.lines().next().unwrap_or("Unknown error").to_string()); + } + Err(e) => { + wizard.data_mut().last_poll_error = Some(e.to_string()); + } + } + } + + // Check for timeout (5 minutes) + if let Some(started) = wizard.data().wait_started + && started.elapsed().as_secs() > 300 + { + wizard.set_error("Timeout waiting for cluster to become healthy".to_string()); + } + } + _ => {} + } + } + /// Main event loop async fn main_loop(&mut self, terminal: &mut Tui) -> Result<()> { // Connect on startup diff --git a/crates/talos-pilot-tui/src/components/insecure.rs b/crates/talos-pilot-tui/src/components/insecure.rs index 9e11b2b..4bf8f84 100644 --- a/crates/talos-pilot-tui/src/components/insecure.rs +++ b/crates/talos-pilot-tui/src/components/insecure.rs @@ -2,7 +2,7 @@ //! //! This component provides a simplified UI for nodes that haven't been //! bootstrapped yet. It shows disk and volume information without requiring -//! TLS client certificates. +//! TLS client certificates, and supports generating/applying machine configs. use crate::action::Action; use crate::components::Component; @@ -13,12 +13,12 @@ use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}, + widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState}, }; use talos_pilot_core::AsyncState; use talos_rs::{ - DiskInfo, InsecureVersionInfo, VolumeStatus, get_disks_insecure, get_version_insecure, - get_volume_status_insecure, + DiskInfo, GenConfigResult, InsecureVersionInfo, VolumeStatus, apply_config_insecure, + gen_config, get_disks_insecure, get_version_insecure, get_volume_status_insecure, }; /// View mode for the insecure component @@ -36,13 +36,73 @@ impl InsecureViewMode { InsecureViewMode::Volumes => InsecureViewMode::Disks, } } +} - pub fn label(&self) -> &'static str { +/// Dialog mode for input +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum DialogMode { + #[default] + None, + /// Generate config dialog with fields: cluster_name, k8s_endpoint, output_dir + GenerateConfig { + cluster_name: String, + k8s_endpoint: String, + output_dir: String, + active_field: usize, // 0=cluster_name, 1=k8s_endpoint, 2=output_dir + }, + /// Apply config dialog with field: config_path + ApplyConfig { + config_path: String, + node_type: NodeType, // controlplane or worker + }, + /// Show result of an operation + ShowResult { + title: String, + message: String, + success: bool, + }, + /// Confirm action + Confirm { + title: String, + message: String, + action: ConfirmAction, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NodeType { + #[default] + Controlplane, + Worker, +} + +impl NodeType { + fn toggle(&self) -> Self { match self { - InsecureViewMode::Disks => "Disks", - InsecureViewMode::Volumes => "Volumes", + NodeType::Controlplane => NodeType::Worker, + NodeType::Worker => NodeType::Controlplane, } } + + fn config_filename(&self) -> &'static str { + match self { + NodeType::Controlplane => "controlplane.yaml", + NodeType::Worker => "worker.yaml", + } + } + + #[allow(dead_code)] + fn label(&self) -> &'static str { + match self { + NodeType::Controlplane => "Control Plane", + NodeType::Worker => "Worker", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfirmAction { + ApplyConfig(String), // config_path } /// Data loaded in insecure mode @@ -71,11 +131,17 @@ pub struct InsecureComponent { /// Current view mode (Disks or Volumes) view_mode: InsecureViewMode, + /// Current dialog mode + dialog_mode: DialogMode, + /// Table state for disk list disk_table_state: TableState, /// Table state for volume list volume_table_state: TableState, + + /// Last generated config result (for apply default path) + last_gen_result: Option, } impl InsecureComponent { @@ -94,21 +160,19 @@ impl InsecureComponent { state: AsyncState::with_data(initial_data), endpoint, view_mode: InsecureViewMode::Disks, + dialog_mode: DialogMode::None, disk_table_state, volume_table_state, + last_gen_result: None, } } /// Extract just the IP/hostname from endpoint (strip port if present) fn endpoint_for_talosctl(endpoint: &str) -> String { - // talosctl -n expects just IP, not IP:port if let Some(idx) = endpoint.rfind(':') { - // Check if this is an IPv6 address (contains multiple colons) if endpoint.matches(':').count() > 1 { - // IPv6 - return as-is (talosctl handles it) endpoint.to_string() } else { - // IPv4 with port - strip the port endpoint[..idx].to_string() } } else { @@ -116,6 +180,11 @@ impl InsecureComponent { } } + /// Get the endpoint IP for use in defaults + fn endpoint_ip(&self) -> String { + Self::endpoint_for_talosctl(&self.endpoint) + } + /// Connect and load data pub async fn connect(&mut self) -> Result<()> { self.state.start_loading(); @@ -126,7 +195,6 @@ impl InsecureComponent { ..Default::default() }; - // Try to get version info (may fail in maintenance mode) match get_version_insecure(&endpoint).await { Ok(version) => { data.version = Some(version); @@ -136,20 +204,17 @@ impl InsecureComponent { } } - // Fetch disks match get_disks_insecure(&endpoint).await { Ok(disks) => { data.disks = disks; data.connected = true; } Err(e) => { - self.state - .set_error(format!("Failed to connect: {}", e)); + self.state.set_error(format!("Failed to connect: {}", e)); return Ok(()); } } - // Fetch volumes match get_volume_status_insecure(&endpoint).await { Ok(volumes) => { data.volumes = volumes; @@ -168,22 +233,105 @@ impl InsecureComponent { self.connect().await } - /// Helper to get data reference + /// Generate config with the provided parameters + pub async fn do_generate_config( + &mut self, + cluster_name: &str, + k8s_endpoint: &str, + output_dir: &str, + ) { + let endpoint_ip = self.endpoint_ip(); + let sans: Vec<&str> = vec![&endpoint_ip, "127.0.0.1"]; + + match gen_config(cluster_name, k8s_endpoint, output_dir, Some(&sans), true).await { + Ok(result) => { + self.last_gen_result = Some(result.clone()); + self.dialog_mode = DialogMode::ShowResult { + title: "Config Generated".to_string(), + message: format!( + "Generated configuration files:\n\n\ + - {}\n\ + - {}\n\ + - {}\n\n\ + Press 'a' to apply the controlplane config to this node.", + result.controlplane_path, result.worker_path, result.talosconfig_path + ), + success: true, + }; + } + Err(e) => { + self.dialog_mode = DialogMode::ShowResult { + title: "Generation Failed".to_string(), + message: format!("Failed to generate config:\n\n{}", e), + success: false, + }; + } + } + } + + /// Apply config to the node + pub async fn do_apply_config(&mut self, config_path: &str) { + let endpoint = self.endpoint_ip(); + + match apply_config_insecure(&endpoint, config_path).await { + Ok(result) => { + self.dialog_mode = DialogMode::ShowResult { + title: if result.success { + "Config Applied".to_string() + } else { + "Apply Failed".to_string() + }, + message: result.message, + success: result.success, + }; + } + Err(e) => { + self.dialog_mode = DialogMode::ShowResult { + title: "Apply Failed".to_string(), + message: format!("Failed to apply config:\n\n{}", e), + success: false, + }; + } + } + } + + /// Open generate config dialog with smart defaults + fn open_generate_dialog(&mut self) { + let endpoint_ip = self.endpoint_ip(); + self.dialog_mode = DialogMode::GenerateConfig { + cluster_name: "talos-cluster".to_string(), + k8s_endpoint: format!("https://{}:6443", endpoint_ip), + output_dir: ".".to_string(), + active_field: 0, + }; + } + + /// Open apply config dialog with smart defaults + fn open_apply_dialog(&mut self) { + let default_path = self + .last_gen_result + .as_ref() + .map(|r| r.controlplane_path.clone()) + .unwrap_or_else(|| "./controlplane.yaml".to_string()); + + self.dialog_mode = DialogMode::ApplyConfig { + config_path: default_path, + node_type: NodeType::Controlplane, + }; + } + fn data(&self) -> Option<&InsecureData> { self.state.data() } - /// Get selected disk index fn selected_disk_index(&self) -> usize { self.disk_table_state.selected().unwrap_or(0) } - /// Get selected volume index fn selected_volume_index(&self) -> usize { self.volume_table_state.selected().unwrap_or(0) } - /// Move selection up fn select_prev(&mut self) { match self.view_mode { InsecureViewMode::Disks => { @@ -211,7 +359,6 @@ impl InsecureComponent { } } - /// Move selection down fn select_next(&mut self) { match self.view_mode { InsecureViewMode::Disks => { @@ -235,25 +382,160 @@ impl InsecureComponent { } } - /// Draw the warning banner + /// Handle key events in dialog mode + fn handle_dialog_key(&mut self, key: KeyEvent) -> Option { + match &mut self.dialog_mode { + DialogMode::None => None, + + DialogMode::GenerateConfig { + cluster_name, + k8s_endpoint, + output_dir, + active_field, + } => { + match key.code { + KeyCode::Esc => { + self.dialog_mode = DialogMode::None; + None + } + KeyCode::Tab | KeyCode::Down => { + *active_field = (*active_field + 1) % 3; + None + } + KeyCode::BackTab | KeyCode::Up => { + *active_field = if *active_field == 0 { + 2 + } else { + *active_field - 1 + }; + None + } + KeyCode::Enter => { + // Trigger generate action + Some(Action::InsecureGenConfig( + cluster_name.clone(), + k8s_endpoint.clone(), + output_dir.clone(), + )) + } + KeyCode::Char(c) => { + let field = match *active_field { + 0 => cluster_name, + 1 => k8s_endpoint, + _ => output_dir, + }; + field.push(c); + None + } + KeyCode::Backspace => { + let field = match *active_field { + 0 => cluster_name, + 1 => k8s_endpoint, + _ => output_dir, + }; + field.pop(); + None + } + _ => None, + } + } + + DialogMode::ApplyConfig { + config_path, + node_type, + } => { + match key.code { + KeyCode::Esc => { + self.dialog_mode = DialogMode::None; + None + } + KeyCode::Tab => { + // Toggle between controlplane and worker + *node_type = node_type.toggle(); + // Update path based on type + if let Some(result) = &self.last_gen_result { + *config_path = match node_type { + NodeType::Controlplane => result.controlplane_path.clone(), + NodeType::Worker => result.worker_path.clone(), + }; + } else { + let dir = std::path::Path::new(config_path.as_str()) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()); + *config_path = format!("{}/{}", dir, node_type.config_filename()); + } + None + } + KeyCode::Enter => { + // Show confirmation + let path = config_path.clone(); + self.dialog_mode = DialogMode::Confirm { + title: "Apply Configuration?".to_string(), + message: format!( + "This will apply the configuration from:\n\n {}\n\n\ + The node will install Talos to disk and REBOOT.\n\n\ + Press Enter to confirm, Esc to cancel.", + path + ), + action: ConfirmAction::ApplyConfig(path), + }; + None + } + KeyCode::Char(c) => { + config_path.push(c); + None + } + KeyCode::Backspace => { + config_path.pop(); + None + } + _ => None, + } + } + + DialogMode::ShowResult { .. } => match key.code { + KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q') => { + self.dialog_mode = DialogMode::None; + None + } + _ => None, + }, + + DialogMode::Confirm { action, .. } => match key.code { + KeyCode::Enter => { + let action = action.clone(); + self.dialog_mode = DialogMode::None; + match action { + ConfirmAction::ApplyConfig(path) => Some(Action::InsecureApplyConfig(path)), + } + } + KeyCode::Esc => { + self.dialog_mode = DialogMode::None; + None + } + _ => None, + }, + } + } + fn draw_warning_banner(&self, frame: &mut Frame, area: Rect) { let warning = Paragraph::new(Line::from(vec![ Span::styled( - " ⚠ INSECURE MODE ", + " INSECURE MODE ", Style::default() .fg(Color::Black) .bg(Color::Yellow) .add_modifier(Modifier::BOLD), ), Span::raw(" Connected without TLS authentication - "), - Span::styled("Limited functionality", Style::default().fg(Color::Yellow)), + Span::styled("Maintenance Mode", Style::default().fg(Color::Yellow)), ])) .style(Style::default().bg(Color::DarkGray)); frame.render_widget(warning, area); } - /// Draw connection info fn draw_connection_info(&self, frame: &mut Frame, area: Rect) { let data = self.data(); @@ -274,7 +556,7 @@ impl InsecureComponent { Line::from(vec![ Span::styled("Endpoint: ", Style::default().fg(Color::DarkGray)), Span::styled(&d.endpoint, Style::default().fg(Color::White)), - Span::raw(" │ "), + Span::raw(" | "), Span::styled("Status: ", Style::default().fg(Color::DarkGray)), Span::styled(version_str, Style::default().fg(Color::Green)), ]) @@ -282,7 +564,7 @@ impl InsecureComponent { Line::from(vec![ Span::styled("Endpoint: ", Style::default().fg(Color::DarkGray)), Span::styled(&self.endpoint, Style::default().fg(Color::White)), - Span::raw(" │ "), + Span::raw(" | "), Span::styled("Status: ", Style::default().fg(Color::DarkGray)), Span::styled("Disconnected", Style::default().fg(Color::Red)), ]) @@ -304,7 +586,6 @@ impl InsecureComponent { frame.render_widget(info, area); } - /// Draw tabs for switching between Disks and Volumes fn draw_tabs(&self, frame: &mut Frame, area: Rect) { let tabs = Line::from(vec![ Span::raw(" "), @@ -333,13 +614,12 @@ impl InsecureComponent { }, Span::raw(" "), Span::styled("[Tab]", Style::default().fg(Color::DarkGray)), - Span::styled(" switch view", Style::default().fg(Color::DarkGray)), + Span::styled(" switch", Style::default().fg(Color::DarkGray)), ]); frame.render_widget(Paragraph::new(tabs), area); } - /// Draw disk table fn draw_disks(&mut self, frame: &mut Frame, area: Rect) { let data = self.data(); let empty_disks: Vec = vec![]; @@ -397,7 +677,6 @@ impl InsecureComponent { frame.render_stateful_widget(table, area, &mut self.disk_table_state); } - /// Draw volume table fn draw_volumes(&mut self, frame: &mut Frame, area: Rect) { let data = self.data(); let empty_volumes: Vec = vec![]; @@ -450,14 +729,16 @@ impl InsecureComponent { frame.render_stateful_widget(table, area, &mut self.volume_table_state); } - /// Draw help text fn draw_help(&self, frame: &mut Frame, area: Rect) { let help = Line::from(vec![ - Span::styled(" [Tab] ", Style::default().fg(Color::Cyan)), - Span::raw("Switch view"), + Span::styled(" [g] ", Style::default().fg(Color::Green)), + Span::raw("Generate Config"), + Span::raw(" "), + Span::styled(" [a] ", Style::default().fg(Color::Yellow)), + Span::raw("Apply Config"), Span::raw(" "), - Span::styled(" [↑/↓] ", Style::default().fg(Color::Cyan)), - Span::raw("Navigate"), + Span::styled(" [Tab] ", Style::default().fg(Color::Cyan)), + Span::raw("Switch"), Span::raw(" "), Span::styled(" [r] ", Style::default().fg(Color::Cyan)), Span::raw("Refresh"), @@ -468,13 +749,266 @@ impl InsecureComponent { frame.render_widget(Paragraph::new(help), area); } + + fn draw_dialog(&self, frame: &mut Frame, area: Rect) { + match &self.dialog_mode { + DialogMode::None => {} + + DialogMode::GenerateConfig { + cluster_name, + k8s_endpoint, + output_dir, + active_field, + } => { + let dialog_area = centered_rect(60, 14, area); + frame.render_widget(Clear, dialog_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)) + .title(" Generate Talos Config "); + + let inner = block.inner(dialog_area); + frame.render_widget(block, dialog_area); + + let layout = Layout::vertical([ + Constraint::Length(1), // Instructions + Constraint::Length(1), // Spacer + Constraint::Length(2), // Cluster name + Constraint::Length(2), // K8s endpoint + Constraint::Length(2), // Output dir + Constraint::Length(1), // Spacer + Constraint::Length(1), // Help + ]) + .split(inner); + + let instructions = + Paragraph::new("Enter configuration details (press Enter to generate):") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(instructions, layout[0]); + + // Cluster name field + let cluster_style = if *active_field == 0 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::White) + }; + let cluster_field = Paragraph::new(Line::from(vec![ + Span::styled("Cluster Name: ", Style::default().fg(Color::Cyan)), + Span::styled(cluster_name.as_str(), cluster_style), + if *active_field == 0 { + Span::styled("_", Style::default().fg(Color::Yellow)) + } else { + Span::raw("") + }, + ])); + frame.render_widget(cluster_field, layout[2]); + + // K8s endpoint field + let endpoint_style = if *active_field == 1 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::White) + }; + let endpoint_field = Paragraph::new(Line::from(vec![ + Span::styled("K8s Endpoint: ", Style::default().fg(Color::Cyan)), + Span::styled(k8s_endpoint.as_str(), endpoint_style), + if *active_field == 1 { + Span::styled("_", Style::default().fg(Color::Yellow)) + } else { + Span::raw("") + }, + ])); + frame.render_widget(endpoint_field, layout[3]); + + // Output dir field + let dir_style = if *active_field == 2 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::White) + }; + let dir_field = Paragraph::new(Line::from(vec![ + Span::styled("Output Dir: ", Style::default().fg(Color::Cyan)), + Span::styled(output_dir.as_str(), dir_style), + if *active_field == 2 { + Span::styled("_", Style::default().fg(Color::Yellow)) + } else { + Span::raw("") + }, + ])); + frame.render_widget(dir_field, layout[4]); + + let help = Paragraph::new("[Tab] Next field [Enter] Generate [Esc] Cancel") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(help, layout[6]); + } + + DialogMode::ApplyConfig { + config_path, + node_type, + } => { + let dialog_area = centered_rect(60, 10, area); + frame.render_widget(Clear, dialog_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(" Apply Configuration "); + + let inner = block.inner(dialog_area); + frame.render_widget(block, dialog_area); + + let layout = Layout::vertical([ + Constraint::Length(1), // Instructions + Constraint::Length(1), // Spacer + Constraint::Length(1), // Node type + Constraint::Length(2), // Config path + Constraint::Length(1), // Spacer + Constraint::Length(1), // Help + ]) + .split(inner); + + let instructions = Paragraph::new("Apply machine configuration to this node:") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(instructions, layout[0]); + + // Node type selector + let type_line = Paragraph::new(Line::from(vec![ + Span::styled("Node Type: ", Style::default().fg(Color::Cyan)), + if *node_type == NodeType::Controlplane { + Span::styled( + " Control Plane ", + Style::default().fg(Color::Black).bg(Color::Green), + ) + } else { + Span::styled(" Control Plane ", Style::default().fg(Color::DarkGray)) + }, + Span::raw(" "), + if *node_type == NodeType::Worker { + Span::styled( + " Worker ", + Style::default().fg(Color::Black).bg(Color::Blue), + ) + } else { + Span::styled(" Worker ", Style::default().fg(Color::DarkGray)) + }, + Span::styled(" [Tab to switch]", Style::default().fg(Color::DarkGray)), + ])); + frame.render_widget(type_line, layout[2]); + + // Config path field + let path_field = Paragraph::new(Line::from(vec![ + Span::styled("Config File: ", Style::default().fg(Color::Cyan)), + Span::styled(config_path.as_str(), Style::default().fg(Color::Yellow)), + Span::styled("_", Style::default().fg(Color::Yellow)), + ])); + frame.render_widget(path_field, layout[3]); + + let help = Paragraph::new("[Tab] Switch type [Enter] Apply [Esc] Cancel") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(help, layout[5]); + } + + DialogMode::ShowResult { + title, + message, + success, + } => { + let dialog_area = centered_rect(60, 12, area); + frame.render_widget(Clear, dialog_area); + + let border_color = if *success { Color::Green } else { Color::Red }; + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .title(format!(" {} ", title)); + + let inner = block.inner(dialog_area); + frame.render_widget(block, dialog_area); + + let layout = Layout::vertical([ + Constraint::Fill(1), // Message + Constraint::Length(1), // Help + ]) + .split(inner); + + let msg = Paragraph::new(message.as_str()).style(Style::default().fg(Color::White)); + frame.render_widget(msg, layout[0]); + + let help = Paragraph::new("[Enter] or [Esc] to close") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(help, layout[1]); + } + + DialogMode::Confirm { + title, + message, + action: _, + } => { + let dialog_area = centered_rect(60, 12, area); + frame.render_widget(Clear, dialog_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(format!(" {} ", title)); + + let inner = block.inner(dialog_area); + frame.render_widget(block, dialog_area); + + let layout = Layout::vertical([ + Constraint::Fill(1), // Message + Constraint::Length(1), // Help + ]) + .split(inner); + + let msg = Paragraph::new(message.as_str()).style(Style::default().fg(Color::White)); + frame.render_widget(msg, layout[0]); + + let help = Paragraph::new("[Enter] Confirm [Esc] Cancel") + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(help, layout[1]); + } + } + } +} + +/// Helper function to create a centered rectangle +fn centered_rect(percent_x: u16, height: u16, r: Rect) -> Rect { + let popup_layout = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(height), + Constraint::Fill(1), + ]) + .split(r); + + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] } impl Component for InsecureComponent { fn handle_key_event(&mut self, key: KeyEvent) -> Result> { + // If in dialog mode, handle dialog keys first + if self.dialog_mode != DialogMode::None { + return Ok(self.handle_dialog_key(key)); + } + + // Normal mode key handling match key.code { KeyCode::Char('q') | KeyCode::Esc => Ok(Some(Action::Quit)), KeyCode::Char('r') => Ok(Some(Action::Refresh)), + KeyCode::Char('g') => { + self.open_generate_dialog(); + Ok(None) + } + KeyCode::Char('a') => { + self.open_apply_dialog(); + Ok(None) + } KeyCode::Tab => { self.view_mode = self.view_mode.next(); Ok(None) @@ -492,12 +1026,10 @@ impl Component for InsecureComponent { } fn update(&mut self, _action: Action) -> Result> { - // No tick-based updates needed for insecure mode Ok(None) } fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { - // Layout: warning banner, connection info, tabs, content, help let layout = Layout::vertical([ Constraint::Length(1), // Warning banner Constraint::Length(1), // Connection info @@ -507,16 +1039,10 @@ impl Component for InsecureComponent { ]) .split(area); - // Draw warning banner self.draw_warning_banner(frame, layout[0]); - - // Draw connection info self.draw_connection_info(frame, layout[1]); - - // Draw tabs self.draw_tabs(frame, layout[2]); - // Draw content based on state if self.state.is_loading() && !self.state.has_data() { let loading = Paragraph::new("Connecting...") .style(Style::default().fg(Color::Yellow)) @@ -539,9 +1065,13 @@ impl Component for InsecureComponent { } } - // Draw help self.draw_help(frame, layout[4]); + // Draw dialog overlay if active + if self.dialog_mode != DialogMode::None { + self.draw_dialog(frame, area); + } + Ok(()) } } diff --git a/crates/talos-pilot-tui/src/components/mod.rs b/crates/talos-pilot-tui/src/components/mod.rs index 16505c2..91babe5 100644 --- a/crates/talos-pilot-tui/src/components/mod.rs +++ b/crates/talos-pilot-tui/src/components/mod.rs @@ -16,6 +16,7 @@ pub mod processes; pub mod rolling_operations; pub mod security; pub mod storage; +pub mod wizard; pub mod workloads; pub use cluster::ClusterComponent; @@ -32,6 +33,7 @@ pub use processes::ProcessesComponent; pub use rolling_operations::RollingOperationsComponent; pub use security::SecurityComponent; pub use storage::StorageComponent; +pub use wizard::WizardComponent; pub use workloads::WorkloadHealthComponent; use crate::action::Action; diff --git a/crates/talos-pilot-tui/src/components/wizard.rs b/crates/talos-pilot-tui/src/components/wizard.rs new file mode 100644 index 0000000..501d26e --- /dev/null +++ b/crates/talos-pilot-tui/src/components/wizard.rs @@ -0,0 +1,1328 @@ +//! Bootstrap Wizard - State machine driven bootstrap flow +//! +//! Guides users through the complete Talos node bootstrap process: +//! 1. Connect to maintenance mode node +//! 2. Select installation disk +//! 3. Configure cluster settings +//! 4. Generate and apply config +//! 5. Wait for reboot +//! 6. Bootstrap cluster +//! 7. Transition to secure mode + +use crate::action::Action; +use crate::components::Component; +use color_eyre::Result; +use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}, +}; +use std::time::Instant; +use talos_rs::{DiskInfo, GenConfigResult, VolumeStatus}; + +/// Wizard states +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WizardState { + /// Initial connection to maintenance mode node + Connecting, + /// User selects which disk to install Talos onto + SelectDisk, + /// User configures cluster settings + ConfigureCluster, + /// Config has been generated, ready to apply + ConfigReady, + /// Applying configuration to node + Applying, + /// Waiting for node to reboot after install + WaitingReboot, + /// Node is up, ready for bootstrap + ReadyToBootstrap, + /// Running bootstrap command + Bootstrapping, + /// Waiting for cluster to become healthy + WaitingHealthy, + /// Bootstrap complete, ready to transition + Complete, + /// Error state with message + Error(String), +} + +impl WizardState { + /// Get the step number (1-based) for display + pub fn step_number(&self) -> usize { + match self { + WizardState::Connecting => 1, + WizardState::SelectDisk => 2, + WizardState::ConfigureCluster => 3, + WizardState::ConfigReady => 4, + WizardState::Applying => 5, + WizardState::WaitingReboot => 6, + WizardState::ReadyToBootstrap => 7, + WizardState::Bootstrapping => 8, + WizardState::WaitingHealthy => 9, + WizardState::Complete => 10, + WizardState::Error(_) => 0, + } + } + + /// Get the total number of steps + pub fn total_steps() -> usize { + 10 + } + + /// Get the step title for display + pub fn title(&self) -> &'static str { + match self { + WizardState::Connecting => "Connecting", + WizardState::SelectDisk => "Select Installation Disk", + WizardState::ConfigureCluster => "Configure Cluster", + WizardState::ConfigReady => "Review Configuration", + WizardState::Applying => "Applying Configuration", + WizardState::WaitingReboot => "Waiting for Reboot", + WizardState::ReadyToBootstrap => "Ready to Bootstrap", + WizardState::Bootstrapping => "Bootstrapping", + WizardState::WaitingHealthy => "Waiting for Cluster", + WizardState::Complete => "Complete", + WizardState::Error(_) => "Error", + } + } +} + +/// Node type for configuration +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NodeType { + #[default] + Controlplane, + Worker, +} + +impl NodeType { + pub fn toggle(&self) -> Self { + match self { + NodeType::Controlplane => NodeType::Worker, + NodeType::Worker => NodeType::Controlplane, + } + } + + pub fn config_filename(&self) -> &'static str { + match self { + NodeType::Controlplane => "controlplane.yaml", + NodeType::Worker => "worker.yaml", + } + } +} + +/// Data accumulated through the wizard flow +#[derive(Debug, Clone, Default)] +pub struct WizardData { + // Connection info + pub endpoint: String, + + // From Connecting state + pub disks: Vec, + pub volumes: Vec, + pub connected: bool, + + // From SelectDisk state + pub selected_disk: Option, + + // From ConfigureCluster state + pub cluster_name: String, + pub k8s_endpoint: String, + pub node_type: NodeType, + pub output_dir: String, + + // From ConfigReady state (after generation) + pub config_result: Option, + pub context_name: Option, + + // Timing for wait states + pub wait_started: Option, + + // Polling tracking + pub poll_attempts: u32, + pub last_poll_error: Option, + + // Spinner for animations + pub spinner_frame: usize, + + // Error tracking + pub last_error: Option, +} + +/// Spinner frames for wait states +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// Format error messages with better descriptions for common issues +fn format_poll_error(error: &str) -> String { + if error.contains("certificate signed by unknown authority") + || error.contains("x509:") + || error.contains("tls:") + { + "Certificate mismatch - remove old config and regenerate".to_string() + } else if error.contains("connection refused") { + "Connection refused - node may not be running".to_string() + } else if error.contains("connection error") || error.contains("Unavailable") { + "Node not reachable - waiting for boot...".to_string() + } else if error.contains("deadline exceeded") || error.contains("timeout") { + "Connection timeout - node may still be booting".to_string() + } else { + // Truncate long error messages + if error.len() > 80 { + format!("{}...", &error[..77]) + } else { + error.to_string() + } + } +} + +impl WizardData { + pub fn new(endpoint: String) -> Self { + let k8s_endpoint = format!("https://{}:6443", endpoint); + Self { + endpoint: endpoint.clone(), + cluster_name: "talos-cluster".to_string(), + k8s_endpoint, + node_type: NodeType::Controlplane, + output_dir: ".".to_string(), + ..Default::default() + } + } + + /// Get installable disks (filter out read-only, CD-ROM) + pub fn installable_disks(&self) -> Vec<&DiskInfo> { + self.disks + .iter() + .filter(|d| !d.readonly && !d.cdrom) + .collect() + } + + /// Get current spinner character + pub fn spinner(&self) -> &'static str { + SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()] + } + + /// Advance spinner to next frame + pub fn advance_spinner(&mut self) { + self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len(); + } +} + +/// Active field in ConfigureCluster dialog +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ConfigField { + #[default] + ClusterName, + K8sEndpoint, + NodeType, + OutputDir, +} + +impl ConfigField { + pub fn next(&self) -> Self { + match self { + ConfigField::ClusterName => ConfigField::K8sEndpoint, + ConfigField::K8sEndpoint => ConfigField::NodeType, + ConfigField::NodeType => ConfigField::OutputDir, + ConfigField::OutputDir => ConfigField::ClusterName, + } + } + + pub fn prev(&self) -> Self { + match self { + ConfigField::ClusterName => ConfigField::OutputDir, + ConfigField::K8sEndpoint => ConfigField::ClusterName, + ConfigField::NodeType => ConfigField::K8sEndpoint, + ConfigField::OutputDir => ConfigField::NodeType, + } + } +} + +/// Bootstrap wizard component +pub struct WizardComponent { + /// Current state + state: WizardState, + + /// Accumulated data + data: WizardData, + + /// Table state for disk selection + disk_table_state: TableState, + + /// Active field in config dialog + active_field: ConfigField, + + /// Whether viewing volumes instead of disks + viewing_volumes: bool, +} + +impl WizardComponent { + pub fn new(endpoint: String) -> Self { + let mut disk_table_state = TableState::default(); + disk_table_state.select(Some(0)); + + Self { + state: WizardState::Connecting, + data: WizardData::new(endpoint), + disk_table_state, + active_field: ConfigField::default(), + viewing_volumes: false, + } + } + + /// Get current state + pub fn state(&self) -> &WizardState { + &self.state + } + + /// Get wizard data + pub fn data(&self) -> &WizardData { + &self.data + } + + /// Get mutable wizard data + pub fn data_mut(&mut self) -> &mut WizardData { + &mut self.data + } + + /// Transition to a new state + pub fn transition(&mut self, new_state: WizardState) { + tracing::info!("Wizard: {:?} -> {:?}", self.state, new_state); + self.state = new_state; + } + + /// Set error state + pub fn set_error(&mut self, message: String) { + self.data.last_error = Some(message.clone()); + self.state = WizardState::Error(message); + } + + /// Connect to the maintenance mode node and fetch disk info + pub async fn connect(&mut self) -> Result<()> { + use talos_rs::{get_disks_insecure, get_volume_status_insecure}; + + let endpoint = &self.data.endpoint; + + // Fetch disks + match get_disks_insecure(endpoint).await { + Ok(disks) => { + self.data.disks = disks; + self.data.connected = true; + } + Err(e) => { + self.set_error(format!("Failed to connect: {}", e)); + return Ok(()); + } + } + + // Fetch volumes (optional, don't fail if unavailable) + if let Ok(volumes) = get_volume_status_insecure(endpoint).await { + self.data.volumes = volumes; + } + + // Transition to disk selection + self.transition(WizardState::SelectDisk); + Ok(()) + } + + /// Get selected disk index + fn selected_disk_index(&self) -> usize { + self.disk_table_state.selected().unwrap_or(0) + } + + /// Move disk selection up + fn select_prev_disk(&mut self) { + let disks = self.data.installable_disks(); + if !disks.is_empty() { + let i = self.selected_disk_index(); + let new_i = if i == 0 { disks.len() - 1 } else { i - 1 }; + self.disk_table_state.select(Some(new_i)); + } + } + + /// Move disk selection down + fn select_next_disk(&mut self) { + let disks = self.data.installable_disks(); + if !disks.is_empty() { + let i = self.selected_disk_index(); + let new_i = (i + 1) % disks.len(); + self.disk_table_state.select(Some(new_i)); + } + } + + /// Confirm disk selection and move to configure + fn confirm_disk_selection(&mut self) { + let disks = self.data.installable_disks(); + let idx = self.selected_disk_index(); + if idx < disks.len() { + self.data.selected_disk = Some(disks[idx].clone()); + self.transition(WizardState::ConfigureCluster); + } + } + + /// Handle key events for SelectDisk state + fn handle_select_disk_key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.select_prev_disk(); + None + } + KeyCode::Down | KeyCode::Char('j') => { + self.select_next_disk(); + None + } + KeyCode::Enter => { + self.confirm_disk_selection(); + None + } + KeyCode::Tab => { + self.viewing_volumes = !self.viewing_volumes; + None + } + KeyCode::Char('q') | KeyCode::Esc => Some(Action::Quit), + _ => None, + } + } + + /// Handle key events for ConfigureCluster state + fn handle_configure_key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Tab | KeyCode::Down => { + self.active_field = self.active_field.next(); + None + } + KeyCode::BackTab | KeyCode::Up => { + self.active_field = self.active_field.prev(); + None + } + KeyCode::Enter => { + // Generate config + Some(Action::WizardGenConfig) + } + KeyCode::Esc => { + // Go back to disk selection + self.transition(WizardState::SelectDisk); + None + } + KeyCode::Char(c) => { + match self.active_field { + ConfigField::ClusterName => self.data.cluster_name.push(c), + ConfigField::K8sEndpoint => self.data.k8s_endpoint.push(c), + ConfigField::NodeType => { + // Space or any char toggles + self.data.node_type = self.data.node_type.toggle(); + } + ConfigField::OutputDir => self.data.output_dir.push(c), + } + None + } + KeyCode::Backspace => { + match self.active_field { + ConfigField::ClusterName => { + self.data.cluster_name.pop(); + } + ConfigField::K8sEndpoint => { + self.data.k8s_endpoint.pop(); + } + ConfigField::NodeType => {} + ConfigField::OutputDir => { + self.data.output_dir.pop(); + } + } + None + } + _ => None, + } + } + + /// Handle key events for ConfigReady state + fn handle_config_ready_key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Char('a') | KeyCode::Enter => { + // Apply config + Some(Action::WizardApplyConfig) + } + KeyCode::Esc => { + // Go back to configure + self.transition(WizardState::ConfigureCluster); + None + } + KeyCode::Char('q') => Some(Action::Quit), + _ => None, + } + } + + /// Handle key events for ReadyToBootstrap state + fn handle_ready_bootstrap_key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Char('b') | KeyCode::Enter => { + // Bootstrap + Some(Action::WizardBootstrap) + } + KeyCode::Char('q') | KeyCode::Esc => Some(Action::Quit), + _ => None, + } + } + + /// Handle key events for Complete state + fn handle_complete_key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Enter => { + // Exit to secure mode + Some(Action::WizardComplete(self.data.context_name.clone())) + } + KeyCode::Char('q') | KeyCode::Esc => Some(Action::Quit), + _ => None, + } + } + + /// Handle key events for Error state + fn handle_error_key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Char('r') => { + // Retry - go back to connecting + self.transition(WizardState::Connecting); + Some(Action::WizardRetry) + } + KeyCode::Char('q') | KeyCode::Esc => Some(Action::Quit), + _ => None, + } + } + + /// Handle key events for waiting states + fn handle_waiting_key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => Some(Action::Quit), + _ => None, + } + } + + // ============ DRAWING ============ + + /// Draw the wizard header with step indicator + fn draw_header(&self, frame: &mut Frame, area: Rect) { + let step = self.state.step_number(); + let total = WizardState::total_steps(); + let title = self.state.title(); + + let header = if step > 0 { + Line::from(vec![ + Span::styled( + " Bootstrap Wizard ", + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!("Step {} of {}: ", step, total), + Style::default().fg(Color::DarkGray), + ), + Span::styled(title, Style::default().fg(Color::White)), + ]) + } else { + Line::from(vec![ + Span::styled( + " Bootstrap Wizard ", + Style::default() + .fg(Color::Black) + .bg(Color::Red) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(title, Style::default().fg(Color::Red)), + ]) + }; + + frame.render_widget(Paragraph::new(header), area); + } + + /// Draw connecting state + fn draw_connecting(&self, frame: &mut Frame, area: Rect) { + let spinner = self.data.spinner(); + let content = Paragraph::new(vec![ + Line::raw(""), + Line::from(vec![ + Span::styled(format!(" {} ", spinner), Style::default().fg(Color::Cyan)), + Span::raw("Connecting to "), + Span::styled(&self.data.endpoint, Style::default().fg(Color::Cyan)), + Span::raw("..."), + ]), + Line::raw(""), + Line::styled( + " Please wait while we detect available disks.", + Style::default().fg(Color::DarkGray), + ), + ]) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(content, area); + } + + /// Draw disk selection state + fn draw_select_disk(&mut self, frame: &mut Frame, area: Rect) { + let layout = Layout::vertical([ + Constraint::Length(2), // Instructions + Constraint::Min(8), // Disk table + Constraint::Length(8), // Disk details + Constraint::Length(2), // Warning + Constraint::Length(1), // Help + ]) + .split(area); + + // Instructions + let instructions = Paragraph::new(Line::from(vec![Span::raw( + " Select the disk where Talos will be installed:", + )])) + .style(Style::default().fg(Color::White)); + frame.render_widget(instructions, layout[0]); + + // Disk table or volume table + if self.viewing_volumes { + self.draw_volume_table(frame, layout[1]); + } else { + self.draw_disk_table(frame, layout[1]); + } + + // Disk details + self.draw_disk_details(frame, layout[2]); + + // Warning + let warning = Paragraph::new(Line::from(vec![ + Span::styled( + " ⚠ WARNING: ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "Selected disk will be COMPLETELY ERASED", + Style::default().fg(Color::Yellow), + ), + ])); + frame.render_widget(warning, layout[3]); + + // Help + let help = Line::from(vec![ + Span::styled(" [↑↓] ", Style::default().fg(Color::Cyan)), + Span::raw("Navigate"), + Span::raw(" "), + Span::styled(" [Enter] ", Style::default().fg(Color::Green)), + Span::raw("Select"), + Span::raw(" "), + Span::styled(" [Tab] ", Style::default().fg(Color::Cyan)), + Span::raw(if self.viewing_volumes { + "View Disks" + } else { + "View Volumes" + }), + Span::raw(" "), + Span::styled(" [q] ", Style::default().fg(Color::Cyan)), + Span::raw("Quit"), + ]); + frame.render_widget(Paragraph::new(help), layout[4]); + } + + /// Draw disk table + fn draw_disk_table(&mut self, frame: &mut Frame, area: Rect) { + let disks = self.data.installable_disks(); + + let header = Row::new(vec![ + Cell::from("Device").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Size").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Type").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Transport").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Model").style(Style::default().add_modifier(Modifier::BOLD)), + ]) + .height(1) + .style(Style::default().fg(Color::Cyan)); + + let rows: Vec = disks + .iter() + .map(|disk| { + let disk_type = if disk.rotational { + ("HDD", Color::Yellow) + } else { + ("SSD", Color::Green) + }; + + Row::new(vec![ + Cell::from(disk.dev_path.clone()), + Cell::from(disk.size_pretty.clone()), + Cell::from(disk_type.0).style(Style::default().fg(disk_type.1)), + Cell::from(disk.transport.clone().unwrap_or_default()), + Cell::from(disk.model.clone().unwrap_or_else(|| "-".to_string())), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(15), + Constraint::Length(12), + Constraint::Length(6), + Constraint::Length(10), + Constraint::Fill(1), + ], + ) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Disks ({}) ", disks.len())), + ) + .row_highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ); + + frame.render_stateful_widget(table, area, &mut self.disk_table_state); + } + + /// Draw volume table + fn draw_volume_table(&self, frame: &mut Frame, area: Rect) { + let volumes = &self.data.volumes; + + let header = Row::new(vec![ + Cell::from("Volume").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Size").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Phase").style(Style::default().add_modifier(Modifier::BOLD)), + Cell::from("Location").style(Style::default().add_modifier(Modifier::BOLD)), + ]) + .height(1) + .style(Style::default().fg(Color::Cyan)); + + let rows: Vec = volumes + .iter() + .map(|vol| { + let phase_color = match vol.phase.as_str() { + "ready" => Color::Green, + "waiting" => Color::Yellow, + _ => Color::Red, + }; + + Row::new(vec![ + Cell::from(vol.id.clone()), + Cell::from(vol.size.clone()), + Cell::from(vol.phase.clone()).style(Style::default().fg(phase_color)), + Cell::from(vol.mount_location.clone().unwrap_or_default()), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(20), + Constraint::Length(12), + Constraint::Length(10), + Constraint::Fill(1), + ], + ) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!(" Volumes ({}) - Reference Only ", volumes.len())), + ); + + frame.render_widget(table, area); + } + + /// Draw disk details panel + fn draw_disk_details(&self, frame: &mut Frame, area: Rect) { + let disks = self.data.installable_disks(); + let idx = self.selected_disk_index(); + + let content = if idx < disks.len() { + let disk = disks[idx]; + vec![ + Line::from(vec![ + Span::styled(" Device: ", Style::default().fg(Color::DarkGray)), + Span::styled(&disk.dev_path, Style::default().fg(Color::White)), + ]), + Line::from(vec![ + Span::styled(" Size: ", Style::default().fg(Color::DarkGray)), + Span::styled(&disk.size_pretty, Style::default().fg(Color::White)), + ]), + Line::from(vec![ + Span::styled(" Type: ", Style::default().fg(Color::DarkGray)), + Span::styled( + if disk.rotational { + "HDD (rotational)" + } else { + "SSD (non-rotational)" + }, + Style::default().fg(if disk.rotational { + Color::Yellow + } else { + Color::Green + }), + ), + ]), + Line::from(vec![ + Span::styled(" Transport: ", Style::default().fg(Color::DarkGray)), + Span::styled( + disk.transport.as_deref().unwrap_or("-"), + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled(" Model: ", Style::default().fg(Color::DarkGray)), + Span::styled( + disk.model.as_deref().unwrap_or("-"), + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled(" Serial: ", Style::default().fg(Color::DarkGray)), + Span::styled( + disk.serial.as_deref().unwrap_or("-"), + Style::default().fg(Color::White), + ), + ]), + ] + } else { + vec![Line::styled( + " No disk selected", + Style::default().fg(Color::DarkGray), + )] + }; + + let details = Paragraph::new(content).block( + Block::default() + .borders(Borders::ALL) + .title(" Disk Details ") + .border_style(Style::default().fg(Color::DarkGray)), + ); + + frame.render_widget(details, area); + } + + /// Draw configure cluster state + fn draw_configure_cluster(&self, frame: &mut Frame, area: Rect) { + let layout = Layout::vertical([ + Constraint::Length(3), // Selected disk + Constraint::Length(2), // Cluster name + Constraint::Length(2), // K8s endpoint + Constraint::Length(2), // Node type + Constraint::Length(2), // Output dir + Constraint::Fill(1), // Spacer + Constraint::Length(1), // Help + ]) + .split(area); + + // Selected disk (read-only) + let disk_info = self + .data + .selected_disk + .as_ref() + .map(|d| { + format!( + "{} ({} {})", + d.dev_path, + d.size_pretty, + if d.rotational { "HDD" } else { "SSD" } + ) + }) + .unwrap_or_else(|| "None".to_string()); + + let disk_line = Paragraph::new(Line::from(vec![ + Span::styled(" Install Disk: ", Style::default().fg(Color::DarkGray)), + Span::styled(&disk_info, Style::default().fg(Color::Green)), + Span::styled(" ✓", Style::default().fg(Color::Green)), + ])); + frame.render_widget(disk_line, layout[0]); + + // Cluster name field + let cluster_style = if self.active_field == ConfigField::ClusterName { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::White) + }; + let cluster_field = Paragraph::new(Line::from(vec![ + Span::styled(" Cluster Name: ", Style::default().fg(Color::Cyan)), + Span::styled(&self.data.cluster_name, cluster_style), + if self.active_field == ConfigField::ClusterName { + Span::styled("_", Style::default().fg(Color::Yellow)) + } else { + Span::raw("") + }, + ])); + frame.render_widget(cluster_field, layout[1]); + + // K8s endpoint field + let endpoint_style = if self.active_field == ConfigField::K8sEndpoint { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::White) + }; + let endpoint_field = Paragraph::new(Line::from(vec![ + Span::styled(" K8s Endpoint: ", Style::default().fg(Color::Cyan)), + Span::styled(&self.data.k8s_endpoint, endpoint_style), + if self.active_field == ConfigField::K8sEndpoint { + Span::styled("_", Style::default().fg(Color::Yellow)) + } else { + Span::raw("") + }, + ])); + frame.render_widget(endpoint_field, layout[2]); + + // Node type selector + let type_line = Paragraph::new(Line::from(vec![ + Span::styled(" Node Type: ", Style::default().fg(Color::Cyan)), + if self.data.node_type == NodeType::Controlplane { + Span::styled( + " Control Plane ", + Style::default().fg(Color::Black).bg(Color::Green), + ) + } else { + Span::styled(" Control Plane ", Style::default().fg(Color::DarkGray)) + }, + Span::raw(" "), + if self.data.node_type == NodeType::Worker { + Span::styled( + " Worker ", + Style::default().fg(Color::Black).bg(Color::Blue), + ) + } else { + Span::styled(" Worker ", Style::default().fg(Color::DarkGray)) + }, + if self.active_field == ConfigField::NodeType { + Span::styled(" ← Space to toggle", Style::default().fg(Color::Yellow)) + } else { + Span::raw("") + }, + ])); + frame.render_widget(type_line, layout[3]); + + // Output dir field + let dir_style = if self.active_field == ConfigField::OutputDir { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::White) + }; + let dir_field = Paragraph::new(Line::from(vec![ + Span::styled(" Output Dir: ", Style::default().fg(Color::Cyan)), + Span::styled(&self.data.output_dir, dir_style), + if self.active_field == ConfigField::OutputDir { + Span::styled("_", Style::default().fg(Color::Yellow)) + } else { + Span::raw("") + }, + ])); + frame.render_widget(dir_field, layout[4]); + + // Help + let help = Line::from(vec![ + Span::styled(" [Tab] ", Style::default().fg(Color::Cyan)), + Span::raw("Next field"), + Span::raw(" "), + Span::styled(" [Enter] ", Style::default().fg(Color::Green)), + Span::raw("Generate config"), + Span::raw(" "), + Span::styled(" [Esc] ", Style::default().fg(Color::Cyan)), + Span::raw("Back"), + ]); + frame.render_widget(Paragraph::new(help), layout[6]); + } + + /// Draw config ready state + fn draw_config_ready(&self, frame: &mut Frame, area: Rect) { + let config = self.data.config_result.as_ref(); + + let content = if let Some(cfg) = config { + vec![ + Line::raw(""), + Line::styled( + " Configuration generated successfully!", + Style::default().fg(Color::Green), + ), + Line::raw(""), + Line::from(vec![Span::styled( + " Files created:", + Style::default().fg(Color::DarkGray), + )]), + Line::from(vec![ + Span::raw(" • "), + Span::styled(&cfg.controlplane_path, Style::default().fg(Color::White)), + ]), + Line::from(vec![ + Span::raw(" • "), + Span::styled(&cfg.worker_path, Style::default().fg(Color::White)), + ]), + Line::from(vec![ + Span::raw(" • "), + Span::styled(&cfg.talosconfig_path, Style::default().fg(Color::White)), + ]), + Line::raw(""), + if let Some(ctx) = &self.data.context_name { + Line::from(vec![ + Span::styled(" Context merged: ", Style::default().fg(Color::DarkGray)), + Span::styled(ctx, Style::default().fg(Color::Cyan)), + ]) + } else { + Line::raw("") + }, + Line::raw(""), + Line::styled( + " Press [a] or [Enter] to apply configuration to the node.", + Style::default().fg(Color::Yellow), + ), + Line::raw(""), + Line::styled( + " ⚠ This will install Talos to the selected disk and reboot.", + Style::default().fg(Color::Yellow), + ), + ] + } else { + vec![Line::styled( + " No configuration generated", + Style::default().fg(Color::Red), + )] + }; + + let para = Paragraph::new(content).block(Block::default().borders(Borders::ALL)); + frame.render_widget(para, area); + } + + /// Draw applying state + fn draw_applying(&self, frame: &mut Frame, area: Rect) { + let spinner = self.data.spinner(); + let content = Paragraph::new(vec![ + Line::raw(""), + Line::from(vec![ + Span::styled(format!(" {} ", spinner), Style::default().fg(Color::Cyan)), + Span::styled( + "Applying configuration...", + Style::default().fg(Color::Yellow), + ), + ]), + Line::raw(""), + Line::styled( + " The node will install Talos to disk and reboot.", + Style::default().fg(Color::DarkGray), + ), + ]) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(content, area); + } + + /// Draw waiting for reboot state + fn draw_waiting_reboot(&self, frame: &mut Frame, area: Rect) { + let elapsed = self + .data + .wait_started + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); + + let spinner = self.data.spinner(); + let mut lines = vec![ + Line::raw(""), + Line::from(vec![ + Span::styled(format!(" {} ", spinner), Style::default().fg(Color::Cyan)), + Span::styled( + "Waiting for node to come back online...", + Style::default().fg(Color::Yellow), + ), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled(" Endpoint: ", Style::default().fg(Color::DarkGray)), + Span::styled(&self.data.endpoint, Style::default().fg(Color::White)), + ]), + Line::from(vec![ + Span::styled(" Context: ", Style::default().fg(Color::DarkGray)), + Span::styled( + self.data.context_name.as_deref().unwrap_or("-"), + Style::default().fg(Color::Cyan), + ), + ]), + Line::from(vec![ + Span::styled(" Elapsed: ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{} seconds", elapsed), + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled(" Attempts: ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{}", self.data.poll_attempts), + Style::default().fg(Color::White), + ), + ]), + ]; + + // Show last poll error if any + if let Some(err) = &self.data.last_poll_error { + let formatted = format_poll_error(err); + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::styled(" Status: ", Style::default().fg(Color::DarkGray)), + Span::styled(formatted, Style::default().fg(Color::Red)), + ])); + } + + lines.push(Line::raw("")); + lines.push(Line::styled( + " The node will install Talos to disk and shut down.", + Style::default().fg(Color::DarkGray), + )); + lines.push(Line::styled( + " Power on the node to boot from the installed disk.", + Style::default().fg(Color::DarkGray), + )); + lines.push(Line::styled( + " This screen will automatically advance when the node responds.", + Style::default().fg(Color::DarkGray), + )); + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::styled(" [q] ", Style::default().fg(Color::Cyan)), + Span::raw("Quit"), + ])); + + let content = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL)) + .wrap(ratatui::widgets::Wrap { trim: false }); + + frame.render_widget(content, area); + } + + /// Draw ready to bootstrap state + fn draw_ready_bootstrap(&self, frame: &mut Frame, area: Rect) { + let mut lines = vec![ + Line::raw(""), + Line::styled( + " ✓ Node is up and configured!", + Style::default().fg(Color::Green), + ), + Line::raw(""), + Line::from(vec![ + Span::styled(" Context: ", Style::default().fg(Color::DarkGray)), + Span::styled( + self.data.context_name.as_deref().unwrap_or("-"), + Style::default().fg(Color::Cyan), + ), + ]), + Line::raw(""), + ]; + + if self.data.node_type == NodeType::Controlplane { + lines.push(Line::styled( + " Ready to bootstrap the cluster.", + Style::default().fg(Color::DarkGray), + )); + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::styled(" [b/Enter] ", Style::default().fg(Color::Green)), + Span::raw("Bootstrap"), + Span::raw(" "), + Span::styled(" [q] ", Style::default().fg(Color::Cyan)), + Span::raw("Quit"), + ])); + } else { + lines.push(Line::styled( + " Worker node ready. Join to existing cluster.", + Style::default().fg(Color::Yellow), + )); + } + + let content = Paragraph::new(lines).block(Block::default().borders(Borders::ALL)); + + frame.render_widget(content, area); + } + + /// Draw bootstrapping state + fn draw_bootstrapping(&self, frame: &mut Frame, area: Rect) { + let spinner = self.data.spinner(); + let content = Paragraph::new(vec![ + Line::raw(""), + Line::from(vec![ + Span::styled(format!(" {} ", spinner), Style::default().fg(Color::Cyan)), + Span::styled( + "Bootstrapping cluster...", + Style::default().fg(Color::Yellow), + ), + ]), + Line::raw(""), + Line::styled( + " Initializing etcd and starting Kubernetes control plane.", + Style::default().fg(Color::DarkGray), + ), + ]) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(content, area); + } + + /// Draw waiting for healthy state + fn draw_waiting_healthy(&self, frame: &mut Frame, area: Rect) { + let elapsed = self + .data + .wait_started + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); + + let spinner = self.data.spinner(); + let content = Paragraph::new(vec![ + Line::raw(""), + Line::from(vec![ + Span::styled(format!(" {} ", spinner), Style::default().fg(Color::Cyan)), + Span::styled( + "Waiting for cluster to become healthy...", + Style::default().fg(Color::Yellow), + ), + ]), + Line::raw(""), + Line::from(vec![ + Span::styled(" Elapsed: ", Style::default().fg(Color::DarkGray)), + Span::styled( + format!("{} seconds", elapsed), + Style::default().fg(Color::White), + ), + ]), + Line::raw(""), + Line::styled(" Checklist:", Style::default().fg(Color::DarkGray)), + Line::raw(" [ ] etcd running"), + Line::raw(" [ ] API server running"), + Line::raw(" [ ] Node ready"), + Line::raw(""), + Line::from(vec![ + Span::styled(" [q] ", Style::default().fg(Color::Cyan)), + Span::raw("Quit"), + ]), + ]) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(content, area); + } + + /// Draw complete state + fn draw_complete(&self, frame: &mut Frame, area: Rect) { + let content = Paragraph::new(vec![ + Line::raw(""), + Line::styled( + " 🎉 Cluster bootstrap complete!", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Line::raw(""), + Line::from(vec![ + Span::styled(" Cluster: ", Style::default().fg(Color::DarkGray)), + Span::styled(&self.data.cluster_name, Style::default().fg(Color::Cyan)), + ]), + Line::from(vec![ + Span::styled(" Context: ", Style::default().fg(Color::DarkGray)), + Span::styled( + self.data.context_name.as_deref().unwrap_or("-"), + Style::default().fg(Color::Cyan), + ), + ]), + Line::raw(""), + Line::styled(" Next steps:", Style::default().fg(Color::White)), + Line::styled( + " • Get kubeconfig: talosctl kubeconfig", + Style::default().fg(Color::DarkGray), + ), + Line::styled( + " • View cluster: kubectl get nodes", + Style::default().fg(Color::DarkGray), + ), + Line::raw(""), + Line::from(vec![ + Span::styled(" [Enter] ", Style::default().fg(Color::Green)), + Span::raw("Exit"), + Span::raw(" "), + Span::styled(" [q] ", Style::default().fg(Color::Cyan)), + Span::raw("Quit"), + ]), + ]) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(content, area); + } + + /// Draw error state + fn draw_error(&self, frame: &mut Frame, area: Rect, message: &str) { + let content = Paragraph::new(vec![ + Line::raw(""), + Line::styled(" Error occurred:", Style::default().fg(Color::Red)), + Line::raw(""), + Line::styled(format!(" {}", message), Style::default().fg(Color::White)), + Line::raw(""), + Line::styled( + " Press [r] to retry, [q] to quit.", + Style::default().fg(Color::Yellow), + ), + ]) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)), + ); + + frame.render_widget(content, area); + } +} + +impl Component for WizardComponent { + fn handle_key_event(&mut self, key: KeyEvent) -> Result> { + let action = match &self.state { + WizardState::Connecting => self.handle_waiting_key(key), + WizardState::SelectDisk => self.handle_select_disk_key(key), + WizardState::ConfigureCluster => self.handle_configure_key(key), + WizardState::ConfigReady => self.handle_config_ready_key(key), + WizardState::Applying => self.handle_waiting_key(key), + WizardState::WaitingReboot => self.handle_waiting_key(key), + WizardState::ReadyToBootstrap => self.handle_ready_bootstrap_key(key), + WizardState::Bootstrapping => self.handle_waiting_key(key), + WizardState::WaitingHealthy => self.handle_waiting_key(key), + WizardState::Complete => self.handle_complete_key(key), + WizardState::Error(_) => self.handle_error_key(key), + }; + + Ok(action) + } + + fn update(&mut self, _action: Action) -> Result> { + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let layout = Layout::vertical([ + Constraint::Length(1), // Header + Constraint::Fill(1), // Content + ]) + .split(area); + + // Draw header + self.draw_header(frame, layout[0]); + + // Draw content based on state + match &self.state.clone() { + WizardState::Connecting => self.draw_connecting(frame, layout[1]), + WizardState::SelectDisk => self.draw_select_disk(frame, layout[1]), + WizardState::ConfigureCluster => self.draw_configure_cluster(frame, layout[1]), + WizardState::ConfigReady => self.draw_config_ready(frame, layout[1]), + WizardState::Applying => self.draw_applying(frame, layout[1]), + WizardState::WaitingReboot => self.draw_waiting_reboot(frame, layout[1]), + WizardState::ReadyToBootstrap => self.draw_ready_bootstrap(frame, layout[1]), + WizardState::Bootstrapping => self.draw_bootstrapping(frame, layout[1]), + WizardState::WaitingHealthy => self.draw_waiting_healthy(frame, layout[1]), + WizardState::Complete => self.draw_complete(frame, layout[1]), + WizardState::Error(msg) => self.draw_error(frame, layout[1], msg), + } + + Ok(()) + } +} diff --git a/crates/talos-rs/src/lib.rs b/crates/talos-rs/src/lib.rs index 113d7f8..b614c02 100644 --- a/crates/talos-rs/src/lib.rs +++ b/crates/talos-rs/src/lib.rs @@ -107,10 +107,11 @@ pub use client::{ pub use config::{Context, TalosConfig}; pub use error::TalosError; pub use talosctl::{ - AddressStatus, DiscoveryMember, DiskInfo, InsecureVersionInfo, KubeSpanPeerStatus, - MachineConfigInfo, VolumeStatus, check_insecure_connection, get_address_status, + AddressStatus, DiscoveryMember, DiskInfo, GenConfigResult, InsecureApplyResult, + InsecureVersionInfo, KubeSpanPeerStatus, MachineConfigInfo, VolumeStatus, + apply_config_insecure, check_insecure_connection, gen_config, get_address_status, get_discovery_members, get_discovery_members_for_context, get_disks, get_disks_for_context, get_disks_for_node, get_disks_insecure, get_kubespan_peers, get_machine_config, get_version_insecure, get_volume_status, get_volume_status_for_node, - get_volume_status_insecure, is_kubespan_enabled, + get_volume_status_insecure, is_kubespan_enabled, reboot_insecure, shutdown_insecure, }; diff --git a/crates/talos-rs/src/talosctl.rs b/crates/talos-rs/src/talosctl.rs index 9b61109..a928791 100644 --- a/crates/talos-rs/src/talosctl.rs +++ b/crates/talos-rs/src/talosctl.rs @@ -286,9 +286,16 @@ pub async fn get_disks_insecure(endpoint: &str) -> Result, TalosEr /// /// Executes: talosctl get volumestatus --insecure -n -o yaml pub async fn get_volume_status_insecure(endpoint: &str) -> Result, TalosError> { - let output = - exec_talosctl_async(&["get", "volumestatus", "--insecure", "-n", endpoint, "-o", "yaml"]) - .await?; + let output = exec_talosctl_async(&[ + "get", + "volumestatus", + "--insecure", + "-n", + endpoint, + "-o", + "yaml", + ]) + .await?; parse_volume_status_yaml(&output) } @@ -348,6 +355,119 @@ pub async fn check_insecure_connection(endpoint: &str) -> bool { get_disks_insecure(endpoint).await.is_ok() } +/// Result of generating Talos configuration +#[derive(Debug, Clone)] +pub struct GenConfigResult { + /// Path to generated controlplane.yaml + pub controlplane_path: String, + /// Path to generated worker.yaml + pub worker_path: String, + /// Path to generated talosconfig + pub talosconfig_path: String, + /// Output directory + pub output_dir: String, +} + +/// Generate Talos machine configuration +/// +/// Executes: talosctl gen config --output-dir [--force] +/// +/// This generates controlplane.yaml, worker.yaml, and talosconfig in the output directory. +pub async fn gen_config( + cluster_name: &str, + kubernetes_endpoint: &str, + output_dir: &str, + additional_sans: Option<&[&str]>, + force: bool, +) -> Result { + let mut args = vec!["gen", "config", cluster_name, kubernetes_endpoint]; + + args.push("--output-dir"); + args.push(output_dir); + + // Add additional SANs if provided + let sans_joined: String; + if let Some(sans) = additional_sans + && !sans.is_empty() + { + sans_joined = sans.join(","); + args.push("--additional-sans"); + args.push(&sans_joined); + } + + if force { + args.push("--force"); + } + + exec_talosctl_async(&args).await?; + + Ok(GenConfigResult { + controlplane_path: format!("{}/controlplane.yaml", output_dir), + worker_path: format!("{}/worker.yaml", output_dir), + talosconfig_path: format!("{}/talosconfig", output_dir), + output_dir: output_dir.to_string(), + }) +} + +/// Result of applying configuration in insecure mode +#[derive(Debug, Clone)] +pub struct InsecureApplyResult { + /// Whether the apply was successful + pub success: bool, + /// Output message + pub message: String, +} + +/// Apply configuration to a node in insecure mode +/// +/// Executes: talosctl apply-config --insecure -n -f +/// +/// This applies a machine configuration to a node in maintenance mode. +/// The node will install Talos and reboot. +pub async fn apply_config_insecure( + endpoint: &str, + config_path: &str, +) -> Result { + let output = exec_talosctl_async(&[ + "apply-config", + "--insecure", + "-n", + endpoint, + "-f", + config_path, + ]) + .await; + + match output { + Ok(msg) => Ok(InsecureApplyResult { + success: true, + message: if msg.trim().is_empty() { + "Configuration applied successfully. Node will install and reboot.".to_string() + } else { + msg + }, + }), + Err(e) => Ok(InsecureApplyResult { + success: false, + message: format!("Failed to apply config: {}", e), + }), + } +} + +/// Reboot a node in insecure mode +/// +/// Executes: talosctl reboot --insecure -n +pub async fn reboot_insecure(endpoint: &str) -> Result { + exec_talosctl_async(&["reboot", "--insecure", "-n", endpoint]).await +} + +/// Shutdown a node in insecure mode +/// +/// Executes: talosctl shutdown --insecure -n +pub async fn shutdown_insecure(endpoint: &str) -> Result { + exec_talosctl_async(&["shutdown", "--insecure", "-n", endpoint]).await +} + /// Get machine config info for a node /// /// Executes: talosctl get machineconfig --nodes -o yaml diff --git a/test-clusters/scripts/test-cluster-qemu.sh b/test-clusters/scripts/test-cluster-qemu.sh index 20fb3ee..26c7eee 100755 --- a/test-clusters/scripts/test-cluster-qemu.sh +++ b/test-clusters/scripts/test-cluster-qemu.sh @@ -8,32 +8,45 @@ set -e # Configuration -CLUSTER_NAME="talos-qemu" +CLUSTER_NAME="talos-cluster" WORK_DIR="/tmp/talos-qemu-test" +CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/talos-pilot" DISK_SIZE="20G" MEMORY="2048" CPUS="2" TALOS_VERSION="v1.12.1" ISO_URL="https://factory.talos.dev/image/376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba/${TALOS_VERSION}/metal-amd64.iso" +ISO_PATH="$CACHE_DIR/talos-${TALOS_VERSION}.iso" # Port mappings (host:guest) TALOS_API_PORT="50000" K8S_API_PORT="6443" +# Display mode (set by --gui flag) +GUI_MODE="false" + usage() { cat << EOF -Usage: $0 +Usage: $0 [--gui] + +Options: + --gui - Show QEMU graphical display (default: headless) Commands: - create - Create QEMU Talos cluster (runs in foreground) - create-bg - Create QEMU Talos cluster (runs in background) + create - Create VM and generate config (script-managed flow) + create-bg - Same as create, but runs in background + wizard - Create VM for talos-pilot wizard (no config generation) + wizard-bg - Same as wizard, but runs in background + start - Start VM from installed disk (after apply) destroy - Destroy the cluster and clean up status - Show cluster status apply - Apply config to running VM in maintenance mode bootstrap - Bootstrap the cluster (after apply) connect - Show connection info -This script creates a QEMU VM with a real disk for testing the Storage/Disks view. +Workflows: + Script-managed: $0 create -> apply -> start -> bootstrap + Wizard: $0 wizard -> cargo run -- --insecure --endpoint 127.0.0.1 Prerequisites: - qemu-system-x86_64 installed @@ -67,16 +80,19 @@ check_prereqs() { } download_iso() { - local iso_path="$WORK_DIR/talos.iso" + # Create cache directory if needed + mkdir -p "$CACHE_DIR" - if [[ -f "$iso_path" ]]; then - echo "ISO already exists: $iso_path" + if [[ -f "$ISO_PATH" ]]; then + echo "ISO already cached: $ISO_PATH" return fi - echo "Downloading Talos ISO..." - curl -L -o "$iso_path" "$ISO_URL" - echo "Downloaded: $iso_path" + echo "Downloading Talos ISO (will be cached for future use)..." + echo " URL: $ISO_URL" + echo " Destination: $ISO_PATH" + curl -L -o "$ISO_PATH" "$ISO_URL" + echo "Downloaded and cached: $ISO_PATH" } create_disk() { @@ -112,6 +128,7 @@ start_vm() { echo " Memory: ${MEMORY}MB" echo " CPUs: $CPUS" echo " Disk: $WORK_DIR/talos-disk.raw" + echo " Display: $(if [[ "$GUI_MODE" == "true" ]]; then echo "GUI"; else echo "headless"; fi)" echo "" echo "Port mappings:" echo " localhost:$TALOS_API_PORT -> Talos API" @@ -125,7 +142,7 @@ start_vm() { -cpu host -enable-kvm -drive "file=$WORK_DIR/talos-disk.raw,format=raw,if=ide" - -cdrom "$WORK_DIR/talos.iso" + -cdrom "$ISO_PATH" -boot d -netdev "user,id=net0,hostfwd=tcp::${TALOS_API_PORT}-:50000,hostfwd=tcp::${K8S_API_PORT}-:6443" -device virtio-net-pci,netdev=net0 @@ -133,7 +150,11 @@ start_vm() { if [[ "$background" == "true" ]]; then echo "Starting in background..." - "${qemu_cmd[@]}" -display none -daemonize -pidfile "$WORK_DIR/qemu.pid" + if [[ "$GUI_MODE" == "true" ]]; then + "${qemu_cmd[@]}" -daemonize -pidfile "$WORK_DIR/qemu.pid" + else + "${qemu_cmd[@]}" -display none -daemonize -pidfile "$WORK_DIR/qemu.pid" + fi echo "VM started. PID file: $WORK_DIR/qemu.pid" echo "" echo "Next steps:" @@ -144,12 +165,17 @@ start_vm() { else echo "Starting in foreground (Ctrl+C to stop)..." echo "" - "${qemu_cmd[@]}" + if [[ "$GUI_MODE" == "true" ]]; then + "${qemu_cmd[@]}" + else + "${qemu_cmd[@]}" -display none + fi fi } create_cluster() { local background="${1:-false}" + local skip_config="${2:-false}" check_prereqs @@ -157,17 +183,29 @@ create_cluster() { download_iso create_disk - generate_config - echo "" - echo "=========================================" - echo "VM will boot into maintenance mode." - echo "" - echo "After boot, run in another terminal:" - echo " $0 apply # Apply configuration" - echo " $0 bootstrap # Bootstrap cluster" - echo "=========================================" - echo "" + if [[ "$skip_config" == "false" ]]; then + generate_config + + echo "" + echo "=========================================" + echo "VM will boot into maintenance mode." + echo "" + echo "After boot, run in another terminal:" + echo " $0 apply # Apply configuration" + echo " $0 bootstrap # Bootstrap cluster" + echo "=========================================" + echo "" + else + echo "" + echo "=========================================" + echo "VM will boot into maintenance mode." + echo "" + echo "Use talos-pilot wizard to configure:" + echo " cargo run -- --insecure --endpoint 127.0.0.1" + echo "=========================================" + echo "" + fi start_vm "$background" } @@ -181,15 +219,58 @@ apply_config() { echo "Applying configuration to VM..." if talosctl apply-config --insecure --nodes 127.0.0.1 --file "$WORK_DIR/controlplane.yaml"; then echo "" - echo "Config applied! The VM will install Talos and reboot." - echo "Watch the QEMU window for progress." + echo "Config applied! The VM will install Talos and shut down." echo "" - echo "Once healthy, run: $0 bootstrap" + echo "Next steps:" + echo " 1. Run: $0 start # Boot from installed disk" + echo " 2. Run: $0 bootstrap # Bootstrap the cluster" else echo "Failed to apply config. Is the VM in maintenance mode?" fi } +start_from_disk() { + if [[ ! -f "$WORK_DIR/talos-disk.raw" ]]; then + echo "Error: Disk not found. Run '$0 create' first." + exit 1 + fi + + check_prereqs + + echo "Starting QEMU VM from installed disk..." + echo " Memory: ${MEMORY}MB" + echo " CPUs: $CPUS" + echo " Disk: $WORK_DIR/talos-disk.raw" + echo " Display: $(if [[ "$GUI_MODE" == "true" ]]; then echo "GUI"; else echo "headless"; fi)" + echo "" + echo "Port mappings:" + echo " localhost:$TALOS_API_PORT -> Talos API" + echo " localhost:$K8S_API_PORT -> Kubernetes API" + echo "" + + local qemu_cmd=( + qemu-system-x86_64 + -m "$MEMORY" + -smp "$CPUS" + -cpu host + -enable-kvm + -drive "file=$WORK_DIR/talos-disk.raw,format=raw,if=ide" + -netdev "user,id=net0,hostfwd=tcp::${TALOS_API_PORT}-:50000,hostfwd=tcp::${K8S_API_PORT}-:6443" + -device virtio-net-pci,netdev=net0 + ) + + if [[ "$GUI_MODE" == "true" ]]; then + "${qemu_cmd[@]}" -daemonize -pidfile "$WORK_DIR/qemu.pid" + else + "${qemu_cmd[@]}" -display none -daemonize -pidfile "$WORK_DIR/qemu.pid" + fi + + echo "VM started in background. PID file: $WORK_DIR/qemu.pid" + echo "" + echo "Wait for boot, then run: $0 bootstrap" + echo "Check status with: $0 status" +} + bootstrap_cluster() { echo "Checking connection..." if ! talosctl --context "$CLUSTER_NAME" version &>/dev/null; then @@ -233,10 +314,48 @@ destroy_cluster() { rm -rf "$WORK_DIR" fi - # Remove talosconfig context - if talosctl config contexts 2>/dev/null | grep -q "$CLUSTER_NAME"; then - echo "Removing talosconfig context '$CLUSTER_NAME'..." - talosctl config remove "$CLUSTER_NAME" --noconfirm 2>/dev/null || true + # Remove talosconfig contexts (including numbered variants like talos-cluster-1, talos-cluster-2, etc.) + echo "Removing talosconfig contexts matching '$CLUSTER_NAME*'..." + + # Get all context names (skip header, get NAME column which is $2, handle * in CURRENT column) + local all_contexts + all_contexts=$(talosctl config contexts 2>/dev/null | tail -n +2 | awk '{if ($1 == "*") print $2; else print $1}' || true) + + # Filter to just talos-cluster* contexts + local contexts + contexts=$(echo "$all_contexts" | grep "^${CLUSTER_NAME}" || true) + + if [[ -n "$contexts" ]]; then + # Get current context + local current_ctx + current_ctx=$(talosctl config contexts 2>/dev/null | grep '^\*' | awk '{print $2}' || true) + echo " Current context: $current_ctx" + + # Check if current context is one we want to delete + if echo "$current_ctx" | grep -q "^${CLUSTER_NAME}"; then + # Get first non-matching context to switch to + local other_ctx + other_ctx=$(echo "$all_contexts" | grep -v "^${CLUSTER_NAME}" | head -1 || true) + + if [[ -n "$other_ctx" ]]; then + echo " Switching from '$current_ctx' to '$other_ctx'..." + talosctl config context "$other_ctx" 2>/dev/null || true + else + # No other context - clear current context in config file directly + echo " No other context available, clearing current context..." + local config_file="${TALOSCONFIG:-$HOME/.talos/config}" + if [[ -f "$config_file" ]]; then + # Use sed to remove or clear the 'context:' line + sed -i 's/^context: .*/context: ""/' "$config_file" 2>/dev/null || true + fi + fi + fi + + # Now remove all matching contexts + for ctx in $contexts; do + echo " Removing context: $ctx" + talosctl config remove "$ctx" --noconfirm 2>/dev/null || true + done fi echo "Done." @@ -258,7 +377,7 @@ show_status() { echo "" echo "Files:" [[ -f "$WORK_DIR/talos-disk.raw" ]] && echo " Disk: $WORK_DIR/talos-disk.raw" || echo " Disk: Not created" - [[ -f "$WORK_DIR/talos.iso" ]] && echo " ISO: $WORK_DIR/talos.iso" || echo " ISO: Not downloaded" + [[ -f "$ISO_PATH" ]] && echo " ISO: $ISO_PATH (cached)" || echo " ISO: Not downloaded" [[ -f "$WORK_DIR/controlplane.yaml" ]] && echo " Config: $WORK_DIR/controlplane.yaml" || echo " Config: Not generated" # Check context @@ -311,13 +430,35 @@ talos-pilot: EOF } +# Parse flags +while [[ $# -gt 0 ]]; do + case "$1" in + --gui) + GUI_MODE="true" + shift + ;; + *) + break + ;; + esac +done + # Main case "${1:-}" in create) - create_cluster false + create_cluster false false ;; create-bg) - create_cluster true + create_cluster true false + ;; + wizard) + create_cluster false true + ;; + wizard-bg) + create_cluster true true + ;; + start) + start_from_disk ;; destroy) destroy_cluster From 973518a86914932837cdd136374a2b8fd4361a74 Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Fri, 16 Jan 2026 13:33:21 -0500 Subject: [PATCH 7/8] docs: update readme and release.yml --- .github/workflows/release.yml | 16 ++++++++++++++-- README.md | 22 +++++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff30c92..0f0554e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -273,8 +273,20 @@ jobs: ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" RELEASE_COMMIT: "${{ github.sha }}" run: | - # Write and read notes from a file to avoid quoting breaking things - echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + # Generate GitHub's auto release notes (What's Changed, New Contributors) + gh api repos/${{ github.repository }}/releases/generate-notes \ + -f tag_name="${{ needs.plan.outputs.tag }}" \ + -f target_commitish="$RELEASE_COMMIT" \ + --jq '.body' > $RUNNER_TEMP/auto_notes.txt + + # Combine: auto-generated notes first, then cargo-dist announcement + { + cat $RUNNER_TEMP/auto_notes.txt + echo "" + echo "---" + echo "" + echo "$ANNOUNCEMENT_BODY" + } > $RUNNER_TEMP/notes.txt gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* diff --git a/README.md b/README.md index 08ff067..3853f97 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Use **talos-pilot** for "why won't my node join the cluster?" | **Multi-Service Logs** | Stern-style interleaved logs from multiple services | | **Processes View** | htop-like process list with tree view, CPU/MEM sorting | | **Network Stats** | Interface traffic, connections, KubeSpan peers, packet capture | +| **Storage/Disks** | Disk list with size, transport, serial, system disk indicators | | **etcd Status** | Quorum health, member list, alarms, leader tracking | | **Workload Health** | K8s deployments, statefulsets, pod issues by namespace | | **Lifecycle View** | Version status, config drift detection, cluster alerts | @@ -122,6 +123,22 @@ talos-pilot --tail 1000 talos-pilot --debug --log-file ~/talos-pilot.log ``` +### Bootstrap Wizard (Insecure Mode) + +For bootstrapping new clusters on bare metal or VMs in maintenance mode, talos-pilot provides an interactive wizard: + +```bash +# Connect to a node in maintenance mode +talos-pilot --insecure --endpoint +``` + +The wizard guides you through: +1. **Generate Config** - Creates talosconfig, controlplane.yaml, and worker.yaml +2. **Apply Config** - Applies configuration to the node, triggering installation +3. **Bootstrap** - Initializes etcd and starts the Kubernetes cluster + +Once complete, you can manage the cluster using standard talos-pilot commands. + ### Keyboard Navigation | Key | Action | @@ -141,8 +158,8 @@ talos-pilot --debug --log-file ~/talos-pilot.log | Key | View | Description | |-----|------|-------------| -| `c` | Cluster | Node overview | -| `s` | Services | Service list for selected node | +| `c` | Security | PKI and encryption audit | +| `s` | Storage | Disk list with system disk indicators | | `l` | Logs | Single service logs | | `L` | Multi-Logs | Interleaved multi-service logs | | `p` | Processes | Process tree view | @@ -151,7 +168,6 @@ talos-pilot --debug --log-file ~/talos-pilot.log | `w` | Workloads | K8s deployment health | | `y` | Lifecycle | Version status, alerts | | `d` | Diagnostics | System health checks | -| `S` | Security | PKI and encryption audit | | `o` | Operations | Single node operations | | `O` | Rolling | Multi-node rolling operations | From 8532094cd6ec235013f974d37161e446ccb9b24e Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Fri, 16 Jan 2026 13:39:48 -0500 Subject: [PATCH 8/8] chore: lint and bump version --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- crates/talos-pilot-tui/src/app.rs | 12 ++++++------ dist-workspace.toml | 2 ++ 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2b72b2f..942c9f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2778,7 +2778,7 @@ dependencies = [ [[package]] name = "talos-pilot" -version = "0.1.2" +version = "0.1.3" dependencies = [ "clap", "color-eyre", @@ -2791,7 +2791,7 @@ dependencies = [ [[package]] name = "talos-pilot-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "chrono", "serde", @@ -2802,7 +2802,7 @@ dependencies = [ [[package]] name = "talos-pilot-tui" -version = "0.1.2" +version = "0.1.3" dependencies = [ "arboard", "base64", @@ -2828,7 +2828,7 @@ dependencies = [ [[package]] name = "talos-rs" -version = "0.1.2" +version = "0.1.3" dependencies = [ "base64", "dirs-next", diff --git a/Cargo.toml b/Cargo.toml index e7e679a..13e3d44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.1.2" +version = "0.1.3" edition = "2024" authors = ["Ken Udovic"] license = "MIT" diff --git a/crates/talos-pilot-tui/src/app.rs b/crates/talos-pilot-tui/src/app.rs index 80b2052..a954c2f 100644 --- a/crates/talos-pilot-tui/src/app.rs +++ b/crates/talos-pilot-tui/src/app.rs @@ -296,12 +296,12 @@ impl App { return false; } - if let Ok(out) = &merge_output { - if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); - tracing::warn!("Failed to merge config: {}", stderr); - return false; - } + if let Ok(out) = &merge_output + && !out.status.success() + { + let stderr = String::from_utf8_lossy(&out.stderr); + tracing::warn!("Failed to merge config: {}", stderr); + return false; } // Set the endpoint on the context diff --git a/dist-workspace.toml b/dist-workspace.toml index 90fd55b..971ee1f 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -5,6 +5,8 @@ members = ["cargo:."] [dist] # The preferred dist version to use in CI (Cargo.toml SemVer syntax) cargo-dist-version = "0.30.3" +# Allow custom modifications to release.yml (for auto-generated release notes) +allow-dirty = ["ci"] # CI backends to support ci = "github" # The installers to generate for each app