Add offline resource capability discovery
This commit is contained in:
parent
116a431a12
commit
47d16eb714
6 changed files with 200 additions and 12 deletions
|
|
@ -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>]`
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue