Polish first-run operator workflow

This commit is contained in:
Eric Wendland 2026-05-22 15:00:09 +02:00
commit e6879e8949
6 changed files with 208 additions and 14 deletions

View file

@ -447,9 +447,82 @@ operation log, not yet a CRDT or Keyhive-style convergent authority.
The daemon also runs best-effort live sync for imported peers. `geth sync now The daemon also runs best-effort live sync for imported peers. `geth sync now
[node]` triggers the same sync pass immediately, and `geth sync status` reports [node]` triggers the same sync pass immediately, and `geth sync status` reports
the last local attempt, success, cursor, import count, rejection count, and the last local attempt, success, cursor, import count, rejection count, and
error per peer stream. Keychain and auth sync now use per-peer high-water error per peer stream. `geth sync status --json` also includes per-stream
cursors, while receivers still verify every imported signed operation before it `state`, `stale`, `stale_after_ms`, and `next_action` fields so smoke tests can
can affect the reduced keychain or authorization views. fail on stale or failed streams. Keychain and auth sync now use per-peer
high-water cursors, while receivers still verify every imported signed operation
before it can affect the reduced keychain or authorization views.
## Two-Machine Smoke Test
Use two terminals or machines with different `GETH_HOME` values.
Owner machine:
```sh
export GETH_HOME=/tmp/geth-owner
geth init --admin-key ~/.ssh/id_ed25519_sk.pub \
--signing-key ~/.ssh/id_ed25519_sk \
--node-name owner
geth daemon run
geth peer export --out /tmp/owner.peer.json
```
New node:
```sh
export GETH_HOME=/tmp/geth-node
geth init
geth daemon run
geth keychain init --admin-key ~/.ssh/id_ed25519_sk.pub
geth peer import /tmp/owner.peer.json
geth node enroll request --node-name workstation \
--capability resource:cas:local=cas.fetch \
--capability resource:kv:notes=kv.read \
--capability resource:document:notes=document.read \
--capability resource:db:notes=db.sync \
--capability resource:ssh-proxy:local=ssh_proxy.connect \
--out /tmp/workstation-enrollment.json
geth node enroll submit owner --path /tmp/workstation-enrollment.json
```
Owner machine:
```sh
geth node enroll list --status pending
geth node enroll approve <request-id> --signing-key ~/.ssh/id_ed25519_sk
geth node grant workstation resource:ssh-proxy:local ssh_proxy.connect \
--signing-key ~/.ssh/id_ed25519_sk
echo "hello geth" > /tmp/hello-geth.txt
geth cas add /tmp/hello-geth.txt
geth kv create notes
geth kv set notes greeting "hello geth"
geth document create notes
geth document set notes '{"greeting":"hello geth"}'
geth ssh cert requests
```
New node:
```sh
geth sync now owner
geth sync status --json
geth peer ping owner
geth cas fetch owner <hash-from-owner-cas-add>
geth kv sync owner notes
geth kv get notes greeting
geth document sync owner notes
geth document get notes
geth db add notes /path/to/crsqlite-notes.sqlite
geth db sync owner notes
geth ssh cert request --public-key ~/.ssh/id_ed25519.pub --principal "$USER"
geth ssh cert sync owner
geth ssh proxy owner
```
If a command fails, the daemon error includes a `next:` line for common recovery
paths such as importing a peer card, running `auth explain`, granting a missing
capability, or creating/registering a missing resource.
## Authorization Direction ## Authorization Direction

View file

@ -1923,8 +1923,10 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
} }
for stream in peer.streams { for stream in peer.streams {
println!( println!(
" {}\tcursor={}\tlast_attempt={}\tlast_success={}\timported={}\trejected={}\terror={}", " {}\tstate={}\tstale={}\tcursor={}\tlast_attempt={}\tlast_success={}\timported={}\trejected={}\terror={}\tnext={}",
stream.stream, stream.stream,
stream.state,
stream.stale,
stream.cursor_ms, stream.cursor_ms,
stream stream
.last_attempt_ms .last_attempt_ms
@ -1936,7 +1938,8 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
.unwrap_or_else(|| "never".to_owned()), .unwrap_or_else(|| "never".to_owned()),
stream.last_imported, stream.last_imported,
stream.last_rejected, stream.last_rejected,
stream.last_error.unwrap_or_else(|| "-".to_owned()) stream.last_error.unwrap_or_else(|| "-".to_owned()),
stream.next_action
); );
} }
} }

View file

@ -1391,11 +1391,15 @@ pub struct SyncPeerStatus {
pub struct SyncStreamStatus { pub struct SyncStreamStatus {
pub stream: String, pub stream: String,
pub cursor_ms: i64, pub cursor_ms: i64,
pub state: String,
pub stale: bool,
pub stale_after_ms: i64,
pub last_attempt_ms: Option<i64>, pub last_attempt_ms: Option<i64>,
pub last_success_ms: Option<i64>, pub last_success_ms: Option<i64>,
pub last_error: Option<String>, pub last_error: Option<String>,
pub last_imported: usize, pub last_imported: usize,
pub last_rejected: usize, pub last_rejected: usize,
pub next_action: String,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -1614,11 +1618,15 @@ mod tests {
streams: vec![SyncStreamStatus { streams: vec![SyncStreamStatus {
stream: "keychain".to_owned(), stream: "keychain".to_owned(),
cursor_ms: 42, cursor_ms: 42,
state: "ok".to_owned(),
stale: false,
stale_after_ms: 120_000,
last_attempt_ms: Some(43), last_attempt_ms: Some(43),
last_success_ms: Some(43), last_success_ms: Some(43),
last_error: None, last_error: None,
last_imported: 2, last_imported: 2,
last_rejected: 0, last_rejected: 0,
next_action: "none".to_owned(),
}], }],
}], }],
note: "local health".to_owned(), note: "local health".to_owned(),

View file

