use crate::bootstrap::workload_name;
use crate::types::{ServiceExposeView, ServicePlan};
use std::fs;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
static PORT_FORWARD_CHILDREN: Mutex<Vec<u32>> = Mutex::new(Vec::new());
#[derive(Debug, Clone)]
pub struct ExposeResult {
pub lines: Vec<String>,
pub local_url: Option<String>,
pub port_forward_pid: Option<u32>,
}
pub fn should_expose(svc: &ServicePlan, skip_expose: bool) -> bool {
if skip_expose {
return false;
}
effective_expose(svc, false).is_some()
}
pub fn effective_expose(svc: &ServicePlan, auto_local: bool) -> Option<ServiceExposeView> {
if let Some(ex) = svc.expose.clone() {
return Some(ex);
}
if !auto_local {
return None;
}
if svc.deploy.workload.is_none() {
return None;
}
let port = svc.container_port.unwrap_or(8080);
Some(ServiceExposeView {
service_type: Some("ClusterIP".into()),
node_port: None,
host_port: None,
local_port: Some(port),
port_forward: Some(true),
dns_hosts: Vec::new(),
dns_mode: None,
})
}
fn wants_port_forward(ex: &ServiceExposeView) -> bool {
if let Some(pf) = ex.port_forward {
return pf;
}
ex.local_port.is_some() || !ex.dns_hosts.is_empty()
}
pub fn expose_service(
svc: &ServicePlan,
kube_context: Option<&str>,
dry_run: bool,
) -> ExposeResult {
expose_service_with_auto(svc, kube_context, dry_run, false)
}
pub fn expose_service_with_auto(
svc: &ServicePlan,
kube_context: Option<&str>,
dry_run: bool,
auto_local: bool,
) -> ExposeResult {
let mut lines = Vec::new();
let Some(ex) = effective_expose(svc, auto_local) else {
return ExposeResult {
lines,
local_url: None,
port_forward_pid: None,
};
};
if svc.expose.is_none() && auto_local {
lines.push(format!(
"[{}] auto-expose (local-image): port-forward localhost:{} → container",
svc.name,
ex.local_port.unwrap_or(svc.container_port.unwrap_or(8080))
));
}
let ex = &ex;
let container_port = svc.container_port.unwrap_or(8080);
let local_port = ex.local_port.unwrap_or(container_port);
let ns = svc
.deploy
.namespace
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("default");
let resource = service_resource_name(svc);
if let Some(st) = ex.service_type.as_deref() {
lines.push(format!(
"[{}] expose service_type={} service={} local_port={} container_port={}",
svc.name, st, resource, local_port, container_port
));
if st.eq_ignore_ascii_case("NodePort") {
if let Some(np) = ex.node_port {
lines.push(format!(
"[{}] NodePort fixed at {np} (docker-desktop: http://localhost:{np})",
svc.name
));
} else {
lines.push(format!(
"[{}] NodePort auto-assigned — `kubectl get svc -n {ns} {resource}`",
svc.name
));
}
}
if let Some(hp) = ex.host_port {
lines.push(format!(
"[{}] hostPort={hp} on pod (docker-desktop: http://localhost:{hp})",
svc.name
));
}
}
let mut pid = None;
let mut local_url = None;
if wants_port_forward(ex) {
if dry_run {
lines.push(format!(
"[{}] dry-run: would port-forward svc/{resource} {local_port}:{container_port} -n {ns}",
svc.name
));
local_url = Some(format!("http://127.0.0.1:{local_port}"));
} else if port_is_listening(local_port) {
local_url = Some(format!("http://127.0.0.1:{local_port}"));
lines.push(format!(
"[{}] port {local_port} already listening — reusing (skip new port-forward)",
svc.name
));
} else {
match start_port_forward(
kube_context,
ns,
&resource,
local_port,
container_port,
) {
Ok(child_pid) => {
pid = Some(child_pid);
if wait_for_port(local_port, Duration::from_secs(8)) {
local_url = Some(format!("http://127.0.0.1:{local_port}"));
lines.push(format!(
"[{}] port-forward pid={child_pid} → http://127.0.0.1:{local_port} (svc/{resource}:{container_port})",
svc.name
));
if let Some(note) = probe_local_health(local_port) {
lines.push(format!("[{}] {note}", svc.name));
}
} else {
lines.push(format!(
"[{}] port-forward pid={child_pid} started but :{local_port} not ready yet — check `kubectl -n {ns} get svc {resource}`",
svc.name
));
local_url = Some(format!("http://127.0.0.1:{local_port}"));
}
}
Err(e) => {
lines.push(format!("[{}] port-forward failed: {e}", svc.name));
if let Some(hp) = ex.host_port {
lines.push(format!(
"[{}] try hostPort fallback: http://127.0.0.1:{hp}",
svc.name
));
}
if let Some(np) = ex.node_port {
lines.push(format!(
"[{}] try NodePort fallback: http://127.0.0.1:{np}",
svc.name
));
}
}
}
}
}
if !ex.dns_hosts.is_empty() {
let mode = ex
.dns_mode
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("hosts");
match mode.to_ascii_lowercase().as_str() {
"none" => {
lines.push(format!(
"[{}] dns_hosts configured but dns_mode=none (skipped)",
svc.name
));
}
"cloudflare" => {
lines.push(format!(
"[{}] dns_mode=cloudflare: use `xbp dns` / Cloudflare API for public records",
svc.name
));
for h in &ex.dns_hosts {
lines.push(format!(
"[{}] hint: A/AAAA for {h} → LoadBalancer IP, or dns_mode=hosts for local",
svc.name
));
}
}
_ => {
if dry_run {
lines.push(format!(
"[{}] dry-run: would write hosts → 127.0.0.1 for {}",
svc.name,
ex.dns_hosts.join(", ")
));
} else {
match ensure_hosts_entries(&ex.dns_hosts, "127.0.0.1", "xbp-deploy") {
Ok(msg) => {
lines.push(format!("[{}] hosts: {msg}", svc.name));
for h in &ex.dns_hosts {
lines.push(format!(
"[{}] local DNS http://{h}:{local_port}",
svc.name
));
}
}
Err(e) => {
lines.push(format!(
"[{}] hosts update failed (need admin?): {e}",
svc.name
));
for h in &ex.dns_hosts {
lines.push(format!(
"[{}] manual: add `127.0.0.1 {h}` to hosts file",
svc.name
));
}
}
}
}
}
}
}
if let Some(url) = &local_url {
lines.push(format!("[{}] open {url}", svc.name));
}
ExposeResult {
lines,
local_url,
port_forward_pid: pid,
}
}
fn service_resource_name(svc: &ServicePlan) -> String {
if let Some(s) = svc
.deploy
.service
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
return s.to_string();
}
if let Some(w) = svc.deploy.workload.as_deref() {
let n = workload_name(w);
if !n.is_empty() {
return n;
}
}
svc.name.clone()
}
fn port_is_listening(port: u16) -> bool {
TcpStream::connect_timeout(
&SocketAddr::from(([127, 0, 0, 1], port)),
Duration::from_millis(150),
)
.is_ok()
}
fn wait_for_port(port: u16, timeout: Duration) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
if port_is_listening(port) {
return true;
}
std::thread::sleep(Duration::from_millis(200));
}
false
}
fn probe_local_health(local_port: u16) -> Option<String> {
if port_is_listening(local_port) {
Some(format!("localhost:{local_port} accepts TCP"))
} else {
None
}
}
fn start_port_forward(
context: Option<&str>,
namespace: &str,
service_name: &str,
local_port: u16,
container_port: u16,
) -> Result<u32, String> {
let log_path = std::env::temp_dir().join(format!(
"xbp-port-forward-{}-{local_port}.log",
service_name.replace('/', "-")
));
let log_file = fs::File::create(&log_path)
.map_err(|e| format!("create port-forward log {}: {e}", log_path.display()))?;
let log_err = log_file
.try_clone()
.map_err(|e| format!("clone log handle: {e}"))?;
let mut cmd = Command::new("kubectl");
if let Some(ctx) = context.map(str::trim).filter(|s| !s.is_empty()) {
cmd.args(["--context", ctx]);
}
cmd.args([
"port-forward",
"--address",
"127.0.0.1",
"-n",
namespace,
&format!("svc/{service_name}"),
&format!("{local_port}:{container_port}"),
]);
cmd.stdin(Stdio::null())
.stdout(Stdio::from(log_file))
.stderr(Stdio::from(log_err));
let mut child = cmd
.spawn()
.map_err(|e| format!("spawn kubectl port-forward: {e}"))?;
let id = child.id();
std::thread::sleep(Duration::from_millis(350));
match child.try_wait() {
Ok(Some(status)) => {
let mut tail = String::new();
if let Ok(mut f) = fs::File::open(&log_path) {
let _ = f.read_to_string(&mut tail);
}
let snippet = tail.trim();
let snippet = if snippet.len() > 400 {
&snippet[snippet.len() - 400..]
} else {
snippet
};
return Err(format!(
"kubectl port-forward exited ({status}): {snippet}"
));
}
Ok(None) => {}
Err(e) => {
return Err(format!("port-forward status check: {e}"));
}
}
if let Ok(mut guard) = PORT_FORWARD_CHILDREN.lock() {
guard.push(id);
}
std::mem::forget(child);
Ok(id)
}
pub fn ensure_hosts_entries(hosts: &[String], ip: &str, marker: &str) -> Result<String, String> {
if hosts.is_empty() {
return Ok("no hosts".into());
}
let path = hosts_file_path()?;
let begin = format!("# BEGIN {marker}");
let end = format!("# END {marker}");
let existing = fs::read_to_string(&path).unwrap_or_default();
let mut block_lines = vec![begin.clone()];
for h in hosts {
let h = h.trim();
if h.is_empty() {
continue;
}
block_lines.push(format!("{ip} {h}"));
}
block_lines.push(end.clone());
let new_block = block_lines.join("\n");
let updated = if let (Some(start), Some(stop)) = (
existing.find(&begin),
existing.find(&end).map(|i| i + end.len()),
) {
if stop < start {
format!("{existing}\n{new_block}\n")
} else {
let mut s = String::new();
s.push_str(&existing[..start]);
s.push_str(&new_block);
s.push_str(&existing[stop..]);
if !s.ends_with('\n') {
s.push('\n');
}
s
}
} else {
let mut s = existing;
if !s.ends_with('\n') && !s.is_empty() {
s.push('\n');
}
s.push_str(&new_block);
s.push('\n');
s
};
write_hosts_file(&path, &updated)?;
Ok(format!(
"updated {} ({} host(s) → {ip})",
path.display(),
hosts.len()
))
}
fn hosts_file_path() -> Result<PathBuf, String> {
if cfg!(windows) {
let windir = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into());
Ok(PathBuf::from(windir)
.join("System32")
.join("drivers")
.join("etc")
.join("hosts"))
} else {
Ok(PathBuf::from("/etc/hosts"))
}
}
fn write_hosts_file(path: &Path, content: &str) -> Result<(), String> {
match fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(path)
{
Ok(mut f) => {
f.write_all(content.as_bytes())
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(())
}
Err(e) => {
if cfg!(windows) {
let fallback = std::env::temp_dir().join("xbp-hosts-snippet.txt");
fs::write(&fallback, content)
.map_err(|e2| format!("write fallback: {e2} (original: {e})"))?;
return Err(format!(
"cannot write {} ({e}); wrote snippet to {} — merge as admin or run elevated",
path.display(),
fallback.display()
));
}
Err(format!("cannot write {}: {e}", path.display()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ServiceDeployPlan, ServiceExposeView, ServicePlan};
fn plan_with_expose(ex: ServiceExposeView) -> ServicePlan {
ServicePlan {
name: "athena".into(),
provider: "kubernetes".into(),
destination: Some("kubernetes".into()),
root_directory: None,
version: "1".into(),
image: None,
image_ref: Some("img:1".into()),
digest: None,
dockerfile: None,
build_context: None,
platforms: vec![],
worker_app: None,
rollout: None,
runtime_env: Default::default(),
container_port: Some(5052),
config_mounts: vec![],
expose: Some(ex),
deploy: ServiceDeployPlan {
namespace: Some("athena".into()),
workload: Some("deployment/athena".into()),
service: Some("athena".into()),
health: vec![],
manifest_paths: vec![],
crds_path: None,
install_path: None,
selector: None,
actions: vec![],
},
}
}
#[test]
fn should_expose_when_local_port() {
let svc = plan_with_expose(ServiceExposeView {
local_port: Some(4052),
..Default::default()
});
assert!(should_expose(&svc, false));
assert!(!should_expose(&svc, true));
}
#[test]
fn auto_local_expose_without_config() {
let mut svc = plan_with_expose(ServiceExposeView::default());
svc.expose = None;
assert!(effective_expose(&svc, false).is_none());
let auto = effective_expose(&svc, true).expect("auto");
assert_eq!(auto.local_port, Some(5052));
assert_eq!(auto.port_forward, Some(true));
}
#[test]
fn prefers_k8s_service_name() {
let svc = plan_with_expose(ServiceExposeView {
local_port: Some(4052),
..Default::default()
});
assert_eq!(service_resource_name(&svc), "athena");
}
#[test]
fn dry_run_expose_mentions_port_forward() {
let svc = plan_with_expose(ServiceExposeView {
service_type: Some("NodePort".into()),
node_port: Some(30052),
local_port: Some(4052),
port_forward: Some(true),
dns_hosts: vec!["athena.local".into()],
dns_mode: Some("hosts".into()),
..Default::default()
});
let r = expose_service(&svc, None, true);
let joined = r.lines.join("\n");
assert!(joined.contains("port-forward"));
assert!(joined.contains("4052"));
assert!(joined.contains("athena.local"));
assert!(r.local_url.as_deref() == Some("http://127.0.0.1:4052"));
}
}