use std::path::PathBuf;
use crate::generate::GeneratedFile;
#[derive(Debug, Clone)]
pub struct TailscalePort {
pub https_port: u16,
pub host_port: u16,
}
pub fn tailscale_ports(
ports: &[crate::registry::service_def::PortDef],
resolved: &[(String, u16)],
primary_host_port: Option<u16>,
) -> Vec<TailscalePort> {
let mapped: Vec<TailscalePort> = ports
.iter()
.filter_map(|p| {
let https_port = p.tailscale_https?;
let host_port = resolved
.iter()
.find(|(n, _)| n == &p.name)
.map(|(_, hp)| *hp)
.or(p.host_port)?;
Some(TailscalePort {
https_port,
host_port,
})
})
.collect();
if !mapped.is_empty() {
return mapped;
}
primary_host_port
.map(|host_port| {
vec![TailscalePort {
https_port: 443,
host_port,
}]
})
.unwrap_or_default()
}
pub enum Step {
WriteFile(GeneratedFile),
Symlink { link: PathBuf, target: PathBuf },
DaemonReload,
StartService { unit: String },
StopService { unit: String },
RestartService { unit: String },
ReloadCaddy,
PullImage { image: String },
RemoveFile(PathBuf),
RemoveDir(PathBuf),
RemoveVolume { name: String },
CreateDir(PathBuf),
WaitForFile { path: PathBuf, timeout_secs: u32 },
CopyFile { src: PathBuf, dst: PathBuf },
TailscaleSetup,
TailscaleEnable {
svc_name: String,
ports: Vec<TailscalePort>,
},
TailscaleDisable { svc_name: String },
}
impl Step {
pub fn to_command(&self) -> String {
match self {
Step::WriteFile(file) => format!("write {}", file.path.display()),
Step::Symlink { link, target } => {
format!("ln -sf {} {}", target.display(), link.display())
}
Step::DaemonReload => "systemctl --user daemon-reload".into(),
Step::StartService { unit } => format!("systemctl --user start {unit}"),
Step::StopService { unit } => format!("systemctl --user stop {unit}"),
Step::RestartService { unit } => format!("systemctl --user restart {unit}"),
Step::ReloadCaddy => {
"podman exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile"
.into()
}
Step::PullImage { image } => format!("podman pull {image}"),
Step::RemoveFile(path) => format!("rm -f {}", path.display()),
Step::RemoveDir(path) => format!("rm -rf {}", path.display()),
Step::CreateDir(path) => format!("mkdir -p {}", path.display()),
Step::RemoveVolume { name } => format!("podman volume rm {name}"),
Step::WaitForFile { path, timeout_secs } => {
format!("wait for {} (up to {timeout_secs}s)", path.display())
}
Step::CopyFile { src, dst } => format!("cp {} {}", src.display(), dst.display()),
Step::TailscaleSetup => "tailscale: ensure ACL tags + auto-approval".to_string(),
Step::TailscaleEnable { svc_name, ports } => ports
.iter()
.map(|p| {
format!(
"tailscale serve --service=svc:{svc_name} --https={} http://127.0.0.1:{}",
p.https_port, p.host_port
)
})
.collect::<Vec<_>>()
.join(" && "),
Step::TailscaleDisable { svc_name } => {
format!("tailscale serve --service=svc:{svc_name} off + delete service")
}
}
}
}
pub enum Warning {
RamBelowMinimum {
service_name: String,
min_mb: u64,
available_mb: u64,
},
RamBelowRecommended {
service_name: String,
recommended_mb: u64,
available_mb: u64,
},
PortReassigned {
service_name: String,
port_name: String,
original_port: u16,
assigned_port: u16,
reason: String,
},
UrlWithoutReverseProxy {
service_name: String,
url: String,
host_port: u16,
},
}
pub struct AddResult {
pub steps: Vec<Step>,
pub warnings: Vec<Warning>,
pub repo_url: String,
pub allocated_ports: Vec<(String, u16)>,
pub generated_secrets: Vec<String>,
pub env_content: String,
pub url: Option<String>,
pub tracked_envs: Vec<TrackedEnv>,
}
#[derive(Debug, Clone)]
pub struct TrackedEnv {
pub key: String,
pub value: String,
pub kind: crate::registry::service_def::EnvKind,
pub prompt: Option<String>,
}
pub struct RemoveResult {
pub steps: Vec<Step>,
pub service_name: String,
pub url: Option<String>,
}
pub struct ResetResult {
pub steps: Vec<Step>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::service_def::{PortDef, PortProtocol};
fn port(name: &str, container: u16, ts: Option<u16>) -> PortDef {
PortDef {
name: name.into(),
container_port: container,
host_port: None,
protocol: PortProtocol::default(),
tailscale_https: ts,
}
}
#[test]
fn single_port_service_falls_back_to_primary_on_443() {
let ports = vec![port("http", 80, None)];
let resolved = vec![("http".to_string(), 10001u16)];
let out = tailscale_ports(&ports, &resolved, Some(10001));
assert_eq!(out.len(), 1);
assert_eq!(out[0].https_port, 443);
assert_eq!(out[0].host_port, 10001);
}
#[test]
fn multiport_maps_each_declared_port_to_its_resolved_host_port() {
let ports = vec![
port("http", 8080, Some(8080)),
port("photos", 3000, Some(443)),
];
let resolved = vec![
("http".to_string(), 8080u16),
("photos".to_string(), 10002u16),
];
let mut out = tailscale_ports(&ports, &resolved, Some(8080));
out.sort_by_key(|p| p.https_port);
assert_eq!(out.len(), 2);
assert_eq!((out[0].https_port, out[0].host_port), (443, 10002)); assert_eq!((out[1].https_port, out[1].host_port), (8080, 8080)); }
#[test]
fn no_ports_and_no_primary_yields_empty() {
assert!(tailscale_ports(&[], &[], None).is_empty());
}
}