Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/add-dry-run-to-event-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@googleworkspace/cli": patch
---

feat(helpers): add --dry-run support to events helper commands

Add dry-run mode to `gws events +renew` and `gws events +subscribe` commands.
When --dry-run is specified, the commands will print what actions would be
taken without making any API calls. This allows agents to simulate requests
and learn without reaching the server.
151 changes: 91 additions & 60 deletions src/helpers/events/renew.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,78 +30,109 @@ pub(super) async fn handle_renew(
matches: &ArgMatches,
) -> Result<(), GwsError> {
let config = parse_renew_args(matches)?;
let dry_run = matches.get_flag("dry-run");

if dry_run {
eprintln!("🏃 DRY RUN — no changes will be made\n");
}

let client = crate::client::build_client()?;
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Failed to get token: {e}")))?;
let ws_token: Option<String> = if dry_run {
None
} else {
Some(
auth::get_token(&[WORKSPACE_EVENTS_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Failed to get token: {e}")))?,
)
};

if let Some(name) = config.name {
// Reactivate a specific subscription
let name = crate::validate::validate_resource_name(&name)?;
eprintln!("Reactivating subscription: {name}");
let resp = client
.post(format!(
"https://workspaceevents.googleapis.com/v1/{name}:reactivate"
))
.bearer_auth(&ws_token)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await
.context("Failed to reactivate subscription")?;

let body: Value = resp.json().await.context("Failed to parse response")?;

println!(
"{}",
serde_json::to_string_pretty(&body).unwrap_or_default()
);

if !dry_run {
let token = ws_token.as_ref().expect("Token should be present in non-dry-run mode");
let resp = client
.post(format!(
"https://workspaceevents.googleapis.com/v1/{name}:reactivate"
))
.bearer_auth(token)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await
.context("Failed to reactivate subscription")?;

let body: Value = resp.json().await.context("Failed to parse response")?;

println!(
"{}",
serde_json::to_string_pretty(&body).unwrap_or_default()
);
}
Comment on lines +55 to +74
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Inconsistent dry-run behavior. When --dry-run is used with a specific subscription name (--name), the command doesn't produce any JSON output indicating what would have happened. This is inconsistent with the behavior when --all is used. An else block should be added to provide a descriptive JSON output for the single-subscription dry-run case.

        if !dry_run {
            let token = ws_token.as_ref().expect("Token should be present in non-dry-run mode");
            let resp = client
                .post(format!(
                    "https://workspaceevents.googleapis.com/v1/{name}:reactivate"
                ))
                .bearer_auth(token)
                .header("Content-Type", "application/json")
                .body("{}")
                .send()
                .await
                .context("Failed to reactivate subscription")?;

            let body: Value = resp.json().await.context("Failed to parse response")?;

            println!(
                "{}",
                serde_json::to_string_pretty(&body).unwrap_or_default()
            );
        } else {
            let result = json!({
                "dry_run": true,
                "action": "Would reactivate subscription",
                "name": name,
                "note": "Run without --dry-run to actually reactivate the subscription"
            });
            println!(
                "{}",
                serde_json::to_string_pretty(&result).unwrap_or_default()
            );
        }

Comment on lines +55 to +74
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The dry-run mode for reactivating a specific subscription is incomplete. It prints a message to stderr but doesn't produce the JSON output describing the action, which is inconsistent with the behavior for renewing multiple subscriptions. To ensure consistent behavior and fully implement the dry-run feature, you should add an else block to output a JSON summary for this case.

        if !dry_run {
            let token = ws_token.as_ref().expect("Token should be present in non-dry-run mode");
            let resp = client
                .post(format!(
                    "https://workspaceevents.googleapis.com/v1/{name}:reactivate"
                ))
                .bearer_auth(token)
                .header("Content-Type", "application/json")
                .body("{}")
                .send()
                .await
                .context("Failed to reactivate subscription")?;

            let body: Value = resp.json().await.context("Failed to parse response")?;

            println!(
                "{}",
                serde_json::to_string_pretty(&body).unwrap_or_default()
            );
        } else {
            let result = json!({
                "dry_run": true,
                "action": "Would reactivate subscription",
                "name": name,
                "note": "Run without --dry-run to actually reactivate this subscription"
            });
            println!(
                "{}",
                serde_json::to_string_pretty(&result).unwrap_or_default()
            );
        }

} else {
let within_secs = parse_duration(&config.within)?;

// List all subscriptions
let resp = client
.get("https://workspaceevents.googleapis.com/v1/subscriptions")
.bearer_auth(&ws_token)
.send()
.await
.context("Failed to list subscriptions")?;

let body: Value = resp.json().await.context("Failed to parse response")?;

let mut renewed = 0;
if let Some(subs) = body.get("subscriptions").and_then(|s| s.as_array()) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();

let to_renew = filter_subscriptions_to_renew(subs, now, within_secs);

for name in to_renew {
let name = crate::validate::validate_resource_name(&name)?;
eprintln!("Renewing {name}...");
let _ = client
.post(format!(
"https://workspaceevents.googleapis.com/v1/{name}:reactivate"
))
.bearer_auth(&ws_token)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await;
renewed += 1;
if !dry_run {
let token = ws_token.as_ref().expect("Token should be present in non-dry-run mode");
let resp = client
.get("https://workspaceevents.googleapis.com/v1/subscriptions")
.bearer_auth(token)
.send()
.await
.context("Failed to list subscriptions")?;

let body: Value = resp.json().await.context("Failed to parse response")?;

let mut renewed = 0;
if let Some(subs) = body.get("subscriptions").and_then(|s| s.as_array()) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();

let to_renew = filter_subscriptions_to_renew(subs, now, within_secs);

for name in to_renew {
let name = crate::validate::validate_resource_name(&name)?;
eprintln!("Renewing {name}...");
let _ = client
.post(format!(
"https://workspaceevents.googleapis.com/v1/{name}:reactivate"
))
.bearer_auth(token)
.header("Content-Type", "application/json")
.body("{}")
.send()
.await;
renewed += 1;
}
}
}

let result = json!({
"status": "success",
"renewed": renewed,
});
println!(
"{}",
serde_json::to_string_pretty(&result).unwrap_or_default()
);
let result = json!({
"status": "success",
"renewed": renewed,
});
println!(
"{}",
serde_json::to_string_pretty(&result).unwrap_or_default()
);
} else {
// In dry-run mode, just print what would happen
let result = json!({
"dry_run": true,
"action": "Would list and renew subscriptions expiring within",
"within": config.within,
"note": "Run without --dry-run to actually renew subscriptions"
});
println!(
"{}",
serde_json::to_string_pretty(&result).unwrap_or_default()
);
}
}

Ok(())
Expand Down
50 changes: 44 additions & 6 deletions src/helpers/events/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,18 +106,31 @@ pub(super) async fn handle_subscribe(
matches: &ArgMatches,
) -> Result<(), GwsError> {
let config = parse_subscribe_args(matches)?;
let dry_run = matches.get_flag("dry-run");

if dry_run {
eprintln!("🏃 DRY RUN — no changes will be made\n");
}
Comment on lines +109 to +113
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The dry-run implementation is incomplete for existing subscriptions. When the --subscription flag is used in dry-run mode, the command proceeds to call pull_loop, which makes real API calls to Pub/Sub. This defeats the purpose of a dry run.

A check should be added to handle this scenario. When dry_run is true and an existing subscription is provided, the command should print what it would do and exit, rather than entering the pull_loop.


if let Some(ref dir) = config.output_dir {
std::fs::create_dir_all(dir).context("Failed to create output dir")?;
if !dry_run {
std::fs::create_dir_all(dir).context("Failed to create output dir")?;
}
}

let client = crate::client::build_client()?;
let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE]);

// Get Pub/Sub token
let pubsub_token = auth::get_token(&[PUBSUB_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?;
let pubsub_token = if dry_run {
None
} else {
Some(
auth::get_token(&[PUBSUB_SCOPE])
.await
.map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?,
)
};
Comment on lines +125 to +133
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The --dry-run implementation is incomplete and leads to unexpected API calls. When using an existing subscription (--subscription), the code proceeds to the pull_loop, which attempts to fetch a real token and pull messages from Pub/Sub. This defeats the purpose of a dry run.

This happens because there's no dry-run check for the existing subscription path. The pull_loop is called unconditionally, and it uses pubsub_token_provider which is not aware of the dry-run mode.

A possible fix is to handle the dry-run case for existing subscriptions early in the function and return before pull_loop is called, similar to how it's done for new subscriptions. For example, you could add a check after parsing arguments:

if dry_run && config.subscription.is_some() {
    // Print dry-run JSON for existing subscription and return Ok(())
}


let (pubsub_subscription, topic_name, ws_subscription_name, created_resources) =
if let Some(ref sub_name) = config.subscription {
Expand All @@ -139,11 +152,35 @@ pub(super) async fn handle_subscribe(
let topic = format!("projects/{project}/topics/gws-{slug}-{suffix}");
let sub = format!("projects/{project}/subscriptions/gws-{slug}-{suffix}");

// Dry-run: print what would be created and exit
if dry_run {
eprintln!("Would create Pub/Sub topic: {topic}");
eprintln!("Would create Pub/Sub subscription: {sub}");
eprintln!("Would create Workspace Events subscription for target: {target}");
eprintln!("Would listen for event types: {}", config.event_types.join(", "));

let result = json!({
"dry_run": true,
"action": "Would create Workspace Events subscription",
"pubsub_topic": topic,
"pubsub_subscription": sub,
"target": target,
"event_types": config.event_types,
"note": "Run without --dry-run to actually create subscription"
});
println!(
"{}",
serde_json::to_string_pretty(&result).unwrap_or_default()
);
return Ok(());
}

// 1. Create Pub/Sub topic
eprintln!("Creating Pub/Sub topic: {topic}");
let token = pubsub_token.as_ref().expect("Token should be present in non-dry-run mode");
let resp = client
.put(format!("{PUBSUB_API_BASE}/{topic}"))
.bearer_auth(&pubsub_token)
.bearer_auth(token)
.header("Content-Type", "application/json")
.body("{}")
.send()
Expand All @@ -166,9 +203,10 @@ pub(super) async fn handle_subscribe(
"topic": topic,
"ackDeadlineSeconds": 60,
});
let token = pubsub_token.as_ref().expect("Token should be present in non-dry-run mode");
let resp = client
.put(format!("{PUBSUB_API_BASE}/{sub}"))
.bearer_auth(&pubsub_token)
.bearer_auth(token)
.header("Content-Type", "application/json")
.json(&sub_body)
.send()
Expand Down
Loading