diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index 8daa59a..3b8d8cd 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -207,6 +207,22 @@ pub fn tools() -> Vec { "required": ["text"] })), ), + Tool::new( + "list_aws_profiles", + "List AWS credential profiles discovered on this machine (scans ~/.aws/config and ~/.aws/credentials — this server runs as a local sidecar next to the console, so this reads the operator's own machine, not a remote one). Each entry has a name and region (region omitted when not set). `exists=false` means no AWS config was found at all — the caller should suggest `aws configure`/`aws sso login` rather than treat it as an error. Read-only; backs the New Fleet wizard's profile picker.", + as_map(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "list_k8s_contexts", + "List kubeconfig contexts discovered on this machine (reads $KUBECONFIG or ~/.kube/config — local sidecar, same machine as the console). Each entry has the context name, its cluster, namespace (if set), and user. `current_context` names the kubeconfig's default. `exists=false` means no kubeconfig was found at all — the caller should suggest installing a local cluster (OrbStack/kind/minikube) or merging in a cloud kubeconfig, rather than treat it as an error. Read-only; backs the New Fleet wizard's k8s context picker.", + as_map(json!({ + "type": "object", + "properties": {} + })), + ), ] } @@ -319,6 +335,8 @@ impl OabMcp { "runtime_context" => self.t_runtime_context(args).await, "fleet_config" => self.t_fleet_config(args), "fleet_config_write" => self.t_fleet_write(args), + "list_aws_profiles" => self.t_list_aws_profiles(args), + "list_k8s_contexts" => self.t_list_k8s_contexts(args), other => anyhow::bail!("unknown tool {other:?}"), } } @@ -698,6 +716,38 @@ impl OabMcp { self.t_fleet_config(args) } + /// Local AWS profile discovery (studio#104): backs the New Fleet wizard's + /// profile ``. Same `exists`/`error` split as + /// `list_aws_profiles`. + fn t_list_k8s_contexts(&self, _args: &Map) -> Result { + let r = scp::list_k8s_contexts(); + Ok(json!({ + "contexts": r.contexts.iter().map(|c| json!({ + "name": c.name, + "cluster": c.cluster, + "namespace": c.namespace, + "user": c.user, + })).collect::>(), + "current_context": r.current_context, + "exists": r.exists, + "error": r.error, + })) + } + async fn t_delete(&self, args: &Map) -> Result { let t = self.target(args)?; let cluster = t.cluster.clone(); @@ -780,7 +830,7 @@ mod tests { .iter() .map(|t| t["name"].as_str().expect("tool has a name").to_string()) .collect(); - assert_eq!(names.len(), 11); + assert_eq!(names.len(), 13); for expected in [ "deploy_list", "deploy_get", @@ -793,6 +843,8 @@ mod tests { "runtime_context", "fleet_config", "fleet_config_write", + "list_aws_profiles", + "list_k8s_contexts", ] { assert!(names.contains(&expected.to_string()), "missing {expected}"); } diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index f0d5669..bbfd49e 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -326,6 +326,199 @@ pub async fn observe_k8s_identity(context: Option<&str>) -> anyhow::Result` +// fields — the desktop app spawns `oab-mcp` as a local sidecar (`src-tauri/src/ +// mcp.rs`), so these read the *operator's own machine*, not a remote server. +// Deliberately hand-rolled (not `aws-config`'s internal profile-file parser, +// which isn't a stable public surface) — same "pure, regex/line-based, easy to +// unit-test" spirit as `fleetToml.ts`'s client-side TOML edits. + +/// One AWS credential profile discovered on the local machine. +pub struct AwsProfile { + pub name: String, + pub region: Option, +} + +/// Result of scanning `~/.aws/config` (+ `~/.aws/credentials` for profiles that +/// only exist there). `exists=false` means neither file was found — the caller +/// (console) shows "run `aws configure`" guidance rather than a bare error. +/// `error` is set only when a file exists but couldn't be read/parsed. +pub struct AwsProfilesResult { + pub profiles: Vec, + pub source_path: String, + pub exists: bool, + pub error: Option, +} + +fn aws_dir() -> Option { + std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".aws")) +} + +/// Parse `~/.aws/config`'s `[default]` / `[profile ]` sections, pulling +/// out `region` when present. Comments (`#`/`;`) and blank lines are ignored; +/// unrecognized keys are skipped (this isn't a general INI parser, just enough +/// to answer "what profiles exist, with what region"). +fn parse_aws_config(text: &str) -> Vec { + let mut profiles = Vec::new(); + let mut current: Option = None; + for raw_line in text.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if let Some(header) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + if let Some(p) = current.take() { + profiles.push(p); + } + let name = header.strip_prefix("profile ").unwrap_or(header).trim(); + current = Some(AwsProfile { + name: name.to_string(), + region: None, + }); + continue; + } + if let Some((key, value)) = line.split_once('=') { + if key.trim() == "region" { + if let Some(p) = current.as_mut() { + p.region = Some(value.trim().to_string()); + } + } + } + } + if let Some(p) = current.take() { + profiles.push(p); + } + profiles +} + +/// Profile names from `~/.aws/credentials`'s `[]` sections (bare, no +/// `profile ` prefix there) — covers profiles that only carry credentials with +/// no matching `~/.aws/config` entry (no region info available for these). +fn parse_aws_credentials_names(text: &str) -> Vec { + text.lines() + .filter_map(|raw_line| { + let line = raw_line.trim(); + line.strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .map(|name| name.trim().to_string()) + }) + .collect() +} + +/// List AWS profiles discoverable on this machine, merging `~/.aws/config` +/// (name + region) with any profile-only-in-`~/.aws/credentials` names. +pub fn list_aws_profiles() -> AwsProfilesResult { + let Some(dir) = aws_dir() else { + return AwsProfilesResult { + profiles: Vec::new(), + source_path: String::new(), + exists: false, + error: None, + }; + }; + let config_path = dir.join("config"); + let source_path = config_path.display().to_string(); + + let mut profiles = match std::fs::read_to_string(&config_path) { + Ok(text) => parse_aws_config(&text), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(e) => { + return AwsProfilesResult { + profiles: Vec::new(), + source_path, + exists: true, + error: Some(e.to_string()), + }; + } + }; + + if let Ok(creds_text) = std::fs::read_to_string(dir.join("credentials")) { + for name in parse_aws_credentials_names(&creds_text) { + if !profiles.iter().any(|p| p.name == name) { + profiles.push(AwsProfile { name, region: None }); + } + } + } + + let exists = config_path.exists() || dir.join("credentials").exists(); + AwsProfilesResult { + profiles, + source_path, + exists, + error: None, + } +} + +/// One kubeconfig context discovered on the local machine. +pub struct K8sContextInfo { + pub name: String, + pub cluster: String, + pub namespace: Option, + pub user: Option, +} + +/// Result of reading the local kubeconfig. `exists=false` when no kubeconfig +/// file was found at all (`KUBECONFIG` unset, `~/.kube/config` missing) — the +/// caller shows "install OrbStack/kind/minikube, or merge your cluster's +/// kubeconfig" guidance rather than a bare error. +pub struct K8sContextsResult { + pub contexts: Vec, + pub current_context: Option, + pub exists: bool, + pub error: Option, +} + +/// List kubeconfig contexts discoverable on this machine (same "ambient +/// default, explicit override" kubeconfig `Kubeconfig::read()` `K8sDriver:: +/// from_context`/`observe_k8s_identity` already use). +pub fn list_k8s_contexts() -> K8sContextsResult { + match kube::config::Kubeconfig::read() { + Ok(kubeconfig) => { + let contexts = kubeconfig + .contexts + .iter() + .filter_map(|c| { + let ctx = c.context.as_ref()?; + Some(K8sContextInfo { + name: c.name.clone(), + cluster: ctx.cluster.clone(), + namespace: ctx.namespace.clone(), + user: ctx.user.clone(), + }) + }) + .collect(); + K8sContextsResult { + contexts, + current_context: kubeconfig.current_context, + exists: true, + error: None, + } + } + Err(e) => { + // `kube`'s Kubeconfig::read() reports a missing file the same way + // as a malformed one (both surface as an Err), so distinguish by + // checking the well-known path ourselves rather than string- + // matching the error — a real parse failure still reports here, + // just via the fallback `exists` probe. + let path = std::env::var_os("KUBECONFIG") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(|h| std::path::PathBuf::from(h).join(".kube").join("config")) + }); + let exists = path.is_some_and(|p| p.exists()); + K8sContextsResult { + contexts: Vec::new(), + current_context: None, + exists, + error: if exists { Some(e.to_string()) } else { None }, + } + } + } +} + // ---- Fleet → managing-credential binding (ADR: Per-Fleet managing identity) -- // // The *declarative* side of the loop: which credential should manage which @@ -1284,4 +1477,60 @@ namespace = "prod" // this is the cross-crate seam that catches drift instead. assert_eq!(oabctl::studio_api::BUNDLE_ZIP_FILENAME, studio_compose::Bundle::ZIP_FILENAME); } + + #[test] + fn aws_config_parses_default_and_named_profiles_with_region() { + let text = "\ +[default] +region = us-east-1 +output = json + +[profile oab-fleet] +region = ap-east-2 + +[profile no-region] +"; + let profiles = parse_aws_config(text); + assert_eq!(profiles.len(), 3); + assert_eq!(profiles[0].name, "default"); + assert_eq!(profiles[0].region.as_deref(), Some("us-east-1")); + assert_eq!(profiles[1].name, "oab-fleet"); + assert_eq!(profiles[1].region.as_deref(), Some("ap-east-2")); + assert_eq!(profiles[2].name, "no-region"); + assert_eq!(profiles[2].region, None); + } + + #[test] + fn aws_config_ignores_comments_and_blank_lines() { + let text = "\ +# a comment +; also a comment + +[default] +; region is commented out +# region = eu-west-1 +region = us-west-2 +"; + let profiles = parse_aws_config(text); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].region.as_deref(), Some("us-west-2")); + } + + #[test] + fn aws_config_empty_text_yields_no_profiles() { + assert!(parse_aws_config("").is_empty()); + } + + #[test] + fn aws_credentials_names_are_bare_no_profile_prefix() { + let text = "\ +[default] +aws_access_key_id = AKIA... + +[oab-fleet] +aws_access_key_id = AKIA... +"; + let names = parse_aws_credentials_names(text); + assert_eq!(names, vec!["default".to_string(), "oab-fleet".to_string()]); + } }