Phase 8c: cluster-peer-status + cluster-repair support --tailscale-addr
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 11s
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Successful in 11s
Wires the operator CLIs to the Phase 8b connect_lan_first primitive.
Roaming ops (laptop on LTE, coffee-shop wifi) can now pass a
tailnet address alongside the usual --rpc-addr and get the
LAN-first-with-fallback behavior automatically.
New flags on both cluster-peer-status and cluster-repair:
* --tailscale-addr <addr> — optional tailnet RPC socket. When
set, --rpc-addr is tried first with
a short deadline, then this on
failure/timeout.
* --lan-probe-ms <ms> — LAN probe deadline. Default 200
matches the arch doc.
Zero flag → byte-identical to pre-8c behavior (single-addr dial).
Both flags → chosen route printed in the output header so
operators can see whether LAN or tailnet won.
No new tests: this is thin glue over connect_lan_first, which
already has its own unit coverage. Smoke test live on tank
against architect (LAN), and against fake unroutable + real
tailnet exercises both branches.
This commit is contained in:
+63
-9
@@ -171,6 +171,20 @@ enum Cmd {
|
|||||||
/// Peer's RPC socket. Typically gossip_port + 1.
|
/// Peer's RPC socket. Typically gossip_port + 1.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
rpc_addr: SocketAddr,
|
rpc_addr: SocketAddr,
|
||||||
|
/// Phase 8c (2026-07-14): optional Tailscale RPC socket.
|
||||||
|
/// When set, `--rpc-addr` is tried first with a short
|
||||||
|
/// deadline (`--lan-probe-ms`); on failure or timeout we
|
||||||
|
/// fall through to this tailnet address. Roaming ops
|
||||||
|
/// (laptop on LTE, in-flight wifi) get connectivity
|
||||||
|
/// without hand-editing addresses per environment.
|
||||||
|
#[arg(long)]
|
||||||
|
tailscale_addr: Option<SocketAddr>,
|
||||||
|
/// LAN probe deadline in milliseconds. Only used when
|
||||||
|
/// `--tailscale-addr` is set. Default 200ms matches the
|
||||||
|
/// arch doc — long enough for a live LAN handshake, short
|
||||||
|
/// enough that roaming clients don't stall.
|
||||||
|
#[arg(long, default_value_t = 200)]
|
||||||
|
lan_probe_ms: u64,
|
||||||
/// Directory holding this node's mTLS material
|
/// Directory holding this node's mTLS material
|
||||||
/// (`ca.crt` + `node.crt` + `node.key` from `fleet-ca sign`).
|
/// (`ca.crt` + `node.crt` + `node.key` from `fleet-ca sign`).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -247,6 +261,14 @@ enum Cmd {
|
|||||||
/// Peer's RPC socket. Typically gossip_port + 1.
|
/// Peer's RPC socket. Typically gossip_port + 1.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
rpc_addr: SocketAddr,
|
rpc_addr: SocketAddr,
|
||||||
|
/// Phase 8c (2026-07-14): optional Tailscale RPC socket
|
||||||
|
/// for LAN-first-with-fallback routing. See
|
||||||
|
/// `cluster-peer-status` for details.
|
||||||
|
#[arg(long)]
|
||||||
|
tailscale_addr: Option<SocketAddr>,
|
||||||
|
/// LAN probe deadline (ms) when `--tailscale-addr` is set.
|
||||||
|
#[arg(long, default_value_t = 200)]
|
||||||
|
lan_probe_ms: u64,
|
||||||
/// Directory holding this node's mTLS material.
|
/// Directory holding this node's mTLS material.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
tls_dir: PathBuf,
|
tls_dir: PathBuf,
|
||||||
@@ -331,9 +353,11 @@ async fn main() -> Result<()> {
|
|||||||
Cmd::ClusterRepair {
|
Cmd::ClusterRepair {
|
||||||
peer,
|
peer,
|
||||||
rpc_addr,
|
rpc_addr,
|
||||||
|
tailscale_addr,
|
||||||
|
lan_probe_ms,
|
||||||
tls_dir,
|
tls_dir,
|
||||||
dry_run,
|
dry_run,
|
||||||
} => cmd_cluster_repair(&cfg, &peer, rpc_addr, &tls_dir, dry_run).await?,
|
} => cmd_cluster_repair(&cfg, &peer, rpc_addr, tailscale_addr, lan_probe_ms, &tls_dir, dry_run).await?,
|
||||||
Cmd::ClusterRefSweep {
|
Cmd::ClusterRefSweep {
|
||||||
gitea_url,
|
gitea_url,
|
||||||
gitea_token,
|
gitea_token,
|
||||||
@@ -346,8 +370,10 @@ async fn main() -> Result<()> {
|
|||||||
Cmd::ClusterPeerStatus {
|
Cmd::ClusterPeerStatus {
|
||||||
peer,
|
peer,
|
||||||
rpc_addr,
|
rpc_addr,
|
||||||
|
tailscale_addr,
|
||||||
|
lan_probe_ms,
|
||||||
tls_dir,
|
tls_dir,
|
||||||
} => cmd_cluster_peer_status(&peer, rpc_addr, &tls_dir).await?,
|
} => cmd_cluster_peer_status(&peer, rpc_addr, tailscale_addr, lan_probe_ms, &tls_dir).await?,
|
||||||
Cmd::FleetCaInit { .. } | Cmd::FleetCaSign { .. } | Cmd::FleetCaTailscaleSign { .. } => {
|
Cmd::FleetCaInit { .. } | Cmd::FleetCaSign { .. } | Cmd::FleetCaTailscaleSign { .. } => {
|
||||||
// Handled by the config-independent short-circuit above.
|
// Handled by the config-independent short-circuit above.
|
||||||
unreachable!("fleet-ca commands short-circuit before config load");
|
unreachable!("fleet-ca commands short-circuit before config load");
|
||||||
@@ -520,12 +546,14 @@ async fn cmd_cluster_repair(
|
|||||||
cfg: &Config,
|
cfg: &Config,
|
||||||
peer: &str,
|
peer: &str,
|
||||||
rpc_addr: SocketAddr,
|
rpc_addr: SocketAddr,
|
||||||
|
tailscale_addr: Option<SocketAddr>,
|
||||||
|
lan_probe_ms: u64,
|
||||||
tls_dir: &std::path::Path,
|
tls_dir: &std::path::Path,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use cluster::blob::BlobStore;
|
use cluster::blob::BlobStore;
|
||||||
use cluster::rpc::{call_get_chunk, call_has_chunk};
|
use cluster::rpc::{call_get_chunk, call_has_chunk};
|
||||||
use cluster::transport::{NodeIdentity, QuicClient};
|
use cluster::transport::{ConnectRoute, NodeIdentity, QuicClient};
|
||||||
|
|
||||||
let root = cfg
|
let root = cfg
|
||||||
.cluster
|
.cluster
|
||||||
@@ -581,14 +609,24 @@ async fn cmd_cluster_repair(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: connect to the peer.
|
// Phase 2: connect to the peer. Phase 8c: LAN-first with
|
||||||
|
// optional tailnet fallback when the operator supplied one.
|
||||||
let identity = NodeIdentity::from_pem_dir(tls_dir)
|
let identity = NodeIdentity::from_pem_dir(tls_dir)
|
||||||
.with_context(|| format!("loading identity from {}", tls_dir.display()))?;
|
.with_context(|| format!("loading identity from {}", tls_dir.display()))?;
|
||||||
let client = QuicClient::new("0.0.0.0:0".parse()?, identity)?;
|
let client = QuicClient::new("0.0.0.0:0".parse()?, identity)?;
|
||||||
let conn = client
|
let (conn, route) = client
|
||||||
.connect(rpc_addr, peer)
|
.connect_lan_first(
|
||||||
|
peer,
|
||||||
|
Some(rpc_addr),
|
||||||
|
tailscale_addr,
|
||||||
|
std::time::Duration::from_millis(lan_probe_ms),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("connecting to {peer} at {rpc_addr}"))?;
|
.with_context(|| format!("connecting to {peer}"))?;
|
||||||
|
match route {
|
||||||
|
ConnectRoute::Lan(a) => println!("route: LAN ({a})"),
|
||||||
|
ConnectRoute::Tailscale(a) => println!("route: tailnet ({a})"),
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 3: repair. Fetcher probes HasChunk first (cheap) so a
|
// Phase 3: repair. Fetcher probes HasChunk first (cheap) so a
|
||||||
// peer that lacks the chunk is one round-trip, not a full pull
|
// peer that lacks the chunk is one round-trip, not a full pull
|
||||||
@@ -835,15 +873,31 @@ async fn cmd_cluster_ref_sweep(
|
|||||||
async fn cmd_cluster_peer_status(
|
async fn cmd_cluster_peer_status(
|
||||||
peer: &str,
|
peer: &str,
|
||||||
rpc_addr: SocketAddr,
|
rpc_addr: SocketAddr,
|
||||||
|
tailscale_addr: Option<SocketAddr>,
|
||||||
|
lan_probe_ms: u64,
|
||||||
tls_dir: &std::path::Path,
|
tls_dir: &std::path::Path,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use cluster::rpc::call_peer_status;
|
use cluster::rpc::call_peer_status;
|
||||||
use cluster::transport::{NodeIdentity, QuicClient};
|
use cluster::transport::{ConnectRoute, NodeIdentity, QuicClient};
|
||||||
|
|
||||||
let identity = NodeIdentity::from_pem_dir(tls_dir)
|
let identity = NodeIdentity::from_pem_dir(tls_dir)
|
||||||
.with_context(|| format!("loading identity from {}", tls_dir.display()))?;
|
.with_context(|| format!("loading identity from {}", tls_dir.display()))?;
|
||||||
let client = QuicClient::new("0.0.0.0:0".parse()?, identity)?;
|
let client = QuicClient::new("0.0.0.0:0".parse()?, identity)?;
|
||||||
let conn = client.connect(rpc_addr, peer).await?;
|
// Phase 8c: LAN-first with optional tailnet fallback. When the
|
||||||
|
// caller didn't pass --tailscale-addr the behavior is
|
||||||
|
// byte-identical to the pre-8c path (single-addr dial).
|
||||||
|
let (conn, route) = client
|
||||||
|
.connect_lan_first(
|
||||||
|
peer,
|
||||||
|
Some(rpc_addr),
|
||||||
|
tailscale_addr,
|
||||||
|
std::time::Duration::from_millis(lan_probe_ms),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
match route {
|
||||||
|
ConnectRoute::Lan(a) => println!("route: LAN ({a})"),
|
||||||
|
ConnectRoute::Tailscale(a) => println!("route: tailnet ({a})"),
|
||||||
|
}
|
||||||
let status = call_peer_status(&conn).await?;
|
let status = call_peer_status(&conn).await?;
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
Reference in New Issue
Block a user