diff --git a/.st/rust-test-inputs.json b/.st/rust-test-inputs.json index 251ee913748..329ebdaa5b5 100644 --- a/.st/rust-test-inputs.json +++ b/.st/rust-test-inputs.json @@ -397,7 +397,9 @@ "extra": [] }, "smallclaims": { - "extra": [] + "extra": [ + "crates/fractal" + ] } }, "no_tests": [ diff --git a/crates/fractal-fabric-adapter/src/pairing.rs b/crates/fractal-fabric-adapter/src/pairing.rs index 2628ae105a9..6981543d03e 100644 --- a/crates/fractal-fabric-adapter/src/pairing.rs +++ b/crates/fractal-fabric-adapter/src/pairing.rs @@ -260,7 +260,8 @@ pub async fn invite(client: &reqwest::Client, socket: &Path, state: &Path) -> Re .iter() .any(|endpoint| endpoint["transport"] == "fabric" && endpoint["node"] == gateway - && endpoint["protocol"] == format!("st3/fleet/{fleet}")), + && endpoint["protocol"].as_str().is_some_and(|protocol| + smallclaims::fleet::transport::is_fleet_fabric_protocol(protocol, &fleet))), "sponsor lacks a unique current signed endpoint for the Fabric gateway" ); let deadline = code.expires_at.min((super::now_ms() / 1000) as u64 + 600); @@ -725,7 +726,8 @@ pub async fn join(socket: &Path, state: &Path) -> Result<()> { .iter() .any(|endpoint| endpoint["transport"] == "fabric" && endpoint["node"] == pairing.gateway - && endpoint["protocol"] == format!("st3/fleet/{}", sealed.fleet_id)), + && endpoint["protocol"].as_str().is_some_and(|protocol| + smallclaims::fleet::transport::is_fleet_fabric_protocol(protocol, &sealed.fleet_id))), "authenticated sponsor lacks signed exact Fabric endpoint" ); if let Some(resume) = &start.resume { diff --git a/crates/fractal-fabric-adapter/src/reconcile.rs b/crates/fractal-fabric-adapter/src/reconcile.rs index e63761922e2..8ffa9634491 100644 --- a/crates/fractal-fabric-adapter/src/reconcile.rs +++ b/crates/fractal-fabric-adapter/src/reconcile.rs @@ -255,8 +255,9 @@ fn desired( } // Do not turn arbitrary signed endpoint names into Fabric grants. ensure!( - endpoint["protocol"] == format!("st3/fleet/{fleet_id}"), - "member {} advertises a nonstandard Fabric replication protocol; expected st3/fleet/{fleet_id}", + endpoint["protocol"].as_str().is_some_and(|protocol| + smallclaims::fleet::transport::is_fleet_fabric_protocol(protocol, fleet_id)), + "member {} advertises a nonstandard Fabric replication protocol; expected st3/fleet/{fleet_id} or fractal/fleet/{fleet_id}", member.name ); let node = endpoint["node"] diff --git a/crates/fractal-fabric-adapter/src/reconcile_tests.rs b/crates/fractal-fabric-adapter/src/reconcile_tests.rs index 4154801e861..f62969fd746 100644 --- a/crates/fractal-fabric-adapter/src/reconcile_tests.rs +++ b/crates/fractal-fabric-adapter/src/reconcile_tests.rs @@ -108,6 +108,26 @@ fn signed_current_members_only_and_local_retirement_withdraws_everything() { assert!(desired(&view, "own-key", FLEET, &BTreeSet::new()).is_err()); } +#[test] +fn endpoint_protocol_accepts_both_names_only_for_this_fleet() { + for (protocol, accepted) in [ + ("st3/fleet/test-fleet", true), + ("fractal/fleet/test-fleet", true), + ("st3/fleet/other-fleet", false), + ("fractal/fleet/other-fleet", false), + ("other/fleet/test-fleet", false), + ("fractal/pty/test-fleet", false), + ] { + let mut view = view(); + view.members[1].endpoints[0]["protocol"] = json!(protocol); + let result = desired(&view, "own-key", FLEET, &BTreeSet::new()); + assert_eq!(result.is_ok(), accepted, "{protocol}"); + if accepted { + assert_eq!(result.unwrap(), BTreeSet::from(["peer-node".into()])); + } + } +} + #[test] fn endpoint_protocol_cannot_inject_non_st_grants() { let mut view = view(); diff --git a/crates/smallclaims/src/fleet/file.rs b/crates/smallclaims/src/fleet/file.rs index 6bc863c08e5..59981159c15 100644 --- a/crates/smallclaims/src/fleet/file.rs +++ b/crates/smallclaims/src/fleet/file.rs @@ -298,6 +298,29 @@ impl FleetFile { mod tests { use super::*; + #[test] + fn persisted_fleet_protocol_round_trip_preserves_either_name() { + let state = tempfile::tempdir().unwrap(); + let fleet_id = "5b0c1d8e-6a44-4f0e-9d51-2f7f3c9a0b12"; + for prefix in [ + super::super::transport::LEGACY_FLEET_PROTOCOL_PREFIX, + super::super::transport::FRACTAL_FLEET_PROTOCOL_PREFIX, + ] { + let protocol = format!("{prefix}{fleet_id}"); + let file: FleetFile = toml::from_str(&format!( + "fleet_id = '{fleet_id}'\nfabric_protocol = '{protocol}'" + )).unwrap(); + file.save(state.path()).unwrap(); + let loaded = FleetFile::load(state.path()).unwrap().unwrap(); + assert_eq!(loaded.fabric_protocol.as_deref(), Some(protocol.as_str())); + loaded.save(state.path()).unwrap(); + assert_eq!( + FleetFile::load(state.path()).unwrap().unwrap().fabric_protocol, + Some(protocol), + ); + } + } + #[test] fn omitted_transport_list_defaults_to_tailscale_then_fabric() { let file: FleetFile = toml::from_str("").unwrap(); diff --git a/crates/smallclaims/src/fleet/join.rs b/crates/smallclaims/src/fleet/join.rs index 744d64d16e4..e34aa7e648b 100644 --- a/crates/smallclaims/src/fleet/join.rs +++ b/crates/smallclaims/src/fleet/join.rs @@ -759,7 +759,8 @@ async fn join_inner(options: &JoinOptions, adapter: Option<&Path>) -> Result Result> { Ok(parse_tailscale_ips(&run(tailscale, &["ip"]).await?)) } +pub const LEGACY_FLEET_PROTOCOL_PREFIX: &str = "st3/fleet/"; +pub const FRACTAL_FLEET_PROTOCOL_PREFIX: &str = "fractal/fleet/"; + +/// Accept only either fleet protocol name bound to this exact fleet. +pub fn is_fleet_fabric_protocol(protocol: &str, fleet_id: &str) -> bool { + [LEGACY_FLEET_PROTOCOL_PREFIX, FRACTAL_FLEET_PROTOCOL_PREFIX] + .into_iter() + .any(|prefix| protocol.strip_prefix(prefix) == Some(fleet_id)) +} + /// The default Fabric protocol for a fleet, so a throwaway fleet never collides with another. pub fn default_fabric_protocol(fleet_id: &str) -> String { - format!("st3/fleet/{fleet_id}") + format!("{LEGACY_FLEET_PROTOCOL_PREFIX}{fleet_id}") } /// The `fabric` command, which talks to the local Fabric daemon. @@ -702,6 +712,53 @@ mod tests { use serde_json::json; use std::os::unix::fs::PermissionsExt as _; + #[test] + fn fleet_protocol_names_are_bound_to_the_exact_fleet() { + for prefix in [LEGACY_FLEET_PROTOCOL_PREFIX, FRACTAL_FLEET_PROTOCOL_PREFIX] { + assert!(is_fleet_fabric_protocol( + &format!("{prefix}fleet-a"), + "fleet-a" + )); + assert!(!is_fleet_fabric_protocol( + &format!("{prefix}fleet-b"), + "fleet-a" + )); + assert!(!is_fleet_fabric_protocol( + &format!("{prefix}fleet-a/extra"), + "fleet-a" + )); + } + for protocol in ["other/fleet/fleet-a", "fractal/pty/fleet-a", "st3/fleet-a"] { + assert!(!is_fleet_fabric_protocol(protocol, "fleet-a")); + } + assert_eq!(default_fabric_protocol("fleet-a"), "st3/fleet/fleet-a"); + } + + #[test] + fn advertised_fleet_protocol_names_are_read_without_rewriting_routes() { + for prefix in [LEGACY_FLEET_PROTOCOL_PREFIX, FRACTAL_FLEET_PROTOCOL_PREFIX] { + let protocol = format!("{prefix}fleet-a"); + let route = Route::Fabric { + node: "node-a".into(), + protocol: protocol.clone(), + }; + assert_eq!( + parse_route(&format!("fabric://node-a/{protocol}")), + Some(route.clone()) + ); + assert_eq!( + routes_from_endpoints( + &[json!({"transport": "fabric", "node": "node-a", "protocol": protocol})], + LocalTransports { + fabric: true, + ..LocalTransports::default() + }, + ), + vec![route], + ); + } + } + #[test] fn carrier_comes_from_the_dial_route_including_overrides() { use crate::replication::ReplicationCarrier; diff --git a/docs/st3/replication.md b/docs/st3/replication.md index 80ed4c5c01e..37673885a51 100644 --- a/docs/st3/replication.md +++ b/docs/st3/replication.md @@ -26,6 +26,8 @@ routes for backward compatibility. To let fleet preference select the carrier, remove the explicit override. Legacy operation without `fleet.toml` retains Tailscale-first automatic routing among locally available carriers. +Fabric fleet readers accept `st3/fleet/` and `fractal/fleet/` for the same fleet; default writers still use `st3/fleet/`. + Use `st fleet transports add fabric --prefer` to enable Fabric and move it to the front, or to prefer an already enabled Fabric route without changing its saved protocol or exposure. An add without `--prefer` appends a new carrier and leaves diff --git a/release-notes/fabric-fleet-accepting-readers.json b/release-notes/fabric-fleet-accepting-readers.json new file mode 100644 index 00000000000..f107495989d --- /dev/null +++ b/release-notes/fabric-fleet-accepting-readers.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Accept both Fabric fleet protocol names at fleet identity checks", + "replay": {"status": "none", "detail": "No claim history or projection changes."}, + "schema": {"status": "none", "detail": "Fleet settings and handshake shapes are unchanged."}, + "rules": {"status": "none", "detail": "Fleet, sponsor, gateway and member identity checks remain required."}, + "compatibility": {"status": "changed", "detail": "Fleet protocol readers accept st3/fleet/ and fractal/fleet/ for the same fleet. Default writers and adapter grants still use st3/fleet/; exposure and dial behavior are unchanged. Saved protocol values retain their original spelling."}, + "downtime": {"status": "none", "detail": "No data migration is required."}, + "manual": {"status": "none", "detail": "No configuration edits are required."}, + "recovery": {"status": "none", "detail": "This change does not rewrite persisted protocol names or signed endpoint history."} +}