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

@ -210,6 +210,7 @@ The bootstrap implementation provides:
Wintun guidance. Wintun guidance.
- `geth resource list` - `geth resource list`
- `geth resource create <kind> <name>` - `geth resource create <kind> <name>`
- `geth resource capabilities [family]`
- `geth keychain init [--admin-key <path>] [--signing-key <path>]` - `geth keychain init [--admin-key <path>] [--signing-key <path>]`
- `geth keychain status` - `geth keychain status`
- `geth keychain admin-add --admin-key <pub> --signing-key <private> [--principal <name>]` - `geth keychain admin-add --admin-key <pub> --signing-key <private> [--principal <name>]`

View file

@ -483,7 +483,7 @@ pub enum Command {
#[command(subcommand)] #[command(subcommand)]
command: OverlayCommand, command: OverlayCommand,
}, },
/// List and create resource registrations /// List/create resources and discover authorization capabilities
Resource { Resource {
#[command(subcommand)] #[command(subcommand)]
command: ResourceCommand, command: ResourceCommand,
@ -974,6 +974,15 @@ pub enum ResourceCommand {
List, List,
/// Register a named resource /// Register a named resource
Create { kind: String, name: String }, 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)] #[derive(Debug, Subcommand)]
@ -1831,6 +1840,13 @@ async fn run_inner(cli: Cli) -> Result<()> {
print_completions(*shell); print_completions(*shell);
return Ok(()); 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() if cli.home.is_some()
&& matches!( && matches!(
&cli.command, &cli.command,
@ -2292,6 +2308,82 @@ fn print_guide(topic: Option<GuideTopic>, output: OutputMode) -> Result<()> {
Ok(()) 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) { fn print_completions(shell: Shell) {
let mut command = documented_cli_command(); let mut command = documented_cli_command();
generate(shell, &mut command, "geth", &mut stdout()); generate(shell, &mut command, "geth", &mut stdout());
@ -2500,6 +2592,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
Command::Resource { Command::Resource {
command: ResourceCommand::Create { kind, name }, command: ResourceCommand::Create { kind, name },
} => ControlRequest::ResourceCreate { kind, name }, } => ControlRequest::ResourceCreate { kind, name },
Command::Resource {
command: ResourceCommand::Capabilities { .. },
} => bail!("resource capability discovery is handled directly by the CLI"),
Command::Keychain { Command::Keychain {
command: command:
KeychainCommand::Init { KeychainCommand::Init {
@ -5487,6 +5582,30 @@ mod tests {
assert!(Cli::try_parse_from(["geth", "status", "--json", "--jsonl"]).is_err()); 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] #[tokio::test]
async fn ephemeral_daemon_rejects_an_explicit_persistent_home() { async fn ephemeral_daemon_rejects_an_explicit_persistent_home() {
let parsed = Cli::try_parse_from([ let parsed = Cli::try_parse_from([

View file

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

View file

@ -1,20 +1,20 @@
//! Reviewable resource-family contracts for daemon handlers. //! Reviewable resource-family contracts for daemon handlers.
//! //!
//! These contracts are intentionally small and static. They make every //! 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 //! mutation or host-access points in one place while the implementation
//! continues to live in the focused handler functions in `lib.rs` and //! continues to live in the focused handler functions in `lib.rs` and
//! `local_control.rs`. //! `local_control.rs`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct ResourceModuleContract { pub struct ResourceModuleContract {
pub(crate) family: &'static str, pub family: &'static str,
pub(crate) resource_ids: &'static [&'static str], pub resource_ids: &'static [&'static str],
pub(crate) capabilities: &'static [&'static str], pub capabilities: &'static [&'static str],
pub(crate) mutation_points: &'static [&'static str], pub mutation_points: &'static [&'static str],
} }
pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[ pub const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[
ResourceModuleContract { ResourceModuleContract {
family: "cas-file-root", family: "cas-file-root",
resource_ids: &["resource:cas:local", "resource:cas-tree:<name>"], resource_ids: &["resource:cas:local", "resource:cas-tree:<name>"],
@ -29,7 +29,12 @@ pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[
ResourceModuleContract { ResourceModuleContract {
family: "kv", family: "kv",
resource_ids: &["resource:kv:<name>"], 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: &[ mutation_points: &[
"local SQLite-backed KV create/set", "local SQLite-backed KV create/set",
"authorized sync imports newer or equal timestamp entries idempotently", "authorized sync imports newer or equal timestamp entries idempotently",
@ -87,7 +92,14 @@ pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[
"resource:ssh-proxy:local", "resource:ssh-proxy:local",
], ],
capabilities: &[ capabilities: &[
"ssh_cert.request",
"ssh_cert.read",
"ssh_cert.approve",
"ssh_cert.import",
"ssh_cert.sync", "ssh_cert.sync",
"ssh_revocation.publish",
"ssh_revocation.read",
"ssh_revocation.import",
"ssh_revocation.sync", "ssh_revocation.sync",
"ssh_proxy.connect", "ssh_proxy.connect",
"ssh_proxy.admin_shell", "ssh_proxy.admin_shell",
@ -102,7 +114,7 @@ pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[
ResourceModuleContract { ResourceModuleContract {
family: "overlay", family: "overlay",
resource_ids: &["resource:overlay:<name>"], resource_ids: &["resource:overlay:<name>"],
capabilities: &["overlay.route"], capabilities: &["overlay.join", "overlay.route"],
mutation_points: &[ mutation_points: &[
"local overlay network membership metadata", "local overlay network membership metadata",
"daemon-lifetime packet queue and optional TUN/Wintun injection", "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 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}"
);
}
}
} }

View file

@ -132,6 +132,16 @@ For deployment-readiness work that cuts across feature areas, see
- `[x]` Tests and automation documentation define the single-document and - `[x]` Tests and automation documentation define the single-document and
line-oriented contracts. line-oriented contracts.
- `[x]` Make resource and capability vocabulary discoverable offline.
Acceptance criteria:
- `[x]` `geth resource capabilities [family]` lists exact resource ID
patterns, capability strings, and important operations or host effects.
- `[x]` Discovery works before initialization and without a running daemon,
and explicitly states that viewing the catalog does not grant access.
- `[x]` Human, JSON, and JSONL forms include a copy-paste grant template.
- `[x]` Tests cover the full catalog, family filtering, common aliases, and
unknown-family recovery.
- `[ ]` Publish copy-paste installation entrypoints for release artifacts. - `[ ]` Publish copy-paste installation entrypoints for release artifacts.
Acceptance criteria: Acceptance criteria:
- `[ ]` Linux, macOS, and Windows installation instructions verify artifact - `[ ]` Linux, macOS, and Windows installation instructions verify artifact

View file

@ -172,6 +172,7 @@ authorized node fetch it over Iroh.
On the provider: On the provider:
```sh ```sh
geth resource capabilities cas
geth cas add ./archive.tar geth cas add ./archive.tar
geth node grant workstation resource:cas:local cas.fetch \ geth node grant workstation resource:cas:local cas.fetch \
--signing-key ~/.ssh/id_ed25519_sk --signing-key ~/.ssh/id_ed25519_sk
@ -197,6 +198,7 @@ building transport, peer authentication, and retry handling myself.
Start locally with one of: Start locally with one of:
```sh ```sh
geth resource capabilities kv
geth kv create preferences geth kv create preferences
geth kv set preferences theme dark geth kv set preferences theme dark
@ -215,6 +217,10 @@ per-peer/per-stream cursors and retry state. File-root application is
conservative: it does not overwrite local edits, and ambiguous changes become conservative: it does not overwrite local edits, and ambiguous changes become
durable conflicts for `geth cas conflict list`. durable conflicts for `geth cas conflict list`.
`geth resource capabilities [family]` is an offline catalog of exact resource
ID patterns, grant capability strings, and operations or host effects. It does
not require a running daemon and discovery does not create a grant.
## Automate Reliably ## Automate Reliably
User story: as an automation author, I want explicit homes, readiness checks, User story: as an automation author, I want explicit homes, readiness checks,