Skip to main content

max / makenotwork

Say which version of each thing is live, and how far behind Finding out that makenot.work was serving 0.11.0 at 6402bf4e while main had nine further server commits took a curl and a hand-run git log. The collection was already there: every health check records the version, and `pom status` shows it per target. What was missing was the roll-up. `pom versions` prints one row per target: live version, live commit, when it was last seen, and how many commits the checkout has moved on since. The commit is new to the health body, so old rows deserialize with a blank sha rather than failing and taking the version down with them. The count is deliberately local. It runs git against a checkout on the machine invoking the command, scoped to the deployable's subdir in a repo holding several, so the answer is "behind what I have" and a host without the repo gets a blank column instead of an error. Every blank that was a failure rather than an absence says so under the row. A sha off a monitored endpoint is untrusted input to an argument list, so it is checked as a hex object name first: git reads a leading dash as an option, not a revision.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-29 18:34 UTC
Signed with PGP, not checked
Commit: 103ff257304d45234cc3725a3912ec4ccf83c09d
Parent: af000f1
11 files changed, +760 insertions, -4 deletions
M pom/README.md +6 -1
@@ -19,11 +19,14 @@
19 19 pom serve
20 20
21 21 # Start as an MCP server (stdio transport, for Claude integration)
22 - pom mcp
22 + pom
23 23
24 24 # Show current status of all targets
25 25 pom status
26 26
27 + # Show what version each target is running, and how far behind local HEAD
28 + pom versions
29 +
27 30 # Run remote test suites via SSH
28 31 pom test
29 32
@@ -40,6 +43,7 @@
40 43 - **Alerts**: Postmark API credentials, recipient addresses, per-target cooldowns (falls back to stdout in dev mode)
41 44 - **TLS**: hosts to probe for certificate expiry warnings
42 45 - **Tests**: SSH targets and commands for remote test suite execution
46 + - **Repo**: per-target local checkout (`path`, optional `subdir`) that `pom versions` counts the live build against. Optional; without it the commits-behind column is blank
43 47
44 48 ## Module Overview
45 49
@@ -54,6 +58,7 @@
54 58 | `api.rs` | Axum HTTP API (status, trends, mesh data) |
55 59 | `alerts.rs` | Email alerts via Postmark API |
56 60 | `tools/` | MCP tool definitions for Claude integration |
61 + | `versions.rs` | Live version per target and how far it is behind the local checkout |
57 62 | `display.rs` | Terminal output formatting |
58 63 | `error.rs` | Error types |
59 64
@@ -15,7 +15,7 @@
15 15 | Module | File | Role |
16 16 |--------|------|------|
17 17 | `main` | `src/main.rs` | Entry point: parses CLI args, dispatches to CLI handler or MCP server |
18 - | `cli` | `src/cli.rs` | CLI command handlers (health, test, status, history, prune, serve, mesh) |
18 + | `cli` | `src/cli.rs` | CLI command handlers (health, test, status, versions, history, prune, serve, mesh) |
19 19 | `config` | `src/config.rs` | TOML config loading, types for targets/peers/alerts/serve settings |
20 20 | `types` | `src/types.rs` | Shared domain types: HealthSnapshot, TestRun, TlsStatus, LatencyStats, TestStaleness |
21 21 | `db` | `src/db.rs` | SQLite schema (versioned migrations), all queries for health/tests/alerts/TLS/incidents/peers |
@@ -23,6 +23,7 @@
23 23 | `alerts` | `src/alerts.rs` | Alerter struct: sends emails via Postmark on status transitions, with cooldown tracking |
24 24 | `peer` | `src/peer.rs` | Peer mesh: identity management, heartbeat loops, grace period state machine, mesh state |
25 25 | `display` | `src/display.rs` | Pure formatting functions for CLI output (no I/O) |
26 + | `versions` | `src/versions.rs` | The `pom versions` roll-up: live version/sha per target, plus commits behind the local checkout |
26 27 | `error` | `src/error.rs` | Typed error enum (PomError) wrapping IO, DB, HTTP, JSON, config errors |
27 28 | `checks::http` | `src/checks/http.rs` | HTTP health checker, response classification, expectation validation, latency drift detection, test staleness computation |
28 29 | `checks::tls` | `src/checks/tls.rs` | TLS certificate prober: TCP connect, TLS handshake, x509 leaf cert parsing |
@@ -166,7 +167,7 @@
166 167 - Non-2xx or unknown status --> Error
167 168 - Connection failure --> Unreachable
168 169
169 - Extracts version, uptime, checks, and monitoring from the JSON response body. Supports expectation validation: expected status code, required body substrings, and JSON field value assertions (with dot-path traversal for nested fields).
170 + Extracts version, git_sha, uptime, checks, and monitoring from the JSON response body. A target that reports `git_sha` gets a commits-behind figure in `pom versions`; one that does not still reports its version. Supports expectation validation: expected status code, required body substrings, and JSON field value assertions (with dot-path traversal for nested fields).
170 171
171 172 ### TLS Certificate Check
172 173
@@ -270,11 +270,28 @@
270 270 /// Scan-pipeline health check against `<base_url>/admin/uploads/health.json`.
271 271 /// `None` disables.
272 272 pub scan_pipeline: Option<ScanPipelineConfig>,
273 + /// Local checkout of the code this target runs, used by `pom versions` to
274 + /// count how far the live build is behind. `None` leaves that column blank.
275 + pub repo: Option<RepoConfig>,
273 276 /// Local systemd daemon liveness / crash-loop / failed-unit check. `None`
274 277 /// disables. Probes the host PoM runs on, not a remote target.
275 278 pub systemd: Option<SystemdConfig>,
276 279 }
277 280
281 + /// A local git checkout to measure a target's live build against.
282 + ///
283 + /// Local-only by design: the count is taken against whatever the checkout has
284 + /// on HEAD right now, on the machine running `pom versions`. On a host without
285 + /// the repo the column goes blank instead of the command failing.
286 + #[derive(Debug, Clone, Deserialize)]
287 + pub struct RepoConfig {
288 + /// Absolute path to the checkout.
289 + pub path: PathBuf,
290 + /// Path within the repo the count is scoped to (e.g. "server" in a repo
291 + /// holding several deployables). `None` counts every commit on HEAD.
292 + pub subdir: Option<String>,
293 + }
294 +
278 295 /// Local systemd unit monitoring for a host target. Watches named daemons for
279 296 /// liveness and crash-loops, and optionally sweeps the host for any failed unit.
280 297 #[derive(Debug, Clone, Deserialize)]
@@ -600,6 +617,17 @@
600 617 )));
601 618 }
602 619 }
620 + // A relative repo path would resolve against whatever directory pom
621 + // happened to be launched from, so the same config would measure a
622 + // different checkout under systemd than it does from a shell.
623 + if let Some(repo) = &target.repo
624 + && !repo.path.is_absolute()
625 + {
626 + return Err(PomError::Config(format!(
627 + "target {name}: repo.path \"{}\" must be absolute",
628 + repo.path.display()
629 + )));
630 + }
603 631 }
604 632
605 633 Ok(config)
M pom/src/display.rs +172 -1
@@ -7,7 +7,7 @@
7 7
8 8 use crate::db::{DnsCheckRow, IncidentRow, PruneResult, RouteCheckRow, TlsCheckRow, WhoisCheckRow};
9 9 use crate::types::{
10 - DnsCheckResult, HealthSnapshot, LatencyStats, TestRun, TestStaleness, WhoisResult,
10 + DnsCheckResult, HealthSnapshot, LatencyStats, TestRun, TestStaleness, VersionRow, WhoisResult,
11 11 };
12 12
13 13 /// Strip terminal control characters from an untrusted remote string before it
@@ -362,6 +362,87 @@
362 362 )
363 363 }
364 364
365 + /// Format the `pom versions` roll-up as one aligned table.
366 + ///
367 + /// Every cell is a value a monitored target chose, so all of them are scrubbed.
368 + /// A missing value prints as `-`: the table answers "what is live everywhere",
369 + /// and a row that has to be left out of the answer is itself the finding.
370 + pub fn format_versions(rows: &[VersionRow]) -> String {
371 + if rows.is_empty() {
372 + return "No targets configured.\n".to_string();
373 + }
374 +
375 + let cells: Vec<[String; 5]> = rows
376 + .iter()
377 + .map(|r| {
378 + [
379 + scrub(&r.target),
380 + r.version.as_deref().map_or_else(dash, scrub),
381 + r.git_sha.as_deref().map_or_else(dash, short_sha),
382 + r.checked_at.as_deref().map_or_else(dash, minute_stamp),
383 + r.commits_behind.map_or_else(dash, |n| n.to_string()),
384 + ]
385 + })
386 + .collect();
387 +
388 + const HEADERS: [&str; 5] = ["TARGET", "VERSION", "SHA", "CHECKED", "BEHIND"];
389 + let widths: Vec<usize> = (0..HEADERS.len())
390 + .map(|i| {
391 + cells
392 + .iter()
393 + .map(|row| row[i].chars().count())
394 + .chain(std::iter::once(HEADERS[i].len()))
395 + .max()
396 + .unwrap_or(0)
397 + })
398 + .collect();
399 +
400 + let mut out = String::new();
401 + write_row(&mut out, &HEADERS.map(String::from), &widths);
402 + for (row, source) in cells.iter().zip(rows) {
403 + write_row(&mut out, row, &widths);
404 + // The blank in the BEHIND column has a reason whenever a count was
405 + // attempted and did not come back. Printing it under the row keeps the
406 + // table one line per target while still saying what happened.
407 + if let Some(err) = &source.behind_error {
408 + writeln!(out, " behind: {}", scrub(err)).unwrap();
409 + }
410 + }
411 + out
412 + }
413 +
414 + /// Write one table row, every column but the last padded to its width.
415 + fn write_row(out: &mut String, cells: &[String; 5], widths: &[usize]) {
416 + for (i, cell) in cells.iter().enumerate() {
417 + if i + 1 == cells.len() {
418 + writeln!(out, "{cell}").unwrap();
419 + } else {
420 + let pad = widths[i].saturating_sub(cell.chars().count());
421 + write!(out, "{cell}{:pad$} ", "", pad = pad).unwrap();
422 + }
423 + }
424 + }
425 +
426 + fn dash() -> String {
427 + "-".to_string()
428 + }
429 +
430 + /// First 8 characters of a commit id, enough to identify it by eye.
431 + fn short_sha(sha: &str) -> String {
432 + scrub(sha).chars().take(8).collect()
433 + }
434 +
435 + /// RFC 3339 down to the minute: seconds and offset are noise in a table whose
436 + /// rows are minutes to hours apart. Anything that does not look like a
437 + /// timestamp is passed through rather than truncated into nonsense.
438 + fn minute_stamp(ts: &str) -> String {
439 + let scrubbed = scrub(ts);
440 + match (scrubbed.len() >= 16, scrubbed.get(..16)) {
441 + (true, Some(head)) if scrubbed.as_bytes()[10] == b'T' => head.replacen('T', " ", 1),
442 + _ => scrubbed,
443 + }
444 + }
445 +
365 446 /// Format mesh data (from JSON) for human-readable CLI display.
366 447 pub fn format_mesh(data: &serde_json::Value) -> String {
367 448 let Some(instances) = data.get("instances").and_then(|v| v.as_object()) else {
@@ -484,6 +565,7 @@
484 565 response_time_ms: 95,
485 566 details: Some(HealthDetails {
486 567 version: Some("1.2.0".to_string()),
568 + git_sha: None,
487 569 uptime: Some("5d 3h".to_string()),
488 570 checks: None,
489 571 monitoring: None,
@@ -694,6 +776,7 @@
694 776 response_time_ms: 95,
695 777 details: Some(HealthDetails {
696 778 version: Some("2.1.0".to_string()),
779 + git_sha: None,
697 780 uptime: None,
698 781 checks: None,
699 782 monitoring: None,
@@ -1494,6 +1577,94 @@
1494 1577 assert!(out.contains("Routes: 1/3 (FAIL: /docs/faq, /pricing)"));
1495 1578 }
1496 1579
1580 + // format_versions
1581 +
1582 + fn version_row(target: &str, version: Option<&str>, sha: Option<&str>) -> VersionRow {
1583 + VersionRow {
1584 + target: target.to_string(),
1585 + label: format!("{target} label"),
1586 + version: version.map(String::from),
1587 + git_sha: sha.map(String::from),
1588 + checked_at: Some("2026-07-29T18:04:37.123456+00:00".to_string()),
1589 + commits_behind: Some(8),
1590 + behind_error: None,
1591 + }
1592 + }
1593 +
1594 + #[test]
1595 + fn versions_table_aligns_columns_and_shortens_the_sha() {
1596 + let rows = vec![
1597 + version_row("mnw", Some("0.11.0"), Some("6402bf4e9c1d2e3f4a5b")),
1598 + version_row("multithreaded", Some("0.4.2"), Some("aaaabbbbcccc")),
1599 + ];
1600 + let out = format_versions(&rows);
1601 + let lines: Vec<&str> = out.lines().collect();
1602 +
1603 + assert!(lines[0].starts_with("TARGET"));
1604 + // The header pads to the widest target, so every column starts at the
1605 + // same offset on every line.
1606 + let version_col = lines[0].find("VERSION").unwrap();
1607 + assert_eq!(lines[1].find("0.11.0"), Some(version_col));
1608 + assert_eq!(lines[2].find("0.4.2"), Some(version_col));
1609 +
1610 + assert!(out.contains("6402bf4e "), "sha shortened to 8: {out}");
1611 + assert!(!out.contains("6402bf4e9c1d"));
1612 + assert!(
1613 + out.contains("2026-07-29 18:04"),
1614 + "timestamp to the minute: {out}"
1615 + );
1616 + }
1617 +
1618 + #[test]
1619 + fn versions_table_prints_a_dash_for_every_missing_value() {
1620 + let rows = vec![VersionRow {
1621 + target: "mt".to_string(),
1622 + label: "Multithreaded".to_string(),
1623 + version: None,
1624 + git_sha: None,
1625 + checked_at: None,
1626 + commits_behind: None,
1627 + behind_error: None,
1628 + }];
1629 + let out = format_versions(&rows);
1630 + let row = out.lines().nth(1).unwrap();
1631 + assert_eq!(
1632 + row.split_whitespace().collect::<Vec<_>>(),
1633 + ["mt", "-", "-", "-", "-"]
1634 + );
1635 + }
1636 +
1637 + #[test]
1638 + fn versions_table_says_why_a_count_is_blank() {
1639 + let mut row = version_row("mnw", Some("0.11.0"), Some("6402bf4e"));
1640 + row.commits_behind = None;
1641 + row.behind_error = Some("git rev-list exited 128: bad revision".to_string());
1642 + let out = format_versions(&[row]);
1643 + assert!(out.contains("behind: git rev-list exited 128"), "{out}");
1644 + }
1645 +
1646 + #[test]
1647 + fn versions_table_scrubs_a_hostile_version_string() {
1648 + // Same terminal-injection surface as every other display sink: the
1649 + // version and sha are whatever the monitored target chose to send.
1650 + let mut row = version_row("mnw", Some("1.0\u{1b}[2J FAKE"), Some("6402bf4e"));
1651 + row.behind_error = Some("boom\u{1b}[2J".to_string());
1652 + let out = format_versions(&[row]);
1653 + assert!(!out.contains('\u{1b}'), "ESC must not reach the terminal");
1654 + }
1655 +
1656 + #[test]
1657 + fn versions_empty_config_says_so() {
1658 + assert_eq!(format_versions(&[]), "No targets configured.\n");
1659 + }
1660 +
1661 + #[test]
1662 + fn minute_stamp_passes_through_anything_that_is_not_a_timestamp() {
1663 + assert_eq!(minute_stamp("2026-07-29T18:04:37Z"), "2026-07-29 18:04");
1664 + assert_eq!(minute_stamp("whenever"), "whenever");
1665 + assert_eq!(minute_stamp("2026-07-29 18:04:37"), "2026-07-29 18:04:37");
1666 + }
1667 +
1497 1668 #[test]
1498 1669 fn status_target_no_route_checks() {
1499 1670 let out = format_status_target(
@@ -21,3 +21,4 @@
21 21 pub mod tls;
22 22 pub mod tools;
23 23 pub mod types;
24 + pub mod versions;
@@ -54,6 +54,12 @@
54 54 #[arg(long)]
55 55 json: bool,
56 56 },
57 + /// Show what version each target is running, and how far behind
58 + Versions {
59 + /// Output as JSON
60 + #[arg(long)]
61 + json: bool,
62 + },
57 63 /// View history
58 64 History {
59 65 #[command(subcommand)]
@@ -151,6 +157,7 @@
151 157 json,
152 158 } => cli::cmd_test(&pool, &config, &target, filter.as_deref(), json).await,
153 159 Commands::Status { json } => cli::cmd_status(&pool, &config, json).await,
160 + Commands::Versions { json } => cli::cmd_versions(&pool, &config, json).await,
154 161 Commands::History { kind } => cli::cmd_history(&pool, kind).await,
155 162 Commands::Prune { days } => cli::cmd_prune(&pool, days).await,
156 163 Commands::Dns { target, json } => {
@@ -217,6 +217,11 @@
217 217 pub struct HealthDetails {
218 218 /// Application version string reported by the health endpoint.
219 219 pub version: Option<String>,
220 + /// Commit the running build was cut from, if the endpoint reports one.
221 + /// Absent from rows written before targets started sending it, which is
222 + /// why it deserializes as `None` rather than failing the row.
223 + #[serde(default)]
224 + pub git_sha: Option<String>,
220 225 /// Human-readable uptime string from the health endpoint (e.g. "3d 12h").
221 226 pub uptime: Option<String>,
222 227 /// Subsystem check results as freeform JSON (e.g. `{"db": "ok", "redis": "ok"}`).
@@ -225,6 +230,31 @@
225 230 pub monitoring: Option<serde_json::Value>,
226 231 }
227 232
233 + /// One row of the `pom versions` roll-up: what a target is running right now,
234 + /// and how far that is behind the local checkout it was built from.
235 + #[derive(Debug, Clone, Serialize, Deserialize)]
236 + pub struct VersionRow {
237 + /// Config key identifying the target (e.g. "mnw").
238 + pub target: String,
239 + /// Human-readable display name for the target.
240 + pub label: String,
241 + /// Version string from the target's last health check.
242 + pub version: Option<String>,
243 + /// Commit sha from the target's last health check.
244 + pub git_sha: Option<String>,
245 + /// When that health check ran, RFC 3339. `None` if the target has never
246 + /// been checked, or has no health endpoint at all.
247 + pub checked_at: Option<String>,
248 + /// Commits in the configured repo between the live sha and local HEAD.
249 + /// `None` when there is no repo configured, no live sha to anchor on, or
250 + /// the count could not be taken: the column degrades to blank rather than
251 + /// failing the roll-up.
252 + pub commits_behind: Option<i64>,
253 + /// Why `commits_behind` is blank, when the reason is a failure rather than
254 + /// a target that simply has no repo configured.
255 + pub behind_error: Option<String>,
256 + }
257 +
228 258 /// Strongly-typed wrapper for test run row IDs.
229 259 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
230 260 pub struct TestRunId(pub i64);
@@ -21,6 +21,7 @@
21 21 response_time_ms: 150,
22 22 details: Some(HealthDetails {
23 23 version: Some("1.0.0".to_string()),
24 + git_sha: None,
24 25 uptime: Some("5h 30m".to_string()),
25 26 checks: None,
26 27 monitoring: None,
@@ -574,6 +575,7 @@
574 575 response_time_ms: 95,
575 576 details: Some(HealthDetails {
576 577 version: Some("2.1.0".to_string()),
578 + git_sha: None,
577 579 uptime: Some("3d".to_string()),
578 580 checks: None,
579 581 monitoring: None,
@@ -1679,6 +1681,7 @@
1679 1681 response_time_ms: 95,
1680 1682 details: Some(HealthDetails {
1681 1683 version: Some("0.1.8".to_string()),
1684 + git_sha: None,
1682 1685 uptime: None,
1683 1686 checks: None,
1684 1687 monitoring: None,
@@ -1718,6 +1721,7 @@
1718 1721 response_time_ms: 95,
1719 1722 details: Some(HealthDetails {
1720 1723 version: Some("0.1.8".to_string()),
1724 + git_sha: None,
1721 1725 uptime: None,
1722 1726 checks: None,
1723 1727 monitoring: None,
@@ -1755,6 +1759,7 @@
1755 1759 response_time_ms: 95,
1756 1760 details: Some(HealthDetails {
1757 1761 version: Some("0.1.9".to_string()),
1762 + git_sha: None,
1758 1763 uptime: None,
1759 1764 checks: None,
1760 1765 monitoring: None,
@@ -1787,6 +1792,7 @@
1787 1792 response_time_ms: 95,
1788 1793 details: Some(HealthDetails {
1789 1794 version: Some("0.1.9".to_string()),
1795 + git_sha: None,
1790 1796 uptime: None,
1791 1797 checks: None,
1792 1798 monitoring: None,
@@ -1824,6 +1830,7 @@
1824 1830 response_time_ms: 95,
1825 1831 details: Some(HealthDetails {
1826 1832 version: Some("0.1.9".to_string()),
1833 + git_sha: None,
1827 1834 uptime: None,
1828 1835 checks: None,
1829 1836 monitoring: None,
@@ -1857,6 +1864,7 @@
1857 1864 response_time_ms: 95,
1858 1865 details: Some(HealthDetails {
1859 1866 version: Some("0.1.9".to_string()),
1867 + git_sha: None,
1860 1868 uptime: None,
1861 1869 checks: None,
1862 1870 monitoring: None,
@@ -3680,3 +3688,340 @@
3680 3688 let drift_msg = json["test_duration_drift"].as_str().unwrap();
3681 3689 assert!(drift_msg.contains("drift"), "drift message: {drift_msg}");
3682 3690 }
3691 +
3692 + // pom versions roll-up
3693 +
3694 + /// Insert one health check carrying the given version/sha for a target.
3695 + async fn insert_version_health(
3696 + pool: &sqlx::SqlitePool,
3697 + target: &str,
3698 + version: Option<&str>,
3699 + git_sha: Option<&str>,
3700 + checked_at: &str,
3701 + ) {
3702 + let snapshot = HealthSnapshot {
3703 + id: None,
3704 + target: target.to_string(),
3705 + status: HealthStatus::Operational,
3706 + checked_at: checked_at.to_string(),
3707 + response_time_ms: 100,
3708 + details: Some(HealthDetails {
3709 + version: version.map(String::from),
3710 + git_sha: git_sha.map(String::from),
3711 + uptime: None,
3712 + checks: None,
3713 + monitoring: None,
3714 + }),
3715 + error: None,
3716 + };
3717 + db::insert_health_check(pool, &snapshot).await.unwrap();
3718 + }
3719 +
3720 + /// A throwaway git repo with `commits` commits on HEAD, each touching
3721 + /// `subdir/f` when a subdir is given and a top-level file otherwise. Returns
3722 + /// the repo path and every commit sha, oldest first.
3723 + fn scratch_repo(
3724 + name: &str,
3725 + commits: usize,
3726 + subdir: Option<&str>,
3727 + ) -> (std::path::PathBuf, Vec<String>) {
3728 + use std::process::Command;
3729 +
3730 + let path = std::env::temp_dir().join(format!("pom_versions_{}_{name}", std::process::id()));
3731 + let _ = std::fs::remove_dir_all(&path);
3732 + std::fs::create_dir_all(&path).unwrap();
3733 +
3734 + let git = |args: &[&str]| {
3735 + let out = Command::new("git")
3736 + .arg("-C")
3737 + .arg(&path)
3738 + .args(args)
3739 + .output()
3740 + .unwrap();
3741 + assert!(
3742 + out.status.success(),
3743 + "git {args:?}: {}",
3744 + String::from_utf8_lossy(&out.stderr)
3745 + );
3746 + String::from_utf8_lossy(&out.stdout).trim().to_string()
3747 + };
3748 +
3749 + git(&["init", "-b", "main"]);
3750 + git(&["config", "user.email", "test@example.invalid"]);
3751 + git(&["config", "user.name", "pom test"]);
3752 +
3753 + let mut shas = Vec::new();
3754 + for i in 0..commits {
3755 + let file = match subdir {
3756 + Some(d) => {
3757 + std::fs::create_dir_all(path.join(d)).unwrap();
3758 + path.join(d).join("f")
3759 + }
3760 + None => path.join("f"),
3761 + };
3762 + std::fs::write(&file, format!("{i}\n")).unwrap();
3763 + git(&["add", "-A"]);
3764 + git(&["commit", "-m", &format!("commit {i}")]);
3765 + shas.push(git(&["rev-parse", "HEAD"]));
3766 + }
3767 +
3768 + (path, shas)
3769 + }
3770 +
3771 + #[tokio::test]
3772 + async fn versions_rollup_reads_the_latest_health_per_target() {
3773 + let pool = db::connect_in_memory().await.unwrap();
3774 + let config: pom::config::Config = toml::from_str(
3775 + r#"
3776 + [targets.mnw]
3777 + label = "MakeNotWork"
3778 + [targets.mt]
3779 + label = "Multithreaded"
3780 + "#,
3781 + )
3782 + .unwrap();
3783 +
3784 + insert_version_health(
3785 + &pool,
3786 + "mnw",
3787 + Some("0.10.0"),
3788 + Some("aaaa1111"),
3789 + "2026-07-28T00:00:00Z",
3790 + )
3791 + .await;
3792 + insert_version_health(
3793 + &pool,
3794 + "mnw",
3795 + Some("0.11.0"),
3796 + Some("6402bf4e"),
3797 + "2026-07-29T00:00:00Z",
3798 + )
3799 + .await;
3800 +
3801 + let rows = pom::versions::collect(&pool, &config).await.unwrap();
3802 + assert_eq!(rows.len(), 2);
3803 +
3804 + let mnw = rows.iter().find(|r| r.target == "mnw").unwrap();
3805 + assert_eq!(mnw.label, "MakeNotWork");
3806 + assert_eq!(mnw.version.as_deref(), Some("0.11.0"));
3807 + assert_eq!(mnw.git_sha.as_deref(), Some("6402bf4e"));
3808 + assert_eq!(mnw.checked_at.as_deref(), Some("2026-07-29T00:00:00Z"));
3809 +
3810 + // Never checked, and no repo configured: blank, and not an error.
3811 + let mt = rows.iter().find(|r| r.target == "mt").unwrap();
3812 + assert!(mt.version.is_none() && mt.checked_at.is_none());
3813 + assert!(mt.commits_behind.is_none() && mt.behind_error.is_none());
3814 + }
3815 +
3816 + #[tokio::test]
3817 + async fn versions_counts_commits_behind_local_head() {
3818 + let (path, shas) = scratch_repo("behind", 3, None);
3819 + let pool = db::connect_in_memory().await.unwrap();
3820 + let config: pom::config::Config = toml::from_str(&format!(
3821 + r#"
3822 + [targets.mnw]
3823 + label = "MakeNotWork"
3824 + [targets.mnw.repo]
3825 + path = "{}"
3826 + "#,
3827 + path.display()
3828 + ))
3829 + .unwrap();
3830 +
3831 + insert_version_health(
3832 + &pool,
3833 + "mnw",
3834 + Some("0.11.0"),
3835 + Some(&shas[0]),
3836 + "2026-07-29T00:00:00Z",
3837 + )
3838 + .await;
3839 +
3840 + let rows = pom::versions::collect(&pool, &config).await.unwrap();
3841 + assert_eq!(rows[0].commits_behind, Some(2), "row: {:?}", rows[0]);
3842 + assert!(rows[0].behind_error.is_none());
3843 +
3844 + // Live sha == HEAD is zero behind, not a missing measurement.
3845 + insert_version_health(
3846 + &pool,
3847 + "mnw",
3848 + Some("0.11.1"),
3849 + Some(&shas[2]),
3850 + "2026-07-29T01:00:00Z",
3851 + )
3852 + .await;
3853 + let rows = pom::versions::collect(&pool, &config).await.unwrap();
3854 + assert_eq!(rows[0].commits_behind, Some(0));
3855 +
3856 + std::fs::remove_dir_all(&path).unwrap();
3857 + }
3858 +
3859 + #[tokio::test]
3860 + async fn versions_scopes_the_count_to_the_configured_subdir() {
3861 + // Every commit here touches server/, then one lands outside it. Scoped to
3862 + // server/, the trailing commit must not count against the deployed build.
3863 + let (path, shas) = scratch_repo("subdir", 2, Some("server"));
3864 + std::fs::write(path.join("unrelated"), "x\n").unwrap();
3865 + let git = |args: &[&str]| {
3866 + let out = std::process::Command::new("git")
3867 + .arg("-C")
3868 + .arg(&path)
3869 + .args(args)
3870 + .output()
3871 + .unwrap();
3872 + assert!(
3873 + out.status.success(),
3874 + "{}",
3875 + String::from_utf8_lossy(&out.stderr)
3876 + );
3877 + };
3878 + git(&["add", "-A"]);
3879 + git(&["commit", "-m", "outside server"]);
3880 +
3881 + let pool = db::connect_in_memory().await.unwrap();
3882 + let config: pom::config::Config = toml::from_str(&format!(
3883 + r#"
3884 + [targets.mnw]
3885 + label = "MakeNotWork"
3886 + [targets.mnw.repo]
3887 + path = "{}"
3888 + subdir = "server"
3889 + "#,
3890 + path.display()
3891 + ))
3892 + .unwrap();
3893 +
3894 + insert_version_health(
3895 + &pool,
3896 + "mnw",
3897 + Some("0.11.0"),
3898 + Some(&shas[0]),
3899 + "2026-07-29T00:00:00Z",
3900 + )
3901 + .await;
3902 +
3903 + let rows = pom::versions::collect(&pool, &config).await.unwrap();
3904 + assert_eq!(rows[0].commits_behind, Some(1), "row: {:?}", rows[0]);
3905 +
3906 + std::fs::remove_dir_all(&path).unwrap();
3907 + }
3908 +
3909 + #[tokio::test]
3910 + async fn versions_blanks_the_column_when_the_repo_is_not_reachable() {
3911 + let pool = db::connect_in_memory().await.unwrap();
3912 + let config: pom::config::Config = toml::from_str(
3913 + r#"
3914 + [targets.mnw]
3915 + label = "MakeNotWork"
3916 + [targets.mnw.repo]
3917 + path = "/nonexistent/pom-versions-test"
3918 + "#,
3919 + )
3920 + .unwrap();
3921 +
3922 + insert_version_health(
3923 + &pool,
3924 + "mnw",
3925 + Some("0.11.0"),
3926 + Some("6402bf4e"),
3927 + "2026-07-29T00:00:00Z",
3928 + )
3929 + .await;
3930 +
3931 + let rows = pom::versions::collect(&pool, &config).await.unwrap();
3932 + assert!(rows[0].commits_behind.is_none());
3933 + assert!(
3934 + rows[0].behind_error.is_some(),
3935 + "a failed count must say why"
3936 + );
3937 + // The rest of the row still reports.
3938 + assert_eq!(rows[0].version.as_deref(), Some("0.11.0"));
3939 + }
3940 +
3941 + #[tokio::test]
3942 + async fn versions_says_when_a_target_reports_no_sha_to_anchor_on() {
3943 + let (path, _) = scratch_repo("nosha", 1, None);
3944 + let pool = db::connect_in_memory().await.unwrap();
3945 + let config: pom::config::Config = toml::from_str(&format!(
3946 + r#"
3947 + [targets.mnw]
3948 + label = "MakeNotWork"
3949 + [targets.mnw.repo]
3950 + path = "{}"
3951 + "#,
3952 + path.display()
3953 + ))
3954 + .unwrap();
3955 +
3956 + insert_version_health(&pool, "mnw", Some("0.11.0"), None, "2026-07-29T00:00:00Z").await;
3957 +
3958 + let rows = pom::versions::collect(&pool, &config).await.unwrap();
3959 + assert!(rows[0].commits_behind.is_none());
3960 + assert!(rows[0].behind_error.as_deref().unwrap().contains("git_sha"));
3961 +
3962 + std::fs::remove_dir_all(&path).unwrap();
3963 + }
3964 +
3965 + #[tokio::test]
3966 + async fn versions_rejects_a_git_sha_that_git_would_read_as_an_option() {
3967 + let (path, _) = scratch_repo("injection", 1, None);
3968 + let pool = db::connect_in_memory().await.unwrap();
3969 + let config: pom::config::Config = toml::from_str(&format!(
3970 + r#"
3971 + [targets.mnw]
3972 + label = "MakeNotWork"
3973 + [targets.mnw.repo]
3974 + path = "{}"
3975 + "#,
3976 + path.display()
3977 + ))
3978 + .unwrap();
3979 +
3980 + insert_version_health(
3981 + &pool,
3982 + "mnw",
3983 + Some("0.11.0"),
3984 + Some("--output=/tmp/pom-versions-pwned"),
3985 + "2026-07-29T00:00:00Z",
3986 + )
3987 + .await;
3988 +
3989 + let rows = pom::versions::collect(&pool, &config).await.unwrap();
3990 + assert!(rows[0].commits_behind.is_none());
3991 + assert!(
3992 + rows[0]
3993 + .behind_error
3994 + .as_deref()
3995 + .unwrap()
3996 + .contains("unusable")
3997 + );
3998 + assert!(!std::path::Path::new("/tmp/pom-versions-pwned").exists());
3999 +
4000 + std::fs::remove_dir_all(&path).unwrap();
4001 + }
4002 +
4003 + #[test]
4004 + fn health_details_written_before_git_sha_existed_still_read() {
4005 + // Rows already in health_checks have no git_sha key. They must come back as
4006 + // snapshots with a blank sha, not fail to deserialize and lose the version.
4007 + let old = r#"{"version":"0.10.0","uptime":"3d","checks":null,"monitoring":null}"#;
4008 + let details: HealthDetails = serde_json::from_str(old).unwrap();
4009 + assert_eq!(details.version.as_deref(), Some("0.10.0"));
4010 + assert!(details.git_sha.is_none());
4011 + }
4012 +
4013 + #[test]
4014 + fn repo_path_must_be_absolute() {
4015 + let toml = r#"
4016 + [targets.mnw]
4017 + label = "MakeNotWork"
4018 + [targets.mnw.repo]
4019 + path = "../server"
4020 + "#;
4021 + let tmp = std::env::temp_dir().join(format!("pom_repo_rel_{}.toml", std::process::id()));
4022 + std::fs::write(&tmp, toml).unwrap();
4023 + let result = pom::config::Config::load(Some(tmp.as_path()));
4024 + std::fs::remove_file(&tmp).unwrap();
4025 + let err = result.unwrap_err().to_string();
4026 + assert!(err.contains("must be absolute"), "error: {err}");
4027 + }
@@ -227,6 +227,10 @@
227 227 .get("version")
228 228 .and_then(|v| v.as_str())
229 229 .map(String::from),
230 + git_sha: json
231 + .get("git_sha")
232 + .and_then(|v| v.as_str())
233 + .map(String::from),
230 234 uptime: json
231 235 .get("uptime")
232 236 .and_then(|v| v.as_str())
@@ -232,6 +232,22 @@
232 232 Ok(())
233 233 }
234 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 +
235 251 pub(crate) async fn cmd_mesh(config: &Config, json: bool) -> Result<()> {
236 252 let listen = &config.serve.listen;
237 253 let url = format!("http://{listen}/api/mesh");
@@ -1,0 +1,148 @@
1 + //! The `pom versions` roll-up: what every target is running, and how far each
2 + //! live build is behind the checkout it came from.
3 + //!
4 + //! Everything but the last column is a read over the health checks already
5 + //! collected: the latest snapshot per target carries the version, the commit,
6 + //! and when it was seen. The commits-behind column is the one measurement taken
7 + //! here, and it is deliberately local. `git rev-list --count <sha>..HEAD` runs
8 + //! against a checkout on the machine invoking the command, so the answer is
9 + //! "behind what I have", and a host without the repo gets a blank column rather
10 + //! than an error.
11 + //!
12 + //! A blank is never silent about being a failure: anything that went wrong
13 + //! taking the count lands in `behind_error` and is printed beside the row.
14 +
15 + use tokio::process::Command;
16 +
17 + use crate::config::{Config, RepoConfig};
18 + use crate::db;
19 + use crate::error::Result;
20 + use crate::types::VersionRow;
21 +
22 + /// Build the roll-up for every configured target, ordered by target name.
23 + pub async fn collect(pool: &sqlx::SqlitePool, config: &Config) -> Result<Vec<VersionRow>> {
24 + let mut rows = Vec::new();
25 +
26 + for name in config.target_names() {
27 + let target = config.get_target(&name).unwrap();
28 + let health = db::get_latest_health(pool, &name).await?;
29 + let details = health.as_ref().and_then(|h| h.details.as_ref());
30 + let git_sha = details.and_then(|d| d.git_sha.clone());
31 +
32 + let (commits_behind, behind_error) = match (&target.repo, &git_sha) {
33 + (None, _) => (None, None),
34 + // A repo is configured but the target never reported a commit, so
35 + // there is nothing to anchor the count to. Say which of the two is
36 + // missing: the fix is on the target, not in pom.toml.
37 + (Some(_), None) => (
38 + None,
39 + Some("target reports no git_sha in its health body".to_string()),
40 + ),
41 + (Some(repo), Some(sha)) => match commits_behind(repo, sha).await {
42 + Ok(n) => (Some(n), None),
43 + Err(e) => (None, Some(e)),
44 + },
45 + };
46 +
47 + rows.push(VersionRow {
48 + target: name,
49 + label: target.label.clone(),
50 + version: details.and_then(|d| d.version.clone()),
51 + git_sha,
52 + checked_at: health.as_ref().map(|h| h.checked_at.clone()),
53 + commits_behind,
54 + behind_error,
55 + });
56 + }
57 +
58 + Ok(rows)
59 + }
60 +
61 + /// Count the commits between `sha` and the checkout's HEAD, scoped to the
62 + /// repo's subdir when it has one.
63 + ///
64 + /// The sha comes off a monitored target's health endpoint, so it is untrusted
65 + /// input to a subprocess argument list. It is validated as a hex object name
66 + /// before it goes anywhere near git: without that, a value like `--upload-pack=`
67 + /// or `-C/some/path` would be read by git as an option rather than a revision.
68 + /// There is no shell here to escape into, but the option-injection surface is
69 + /// real regardless.
70 + async fn commits_behind(repo: &RepoConfig, sha: &str) -> std::result::Result<i64, String> {
71 + if !is_object_name(sha) {
72 + return Err(format!("target reported an unusable git_sha: {sha:?}"));
73 + }
74 +
75 + let mut cmd = Command::new("git");
76 + cmd.arg("-C").arg(&repo.path);
77 + cmd.args(["rev-list", "--count", &format!("{sha}..HEAD")]);
78 + if let Some(subdir) = &repo.subdir {
79 + cmd.arg("--").arg(subdir);
80 + }
81 +
82 + let output = cmd
83 + .output()
84 + .await
85 + .map_err(|e| format!("could not run git in {}: {e}", repo.path.display()))?;
86 +
87 + if !output.status.success() {
88 + // The common cases are a missing checkout and a sha the local repo has
89 + // never fetched. Both are ordinary on a host that is not the build box,
90 + // so they read as a blank column plus this note, not as a hard failure.
91 + let stderr = String::from_utf8_lossy(&output.stderr);
92 + return Err(format!(
93 + "git rev-list exited {}: {}",
94 + output.status.code().unwrap_or(-1),
95 + stderr.trim()
96 + ));
97 + }
98 +
99 + parse_count(&String::from_utf8_lossy(&output.stdout))
100 + }
101 +
102 + /// Parse `git rev-list --count` output: a single decimal number on one line.
103 + fn parse_count(stdout: &str) -> std::result::Result<i64, String> {
104 + let trimmed = stdout.trim();
105 + trimmed
106 + .parse::<i64>()
107 + .map_err(|_| format!("unparseable rev-list count: {trimmed:?}"))
108 + }
109 +
110 + /// Whether a string is safe to hand to git as a revision: a hex object name,
111 + /// full or abbreviated. Deliberately narrow, since the only thing that belongs
112 + /// in the health body's `git_sha` is a commit id.
113 + fn is_object_name(s: &str) -> bool {
114 + (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit())
115 + }
116 +
117 + #[cfg(test)]
118 + mod tests {
119 + use super::*;
120 +
121 + #[test]
122 + fn parses_a_count() {
123 + assert_eq!(parse_count("8\n").unwrap(), 8);
124 + assert_eq!(parse_count("0").unwrap(), 0);
125 + }
126 +
127 + #[test]
128 + fn rejects_non_numeric_output() {
129 + assert!(parse_count("").is_err());
130 + assert!(parse_count("fatal: bad revision\n").is_err());
131 + }
132 +
133 + #[test]
134 + fn accepts_full_and_abbreviated_shas() {
135 + assert!(is_object_name("6402bf4e"));
136 + assert!(is_object_name("6402bf4e0000000000000000000000000000abcd"));
137 + }
138 +
139 + #[test]
140 + fn rejects_anything_git_could_read_as_an_option() {
141 + assert!(!is_object_name("--upload-pack=touch /tmp/pwned"));
142 + assert!(!is_object_name("-C/etc"));
143 + assert!(!is_object_name("main"));
144 + assert!(!is_object_name("6402bf4e; rm -rf /"));
145 + assert!(!is_object_name("abc"));
146 + assert!(!is_object_name(""));
147 + }
148 + }