Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,9 @@ site: datadoghq.com
output: json
verbose: false

# Safety
read_only: false

# Defaults
default_from: 1h
default_to: now
Expand Down
1 change: 1 addition & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ Available on all commands:
--output string Output format: json, yaml, table (default: json)
--verbose Enable verbose logging
--yes Skip confirmation prompts
--read-only Block all write operations (create, update, delete)
```

## Recent Enhancements
Expand Down
10 changes: 10 additions & 0 deletions docs/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,16 @@ pup --verbose monitors list
pup --yes monitors delete 12345678
```

### Read-Only Mode
```bash
# Block all write operations (create, update, delete)
pup --read-only monitors list
pup --read-only dashboards list

# Also available via env var or config file
DD_READ_ONLY=true pup monitors list
```

## Common Workflows

### Monitoring Dashboard
Expand Down
1 change: 1 addition & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ mod tests {
output_format: crate::config::OutputFormat::Json,
auto_approve: false,
agent_mode: false,
read_only: false,
}
}

Expand Down
39 changes: 39 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub struct Config {
pub output_format: OutputFormat,
pub auto_approve: bool,
pub agent_mode: bool,
pub read_only: bool,
}

#[derive(Clone, Debug, PartialEq)]
Expand Down Expand Up @@ -55,6 +56,7 @@ struct FileConfig {
org: Option<String>,
output: Option<String>,
auto_approve: Option<bool>,
read_only: Option<bool>,
}

impl Config {
Expand Down Expand Up @@ -85,6 +87,9 @@ impl Config {
|| env_bool("DD_CLI_AUTO_APPROVE")
|| file_cfg.auto_approve.unwrap_or(false),
agent_mode: false, // set by caller from --agent flag or useragent detection
read_only: env_bool("DD_READ_ONLY")
|| env_bool("DD_CLI_READ_ONLY")
|| file_cfg.read_only.unwrap_or(false),
};

Ok(cfg)
Expand All @@ -108,6 +113,7 @@ impl Config {
output_format: OutputFormat::Json,
auto_approve: false,
agent_mode: false,
read_only: false,
}
}

Expand Down Expand Up @@ -253,6 +259,7 @@ mod tests {
output_format: OutputFormat::Json,
auto_approve: false,
agent_mode: false,
read_only: false,
}
}

Expand Down Expand Up @@ -480,4 +487,36 @@ mod tests {
);
std::env::remove_var("__PUP_TEST_ENV_EMPTY__");
}

#[test]
fn test_file_config_read_only() {
let yaml = "read_only: true\n";
let fc: FileConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(fc.read_only, Some(true));
}

#[test]
fn test_read_only_from_env() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
std::env::remove_var("DD_READ_ONLY");
std::env::remove_var("DD_CLI_READ_ONLY");
std::env::set_var("PUP_CONFIG_DIR", "/tmp/pup_test_nonexistent");
std::env::set_var("DD_ACCESS_TOKEN", "test");

let cfg = Config::from_env().unwrap();
assert!(!cfg.read_only);

std::env::set_var("DD_READ_ONLY", "true");
let cfg = Config::from_env().unwrap();
assert!(cfg.read_only);
std::env::remove_var("DD_READ_ONLY");

std::env::set_var("DD_CLI_READ_ONLY", "1");
let cfg = Config::from_env().unwrap();
assert!(cfg.read_only);
std::env::remove_var("DD_CLI_READ_ONLY");

std::env::remove_var("DD_ACCESS_TOKEN");
std::env::remove_var("PUP_CONFIG_DIR");
}
}
1 change: 1 addition & 0 deletions src/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,7 @@ mod tests {
output_format: OutputFormat::Json,
auto_approve: false,
agent_mode: false,
read_only: false,
};
let data = serde_json::json!({"hello": "world"});
assert!(output(&cfg, &data).is_ok());
Expand Down
95 changes: 72 additions & 23 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ pub(crate) mod test_utils {
pub static ENV_LOCK: Mutex<()> = Mutex::new(());
}

use clap::{CommandFactory, Parser, Subcommand};
use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};

