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([

View file

@ -5,7 +5,7 @@ mod local_control;
mod local_transport;
mod peer_client;
mod peer_control;
mod resource_contracts;
pub mod resource_contracts;
mod runtime;
pub mod service;
mod sync;

View file

@ -1,20 +1,20 @@
//! Reviewable resource-family contracts for daemon handlers.
//!
//! These contracts are intentionally small and static. They make every
//! resource family name its resource ID pattern, remote capabilities, and
//! resource family name its resource ID pattern, resource-scoped capabilities, and
//! mutation or host-access points in one place while the implementation
//! continues to live in the focused handler functions in `lib.rs` and
//! `local_control.rs`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct ResourceModuleContract {
pub(crate) family: &'static str,
pub(crate) resource_ids: &'static [&'static str],
pub(crate) capabilities: &'static [&'static str],
pub(crate) mutation_points: &'static [&'static str],
pub struct ResourceModuleContract {
pub family: &'static str,
pub resource_ids: &'static [&'static str],
pub capabilities: &'static [&'static str],
pub mutation_points: &'static [&'static str],
}
pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[
pub const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[
ResourceModuleContract {
family: "cas-file-root",
resource_ids: &["resource:cas:local", "resource:cas-tree:<name>"],
@ -29,7 +29,12 @@ pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[
ResourceModuleContract {
family: "kv",
resource_ids: &["resource:kv:<name>"],
capabilities: &["kv.read", "kv.write_prefix:<prefix>", "kv.write_key:<key>"],
capabilities: &[
"kv.read",
"kv.write",
"kv.write_prefix:<prefix>",
"kv.write_key:<key>",
],
mutation_points: &[
"local SQLite-backed KV create/set",
"authorized sync imports newer or equal timestamp entries idempotently",
@ -87,7 +92,14 @@ pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[
"resource:ssh-proxy:local",
],
capabilities: &[
"ssh_cert.request",
"ssh_cert.read",
"ssh_cert.approve",
"ssh_cert.import",
"ssh_cert.sync",
"ssh_revocation.publish",
"ssh_revocation.read",
"ssh_revocation.import",
"ssh_revocation.sync",
"ssh_proxy.connect",
"ssh_proxy.admin_shell",
@ -102,7 +114,7 @@ pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[
ResourceModuleContract {
family: "overlay",
resource_ids: &["resource:overlay:<name>"],
capabilities: &["overlay.route"],
capabilities: &["overlay.join", "overlay.route"],
mutation_points: &[
"local overlay network membership metadata",
"daemon-lifetime packet queue and optional TUN/Wintun injection",
@ -111,7 +123,8 @@ pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[
},
];
pub(crate) fn contracts() -> &'static [ResourceModuleContract] {
#[must_use]
pub fn contracts() -> &'static [ResourceModuleContract] {
RESOURCE_MODULE_CONTRACTS
}
@ -160,4 +173,43 @@ mod tests {
);
}
}
#[test]
fn catalog_includes_operator_facing_grant_vocabulary() {
let capabilities = RESOURCE_MODULE_CONTRACTS
.iter()
.flat_map(|contract| contract.capabilities.iter().copied())
.collect::<std::collections::BTreeSet<_>>();
for expected in [
"cas.fetch",
"kv.read",
"kv.write",
"db.sync",
"document.read",
"pubsub.publish",
"pubsub.subscribe",
"pipe.connect",
"pipe.listen",
"pipe.forward",
"ssh_cert.request",
"ssh_cert.read",
"ssh_cert.approve",
"ssh_cert.import",
"ssh_cert.sync",
"ssh_revocation.publish",
"ssh_revocation.read",
"ssh_revocation.import",
"ssh_revocation.sync",
"ssh_proxy.connect",
"ssh_proxy.admin_shell",
"overlay.join",
"overlay.route",
] {
assert!(
capabilities.contains(expected),
"missing capability {expected}"
);
}
}
}