sherpack_kube/
progress.rs

1//! Real-time progress reporting for Kubernetes operations
2//!
3//! Provides visual feedback during deployment operations, showing:
4//! - Resource apply status
5//! - Health check progress (ready/desired replicas)
6//! - Wave execution progress
7//! - Hook execution status
8
9use std::collections::HashMap;
10use std::io::{self, Write};
11use std::time::{Duration, Instant};
12
13use console::{Term, style};
14
15/// Progress reporter for deployment operations
16pub struct ProgressReporter {
17    /// Terminal for output
18    term: Term,
19    /// Resource states
20    resources: HashMap<String, ResourceState>,
21    /// Current wave being processed
22    current_wave: Option<i32>,
23    /// Start time
24    start_time: Instant,
25    /// Whether colors are enabled
26    #[allow(dead_code)]
27    colors_enabled: bool,
28    /// Whether to show verbose output
29    verbose: bool,
30}
31
32/// State of a single resource
33#[derive(Debug, Clone)]
34pub struct ResourceState {
35    pub kind: String,
36    pub name: String,
37    pub status: ResourceStatus,
38    pub ready: Option<i32>,
39    pub desired: Option<i32>,
40    pub message: Option<String>,
41    pub last_update: Instant,
42}
43
44/// Status of a resource
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ResourceStatus {
47    Pending,
48    Applying,
49    Applied,
50    WaitingForReady,
51    Ready,
52    Failed,
53    Skipped,
54}
55
56impl ResourceStatus {
57    fn symbol(&self) -> &'static str {
58        match self {
59            ResourceStatus::Pending => "○",
60            ResourceStatus::Applying => "◐",
61            ResourceStatus::Applied => "◑",
62            ResourceStatus::WaitingForReady => "◕",
63            ResourceStatus::Ready => "●",
64            ResourceStatus::Failed => "✗",
65            ResourceStatus::Skipped => "⊘",
66        }
67    }
68
69    fn styled_symbol(&self) -> console::StyledObject<&'static str> {
70        match self {
71            ResourceStatus::Pending => style(self.symbol()).dim(),
72            ResourceStatus::Applying => style(self.symbol()).cyan(),
73            ResourceStatus::Applied => style(self.symbol()).blue(),
74            ResourceStatus::WaitingForReady => style(self.symbol()).yellow(),
75            ResourceStatus::Ready => style(self.symbol()).green(),
76            ResourceStatus::Failed => style(self.symbol()).red(),
77            ResourceStatus::Skipped => style(self.symbol()).dim(),
78        }
79    }
80}
81
82impl ProgressReporter {
83    /// Create a new progress reporter
84    pub fn new() -> Self {
85        Self {
86            term: Term::stderr(),
87            resources: HashMap::new(),
88            current_wave: None,
89            start_time: Instant::now(),
90            colors_enabled: console::colors_enabled(),
91            verbose: false,
92        }
93    }
94
95    /// Create with verbose output
96    pub fn verbose(mut self) -> Self {
97        self.verbose = true;
98        self
99    }
100
101    /// Add a resource to track
102    pub fn add_resource(&mut self, kind: &str, name: &str) {
103        let key = format!("{}/{}", kind, name);
104        self.resources.insert(
105            key,
106            ResourceState {
107                kind: kind.to_string(),
108                name: name.to_string(),
109                status: ResourceStatus::Pending,
110                ready: None,
111                desired: None,
112                message: None,
113                last_update: Instant::now(),
114            },
115        );
116    }
117
118    /// Set current wave
119    pub fn set_wave(&mut self, wave: i32) {
120        self.current_wave = Some(wave);
121        self.print_wave_header(wave);
122    }
123
124    /// Update resource status
125    pub fn update_status(&mut self, key: &str, status: ResourceStatus) {
126        if let Some(resource) = self.resources.get_mut(key) {
127            resource.status = status;
128            resource.last_update = Instant::now();
129            self.print_resource_update(key);
130        }
131    }
132
133    /// Update resource readiness
134    pub fn update_readiness(&mut self, key: &str, ready: i32, desired: i32, message: Option<&str>) {
135        if let Some(resource) = self.resources.get_mut(key) {
136            resource.ready = Some(ready);
137            resource.desired = Some(desired);
138            resource.message = message.map(String::from);
139            resource.last_update = Instant::now();
140
141            if ready == desired {
142                resource.status = ResourceStatus::Ready;
143            }
144
145            self.print_resource_update(key);
146        }
147    }
148
149    /// Mark resource as failed
150    pub fn fail(&mut self, key: &str, error: &str) {
151        if let Some(resource) = self.resources.get_mut(key) {
152            resource.status = ResourceStatus::Failed;
153            resource.message = Some(error.to_string());
154            resource.last_update = Instant::now();
155            self.print_resource_update(key);
156        }
157    }
158
159    /// Print wave header
160    fn print_wave_header(&self, wave: i32) {
161        let wave_resources: Vec<_> = self
162            .resources
163            .values()
164            .filter(|_| true) // In reality, filter by wave
165            .collect();
166
167        let _ = writeln!(
168            io::stderr(),
169            "\n{} Wave {} ({} resources)",
170            style("▶").cyan().bold(),
171            wave,
172            wave_resources.len()
173        );
174    }
175
176    /// Print resource update
177    fn print_resource_update(&self, key: &str) {
178        if let Some(resource) = self.resources.get(key) {
179            let styled_symbol = resource.status.styled_symbol();
180
181            let readiness = match (resource.ready, resource.desired) {
182                (Some(r), Some(d)) => format!(" ({}/{})", r, d),
183                _ => String::new(),
184            };
185
186            let message = resource
187                .message
188                .as_ref()
189                .map(|m| format!(" - {}", style(m).dim()))
190                .unwrap_or_default();
191
192            let line = format!(
193                "  {} {}/{}{}{}",
194                styled_symbol, resource.kind, resource.name, readiness, message
195            );
196
197            let _ = writeln!(io::stderr(), "{}", line);
198        }
199    }
200
201    /// Print hook execution start
202    pub fn hook_start(&self, phase: &str, name: &str) {
203        let _ = writeln!(
204            io::stderr(),
205            "  {} Hook [{}] {}",
206            style("⟳").cyan(),
207            phase,
208            name
209        );
210    }
211
212    /// Print hook execution result
213    pub fn hook_result(&self, name: &str, success: bool, duration: Duration, error: Option<&str>) {
214        let symbol = if success {
215            style("✓").green()
216        } else {
217            style("✗").red()
218        };
219
220        let duration_str = format!("{:.1}s", duration.as_secs_f64());
221
222        let error_msg = error
223            .map(|e| format!(" - {}", style(e).red()))
224            .unwrap_or_default();
225
226        let _ = writeln!(
227            io::stderr(),
228            "  {} Hook {} ({}){}",
229            symbol,
230            name,
231            duration_str,
232            error_msg
233        );
234    }
235
236    /// Print overall progress summary
237    pub fn print_summary(&self) {
238        let total = self.resources.len();
239        let ready = self
240            .resources
241            .values()
242            .filter(|r| r.status == ResourceStatus::Ready)
243            .count();
244        let failed = self
245            .resources
246            .values()
247            .filter(|r| r.status == ResourceStatus::Failed)
248            .count();
249
250        let elapsed = self.start_time.elapsed();
251
252        let _ = writeln!(io::stderr());
253
254        if failed > 0 {
255            let _ = writeln!(
256                io::stderr(),
257                "{} {}/{} resources ready, {} failed ({:.1}s)",
258                style("✗").red().bold(),
259                ready,
260                total,
261                failed,
262                elapsed.as_secs_f64()
263            );
264        } else if ready == total {
265            let _ = writeln!(
266                io::stderr(),
267                "{} All {} resources ready ({:.1}s)",
268                style("✓").green().bold(),
269                total,
270                elapsed.as_secs_f64()
271            );
272        } else {
273            let _ = writeln!(
274                io::stderr(),
275                "{} {}/{} resources ready ({:.1}s)",
276                style("○").yellow(),
277                ready,
278                total,
279                elapsed.as_secs_f64()
280            );
281        }
282    }
283
284    /// Print a simple message
285    pub fn message(&self, msg: &str) {
286        let _ = writeln!(io::stderr(), "  {}", msg);
287    }
288
289    /// Print an info message
290    pub fn info(&self, msg: &str) {
291        let _ = writeln!(io::stderr(), "  {} {}", style("ℹ").blue(), msg);
292    }
293
294    /// Print a warning message
295    pub fn warn(&self, msg: &str) {
296        let _ = writeln!(io::stderr(), "  {} {}", style("⚠").yellow(), msg);
297    }
298
299    /// Print an error message
300    pub fn error(&self, msg: &str) {
301        let _ = writeln!(io::stderr(), "  {} {}", style("✗").red(), msg);
302    }
303
304    /// Print success message
305    pub fn success(&self, msg: &str) {
306        let _ = writeln!(io::stderr(), "  {} {}", style("✓").green(), msg);
307    }
308
309    /// Get elapsed time
310    pub fn elapsed(&self) -> Duration {
311        self.start_time.elapsed()
312    }
313
314    /// Clear the screen (for interactive mode)
315    pub fn clear(&self) {
316        let _ = self.term.clear_screen();
317    }
318
319    /// Check if all resources are ready
320    pub fn all_ready(&self) -> bool {
321        self.resources
322            .values()
323            .all(|r| r.status == ResourceStatus::Ready || r.status == ResourceStatus::Skipped)
324    }
325
326    /// Check if any resource failed
327    pub fn any_failed(&self) -> bool {
328        self.resources
329            .values()
330            .any(|r| r.status == ResourceStatus::Failed)
331    }
332
333    /// Get failed resources
334    pub fn failed_resources(&self) -> Vec<&ResourceState> {
335        self.resources
336            .values()
337            .filter(|r| r.status == ResourceStatus::Failed)
338            .collect()
339    }
340}
341
342impl Default for ProgressReporter {
343    fn default() -> Self {
344        Self::new()
345    }
346}
347
348/// Quiet progress reporter that only logs errors
349pub struct QuietProgressReporter;
350
351impl QuietProgressReporter {
352    pub fn new() -> Self {
353        Self
354    }
355
356    pub fn error(&self, msg: &str) {
357        eprintln!("Error: {}", msg);
358    }
359
360    pub fn warn(&self, msg: &str) {
361        eprintln!("Warning: {}", msg);
362    }
363}
364
365impl Default for QuietProgressReporter {
366    fn default() -> Self {
367        Self::new()
368    }
369}
370
371/// JSON progress reporter for CI/CD integration
372pub struct JsonProgressReporter {
373    resources: HashMap<String, ResourceState>,
374}
375
376impl JsonProgressReporter {
377    pub fn new() -> Self {
378        Self {
379            resources: HashMap::new(),
380        }
381    }
382
383    pub fn add_resource(&mut self, kind: &str, name: &str) {
384        let key = format!("{}/{}", kind, name);
385        self.resources.insert(
386            key,
387            ResourceState {
388                kind: kind.to_string(),
389                name: name.to_string(),
390                status: ResourceStatus::Pending,
391                ready: None,
392                desired: None,
393                message: None,
394                last_update: Instant::now(),
395            },
396        );
397    }
398
399    pub fn update_status(&mut self, key: &str, status: ResourceStatus) {
400        if let Some(resource) = self.resources.get_mut(key) {
401            resource.status = status;
402            self.emit_event(key, "status_changed");
403        }
404    }
405
406    pub fn update_readiness(&mut self, key: &str, ready: i32, desired: i32) {
407        if let Some(resource) = self.resources.get_mut(key) {
408            resource.ready = Some(ready);
409            resource.desired = Some(desired);
410            if ready == desired {
411                resource.status = ResourceStatus::Ready;
412            }
413            self.emit_event(key, "readiness_changed");
414        }
415    }
416
417    fn emit_event(&self, key: &str, event_type: &str) {
418        if let Some(resource) = self.resources.get(key) {
419            let event = serde_json::json!({
420                "type": event_type,
421                "resource": {
422                    "kind": resource.kind,
423                    "name": resource.name,
424                    "status": format!("{:?}", resource.status),
425                    "ready": resource.ready,
426                    "desired": resource.desired,
427                    "message": resource.message,
428                }
429            });
430            println!("{}", event);
431        }
432    }
433
434    pub fn print_summary(&self) {
435        let summary: Vec<_> = self
436            .resources
437            .values()
438            .map(|r| {
439                serde_json::json!({
440                    "kind": r.kind,
441                    "name": r.name,
442                    "status": format!("{:?}", r.status),
443                    "ready": r.ready,
444                    "desired": r.desired,
445                })
446            })
447            .collect();
448
449        let output = serde_json::json!({
450            "type": "summary",
451            "resources": summary,
452        });
453        println!("{}", output);
454    }
455}
456
457impl Default for JsonProgressReporter {
458    fn default() -> Self {
459        Self::new()
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn test_resource_status_symbols() {
469        assert_eq!(ResourceStatus::Pending.symbol(), "○");
470        assert_eq!(ResourceStatus::Ready.symbol(), "●");
471        assert_eq!(ResourceStatus::Failed.symbol(), "✗");
472    }
473
474    #[test]
475    fn test_progress_reporter_add_resource() {
476        let mut reporter = ProgressReporter::new();
477        reporter.add_resource("Deployment", "my-app");
478
479        assert!(reporter.resources.contains_key("Deployment/my-app"));
480        assert_eq!(
481            reporter.resources["Deployment/my-app"].status,
482            ResourceStatus::Pending
483        );
484    }
485
486    #[test]
487    fn test_progress_reporter_update_status() {
488        let mut reporter = ProgressReporter::new();
489        reporter.add_resource("Deployment", "my-app");
490        reporter.update_status("Deployment/my-app", ResourceStatus::Applied);
491
492        assert_eq!(
493            reporter.resources["Deployment/my-app"].status,
494            ResourceStatus::Applied
495        );
496    }
497
498    #[test]
499    fn test_progress_reporter_update_readiness() {
500        let mut reporter = ProgressReporter::new();
501        reporter.add_resource("Deployment", "my-app");
502        reporter.update_readiness("Deployment/my-app", 2, 3, Some("Waiting for pods"));
503
504        let resource = &reporter.resources["Deployment/my-app"];
505        assert_eq!(resource.ready, Some(2));
506        assert_eq!(resource.desired, Some(3));
507        assert_eq!(resource.message, Some("Waiting for pods".to_string()));
508    }
509
510    #[test]
511    fn test_progress_reporter_readiness_triggers_ready() {
512        let mut reporter = ProgressReporter::new();
513        reporter.add_resource("Deployment", "my-app");
514        reporter.update_readiness("Deployment/my-app", 3, 3, None);
515
516        assert_eq!(
517            reporter.resources["Deployment/my-app"].status,
518            ResourceStatus::Ready
519        );
520    }
521
522    #[test]
523    fn test_progress_reporter_all_ready() {
524        let mut reporter = ProgressReporter::new();
525        reporter.add_resource("Deployment", "app1");
526        reporter.add_resource("Deployment", "app2");
527
528        assert!(!reporter.all_ready());
529
530        reporter.update_status("Deployment/app1", ResourceStatus::Ready);
531        assert!(!reporter.all_ready());
532
533        reporter.update_status("Deployment/app2", ResourceStatus::Ready);
534        assert!(reporter.all_ready());
535    }
536
537    #[test]
538    fn test_progress_reporter_any_failed() {
539        let mut reporter = ProgressReporter::new();
540        reporter.add_resource("Deployment", "app1");
541        reporter.add_resource("Deployment", "app2");
542
543        assert!(!reporter.any_failed());
544
545        reporter.fail("Deployment/app1", "ImagePullBackOff");
546        assert!(reporter.any_failed());
547    }
548}