Skip to main content

xbp_deploy/
expose.rs

1//! Post-deploy exposure: kubectl port-forward to localhost + optional hosts-file DNS.
2
3use crate::bootstrap::workload_name;
4use crate::types::{ServiceExposeView, ServicePlan};
5use std::fs;
6use std::io::{Read, Write};
7use std::net::{SocketAddr, TcpStream};
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use std::sync::Mutex;
11use std::time::{Duration, Instant};
12
13/// Active port-forward child processes for this process (best-effort).
14static PORT_FORWARD_CHILDREN: Mutex<Vec<u32>> = Mutex::new(Vec::new());
15
16#[derive(Debug, Clone)]
17pub struct ExposeResult {
18    pub lines: Vec<String>,
19    pub local_url: Option<String>,
20    pub port_forward_pid: Option<u32>,
21}
22
23/// Whether expose should run for this service after rollout.
24pub fn should_expose(svc: &ServicePlan, skip_expose: bool) -> bool {
25    if skip_expose {
26        return false;
27    }
28    effective_expose(svc, false).is_some()
29}
30
31/// Resolve expose config: explicit `deploy.envs.*.expose`, or automatic local
32/// defaults when deploying with `--local-image` (docker-desktop / kind).
33pub fn effective_expose(svc: &ServicePlan, auto_local: bool) -> Option<ServiceExposeView> {
34    if let Some(ex) = svc.expose.clone() {
35        return Some(ex);
36    }
37    if !auto_local {
38        return None;
39    }
40    // Only auto for k8s bootstrap workloads with a known container port.
41    if svc.deploy.workload.is_none() {
42        return None;
43    }
44    let port = svc.container_port.unwrap_or(8080);
45    Some(ServiceExposeView {
46        service_type: Some("ClusterIP".into()),
47        node_port: None,
48        host_port: None,
49        local_port: Some(port),
50        port_forward: Some(true),
51        dns_hosts: Vec::new(),
52        dns_mode: None,
53    })
54}
55
56fn wants_port_forward(ex: &ServiceExposeView) -> bool {
57    if let Some(pf) = ex.port_forward {
58        return pf;
59    }
60    // Default on when local_port is set or dns_hosts need localhost.
61    ex.local_port.is_some() || !ex.dns_hosts.is_empty()
62}
63
64/// After k8s rollout: optional port-forward + hosts DNS entries.
65pub fn expose_service(
66    svc: &ServicePlan,
67    kube_context: Option<&str>,
68    dry_run: bool,
69) -> ExposeResult {
70    expose_service_with_auto(svc, kube_context, dry_run, false)
71}
72
73/// Like [`expose_service`], but when `auto_local` is true and no expose config
74/// is set, synthesize a localhost port-forward (used with `--local-image`).
75pub fn expose_service_with_auto(
76    svc: &ServicePlan,
77    kube_context: Option<&str>,
78    dry_run: bool,
79    auto_local: bool,
80) -> ExposeResult {
81    let mut lines = Vec::new();
82    let Some(ex) = effective_expose(svc, auto_local) else {
83        return ExposeResult {
84            lines,
85            local_url: None,
86            port_forward_pid: None,
87        };
88    };
89    if svc.expose.is_none() && auto_local {
90        lines.push(format!(
91            "[{}] auto-expose (local-image): port-forward localhost:{} → container",
92            svc.name,
93            ex.local_port.unwrap_or(svc.container_port.unwrap_or(8080))
94        ));
95    }
96    let ex = &ex;
97
98    let container_port = svc.container_port.unwrap_or(8080);
99    let local_port = ex.local_port.unwrap_or(container_port);
100    let ns = svc
101        .deploy
102        .namespace
103        .as_deref()
104        .map(str::trim)
105        .filter(|s| !s.is_empty())
106        .unwrap_or("default");
107    let resource = service_resource_name(svc);
108
109    if let Some(st) = ex.service_type.as_deref() {
110        lines.push(format!(
111            "[{}] expose service_type={} service={} local_port={} container_port={}",
112            svc.name, st, resource, local_port, container_port
113        ));
114        if st.eq_ignore_ascii_case("NodePort") {
115            if let Some(np) = ex.node_port {
116                lines.push(format!(
117                    "[{}] NodePort fixed at {np} (docker-desktop: http://localhost:{np})",
118                    svc.name
119                ));
120            } else {
121                lines.push(format!(
122                    "[{}] NodePort auto-assigned — `kubectl get svc -n {ns} {resource}`",
123                    svc.name
124                ));
125            }
126        }
127        if let Some(hp) = ex.host_port {
128            lines.push(format!(
129                "[{}] hostPort={hp} on pod (docker-desktop: http://localhost:{hp})",
130                svc.name
131            ));
132        }
133    }
134
135    let mut pid = None;
136    let mut local_url = None;
137
138    if wants_port_forward(ex) {
139        if dry_run {
140            lines.push(format!(
141                "[{}] dry-run: would port-forward svc/{resource} {local_port}:{container_port} -n {ns}",
142                svc.name
143            ));
144            local_url = Some(format!("http://127.0.0.1:{local_port}"));
145        } else if port_is_listening(local_port) {
146            // Something already bound — treat as success if TCP accepts.
147            local_url = Some(format!("http://127.0.0.1:{local_port}"));
148            lines.push(format!(
149                "[{}] port {local_port} already listening — reusing (skip new port-forward)",
150                svc.name
151            ));
152        } else {
153            match start_port_forward(
154                kube_context,
155                ns,
156                &resource,
157                local_port,
158                container_port,
159            ) {
160                Ok(child_pid) => {
161                    pid = Some(child_pid);
162                    if wait_for_port(local_port, Duration::from_secs(8)) {
163                        local_url = Some(format!("http://127.0.0.1:{local_port}"));
164                        lines.push(format!(
165                            "[{}] port-forward pid={child_pid} → http://127.0.0.1:{local_port} (svc/{resource}:{container_port})",
166                            svc.name
167                        ));
168                        // Optional HTTP probe when health URLs look local or generic.
169                        if let Some(note) = probe_local_health(local_port) {
170                            lines.push(format!("[{}] {note}", svc.name));
171                        }
172                    } else {
173                        lines.push(format!(
174                            "[{}] port-forward pid={child_pid} started but :{local_port} not ready yet — check `kubectl -n {ns} get svc {resource}`",
175                            svc.name
176                        ));
177                        local_url = Some(format!("http://127.0.0.1:{local_port}"));
178                    }
179                }
180                Err(e) => {
181                    lines.push(format!("[{}] port-forward failed: {e}", svc.name));
182                    // Fallbacks for docker-desktop without PF
183                    if let Some(hp) = ex.host_port {
184                        lines.push(format!(
185                            "[{}] try hostPort fallback: http://127.0.0.1:{hp}",
186                            svc.name
187                        ));
188                    }
189                    if let Some(np) = ex.node_port {
190                        lines.push(format!(
191                            "[{}] try NodePort fallback: http://127.0.0.1:{np}",
192                            svc.name
193                        ));
194                    }
195                }
196            }
197        }
198    }
199
200    // DNS hosts → 127.0.0.1 (works with port-forward / hostPort on single-node clusters)
201    if !ex.dns_hosts.is_empty() {
202        let mode = ex
203            .dns_mode
204            .as_deref()
205            .map(str::trim)
206            .filter(|s| !s.is_empty())
207            .unwrap_or("hosts");
208        match mode.to_ascii_lowercase().as_str() {
209            "none" => {
210                lines.push(format!(
211                    "[{}] dns_hosts configured but dns_mode=none (skipped)",
212                    svc.name
213                ));
214            }
215            "cloudflare" => {
216                lines.push(format!(
217                    "[{}] dns_mode=cloudflare: use `xbp dns` / Cloudflare API for public records",
218                    svc.name
219                ));
220                for h in &ex.dns_hosts {
221                    lines.push(format!(
222                        "[{}] hint: A/AAAA for {h} → LoadBalancer IP, or dns_mode=hosts for local",
223                        svc.name
224                    ));
225                }
226            }
227            _ => {
228                if dry_run {
229                    lines.push(format!(
230                        "[{}] dry-run: would write hosts → 127.0.0.1 for {}",
231                        svc.name,
232                        ex.dns_hosts.join(", ")
233                    ));
234                } else {
235                    match ensure_hosts_entries(&ex.dns_hosts, "127.0.0.1", "xbp-deploy") {
236                        Ok(msg) => {
237                            lines.push(format!("[{}] hosts: {msg}", svc.name));
238                            for h in &ex.dns_hosts {
239                                lines.push(format!(
240                                    "[{}] local DNS http://{h}:{local_port}",
241                                    svc.name
242                                ));
243                            }
244                        }
245                        Err(e) => {
246                            lines.push(format!(
247                                "[{}] hosts update failed (need admin?): {e}",
248                                svc.name
249                            ));
250                            for h in &ex.dns_hosts {
251                                lines.push(format!(
252                                    "[{}] manual: add `127.0.0.1 {h}` to hosts file",
253                                    svc.name
254                                ));
255                            }
256                        }
257                    }
258                }
259            }
260        }
261    }
262
263    if let Some(url) = &local_url {
264        lines.push(format!("[{}] open {url}", svc.name));
265    }
266
267    ExposeResult {
268        lines,
269        local_url,
270        port_forward_pid: pid,
271    }
272}
273
274fn service_resource_name(svc: &ServicePlan) -> String {
275    if let Some(s) = svc
276        .deploy
277        .service
278        .as_deref()
279        .map(str::trim)
280        .filter(|s| !s.is_empty())
281    {
282        return s.to_string();
283    }
284    if let Some(w) = svc.deploy.workload.as_deref() {
285        let n = workload_name(w);
286        if !n.is_empty() {
287            return n;
288        }
289    }
290    svc.name.clone()
291}
292
293fn port_is_listening(port: u16) -> bool {
294    TcpStream::connect_timeout(
295        &SocketAddr::from(([127, 0, 0, 1], port)),
296        Duration::from_millis(150),
297    )
298    .is_ok()
299}
300
301fn wait_for_port(port: u16, timeout: Duration) -> bool {
302    let start = Instant::now();
303    while start.elapsed() < timeout {
304        if port_is_listening(port) {
305            return true;
306        }
307        std::thread::sleep(Duration::from_millis(200));
308    }
309    false
310}
311
312fn probe_local_health(local_port: u16) -> Option<String> {
313    // Lightweight TCP-only confirmation (no reqwest dependency in deploy crate).
314    if port_is_listening(local_port) {
315        Some(format!("localhost:{local_port} accepts TCP"))
316    } else {
317        None
318    }
319}
320
321fn start_port_forward(
322    context: Option<&str>,
323    namespace: &str,
324    service_name: &str,
325    local_port: u16,
326    container_port: u16,
327) -> Result<u32, String> {
328    let log_path = std::env::temp_dir().join(format!(
329        "xbp-port-forward-{}-{local_port}.log",
330        service_name.replace('/', "-")
331    ));
332    let log_file = fs::File::create(&log_path)
333        .map_err(|e| format!("create port-forward log {}: {e}", log_path.display()))?;
334    let log_err = log_file
335        .try_clone()
336        .map_err(|e| format!("clone log handle: {e}"))?;
337
338    let mut cmd = Command::new("kubectl");
339    if let Some(ctx) = context.map(str::trim).filter(|s| !s.is_empty()) {
340        cmd.args(["--context", ctx]);
341    }
342    cmd.args([
343        "port-forward",
344        "--address",
345        "127.0.0.1",
346        "-n",
347        namespace,
348        &format!("svc/{service_name}"),
349        &format!("{local_port}:{container_port}"),
350    ]);
351    cmd.stdin(Stdio::null())
352        .stdout(Stdio::from(log_file))
353        .stderr(Stdio::from(log_err));
354
355    let mut child = cmd
356        .spawn()
357        .map_err(|e| format!("spawn kubectl port-forward: {e}"))?;
358    let id = child.id();
359
360    // Brief window: if process exits immediately, surface log tail.
361    std::thread::sleep(Duration::from_millis(350));
362    match child.try_wait() {
363        Ok(Some(status)) => {
364            let mut tail = String::new();
365            if let Ok(mut f) = fs::File::open(&log_path) {
366                let _ = f.read_to_string(&mut tail);
367            }
368            let snippet = tail.trim();
369            let snippet = if snippet.len() > 400 {
370                &snippet[snippet.len() - 400..]
371            } else {
372                snippet
373            };
374            return Err(format!(
375                "kubectl port-forward exited ({status}): {snippet}"
376            ));
377        }
378        Ok(None) => {}
379        Err(e) => {
380            return Err(format!("port-forward status check: {e}"));
381        }
382    }
383
384    if let Ok(mut guard) = PORT_FORWARD_CHILDREN.lock() {
385        guard.push(id);
386    }
387    // Detach so it keeps running after deploy exits.
388    std::mem::forget(child);
389    Ok(id)
390}
391
392/// Ensure each host maps to `ip` in the OS hosts file under an xbp marker block.
393pub fn ensure_hosts_entries(hosts: &[String], ip: &str, marker: &str) -> Result<String, String> {
394    if hosts.is_empty() {
395        return Ok("no hosts".into());
396    }
397    let path = hosts_file_path()?;
398    let begin = format!("# BEGIN {marker}");
399    let end = format!("# END {marker}");
400    let existing = fs::read_to_string(&path).unwrap_or_default();
401
402    let mut block_lines = vec![begin.clone()];
403    for h in hosts {
404        let h = h.trim();
405        if h.is_empty() {
406            continue;
407        }
408        block_lines.push(format!("{ip} {h}"));
409    }
410    block_lines.push(end.clone());
411    let new_block = block_lines.join("\n");
412
413    let updated = if let (Some(start), Some(stop)) = (
414        existing.find(&begin),
415        existing.find(&end).map(|i| i + end.len()),
416    ) {
417        if stop < start {
418            format!("{existing}\n{new_block}\n")
419        } else {
420            let mut s = String::new();
421            s.push_str(&existing[..start]);
422            s.push_str(&new_block);
423            s.push_str(&existing[stop..]);
424            if !s.ends_with('\n') {
425                s.push('\n');
426            }
427            s
428        }
429    } else {
430        let mut s = existing;
431        if !s.ends_with('\n') && !s.is_empty() {
432            s.push('\n');
433        }
434        s.push_str(&new_block);
435        s.push('\n');
436        s
437    };
438
439    write_hosts_file(&path, &updated)?;
440    Ok(format!(
441        "updated {} ({} host(s) → {ip})",
442        path.display(),
443        hosts.len()
444    ))
445}
446
447fn hosts_file_path() -> Result<PathBuf, String> {
448    if cfg!(windows) {
449        let windir = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into());
450        Ok(PathBuf::from(windir)
451            .join("System32")
452            .join("drivers")
453            .join("etc")
454            .join("hosts"))
455    } else {
456        Ok(PathBuf::from("/etc/hosts"))
457    }
458}
459
460fn write_hosts_file(path: &Path, content: &str) -> Result<(), String> {
461    match fs::OpenOptions::new()
462        .write(true)
463        .truncate(true)
464        .open(path)
465    {
466        Ok(mut f) => {
467            f.write_all(content.as_bytes())
468                .map_err(|e| format!("write {}: {e}", path.display()))?;
469            Ok(())
470        }
471        Err(e) => {
472            if cfg!(windows) {
473                let fallback = std::env::temp_dir().join("xbp-hosts-snippet.txt");
474                fs::write(&fallback, content)
475                    .map_err(|e2| format!("write fallback: {e2} (original: {e})"))?;
476                return Err(format!(
477                    "cannot write {} ({e}); wrote snippet to {} — merge as admin or run elevated",
478                    path.display(),
479                    fallback.display()
480                ));
481            }
482            Err(format!("cannot write {}: {e}", path.display()))
483        }
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::types::{ServiceDeployPlan, ServiceExposeView, ServicePlan};
491
492    fn plan_with_expose(ex: ServiceExposeView) -> ServicePlan {
493        ServicePlan {
494            name: "athena".into(),
495            provider: "kubernetes".into(),
496            destination: Some("kubernetes".into()),
497            root_directory: None,
498            version: "1".into(),
499            image: None,
500            image_ref: Some("img:1".into()),
501            digest: None,
502            dockerfile: None,
503            build_context: None,
504            platforms: vec![],
505            worker_app: None,
506            rollout: None,
507            runtime_env: Default::default(),
508            container_port: Some(5052),
509            config_mounts: vec![],
510            expose: Some(ex),
511            deploy: ServiceDeployPlan {
512                namespace: Some("athena".into()),
513                workload: Some("deployment/athena".into()),
514                service: Some("athena".into()),
515                health: vec![],
516                manifest_paths: vec![],
517                crds_path: None,
518                install_path: None,
519                selector: None,
520                actions: vec![],
521            },
522        }
523    }
524
525    #[test]
526    fn should_expose_when_local_port() {
527        let svc = plan_with_expose(ServiceExposeView {
528            local_port: Some(4052),
529            ..Default::default()
530        });
531        assert!(should_expose(&svc, false));
532        assert!(!should_expose(&svc, true));
533    }
534
535    #[test]
536    fn auto_local_expose_without_config() {
537        let mut svc = plan_with_expose(ServiceExposeView::default());
538        svc.expose = None;
539        assert!(effective_expose(&svc, false).is_none());
540        let auto = effective_expose(&svc, true).expect("auto");
541        assert_eq!(auto.local_port, Some(5052));
542        assert_eq!(auto.port_forward, Some(true));
543    }
544
545    #[test]
546    fn prefers_k8s_service_name() {
547        let svc = plan_with_expose(ServiceExposeView {
548            local_port: Some(4052),
549            ..Default::default()
550        });
551        assert_eq!(service_resource_name(&svc), "athena");
552    }
553
554    #[test]
555    fn dry_run_expose_mentions_port_forward() {
556        let svc = plan_with_expose(ServiceExposeView {
557            service_type: Some("NodePort".into()),
558            node_port: Some(30052),
559            local_port: Some(4052),
560            port_forward: Some(true),
561            dns_hosts: vec!["athena.local".into()],
562            dns_mode: Some("hosts".into()),
563            ..Default::default()
564        });
565        let r = expose_service(&svc, None, true);
566        let joined = r.lines.join("\n");
567        assert!(joined.contains("port-forward"));
568        assert!(joined.contains("4052"));
569        assert!(joined.contains("athena.local"));
570        assert!(r.local_url.as_deref() == Some("http://127.0.0.1:4052"));
571    }
572}