use crate::error::{Error, Result};
use crate::spec::{PortBind, PortSpec, ReadyCheck, VolumeSpec, is_host_path};
pub fn key_value(s: &str) -> Result<(String, String)> {
s.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
.filter(|(k, _)| !k.is_empty())
.ok_or_else(|| Error::invalid(format!("expected KEY=VALUE, got {s:?}")))
}
pub fn volume(s: &str) -> Result<VolumeSpec> {
let mut parts = s.splitn(3, ':');
let (Some(src), Some(guest)) = (parts.next(), parts.next()) else {
return Err(Error::invalid(format!(
"volume {s:?}: expected SOURCE:TARGET[:options] (isb has no anonymous volumes)"
)));
};
if src.is_empty() || !guest.starts_with('/') {
return Err(Error::invalid(format!(
"volume {s:?}: expected SOURCE:TARGET with an absolute target"
)));
}
let mut v = if is_host_path(src) {
crate::spec::Volume::bind(src)
} else {
crate::spec::Volume::named(src)
};
v.target = guest.to_string();
if let Some(opts) = parts.next() {
for o in opts.split(',').filter(|o| !o.is_empty()) {
match o.split_once('=') {
None if o == "ro" => v.read_only = true,
None if o == "rw" => v.read_only = false,
None if o == "z" || o == "Z" => {}
None if matches!(
o,
"shared" | "rshared" | "slave" | "rslave" | "private" | "rprivate"
) =>
{
v.options.insert("propagation".into(), o.into());
}
None if o == "external" => v.external = true,
None if o == "nocopy" => v.volume.nocopy = true,
Some(("owner", u)) => v.owner = Some(u.into()),
Some(("mode", m)) => v.mode = Some(m.into()),
Some(("device", d)) => v.device = Some(d.into()),
Some(("pool", p)) => v.pool = Some(p.into()),
_ => {
return Err(Error::invalid(format!(
"volume {s:?}: unknown option {o:?} (ro, rw, nocopy, owner=, mode=, device=, pool=, external)"
)));
}
}
}
}
Ok(v)
}
fn port_range(s: &str, what: &str, spec: &str) -> Result<(u16, u16)> {
let bad = || {
Error::invalid(format!(
"port {spec:?}: {what} {s:?} is not a port or range"
))
};
let parse = |n: &str| {
n.trim()
.parse::<u16>()
.ok()
.filter(|n| *n > 0)
.ok_or_else(bad)
};
match s.split_once('-') {
Some((a, b)) => {
let (a, b) = (parse(a)?, parse(b)?);
if b < a {
return Err(bad());
}
Ok((a, b))
}
None => parse(s).map(|n| (n, n)),
}
}
pub fn docker_port(
host_ip: Option<&str>,
published: &str,
target: &str,
proto: &str,
) -> Result<PortSpec> {
let spec = format!(
"{}:{published}:{target}/{proto}",
host_ip.unwrap_or("127.0.0.1")
);
if !matches!(proto, "tcp" | "udp") {
return Err(Error::invalid(format!(
"port {spec:?}: protocol is tcp or udp"
)));
}
let host = match host_ip.map(str::trim).filter(|h| !h.is_empty()) {
None => "127.0.0.1".to_string(),
Some(h) if h.contains(':') && !h.starts_with('[') => format!("[{h}]"),
Some(h) => h.to_string(),
};
let (pa, pb) = port_range(published, "published", &spec)?;
let (ta, tb) = port_range(target, "target", &spec)?;
let mut p = PortSpec {
bind: PortBind::Host,
..Default::default()
};
if ta == tb {
p.listen = format!("{proto}:{host}:{pa}");
p.connect = format!("{proto}:{ta}");
p.search = (pb > pa).then(|| pb - pa);
} else if pb - pa == tb - ta {
p.listen = format!("{proto}:{host}:{pa}-{pb}");
p.connect = format!("{proto}:{ta}-{tb}");
} else {
return Err(Error::invalid(format!(
"port {spec:?}: a target range needs a published range of the same length"
)));
}
Ok(p)
}
pub fn port(s: &str) -> Result<PortSpec> {
if s.contains('=') {
let mut p = PortSpec::default();
let mut have = (false, false);
for kv in s.split(',') {
let (k, v) = key_value(kv)?;
match k.as_str() {
"listen" => {
p.listen = v;
have.0 = true;
}
"connect" => {
p.connect = v;
have.1 = true;
}
"bind" => {
p.bind = match v.as_str() {
"host" => PortBind::Host,
"guest" => PortBind::Guest,
_ => {
return Err(Error::invalid(format!(
"port {s:?}: bind is host or guest"
)));
}
}
}
"name" => p.name = Some(v),
"search" => {
p.search = Some(v.parse().map_err(|_| {
Error::invalid(format!("port {s:?}: search must be a number"))
})?)
}
"nat" | "proxy_protocol" | "security.uid" | "security.gid" | "uid" | "gid"
| "mode" => {
p.options.insert(k, v);
}
other => {
return Err(Error::invalid(format!(
"port {s:?}: unknown key {other:?} (listen, connect, bind, name, search, nat, proxy_protocol, uid, gid, mode, security.uid, security.gid)"
)));
}
}
}
if !(have.0 && have.1) {
return Err(Error::invalid(format!(
"port {s:?}: needs listen= and connect="
)));
}
return Ok(p);
}
docker_short_port(s)
}
pub fn docker_short_port(s: &str) -> Result<PortSpec> {
let (body, proto) = match s.rsplit_once('/') {
Some((b, p)) if p == "tcp" || p == "udp" => (b, p),
Some(_) => {
return Err(Error::invalid(format!(
"port {s:?}: protocol is tcp or udp"
)));
}
None => (s, "tcp"),
};
let (ip, rest) = match body.strip_prefix('[') {
Some(b) => {
let (ip, rest) = b.split_once("]:").ok_or_else(|| {
Error::invalid(format!("port {s:?}: expected [IPv6]:PUBLISHED:TARGET"))
})?;
(Some(ip), rest)
}
None => (None, body),
};
let parts: Vec<&str> = rest.rsplitn(3, ':').collect();
let (ip, published, target) = match (ip, parts.as_slice()) {
(None, [t, p]) => (None, *p, *t),
(None, [t, p, ip]) => (Some(*ip), *p, *t),
(Some(ip), [t, p]) => (Some(ip), *p, *t),
(None, [_]) => {
return Err(Error::invalid(format!(
"port {s:?}: isb needs the host port too, e.g. {body}:{body} (docker would pick a random one)"
)));
}
_ => {
return Err(Error::invalid(format!(
"port {s:?}: expected [HOST_IP:]PUBLISHED:TARGET[/udp]"
)));
}
};
docker_port(ip, published, target, proto)
}
pub fn ready(s: &str) -> Result<ReadyCheck> {
Ok(match s.split_once('=') {
None if s == "running" => ReadyCheck::Running,
None if s == "default_route" => ReadyCheck::DefaultRoute,
None if s == "agent" => ReadyCheck::Agent,
Some(("user_exists", u)) => ReadyCheck::UserExists(u.into()),
Some(("path_writable", p)) => ReadyCheck::PathWritable(p.into()),
Some(("command", c)) => ReadyCheck::Command(c.split(',').map(String::from).collect()),
_ => {
return Err(Error::invalid(format!(
"ready {s:?}: running, agent, default_route, user_exists=USER, path_writable=PATH or command=ARG,ARG"
)));
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn volumes() {
use crate::spec::MountType;
let v = volume("./src:/app:ro").unwrap();
assert_eq!(v.target, "/app");
assert_eq!(
(v.mount_type, v.source.as_str()),
(MountType::Bind, "./src")
);
assert!(v.read_only);
let v = volume("cache:/home/dev/.cache:owner=dev,mode=0770,device=c").unwrap();
assert_eq!(v.mode.as_deref(), Some("0770"));
assert_eq!(v.target, "/home/dev/.cache");
assert_eq!(
(v.mount_type, v.source.as_str()),
(MountType::Volume, "cache")
);
assert_eq!(v.owner.as_deref(), Some("dev"));
assert_eq!(v.device.as_deref(), Some("c"));
let v = volume("/srv:/srv:z,rslave").unwrap();
assert_eq!(v.options["propagation"], "rslave");
assert!(volume("/data").is_err());
assert!(volume("x").is_err());
assert!(volume("a:rel").is_err());
assert!(volume("a:/b:bogus").is_err());
}
#[test]
fn ports() {
let p = port("8080:80").unwrap();
assert_eq!(p.listen, "tcp:127.0.0.1:8080");
assert_eq!(p.connect, "tcp:80");
let p = port("100.1.2.3:5173:5173/udp").unwrap();
assert_eq!(p.listen, "udp:100.1.2.3:5173");
let p = port("100.1.2.3:5173-5223:5173").unwrap();
assert_eq!(p.listen, "tcp:100.1.2.3:5173");
assert_eq!(p.search, Some(50));
let p = port("8000-8002:9000-9002").unwrap();
assert_eq!(p.listen, "tcp:127.0.0.1:8000-8002");
assert_eq!(p.connect, "tcp:9000-9002");
assert_eq!(p.search, None);
let p = port("[::1]:8080:80").unwrap();
assert_eq!(p.listen, "tcp:[::1]:8080");
assert!(port("8000:9000-9002").is_err());
assert!(port("8000-8001:9000-9002").is_err());
assert!(docker_short_port("listen=tcp:1.2.3.4:1,connect=tcp:1.2.3.4:2").is_err());
let p =
port("bind=guest,listen=tcp:127.0.0.1:8190,connect=tcp:127.0.0.1:9000,name=backend")
.unwrap();
assert_eq!(p.bind, PortBind::Guest);
assert_eq!(p.name.as_deref(), Some("backend"));
let p = port("listen=tcp:1.2.3.4:5173,connect=tcp:127.0.0.1:5173,search=50").unwrap();
assert_eq!(p.search, Some(50));
assert!(port("80").is_err());
assert!(port("a:b").is_err());
assert!(port("listen=tcp:1.2.3.4:1").is_err());
assert!(port("1:2/sctp").is_err());
assert!(port("listen=tcp:1.2.3.4:1,connect=tcp:1.2.3.4:2,serach=5").is_err());
}
#[test]
fn readies() {
assert_eq!(ready("running").unwrap(), ReadyCheck::Running);
assert_eq!(
ready("user_exists=dev").unwrap(),
ReadyCheck::UserExists("dev".into())
);
assert_eq!(
ready("command=test,-d,/tmp").unwrap(),
ReadyCheck::Command(vec!["test".into(), "-d".into(), "/tmp".into()])
);
assert!(ready("nope").is_err());
}
}