Add offline resource capability discovery

This commit is contained in:
Eric Wendland 2026-07-18 17:21:12 +02:00
commit 47d16eb714
6 changed files with 200 additions and 12 deletions

View file

@ -483,7 +483,7 @@ pub enum Command {
#[command(subcommand)]
command: OverlayCommand,
},
/// List and create resource registrations
/// List/create resources and discover authorization capabilities
Resource {
#[command(subcommand)]
command: ResourceCommand,
@ -974,6 +974,15 @@ pub enum ResourceCommand {
List,
/// Register a named resource
Create { kind: String, name: String },
/// List resource ID patterns and the capabilities they accept
#[command(
alias = "catalog",
after_help = "Examples:\n geth resource capabilities\n geth resource capabilities kv\n geth resource capabilities --json"
)]
Capabilities {
#[arg(help = "Optional resource family to describe, such as kv, ssh, or pipe")]
family: Option<String>,
},
}
#[derive(Debug, Subcommand)]
@ -1831,6 +1840,13 @@ async fn run_inner(cli: Cli) -> Result<()> {
print_completions(*shell);
return Ok(());
}
if let Command::Resource {
command: ResourceCommand::Capabilities { family },
} = &cli.command
{
print_resource_capabilities(family.as_deref(), output)?;
return Ok(());
}
if cli.home.is_some()
&& matches!(
&cli.command,
@ -2292,6 +2308,82 @@ fn print_guide(topic: Option<GuideTopic>, output: OutputMode) -> Result<()> {
Ok(())
}
fn resource_capability_contracts(
family: Option<&str>,
) -> Result<Vec<&'static geth_node::resource_contracts::ResourceModuleContract>> {
let contracts = geth_node::resource_contracts::contracts();
let Some(family) = family else {
return Ok(contracts.iter().collect());
};
let canonical_family = match family {
"cas" | "file-root" => "cas-file-root",
"ssh-proxy" => "ssh",
other => other,
};
let selected = contracts
.iter()
.filter(|contract| contract.family == canonical_family)
.collect::<Vec<_>>();
if selected.is_empty() {
let available = contracts
.iter()
.map(|contract| contract.family)
.collect::<Vec<_>>()
.join(", ");
bail!(
"invalid resource family {family:?}; available families: {available}\nnext: run `geth resource capabilities` to inspect the complete catalog"
);
}
Ok(selected)
}
fn print_resource_capabilities(family: Option<&str>, output: OutputMode) -> Result<()> {
let contracts = resource_capability_contracts(family)?;
if output.is_machine() {
let families = contracts
.iter()
.map(|contract| {
serde_json::json!({
"family": contract.family,
"resource_id_patterns": contract.resource_ids,
"capabilities": contract.capabilities,
"operations_and_host_effects": contract.mutation_points,
})
})
.collect::<Vec<_>>();
output.print(&serde_json::json!({
"type": "resource-capabilities",
"families": families,
"grant_example": "geth auth grant <subject> <resource-id> <capability> --signing-key <path>",
"note": "replace angle-bracket placeholders; discovery does not grant access",
}))?;
return Ok(());
}
for (index, contract) in contracts.iter().enumerate() {
if index > 0 {
println!();
}
println!("{}", contract.family);
println!(" resource IDs:");
for resource_id in contract.resource_ids {
println!(" {resource_id}");
}
println!(" capabilities:");
for capability in contract.capabilities {
println!(" {capability}");
}
println!(" operations and host effects:");
for point in contract.mutation_points {
println!(" - {point}");
}
}
println!();
println!("grant: geth auth grant <subject> <resource-id> <capability> --signing-key <path>");
println!("note: replace angle-bracket placeholders; discovery does not grant access");
Ok(())
}
fn print_completions(shell: Shell) {
let mut command = documented_cli_command();
generate(shell, &mut command, "geth", &mut stdout());
@ -2500,6 +2592,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
Command::Resource {
command: ResourceCommand::Create { kind, name },
} => ControlRequest::ResourceCreate { kind, name },
Command::Resource {
command: ResourceCommand::Capabilities { .. },
} => bail!("resource capability discovery is handled directly by the CLI"),
Command::Keychain {
command:
KeychainCommand::Init {
@ -5487,6 +5582,30 @@ mod tests {
assert!(Cli::try_parse_from(["geth", "status", "--json", "--jsonl"]).is_err());
}
#[test]
fn resource_capabilities_are_available_without_daemon_state() {
let all = resource_capability_contracts(None).expect("complete catalog");
assert!(all.len() >= 8);
let kv = resource_capability_contracts(Some("kv")).expect("kv catalog");
assert_eq!(kv.len(), 1);
assert_eq!(kv[0].resource_ids, &["resource:kv:<name>"]);
assert!(kv[0].capabilities.contains(&"kv.write_prefix:<prefix>"));
assert_eq!(
resource_capability_contracts(Some("cas")).expect("cas alias")[0].family,
"cas-file-root"
);
assert!(resource_capability_contracts(Some("unknown")).is_err());
let parsed = Cli::try_parse_from(["geth", "resource", "capabilities", "ssh"])
.expect("parse capability discovery");
assert!(matches!(
parsed.command,
Command::Resource {
command: ResourceCommand::Capabilities { family: Some(family) }
} if family == "ssh"
));
}
#[tokio::test]
async fn ephemeral_daemon_rejects_an_explicit_persistent_home() {
let parsed = Cli::try_parse_from([