1use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9use chrono::{DateTime, Utc};
10use nixfleet_proto::{HostStatusEntry, HostsResponse, RolloutEvents, RolloutHosts};
11use reqwest::{Certificate, Identity};
12
13pub mod color;
14pub mod commands;
15pub mod config;
16pub mod operator_cert;
17pub use config::{ConfigError, FileConfig, Overrides};
18pub use operator_cert::{MintOperatorCertArgs, MintOutcome, mint_operator_cert};
19
20pub fn run_config_init(
23 path: &Path,
24 cp_url: String,
25 ca_cert: PathBuf,
26 client_cert: PathBuf,
27 client_key: PathBuf,
28 overwrite: bool,
29) -> Result<PathBuf> {
30 if path.exists() && !overwrite {
31 anyhow::bail!(
32 "{} already exists; pass --force to overwrite",
33 path.display(),
34 );
35 }
36 let cfg = config::FileConfig {
37 cp_url: Some(cp_url),
38 ca_cert: Some(ca_cert),
39 client_cert: Some(client_cert),
40 client_key: Some(client_key),
41 };
42 cfg.save(path)
43 .with_context(|| format!("write {}", path.display()))?;
44 Ok(path.to_path_buf())
45}
46
47#[derive(Debug, Clone)]
50pub struct ResolvedClientConfig {
51 pub cp_url: String,
52 pub ca_cert: PathBuf,
53 pub client_cert: PathBuf,
54 pub client_key: PathBuf,
55}
56
57pub fn build_client(cfg: &ResolvedClientConfig) -> Result<reqwest::Client> {
58 let mut builder = reqwest::Client::builder().use_rustls_tls();
59 let pem = std::fs::read(&cfg.ca_cert)
60 .with_context(|| format!("read CA cert {}", cfg.ca_cert.display()))?;
61 let cert = Certificate::from_pem(&pem).context("parse CA cert PEM")?;
62 builder = builder.add_root_certificate(cert);
63
64 let mut id_pem = std::fs::read(&cfg.client_cert)
65 .with_context(|| format!("read client cert {}", cfg.client_cert.display()))?;
66 let key_pem = std::fs::read(&cfg.client_key)
67 .with_context(|| format!("read client key {}", cfg.client_key.display()))?;
68 id_pem.extend_from_slice(&key_pem);
69 let identity = Identity::from_pem(&id_pem).context("parse client identity PEM")?;
70 builder = builder.identity(identity);
71
72 builder.build().context("build HTTP client")
73}
74
75pub async fn run_status(cfg: &ResolvedClientConfig, json: bool, color: bool) -> Result<String> {
76 let cp_url = cfg.cp_url.trim_end_matches('/');
77 let client = build_client(cfg)?;
78
79 let hosts: HostsResponse = client
80 .get(format!("{cp_url}/v1/hosts"))
81 .send()
82 .await
83 .with_context(|| format!("GET {cp_url}/v1/hosts"))?
84 .error_for_status()?
85 .json()
86 .await
87 .context("parse /v1/hosts response")?;
88
89 if json {
90 return serde_json::to_string_pretty(&hosts).context("serialize HostsResponse to JSON");
91 }
92
93 let mut channels_seen: Vec<String> = hosts.hosts.iter().map(|h| h.channel.clone()).collect();
94 channels_seen.sort();
95 channels_seen.dedup();
96 let mut channel_freshness: BTreeMap<String, u32> = BTreeMap::new();
97 for channel in &channels_seen {
98 let resp: serde_json::Value = client
99 .get(format!("{cp_url}/v1/channels/{channel}"))
100 .send()
101 .await
102 .with_context(|| format!("GET {cp_url}/v1/channels/{channel}"))?
103 .error_for_status()?
104 .json()
105 .await
106 .context("parse /v1/channels response")?;
107 if let Some(window) = resp
108 .get("freshness_window_minutes")
109 .and_then(serde_json::Value::as_u64)
110 {
111 channel_freshness.insert(channel.clone(), window as u32);
112 }
113 }
114
115 let inputs = StatusInputs {
116 now: Utc::now(),
117 hosts: hosts.hosts,
118 channel_freshness,
119 };
120 Ok(render_status_table_with_color(&inputs, color))
121}
122
123pub async fn run_hosts(cfg: &ResolvedClientConfig, rollout_id: &str, json: bool) -> Result<String> {
126 let cp_url = cfg.cp_url.trim_end_matches('/');
127 let client = build_client(cfg)?;
128 let url = format!("{cp_url}/v1/rollouts/{}/hosts", rollout_id);
129 let resp = client
130 .get(&url)
131 .send()
132 .await
133 .with_context(|| format!("GET {url}"))?;
134 if resp.status() == reqwest::StatusCode::NOT_FOUND {
135 anyhow::bail!(
136 "rollout {rollout_id} has no host_rollout_records (never dispatched, or rollout id unknown)",
137 );
138 }
139 let hosts: RolloutHosts = resp
140 .error_for_status()?
141 .json()
142 .await
143 .context("parse /v1/rollouts/{id}/hosts response")?;
144 if json {
145 return serde_json::to_string_pretty(&hosts).context("serialize RolloutHosts to JSON");
146 }
147 Ok(render_hosts_table(&hosts))
148}
149
150pub async fn run_events(
156 cfg: &ResolvedClientConfig,
157 rollout_id: &str,
158 limit: Option<i64>,
159 json: bool,
160) -> Result<String> {
161 let cp_url = cfg.cp_url.trim_end_matches('/');
162 let client = build_client(cfg)?;
163 let mut url = format!("{cp_url}/v1/rollouts/{}/events", rollout_id);
164 if let Some(n) = limit {
165 url.push_str(&format!("?limit={n}"));
166 }
167 let resp = client
168 .get(&url)
169 .send()
170 .await
171 .with_context(|| format!("GET {url}"))?;
172 if resp.status() == reqwest::StatusCode::NOT_FOUND {
173 anyhow::bail!("rollout {rollout_id} unknown");
174 }
175 let events: RolloutEvents = resp
176 .error_for_status()?
177 .json()
178 .await
179 .context("parse /v1/rollouts/{id}/events response")?;
180 if json {
181 return serde_json::to_string_pretty(&events).context("serialize RolloutEvents to JSON");
182 }
183 Ok(render_events_summary(&events))
184}
185
186pub fn render_events_summary(events: &RolloutEvents) -> String {
190 let mut s = String::new();
191 s.push_str(&format!(
192 "rollout {} — {} event(s)\n",
193 events.rollout_id,
194 events.events.len()
195 ));
196 s.push_str(&format!(
197 "{:>8} {:24} {:18} {}\n",
198 "SEQ", "TS", "KIND", "HOST"
199 ));
200 for e in &events.events {
201 s.push_str(&format!(
202 "{:>8} {:24} {:18} {}\n",
203 e.seq,
204 e.ts,
205 e.kind,
206 e.host.as_deref().unwrap_or("-"),
207 ));
208 }
209 s
210}
211
212pub struct StatusInputs {
213 pub now: DateTime<Utc>,
214 pub hosts: Vec<HostStatusEntry>,
215 pub channel_freshness: BTreeMap<String, u32>,
217}
218
219pub fn render_status_table(input: &StatusInputs) -> String {
220 let mut rows: Vec<[String; 6]> = Vec::with_capacity(input.hosts.len() + 1);
221 rows.push([
222 "HOST".into(),
223 "CHANNEL".into(),
224 "CURRENT".into(),
225 "DECLARED".into(),
226 "STATUS".into(),
227 "COMPLIANCE".into(),
228 ]);
229 for host in &input.hosts {
230 rows.push([
231 host.hostname.clone(),
232 host.channel.clone(),
233 current_column(host),
234 display_hash(host.declared_closure_hash.as_deref(), "<unset>"),
235 status_label(
236 host,
237 input.now,
238 input.channel_freshness.get(&host.channel).copied(),
239 ),
240 compliance_label(host),
241 ]);
242 }
243
244 let mut widths = [0usize; 6];
245 for row in &rows {
246 for (i, col) in row.iter().enumerate() {
247 widths[i] = widths[i].max(col.chars().count());
248 }
249 }
250
251 let mut out = String::new();
252 for row in &rows {
253 for (i, col) in row.iter().enumerate() {
254 if i > 0 {
255 out.push_str(" ");
256 }
257 out.push_str(col);
258 if i + 1 < row.len() {
259 let pad = widths[i].saturating_sub(col.chars().count());
260 for _ in 0..pad {
261 out.push(' ');
262 }
263 }
264 }
265 out.push('\n');
266 }
267 out
268}
269
270pub fn render_status_table_with_color(input: &StatusInputs, color: bool) -> String {
271 use crate::color::Stylizer;
272 let st = Stylizer { enabled: color };
273 let mut rows: Vec<[(String, String); 6]> = Vec::with_capacity(input.hosts.len() + 1);
274 rows.push([
275 ("HOST".into(), "HOST".into()),
276 ("CHANNEL".into(), "CHANNEL".into()),
277 ("CURRENT".into(), "CURRENT".into()),
278 ("DECLARED".into(), "DECLARED".into()),
279 ("STATUS".into(), "STATUS".into()),
280 ("COMPLIANCE".into(), "COMPLIANCE".into()),
281 ]);
282 for host in &input.hosts {
283 let raw_status = status_label(
284 host,
285 input.now,
286 input.channel_freshness.get(&host.channel).copied(),
287 );
288 let painted = paint_status(&st, &raw_status);
289 let current = current_column(host);
290 let declared = display_hash(host.declared_closure_hash.as_deref(), "<unset>");
291 let compliance = compliance_label(host);
292 rows.push([
293 (host.hostname.clone(), host.hostname.clone()),
294 (host.channel.clone(), host.channel.clone()),
295 (current.clone(), current),
296 (declared.clone(), declared),
297 (painted, raw_status),
298 (compliance.clone(), compliance),
299 ]);
300 }
301 layout_styled(&rows)
302}
303
304fn paint_status(st: &crate::color::Stylizer, label: &str) -> String {
308 use crate::color::Style;
309 if label.contains('\u{2713}') {
310 st.paint(Style::Green, label)
311 } else if label.contains('\u{26A0}')
312 || label.contains('\u{27F3}')
313 || label.contains('\u{2192}')
314 || label.contains('\u{2026}')
315 {
316 st.paint(Style::Yellow, label)
317 } else if label.contains('\u{2717}') {
318 st.paint(Style::Red, label)
319 } else {
320 label.to_string()
321 }
322}
323
324fn layout_styled(rows: &[[(String, String); 6]]) -> String {
325 let mut widths = [0usize; 6];
326 for row in rows {
327 for (i, (_render, width_src)) in row.iter().enumerate() {
328 widths[i] = widths[i].max(width_src.chars().count());
329 }
330 }
331 let mut out = String::new();
332 for row in rows {
333 for (i, (render, width_src)) in row.iter().enumerate() {
334 if i > 0 {
335 out.push_str(" ");
336 }
337 out.push_str(render);
338 if i + 1 < row.len() {
339 let pad = widths[i].saturating_sub(width_src.chars().count());
340 for _ in 0..pad {
341 out.push(' ');
342 }
343 }
344 }
345 out.push('\n');
346 }
347 out
348}
349
350fn display_hash(h: Option<&str>, fallback: &str) -> String {
351 match h {
352 None => fallback.to_string(),
353 Some(s) if s.chars().count() <= 14 => s.to_string(),
354 Some(s) => {
355 let prefix: String = s.chars().take(13).collect();
356 format!("{prefix}\u{2026}")
357 }
358 }
359}
360
361fn current_column(host: &HostStatusEntry) -> String {
379 if let Some(c) = host.current_closure_hash.as_deref() {
380 return display_hash(Some(c), "");
381 }
382 if let Some(p) = host.pending_closure_hash.as_deref() {
383 return format!("{} \u{2192}", display_hash(Some(p), ""));
384 }
385 "<unseen>".to_string()
386}
387
388fn status_label(
389 host: &HostStatusEntry,
390 now: DateTime<Utc>,
391 freshness_minutes: Option<u32>,
392) -> String {
393 let base = base_status_label(host, now, freshness_minutes);
394 match host.pin.as_ref() {
397 Some(pin) => {
398 let short: String = pin.commit.chars().take(7).collect();
399 format!("{base} \u{1F512}{short}")
400 }
401 None => base,
402 }
403}
404
405fn base_status_label(
406 host: &HostStatusEntry,
407 now: DateTime<Utc>,
408 freshness_minutes: Option<u32>,
409) -> String {
410 use nixfleet_proto::HostRolloutState;
411
412 let quarantined = host.quarantined_closure.is_some();
417
418 let stale_label = host
423 .last_checkin_at
424 .zip(freshness_minutes)
425 .and_then(|(last, window)| {
426 let age = now.signed_duration_since(last);
427 let stale_threshold = chrono::Duration::minutes(i64::from(window) * 2);
428 (age > stale_threshold).then(|| format!("\u{26A0} stale ({})", format_age(age)))
429 });
430
431 if let Some(state) = host.rollout_state {
432 if state.is_in_flight()
433 && let Some(label) = stale_label
434 {
435 return label;
436 }
437 return match state {
438 HostRolloutState::Pending => "\u{2192} in progress".to_string(),
439 HostRolloutState::Activating => "\u{2192} activating".to_string(),
440 HostRolloutState::Deferred => {
441 "\u{25B2} pending reboot".to_string()
446 }
447 HostRolloutState::Soaking => {
448 if host.outstanding_health_failures > 0 {
451 "\u{26A0} probes failing".to_string()
452 } else {
453 "\u{2192} soaking".to_string()
454 }
455 }
456 HostRolloutState::Converged => "\u{2713} converged".to_string(),
457 HostRolloutState::Failed => {
458 if quarantined {
459 "\u{2717} failed - channel halted, push fix".to_string()
460 } else {
461 "\u{2717} failed".to_string()
462 }
463 }
464 HostRolloutState::Reverted => {
465 if quarantined {
466 "\u{2717} reverted - channel halted, push fix".to_string()
467 } else {
468 "\u{2717} reverted".to_string()
469 }
470 }
471 };
472 }
473
474 if quarantined {
478 return "\u{2717} quarantined - channel halted, push fix".to_string();
479 }
480 if host.pending_reboot {
481 return "\u{27F3} pending reboot".to_string();
482 }
483 if host.converged {
484 return "\u{2713} converged".to_string();
485 }
486
487 if host.last_checkin_at.is_none() {
488 return "\u{2717} never".to_string();
489 }
490 if let Some(label) = stale_label {
491 return label;
492 }
493 "\u{2192} in progress".to_string()
494}
495
496fn format_age(d: chrono::Duration) -> String {
497 let total_seconds = d.num_seconds().max(0);
498 if total_seconds >= 86400 {
499 format!("{}d", total_seconds / 86400)
500 } else if total_seconds >= 3600 {
501 format!("{}h", total_seconds / 3600)
502 } else {
503 format!("{}m", total_seconds / 60)
504 }
505}
506
507fn compliance_label(host: &HostStatusEntry) -> String {
508 let total = host.outstanding_compliance_failures
511 + host.outstanding_runtime_gate_errors
512 + host.outstanding_health_failures;
513 format!("{total} outstanding")
514}
515
516pub fn render_hosts_table(rollout: &RolloutHosts) -> String {
519 let mut rows: Vec<[String; 5]> = Vec::with_capacity(rollout.hosts.len() + 1);
520 rows.push([
521 "WAVE".into(),
522 "HOST".into(),
523 "DISPATCHED".into(),
524 "TERMINAL".into(),
525 "AT".into(),
526 ]);
527 for h in &rollout.hosts {
528 rows.push([
529 h.wave.to_string(),
530 h.host.clone(),
531 short_ts(&h.dispatched_at),
532 h.terminal_state.clone().unwrap_or_else(|| "<open>".into()),
533 h.terminal_at.as_deref().map(short_ts).unwrap_or_default(),
534 ]);
535 }
536
537 let mut widths = [0usize; 5];
538 for row in &rows {
539 for (i, col) in row.iter().enumerate() {
540 widths[i] = widths[i].max(col.chars().count());
541 }
542 }
543
544 let mut out = format!("rollout {}\n", rollout.rollout_id);
545 for row in &rows {
546 for (i, col) in row.iter().enumerate() {
547 if i > 0 {
548 out.push_str(" ");
549 }
550 out.push_str(col);
551 if i + 1 < row.len() {
552 let pad = widths[i].saturating_sub(col.chars().count());
553 for _ in 0..pad {
554 out.push(' ');
555 }
556 }
557 }
558 out.push('\n');
559 }
560 out
561}
562
563fn short_ts(rfc3339: &str) -> String {
566 DateTime::parse_from_rfc3339(rfc3339)
567 .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
568 .unwrap_or_else(|_| rfc3339.to_string())
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use chrono::TimeZone;
575
576 fn fixture_host(
577 hostname: &str,
578 channel: &str,
579 converged: bool,
580 last_checkin_min_ago: Option<i64>,
581 outstanding: usize,
582 ) -> HostStatusEntry {
583 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
584 HostStatusEntry {
585 hostname: hostname.into(),
586 channel: channel.into(),
587 declared_closure_hash: Some("aaaaaaaaaaaaaaaaaaaa".into()),
588 current_closure_hash: last_checkin_min_ago.map(|_| "bbbbbbbbbbbbbbbbbbbb".to_string()),
589 pending_closure_hash: None,
590 last_checkin_at: last_checkin_min_ago.map(|m| now - chrono::Duration::minutes(m)),
591 last_rollout_id: None,
592 converged,
593 outstanding_compliance_failures: outstanding,
594 outstanding_runtime_gate_errors: 0,
595 verified_event_count: 0,
596 last_uptime_secs: None,
597 rollout_state: None,
598 pending_reboot: false,
599 quarantined_closure: None,
600 pin: None,
601 outstanding_health_failures: 0,
602 }
603 }
604
605 #[test]
606 fn renders_three_status_classes() {
607 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
608 let inputs = StatusInputs {
609 now,
610 hosts: vec![
611 fixture_host("host-05", "stable", true, Some(0), 0),
612 fixture_host("host-01", "stable", false, None, 0),
613 fixture_host("host-02", "stable", false, Some(60 * 24 * 3), 2),
614 ],
615 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
616 };
617 let out = render_status_table(&inputs);
618 assert!(out.contains("\u{2713} converged"), "no converged: {out}");
619 assert!(out.contains("\u{2717} never"), "no never: {out}");
620 assert!(out.contains("\u{26A0} stale (3d)"), "no stale: {out}");
621 assert!(out.contains("HOST"));
622 assert!(out.contains("0 outstanding"));
623 assert!(out.contains("2 outstanding"));
624 }
625
626 #[test]
627 fn long_hashes_truncate_with_ellipsis() {
628 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
629 let mut h = fixture_host("a", "stable", true, Some(0), 0);
630 h.declared_closure_hash = Some("0123456789abcdef0123456789abcdef".into());
631 let inputs = StatusInputs {
632 now,
633 hosts: vec![h],
634 channel_freshness: BTreeMap::new(),
635 };
636 let out = render_status_table(&inputs);
637 assert!(
638 out.contains("0123456789abc\u{2026}"),
639 "no truncation: {out}"
640 );
641 }
642
643 #[test]
644 fn missing_freshness_window_skips_staleness_check() {
645 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
646 let inputs = StatusInputs {
647 now,
648 hosts: vec![fixture_host("a", "stable", false, Some(60 * 24 * 7), 0)],
649 channel_freshness: BTreeMap::new(),
650 };
651 let out = render_status_table(&inputs);
652 assert!(
653 out.contains("\u{2192} in progress"),
654 "fell through to in-progress without a window: {out}"
655 );
656 assert!(
657 !out.contains("stale"),
658 "shouldn't be stale without window: {out}"
659 );
660 }
661
662 #[test]
665 fn quarantined_renders_above_pending_reboot_priority() {
666 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
667 let mut h = fixture_host("a", "stable", false, Some(1), 0);
668 h.quarantined_closure = Some("broken-closure-h1".into());
669 h.pending_reboot = true;
670 let inputs = StatusInputs {
671 now,
672 hosts: vec![h],
673 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
674 };
675 let out = render_status_table(&inputs);
676 assert!(
677 out.contains("\u{2717} quarantined"),
678 "expected quarantined label: {out}",
679 );
680 assert!(
681 !out.contains("pending reboot"),
682 "quarantined must out-rank pending-reboot: {out}",
683 );
684 }
685
686 #[test]
687 fn health_failures_roll_into_outstanding_count() {
688 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
689 let mut h = fixture_host("a", "stable", true, Some(0), 1);
690 h.outstanding_runtime_gate_errors = 1;
691 h.outstanding_health_failures = 2;
692 let inputs = StatusInputs {
693 now,
694 hosts: vec![h],
695 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
696 };
697 let out = render_status_table(&inputs);
698 assert!(
699 out.contains("4 outstanding"),
700 "expected combined count: {out}"
701 );
702 }
703
704 #[test]
705 fn pin_appends_to_converged_label() {
706 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
707 let mut h = fixture_host("a", "stable", true, Some(0), 0);
708 h.pin = Some(nixfleet_proto::Pin {
709 commit: "abc12345-deadbeef".into(),
710 reason: "investigating CVE".into(),
711 expires_at: None,
712 });
713 let inputs = StatusInputs {
714 now,
715 hosts: vec![h],
716 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
717 };
718 let out = render_status_table(&inputs);
719 assert!(
720 out.contains("\u{2713} converged"),
721 "must keep converged: {out}"
722 );
723 assert!(
724 out.contains("\u{1F512}abc1234"),
725 "must show 7-char pin prefix: {out}"
726 );
727 assert!(
728 !out.contains("abc12345"),
729 "8th char must be truncated: {out}"
730 );
731 }
732
733 #[test]
736 fn pin_appends_to_failed_label_too() {
737 use nixfleet_proto::HostRolloutState;
738 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
739 let mut h = fixture_host("a", "stable", false, Some(1), 0);
740 h.rollout_state = Some(HostRolloutState::Failed);
741 h.current_closure_hash = h.declared_closure_hash.clone();
744 h.pin = Some(nixfleet_proto::Pin {
745 commit: "frozen1".into(),
746 reason: "Q2 audit".into(),
747 expires_at: None,
748 });
749 let inputs = StatusInputs {
750 now,
751 hosts: vec![h],
752 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
753 };
754 let out = render_status_table(&inputs);
755 assert!(out.contains("\u{2717} failed"));
756 assert!(out.contains("\u{1F512}frozen1"));
757 }
758
759 #[test]
760 fn pending_reboot_renders_distinctly_when_not_converged() {
761 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
762 let mut h = fixture_host("a", "stable", false, Some(1), 0);
763 h.pending_reboot = true;
764 let inputs = StatusInputs {
765 now,
766 hosts: vec![h],
767 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
768 };
769 let out = render_status_table(&inputs);
770 assert!(
771 out.contains("\u{27F3} pending reboot"),
772 "expected pending-reboot label: {out}",
773 );
774 assert!(
775 !out.contains("converged"),
776 "should not show converged: {out}"
777 );
778 assert!(
779 !out.contains("in progress"),
780 "pending-reboot is louder than in-progress: {out}"
781 );
782 }
783
784 #[test]
785 fn rollout_state_failed_takes_priority_over_converged() {
786 use nixfleet_proto::HostRolloutState;
787 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
788 let mut h = fixture_host("a", "stable", true, Some(1), 0);
789 h.rollout_state = Some(HostRolloutState::Failed);
790 h.current_closure_hash = h.declared_closure_hash.clone();
793 let inputs = StatusInputs {
794 now,
795 hosts: vec![h],
796 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
797 };
798 let out = render_status_table(&inputs);
799 assert!(
800 out.contains("\u{2717} failed"),
801 "expected failed label: {out}"
802 );
803 assert!(
804 !out.contains("converged"),
805 "should not show converged: {out}"
806 );
807 }
808
809 #[test]
814 fn rollout_state_failed_renders_as_failed_under_new_state_machine() {
815 use nixfleet_proto::HostRolloutState;
820 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
821 let mut h = fixture_host("a", "stable", false, Some(1), 0);
822 h.rollout_state = Some(HostRolloutState::Failed);
823 let inputs = StatusInputs {
824 now,
825 hosts: vec![h],
826 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
827 };
828 let out = render_status_table(&inputs);
829 assert!(
830 out.contains("\u{2717} failed"),
831 "Failed renders as '✗ failed' regardless of agent's current closure: {out}",
832 );
833 assert!(
834 !out.contains("\u{2192} reverting"),
835 "v0.1 '→ reverting' transition label is gone: {out}",
836 );
837 }
838
839 #[test]
840 fn soaked_with_failing_probes_does_not_render_as_converged() {
841 use nixfleet_proto::HostRolloutState;
842 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
843 let mut h = fixture_host("a", "stable", true, Some(1), 0);
844 h.rollout_state = Some(HostRolloutState::Soaking);
845 h.outstanding_health_failures = 1;
846 let inputs = StatusInputs {
847 now,
848 hosts: vec![h],
849 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
850 };
851 let out = render_status_table(&inputs);
852 assert!(
853 out.contains("\u{26A0} probes failing"),
854 "expected probes-failing label: {out}",
855 );
856 assert!(
857 !out.contains("\u{2713} converged"),
858 "should not show converged when probes are failing: {out}",
859 );
860 }
861
862 #[test]
863 fn healthy_with_failing_probes_does_not_render_as_converged() {
864 use nixfleet_proto::HostRolloutState;
865 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
866 let mut h = fixture_host("a", "stable", true, Some(1), 0);
869 h.rollout_state = Some(HostRolloutState::Soaking);
870 h.outstanding_health_failures = 1;
871 let inputs = StatusInputs {
872 now,
873 hosts: vec![h],
874 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
875 };
876 let out = render_status_table(&inputs);
877 assert!(
878 out.contains("\u{26A0} probes failing"),
879 "expected probes-failing label: {out}",
880 );
881 assert!(
882 !out.contains("\u{2713} converged"),
883 "should not show converged when probes are failing: {out}",
884 );
885 }
886
887 #[test]
888 fn soaking_with_no_failing_probes_renders_soaking_not_converged() {
889 use nixfleet_proto::HostRolloutState;
892 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
893 let mut h = fixture_host("a", "stable", true, Some(1), 0);
894 h.rollout_state = Some(HostRolloutState::Soaking);
895 let inputs = StatusInputs {
896 now,
897 hosts: vec![h],
898 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
899 };
900 let out = render_status_table(&inputs);
901 assert!(
902 out.contains("\u{2192} soaking"),
903 "soaking+passing must render as '→ soaking': {out}",
904 );
905 assert!(
906 !out.contains("\u{2713} converged"),
907 "must not collapse soaking into converged: {out}",
908 );
909 }
910
911 #[test]
915 fn healthy_with_passing_probes_renders_soaking_not_converged() {
916 use nixfleet_proto::HostRolloutState;
921 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
922 let mut h = fixture_host("a", "stable", true, Some(1), 0);
923 h.rollout_state = Some(HostRolloutState::Soaking);
924 let inputs = StatusInputs {
925 now,
926 hosts: vec![h],
927 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
928 };
929 let out = render_status_table(&inputs);
930 assert!(
931 !out.contains("\u{2713} converged"),
932 "must not collapse healthy into converged: {out}",
933 );
934 assert!(
935 out.contains("\u{2192} soaking"),
936 "Healthy must render as '→ soaking': {out}",
937 );
938 assert!(
939 !out.contains("\u{2192} healthy"),
940 "must not surface the raw '→ healthy' label: {out}",
941 );
942 }
943
944 #[test]
948 fn reverted_with_quarantine_appends_channel_halt_hint() {
949 use nixfleet_proto::HostRolloutState;
950 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
951 let mut h = fixture_host("a", "stable", false, Some(1), 0);
952 h.rollout_state = Some(HostRolloutState::Reverted);
953 h.quarantined_closure = Some("bad-sha".to_string());
954 let inputs = StatusInputs {
955 now,
956 hosts: vec![h],
957 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
958 };
959 let out = render_status_table(&inputs);
960 assert!(
961 out.contains("reverted") && out.contains("channel halted"),
962 "must surface channel-halt hint alongside reverted: {out}",
963 );
964 }
965
966 #[test]
968 fn reverted_without_quarantine_stays_plain() {
969 use nixfleet_proto::HostRolloutState;
970 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
971 let mut h = fixture_host("a", "stable", false, Some(1), 0);
972 h.rollout_state = Some(HostRolloutState::Reverted);
973 let inputs = StatusInputs {
974 now,
975 hosts: vec![h],
976 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
977 };
978 let out = render_status_table(&inputs);
979 assert!(
980 out.contains("reverted") && !out.contains("channel halted"),
981 "no quarantine -> no halt hint: {out}",
982 );
983 }
984
985 #[test]
987 fn quarantined_label_includes_channel_halt_hint() {
988 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
989 let mut h = fixture_host("a", "stable", false, Some(1), 0);
990 h.quarantined_closure = Some("bad-sha".to_string());
991 let inputs = StatusInputs {
992 now,
993 hosts: vec![h],
994 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
995 };
996 let out = render_status_table(&inputs);
997 assert!(
998 out.contains("quarantined") && out.contains("channel halted"),
999 "quarantined-only label must include halt hint: {out}",
1000 );
1001 }
1002
1003 #[test]
1006 fn converged_state_renders_converged() {
1007 use nixfleet_proto::HostRolloutState;
1008 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1009 let mut h = fixture_host("a", "stable", true, Some(1), 0);
1010 h.rollout_state = Some(HostRolloutState::Converged);
1011 let inputs = StatusInputs {
1012 now,
1013 hosts: vec![h],
1014 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1015 };
1016 let out = render_status_table(&inputs);
1017 assert!(
1018 out.contains("\u{2713} converged"),
1019 "Converged state must render as converged: {out}",
1020 );
1021 }
1022
1023 #[test]
1024 fn rollout_state_in_flight_renders_active_state() {
1025 use nixfleet_proto::HostRolloutState;
1026 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1027 let mut h = fixture_host("a", "stable", false, Some(1), 0);
1028 h.rollout_state = Some(HostRolloutState::Activating);
1029 let inputs = StatusInputs {
1030 now,
1031 hosts: vec![h],
1032 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1033 };
1034 let out = render_status_table(&inputs);
1035 assert!(
1036 out.contains("\u{2192} activating"),
1037 "expected activating: {out}"
1038 );
1039 }
1040
1041 #[test]
1042 fn rollout_state_soaking_renders_in_flight_not_converged() {
1043 use nixfleet_proto::HostRolloutState;
1047 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1048 let mut h = fixture_host("a", "stable", false, Some(1), 0);
1049 h.rollout_state = Some(HostRolloutState::Soaking);
1050 let inputs = StatusInputs {
1051 now,
1052 hosts: vec![h],
1053 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1054 };
1055 let out = render_status_table(&inputs);
1056 assert!(
1057 out.contains("\u{2192} soaking"),
1058 "Soaking must render in-flight: {out}",
1059 );
1060 assert!(
1061 !out.contains("\u{2713}"),
1062 "Soaking is not terminal-for-ordering in v0.2: {out}",
1063 );
1064 }
1065
1066 #[test]
1067 fn rollout_state_pending_renders_in_progress() {
1068 use nixfleet_proto::HostRolloutState;
1071 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1072 let mut h = fixture_host("a", "stable", false, Some(1), 0);
1073 h.rollout_state = Some(HostRolloutState::Pending);
1074 let inputs = StatusInputs {
1075 now,
1076 hosts: vec![h],
1077 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1078 };
1079 let out = render_status_table(&inputs);
1080 assert!(
1081 out.contains("\u{2192} in progress"),
1082 "Pending must render '→ in progress': {out}",
1083 );
1084 }
1085
1086 fn host_entry(
1087 host: &str,
1088 wave: u32,
1089 terminal: Option<&str>,
1090 ) -> nixfleet_proto::RolloutHostEntry {
1091 nixfleet_proto::RolloutHostEntry {
1092 host: host.into(),
1093 channel: "stable".into(),
1094 wave,
1095 target_closure_hash: "system-r1".into(),
1096 target_channel_ref: "stable@trace1".into(),
1097 dispatched_at: "2026-05-05T12:00:00Z".into(),
1098 terminal_state: terminal.map(String::from),
1099 terminal_at: terminal.map(|_| "2026-05-05T12:30:00Z".into()),
1100 }
1101 }
1102
1103 #[test]
1104 fn render_hosts_table_shows_open_dispatches_distinctly() {
1105 let rollout = RolloutHosts {
1106 rollout_id: nixfleet_proto::RolloutId::new("stable", "trace1"),
1107 hosts: vec![
1108 host_entry("host-05", 0, Some("converged")),
1109 host_entry("host-01", 1, None),
1110 ],
1111 };
1112 let out = render_hosts_table(&rollout);
1113 assert!(
1114 out.contains("rollout stable@trace1"),
1115 "missing header: {out}"
1116 );
1117 assert!(out.contains("WAVE"), "missing column header: {out}");
1118 assert!(out.contains("converged"), "missing terminal state: {out}");
1119 assert!(out.contains("<open>"), "missing open marker: {out}");
1120 assert!(
1121 out.contains("2026-05-05 12:00:00"),
1122 "timestamp not shortened: {out}"
1123 );
1124 }
1125
1126 #[test]
1127 fn stale_checkin_overrides_in_flight_state() {
1128 use nixfleet_proto::HostRolloutState;
1129 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1130 let mut h = fixture_host("a", "stable", false, Some(60 * 24 * 3), 0);
1131 h.rollout_state = Some(HostRolloutState::Activating);
1132 let inputs = StatusInputs {
1133 now,
1134 hosts: vec![h],
1135 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1136 };
1137 let out = render_status_table(&inputs);
1138 assert!(
1139 out.contains("\u{26A0} stale"),
1140 "stale should win over in-flight Activating: {out}"
1141 );
1142 }
1143
1144 #[test]
1146 fn run_status_json_branch_compiles() {
1147 fn _typecheck(cfg: &crate::ResolvedClientConfig) {
1148 let _fut = crate::run_status(cfg, true, false);
1149 }
1150 }
1151
1152 #[test]
1153 fn color_render_preserves_column_widths() {
1154 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1155 let inputs = StatusInputs {
1156 now,
1157 hosts: vec![
1158 fixture_host("a", "stable", true, Some(0), 0),
1159 fixture_host("verylonghostname", "stable", false, None, 0),
1160 ],
1161 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1162 };
1163 let plain = render_status_table(&inputs);
1164 let painted = render_status_table_with_color(&inputs, true);
1165 assert_eq!(plain.lines().count(), painted.lines().count());
1166 assert!(painted.contains("\x1b["), "expected ANSI in painted output");
1167 assert!(!plain.contains("\x1b["), "plain must not have ANSI escapes");
1168 let strip_ansi = |s: &str| -> String {
1171 let mut out = String::new();
1172 let mut chars = s.chars().peekable();
1173 while let Some(c) = chars.next() {
1174 if c == '\x1b' && chars.peek() == Some(&'[') {
1175 chars.next();
1176 while let Some(&c2) = chars.peek() {
1177 chars.next();
1178 if c2 == 'm' {
1179 break;
1180 }
1181 }
1182 } else {
1183 out.push(c);
1184 }
1185 }
1186 out
1187 };
1188 let painted_plain: Vec<&str> = painted.lines().collect();
1189 let stripped: Vec<String> = painted_plain.iter().map(|l| strip_ansi(l)).collect();
1190 let plain_lines: Vec<&str> = plain.lines().collect();
1191 for (a, b) in stripped.iter().zip(plain_lines.iter()) {
1192 assert_eq!(
1193 a.trim_end(),
1194 b.trim_end(),
1195 "row mismatch:\nstripped: {a}\nplain: {b}"
1196 );
1197 }
1198 }
1199
1200 #[test]
1201 fn paint_status_glyph_color_mapping_locks_in() {
1202 use nixfleet_proto::HostRolloutState;
1203 let now = Utc.with_ymd_and_hms(2026, 5, 5, 0, 0, 0).unwrap();
1204
1205 let inputs = StatusInputs {
1206 now,
1207 hosts: vec![fixture_host("a", "stable", true, Some(0), 0)],
1208 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1209 };
1210 let painted = render_status_table_with_color(&inputs, true);
1211 assert!(
1212 painted.contains("\x1b[32m") && painted.contains("\u{2713} converged"),
1213 "converged should be green: {painted}",
1214 );
1215
1216 let mut h = fixture_host("a", "stable", false, Some(1), 0);
1217 h.rollout_state = Some(HostRolloutState::Failed);
1218 h.current_closure_hash = h.declared_closure_hash.clone();
1221 let inputs = StatusInputs {
1222 now,
1223 hosts: vec![h],
1224 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1225 };
1226 let painted = render_status_table_with_color(&inputs, true);
1227 assert!(
1228 painted.contains("\x1b[31m") && painted.contains("\u{2717} failed"),
1229 "failed should be red: {painted}",
1230 );
1231
1232 let inputs = StatusInputs {
1233 now,
1234 hosts: vec![fixture_host("a", "stable", false, Some(60 * 24 * 3), 0)],
1235 channel_freshness: BTreeMap::from([("stable".to_string(), 180)]),
1236 };
1237 let painted = render_status_table_with_color(&inputs, true);
1238 assert!(
1239 painted.contains("\x1b[33m") && painted.contains("\u{26A0} stale"),
1240 "stale should be yellow: {painted}",
1241 );
1242 }
1243
1244 #[test]
1250 fn current_column_shows_pending_closure_during_rollout() {
1251 use nixfleet_proto::HostRolloutState;
1252 let mut h = fixture_host("a", "stable", false, None, 0);
1253 h.current_closure_hash = None;
1254 h.pending_closure_hash = Some("aaaa1111bbbb2222".into());
1255 h.rollout_state = Some(HostRolloutState::Activating);
1256 let rendered = current_column(&h);
1257 assert!(
1258 rendered.contains("\u{2192}"),
1259 "switch-in-progress must mark direction with arrow: {rendered}",
1260 );
1261 assert!(
1262 rendered.contains("aaaa1111bbbb2"),
1263 "switch-in-progress must show pending hash: {rendered}",
1264 );
1265 assert!(
1266 !rendered.contains("<unseen>"),
1267 "switch-in-progress must NOT render <unseen>: {rendered}",
1268 );
1269 }
1270
1271 #[test]
1278 fn current_column_renders_unseen_when_no_closure_data() {
1279 use nixfleet_proto::HostRolloutState;
1280 let mut h = fixture_host("a", "stable", false, None, 0);
1281 h.current_closure_hash = None;
1282 h.pending_closure_hash = None;
1283 h.rollout_state = Some(HostRolloutState::Pending);
1285 assert_eq!(current_column(&h), "<unseen>");
1286 h.rollout_state = None;
1287 assert_eq!(current_column(&h), "<unseen>");
1288 }
1289
1290 #[test]
1293 fn current_column_prefers_current_over_pending() {
1294 let mut h = fixture_host("a", "stable", true, Some(0), 0);
1295 h.current_closure_hash = Some("ccccdddd33334444".into());
1296 h.pending_closure_hash = Some("aaaabbbb11112222".into());
1297 let rendered = current_column(&h);
1298 assert!(rendered.contains("ccccdddd33334"));
1299 assert!(!rendered.contains("aaaabbbb"));
1300 assert!(!rendered.contains("\u{2192}"));
1301 }
1302}