Skip to content

Commit 90a7ab3

Browse files
committed
feat: stream tunnel setup progress and fail fast on iroh DNS collision
The setup loop only checked accepted && programmed && !hostnames.is_empty() and slept 2s at a time, so the user saw "Setting up tunnel..." then a 2s silence and then "Tunnel ready" — even when the Connector's IrohDNSPublished=False; DeferredToOwner condition meant the data plane was silently unreachable. Surface the six controller conditions that already exist on the HTTPProxy and Connector (Accepted, CertificatesReady, ConnectorReady, IrohDNSPublished, Programmed, ConnectorMetadataProgrammed) through a typed TunnelProgress, and stream each transition as a checklist line. Bail immediately when IrohDNSPublished comes back False with reason DeferredToOwner — that's the cross-project iroh-key collision case where waiting longer can't help, so we print the operator's message naming the owning Connector and exit non-zero. Also warn on stdout when any step stays pending past 30 seconds, since the controller's reason string is the most useful diagnostic when something genuinely stalls. Polling at 750ms is fine: get_active_progress does two reads (HTTPProxy + Connector) on an already-warm PCP client, and server-side reconcile latency dominates.
1 parent 76987b7 commit 90a7ab3

5 files changed

Lines changed: 473 additions & 19 deletions

File tree

cli/src/main.rs

Lines changed: 107 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use rustls::crypto::ring as rustls_ring;
88

