diff --git a/src/client.rs b/src/client.rs index 95098fe..bd7dcbe 100644 --- a/src/client.rs +++ b/src/client.rs @@ -223,20 +223,12 @@ static UNSTABLE_OPS: &[&str] = &[ "v2.get_incident_postmortem_template", "v2.list_incident_postmortem_templates", "v2.update_incident_postmortem_template", - // Incident Services (5) - "v2.create_incident_service", - "v2.delete_incident_service", - "v2.get_incident_service", - "v2.list_incident_services", - "v2.update_incident_service", - // Fleet Automation (18) + // Fleet Automation (16) "v2.list_fleet_agents", "v2.get_fleet_agent_info", "v2.list_fleet_agent_versions", "v2.list_fleet_agent_tracers", "v2.list_fleet_tracers", - "v2.list_fleet_clusters", - "v2.list_fleet_instrumented_pods", "v2.list_fleet_deployments", "v2.get_fleet_deployment", "v2.create_fleet_deployment_configure", @@ -398,6 +390,30 @@ static UNSTABLE_OPS: &[&str] = &[ "v2.trigger_investigation", // Cloud Cost Management — Anomalies (1) "v2.list_cost_anomalies", + // Tag Policies (6) + "v2.create_tag_policy", + "v2.delete_tag_policy", + "v2.get_tag_policy", + "v2.get_tag_policy_score", + "v2.list_tag_policies", + "v2.update_tag_policy", + // Model Lab (16) + "v2.delete_model_lab_run", + "v2.get_model_lab_artifact_content", + "v2.get_model_lab_project", + "v2.get_model_lab_run", + "v2.list_model_lab_project_artifacts", + "v2.list_model_lab_project_facet_keys", + "v2.list_model_lab_project_facet_values", + "v2.list_model_lab_projects", + "v2.list_model_lab_run_artifacts", + "v2.list_model_lab_run_facet_keys", + "v2.list_model_lab_run_facet_values", + "v2.list_model_lab_runs", + "v2.pin_model_lab_run", + "v2.star_model_lab_project", + "v2.unpin_model_lab_run", + "v2.unstar_model_lab_project", ]; // --------------------------------------------------------------------------- @@ -1303,7 +1319,7 @@ mod tests { #[test] fn test_unstable_ops_count() { - assert_eq!(UNSTABLE_OPS.len(), 171); + assert_eq!(UNSTABLE_OPS.len(), 186); } #[test] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a8bb41c..69e03d6 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -55,6 +55,7 @@ pub mod logs; pub mod logs_restriction; pub mod metrics; pub mod misc; +pub mod model_lab; pub mod monitors; pub mod ms_teams; pub mod network; @@ -80,6 +81,7 @@ pub mod static_analysis; pub mod status_pages; pub mod symdb; pub mod synthetics; +pub mod tag_policies; pub mod tags; pub mod test; pub mod test_optimization; diff --git a/src/commands/model_lab.rs b/src/commands/model_lab.rs new file mode 100644 index 0000000..04379d6 --- /dev/null +++ b/src/commands/model_lab.rs @@ -0,0 +1,536 @@ +use anyhow::Result; +use datadog_api_client::datadogV2::api_model_lab_api::{ + ListModelLabProjectsOptionalParams, ListModelLabRunArtifactsOptionalParams, + ListModelLabRunsOptionalParams, ModelLabAPIAPI, +}; +use datadog_api_client::datadogV2::model::{ + ModelLabFacetType, ModelLabProjectFacetType, ModelLabRunStatus, +}; + +use crate::config::Config; +use crate::formatter; + +fn make_api(cfg: &Config) -> ModelLabAPIAPI { + crate::make_api!(ModelLabAPIAPI, cfg) +} + +// --- Projects --- + +pub async fn projects_list( + cfg: &Config, + filter: Option, + filter_tags: Option, + sort: Option, + page_size: Option, + page_number: Option, +) -> Result<()> { + let api = make_api(cfg); + let mut params = ListModelLabProjectsOptionalParams::default(); + if let Some(f) = filter { + params = params.filter(f); + } + if let Some(t) = filter_tags { + params = params.filter_tags(t); + } + if let Some(s) = sort { + params = params.sort(s); + } + if let Some(n) = page_size { + params = params.page_size(n); + } + if let Some(n) = page_number { + params = params.page_number(n); + } + let resp = api + .list_model_lab_projects(params) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab projects: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn projects_get(cfg: &Config, project_id: i64) -> Result<()> { + let api = make_api(cfg); + let resp = api + .get_model_lab_project(project_id) + .await + .map_err(|e| anyhow::anyhow!("failed to get model lab project: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn projects_star(cfg: &Config, project_id: i64) -> Result<()> { + let api = make_api(cfg); + api.star_model_lab_project(project_id) + .await + .map_err(|e| anyhow::anyhow!("failed to star model lab project: {e:?}"))?; + println!("Project {project_id} starred."); + Ok(()) +} + +pub async fn projects_unstar(cfg: &Config, project_id: i64) -> Result<()> { + let api = make_api(cfg); + api.unstar_model_lab_project(project_id) + .await + .map_err(|e| anyhow::anyhow!("failed to unstar model lab project: {e:?}"))?; + println!("Project {project_id} unstarred."); + Ok(()) +} + +pub async fn projects_artifacts(cfg: &Config, project_id: i64) -> Result<()> { + let api = make_api(cfg); + let resp = api + .list_model_lab_project_artifacts(project_id) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab project artifacts: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn projects_facet_keys(cfg: &Config) -> Result<()> { + let api = make_api(cfg); + let resp = api + .list_model_lab_project_facet_keys() + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab project facet keys: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn projects_facet_values( + cfg: &Config, + facet_type: &str, + facet_name: String, +) -> Result<()> { + let api = make_api(cfg); + let ft = match facet_type { + "tag" => ModelLabProjectFacetType::TAG, + other => anyhow::bail!("unknown facet type '{other}'; valid values: tag"), + }; + let resp = api + .list_model_lab_project_facet_values(ft, facet_name) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab project facet values: {e:?}"))?; + formatter::output(cfg, &resp) +} + +// --- Runs --- + +#[allow(clippy::too_many_arguments)] +pub async fn runs_list( + cfg: &Config, + filter: Option, + filter_project_id: Option, + filter_status: Option, + filter_tags: Option, + filter_params: Option, + filter_parent_run_id: Option, + pinned_first: bool, + include_pinned: bool, + sort: Option, + page_size: Option, + page_number: Option, +) -> Result<()> { + let api = make_api(cfg); + let mut params = ListModelLabRunsOptionalParams::default(); + if let Some(f) = filter { + params = params.filter(f); + } + if let Some(id) = filter_project_id { + params = params.filter_project_id(id); + } + if let Some(s) = filter_status { + let status = parse_run_status(&s)?; + params = params.filter_status(status); + } + if let Some(t) = filter_tags { + params = params.filter_tags(t); + } + if let Some(p) = filter_params { + params = params.filter_params(p); + } + if let Some(r) = filter_parent_run_id { + params = params.filter_parent_run_id(r); + } + if pinned_first { + params = params.pinned_first(true); + } + if include_pinned { + params = params.include_pinned(true); + } + if let Some(s) = sort { + params = params.sort(s); + } + if let Some(n) = page_size { + params = params.page_size(n); + } + if let Some(n) = page_number { + params = params.page_number(n); + } + let resp = api + .list_model_lab_runs(params) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab runs: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn runs_get(cfg: &Config, run_id: i64) -> Result<()> { + let api = make_api(cfg); + let resp = api + .get_model_lab_run(run_id) + .await + .map_err(|e| anyhow::anyhow!("failed to get model lab run: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn runs_delete(cfg: &Config, run_id: i64) -> Result<()> { + let api = make_api(cfg); + api.delete_model_lab_run(run_id) + .await + .map_err(|e| anyhow::anyhow!("failed to delete model lab run: {e:?}"))?; + println!("Run {run_id} deleted."); + Ok(()) +} + +pub async fn runs_pin(cfg: &Config, run_id: i64) -> Result<()> { + let api = make_api(cfg); + api.pin_model_lab_run(run_id) + .await + .map_err(|e| anyhow::anyhow!("failed to pin model lab run: {e:?}"))?; + println!("Run {run_id} pinned."); + Ok(()) +} + +pub async fn runs_unpin(cfg: &Config, run_id: i64) -> Result<()> { + let api = make_api(cfg); + api.unpin_model_lab_run(run_id) + .await + .map_err(|e| anyhow::anyhow!("failed to unpin model lab run: {e:?}"))?; + println!("Run {run_id} unpinned."); + Ok(()) +} + +pub async fn runs_artifacts(cfg: &Config, run_id: i64, path: Option) -> Result<()> { + let api = make_api(cfg); + let mut params = ListModelLabRunArtifactsOptionalParams::default(); + if let Some(p) = path { + params = params.path(p); + } + let resp = api + .list_model_lab_run_artifacts(run_id, params) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab run artifacts: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn runs_facet_keys(cfg: &Config, filter_project_id: i64) -> Result<()> { + let api = make_api(cfg); + let resp = api + .list_model_lab_run_facet_keys(filter_project_id) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab run facet keys: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn runs_facet_values( + cfg: &Config, + filter_project_id: i64, + facet_type: &str, + facet_name: String, +) -> Result<()> { + let api = make_api(cfg); + let ft = parse_facet_type(facet_type)?; + let resp = api + .list_model_lab_run_facet_values(filter_project_id, ft, facet_name) + .await + .map_err(|e| anyhow::anyhow!("failed to list model lab run facet values: {e:?}"))?; + formatter::output(cfg, &resp) +} + +fn parse_run_status(s: &str) -> Result { + match s { + "pending" => Ok(ModelLabRunStatus::PENDING), + "running" => Ok(ModelLabRunStatus::RUNNING), + "completed" => Ok(ModelLabRunStatus::COMPLETED), + "failed" => Ok(ModelLabRunStatus::FAILED), + "killed" => Ok(ModelLabRunStatus::KILLED), + "unresponsive" => Ok(ModelLabRunStatus::UNRESPONSIVE), + "paused" => Ok(ModelLabRunStatus::PAUSED), + other => anyhow::bail!( + "unknown status '{other}'; valid values: pending, running, completed, failed, killed, unresponsive, paused" + ), + } +} + +fn parse_facet_type(s: &str) -> Result { + match s { + "parameter" => Ok(ModelLabFacetType::PARAMETER), + "attribute" => Ok(ModelLabFacetType::ATTRIBUTE), + "tag" => Ok(ModelLabFacetType::TAG), + "metric" => Ok(ModelLabFacetType::METRIC), + other => anyhow::bail!( + "unknown facet type '{other}'; valid values: parameter, attribute, tag, metric" + ), + } +} + +#[cfg(test)] +mod tests { + use crate::test_support::*; + + // --- Projects --- + + #[tokio::test] + async fn test_model_lab_projects_list() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":[],"meta":{"page":{"number":0,"size":10,"total":0}}}"#, + ) + .await; + let result = super::projects_list(&cfg, None, None, None, None, None).await; + assert!(result.is_ok(), "projects list failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_projects_list_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(403) + .with_body(r#"{"errors":["Forbidden"]}"#) + .create_async() + .await; + let result = super::projects_list(&cfg, None, None, None, None, None).await; + assert!(result.is_err(), "projects list should fail on 403"); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_projects_get() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"1","type":"projects","attributes":{"artifact_storage_location":"s3://bucket","created_at":"2024-01-01T00:00:00Z","description":"test","is_starred":false,"name":"my-project","tags":[],"updated_at":"2024-01-01T00:00:00Z"}}}"#, + ) + .await; + let result = super::projects_get(&cfg, 1).await; + assert!(result.is_ok(), "projects get failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_projects_get_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(404) + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + let result = super::projects_get(&cfg, 999).await; + assert!(result.is_err(), "projects get should fail on 404"); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_projects_star() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let result = super::projects_star(&cfg, 1).await; + assert!(result.is_ok(), "projects star failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_projects_unstar() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let result = super::projects_unstar(&cfg, 1).await; + assert!(result.is_ok(), "projects unstar failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_projects_artifacts() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"1","type":"project_files","attributes":{"files":[]}}}"#, + ) + .await; + let result = super::projects_artifacts(&cfg, 1).await; + assert!( + result.is_ok(), + "projects artifacts failed: {:?}", + result.err() + ); + cleanup_env(); + } + + // --- Runs --- + + #[tokio::test] + async fn test_model_lab_runs_list() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":[],"meta":{"page":{"number":0,"size":10,"total":0}}}"#, + ) + .await; + let result = super::runs_list( + &cfg, None, None, None, None, None, None, false, false, None, None, None, + ) + .await; + assert!(result.is_ok(), "runs list failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_list_invalid_status() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":[]}"#).await; + let result = super::runs_list( + &cfg, + None, + None, + Some("bogus".to_string()), + None, + None, + None, + false, + false, + None, + None, + None, + ) + .await; + assert!(result.is_err(), "invalid status should fail"); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_get() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"42","type":"runs","attributes":{"created_at":"2024-01-01T00:00:00Z","descendant_match":false,"description":"test run","has_children":false,"is_pinned":false,"metric_summaries":[],"mlflow_artifact_location":"s3://bucket/run","name":"run-1","params":null,"project_id":1,"started_at":"2024-01-01T00:00:00Z","status":"completed","tags":[],"updated_at":"2024-01-01T00:00:00Z"}}}"#, + ) + .await; + let result = super::runs_get(&cfg, 42).await; + assert!(result.is_ok(), "runs get failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_get_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(404) + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + let result = super::runs_get(&cfg, 999).await; + assert!(result.is_err(), "runs get should fail on 404"); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_delete() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let result = super::runs_delete(&cfg, 42).await; + assert!(result.is_ok(), "runs delete failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_delete_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("DELETE", mockito::Matcher::Any) + .with_status(404) + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + let result = super::runs_delete(&cfg, 999).await; + assert!(result.is_err(), "runs delete should fail on 404"); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_pin() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let result = super::runs_pin(&cfg, 42).await; + assert!(result.is_ok(), "runs pin failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_unpin() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let result = super::runs_unpin(&cfg, 42).await; + assert!(result.is_ok(), "runs unpin failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_runs_artifacts() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":{"id":"42","type":"artifacts","attributes":{"files":[],"path_in_project":""}}}"#).await; + let result = super::runs_artifacts(&cfg, 42, None).await; + assert!(result.is_ok(), "runs artifacts failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_model_lab_parse_run_status_valid() { + for s in &[ + "pending", + "running", + "completed", + "failed", + "killed", + "unresponsive", + "paused", + ] { + assert!( + super::parse_run_status(s).is_ok(), + "expected '{s}' to be valid" + ); + } + } + + #[tokio::test] + async fn test_model_lab_parse_run_status_invalid() { + assert!(super::parse_run_status("bogus").is_err()); + } +} diff --git a/src/commands/tag_policies.rs b/src/commands/tag_policies.rs new file mode 100644 index 0000000..9bbf92c --- /dev/null +++ b/src/commands/tag_policies.rs @@ -0,0 +1,316 @@ +use anyhow::Result; +use datadog_api_client::datadogV2::api_tag_policies::{ + DeleteTagPolicyOptionalParams, GetTagPolicyOptionalParams, GetTagPolicyScoreOptionalParams, + ListTagPoliciesOptionalParams, TagPoliciesAPI, +}; +use datadog_api_client::datadogV2::model::{TagPolicyCreateRequest, TagPolicyUpdateRequest}; + +use crate::config::Config; +use crate::formatter; +use crate::util; + +fn make_api(cfg: &Config) -> TagPoliciesAPI { + crate::make_api!(TagPoliciesAPI, cfg) +} + +pub async fn list( + cfg: &Config, + include_disabled: bool, + include_deleted: bool, + include_score: bool, + filter_source: Option, +) -> Result<()> { + let api = make_api(cfg); + let mut params = ListTagPoliciesOptionalParams::default(); + if include_disabled { + params = params.include_disabled(true); + } + if include_deleted { + params = params.include_deleted(true); + } + if include_score { + params = params.include(datadog_api_client::datadogV2::model::TagPolicyInclude::SCORE); + } + if let Some(src) = filter_source { + let source = match src.as_str() { + "logs" => datadog_api_client::datadogV2::model::TagPolicySource::LOGS, + "spans" => datadog_api_client::datadogV2::model::TagPolicySource::SPANS, + "metrics" => datadog_api_client::datadogV2::model::TagPolicySource::METRICS, + "rum" => datadog_api_client::datadogV2::model::TagPolicySource::RUM, + "feed" => datadog_api_client::datadogV2::model::TagPolicySource::FEED, + other => anyhow::bail!( + "unknown source '{other}'; valid values: logs, spans, metrics, rum, feed" + ), + }; + params = params.filter_source(source); + } + let resp = api + .list_tag_policies(params) + .await + .map_err(|e| anyhow::anyhow!("failed to list tag policies: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn get(cfg: &Config, policy_id: &str, include_score: bool) -> Result<()> { + let api = make_api(cfg); + let mut params = GetTagPolicyOptionalParams::default(); + if include_score { + params = params.include(datadog_api_client::datadogV2::model::TagPolicyInclude::SCORE); + } + let resp = api + .get_tag_policy(policy_id.to_string(), params) + .await + .map_err(|e| anyhow::anyhow!("failed to get tag policy: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn create(cfg: &Config, file: &str) -> Result<()> { + let api = make_api(cfg); + let body: TagPolicyCreateRequest = util::read_json_file(file)?; + let resp = api + .create_tag_policy(body) + .await + .map_err(|e| anyhow::anyhow!("failed to create tag policy: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn update(cfg: &Config, policy_id: &str, file: &str) -> Result<()> { + let api = make_api(cfg); + let body: TagPolicyUpdateRequest = util::read_json_file(file)?; + let resp = api + .update_tag_policy(policy_id.to_string(), body) + .await + .map_err(|e| anyhow::anyhow!("failed to update tag policy: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn delete(cfg: &Config, policy_id: &str, hard_delete: bool) -> Result<()> { + let api = make_api(cfg); + let mut params = DeleteTagPolicyOptionalParams::default(); + if hard_delete { + params = params.hard_delete(true); + } + api.delete_tag_policy(policy_id.to_string(), params) + .await + .map_err(|e| anyhow::anyhow!("failed to delete tag policy: {e:?}"))?; + println!("Tag policy {policy_id} deleted."); + Ok(()) +} + +pub async fn score( + cfg: &Config, + policy_id: &str, + ts_start: Option, + ts_end: Option, +) -> Result<()> { + let api = make_api(cfg); + let mut params = GetTagPolicyScoreOptionalParams::default(); + if let Some(s) = ts_start { + params = params.ts_start(s); + } + if let Some(e) = ts_end { + params = params.ts_end(e); + } + let resp = api + .get_tag_policy_score(policy_id.to_string(), params) + .await + .map_err(|e| anyhow::anyhow!("failed to get tag policy score: {e:?}"))?; + formatter::output(cfg, &resp) +} + +#[cfg(test)] +mod tests { + use crate::test_support::*; + + #[tokio::test] + async fn test_tag_policies_list() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":[]}"#).await; + let result = super::list(&cfg, false, false, false, None).await; + assert!( + result.is_ok(), + "tag policies list failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_list_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(403) + .with_body(r#"{"errors":["Forbidden"]}"#) + .create_async() + .await; + let result = super::list(&cfg, false, false, false, None).await; + assert!(result.is_err(), "tag policies list should fail on 403"); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_list_invalid_source() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":[]}"#).await; + let result = super::list(&cfg, false, false, false, Some("invalid".to_string())).await; + assert!(result.is_err(), "unknown source should fail"); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_get() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"pol-1","type":"tag_policies","attributes":{"created_at":"2024-01-01T00:00:00Z","created_by":"u","enabled":true,"modified_at":"2024-01-01T00:00:00Z","modified_by":"u","negated":false,"policy_name":"p","policy_type":"blocking","required":true,"scope":"org","source":"logs","tag_key":"env","tag_value_patterns":[],"version":1}}}"#, + ) + .await; + let result = super::get(&cfg, "pol-1", false).await; + assert!( + result.is_ok(), + "tag policies get failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_get_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(404) + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + let result = super::get(&cfg, "missing", false).await; + assert!(result.is_err(), "get should fail for missing policy"); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_create() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"pol-1","type":"tag_policies","attributes":{"created_at":"2024-01-01T00:00:00Z","created_by":"u","enabled":true,"modified_at":"2024-01-01T00:00:00Z","modified_by":"u","negated":false,"policy_name":"p","policy_type":"blocking","required":true,"scope":"org","source":"logs","tag_key":"env","tag_value_patterns":[],"version":1}}}"#, + ) + .await; + let tmp = write_temp_json( + "tag_policy_create.json", + r#"{"data":{"type":"tag_policies","attributes":{"policy_name":"p","policy_type":"surfacing","scope":"org","source":"logs","tag_key":"env","tag_value_patterns":[]}}}"#, + ); + let result = super::create(&cfg, tmp.to_str().unwrap()).await; + assert!( + result.is_ok(), + "tag policies create failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_create_bad_file() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "{}").await; + let result = super::create(&cfg, "/nonexistent/file.json").await; + assert!(result.is_err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_update() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"pol-1","type":"tag_policies","attributes":{"created_at":"2024-01-01T00:00:00Z","created_by":"u","enabled":true,"modified_at":"2024-01-01T00:00:00Z","modified_by":"u","negated":false,"policy_name":"p","policy_type":"blocking","required":true,"scope":"org","source":"logs","tag_key":"env","tag_value_patterns":[],"version":1}}}"#, + ) + .await; + let tmp = write_temp_json( + "tag_policy_update.json", + r#"{"data":{"id":"pol-1","type":"tag_policies","attributes":{"tag_key":"env","policy_type":"require"}}}"#, + ); + let result = super::update(&cfg, "pol-1", tmp.to_str().unwrap()).await; + assert!( + result.is_ok(), + "tag policies update failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_delete() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let result = super::delete(&cfg, "pol-1", false).await; + assert!( + result.is_ok(), + "tag policies delete failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_delete_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("DELETE", mockito::Matcher::Any) + .with_status(404) + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + let result = super::delete(&cfg, "missing", false).await; + assert!(result.is_err(), "delete should fail for missing policy"); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_score() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":{"id":"pol-1","type":"tag_policy_score","attributes":{"score":null,"ts_start":0,"ts_end":0,"version":1}}}"#).await; + let result = super::score(&cfg, "pol-1", None, None).await; + assert!( + result.is_ok(), + "tag policies score failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_tag_policies_score_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(403) + .with_body(r#"{"errors":["Forbidden"]}"#) + .create_async() + .await; + let result = super::score(&cfg, "pol-1", None, None).await; + assert!(result.is_err(), "score should fail on 403"); + cleanup_env(); + } +} diff --git a/src/config.rs b/src/config.rs index 4e3d38a..d82aae2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1936,6 +1936,7 @@ mod tests { auto_approve: false, agent_mode: false, read_only: false, + jq: None, }; super::apply_org_override(&mut cfg, "unknown-org".into()).unwrap(); diff --git a/src/main.rs b/src/main.rs index f95f1ad..cba56b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1879,6 +1879,30 @@ enum Commands { #[command(subcommand)] action: MiscActions, }, + /// Explore Model Lab projects and runs + /// + /// Browse ML experiment projects and their training runs in Datadog Model Lab. + /// + /// SUBCOMMANDS: + /// projects Manage Model Lab projects + /// runs Manage Model Lab runs + /// + /// EXAMPLES: + /// pup model-lab projects list + /// pup model-lab projects get 123 + /// pup model-lab projects star 123 + /// pup model-lab runs list --filter-project-id 123 + /// pup model-lab runs get 456 + /// pup model-lab runs delete 456 + /// pup model-lab runs pin 456 + /// + /// AUTHENTICATION: + /// Requires either OAuth2 authentication or API keys. + #[command(name = "model-lab", verbatim_doc_comment)] + ModelLab { + #[command(subcommand)] + action: ModelLabActions, + }, /// Manage monitors /// /// Manage Datadog monitors for alerting and notifications. @@ -2603,6 +2627,35 @@ enum Commands { #[command(subcommand)] action: SyntheticsActions, }, + /// Manage tag policies for governance and compliance + /// + /// Create, list, get, update, and delete tag policies. Tag policies enforce + /// required tag keys and allowed values across your Datadog resources, and + /// provide compliance scoring to measure adherence. + /// + /// COMMANDS: + /// list List all tag policies + /// get Get a tag policy by ID + /// create --file Create a tag policy from JSON + /// update -f Update a tag policy + /// delete Delete a tag policy + /// score Get the overall tag policy compliance score + /// + /// EXAMPLES: + /// pup tag-policies list + /// pup tag-policies list --include-score + /// pup tag-policies list --filter-source api + /// pup tag-policies get pol-abc123 --include-score + /// pup tag-policies create --file policy.json + /// pup tag-policies score pol-abc123 + /// + /// AUTHENTICATION: + /// Requires either OAuth2 authentication or API keys. + #[command(name = "tag-policies", verbatim_doc_comment)] + TagPolicies { + #[command(subcommand)] + action: TagPoliciesActions, + }, /// Manage host tags /// /// Manage tags for hosts in your infrastructure. @@ -4101,6 +4154,65 @@ enum TagActions { Delete { hostname: String }, } +// ---- Tag Policies ---- +#[derive(Subcommand)] +enum TagPoliciesActions { + /// List all tag policies + List { + #[arg(long, default_value_t = false, help = "Include disabled policies")] + include_disabled: bool, + #[arg(long, default_value_t = false, help = "Include soft-deleted policies")] + include_deleted: bool, + #[arg( + long, + default_value_t = false, + help = "Include compliance score in response" + )] + include_score: bool, + #[arg(long, help = "Filter by policy source: api, terraform, ui")] + filter_source: Option, + }, + /// Get a tag policy by ID + Get { + policy_id: String, + #[arg( + long, + default_value_t = false, + help = "Include compliance score in response" + )] + include_score: bool, + }, + /// Create a tag policy from a JSON file + Create { + #[arg(long, help = "JSON file with TagPolicyCreateRequest body")] + file: String, + }, + /// Update a tag policy from a JSON file + Update { + policy_id: String, + #[arg(long, short, help = "JSON file with TagPolicyUpdateRequest body")] + file: String, + }, + /// Delete a tag policy + Delete { + policy_id: String, + #[arg( + long, + default_value_t = false, + help = "Permanently delete (default: soft delete)" + )] + hard_delete: bool, + }, + /// Get the compliance score for a tag policy + Score { + policy_id: String, + #[arg(long, help = "Start of scoring window (Unix ms timestamp)")] + ts_start: Option, + #[arg(long, help = "End of scoring window (Unix ms timestamp)")] + ts_end: Option, + }, +} + // ---- Users ---- #[derive(Subcommand)] enum UserActions { @@ -8034,6 +8146,119 @@ enum MiscActions { Status, } +// ---- Model Lab ---- +#[derive(Subcommand)] +enum ModelLabActions { + /// Manage Model Lab projects + Projects { + #[command(subcommand)] + action: ModelLabProjectActions, + }, + /// Manage Model Lab runs + Runs { + #[command(subcommand)] + action: ModelLabRunActions, + }, +} + +#[derive(Subcommand)] +enum ModelLabProjectActions { + /// List Model Lab projects + List { + #[arg(long, help = "Filter query string")] + filter: Option, + #[arg(long, help = "Filter by tags (comma-separated)")] + filter_tags: Option, + #[arg(long, help = "Sort field (e.g. name, created_at)")] + sort: Option, + #[arg(long)] + page_size: Option, + #[arg(long, help = "Page number (0-indexed)")] + page_number: Option, + }, + /// Get a Model Lab project by ID + Get { project_id: i64 }, + /// Star a Model Lab project + Star { project_id: i64 }, + /// Unstar a Model Lab project + Unstar { project_id: i64 }, + /// List artifacts for a Model Lab project + Artifacts { project_id: i64 }, + /// List facet keys for Model Lab projects + #[command(name = "facet-keys")] + FacetKeys, + /// List facet values for a Model Lab project facet + #[command(name = "facet-values")] + FacetValues { + #[arg(help = "Facet type (tag)")] + facet_type: String, + #[arg(help = "Facet name")] + facet_name: String, + }, +} + +#[derive(Subcommand)] +enum ModelLabRunActions { + /// List Model Lab runs + List { + #[arg(long, help = "Filter query string")] + filter: Option, + #[arg(long, help = "Filter by project ID")] + filter_project_id: Option, + #[arg( + long, + help = "Filter by status: pending, running, completed, failed, killed, unresponsive, paused" + )] + filter_status: Option, + #[arg(long, help = "Filter by tags (comma-separated)")] + filter_tags: Option, + #[arg(long, help = "Filter by params")] + filter_params: Option, + #[arg(long, help = "Filter by parent run ID")] + filter_parent_run_id: Option, + #[arg(long, default_value_t = false, help = "Show pinned runs first")] + pinned_first: bool, + #[arg(long, default_value_t = false, help = "Include pinned runs")] + include_pinned: bool, + #[arg(long, help = "Sort field")] + sort: Option, + #[arg(long)] + page_size: Option, + #[arg(long, help = "Page number (0-indexed)")] + page_number: Option, + }, + /// Get a Model Lab run by ID + Get { run_id: i64 }, + /// Delete a Model Lab run + Delete { run_id: i64 }, + /// Pin a Model Lab run + Pin { run_id: i64 }, + /// Unpin a Model Lab run + Unpin { run_id: i64 }, + /// List artifacts for a Model Lab run + Artifacts { + run_id: i64, + #[arg(long, help = "Filter by artifact path prefix")] + path: Option, + }, + /// List facet keys for Model Lab runs + #[command(name = "facet-keys")] + FacetKeys { + #[arg(long, help = "Project ID to scope facet keys")] + filter_project_id: i64, + }, + /// List facet values for a Model Lab run facet + #[command(name = "facet-values")] + FacetValues { + #[arg(long, help = "Project ID to scope facet values")] + filter_project_id: i64, + #[arg(help = "Facet type (parameter, attribute, tag, metric)")] + facet_type: String, + #[arg(help = "Facet name")] + facet_name: String, + }, +} + // ---- APM ---- #[derive(Subcommand)] enum ApmActions { @@ -11748,6 +11973,52 @@ async fn main_inner() -> anyhow::Result<()> { } } } + // --- Tag Policies --- + Commands::TagPolicies { action } => { + cfg.validate_auth()?; + match action { + TagPoliciesActions::List { + include_disabled, + include_deleted, + include_score, + filter_source, + } => { + commands::tag_policies::list( + &cfg, + include_disabled, + include_deleted, + include_score, + filter_source, + ) + .await?; + } + TagPoliciesActions::Get { + policy_id, + include_score, + } => { + commands::tag_policies::get(&cfg, &policy_id, include_score).await?; + } + TagPoliciesActions::Create { file } => { + commands::tag_policies::create(&cfg, &file).await?; + } + TagPoliciesActions::Update { policy_id, file } => { + commands::tag_policies::update(&cfg, &policy_id, &file).await?; + } + TagPoliciesActions::Delete { + policy_id, + hard_delete, + } => { + commands::tag_policies::delete(&cfg, &policy_id, hard_delete).await?; + } + TagPoliciesActions::Score { + policy_id, + ts_start, + ts_end, + } => { + commands::tag_policies::score(&cfg, &policy_id, ts_start, ts_end).await?; + } + } + } // --- Users --- Commands::Users { action } => { cfg.validate_auth()?; @@ -14169,6 +14440,115 @@ async fn main_inner() -> anyhow::Result<()> { MiscActions::Status => commands::misc::status(&cfg).await?, } } + // --- Model Lab --- + Commands::ModelLab { action } => { + cfg.validate_auth()?; + match action { + ModelLabActions::Projects { action } => match action { + ModelLabProjectActions::List { + filter, + filter_tags, + sort, + page_size, + page_number, + } => { + commands::model_lab::projects_list( + &cfg, + filter, + filter_tags, + sort, + page_size, + page_number, + ) + .await?; + } + ModelLabProjectActions::Get { project_id } => { + commands::model_lab::projects_get(&cfg, project_id).await?; + } + ModelLabProjectActions::Star { project_id } => { + commands::model_lab::projects_star(&cfg, project_id).await?; + } + ModelLabProjectActions::Unstar { project_id } => { + commands::model_lab::projects_unstar(&cfg, project_id).await?; + } + ModelLabProjectActions::Artifacts { project_id } => { + commands::model_lab::projects_artifacts(&cfg, project_id).await?; + } + ModelLabProjectActions::FacetKeys => { + commands::model_lab::projects_facet_keys(&cfg).await?; + } + ModelLabProjectActions::FacetValues { + facet_type, + facet_name, + } => { + commands::model_lab::projects_facet_values(&cfg, &facet_type, facet_name) + .await?; + } + }, + ModelLabActions::Runs { action } => match action { + ModelLabRunActions::List { + filter, + filter_project_id, + filter_status, + filter_tags, + filter_params, + filter_parent_run_id, + pinned_first, + include_pinned, + sort, + page_size, + page_number, + } => { + commands::model_lab::runs_list( + &cfg, + filter, + filter_project_id, + filter_status, + filter_tags, + filter_params, + filter_parent_run_id, + pinned_first, + include_pinned, + sort, + page_size, + page_number, + ) + .await?; + } + ModelLabRunActions::Get { run_id } => { + commands::model_lab::runs_get(&cfg, run_id).await?; + } + ModelLabRunActions::Delete { run_id } => { + commands::model_lab::runs_delete(&cfg, run_id).await?; + } + ModelLabRunActions::Pin { run_id } => { + commands::model_lab::runs_pin(&cfg, run_id).await?; + } + ModelLabRunActions::Unpin { run_id } => { + commands::model_lab::runs_unpin(&cfg, run_id).await?; + } + ModelLabRunActions::Artifacts { run_id, path } => { + commands::model_lab::runs_artifacts(&cfg, run_id, path).await?; + } + ModelLabRunActions::FacetKeys { filter_project_id } => { + commands::model_lab::runs_facet_keys(&cfg, filter_project_id).await?; + } + ModelLabRunActions::FacetValues { + filter_project_id, + facet_type, + facet_name, + } => { + commands::model_lab::runs_facet_values( + &cfg, + filter_project_id, + &facet_type, + facet_name, + ) + .await?; + } + }, + } + } // --- APM --- Commands::Apm { action } => { cfg.validate_auth()?;