Live-sync authorized file roots
This commit is contained in:
parent
1caaedda90
commit
bab13cc0a1
5 changed files with 128 additions and 7 deletions
|
|
@ -141,9 +141,10 @@ Roadmap items should be actionable and checkable:
|
||||||
to the working tree. `geth cas root sync <node-id> <name>` can pull authorized
|
to the working tree. `geth cas root sync <node-id> <name>` can pull authorized
|
||||||
remote tree metadata and CAS tree bytes, recording a peer-qualified remote
|
remote tree metadata and CAS tree bytes, recording a peer-qualified remote
|
||||||
root with path `remote:<node>:<name>` without applying files or overwriting
|
root with path `remote:<node>:<name>` without applying files or overwriting
|
||||||
same-named local roots. Durable local file-conflict records can be listed and
|
same-named local roots. Background live-sync refreshes authorized remote file
|
||||||
resolved manually; automatic cross-node conflict detection and file application
|
roots from sync-status `cas-tree:<name>` watermarks. Durable local
|
||||||
are still roadmap work.
|
file-conflict records can be listed and resolved manually; automatic
|
||||||
|
cross-node conflict detection and file application are still roadmap work.
|
||||||
- DB resources can be registered locally and report local-only status plus a
|
- DB resources can be registered locally and report local-only status plus a
|
||||||
read-only SQLite schema summary/hash and `crsql_changes` metadata when
|
read-only SQLite schema summary/hash and `crsql_changes` metadata when
|
||||||
present. The DB crate and daemon can extract typed read-only `crsql_changes`
|
present. The DB crate and daemon can extract typed read-only `crsql_changes`
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,9 @@ Before probing individual modules, the daemon asks the peer for an authorized
|
||||||
sync-status summary over Iroh. The peer only returns stream watermarks for
|
sync-status summary over Iroh. The peer only returns stream watermarks for
|
||||||
resources where the caller already has the matching capability, letting the
|
resources where the caller already has the matching capability, letting the
|
||||||
local daemon skip unchanged or unauthorized streams.
|
local daemon skip unchanged or unauthorized streams.
|
||||||
|
File roots advertise `cas-tree:<name>` watermarks when the caller has
|
||||||
|
`cas.fetch`; background live-sync imports updated tree metadata and CAS tree
|
||||||
|
bytes into peer-qualified remote roots without writing files.
|
||||||
Named KV stores participate in the same live-sync loop once they exist locally:
|
Named KV stores participate in the same live-sync loop once they exist locally:
|
||||||
manual `geth kv sync <node-id> <name>` and background ticks require `kv.read`
|
manual `geth kv sync <node-id> <name>` and background ticks require `kv.read`
|
||||||
on the remote `resource:kv:<name>` and import only remote entries that are not
|
on the remote `resource:kv:<name>` and import only remote entries that are not
|
||||||
|
|
|
||||||
|
|
@ -917,6 +917,7 @@ async fn cas_root_sync_from_peer(
|
||||||
}
|
}
|
||||||
let store = Store::open(&node.paths.metadata_db())?;
|
let store = Store::open(&node.paths.metadata_db())?;
|
||||||
let mut tree_bytes_imported = false;
|
let mut tree_bytes_imported = false;
|
||||||
|
let mut imported_high_water = None;
|
||||||
let root = root
|
let root = root
|
||||||
.map(|remote_root| {
|
.map(|remote_root| {
|
||||||
let stored_name = remote_file_root_name(&node_id, &remote_root.name);
|
let stored_name = remote_file_root_name(&node_id, &remote_root.name);
|
||||||
|
|
@ -960,9 +961,18 @@ async fn cas_root_sync_from_peer(
|
||||||
updated_at_ms: remote_root.updated_at_ms,
|
updated_at_ms: remote_root.updated_at_ms,
|
||||||
};
|
};
|
||||||
store.upsert_file_root(&stored)?;
|
store.upsert_file_root(&stored)?;
|
||||||
|
imported_high_water = Some(remote_root.updated_at_ms);
|
||||||
Ok(file_root_from_stored(&stored))
|
Ok(file_root_from_stored(&stored))
|
||||||
})
|
})
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
|
if let Some(high_water_ms) = imported_high_water {
|
||||||
|
store_live_sync_cursor(
|
||||||
|
&store,
|
||||||
|
peer_node,
|
||||||
|
&format!("cas-tree:{response_name}"),
|
||||||
|
high_water_ms,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
Ok(ControlResponse::CasRootSynced {
|
Ok(ControlResponse::CasRootSynced {
|
||||||
peer_node_id: node_id,
|
peer_node_id: node_id,
|
||||||
peer_agent_id: agent_id,
|
peer_agent_id: agent_id,
|
||||||
|
|
@ -1844,6 +1854,18 @@ fn sync_watermarks_for_peer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for root in store.list_file_roots()? {
|
||||||
|
if root.path.starts_with("remote:") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let resource = format!("resource:cas-tree:{}", root.name);
|
||||||
|
if can_sync_resource(store, &peer, &resource, "cas.fetch")? {
|
||||||
|
watermarks.push(SyncWatermark {
|
||||||
|
stream: format!("cas-tree:{}", root.name),
|
||||||
|
high_water: root.updated_at_ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
watermarks.sort_by(|left, right| left.stream.cmp(&right.stream));
|
watermarks.sort_by(|left, right| left.stream.cmp(&right.stream));
|
||||||
Ok(watermarks)
|
Ok(watermarks)
|
||||||
}
|
}
|
||||||
|
|
@ -2176,6 +2198,22 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> {
|
||||||
tracing::debug!(peer = %peer.peer_id, %error, "SSH revocation live sync failed");
|
tracing::debug!(peer = %peer.peer_id, %error, "SSH revocation live sync failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(remote_watermarks) = remote_watermarks.as_ref() {
|
||||||
|
for stream in remote_watermarks
|
||||||
|
.keys()
|
||||||
|
.filter(|stream| stream.starts_with("cas-tree:"))
|
||||||
|
{
|
||||||
|
if should_live_sync_stream(&store, &peer.peer_id, stream, Some(remote_watermarks))?
|
||||||
|
{
|
||||||
|
let name = stream.trim_start_matches("cas-tree:");
|
||||||
|
if let Err(error) =
|
||||||
|
cas_root_sync_from_peer(node, &peer.peer_id, name, None).await
|
||||||
|
{
|
||||||
|
tracing::debug!(peer = %peer.peer_id, root = %name, %error, "file-root live sync failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for kv in &kv_stores {
|
for kv in &kv_stores {
|
||||||
let stream = format!("kv:{}", kv.name);
|
let stream = format!("kv:{}", kv.name);
|
||||||
if should_live_sync_stream(&store, &peer.peer_id, &stream, remote_watermarks.as_ref())?
|
if should_live_sync_stream(&store, &peer.peer_id, &stream, remote_watermarks.as_ref())?
|
||||||
|
|
@ -5358,9 +5396,32 @@ mod tests {
|
||||||
path: db_path.display().to_string(),
|
path: db_path.display().to_string(),
|
||||||
})
|
})
|
||||||
.expect("insert db");
|
.expect("insert db");
|
||||||
|
store
|
||||||
|
.upsert_file_root(&StoredFileRoot {
|
||||||
|
root_id: "file-root:shared".to_owned(),
|
||||||
|
resource_id: "resource:cas-tree:shared".to_owned(),
|
||||||
|
name: "shared".to_owned(),
|
||||||
|
path: dir.path().join("shared").display().to_string(),
|
||||||
|
latest_tree_hash: Some("abc".to_owned()),
|
||||||
|
latest_tree_json: None,
|
||||||
|
updated_at_ms: 123,
|
||||||
|
})
|
||||||
|
.expect("insert file root");
|
||||||
|
store
|
||||||
|
.upsert_file_root(&StoredFileRoot {
|
||||||
|
root_id: "file-root:remote:node:right:shared".to_owned(),
|
||||||
|
resource_id: "resource:cas-tree:shared".to_owned(),
|
||||||
|
name: "remote-node-right-shared".to_owned(),
|
||||||
|
path: "remote:node:right:shared".to_owned(),
|
||||||
|
latest_tree_hash: Some("def".to_owned()),
|
||||||
|
latest_tree_json: None,
|
||||||
|
updated_at_ms: 456,
|
||||||
|
})
|
||||||
|
.expect("insert imported remote file root");
|
||||||
|
|
||||||
grant_test_capability(&store, "node:left", "resource:kv:prefs", "kv.read");
|
grant_test_capability(&store, "node:left", "resource:kv:prefs", "kv.read");
|
||||||
grant_test_capability(&store, "node:left", "resource:db:notes", "db.sync");
|
grant_test_capability(&store, "node:left", "resource:db:notes", "db.sync");
|
||||||
|
grant_test_capability(&store, "node:left", "resource:cas-tree:shared", "cas.fetch");
|
||||||
|
|
||||||
let watermarks = sync_watermarks_for_peer(&store, "node:left").expect("watermarks");
|
let watermarks = sync_watermarks_for_peer(&store, "node:left").expect("watermarks");
|
||||||
|
|
||||||
|
|
@ -5372,6 +5433,15 @@ mod tests {
|
||||||
stream: "db:notes".to_owned(),
|
stream: "db:notes".to_owned(),
|
||||||
high_water: 7,
|
high_water: 7,
|
||||||
}));
|
}));
|
||||||
|
assert!(watermarks.contains(&SyncWatermark {
|
||||||
|
stream: "cas-tree:shared".to_owned(),
|
||||||
|
high_water: 123,
|
||||||
|
}));
|
||||||
|
assert!(
|
||||||
|
!watermarks
|
||||||
|
.iter()
|
||||||
|
.any(|watermark| watermark.stream == "cas-tree:remote-node-right-shared")
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!watermarks
|
!watermarks
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -5475,7 +5545,7 @@ mod tests {
|
||||||
&right,
|
&right,
|
||||||
ControlRequest::CasRootAdd {
|
ControlRequest::CasRootAdd {
|
||||||
name: "shared".to_owned(),
|
name: "shared".to_owned(),
|
||||||
path: right_file_root,
|
path: right_file_root.clone(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("right file root add");
|
.expect("right file root add");
|
||||||
|
|
@ -6588,6 +6658,19 @@ mod tests {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.expect("right live document set");
|
.expect("right live document set");
|
||||||
|
std::fs::write(right_file_root.join("live.txt"), b"live file root sync")
|
||||||
|
.expect("write live file");
|
||||||
|
let second_tree_hash = match handle_request(
|
||||||
|
&right,
|
||||||
|
ControlRequest::CasRootScan {
|
||||||
|
name: "shared".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("right live file root scan")
|
||||||
|
{
|
||||||
|
ControlResponse::CasRootScanned { scan } => scan.tree.hash,
|
||||||
|
other => panic!("unexpected right live file root scan response: {other:?}"),
|
||||||
|
};
|
||||||
insert_mock_crsqlite_change(&right_db_path, 8, "live");
|
insert_mock_crsqlite_change(&right_db_path, 8, "live");
|
||||||
|
|
||||||
run_live_sync_once(&left)
|
run_live_sync_once(&left)
|
||||||
|
|
@ -6622,6 +6705,34 @@ mod tests {
|
||||||
.map(|document| document.state_json),
|
.map(|document| document.state_json),
|
||||||
Some(r#"{"title":"live"}"#.to_owned())
|
Some(r#"{"title":"live"}"#.to_owned())
|
||||||
);
|
);
|
||||||
|
let live_remote_root = left_store
|
||||||
|
.list_file_roots()
|
||||||
|
.expect("list live-synced file roots")
|
||||||
|
.into_iter()
|
||||||
|
.find(|root| root.path == format!("remote:{}:shared", right.node_id))
|
||||||
|
.expect("live-synced remote root");
|
||||||
|
assert_eq!(
|
||||||
|
live_remote_root.latest_tree_hash.as_deref(),
|
||||||
|
Some(second_tree_hash.as_str())
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
LocalCas::new(left.paths.cas_dir())
|
||||||
|
.has(&second_tree_hash)
|
||||||
|
.expect("left has live-synced tree object")
|
||||||
|
);
|
||||||
|
let file_root_cursor = left_store
|
||||||
|
.get_module_state(&live_sync_cursor_key(
|
||||||
|
right_card.node_id.as_str(),
|
||||||
|
"cas-tree:shared",
|
||||||
|
))
|
||||||
|
.expect("get live-synced file root cursor")
|
||||||
|
.expect("file root cursor exists");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<LiveSyncCursor>(&file_root_cursor.state_json)
|
||||||
|
.expect("parse file root cursor")
|
||||||
|
.cursor_ms,
|
||||||
|
live_remote_root.updated_at_ms
|
||||||
|
);
|
||||||
let db_cursor = left_store
|
let db_cursor = left_store
|
||||||
.get_module_state(&live_sync_cursor_key(
|
.get_module_state(&live_sync_cursor_key(
|
||||||
right_card.node_id.as_str(),
|
right_card.node_id.as_str(),
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,9 @@ scans are local metadata only and never overwrite the working tree. A peer can
|
||||||
pull authorized remote file-root tree metadata with `geth cas root sync <node>
|
pull authorized remote file-root tree metadata with `geth cas root sync <node>
|
||||||
<name>` when it has `cas.fetch` on `resource:cas-tree:<name>`; sync imports the
|
<name>` when it has `cas.fetch` on `resource:cas-tree:<name>`; sync imports the
|
||||||
remote CAS tree bytes and records a peer-qualified remote root whose path is
|
remote CAS tree bytes and records a peer-qualified remote root whose path is
|
||||||
`remote:<node>:<name>` without applying files. The daemon also has durable local
|
`remote:<node>:<name>` without applying files. File roots also participate in
|
||||||
file-conflict records with
|
the daemon background live-sync loop through authorized `cas-tree:<name>`
|
||||||
|
watermarks. The daemon also has durable local file-conflict records with
|
||||||
explicit resolution choices; future cross-node file sync will create those
|
explicit resolution choices; future cross-node file sync will create those
|
||||||
records automatically instead of silently applying ambiguous remote changes.
|
records automatically instead of silently applying ambiguous remote changes.
|
||||||
|
|
||||||
|
|
@ -235,7 +236,10 @@ authorization testing: non-owner subjects must hold `ssh_cert.*` capabilities on
|
||||||
`resource:ssh:revocations` before requests, approval/import/read operations, or
|
`resource:ssh:revocations` before requests, approval/import/read operations, or
|
||||||
revocation publish/read/import operations
|
revocation publish/read/import operations
|
||||||
are accepted. The live-sync loop first asks for authorized stream watermarks and
|
are accepted. The live-sync loop first asks for authorized stream watermarks and
|
||||||
skips module pulls whose remote high-water value has not advanced.
|
skips module pulls whose remote high-water value has not advanced. File-root
|
||||||
|
live-sync uses only sync-status-advertised `cas-tree:<name>` streams, because
|
||||||
|
the local node otherwise does not know which roots the peer is willing to
|
||||||
|
expose.
|
||||||
|
|
||||||
## Keychain, Auth, And Secrets
|
## Keychain, Auth, And Secrets
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -517,6 +517,8 @@ and future group key evolution.
|
||||||
- `[x]` Sync imports CAS tree bytes and records a peer-qualified remote root
|
- `[x]` Sync imports CAS tree bytes and records a peer-qualified remote root
|
||||||
with path `remote:<node>:<name>` without writing files into the working tree
|
with path `remote:<node>:<name>` without writing files into the working tree
|
||||||
or overwriting same-named local roots.
|
or overwriting same-named local roots.
|
||||||
|
- `[x]` Background live-sync imports updated authorized file-root trees from
|
||||||
|
sync-status `cas-tree:<name>` watermarks and stores per-peer cursors.
|
||||||
- `[ ]` Future completion applies file-root sync safely with conflict
|
- `[ ]` Future completion applies file-root sync safely with conflict
|
||||||
detection and explicit resolution.
|
detection and explicit resolution.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue