fix: enable APIs individually and surface gcloud errors (#77)

* fix: enable APIs individually and surface gcloud errors

Previously, `gws auth setup` used a single batch `gcloud services enable`
call for all 22 Workspace APIs. If any one API failed, the entire batch
was marked as failed. Additionally, stderr was piped to /dev/null, so
users never saw why APIs failed to enable.

Changes:
- Enable each API individually so one failure doesn't block the rest
- Capture stderr from gcloud and include error messages in output
- Show failure details with ⚠ warnings in the wizard status
- Include structured error objects in JSON output (apis_failed array)

Fixes #75

* address review: parallelize API enablement with tokio, remove clone

- Use tokio::process::Command + futures_util::stream::buffer_unordered(5)
  for parallel, non-blocking API enablement
- Remove unnecessary failed_apis.clone() by moving into ctx.failed first
- Add changeset

* test: add coverage for enable_apis and JSON output structure

- test_enable_apis_all_already_enabled: empty input returns empty results
- test_enable_apis_with_invalid_project: verifies errors are captured (not swallowed)
- test_failed_apis_json_structure: validates {api, error} JSON shape
- test_failed_apis_json_empty: empty failures produce empty array

* Update src/setup.rs

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Justin Poehnelt
2026-03-04 18:44:59 -07:00
committed by GitHub
parent 6092bf90a0
commit 704928bd99
2 changed files with 143 additions and 23 deletions
+10
View File
@@ -0,0 +1,10 @@
---
"gws": patch
---
fix(setup): enable APIs individually and surface gcloud errors
Previously `gws auth setup` used a single batch `gcloud services enable` call
for all Workspace APIs. If any one API failed, the entire batch was marked as
failed and stderr was silently discarded. APIs are now enabled individually and
in parallel, with error messages surfaced to the user.
+133 -23
View File
@@ -615,11 +615,12 @@ fn get_access_token() -> Result<String, GwsError> {
// ── API enabling ────────────────────────────────────────────────
/// Enable selected Workspace APIs for a project. Returns (enabled, skipped, failed).
/// Enable selected Workspace APIs for a project.
/// Returns (enabled, skipped, failed) where failed includes the gcloud error message.
async fn enable_apis(
project_id: &str,
api_ids: &[String],
) -> (Vec<String>, Vec<String>, Vec<String>) {
) -> (Vec<String>, Vec<String>, Vec<(String, String)>) {
// First, get already-enabled APIs
let already_enabled = get_enabled_apis(project_id);
@@ -638,23 +639,55 @@ async fn enable_apis(
return (Vec::new(), skipped, Vec::new());
}
// Batch enable all APIs in a single gcloud call
let mut args = vec!["services".to_string(), "enable".to_string()];
args.extend(to_enable.clone());
args.push("--project".to_string());
args.push(project_id.to_string());
// Enable each API individually and in parallel so one failure doesn't
// block the rest. Uses tokio::process to avoid blocking the executor.
use futures_util::stream::StreamExt;
let result = gcloud_cmd()
.args(&args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output();
let results = futures_util::stream::iter(to_enable)
.map(|api_id| {
let project_id = project_id.to_string();
async move {
let result = tokio::process::Command::new("gcloud")
.env("CLOUDSDK_CORE_DISABLE_PROMPTS", "1")
.args(["services", "enable", &api_id, "--project", &project_id])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output()
.await;
(api_id, result)
}
})
.buffer_unordered(5)
.collect::<Vec<_>>()
.await;
match result {
Ok(output) if output.status.success() => (to_enable, skipped, Vec::new()),
Ok(_) => (Vec::new(), skipped, to_enable),
Err(_) => (Vec::new(), skipped, to_enable),
let mut enabled = Vec::new();
let mut failed = Vec::new();
for (api_id, result) in results {
match result {
Ok(output) if output.status.success() => {
enabled.push(api_id);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let msg = if stderr.is_empty() {
format!(
"gcloud services enable failed (exit code {:?})",
output.status.code()
)
} else {
stderr
};
failed.push((api_id, msg));
}
Err(e) => {
failed.push((api_id, format!("Failed to run gcloud: {e}")));
}
}
}
(enabled, skipped, failed)
}
/// Get the list of already-enabled API service names for a project.
@@ -784,7 +817,7 @@ struct SetupContext {
client_secret: String,
enabled: Vec<String>,
skipped: Vec<String>,
failed: Vec<String>,
failed: Vec<(String, String)>,
}
impl SetupContext {
@@ -1152,14 +1185,31 @@ async fn stage_enable_apis(ctx: &mut SetupContext) -> Result<SetupStage, GwsErro
ctx.enabled = enabled_apis;
ctx.skipped = skipped_apis;
ctx.failed = failed_apis;
ctx.wiz(
3,
StepStatus::Done(format!(
// Show failure details so the user knows what went wrong
if !ctx.failed.is_empty() {
eprintln!();
for (api, err) in &ctx.failed {
eprintln!("{}{}", api, err);
}
eprintln!();
}
let status_msg = if ctx.failed.is_empty() {
format!(
"{} enabled, {} skipped",
ctx.enabled.len(),
ctx.skipped.len()
)),
);
)
} else {
format!(
"{} enabled, {} skipped, {} failed",
ctx.enabled.len(),
ctx.skipped.len(),
ctx.failed.len()
)
};
ctx.wiz(3, StepStatus::Done(status_msg));
Ok(SetupStage::ConfigureOauth)
}
@@ -1388,7 +1438,7 @@ pub async fn run_setup(args: &[String]) -> Result<(), GwsError> {
"project": ctx.project_id,
"apis_enabled": ctx.enabled.len(),
"apis_skipped": ctx.skipped.len(),
"apis_failed": ctx.failed.len(),
"apis_failed": ctx.failed.iter().map(|(api, err)| json!({"api": api, "error": err})).collect::<Vec<_>>(),
"client_config": crate::oauth_config::client_config_path().display().to_string(),
});
println!(
@@ -1852,4 +1902,64 @@ mod tests {
_ => panic!("Expected EnableApis"),
}
}
// ── enable_apis unit tests ──────────────────────────────────
#[tokio::test]
async fn test_enable_apis_with_no_apis_to_enable() {
// When no APIs are requested for enablement, `enable_apis` should
// return empty lists for enabled, skipped, and failed.
let (enabled, skipped, failed) = enable_apis("__nonexistent__", &[]).await;
assert!(enabled.is_empty());
assert!(skipped.is_empty());
assert!(failed.is_empty());
}
#[tokio::test]
async fn test_enable_apis_with_invalid_project() {
// Calling enable_apis with a bogus project and a real API name
// should produce a failure with an error message (not swallowed).
let apis = vec!["storage.googleapis.com".to_string()];
let (enabled, skipped, failed) = enable_apis("__nonexistent_project_99999__", &apis).await;
// The API should not be in enabled (project doesn't exist)
assert!(enabled.is_empty());
assert!(skipped.is_empty());
// Should have exactly one failure with a non-empty error message
assert_eq!(failed.len(), 1);
assert_eq!(failed[0].0, "storage.googleapis.com");
assert!(!failed[0].1.is_empty(), "Error message should not be empty");
}
#[test]
fn test_failed_apis_json_structure() {
// Verify the JSON output structure for failed APIs includes
// both "api" and "error" fields.
let failed: Vec<(String, String)> = vec![
("vault.googleapis.com".into(), "Permission denied".into()),
("admin.googleapis.com".into(), "Not found".into()),
];
let json_failed: Vec<serde_json::Value> = failed
.iter()
.map(|(api, err)| json!({"api": api, "error": err}))
.collect();
assert_eq!(json_failed.len(), 2);
assert_eq!(json_failed[0]["api"], "vault.googleapis.com");
assert_eq!(json_failed[0]["error"], "Permission denied");
assert_eq!(json_failed[1]["api"], "admin.googleapis.com");
assert_eq!(json_failed[1]["error"], "Not found");
}
#[test]
fn test_failed_apis_json_empty() {
// When no APIs fail, the JSON array should be empty.
let failed: Vec<(String, String)> = vec![];
let json_failed: Vec<serde_json::Value> = failed
.iter()
.map(|(api, err)| json!({"api": api, "error": err}))
.collect();
assert!(json_failed.is_empty());
}
}