99
use lib::{
1010
Advertisment, AdvertismentTicket, ConnectNode, DiscoveryMode, HeartbeatAgent, ListenNode,
11-
ProxyState, Repo, SelectedContext, TcpProxyData, TunnelService,
11+
ProgressStepKind, ProxyState, Repo, SelectedContext, StepStatus, TcpProxyData, TunnelService,
1212
datum_cloud::{ApiEnv, DatumCloudClient, LoginState},
1313
};
1414
use n0_error::StdResultExt;
@@ -657,21 +657,10 @@ async fn main() -> n0_error::Result<()> {
657657
println!();
658658
println!("Your endpoint ID: {}", endpoint_id);
659659
println!("Setting up tunnel...");
660-
let setup_start = std::time::Instant::now();
661-
662-
let tunnel = loop {
663-
let t = service.get_active(&tunnel_id).await?;
664-
let Some(t) = t else {
665-
n0_error::bail_any!("Tunnel {} not found", tunnel_id);
666-
};
667-
if t.accepted && t.programmed && !t.hostnames.is_empty() {
668-
break t;
669-
}
670-
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
671-
};
660+
let progress = await_tunnel_progress(&service, &tunnel_id).await?;
672661

673-
let elapsed = setup_start.elapsed().as_secs();
674-
for hostname in &tunnel.hostnames {
662+
let elapsed = progress.elapsed.as_secs();
663+
for hostname in &progress.hostnames {
675664
println!("Tunnel ready after {} sec: https://{}", elapsed, hostname);
676665
}
677666
println!("Press Ctrl+C to stop...");
@@ -834,3 +823,106 @@ fn resolve_project_context(
834823
}
835824
None
836825
}
826+
827+
/// Result of streaming the tunnel-setup progress to stdout. All conditions
828+
/// reached `Ready` (or we bailed before that for a terminal failure).
829+
struct SetupResult {
830+
elapsed: std::time::Duration,
831+
hostnames: Vec<String>,
832+
}
833+
834+
/// Stuck threshold: a step that stays pending this long without progressing
835+
/// gets called out with a hint. Picked to cover normal slow paths (TLS cert
836+
/// issuance, edge programming) while still flagging genuine wedges.
837+
const PROGRESS_STUCK_WARN: std::time::Duration = std::time::Duration::from_secs(30);
838+
839+
/// Poll cadence. Fast enough that step transitions feel responsive;
840+
/// the actual server-side reconcile latency dominates wall-clock anyway.
841+
const PROGRESS_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(750);
842+
843+
/// Drive a tunnel through its setup conditions, printing a checklist as
844+
/// each one transitions to Ready. Bails fast on terminal failure
845+
/// (e.g. iroh DNS deferred to another project's Connector — waiting won't
846+
/// help, and the operator's message already names the conflict).
847+
async fn await_tunnel_progress(
848+
service: &TunnelService,
849+
tunnel_id: &str,
850+
) -> n0_error::Result<SetupResult> {
851+
use std::collections::HashMap;
852+
853+
let start = std::time::Instant::now();
854+
let mut last_status: HashMap<ProgressStepKind, StepStatus> = HashMap::new();
855+
let mut pending_since: HashMap<ProgressStepKind, std::time::Instant> = HashMap::new();
856+
let mut warned_stuck: std::collections::HashSet<ProgressStepKind> = Default::default();
857+
858+
loop {
859+
let Some(progress) = service.get_active_progress(tunnel_id).await? else {
860+
n0_error::bail_any!("Tunnel {} not found", tunnel_id);
861+
};
862+
863+
for step in &progress.steps {
864+
let prev = last_status.get(&step.kind).copied();
865+
if prev != Some(step.status) {
866+
match step.status {
867+
StepStatus::Ready => {
868+
println!(
869+
" ✓ {} ({:.1}s)",
870+
step.kind.label(),
871+
start.elapsed().as_secs_f32()
872+
);
873+
pending_since.remove(&step.kind);
874+
}
875+
StepStatus::Pending => {
876+
pending_since.entry(step.kind).or_insert_with(std::time::Instant::now);
877+
}
878+
StepStatus::Unknown => {}
879+
}
880+
last_status.insert(step.kind, step.status);
881+
}
882+
883+
// Generic stuck warning: if a step has been Pending past the
884+
// threshold and we haven't already warned, surface the
885+
// controller's reason/message so the user knows what's stalled.
886+
if step.status == StepStatus::Pending
887+
&& !warned_stuck.contains(&step.kind)
888+
&& let Some(since) = pending_since.get(&step.kind)
889+
&& since.elapsed() >= PROGRESS_STUCK_WARN
890+
{
891+
warned_stuck.insert(step.kind);
892+
let detail = step
893+
.message
894+
.as_deref()
895+
.or(step.reason.as_deref())
896+
.unwrap_or("no detail from controller");
897+
eprintln!(
898+
" … {} still pending after {}s: {}",
899+
step.kind.label(),
900+
since.elapsed().as_secs(),
901+
detail,
902+
);
903+
}
904+
}
905+
906+
if let Some(fail) = progress.terminal_failure() {
907+
let owner = fail.message.as_deref().unwrap_or("unknown owner");
908+
n0_error::bail_any!(
909+
"✗ {}: iroh DNS record is owned by another Connector ({}). \
910+
Another project on this machine registered the same iroh identity. \
911+
With per-project keys, delete the per-project listen_key under this project's \
912+
directory in your Datum repo (or the offending Connector in the other project) \
913+
and rerun.",
914+
fail.kind.label(),
915+
owner,
916+
);
917+
}
918+
919+
if progress.all_ready() && !progress.hostnames.is_empty() {
920+
return Ok(SetupResult {
921+
elapsed: start.elapsed(),
922+
hostnames: progress.hostnames,
923+
});
924+
}
925+
926+
tokio::time::sleep(PROGRESS_POLL_INTERVAL).await;
927+
}
928+
}

lib/src/datum_apis/connector.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,11 @@ pub struct ConnectorStatus {
102102
pub connection_details: Option<ConnectorConnectionDetails>,
103103
pub lease_ref: Option<v1::LocalObjectReference>,
104104
}
105+
106+
pub const CONNECTOR_CONDITION_READY: &str = "Ready";
107+
pub const CONNECTOR_CONDITION_IROH_DNS_PUBLISHED: &str = "IrohDNSPublished";
108+
/// The iroh DNS record is already owned by another Connector with the same
109+
/// public key — typically a Connector in a different project. The losing
110+
/// Connector cannot publish DNS and its tunnel data plane is silently
111+
/// unreachable. See network-services-operator iroh_dns_controller.go.
112+
pub const CONNECTOR_REASON_DEFERRED_TO_OWNER: &str = "DeferredToOwner";

lib/src/datum_apis/http_proxy.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ pub const HTTP_PROXY_CONDITION_ACCEPTED: &str = "Accepted";
6363
pub const HTTP_PROXY_CONDITION_PROGRAMMED: &str = "Programmed";
6464
pub const HTTP_PROXY_CONDITION_HOSTNAMES_VERIFIED: &str = "HostnamesVerified";
6565
pub const HTTP_PROXY_CONDITION_HOSTNAMES_IN_USE: &str = "HostnamesInUse";
66+
pub const HTTP_PROXY_CONDITION_CERTIFICATES_READY: &str = "CertificatesReady";
67+
pub const HTTP_PROXY_CONDITION_CONNECTOR_METADATA_PROGRAMMED: &str = "ConnectorMetadataProgrammed";
6668

6769
pub const HTTP_PROXY_REASON_ACCEPTED: &str = "Accepted";
6870
pub const HTTP_PROXY_REASON_PROGRAMMED: &str = "Programmed";

lib/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ pub use node::*;
1919
pub use project_control_plane::ProjectControlPlaneClient;
2020
pub use repo::Repo;
2121
pub use state::*;
22-
pub use tunnels::{TunnelDeleteOutcome, TunnelService, TunnelSummary};
22+
pub use tunnels::{
23+
ProgressStep, ProgressStepKind, StepStatus, TunnelDeleteOutcome, TunnelProgress, TunnelService,
24+
TunnelSummary,
25+
};
2326
pub use update::{UpdateChannel, UpdateChecker, UpdateInfo, UpdateSettings};
2427

2528
/// The root domain for datum connect urls to subdomain from. A proxy URL will

0 commit comments

Comments
 (0)