Skip to content

Commit 28dd51b

Browse files
Merge pull request #708 from DataDog/dd/workflow-diff-issue-634-20260804-a7c9
Add reusable workflow diff
2 parents ef1906d + 0b41c03 commit 28dd51b

6 files changed

Lines changed: 370 additions & 51 deletions

File tree

docs/COMMANDS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ pup <domain> <subgroup> <action> [options] # Nested commands
7979
| fleet | agents (list, get, versions, tracers), deployments (list, get, configure, upgrade, cancel), schedules (list, get, create, update, delete, trigger), tracers (list), clusters (list), instrumented-pods (list) | src/commands/fleet.rs ||
8080
| skills | list, install, path (positional `<platform>`: claude/cursor/codex/opencode/windsurf/gemini/pi/devin/all; `--name`, `--type`, `--project` for project-local scope) | src/commands/skills.rs ||
8181
| runbooks | list, describe, run, import, validate | src/commands/runbooks.rs ||
82-
| workflows | get, create, update, delete, run, instances (list, get, cancel), connections (get, create, update, delete) | src/commands/workflows.rs ||
82+
| workflows | get, create, update, diff, delete, run, instances (list, get, cancel), connections (get, create, update, delete) | src/commands/workflows.rs ||
8383
| investigations | list, get, trigger | src/commands/investigations.rs ||
8484
| change-requests | create, get, update, create-branch, decisions (update, delete) | src/commands/change_management.rs ||
8585
| change-stories | list | src/commands/change_stories.rs ||
@@ -193,7 +193,7 @@ pup infrastructure hosts list
193193
- **hamr** - High Availability Multi-Region connections
194194
- **fleet** - Fleet Automation (agents, deployments, schedules, tracers, clusters, instrumented-pods)
195195
- **runbooks** - Local runbook execution engine (list, describe, run, import, validate)
196-
- **workflows** - Workflow Automation (get, create, update, delete, run, instances, connections)
196+
- **workflows** - Workflow Automation (get, create, update, diff, delete, run, instances, connections)
197197
- **investigations** - Bits AI SRE investigations (list, get, trigger)
198198
- **change-requests** - Change request management (create, get, update, create-branch, decisions)
199199
- **change-stories** - Change events for a service (deployments, feature flags, config, k8s, watchdog) over time window

docs/EXAMPLES.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,16 @@ pup workflows create --file=workflow.json
773773
pup workflows update <workflow-id> --file=workflow.json
774774
```
775775

776+
### Diff a Workflow
777+
```bash
778+
# Compare a candidate JSON file against the live workflow
779+
pup workflows diff <workflow-id> workflow.json
780+
781+
# Scope or suppress specific field paths
782+
pup workflows diff <workflow-id> workflow.json --only data.attributes.spec
783+
pup workflows diff <workflow-id> workflow.json --ignore data.attributes.updatedAt
784+
```
785+
776786
### Delete a Workflow
777787
```bash
778788
pup workflows delete <workflow-id>

src/commands/monitors.rs