#[derive(Parser)]
#[command(name = "pup", version = version::VERSION, about = "Datadog API CLI")]
struct Cli {
pub(crate) struct Cli {
/// Output format (json, table, yaml)
#[arg(short, long, global = true, default_value = "json")]
output: String,
Expand All @@ -34,6 +34,9 @@ struct Cli {
/// Enable agent mode
#[arg(long, global = true)]
agent: bool,
/// Block all write operations (create, update, delete)
#[arg(long, global = true)]
read_only: bool,
/// Named org session (see 'pup auth login --org')
#[arg(long, global = true)]
org: Option<String>,
Expand Down Expand Up @@ -4738,25 +4741,10 @@ fn build_compact_agent_schema(cmd: &clap::Command) -> serde_json::Value {
serde_json::Value::Object(root)
}

fn build_command_schema(cmd: &clap::Command, parent_path: &str) -> serde_json::Value {
let mut obj = serde_json::Map::new();
let name = cmd.get_name().to_string();
let full_path = if parent_path.is_empty() {
name.clone()
} else {
format!("{parent_path} {name}")
};

obj.insert("name".into(), serde_json::json!(name));
obj.insert("full_path".into(), serde_json::json!(full_path));

if let Some(about) = cmd.get_about() {
obj.insert("description".into(), serde_json::json!(about.to_string()));
}

// Determine read_only based on command name — but only emit for leaf commands
// (commands with no subcommands), matching Go behavior
let is_write = name == "delete"
/// Returns true if a leaf subcommand name represents a write (mutating) operation.
/// Used by both the read-only runtime guard and the agent JSON schema.
pub(crate) fn is_write_command_name(name: &str) -> bool {
name == "delete"
|| name == "create"
|| name == "update"
|| name == "cancel"
Expand All @@ -4769,6 +4757,11 @@ fn build_command_schema(cmd: &clap::Command, parent_path: &str) -> serde_json::V
|| name == "unarchive"
|| name == "activate"
|| name == "deactivate"
|| name == "move"
|| name == "link"
|| name == "unlink"
|| name == "configure"
|| name == "upgrade"
|| name.starts_with("update-")
|| name.starts_with("create-")
|| name == "submit"
Expand All @@ -4777,7 +4770,29 @@ fn build_command_schema(cmd: &clap::Command, parent_path: &str) -> serde_json::V
|| name == "register"
|| name == "unregister"
|| name.contains("delete")
|| name.contains("patch");
|| name == "patch"
|| name.starts_with("patch-")
}

fn build_command_schema(cmd: &clap::Command, parent_path: &str) -> serde_json::Value {
let mut obj = serde_json::Map::new();
let name = cmd.get_name().to_string();
let full_path = if parent_path.is_empty() {
name.clone()
} else {
format!("{parent_path} {name}")
};

obj.insert("name".into(), serde_json::json!(name));
obj.insert("full_path".into(), serde_json::json!(full_path));

if let Some(about) = cmd.get_about() {
obj.insert("description".into(), serde_json::json!(about.to_string()));
}

// Determine read_only based on command name — but only emit for leaf commands
// (commands with no subcommands), matching Go behavior
let is_write = is_write_command_name(&name);

// Flags (named --flags only, excluding positional args and globals)
let flags: Vec<serde_json::Value> = cmd
Expand Down Expand Up @@ -4867,6 +4882,19 @@ async fn main() -> anyhow::Result<()> {
main_inner().await
}

pub(crate) fn get_leaf_subcommand_name(matches: &clap::ArgMatches) -> Option<String> {
match matches.subcommand() {
Some((name, sub_matches)) => {
get_leaf_subcommand_name(sub_matches).or(Some(name.to_string()))
}
None => None,
}
}

pub(crate) fn get_top_level_subcommand_name(matches: &clap::ArgMatches) -> Option<String> {
matches.subcommand().map(|(name, _)| name.to_string())
}

async fn main_inner() -> anyhow::Result<()> {
// In agent mode, intercept --help to return a JSON schema instead of plain text.
let args: Vec<String> = std::env::args().collect();
Expand Down Expand Up @@ -4894,7 +4922,8 @@ async fn main_inner() -> anyhow::Result<()> {
return Ok(());
}

let cli = Cli::parse();
let matches = Cli::command().get_matches();
let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit());
let mut cfg = config::Config::from_env()?;

// Apply flag overrides
Expand Down Expand Up @@ -4922,6 +4951,26 @@ async fn main_inner() -> anyhow::Result<()> {
}
}

if cli.read_only {
cfg.read_only = true;
}
if cfg.read_only {
let top = get_top_level_subcommand_name(&matches);
let is_local_only = matches!(top.as_deref(), Some("auth") | Some("alias"));
if !is_local_only {
if let Some(leaf) = get_leaf_subcommand_name(&matches) {
if is_write_command_name(&leaf) {
anyhow::bail!(
"write operation '{}' is blocked in read-only mode \
(--read-only flag, DD_READ_ONLY / DD_CLI_READ_ONLY env var, \
or read_only: true in config file)",
leaf
);
}
}
}
}

match cli.command {
// --- Monitors ---
Commands::Monitors { action } => {
Expand Down
Loading