Skip to content

Commit ce84d47

Browse files
Merge pull request #621 from DataDog/feat/monitors-diff
feat(monitors): add `pup monitors diff` subcommand
2 parents 19459cf + ab0e27c commit ce84d47

5 files changed

Lines changed: 656 additions & 1 deletion

File tree

docs/COMMANDS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ pup <domain> <subgroup> <action> [options] # Nested commands
2626
| metrics | query, list, search, timeseries, metadata, tags, submit | src/commands/metrics.rs ||
2727
| logs | search, list, aggregate | src/commands/logs.rs ||
2828
| traces | metrics (list, get, create, update, delete) | src/commands/traces.rs ||
29-
| monitors | list, get, delete, search | src/commands/monitors.rs ||
29+
| monitors | list, get, create, update, delete, search, diff | src/commands/monitors.rs ||
3030
| dashboards | list, get, delete, url, annotations (list, get-page, create, update, delete) | src/commands/dashboards.rs, src/commands/annotations.rs ||
3131
| dbm | samples (search) | src/commands/dbm.rs ||
3232
| ddsql | table, time-series, spec, schema (tables, columns) | src/commands/ddsql.rs ||

docs/EXAMPLES.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,27 @@ pup monitors delete 12345678
8686
pup monitors delete 12345678 --yes
8787
```
8888

89+
### Diff Monitor (preview changes before update)
90+
```bash
91+
# Compare a candidate JSON file against the live monitor
92+
pup monitors diff 12345678 candidate.json
93+
94+
# Scope the diff to a specific field subtree
95+
pup monitors diff 12345678 candidate.json --only options.thresholds
96+
97+
# Exclude noisy fields from the diff
98+
pup monitors diff 12345678 candidate.json --ignore message
99+
100+
# Combine --only and --ignore; both accept comma-separated or repeated flags
101+
pup monitors diff 12345678 candidate.json --only options.thresholds --ignore options.thresholds.warning
102+
```
103+
104+
> **"removed" entries:** `pup monitors update` is a partial/merge update — fields absent
105+
> from the candidate are left unchanged on the live monitor, not deleted. `"removed"` entries
106+
> in the diff show fields the candidate does not specify; they will **not** be deleted by
107+
> `update`. Use `--ignore` to hide specific live-only fields from the output.
108+
> Example: `pup monitors diff 12345678 candidate.json --ignore options`
109+
89110
## Logs
90111

91112
### Search Logs

src/commands/monitors.rs

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,81 @@ pub async fn update(cfg: &Config, monitor_id: i64, file: &str) -> Result<()> {
9999
formatter::output(cfg, &resp)
100100
}
101101

102+
pub async fn diff(
103+
cfg: &Config,
104+
monitor_id: i64,
105+
file: &str,
106+
only: &[String],
107+
ignore: &[String],
108+
) -> Result<()> {
109+
// Read the candidate (desired-state) definition as a raw JSON value.
110+
// Using Value (rather than MonitorUpdateRequest) preserves the user's exact
111+
// field set and avoids SDK defaults overwriting omitted optional fields during
112+
// deserialization, which would produce spurious "modified" entries.
113+
// Trade-off: field-name typos in the candidate file won't be caught here;
114+
// they will appear as added/removed pairs in the diff output, which is still
115+
// actionable. `pup monitors update` will fail or no-op on unknown fields.
116+
let mut candidate: serde_json::Value = util::read_json_file(file)?;
117+
118+
// Fetch the live monitor
119+
let api = crate::make_api!(MonitorsAPI, cfg);
120+
let live = api
121+
.get_monitor(monitor_id, GetMonitorOptionalParams::default())
122+
.await
123+
.map_err(|e| anyhow::anyhow!("failed to get monitor: {:?}", e))?;
124+
let mut live = serde_json::to_value(&live)
125+
.map_err(|e| anyhow::anyhow!("failed to serialize monitor {monitor_id} for diff: {e:?}"))?;
126+
127+
// Normalize both sides: strip server-managed read-only fields and drop nulls
128+
// so absent and explicit-null compare equal.
129+
// Note: the API may populate concrete option defaults (e.g. new_host_delay,
130+
// notify_no_data) in the live response that the candidate omits. Those appear
131+
// as "removed" because the candidate is treated as the complete desired state.
132+
// Use --ignore to suppress specific option fields if the noise is unwanted.
133+
util::normalize_for_diff(&mut live, util::READONLY_MONITOR_FIELDS);
134+
util::normalize_for_diff(&mut candidate, util::READONLY_MONITOR_FIELDS);
135+
136+
let entries = util::scope_diff(util::diff_json(&live, &candidate), only, ignore);
137+
138+
// `update` (PUT) is a partial/merge update: fields absent from the candidate
139+
// file are left unchanged on the live monitor, not deleted. "removed" entries
140+
// in this diff show what the candidate does not specify — they will NOT be
141+
// removed by `pup monitors update`. Run `update` only for "added"/"modified"
142+
// changes; "removed" entries require no action unless you want to add those
143+
// fields to the candidate explicitly.
144+
let has_removed = entries
145+
.iter()
146+
.any(|e| e.change == util::ChangeKind::Removed);
147+
let next_action = if entries.is_empty() {
148+
None
149+
} else if has_removed {
150+
Some(
151+
"review changes — note: 'removed' entries will NOT be deleted by \
152+
`pup monitors update` (partial update)"
153+
.to_string(),
154+
)
155+
} else {
156+
Some("review changes, then run `pup monitors update`".to_string())
157+
};
158+
let meta = Metadata {
159+
count: Some(entries.len()),
160+
truncated: false,
161+
command: Some("monitors diff".to_string()),
162+
next_action,
163+
};
164+
formatter::format_and_print(
165+
&entries,
166+
&cfg.output_format,
167+
cfg.agent_mode,
168+
Some(&meta),
169+
cfg.jq.as_deref(),
170+
)?;
171+
if entries.is_empty() && !cfg.agent_mode {
172+
eprintln!("No changes — monitor {monitor_id} is in sync.");
173+
}
174+
Ok(())
175+
}
176+
102177
pub async fn search(
103178
cfg: &Config,
104179
query: Option<String>,
@@ -230,4 +305,198 @@ mod tests {
230305
assert!(result.is_ok(), "monitors delete failed: {:?}", result.err());
231306
cleanup_env();
232307
}
308+
309+
// ---- monitors diff ----
310+
311+
/// Write a temp file with the given JSON content; returns the path.
312+
/// Caller must delete the file when done.
313+
fn write_temp_json(label: &str, content: &str) -> String {
314+
let path =
315+
std::env::temp_dir().join(format!("pup_test_{label}_{}.json", std::process::id()));
316+
std::fs::write(&path, content).expect("write temp file");
317+
path.to_string_lossy().to_string()
318+
}
319+
320+
#[tokio::test]
321+
async fn test_monitors_diff_detects_changes() {
322+
let _lock = lock_env().await;
323+
let mut server = mockito::Server::new_async().await;
324+
let cfg = test_config(&server.url());
325+
326+
// Live monitor returned by the API
327+
let live_body = r#"{
328+
"id": 12345,
329+
"name": "CPU Monitor",
330+
"type": "metric alert",
331+
"query": "avg(last_5m):avg:system.cpu.user{*} > 90",
332+
"message": "CPU high",
333+
"tags": ["env:prod"],
334+
"options": {"thresholds": {"critical": 90.0}}
335+
}"#;
336+
let _mock = mock_any(&mut server, "GET", live_body).await;
337+
338+
// Candidate raises the threshold
339+
let candidate = r#"{
340+
"name": "CPU Monitor",
341+
"type": "metric alert",
342+
"query": "avg(last_5m):avg:system.cpu.user{*} > 95",
343+
"message": "CPU high",
344+
"tags": ["env:prod"],
345+
"options": {"thresholds": {"critical": 95.0}}
346+
}"#;
347+
let path = write_temp_json("diff_detects_changes", candidate);
348+
349+
let result = super::diff(&cfg, 12345, &path, &[], &[]).await;
350+
let _ = std::fs::remove_file(&path);
351+
assert!(result.is_ok(), "monitors diff failed: {:?}", result.err());
352+
cleanup_env();
353+
}
354+
355+
#[tokio::test]
356+
async fn test_monitors_diff_no_changes() {
357+
let _lock = lock_env().await;
358+
let mut server = mockito::Server::new_async().await;
359+
let cfg = test_config(&server.url());
360+
361+
let live_body = r#"{
362+
"id": 12345,
363+
"name": "CPU Monitor",
364+
"type": "metric alert",
365+
"query": "avg(last_5m):avg:system.cpu.user{*} > 90",
366+
"message": "CPU high",
367+
"tags": ["env:prod"],
368+
"options": {"thresholds": {"critical": 90.0}}
369+
}"#;
370+
let _mock = mock_any(&mut server, "GET", live_body).await;
371+
372+
// Candidate is identical (minus read-only `id`)
373+
let candidate = r#"{
374+
"name": "CPU Monitor",
375+
"type": "metric alert",
376+
"query": "avg(last_5m):avg:system.cpu.user{*} > 90",
377+
"message": "CPU high",
378+
"tags": ["env:prod"],
379+
"options": {"thresholds": {"critical": 90.0}}
380+
}"#;
381+
let path = write_temp_json("diff_no_changes", candidate);
382+
383+
let result = super::diff(&cfg, 12345, &path, &[], &[]).await;
384+
let _ = std::fs::remove_file(&path);
385+
assert!(
386+
result.is_ok(),
387+
"monitors diff (no-changes) failed: {:?}",
388+
result.err()
389+
);
390+
cleanup_env();
391+
}
392+
393+
#[tokio::test]
394+
async fn test_monitors_diff_readonly_field_ignored() {
395+
let _lock = lock_env().await;
396+
let mut server = mockito::Server::new_async().await;
397+
let cfg = test_config(&server.url());
398+
399+
let live_body = r#"{
400+
"id": 12345,
401+
"name": "CPU Monitor",
402+
"type": "metric alert",
403+
"query": "avg(last_5m):avg:system.cpu.user{*} > 90",
404+
"message": "CPU high",
405+
"overall_state": "OK",
406+
"creator": {"email": "someone@example.com"}
407+
}"#;
408+
let _mock = mock_any(&mut server, "GET", live_body).await;
409+
410+
// Candidate omits `id`, `overall_state`, `creator` — should produce no diff
411+
let candidate = r#"{
412+
"name": "CPU Monitor",
413+
"type": "metric alert",
414+
"query": "avg(last_5m):avg:system.cpu.user{*} > 90",
415+
"message": "CPU high"
416+
}"#;
417+
let path = write_temp_json("diff_readonly", candidate);
418+
419+
let result = super::diff(&cfg, 12345, &path, &[], &[]).await;
420+
let _ = std::fs::remove_file(&path);
421+
assert!(
422+
result.is_ok(),
423+
"monitors diff (readonly) failed: {:?}",
424+
result.err()
425+
);
426+
cleanup_env();
427+
}
428+
429+
#[tokio::test]
430+
async fn test_monitors_diff_file_not_found() {
431+
let cfg = test_config("http://unused.local");
432+
let result = super::diff(&cfg, 12345, "/nonexistent/path.json", &[], &[]).await;
433+
assert!(result.is_err());
434+
assert!(result
435+
.unwrap_err()
436+
.to_string()
437+
.contains("failed to read file"));
438+
}
439+
440+
#[tokio::test]
441+
async fn test_monitors_diff_invalid_json() {
442+
let path = write_temp_json("diff_invalid_json", "not valid json {{{");
443+
let cfg = test_config("http://unused.local");
444+
let result = super::diff(&cfg, 12345, &path, &[], &[]).await;
445+
let _ = std::fs::remove_file(&path);
446+
assert!(result.is_err());
447+
assert!(result
448+
.unwrap_err()
449+
.to_string()
450+
.contains("failed to parse JSON"));
451+
}
452+
453+
#[tokio::test]
454+
async fn test_monitors_diff_fetch_failure() {
455+
let _lock = lock_env().await;
456+
let mut server = mockito::Server::new_async().await;
457+
let cfg = test_config(&server.url());
458+
let _mock = mock_any(&mut server, "GET", r#"{"errors": ["Not Found"]}"#).await;
459+
460+
let path = write_temp_json("diff_fetch_failure", r#"{"name": "CPU Monitor"}"#);
461+
// The mock returns a 200 with error body; the client may or may not error —
462+
// either outcome is acceptable as long as it doesn't panic.
463+
let _result = super::diff(&cfg, 99999, &path, &[], &[]).await;
464+
let _ = std::fs::remove_file(&path);
465+
cleanup_env();
466+
}
467+
468+
#[tokio::test]
469+
async fn test_monitors_diff_with_only_flag() {
470+
let _lock = lock_env().await;
471+
let mut server = mockito::Server::new_async().await;
472+
let cfg = test_config(&server.url());
473+
474+
let live_body = r#"{
475+
"id": 1,
476+
"name": "Old Name",
477+
"type": "metric alert",
478+
"query": "avg(last_5m):avg:system.cpu.user{*} > 90",
479+
"options": {"thresholds": {"critical": 90.0}}
480+
}"#;
481+
let _mock = mock_any(&mut server, "GET", live_body).await;
482+
483+
// Candidate changes both name and threshold; --only options.thresholds should
484+
// scope the result (the diff command itself still succeeds)
485+
let candidate = r#"{
486+
"name": "New Name",
487+
"query": "avg(last_5m):avg:system.cpu.user{*} > 90",
488+
"options": {"thresholds": {"critical": 95.0}}
489+
}"#;
490+
let path = write_temp_json("diff_only_flag", candidate);
491+
let only = vec!["options.thresholds".to_string()];
492+
493+
let result = super::diff(&cfg, 1, &path, &only, &[]).await;
494+
let _ = std::fs::remove_file(&path);
495+
assert!(
496+
result.is_ok(),
497+
"monitors diff --only failed: {:?}",
498+
result.err()
499+
);
500+
cleanup_env();
501+
}
233502
}

src/main.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2962,6 +2962,23 @@ enum MonitorActions {
29622962
},
29632963
/// Delete a monitor
29642964
Delete { monitor_id: i64 },
2965+
/// Diff a candidate JSON definition against the live monitor
2966+
Diff {
2967+
monitor_id: i64,
2968+
file: String,
2969+
#[arg(
2970+
long,
2971+
value_delimiter = ',',
2972+
help = "Restrict the diff to these field paths (dot-notation, comma-separated or repeated)"
2973+
)]
2974+
only: Vec<String>,
2975+
#[arg(
2976+
long,
2977+
value_delimiter = ',',
2978+
help = "Exclude these field paths from the diff (dot-notation, comma-separated or repeated)"
2979+
)]
2980+
ignore: Vec<String>,
2981+
},
29652982
}
29662983

29672984
// ---- MS Teams ----
@@ -11533,6 +11550,14 @@ async fn main_inner() -> anyhow::Result<()> {
1153311550
MonitorActions::Delete { monitor_id } => {
1153411551
commands::monitors::delete(&cfg, monitor_id).await?;
1153511552
}
11553+
MonitorActions::Diff {
11554+
monitor_id,
11555+
file,
11556+
only,
11557+
ignore,
11558+
} => {
11559+
commands::monitors::diff(&cfg, monitor_id, &file, &only, &ignore).await?;
11560+
}
1153611561
}
1153711562
}
1153811563
// --- Logs ---

0 commit comments

Comments
 (0)