Lines changed: 18 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -114,65 +114,39 @@ pub async fn diff(
114114
// Trade-off: field-name typos in the candidate file won't be caught here;
115115
// they will appear as added/removed pairs in the diff output, which is still
116116
// actionable. `pup monitors update` will fail or no-op on unknown fields.
117-
let mut candidate: serde_json::Value = util::read_json_file(file)?;
117+
let candidate: serde_json::Value = util::read_json_file(file)?;
118118

119119
// Fetch the live monitor
120120
let api = crate::make_api!(MonitorsAPI, cfg);
121121
let live = api
122122
.get_monitor(monitor_id, GetMonitorOptionalParams::default())
123123
.await
124124
.map_err(|e| anyhow::anyhow!("failed to get monitor: {:?}", e))?;
125-
let mut live = serde_json::to_value(&live)
125+
let live = serde_json::to_value(&live)
126126
.map_err(|e| anyhow::anyhow!("failed to serialize monitor {monitor_id} for diff: {e:?}"))?;
127127

128-
// Normalize both sides: strip server-managed read-only fields and drop nulls
129-
// so absent and explicit-null compare equal.
130-
// Note: the API may populate concrete option defaults (e.g. new_host_delay,
131-
// notify_no_data) in the live response that the candidate omits. Those appear
132-
// as "removed" because the candidate is treated as the complete desired state.
133-
// Use --ignore to suppress specific option fields if the noise is unwanted.
134-
util_ext::normalize_for_diff(&mut live, util_ext::READONLY_MONITOR_FIELDS);
135-
util_ext::normalize_for_diff(&mut candidate, util_ext::READONLY_MONITOR_FIELDS);
136-
137-
let entries = util_ext::scope_diff(util_ext::diff_json(&live, &candidate), only, ignore);
138-
139128
// `update` (PUT) is a partial/merge update: fields absent from the candidate
140129
// file are left unchanged on the live monitor, not deleted. "removed" entries
141130
// in this diff show what the candidate does not specify — they will NOT be
142131
// removed by `pup monitors update`. Run `update` only for "added"/"modified"
143132
// changes; "removed" entries require no action unless you want to add those
144133
// fields to the candidate explicitly.
145-
let has_removed = entries
146-
.iter()
147-
.any(|e| e.change == util_ext::ChangeKind::Removed);
148-
let next_action = if entries.is_empty() {
149-
None
150-
} else if has_removed {
151-
Some(
152-
"review changes — note: 'removed' entries will NOT be deleted by \
153-
`pup monitors update` (partial update)"
154-
.to_string(),
155-
)
156-
} else {
157-
Some("review changes, then run `pup monitors update`".to_string())
158-
};
159-
let meta = Metadata {
160-
count: Some(entries.len()),
161-
truncated: false,
162-
command: Some("monitors diff".to_string()),
163-
next_action,
164-
};
165-
formatter::format_and_print(
166-
&entries,
167-
&cfg.output_format,
168-
cfg.agent_mode,
169-
Some(&meta),
170-
cfg.jq.as_deref(),
171-
)?;
172-
if entries.is_empty() && !cfg.agent_mode {
173-
eprintln!("No changes — monitor {monitor_id} is in sync.");
174-
}
175-
Ok(())
134+
let resource_id = monitor_id.to_string();
135+
let mut options = util_ext::ResourceDiffOptions::new(
136+
"monitors diff",
137+
"pup monitors update",
138+
"monitor",
139+
&resource_id,
140+
);
141+
options.readonly_paths = util_ext::READONLY_MONITOR_FIELDS;
142+
options.only = only;
143+
options.ignore = ignore;
144+
options.removed_entries_next_action = Some(
145+
"review changes — note: 'removed' entries will NOT be deleted by \
146+
`pup monitors update` (partial update)",
147+
);
148+
options.no_changes_message = Some(format!("No changes — monitor {monitor_id} is in sync."));
149+
util_ext::format_resource_diff(cfg, &live, &candidate, &options)
176150
}
177151

