Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .st/rust-test-inputs.json
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,9 @@
"extra": []
},
"smallclaims": {
"extra": []
"extra": [
"crates/fractal"
]
}
},
"no_tests": [
Expand Down
6 changes: 4 additions & 2 deletions crates/fractal-fabric-adapter/src/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions crates/fractal-fabric-adapter/src/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
20 changes: 20 additions & 0 deletions crates/fractal-fabric-adapter/src/reconcile_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
23 changes: 23 additions & 0 deletions crates/smallclaims/src/fleet/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 8 additions & 2 deletions crates/smallclaims/src/fleet/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -759,7 +759,8 @@ async fn join_inner(options: &JoinOptions, adapter: Option<&Path>) -> Result<Joi
&& sealed.sponsor_endpoints.iter().any(|endpoint|
endpoint["transport"] == "fabric"
&& endpoint["node"] == pairing.gateway
&& endpoint["protocol"] == format!("st3/fleet/{}", code.fleet_id)),
&& endpoint["protocol"].as_str().is_some_and(|protocol|
super::transport::is_fleet_fabric_protocol(protocol, &sealed.fleet_id))),
"the authenticated sponsor answer does not bind the pairing gateway"
);
let saved = PairingRecovery {
Expand Down Expand Up @@ -1068,6 +1069,11 @@ mod tests {

#[tokio::test]
async fn pairing_join_writes_configuration_only_after_helper_confirmation() {
pairing_join_with_protocol_prefix("st3/fleet/").await;
pairing_join_with_protocol_prefix("fractal/fleet/").await;
}

async fn pairing_join_with_protocol_prefix(prefix: &str) {
let root = tempfile::tempdir().unwrap();
let sponsor_dir = root.path().join("sponsor");
let settings = MemberSettings {
Expand All @@ -1084,7 +1090,7 @@ mod tests {
let node_id = "b".repeat(64);
let endpoints = vec![serde_json::json!({
"transport": "fabric", "node": gateway,
"protocol": format!("st3/fleet/{}", founded.fleet_id)
"protocol": format!("{prefix}{}", founded.fleet_id)
})];
store.publish_fleet_endpoints("listening", &endpoints, "test").unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
Expand Down
59 changes: 58 additions & 1 deletion crates/smallclaims/src/fleet/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,19 @@ pub async fn tailscale_addresses(tailscale: &Path) -> Result<Vec<IpAddr>> {
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.
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions docs/st3/replication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<fleet>` and `fractal/fleet/<fleet>` for the same fleet; default writers still use `st3/fleet/<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
Expand Down
11 changes: 11 additions & 0 deletions release-notes/fabric-fleet-accepting-readers.json
Original file line number Diff line number Diff line change
@@ -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/<fleet> and fractal/fleet/<fleet> for the same fleet. Default writers and adapter grants still use st3/fleet/<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."}
}
Loading