Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 94 additions & 2 deletions crates/fractal-fabric-adapter/src/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,24 @@ async fn send(socket: &Path, request: &Value) -> Result<Reply> {
})
}

async fn supports_expiry(socket: &Path) -> Result<bool> {
let reply: Value = tokio::time::timeout(CONTROL_TIMEOUT, async {
let mut stream = tokio::net::UnixStream::connect(socket).await?;
stream.write_all(b"{\"type\":\"reachability_status\"}\n").await?;
let mut bytes = Vec::new();
BufReader::new(stream).take(RESPONSE_LIMIT + 1).read_until(b'\n', &mut bytes).await?;
ensure!(bytes.len() as u64 <= RESPONSE_LIMIT && bytes.last() == Some(&b'\n'),
"Fabric version probe returned an incomplete or oversized response");
serde_json::from_slice::<Value>(&bytes).context("invalid Fabric version probe response")
}).await??;
let mut version = reply["version"].as_str().unwrap_or_default().split(['.', '+', '-']);
let parsed = (version.next().and_then(|v| v.parse::<u64>().ok()),
version.next().and_then(|v| v.parse::<u64>().ok()),
version.next().and_then(|v| v.parse::<u64>().ok()));
Ok(reply["type"] == "reachability_status"
&& matches!(parsed, (Some(major), Some(minor), Some(patch)) if (major, minor, patch) >= (0, 9, 4)))
}

