use std::{path::Path, str::FromStr};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use crate::image::ImageSource;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InitCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<ImageSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<Vec<u8>>,
#[serde(default = "on", skip_serializing_if = "is_on")]
pub network: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ports: Vec<Port>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vcpus: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_mib: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu_memory_mib: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk_gib: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mounts: Vec<MountSpec>,
}
impl Default for InitCall {
fn default() -> Self {
InitCall {
image: None,
snapshot: None,
network: true,
ports: Vec::new(),
vcpus: None,
memory_mib: None,
gpu: None,
gpu_memory_mib: None,
disk_gib: None,
mounts: Vec::new(),
}
}
}
fn on() -> bool {
true
}
fn is_on(network: &bool) -> bool {
*network
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MountSpec {
host_url: String,
guest_path: String,
readonly: bool,
}
impl MountSpec {
pub fn new(
host_url: impl Into<String>,
guest_path: impl Into<String>,
) -> Result<Self, InvalidMount> {
let host_url = host_url.into();
let guest_path = guest_path.into();
let refuse = |why| {
Err(InvalidMount {
why,
spec: format!("{host_url}:{guest_path}"),
})
};
match host_url.split_once("://") {
None => return refuse("a mount URL needs a scheme"),
Some((_, "")) => return refuse("a mount URL needs something after its scheme"),
Some(_) => {}
}
if !guest_path.starts_with('/') {
return refuse("a guest path is absolute");
}
if guest_path.contains(':') {
return refuse("a guest path cannot carry a colon");
}
Ok(MountSpec {
host_url,
guest_path,
readonly: false,
})
}
pub fn read_only(mut self) -> Self {
self.readonly = true;
self
}
pub fn host_url(&self) -> &str {
&self.host_url
}
pub fn guest_path(&self) -> &Path {
Path::new(&self.guest_path)
}
pub fn is_read_only(&self) -> bool {
self.readonly
}
pub fn scheme(&self) -> &str {
self.host_url
.split_once("://")
.map_or(&self.host_url, |(s, _)| s)
}
pub fn file_path(&self) -> Option<&Path> {
self.host_url.strip_prefix("file://").map(Path::new)
}
}
impl std::fmt::Display for MountSpec {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}:{}", self.host_url, self.guest_path)?;
if self.readonly {
f.write_str(":ro")?;
}
Ok(())
}
}
impl FromStr for MountSpec {
type Err = InvalidMount;
fn from_str(spec: &str) -> Result<Self, Self::Err> {
let refuse = |why| {
Err(InvalidMount {
why,
spec: spec.to_string(),
})
};
let Some(scheme) = spec.find("://") else {
return refuse("a mount URL needs a scheme");
};
let (head, mut rest) = spec.split_at(scheme + "://".len());
let mut readonly = false;
loop {
let Some((before, last)) = rest.rsplit_once(':') else {
return refuse("a mount needs a guest path to appear at");
};
if last.starts_with('/') {
let mount = MountSpec::new(format!("{head}{before}"), last)?;
return Ok(if readonly { mount.read_only() } else { mount });
}
match last {
"ro" => readonly = true,
"rw" => readonly = false,
_ => return refuse("a mount trails an absolute guest path with `ro` or `rw`"),
}
rest = before;
}
}
}
impl Serialize for MountSpec {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for MountSpec {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let spec = String::deserialize(deserializer)?;
spec.parse().map_err(de::Error::custom)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InvalidMount {
why: &'static str,
spec: String,
}
impl std::fmt::Display for InvalidMount {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}: {:?}", self.why, self.spec)
}
}
impl std::error::Error for InvalidMount {}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Port {
pub host: u16,
pub console: u16,
}
impl Port {
pub fn new(host: u16, console: u16) -> Result<Self, InvalidPort> {
if host == 0 || console == 0 {
return Err(InvalidPort {
why: "port 0 is no port",
spec: format!("{host}:{console}"),
});
}
Ok(Port { host, console })
}
}
impl std::fmt::Display for Port {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}:{}", self.host, self.console)
}
}
impl FromStr for Port {
type Err = InvalidPort;
fn from_str(spec: &str) -> Result<Self, Self::Err> {
let invalid = |why| InvalidPort {
why,
spec: spec.to_string(),
};
let number = |part: &str| {
part.parse::<u16>()
.map_err(|_| invalid("not a port number"))
};
let (host, console) = spec
.split_once(':')
.ok_or_else(|| invalid("not <host>:<console>"))?;
Port::new(number(host)?, number(console)?).map_err(|_| invalid("port 0 is no port"))
}
}
impl Serialize for Port {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Port {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let spec = String::deserialize(deserializer)?;
spec.parse().map_err(de::Error::custom)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InvalidPort {
why: &'static str,
spec: String,
}
impl std::fmt::Display for InvalidPort {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}: {:?}", self.why, self.spec)
}
}
impl std::error::Error for InvalidPort {}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InitResp {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_port_is_spelled_the_way_docker_spells_it() {
assert_eq!("8080:80".parse(), Port::new(8080, 80));
assert_eq!(
serde_json::to_value(Port::new(5901, 5900).unwrap()).unwrap(),
serde_json::json!("5901:5900")
);
assert_eq!(
serde_json::from_value::<Port>(serde_json::json!("8080:80")).unwrap(),
Port::new(8080, 80).unwrap()
);
for bad in [
"", "80", "80:", ":80", "0:80", "8080:0", "http", "70000:1", "1:2:3",
] {
assert!(bad.parse::<Port>().is_err(), "{bad:?}");
}
}
#[test]
fn a_mount_survives_its_spelling() {
for spec in [
"file:///srv/project:/work",
"file:///srv/project:/work:ro",
"https://example.com:8080/share:/work/in:ro",
"file:///srv/odd:name:/work",
] {
let read: MountSpec = spec.parse().expect(spec);
assert_eq!(read.to_string(), spec);
}
let explicit: MountSpec = "file:///srv/project:/work:rw".parse().unwrap();
assert_eq!(explicit.to_string(), "file:///srv/project:/work");
}
#[test]
fn a_mount_says_where_the_tree_is_and_where_it_goes() {
let mount: MountSpec = "file:///srv/project:/work:ro".parse().unwrap();
assert_eq!(mount.host_url(), "file:///srv/project");
assert_eq!(mount.guest_path(), Path::new("/work"));
assert!(mount.is_read_only());
assert_eq!(mount.scheme(), "file");
assert_eq!(mount.file_path(), Some(Path::new("/srv/project")));
let remote: MountSpec = "s3://bucket/prefix:/work".parse().unwrap();
assert_eq!(remote.scheme(), "s3");
assert_eq!(remote.file_path(), None);
assert!(!remote.is_read_only());
}
#[test]
fn what_is_not_a_mount_says_which_rule_it_broke() {
for (spec, why) in [
("/srv/project:/work", "a mount URL needs a scheme"),
(
"file://:/work",
"a mount URL needs something after its scheme",
),
(
"file:///srv/project",
"a mount needs a guest path to appear at",
),
(
"file:///srv/project:work",
"a mount trails an absolute guest path with `ro` or `rw`",
),
(
"file:///srv/project:/work:rx",
"a mount trails an absolute guest path with `ro` or `rw`",
),
] {
let refused = spec.parse::<MountSpec>().expect_err(spec).to_string();
assert!(refused.starts_with(why), "{spec}: {refused}");
assert!(refused.contains(spec), "{spec}: {refused}");
}
assert!(MountSpec::new("file:///srv/project", "work").is_err());
assert!(MountSpec::new("file:///srv/project", "/wo:rk").is_err());
}
#[test]
fn a_session_names_its_trees_as_strings() {
let init = InitCall {
mounts: vec![
MountSpec::new("file:///srv/project", "/work")
.unwrap()
.read_only(),
MountSpec::new("file:///srv/out", "/work/out").unwrap(),
],
..InitCall::default()
};
let wire = bson::serialize_to_bson(&init).unwrap();
assert_eq!(
wire,
bson::bson!({
"mounts": ["file:///srv/project:/work:ro", "file:///srv/out:/work/out"],
})
);
assert_eq!(bson::deserialize_from_bson::<InitCall>(wire).unwrap(), init);
}
#[test]
fn a_session_says_only_the_shape_it_asked_for() {
let quiet = bson::serialize_to_bson(&InitCall::default()).unwrap();
assert_eq!(quiet, bson::bson!({}));
let init = InitCall {
vcpus: Some(4),
memory_mib: Some(4096),
gpu: Some(false),
..InitCall::default()
};
let wire = bson::serialize_to_bson(&init).unwrap();
assert_eq!(
wire,
bson::bson!({ "vcpus": 4, "memory_mib": 4096i64, "gpu": false })
);
assert_eq!(bson::deserialize_from_bson::<InitCall>(wire).unwrap(), init);
let init = InitCall {
gpu: Some(true),
gpu_memory_mib: Some(8192),
..InitCall::default()
};
let wire = bson::serialize_to_bson(&init).unwrap();
assert_eq!(
wire,
bson::bson!({ "gpu": true, "gpu_memory_mib": 8192i64 })
);
assert_eq!(bson::deserialize_from_bson::<InitCall>(wire).unwrap(), init);
let init = InitCall {
disk_gib: Some(32),
..InitCall::default()
};
let wire = bson::serialize_to_bson(&init).unwrap();
assert_eq!(wire, bson::bson!({ "disk_gib": 32i64 }));
assert_eq!(bson::deserialize_from_bson::<InitCall>(wire).unwrap(), init);
}
}