1use 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
13static 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
23pub 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
31pub 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 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 ex.local_port.is_some() || !ex.dns_hosts.is_empty()
62}
63
64pub 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
73pub 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 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 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 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 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 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 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 std::mem::forget(child);
389 Ok(id)
390}
391
392pub 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}