Skip to content
Open
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
54 changes: 53 additions & 1 deletion crates/oab-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,22 @@ pub fn tools() -> Vec<Tool> {
"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": {}
})),
),
]
}

Expand Down Expand Up @@ -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:?}"),
}
}
Expand Down Expand Up @@ -698,6 +716,38 @@ impl OabMcp {
self.t_fleet_config(args)
}

/// Local AWS profile discovery (studio#104): backs the New Fleet wizard's
/// profile `<select>`. `exists`/`error` are surfaced separately so the
/// console can tell "nothing configured yet" (show setup guidance) apart
/// from "found a config but couldn't read it" (show the raw error).
fn t_list_aws_profiles(&self, _args: &Map<String, Value>) -> Result<Value> {
let r = scp::list_aws_profiles();
Ok(json!({
"profiles": r.profiles.iter().map(|p| json!({ "name": p.name, "region": p.region })).collect::<Vec<_>>(),
"source_path": r.source_path,
"exists": r.exists,
"error": r.error,
}))
}

/// Local kubeconfig context discovery (studio#104): backs the New Fleet
/// wizard's k8s context `<select>`. Same `exists`/`error` split as
/// `list_aws_profiles`.
fn t_list_k8s_contexts(&self, _args: &Map<String, Value>) -> Result<Value> {
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::<Vec<_>>(),
"current_context": r.current_context,
"exists": r.exists,
"error": r.error,
}))
}

async fn t_delete(&self, args: &Map<String, Value>) -> Result<Value> {
let t = self.target(args)?;
let cluster = t.cluster.clone();
Expand Down Expand Up @@ -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",
Expand All @@ -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}");
}
Expand Down
249 changes: 249 additions & 0 deletions crates/studio-cp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,199 @@ pub async fn observe_k8s_identity(context: Option<&str>) -> anyhow::Result<Runti
})
}

// ---- Discovery: local AWS profiles / kubeconfig contexts (studio#104) -------
//
// Backs the "+ New fleet" console wizard's provider-specific `<select>`
// 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<String>,
}

/// 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<AwsProfile>,
pub source_path: String,
pub exists: bool,
pub error: Option<String>,
}

fn aws_dir() -> Option<std::path::PathBuf> {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".aws"))
}

/// Parse `~/.aws/config`'s `[default]` / `[profile <name>]` 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<AwsProfile> {
let mut profiles = Vec::new();
let mut current: Option<AwsProfile> = 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 `[<name>]` 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<String> {
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<String>,
pub user: Option<String>,
}

/// 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<K8sContextInfo>,
pub current_context: Option<String>,
pub exists: bool,
pub error: Option<String>,
}

/// 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
Expand Down Expand Up @@ -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()]);
}
}
Loading