async fn snapshot(socket: &Path) -> Result<Snapshot> {
let mut first: Option<Snapshot> = None;
let mut cursor = None;
Expand Down Expand Up @@ -319,8 +337,65 @@ fn desired(

type DesiredPolicy = BTreeMap<String, BTreeSet<String>>;

#[derive(Debug, Deserialize, Serialize)]
struct Rotation {
old_node: String,
next_node: String,
deadline: u64,
state: String,
}

type Expiries = BTreeMap<String, u64>;

fn rotations(
view: &FleetView,
own_key: &str,
desired: &mut DesiredPolicy,
now: u64,
) -> Result<(Expiries, Vec<(String, Rotation)>)> {
let mut expiries = Expiries::new();
let mut receipts = Vec::new();
if !view.members.iter().any(|member| member.member_key == own_key && member.state == "current") {
return Ok((expiries, receipts));
}
for member in view.members.iter().filter(|member| member.member_key != own_key && member.state == "current") {
let entries = member.endpoints.iter().filter(|entry| entry["transport"] == "fabric-rekey").collect::<Vec<_>>();
ensure!(entries.len() <= 1, "member has multiple Fabric rekey transitions");
let Some(entry) = entries.first() else { continue };
let rotation: Rotation = serde_json::from_value((*entry).clone()).context("invalid signed Fabric rekey transition")?;
ensure!(!rotation.old_node.is_empty() && !rotation.next_node.is_empty() && rotation.old_node != rotation.next_node, "invalid Fabric rekey identities");
ensure!(rotation.deadline <= i64::MAX as u64 && rotation.deadline > 0, "invalid Fabric rekey deadline");
ensure!(matches!(rotation.state.as_str(), "prepared" | "committed" | "aborted"), "invalid Fabric rekey state");
let advertised = if rotation.state == "committed" { &rotation.next_node } else { &rotation.old_node };
let services = desired.get(advertised).cloned().context("Fabric rekey does not match signed routing endpoint")?;
desired.remove(&rotation.old_node);
desired.remove(&rotation.next_node);
let live = rotation.deadline > now;
match rotation.state.as_str() {
"prepared" => {
desired.insert(rotation.old_node.clone(), services.clone());
if live {
desired.insert(rotation.next_node.clone(), services);
expiries.insert(rotation.next_node.clone(), rotation.deadline);
}
}
"committed" => {
desired.insert(rotation.next_node.clone(), services.clone());
if live {
desired.insert(rotation.old_node.clone(), services);
expiries.insert(rotation.old_node.clone(), rotation.deadline);
}
}
"aborted" => { desired.insert(rotation.old_node.clone(), services); }
_ => unreachable!(),
}
if live && rotation.state != "aborted" {
receipts.push((member.member_key.clone(), rotation));
}
}
Ok((expiries, receipts))
}

fn plan(snapshot: &Snapshot, owner: &str, desired: &DesiredPolicy, expiries: &Expiries) -> Vec<Value> {
let owned = snapshot
.peers
Expand Down Expand Up @@ -714,7 +789,7 @@ pub async fn reconcile(
.members
.iter()
.any(|member| member.member_key == own_key && member.state == "current");
let desired: DesiredPolicy = desired
let mut desired: DesiredPolicy = desired
.into_iter()
.map(|node| {
let peer_current = view.members.iter().any(|member| {
Expand All @@ -731,7 +806,20 @@ pub async fn reconcile(
(node, services)
})
.collect();
let expiries = Expiries::new();
let (mut expiries, rotations) = rotations(view, own_key, &mut desired, (now_ms() / 1000) as u64)?;
let blocked_reason = if expiries.is_empty() { None } else {
match supports_expiry(socket).await {
Ok(true) => None,
Ok(false) => Some("fabric_without_expiry: upgrade every participating Fabric daemon to v0.9.4 before rekey".to_owned()),
Err(error) => Some(format!("fabric_capability_unknown: {error:#}")),
}
};
if blocked_reason.is_some() {
// Never turn expiring overlap into permanent authorization on an older daemon.
// Keep the old route; ordinary peer additions and revocations still reconcile.
for (_, rotation) in rotations { desired.remove(&rotation.next_node); }
expiries.clear();
Comment on lines +820 to +821

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve committed policy when the capability probe fails

If a committed rotation has already been installed and a subsequent version probe times out or returns a malformed response, this fallback removes the permanent next_node and clears the old node's expiry. plan() then replaces the expiring old record with permanent grants. A transient probe failure can therefore disconnect an already-activated peer and leave its retired identity authorized indefinitely if the adapter stops before another successful reconciliation. Preserve the installed committed policy and its immutable deadline when capability is unknown rather than reverting it to permanent old-node access.

AGENTS.md reference: AGENTS.md:L5-L7

Useful? React with 👍 / 👎.

}
let ended = view
.members
.iter()
Expand All @@ -741,6 +829,10 @@ pub async fn reconcile(
.filter_map(|endpoint| endpoint["node"].as_str().map(str::to_owned))
.collect();
let mut status = apply(state_dir, socket, fleet_id, desired, expiries, ended, false).await?;
if let Some(reason) = blocked_reason.filter(|_| status.state == "applied") {
status.state = "rekey_blocked".into();
status.message = Some(reason);
}
if status.state == "applied"
&& view.members.iter().any(|member| {
member.state == "ended"
Expand Down
186 changes: 186 additions & 0 deletions crates/fractal-fabric-adapter/src/reconcile_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,78 @@ async fn leave_drain_never_replays_full_service_grants() {
);
}

fn rotation_view(state: &str, deadline: u64) -> FleetView {
let mut view = view();
view.members[1].endpoints[0]["node"] = json!(if state == "committed" { "next-node" } else { "peer-node" });
view.members[1].endpoints.push(json!({
"transport":"fabric-rekey", "old_node":"peer-node", "next_node":"next-node",
"deadline":deadline, "state":state,
}));
view
}

fn expiring_peer(node: &str, deadline: u64) -> Value {
let mut value = peer(node, OWNER, &["st3-client-v0", "st3/fleet/test-fleet"]);
value["trust_sources"][0]["expires_at"] = json!(deadline);
for sources in value["services"].as_object_mut().unwrap().values_mut() {
sources[0]["expires_at"] = json!(deadline);
}
value
}

#[tokio::test]
async fn prepared_committed_and_aborted_reconcile_exact_immutable_expiry() {
let deadline = (now_ms() / 1000) as u64 + 300;
let permanent = |node| peer(node, OWNER, &["st3-client-v0", "st3/fleet/test-fleet"]);
let staged = vec![permanent("peer-node"), expiring_peer("next-node", deadline)];
let committed = vec![expiring_peer("peer-node", deadline), permanent("next-node")];
for (state, before, after, expected) in [
("prepared", vec![permanent("peer-node")], staged.clone(), json!([
{"op":"add_peer","node_id":"next-node"},
{"op":"set_grants","node_id":"next-node","grants":["st3-client-v0","st3/fleet/test-fleet"]},
{"op":"set_expiry","node_id":"next-node","expires_at":deadline}
])),
("committed", staged, committed.clone(), json!([
{"op":"remove_peer","node_id":"next-node"},
{"op":"add_peer","node_id":"next-node"},
{"op":"set_grants","node_id":"next-node","grants":["st3-client-v0","st3/fleet/test-fleet"]},
{"op":"set_expiry","node_id":"peer-node","expires_at":deadline}
])),
("aborted", committed, vec![permanent("peer-node")], json!([
{"op":"remove_peer","node_id":"next-node"},
{"op":"remove_peer","node_id":"peer-node"},
{"op":"add_peer","node_id":"peer-node"},
{"op":"set_grants","node_id":"peer-node","grants":["st3-client-v0","st3/fleet/test-fleet"]}
])),
] {
let root = tempfile::tempdir().unwrap();
let mut replies = vec![
Some(list(before, 7)), Some(mutation(vec![], "complete")), Some(list(after, 8)),
];
if state != "aborted" {
replies.insert(0, Some(json!({"type":"reachability_status","version":"0.9.4+19e7f89"})));
}
let (socket, server) = serve(root.path(), replies);
let status = reconcile(root.path(), &socket, FLEET, "own-key", &rotation_view(state, deadline), &BTreeSet::new()).await.unwrap();
assert_eq!(status.state, "applied");
assert_eq!(server.await.unwrap()[1 + usize::from(state != "aborted")]["request"]["operations"], expected);
}
}

#[test]
fn expired_transitions_never_renew_staging_and_never_expire_prepared_old() {
for state in ["prepared", "committed", "aborted"] {
let view = rotation_view(state, 1000);
let mut policy = desired(&view, "own-key", FLEET, &BTreeSet::new()).unwrap()
.into_iter().map(|node| (node, grants(FLEET))).collect();
let (expiries, receipts) = rotations(&view, "own-key", &mut policy, 1000).unwrap();
assert!(expiries.is_empty());
assert!(receipts.is_empty());
assert_eq!(policy.keys().cloned().collect::<Vec<_>>(),
vec![if state == "committed" { "next-node" } else { "peer-node" }.to_owned()]);
}
}

#[tokio::test]
async fn successful_mutation_without_verified_install_is_an_error() {
let root = tempfile::tempdir().unwrap();
Expand All @@ -1004,3 +1076,117 @@ async fn successful_mutation_without_verified_install_is_an_error() {
server.await.unwrap();
}

#[tokio::test]
async fn elapsed_preparation_removes_next_without_renewal_or_old_expiry() {
let root = tempfile::tempdir().unwrap();
let deadline = (now_ms() / 1000) as u64 - 1;
let old = peer("peer-node", OWNER, &["st3-client-v0", "st3/fleet/test-fleet"]);
let (socket, server) = serve(root.path(), vec![
Some(list(vec![old.clone(), expiring_peer("next-node", deadline)], 7)),
Some(mutation(vec![], "complete")),
Some(list(vec![old], 8)),
]);
reconcile(root.path(), &socket, FLEET, "own-key", &rotation_view("prepared", deadline), &BTreeSet::new())
.await.unwrap();
assert_eq!(server.await.unwrap()[1]["request"]["operations"],
json!([{"op":"remove_peer","node_id":"next-node"}]));
}

#[test]
fn phase_changes_and_elapsed_preparation_cannot_replay_staging_transaction() {
let deadline = 1300;
let pending = Pending {
created_unix_ms: now_ms(),
request: json!({"operations":[
{"op":"add_peer","node_id":"next-node"},
{"op":"set_grants","node_id":"next-node","grants":["st3-client-v0","st3/fleet/test-fleet"]},
{"op":"set_expiry","node_id":"next-node","expires_at":deadline}
]}),
};
for (state, now, expected) in [
("prepared", 1000, true),
("prepared", 1300, false),
("committed", 1000, false),
("aborted", 1000, false),
] {
let view = rotation_view(state, deadline);
let mut policy = desired(&view, "own-key", FLEET, &BTreeSet::new()).unwrap()
.into_iter().map(|node| (node, grants(FLEET))).collect();
let (expiries, _) = rotations(&view, "own-key", &mut policy, now).unwrap();
assert_eq!(pending_matches_desired(&pending, &policy, &expiries), expected);
}
}

#[test]
fn own_metadata_and_noncurrent_members_never_authorize_overlap_or_receipts() {
let mut view = rotation_view("prepared", 1300);
let rotation = view.members[1].endpoints.pop().unwrap();
view.members[0].endpoints.push(rotation);
let mut policy = DesiredPolicy::from([("peer-node".into(), grants(FLEET))]);
let (expiries, receipts) = rotations(&view, "own-key", &mut policy, 1000).unwrap();
assert!(expiries.is_empty());
assert!(receipts.is_empty());
let mut view = rotation_view("prepared", 1300);
view.members[1].state = "ended".into();
let (expiries, receipts) = rotations(&view, "own-key", &mut policy, 1000).unwrap();
assert!(expiries.is_empty());
assert!(receipts.is_empty());
}


#[tokio::test]
async fn fabric_without_expiry_blocks_rekey_but_applies_unrelated_changes() {
for capability in ["old", "timeout", "malformed", "truncated", "closed"] {
let root = tempfile::tempdir().unwrap();
let socket = root.path().join("control.sock");
let listener = UnixListener::bind(&socket).unwrap();
let permanent = |node| peer(node, OWNER, &["st3-client-v0", "st3/fleet/test-fleet"]);
let replies = vec![
json!({"type":"reachability_status","version":"0.9.3+old"}),
list(vec![permanent("peer-node"), permanent("gone")], 7),
mutation(vec![], "complete"),
list(vec![permanent("peer-node"), permanent("new-node")], 8),
];
let server = tokio::spawn(async move {
let mut requests = Vec::new();
for mut reply in replies {
let (mut stream, _) = listener.accept().await.unwrap();
let mut line = String::new();
BufReader::new(&mut stream).read_line(&mut line).await.unwrap();
let request: Value = serde_json::from_str(&line).unwrap();
if request["request"]["operations"].as_array().is_some_and(|ops|
ops.iter().any(|op| op["op"] == "set_expiry")) {
reply = refusal("unsupported_operation");
}
let probing = request["type"] == "reachability_status";
requests.push(request);
if probing && capability != "old" {
match capability {
"timeout" => tokio::time::sleep(CONTROL_TIMEOUT + Duration::from_millis(20)).await,
"malformed" => stream.write_all(b"not-json\n").await.unwrap(),
"truncated" => stream.write_all(b"{\"type\":\"reachability_status\"").await.unwrap(),
"closed" => (),
_ => unreachable!(),
}
continue;
}
let mut bytes = serde_json::to_vec(&reply).unwrap();
bytes.push(b'\n');
stream.write_all(&bytes).await.unwrap();
}
requests
});
let mut view = rotation_view("prepared", (now_ms() / 1000) as u64 + 300);
view.members.push(member("new", "new-key", "new-node", "current"));
let status = reconcile(root.path(), &socket, FLEET, "own-key", &view, &BTreeSet::new()).await.unwrap();
assert_eq!(status.state, "rekey_blocked");
assert!(status.message.unwrap().contains(if capability == "old" {
"fabric_without_expiry"
} else { "fabric_capability_unknown" }));
let requests = server.await.unwrap();
let operations = requests[2]["request"]["operations"].as_array().unwrap();
assert!(operations.iter().any(|op| op["op"] == "remove_peer" && op["node_id"] == "gone"));
assert!(operations.iter().any(|op| op["op"] == "add_peer" && op["node_id"] == "new-node"));
assert!(operations.iter().all(|op| op["op"] != "set_expiry" && op["node_id"] != "next-node"));
}
}
1 change: 1 addition & 0 deletions release-notes/fabric-rekey-bounded-overlap.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"version": 1,"summary": "Reconcile signed Fabric identity rotations with bounded overlap; older Fabric blocks rekey without blocking unrelated policy changes","replay": {"status": "none","detail": "No graph replay."},"schema": {"status": "none","detail": "Reuses fleet.member-endpoints claims; Fabric expiry writes change durable runtime state."},"rules": {"status": "none","detail": "No checkpoint rule change."},"compatibility": {"status": "changed","detail": "The control-socket daemon version must support expiry before overlap is installed. Older daemons report rekey_blocked: fabric_without_expiry; failed probes report fabric_capability_unknown with the diagnostic. Ordinary expiry-free policy changes continue in both cases."},"downtime": {"status": "none","detail": "No Fractal downtime required."},"manual": {"status": "changed","detail": "Hard deploy order: upgrade Fabric to v0.9.4 on every participating member before any rekey, then deploy Fractal with R1-R7."},"recovery": {"status": "changed","detail": "After the first expiry write, no in-place Fabric v0.9.3 rollback is safe without an explicit state migration. Elapsed preparation cannot renew staging or replay an obsolete transaction."}}
Loading