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
[node]` triggers the same sync pass immediately, and `geth sync status` reports
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
cursors, while receivers still verify every imported signed operation before it
can affect the reduced keychain or authorization views.
error per peer stream. `geth sync status --json` also includes per-stream
`state`, `stale`, `stale_after_ms`, and `next_action` fields so smoke tests can
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

View file

@ -1923,8 +1923,10 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
}
for stream in peer.streams {
println!(
" {}\tcursor={}\tlast_attempt={}\tlast_success={}\timported={}\trejected={}\terror={}",
" {}\tstate={}\tstale={}\tcursor={}\tlast_attempt={}\tlast_success={}\timported={}\trejected={}\terror={}\tnext={}",
stream.stream,
stream.state,
stream.stale,
stream.cursor_ms,
stream
.last_attempt_ms
@ -1936,7 +1938,8 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
.unwrap_or_else(|| "never".to_owned()),
stream.last_imported,
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 stream: String,
pub cursor_ms: i64,
pub state: String,
pub stale: bool,
pub stale_after_ms: i64,
pub last_attempt_ms: Option<i64>,
pub last_success_ms: Option<i64>,
pub last_error: Option<String>,
pub last_imported: usize,
pub last_rejected: usize,
pub next_action: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -1614,11 +1618,15 @@ mod tests {
streams: vec![SyncStreamStatus {
stream: "keychain".to_owned(),
cursor_ms: 42,
state: "ok".to_owned(),
stale: false,
stale_after_ms: 120_000,
last_attempt_ms: Some(43),
last_success_ms: Some(43),
last_error: None,
last_imported: 2,
last_rejected: 0,
next_action: "none".to_owned(),
}],
}],
note: "local health".to_owned(),

View file

@ -181,6 +181,7 @@ struct PipeRuntime {
const PUBSUB_RING_LIMIT: usize = 256;
const PIPE_CONNECTION_RING_LIMIT: usize = 256;
const PIPE_MESSAGE_RING_LIMIT: usize = 1024;
const LIVE_SYNC_STALE_AFTER_MS: i64 = 120_000;
#[derive(Debug, serde::Deserialize, serde::Serialize)]
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 {
Ok(response) => response,
Err(error) => ControlResponse::Error {
message: error.to_string(),
message: operator_error_message(&error),
},
};
let mut stream = reader.into_inner();
@ -797,6 +798,48 @@ async fn handle_stream(node: LocalNode, stream: UnixStream) -> Result<(), NodeEr
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> {
if !node.iroh_status.local_discovery || !node.iroh_status.enabled {
return None;
@ -3124,6 +3167,7 @@ fn sync_status_local(store: &Store) -> Result<ControlResponse, NodeError> {
.into_iter()
.map(|peer| {
let prefix = live_sync_status_prefix(&peer.peer_id);
let now_ms = geth_store::now_ms();
let mut streams = store
.list_module_states_with_prefix(&prefix)?
.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 health: LiveSyncHealth = serde_json::from_str(&state.state_json)?;
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 {
stream,
cursor_ms,
state: status_state,
stale,
stale_after_ms: LIVE_SYNC_STALE_AFTER_MS,
last_attempt_ms: Some(health.last_attempt_ms),
last_success_ms: health.last_success_ms,
last_error: health.last_error,
last_imported: health.last_imported,
last_rejected: health.last_rejected,
next_action,
})
})
.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(
store: &Store,
peer_node: &str,
@ -10203,9 +10292,26 @@ mod tests {
assert_eq!(peers[0].streams.len(), 1);
assert_eq!(peers[0].streams[0].stream, "keychain");
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_rejected, 1);
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]

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
immediately and `geth sync status` to inspect locally recorded last-attempt,
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

View file

@ -60,11 +60,11 @@ Implementation order:
- `[x]` Decide and document whether CAS-backed DB snapshots/batches are part
of the prototype.
6. `[ ]` Finish operational first-run polish.
6. `[x]` Finish operational first-run polish.
Acceptance criteria:
- `[ ]` README has a two-machine walkthrough for the main smoke tests.
- `[ ]` CLI recovery errors tell operators the next command to run.
- `[ ]` JSON sync status is script-friendly for stale/failed peer detection.
- `[x]` README has a two-machine walkthrough for the main smoke tests.
- `[x]` CLI recovery errors tell operators the next command to run.
- `[x]` JSON sync status is script-friendly for stale/failed peer detection.
- `[~]` Two-node operator-flow test coverage.
Acceptance criteria:
@ -123,15 +123,15 @@ Implementation order:
control path and merges received Automerge documents.
- `[x]` Resource authorization gates remote document reads and writes.
- `[~]` Operational first-run polish.
- `[x]` Operational first-run polish.
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,
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
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.
## Phase 0: Bootstrap