Polish first-run operator workflow
This commit is contained in:
parent
59c86aba6a
commit
e6879e8949
6 changed files with 208 additions and 14 deletions
79
README.md
79
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue