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
50 changes: 49 additions & 1 deletion crates/oab-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,28 @@ pub fn tools() -> Vec<Tool> {
"properties": {}
})),
),
Tool::new(
"list_namespaces",
"List namespaces in the cluster a kubeconfig context resolves to. Read-only; backs the New Fleet wizard's namespace <select> (with a manual-entry fallback for a namespace that doesn't exist yet — this can only list what's already there).",
as_map(json!({
"type": "object",
"properties": {
"context": { "type": "string", "description": "Kubeconfig context name. Omit to use the kubeconfig's current-context." }
}
})),
),
Tool::new(
"list_service_accounts",
"List service accounts in one namespace of a kubeconfig context. Read-only; backs the New Fleet wizard's optional service-account <select>. Any failure here (including an RBAC-denied list) should be treated by the caller as \"leave it unset\" — the namespace's `default` service account applies — not surfaced as an error.",
as_map(json!({
"type": "object",
"properties": {
"context": { "type": "string", "description": "Kubeconfig context name. Omit to use the kubeconfig's current-context." },
"namespace": { "type": "string", "description": "k8s namespace to list service accounts in." }
},
"required": ["namespace"]
})),
),
]
}

Expand Down Expand Up @@ -337,6 +359,8 @@ impl OabMcp {
"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),
"list_namespaces" => self.t_list_namespaces(args).await,
"list_service_accounts" => self.t_list_service_accounts(args).await,
other => anyhow::bail!("unknown tool {other:?}"),
}
}
Expand Down Expand Up @@ -748,6 +772,28 @@ impl OabMcp {
}))
}

/// Namespace discovery (studio#104): backs the New Fleet wizard's
/// namespace `<select>`.
async fn t_list_namespaces(&self, args: &Map<String, Value>) -> Result<Value> {
let context = args.get("context").and_then(Value::as_str);
let namespaces = scp::list_namespaces(context).await?;
Ok(json!({ "namespaces": namespaces }))
}

/// Service-account discovery (studio#104): backs the New Fleet wizard's
/// optional service-account `<select>`. Errors (including RBAC-denied)
/// propagate as a normal tool error — per the design, the caller treats
/// any failure here as "leave it unset", not something to surface.
async fn t_list_service_accounts(&self, args: &Map<String, Value>) -> Result<Value> {
let context = args.get("context").and_then(Value::as_str);
let namespace = args
.get("namespace")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("missing required arg: namespace"))?;
let service_accounts = scp::list_service_accounts(context, namespace).await?;
Ok(json!({ "service_accounts": service_accounts }))
}

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 @@ -830,7 +876,7 @@ mod tests {
.iter()
.map(|t| t["name"].as_str().expect("tool has a name").to_string())
.collect();
assert_eq!(names.len(), 13);
assert_eq!(names.len(), 15);
for expected in [
"deploy_list",
"deploy_get",
Expand All @@ -845,6 +891,8 @@ mod tests {
"fleet_config_write",
"list_aws_profiles",
"list_k8s_contexts",
"list_namespaces",
"list_service_accounts",
] {
assert!(names.contains(&expected.to_string()), "missing {expected}");
}
Expand Down
54 changes: 48 additions & 6 deletions crates/studio-cp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,14 +295,9 @@ pub async fn observe_k8s_identity(context: Option<&str>) -> anyhow::Result<Runti
None => String::new(),
};

let options = kube::config::KubeConfigOptions {
context: context.map(str::to_string),
..Default::default()
};
let config = kube::Config::from_kubeconfig(&options)
let client = k8s_client_for(context)
.await
.map_err(|e| anyhow::anyhow!("failed to resolve kubeconfig context '{context_name}': {e}"))?;
let client = kube::Client::try_from(config).map_err(|e| anyhow::anyhow!("failed to build k8s client: {e}"))?;

let api: Api<SelfSubjectReview> = Api::all(client);
let review = api
Expand Down Expand Up @@ -519,6 +514,53 @@ pub fn list_k8s_contexts() -> K8sContextsResult {
}
}

async fn k8s_client_for(context: Option<&str>) -> anyhow::Result<kube::Client> {
let options = kube::config::KubeConfigOptions {
context: context.map(str::to_string),
..Default::default()
};
let config = kube::Config::from_kubeconfig(&options)
.await
.map_err(|e| anyhow::anyhow!("failed to resolve kubeconfig context: {e}"))?;
kube::Client::try_from(config).map_err(|e| anyhow::anyhow!("failed to build k8s client: {e}"))
}

/// List namespaces in the cluster the given kubeconfig context (or the
/// ambient current-context) resolves to. Backs the New Fleet wizard's
/// namespace `<select>` — a manual-entry fallback covers a namespace that
/// doesn't exist yet (this can only list what's already there).
pub async fn list_namespaces(context: Option<&str>) -> anyhow::Result<Vec<String>> {
use k8s_openapi::api::core::v1::Namespace;
use kube::api::{Api, ListParams};

let client = k8s_client_for(context).await?;
let api: Api<Namespace> = Api::all(client);
let list = api
.list(&ListParams::default())
.await
.map_err(|e| anyhow::anyhow!("failed to list namespaces: {e}"))?;
Ok(list.items.into_iter().filter_map(|ns| ns.metadata.name).collect())
}

/// List service accounts in one namespace of the given kubeconfig context.
/// Backs the New Fleet wizard's (optional) service-account `<select>` — the
/// caller falls back to leaving it unset (the namespace's `default` service
/// account applies) on any error here, including an RBAC-denied `list`, so
/// this deliberately doesn't distinguish failure reasons the way
/// `list_aws_profiles`/`list_k8s_contexts` do.
pub async fn list_service_accounts(context: Option<&str>, namespace: &str) -> anyhow::Result<Vec<String>> {
use k8s_openapi::api::core::v1::ServiceAccount;
use kube::api::{Api, ListParams};

let client = k8s_client_for(context).await?;
let api: Api<ServiceAccount> = Api::namespaced(client, namespace);
let list = api
.list(&ListParams::default())
.await
.map_err(|e| anyhow::anyhow!("failed to list service accounts: {e}"))?;
Ok(list.items.into_iter().filter_map(|sa| sa.metadata.name).collect())
}

// ---- Fleet → managing-credential binding (ADR: Per-Fleet managing identity) --
//
// The *declarative* side of the loop: which credential should manage which
Expand Down
Loading