178152
pub async fn search(

src/commands/workflows.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use datadog_api_client::datadogV2::api_workflow_automation::{
88

99
use crate::config::Config;
1010
use crate::formatter::{self, Metadata};
11+
use crate::raw_client;
1112
use crate::util;
1213
use crate::util_ext;
1314

@@ -54,6 +55,31 @@ pub async fn update(cfg: &Config, workflow_id: &str, file: &str) -> Result<()> {
5455
formatter::output(cfg, &resp)
5556
}
5657

58+
pub async fn diff(
59+
cfg: &Config,
60+
workflow_id: &str,
61+
file: &str,
62+
only: &[String],
63+
ignore: &[String],
64+
) -> Result<()> {
65+
let candidate: serde_json::Value = util::read_json_file(file)?;
66+
let live = raw_client::raw_get(cfg, &format!("/api/v2/workflows/{workflow_id}"), &[])
67+
.await
68+
.map_err(|e| anyhow::anyhow!("failed to get workflow: {e:?}"))?;
69+
70+
let mut options = util_ext::ResourceDiffOptions::new(
71+
"workflows diff",
72+
"pup workflows update",
73+
"workflow",
74+
workflow_id,
75+
);
76+
options.readonly_paths = util_ext::READONLY_WORKFLOW_FIELDS;
77+
options.only = only;
78+
options.ignore = ignore;
79+
options.no_changes_message = Some(format!("No changes - workflow {workflow_id} is in sync."));
80+
util_ext::format_resource_diff(cfg, &live, &candidate, &options)
81+
}
82+
5783
pub async fn delete(cfg: &Config, workflow_id: &str) -> Result<()> {
5884
let api = make_api(cfg);
5985
api.delete_workflow(workflow_id.to_string())
@@ -270,6 +296,69 @@ mod tests {
270296

271297
use crate::test_support::*;
272298

299+
#[tokio::test]
300+
async fn test_workflows_diff_detects_changes() {
301+
let _lock = lock_env().await;
302+
let mut server = mockito::Server::new_async().await;
303+
let cfg = test_config(&server.url());
304+
305+
let live_body = r#"{
306+
"data": {
307+
"id": "wf-123",
308+
"type": "workflows",
309+
"attributes": {
310+
"action_id": "wf-123",
311+
"name": "Old workflow",
312+
"description": "Deploy service",
313+
"spec": {"steps": [{"name": "deploy", "timeout": 60}]},
314+
"updatedAt": "2024-01-01T00:00:00Z"
315+
}
316+
}
317+
}"#;
318+
let _mock = mock_any(&mut server, "GET", live_body).await;
319+
320+
let candidate = r#"{
321+
"data": {
322+
"type": "workflows",
323+
"attributes": {
324+
"name": "New workflow",
325+
"description": "Deploy service",
326+
"spec": {"steps": [{"name": "deploy", "timeout": 90}]}
327+
}
328+
}
329+
}"#;
330+
let path = write_temp_json("pup_workflows_diff_detects_changes.json", candidate);
331+
332+
let result = super::diff(&cfg, "wf-123", path.to_str().unwrap(), &[], &[]).await;
333+
let _ = std::fs::remove_file(path);
334+
assert!(result.is_ok(), "workflows diff failed: {:?}", result.err());
335+
cleanup_env();
336+
}
337+
338+
#[tokio::test]
339+
async fn test_workflows_diff_file_not_found() {
340+
let cfg = test_config("http://unused.local");
341+
let result = super::diff(&cfg, "wf-123", "/nonexistent/path.json", &[], &[]).await;
342+
assert!(result.is_err());
343+
assert!(result
344+
.unwrap_err()
345+
.to_string()
346+
.contains("failed to read file"));
347+
}
348+
349+
#[tokio::test]
350+
async fn test_workflows_diff_invalid_json() {
351+
let path = write_temp_json("pup_workflows_diff_invalid_json.json", "not valid json {{{");
352+
let cfg = test_config("http://unused.local");
353+
let result = super::diff(&cfg, "wf-123", path.to_str().unwrap(), &[], &[]).await;
354+
let _ = std::fs::remove_file(path);
355+
assert!(result.is_err());
356+
assert!(result
357+
.unwrap_err()
358+
.to_string()
359+
.contains("failed to parse JSON"));
360+
}
361+
273362
#[tokio::test]
274363
async fn test_connections_get() {
275364
let _lock = lock_env().await;

src/main.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4744,6 +4744,23 @@ enum WorkflowActions {
47444744
#[arg(long)]
47454745
file: String,
47464746
},
4747+
/// Diff a candidate JSON definition against the live workflow
4748+
Diff {
4749+
workflow_id: String,
4750+
file: String,
4751+
#[arg(
4752+
long,
4753+
value_delimiter = ',',
4754+
help = "Restrict the diff to these field paths (dot-notation, comma-separated or repeated)"
4755+
)]
4756+
only: Vec<String>,
4757+
#[arg(
4758+
long,
4759+
value_delimiter = ',',
4760+
help = "Exclude these field paths from the diff (dot-notation, comma-separated or repeated)"
4761+
)]
4762+
ignore: Vec<String>,
4763+
},
47474764
/// Delete a workflow
47484765
Delete { workflow_id: String },
47494766
/// Execute a workflow via API trigger
@@ -16560,6 +16577,14 @@ async fn main_inner() -> anyhow::Result<()> {
1656016577
WorkflowActions::Update { workflow_id, file } => {
1656116578
commands::workflows::update(&cfg, &workflow_id, &file).await?;
1656216579
}
16580+
WorkflowActions::Diff {
16581+
workflow_id,
16582+
file,
16583+
only,
16584+
ignore,
16585+
} => {
16586+
commands::workflows::diff(&cfg, &workflow_id, &file, &only, &ignore).await?;
16587+
}
1656316588
WorkflowActions::Delete { workflow_id } => {
1656416589
commands::workflows::delete(&cfg, &workflow_id).await?;
1656516590
}

0 commit comments

Comments
 (0)