Add local DB resource registration

This commit is contained in:
Eric Wendland 2026-05-16 21:13:33 +02:00
commit 4ec50d7473
14 changed files with 313 additions and 8 deletions

View file

@ -118,6 +118,8 @@ Roadmap items should be actionable and checkable:
`keychain status`. SSH signature capture/verification is still roadmap work. `keychain status`. SSH signature capture/verification is still roadmap work.
- Local CAS supports pin/unpin metadata, surfaced through `cas list`, and - Local CAS supports pin/unpin metadata, surfaced through `cas list`, and
`cas cleanup` evicts unpinned blobs while retaining pinned blobs. `cas cleanup` evicts unpinned blobs while retaining pinned blobs.
- DB resources can be registered locally and report local-only status. cr-sqlite
loading, change extraction, and sync are still roadmap work.
- Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future

3
Cargo.lock generated
View file

@ -1115,6 +1115,7 @@ name = "geth-control"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"geth-auth", "geth-auth",
"geth-db",
"geth-keychain", "geth-keychain",
"geth-resource", "geth-resource",
"geth-ssh-identity", "geth-ssh-identity",
@ -1145,6 +1146,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"geth-types", "geth-types",
"serde", "serde",
"thiserror 2.0.18",
] ]
[[package]] [[package]]
@ -1205,6 +1207,7 @@ dependencies = [
"geth-config", "geth-config",
"geth-control", "geth-control",
"geth-crypto", "geth-crypto",
"geth-db",
"geth-iroh", "geth-iroh",
"geth-keychain", "geth-keychain",
"geth-resource", "geth-resource",

View file

@ -81,6 +81,8 @@ The bootstrap implementation provides:
- `geth auth revoke <resource> <grant-id>` - `geth auth revoke <resource> <grant-id>`
- local filesystem CAS commands: `add`, `get`, `hash`, `has`, `pin`, `unpin`, - local filesystem CAS commands: `add`, `get`, `hash`, `has`, `pin`, `unpin`,
`cleanup`, `list` `cleanup`, `list`
- local DB resource registration: `geth db add <name> <path>` and
`geth db status <name>`
- SSH certificate flow metadata: - SSH certificate flow metadata:
- `geth ssh cert request --public-key <path> --principal <name>` - `geth ssh cert request --public-key <path> --principal <name>`
- `geth ssh cert requests` - `geth ssh cert requests`
@ -91,7 +93,7 @@ The bootstrap implementation provides:
- `geth ssh revocation list` - `geth ssh revocation list`
- `geth ssh revocation export --out <path>` - `geth ssh revocation export --out <path>`
Other command groups exist as explicit stubs: `db`, `kv`, `pipe`, `document`, Other command groups exist as explicit stubs: `kv`, `pipe`, `document`,
`pubsub`, `secret`, and `ssh`. `pubsub`, `secret`, and `ssh`.
## Resource Modules ## Resource Modules

View file

@ -419,9 +419,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
module: "pipe".to_owned(), module: "pipe".to_owned(),
command: format!("{command:?}"), command: format!("{command:?}"),
}, },
Command::Db { command } => ControlRequest::ModuleStub { Command::Db { command } => match command {
module: "db".to_owned(), DbCommand::Add { name, path } => ControlRequest::DbAdd { name, path },
command: format!("{command:?}"), DbCommand::Status { name } => ControlRequest::DbStatus { name },
}, },
Command::Document { command } => ControlRequest::ModuleStub { Command::Document { command } => ControlRequest::ModuleStub {
module: "document".to_owned(), module: "document".to_owned(),
@ -744,6 +744,28 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
ControlResponse::SshRevocationExported { out, count } => { ControlResponse::SshRevocationExported { out, count } => {
println!("exported {count} ssh revocations to {}", out.display()); println!("exported {count} ssh revocations to {}", out.display());
} }
ControlResponse::DbAdded { db } => {
println!("registered db: {}", db.name);
println!("id: {}", db.id);
println!("resource: {}", db.resource);
println!("path: {}", db.path);
println!("sync_status: {}", db.sync_status);
}
ControlResponse::DbStatus { db } => {
println!("db: {}", db.name);
println!("id: {}", db.id);
println!("resource: {}", db.resource);
println!("path: {}", db.path);
println!("path_exists: {}", db.path_exists);
println!(
"size_bytes: {}",
db.size_bytes
.map(|size| size.to_string())
.unwrap_or_else(|| "unknown".to_owned())
);
println!("schema_metadata: {}", db.schema_metadata);
println!("sync_status: {}", db.sync_status);
}
ControlResponse::NotImplemented { module, command } => { ControlResponse::NotImplemented { module, command } => {
println!("{module} {command}: not implemented yet"); println!("{module} {command}: not implemented yet");
} }

View file

@ -10,6 +10,7 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
geth-auth = { path = "../geth-auth" } geth-auth = { path = "../geth-auth" }
geth-db = { path = "../geth-db" }
geth-keychain = { path = "../geth-keychain" } geth-keychain = { path = "../geth-keychain" }
geth-resource = { path = "../geth-resource" } geth-resource = { path = "../geth-resource" }
geth-ssh-identity = { path = "../geth-ssh-identity" } geth-ssh-identity = { path = "../geth-ssh-identity" }

View file

@ -1,4 +1,5 @@
use geth_auth::{AuthExplanation, AuthOp}; use geth_auth::{AuthExplanation, AuthOp};
use geth_db::DbResource;
use geth_keychain::KeychainOp; use geth_keychain::KeychainOp;
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
use geth_ssh_identity::{ use geth_ssh_identity::{
@ -90,6 +91,13 @@ pub enum ControlRequest {
SshRevocationExport { SshRevocationExport {
out: PathBuf, out: PathBuf,
}, },
DbAdd {
name: String,
path: PathBuf,
},
DbStatus {
name: String,
},
ModuleStub { ModuleStub {
module: String, module: String,
command: String, command: String,
@ -169,6 +177,12 @@ pub enum ControlResponse {
out: PathBuf, out: PathBuf,
count: usize, count: usize,
}, },
DbAdded {
db: DbResource,
},
DbStatus {
db: DbResource,
},
NotImplemented { NotImplemented {
module: String, module: String,
command: String, command: String,

View file

@ -7,4 +7,5 @@ license.workspace = true
[dependencies] [dependencies]
serde.workspace = true serde.workspace = true
thiserror.workspace = true
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }

View file

@ -5,11 +5,47 @@ use serde::{Deserialize, Serialize};
pub struct DbResource { pub struct DbResource {
pub id: DbId, pub id: DbId,
pub resource: ResourceId, pub resource: ResourceId,
pub name: String,
pub path: String, pub path: String,
pub path_exists: bool,
pub size_bytes: Option<u64>,
pub schema_metadata: String,
pub sync_status: String, pub sync_status: String,
} }
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("invalid db resource name: {0}")]
InvalidName(String),
}
pub fn validate_db_name(name: &str) -> Result<(), DbError> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
return Err(DbError::InvalidName(name.to_owned()));
}
Ok(())
}
#[must_use] #[must_use]
pub fn crsqlite_sync_roadmap() -> &'static str { pub fn crsqlite_sync_roadmap() -> &'static str {
"future db sync reads crsql_changes, exchanges changes over Iroh, and applies through crsql_changes" "future db sync reads crsql_changes, exchanges changes over Iroh, and applies through crsql_changes"
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn db_name_validation_rejects_paths_and_empty_names() {
assert!(validate_db_name("notes").is_ok());
assert!(validate_db_name("notes.v1").is_ok());
assert!(validate_db_name("").is_err());
assert!(validate_db_name("../notes").is_err());
assert!(validate_db_name("notes/main").is_err());
assert!(validate_db_name("notes main").is_err());
}
}

View file

@ -15,6 +15,7 @@ geth-cas = { path = "../geth-cas" }
geth-config = { path = "../geth-config" } geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" } geth-control = { path = "../geth-control" }
geth-crypto = { path = "../geth-crypto" } geth-crypto = { path = "../geth-crypto" }
geth-db = { path = "../geth-db" }
geth-iroh = { path = "../geth-iroh" } geth-iroh = { path = "../geth-iroh" }
geth-keychain = { path = "../geth-keychain" } geth-keychain = { path = "../geth-keychain" }
geth-resource = { path = "../geth-resource" } geth-resource = { path = "../geth-resource" }

View file

@ -8,6 +8,7 @@ use geth_control::{
StatusResponse, StatusResponse,
}; };
use geth_crypto::AgentKey; use geth_crypto::AgentKey;
use geth_db::DbResource;
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
use geth_keychain::{KeychainOp, KeychainOpKind}; use geth_keychain::{KeychainOp, KeychainOpKind};
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
@ -17,7 +18,7 @@ use geth_ssh_identity::{
certificate_id, revocation_id, ssh_public_key_fingerprint, certificate_id, revocation_id, ssh_public_key_fingerprint,
}; };
use geth_store::{ use geth_store::{
Store, StoredAuthOp, StoredKeychainOp, StoredResource, StoredSshCertRequest, Store, StoredAuthOp, StoredDbResource, StoredKeychainOp, StoredResource, StoredSshCertRequest,
StoredSshCertificate, StoredSshRevocation, StoredSshCertificate, StoredSshRevocation,
}; };
use geth_types::{ use geth_types::{
@ -46,6 +47,12 @@ pub enum NodeError {
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("invalid resource kind: {0}")] #[error("invalid resource kind: {0}")]
InvalidResourceKind(String), InvalidResourceKind(String),
#[error("invalid db resource name: {0}")]
InvalidDbName(String),
#[error("db path does not exist or is not a file: {0}")]
InvalidDbPath(String),
#[error("db resource not found: {0}")]
DbNotFound(String),
#[error("invalid ssh certificate kind: {0}")] #[error("invalid ssh certificate kind: {0}")]
InvalidSshCertKind(String), InvalidSshCertKind(String),
#[error("invalid ssh certificate request status: {0}")] #[error("invalid ssh certificate request status: {0}")]
@ -550,6 +557,41 @@ pub fn handle_request(
count: revocations.len(), count: revocations.len(),
}) })
} }
ControlRequest::DbAdd { name, path } => {
geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?;
if !path.is_file() {
return Err(NodeError::InvalidDbPath(path.display().to_string()));
}
let path = std::fs::canonicalize(path)?;
let resource_id = format!("resource:db:{name}");
let db_id = format!("db:{name}");
let resource = StoredResource {
resource_id: resource_id.clone(),
kind: ResourceKind::Db.to_string(),
name: name.clone(),
status: "active".to_owned(),
};
store.insert_resource(&resource)?;
let stored = StoredDbResource {
db_id,
resource_id,
name,
path: path.display().to_string(),
};
store.insert_db_resource(&stored)?;
Ok(ControlResponse::DbAdded {
db: db_resource_from_stored(&stored)?,
})
}
ControlRequest::DbStatus { name } => {
geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?;
let stored = store
.get_db_resource_by_name(&name)?
.ok_or_else(|| NodeError::DbNotFound(name.clone()))?;
Ok(ControlResponse::DbStatus {
db: db_resource_from_stored(&stored)?,
})
}
ControlRequest::ModuleStub { module, command } => { ControlRequest::ModuleStub { module, command } => {
Ok(ControlResponse::NotImplemented { module, command }) Ok(ControlResponse::NotImplemented { module, command })
} }
@ -568,6 +610,21 @@ fn stored_resource_to_descriptor(stored: StoredResource) -> Result<ResourceDescr
)) ))
} }
fn db_resource_from_stored(stored: &StoredDbResource) -> Result<DbResource, NodeError> {
let path = Path::new(&stored.path);
let metadata = path.metadata().ok();
Ok(DbResource {
id: stored.db_id.clone().into(),
resource: stored.resource_id.clone().into(),
name: stored.name.clone(),
path: stored.path.clone(),
path_exists: metadata.as_ref().is_some_and(std::fs::Metadata::is_file),
size_bytes: metadata.map(|metadata| metadata.len()),
schema_metadata: "not-inspected-until-crsqlite-integration".to_owned(),
sync_status: "local-only".to_owned(),
})
}
fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> { fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> {
store.insert_auth_op(&StoredAuthOp { store.insert_auth_op(&StoredAuthOp {
op_id: op.id.to_string(), op_id: op.id.to_string(),

View file

@ -222,6 +222,59 @@ impl Store {
Ok(()) Ok(())
} }
pub fn get_resource_by_kind_name(
&self,
kind: &str,
name: &str,
) -> Result<Option<StoredResource>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT resource_id, kind, name, status FROM resources WHERE kind = ?1 AND name = ?2",
)?;
let mut rows = stmt.query(params![kind, name])?;
if let Some(row) = rows.next()? {
Ok(Some(StoredResource {
resource_id: row.get(0)?,
kind: row.get(1)?,
name: row.get(2)?,
status: row.get(3)?,
}))
} else {
Ok(None)
}
}
pub fn insert_db_resource(&self, db: &StoredDbResource) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO db_resources(db_id, resource_id, path)
VALUES (?1, ?2, ?3)"#,
params![db.db_id, db.resource_id, db.path],
)?;
Ok(())
}
pub fn get_db_resource_by_name(
&self,
name: &str,
) -> Result<Option<StoredDbResource>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT db.db_id, db.resource_id, resources.name, db.path
FROM db_resources db
JOIN resources ON resources.resource_id = db.resource_id
WHERE resources.kind = 'db' AND resources.name = ?1"#,
)?;
let mut rows = stmt.query(params![name])?;
if let Some(row) = rows.next()? {
Ok(Some(StoredDbResource {
db_id: row.get(0)?,
resource_id: row.get(1)?,
name: row.get(2)?,
path: row.get(3)?,
}))
} else {
Ok(None)
}
}
pub fn record_cas_object( pub fn record_cas_object(
&self, &self,
hash: &str, hash: &str,
@ -561,6 +614,14 @@ pub struct StoredResource {
pub status: String, pub status: String,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredDbResource {
pub db_id: String,
pub resource_id: String,
pub name: String,
pub path: String,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct CasObject { pub struct CasObject {
pub hash: String, pub hash: String,
@ -788,4 +849,38 @@ mod tests {
assert!(store.list_cas_objects().expect("objects").is_empty()); assert!(store.list_cas_objects().expect("objects").is_empty());
assert!(store.list_cas_pins().expect("pins").is_empty()); assert!(store.list_cas_pins().expect("pins").is_empty());
} }
#[test]
fn db_resource_roundtrip_by_name() {
let store = Store::open_memory().expect("open");
let resource = StoredResource {
resource_id: "resource:db:notes".to_owned(),
kind: "db".to_owned(),
name: "notes".to_owned(),
status: "active".to_owned(),
};
store.insert_resource(&resource).expect("insert resource");
store
.insert_db_resource(&StoredDbResource {
db_id: "db:notes".to_owned(),
resource_id: resource.resource_id.clone(),
name: resource.name.clone(),
path: "/tmp/notes.sqlite".to_owned(),
})
.expect("insert db");
assert_eq!(
store
.get_resource_by_kind_name("db", "notes")
.expect("get resource"),
Some(resource)
);
assert_eq!(
store
.get_db_resource_by_name("notes")
.expect("get db")
.map(|db| db.path),
Some("/tmp/notes.sqlite".to_owned())
);
}
} }

View file

@ -387,6 +387,73 @@ fn keychain_init_and_status_use_local_keychain_log() {
} }
} }
#[test]
fn db_add_and_status_register_local_db_metadata() {
let home = tempfile::tempdir().expect("tempdir");
let paths = geth_config::GethPaths::from_home(home.path());
let node = geth_node::init_node(&paths).expect("init node");
let db_path = home.path().join("notes.sqlite");
std::fs::write(&db_path, b"sqlite placeholder").expect("write db");
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::DbAdd {
name: "notes".to_owned(),
path: db_path.clone(),
},
)
.expect("add db");
match response {
geth_control::ControlResponse::DbAdded { db } => {
assert_eq!(db.name, "notes");
assert_eq!(db.sync_status, "local-only");
assert!(db.path_exists);
assert_eq!(db.size_bytes, Some(18));
}
other => panic!("unexpected response: {other:?}"),
}
let response = geth_node::handle_request(
&node,
geth_control::ControlRequest::DbStatus {
name: "notes".to_owned(),
},
)
.expect("db status");
match response {
geth_control::ControlResponse::DbStatus { db } => {
assert_eq!(db.name, "notes");
assert!(db.path.ends_with("notes.sqlite"));
assert_eq!(
db.schema_metadata,
"not-inspected-until-crsqlite-integration"
);
}
other => panic!("unexpected response: {other:?}"),
}
assert!(
geth_node::handle_request(
&node,
geth_control::ControlRequest::DbAdd {
name: "../bad".to_owned(),
path: db_path,
},
)
.is_err()
);
assert!(
geth_node::handle_request(
&node,
geth_control::ControlRequest::DbAdd {
name: "missing".to_owned(),
path: home.path().join("missing.sqlite"),
},
)
.is_err()
);
}
#[test] #[test]
fn ssh_cert_request_approval_and_revocation_export_use_local_state() { fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
let home = tempfile::tempdir().expect("tempdir"); let home = tempfile::tempdir().expect("tempdir");

View file

@ -93,8 +93,12 @@ storage. Local pin/unpin metadata is tracked in SQLite and surfaced in
blobs. Iroh-blobs, providers, encrypted blobs, richer cache policies, manifests, blobs. Iroh-blobs, providers, encrypted blobs, richer cache policies, manifests,
and file sync trees are future work. and file sync trees are future work.
`geth-kv`, `geth-db`, `geth-document`, `geth-pubsub`, `geth-pipe`, and `geth-db` currently registers local SQLite paths as DB resources and reports
`geth-ssh-proxy` currently define types, command shape, and roadmap stubs. local-only sync status plus placeholder schema metadata. cr-sqlite loading,
change extraction, and DB sync are future work.
`geth-kv`, `geth-document`, `geth-pubsub`, `geth-pipe`, and `geth-ssh-proxy`
currently define types, command shape, and roadmap stubs.
`geth-ssh-identity` defines SSH trust namespaces plus certificate request, `geth-ssh-identity` defines SSH trust namespaces plus certificate request,
approval, certificate import, and revocation-list data models. The bootstrap approval, certificate import, and revocation-list data models. The bootstrap

View file

@ -273,7 +273,7 @@ Goal: add authorized stream-oriented management workflows over Iroh.
Goal: add durable synchronized data structures for SQLite/cr-sqlite and Goal: add durable synchronized data structures for SQLite/cr-sqlite and
Automerge documents. Automerge documents.
- `[ ]` DB resource registration. - `[x]` DB resource registration.
Acceptance criteria: Acceptance criteria:
- `geth db add <name> <path>` records DB metadata. - `geth db add <name> <path>` records DB metadata.
- `geth db status <name>` reports local path, schema metadata, and sync state. - `geth db status <name>` reports local path, schema metadata, and sync state.