@ -181,6 +181,7 @@ struct PipeRuntime {
const PUBSUB_RING_LIMIT: usize = 256; const PUBSUB_RING_LIMIT: usize = 256;
const PIPE_CONNECTION_RING_LIMIT: usize = 256; const PIPE_CONNECTION_RING_LIMIT: usize = 256;
const PIPE_MESSAGE_RING_LIMIT: usize = 1024; const PIPE_MESSAGE_RING_LIMIT: usize = 1024;
const LIVE_SYNC_STALE_AFTER_MS: i64 = 120_000;
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
struct LiveSyncCursor { struct LiveSyncCursor {
@ -787,7 +788,7 @@ async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeEr
let response = match handle_request_async(&node, request).await { let response = match handle_request_async(&node, request).await {
Ok(response) => response, Ok(response) => response,
Err(error) => ControlResponse::Error { Err(error) => ControlResponse::Error {
message: error.to_string(), message: operator_error_message(&error),
}, },
}; };
let mut stream = reader.into_inner(); let mut stream = reader.into_inner();
@ -797,6 +798,48 @@ async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeEr
Ok(()) Ok(())
} }
fn operator_error_message(error: &NodeError) -> String {
let mut message = error.to_string();
if let Some(next) = operator_recovery_hint(error) {
message.push_str("\nnext: ");
message.push_str(&next);
}
message
}
fn operator_recovery_hint(error: &NodeError) -> Option<String> {
match error {
NodeError::PeerNotFound(peer) => Some(format!(
"import a signed peer card for {peer} with `geth peer import <path>`, then verify it with `geth peer list`"
)),
NodeError::IrohEndpointUnavailable => Some(
"run `geth status` to inspect Iroh endpoint state; restart with `geth daemon run` if the daemon was started without an endpoint".to_owned(),
),
NodeError::Unauthorized(_) => Some(
"run `geth auth explain <subject> <resource> <capability>` and then grant the missing capability with `geth auth grant ... --signing-key <admin-key>`".to_owned(),
),
NodeError::ResourceNotFound(resource) => Some(format!(
"run `geth resource list` to inspect resources or create {resource} with the matching module command"
)),
NodeError::DbNotFound(name) => Some(format!(
"register the database with `geth db add {name} <path>` or inspect existing DB resources with `geth resource list`"
)),
NodeError::KvNotFound(name) => Some(format!(
"create the KV store with `geth kv create {name}` or inspect existing resources with `geth resource list`"
)),
NodeError::DocumentNotFound(name) => Some(format!(
"create the document with `geth document create {name}` or inspect existing resources with `geth resource list`"
)),
NodeError::SshCertRequestNotFound(_) => Some(
"run `geth ssh cert requests` on the signing machine and sync metadata with `geth ssh cert sync <node>` if needed".to_owned(),
),
NodeError::InvalidDbPath(_) => Some(
"create the SQLite file first, then run `geth db add <name> <path>` again".to_owned(),
),
_ => None,
}
}
async fn start_peer_card_lan_discovery(node: &LocalNode) -> Option<swarm_discovery::DropGuard> { async fn start_peer_card_lan_discovery(node: &LocalNode) -> Option<swarm_discovery::DropGuard> {
if !node.iroh_status.local_discovery || !node.iroh_status.enabled { if !node.iroh_status.local_discovery || !node.iroh_status.enabled {
return None; return None;
@ -3124,6 +3167,7 @@ fn sync_status_local(store: &Store) -> Result<ControlResponse, NodeError> {
.into_iter() .into_iter()
.map(|peer| { .map(|peer| {
let prefix = live_sync_status_prefix(&peer.peer_id); let prefix = live_sync_status_prefix(&peer.peer_id);
let now_ms = geth_store::now_ms();
let mut streams = store let mut streams = store
.list_module_states_with_prefix(&prefix)? .list_module_states_with_prefix(&prefix)?
.into_iter() .into_iter()
@ -3131,14 +3175,24 @@ fn sync_status_local(store: &Store) -> Result<ControlResponse, NodeError> {
let stream = state.module.trim_start_matches(&prefix).to_owned(); let stream = state.module.trim_start_matches(&prefix).to_owned();
let health: LiveSyncHealth = serde_json::from_str(&state.state_json)?; let health: LiveSyncHealth = serde_json::from_str(&state.state_json)?;
let cursor_ms = load_live_sync_cursor(store, &peer.peer_id, &stream)?; let cursor_ms = load_live_sync_cursor(store, &peer.peer_id, &stream)?;
let stale = health.last_success_ms.is_some_and(|last_success_ms| {
now_ms.saturating_sub(last_success_ms) > LIVE_SYNC_STALE_AFTER_MS
});
let status_state = sync_stream_state(&health, stale).to_owned();
let next_action =
sync_stream_next_action(&peer.peer_id, &stream, &health, stale);
Ok(SyncStreamStatus { Ok(SyncStreamStatus {
stream, stream,
cursor_ms, cursor_ms,
state: status_state,
stale,
stale_after_ms: LIVE_SYNC_STALE_AFTER_MS,
last_attempt_ms: Some(health.last_attempt_ms), last_attempt_ms: Some(health.last_attempt_ms),
last_success_ms: health.last_success_ms, last_success_ms: health.last_success_ms,
last_error: health.last_error, last_error: health.last_error,
last_imported: health.last_imported, last_imported: health.last_imported,
last_rejected: health.last_rejected, last_rejected: health.last_rejected,
next_action,
}) })
}) })
.collect::<Result<Vec<_>, NodeError>>()?; .collect::<Result<Vec<_>, NodeError>>()?;
@ -3155,6 +3209,41 @@ fn sync_status_local(store: &Store) -> Result<ControlResponse, NodeError> {
}) })
} }
fn sync_stream_state(health: &LiveSyncHealth, stale: bool) -> &'static str {
if health.last_error.is_some() {
"failed"
} else if health.last_success_ms.is_none() {
"never-succeeded"
} else if stale {
"stale"
} else {
"ok"
}
}
fn sync_stream_next_action(
peer_node: &str,
stream: &str,
health: &LiveSyncHealth,
stale: bool,
) -> String {
if health.last_error.is_some() {
format!(
"run `geth sync now {peer_node}`; if it still fails, run `geth auth explain <subject> <resource> <capability>` for stream {stream}"
)
} else if health.last_success_ms.is_none() {
format!(
"run `geth sync now {peer_node}` to establish the first successful sync for {stream}"
)
} else if stale {
format!(
"run `geth sync now {peer_node}` and check `geth peer ping {peer_node}` if the stream remains stale"
)
} else {
"none".to_owned()
}
}
fn should_live_sync_stream( fn should_live_sync_stream(
store: &Store, store: &Store,
peer_node: &str, peer_node: &str,
@ -10203,9 +10292,26 @@ mod tests {
assert_eq!(peers[0].streams.len(), 1); assert_eq!(peers[0].streams.len(), 1);
assert_eq!(peers[0].streams[0].stream, "keychain"); assert_eq!(peers[0].streams[0].stream, "keychain");
assert_eq!(peers[0].streams[0].cursor_ms, 42); assert_eq!(peers[0].streams[0].cursor_ms, 42);
assert_eq!(peers[0].streams[0].state, "ok");
assert!(!peers[0].streams[0].stale);
assert_eq!(peers[0].streams[0].stale_after_ms, LIVE_SYNC_STALE_AFTER_MS);
assert_eq!(peers[0].streams[0].last_imported, 3); assert_eq!(peers[0].streams[0].last_imported, 3);
assert_eq!(peers[0].streams[0].last_rejected, 1); assert_eq!(peers[0].streams[0].last_rejected, 1);
assert!(peers[0].streams[0].last_error.is_none()); assert!(peers[0].streams[0].last_error.is_none());
assert_eq!(peers[0].streams[0].next_action, "none");
}
#[test]
fn operator_errors_include_recovery_commands() {
let peer_error =
operator_error_message(&NodeError::PeerNotFound("node:missing".to_owned()));
assert!(peer_error.contains("geth peer import"));
assert!(peer_error.contains("geth peer list"));
let denied_error =
operator_error_message(&NodeError::Unauthorized("missing grant".to_owned()));
assert!(denied_error.contains("geth auth explain"));
assert!(denied_error.contains("geth auth grant"));
} }
#[test] #[test]

View file

@ -125,6 +125,10 @@ still verified against trusted-admin OpenSSH signatures before import.
Operators can run `geth sync now [node]` to trigger the same best-effort pass Operators can run `geth sync now [node]` to trigger the same best-effort pass
immediately and `geth sync status` to inspect locally recorded last-attempt, immediately and `geth sync status` to inspect locally recorded last-attempt,
last-success, cursor, import/rejection counts, and errors for each peer stream. last-success, cursor, import/rejection counts, and errors for each peer stream.
JSON status also includes `state`, `stale`, `stale_after_ms`, and `next_action`
fields so scripts can fail on unhealthy streams. Common daemon errors include a
`next:` recovery line for missing peer cards, missing grants, missing resources,
unavailable endpoints, and missing DB/KV/document registrations.
## Resource Model ## Resource Model

View file

@ -60,11 +60,11 @@ Implementation order:
- `[x]` Decide and document whether CAS-backed DB snapshots/batches are part - `[x]` Decide and document whether CAS-backed DB snapshots/batches are part
of the prototype. of the prototype.
6. `[ ]` Finish operational first-run polish. 6. `[x]` Finish operational first-run polish.
Acceptance criteria: Acceptance criteria:
- `[ ]` README has a two-machine walkthrough for the main smoke tests. - `[x]` README has a two-machine walkthrough for the main smoke tests.
- `[ ]` CLI recovery errors tell operators the next command to run. - `[x]` CLI recovery errors tell operators the next command to run.
- `[ ]` JSON sync status is script-friendly for stale/failed peer detection. - `[x]` JSON sync status is script-friendly for stale/failed peer detection.
- `[~]` Two-node operator-flow test coverage. - `[~]` Two-node operator-flow test coverage.
Acceptance criteria: Acceptance criteria:
@ -123,15 +123,15 @@ Implementation order:
control path and merges received Automerge documents. control path and merges received Automerge documents.
- `[x]` Resource authorization gates remote document reads and writes. - `[x]` Resource authorization gates remote document reads and writes.
- `[~]` Operational first-run polish. - `[x]` Operational first-run polish.
Acceptance criteria: Acceptance criteria:
- `[ ]` README has a complete two-machine walkthrough for owner init, - `[x]` README has a complete two-machine walkthrough for owner init,
enrollment, grants, sync status, SSH cert request/approval, SSH proxy, KV, enrollment, grants, sync status, SSH cert request/approval, SSH proxy, KV,
CAS/file-root, and DB/document smoke tests. CAS/file-root, and DB/document smoke tests.
- `[ ]` CLI errors for stale peer cards, missing endpoint bindings, missing - `[x]` CLI errors for stale peer cards, missing endpoint bindings, missing
grants, unavailable relays, unavailable service managers, and unsupported grants, unavailable relays, unavailable service managers, and unsupported
platform features tell the operator what command to run next. platform features tell the operator what command to run next.
- `[ ]` `geth sync status --json` is sufficient for scripts to detect stale - `[x]` `geth sync status --json` is sufficient for scripts to detect stale
peers and failed streams. peers and failed streams.
## Phase 0: Bootstrap ## Phase 0: Bootstrap