Skip to main content

max / makenotwork

8.2 KB · 276 lines History Blame Raw
1 //! CLI command handlers for PoM subcommands.
2
3 mod incident;
4 mod serve;
5 mod status;
6 mod tasks;
7 mod transition;
8
9 pub(crate) use serve::cmd_serve;
10 pub(crate) use status::cmd_status;
11
12 use clap::Subcommand;
13
14 use pom::checks::{dns, http, ssh, whois};
15 use pom::config::Config;
16 use pom::db;
17 use pom::display;
18 use pom::error::{PomError, Result};
19
20 #[derive(Subcommand)]
21 pub(crate) enum HistoryKind {
22 /// Health check history
23 Health {
24 /// Filter by target
25 target: Option<String>,
26 /// Number of results
27 #[arg(short, default_value = "10")]
28 n: i64,
29 /// Output as JSON
30 #[arg(long)]
31 json: bool,
32 },
33 /// Test run history
34 Tests {
35 /// Filter by target
36 target: Option<String>,
37 /// Number of results
38 #[arg(short, default_value = "10")]
39 n: i64,
40 /// Output as JSON
41 #[arg(long)]
42 json: bool,
43 },
44 }
45
46 /// Resolve an optional `--target` filter to the target names to act on.
47 /// `None` selects every configured target; an unknown name is an error rather
48 /// than a bare `process::exit`, so every command reports failure the same way.
49 fn resolve_targets(config: &Config, target: Option<&str>) -> Result<Vec<String>> {
50 match target {
51 Some(t) if config.get_target(t).is_none() => {
52 Err(PomError::Config(format!("Unknown target: {t}")))
53 }
54 Some(t) => Ok(vec![t.to_string()]),
55 None => Ok(config.target_names()),
56 }
57 }
58
59 pub(crate) async fn cmd_health(
60 pool: &sqlx::SqlitePool,
61 config: &Config,
62 target: Option<&str>,
63 json: bool,
64 ) -> Result<()> {
65 let targets = resolve_targets(config, target)?;
66
67 let mut snapshots = Vec::new();
68
69 for name in &targets {
70 let target_config = config.get_target(name).unwrap();
71 if let Some(health_config) = &target_config.health {
72 let snapshot =
73 http::check_health(name, health_config, health_config.expect.as_ref()).await;
74 db::insert_health_check(pool, &snapshot).await?;
75 snapshots.push(snapshot);
76 } else {
77 eprintln!("{name}: no health endpoint configured");
78 }
79 }
80
81 if json {
82 println!("{}", serde_json::to_string_pretty(&snapshots)?);
83 } else {
84 print!("{}", display::format_health_snapshots(&snapshots));
85 }
86
87 Ok(())
88 }
89
90 pub(crate) async fn cmd_test(
91 pool: &sqlx::SqlitePool,
92 config: &Config,
93 target_name: &str,
94 filter: Option<&str>,
95 json: bool,
96 ) -> Result<()> {
97 let target = config
98 .get_target(target_name)
99 .ok_or_else(|| PomError::Config(format!("Unknown target: {target_name}")))?;
100 let tests_config = target.tests.as_ref().ok_or_else(|| {
101 PomError::Config(format!("Target '{target_name}' has no test configuration"))
102 })?;
103
104 eprintln!("Running tests on {target_name}...");
105 let run = ssh::run_tests(target_name, tests_config, filter).await;
106 let run_id = db::insert_test_run(pool, &run).await?;
107
108 // Store per-test details and detect regressions
109 if !run.summary.details.is_empty() {
110 db::insert_test_details(pool, run_id, &run.summary.details).await?;
111 }
112 let regressions = db::get_test_regressions(pool, target_name, run_id)
113 .await
114 .unwrap_or_default();
115
116 if json {
117 let summary = serde_json::json!({
118 "target": run.target,
119 "passed": run.passed,
120 "exit_code": run.exit_code,
121 "duration_secs": run.duration_secs,
122 "started_at": run.started_at,
123 "finished_at": run.finished_at,
124 "filter": run.filter,
125 "summary": run.summary,
126 "regressions": regressions,
127 });
128 println!("{}", serde_json::to_string_pretty(&summary)?);
129 } else {
130 print!("{}", display::format_test_result(target_name, &run));
131 if !regressions.is_empty() {
132 print!("{}", display::format_regressions(&regressions));
133 }
134 }
135
136 Ok(())
137 }
138
139 pub(crate) async fn cmd_history(pool: &sqlx::SqlitePool, kind: HistoryKind) -> Result<()> {
140 match kind {
141 HistoryKind::Health { target, n, json } => {
142 let history = db::get_health_history(pool, target.as_deref(), n).await?;
143 if json {
144 println!("{}", serde_json::to_string_pretty(&history)?);
145 } else {
146 print!("{}", display::format_health_history(&history));
147 }
148 }
149 HistoryKind::Tests { target, n, json } => {
150 let history = db::get_test_history(pool, target.as_deref(), n).await?;
151 if json {
152 let summaries: Vec<serde_json::Value> = history
153 .iter()
154 .map(|r| {
155 serde_json::json!({
156 "id": r.id,
157 "target": r.target,
158 "passed": r.passed,
159 "exit_code": r.exit_code,
160 "duration_secs": r.duration_secs,
161 "started_at": r.started_at,
162 "summary": r.summary,
163 })
164 })
165 .collect();
166 println!("{}", serde_json::to_string_pretty(&summaries)?);
167 } else {
168 print!("{}", display::format_test_history(&history));
169 }
170 }
171 }
172
173 Ok(())
174 }
175
176 pub(crate) async fn cmd_prune(pool: &sqlx::SqlitePool, days: i64) -> Result<()> {
177 let result = db::prune_old_records(pool, days).await?;
178 print!("{}", display::format_prune(&result, days));
179 Ok(())
180 }
181
182 pub(crate) async fn cmd_dns(
183 pool: &sqlx::SqlitePool,
184 config: &Config,
185 target: Option<&str>,
186 json: bool,
187 ) -> Result<()> {
188 let targets = resolve_targets(config, target)?;
189
190 let mut all_dns_results = Vec::new();
191 let mut all_whois_results = Vec::new();
192
193 for name in &targets {
194 let target_config = config.get_target(name).unwrap();
195
196 // DNS checks
197 if !target_config.dns.is_empty() {
198 let results = dns::check_dns(name, &target_config.dns).await;
199 for result in &results {
200 if let Err(e) = db::insert_dns_check(pool, result).await {
201 tracing::error!("{name}: failed to store DNS check: {e}");
202 }
203 }
204 all_dns_results.extend(results);
205 }
206
207 // WHOIS check
208 if let Some(ref whois_config) = target_config.whois {
209 let result = whois::check_whois(name, whois_config).await;
210 if let Err(e) = db::insert_whois_check(pool, &result).await {
211 tracing::error!("{name}: failed to store WHOIS check: {e}");
212 }
213 all_whois_results.push(result);
214 }
215 }
216
217 if json {
218 let output = serde_json::json!({
219 "dns": all_dns_results,
220 "whois": all_whois_results,
221 });
222 println!("{}", serde_json::to_string_pretty(&output)?);
223 } else if all_dns_results.is_empty() && all_whois_results.is_empty() {
224 println!("No DNS or WHOIS checks configured for the selected target(s).");
225 } else {
226 print!(
227 "{}",
228 display::format_dns_results(&all_dns_results, &all_whois_results)
229 );
230 }
231
232 Ok(())
233 }
234
235 pub(crate) async fn cmd_versions(
236 pool: &sqlx::SqlitePool,
237 config: &Config,
238 json: bool,
239 ) -> Result<()> {
240 let rows = pom::versions::collect(pool, config).await?;
241
242 if json {
243 println!("{}", serde_json::to_string_pretty(&rows)?);
244 } else {
245 print!("{}", display::format_versions(&rows));
246 }
247
248 Ok(())
249 }
250
251 pub(crate) async fn cmd_mesh(config: &Config, json: bool) -> Result<()> {
252 let listen = &config.serve.listen;
253 let url = format!("http://{listen}/api/mesh");
254
255 let client = pom::tls::https_client_builder()
256 .timeout(std::time::Duration::from_secs(5))
257 .build()?;
258
259 let response = client.get(&url).send().await.map_err(|e| {
260 PomError::Config(format!(
261 "Could not reach local PoM instance at {listen}: {e}"
262 ))
263 })?;
264
265 let data: serde_json::Value = response.json().await?;
266
267 if json {
268 println!("{}", serde_json::to_string_pretty(&data)?);
269 return Ok(());
270 }
271
272 print!("{}", display::format_mesh(&data));
273
274 Ok(())
275 }
276