use serde::{Deserialize, Serialize};
use std::fmt;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use crate::config::{Egress, EgressEndpoint, Mount, Resources};
use crate::image::{Digest, Image};
use crate::{Error, Result};
pub const GUEST_SHELL: &str = "sh";
pub const TOKEN_LEN: usize = 32;
pub trait Engine: 'static {
type Machine: Machine;
type Gateway: Gateway;
fn image_digest(image: &Image) -> Result<Digest>;
}
pub trait Machine: Sized {
fn start(name: &str, spec: Spec, egress: &EgressEndpoint) -> Result<Self>;
fn attach(name: &str) -> Result<Self>;
fn status(&self) -> Result<Status>;
fn resume(&self) -> Result<()>;
fn exec(&self, command: &[String], timeout: Duration) -> Result<i32>;
fn stop(self) -> Result<()>;
}
pub trait Gateway: 'static {
type FlowError: fmt::Display + Send;
const ALLOW: u8;
const DENY: u8;
fn decode_flow(bytes: &[u8]) -> Result<Decoded, Self::FlowError>;
#[must_use]
fn is_guest_link(ip: IpAddr) -> bool;
#[must_use]
fn is_host_address(ip: IpAddr) -> bool;
}
#[derive(Debug)]
pub enum Decoded {
Partial(usize),
Complete(Flow),
}
pub struct Flow {
pub token: [u8; TOKEN_LEN],
pub destination: SocketAddr,
pub opening: Vec<u8>,
}
impl fmt::Debug for Flow {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Flow")
.field("token", &"<redacted>")
.field("destination", &self.destination)
.field("opening", &self.opening)
.finish()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Spec {
pub image: ImageSpec,
#[serde(default)]
pub mounts: Vec<Mount>,
#[serde(default)]
pub resources: Resources,
#[serde(default)]
pub egress: Egress,
}
impl Spec {
pub fn to_toml(&self) -> Result<String> {
toml::to_string_pretty(self).map_err(Error::ConfigSerialization)
}
#[must_use]
pub(crate) fn satisfies(&self, request: &Spec) -> bool {
self.image.name == request.image.name
&& (request.image.digest.is_none() || self.image.digest == request.image.digest)
&& self.mounts == request.mounts
&& self.resources == request.resources
&& self.egress == request.egress
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImageSpec {
pub name: Image,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<Digest>,
}
impl From<Image> for ImageSpec {
fn from(name: Image) -> Self {
Self { name, digest: None }
}
}
impl ImageSpec {
#[must_use]
pub fn pinned(&self) -> String {
match &self.digest {
Some(digest) => self.name.pinned(digest),
None => self.name.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Status {
pub name: String,
pub state: State,
pub pid: Option<u32>,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum State {
Starting,
Running,
Stopping,
Paused,
Failed,
Unknown,
}
impl fmt::Display for State {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
State::Starting => "starting",
State::Running => "running",
State::Stopping => "stopping",
State::Paused => "paused",
State::Failed => "failed",
State::Unknown => "unknown",
};
write!(f, "{s}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Pattern;
#[test]
fn state_serializes_snake_case_and_displays_the_same_word() {
let variants = [
(State::Starting, "starting"),
(State::Running, "running"),
(State::Stopping, "stopping"),
(State::Paused, "paused"),
(State::Failed, "failed"),
(State::Unknown, "unknown"),
];
for (state, expected_str) in variants {
let serialized = serde_json::to_string(&state).unwrap();
assert_eq!(serialized, format!("\"{expected_str}\""));
let displayed = state.to_string();
assert_eq!(displayed, expected_str);
}
}
#[test]
fn status_serializes_to_the_documented_shape() {
let status = Status {
name: "x".to_string(),
state: State::Running,
pid: Some(4242),
};
let serialized = serde_json::to_string(&status).unwrap();
assert_eq!(serialized, r#"{"name":"x","state":"running","pid":4242}"#);
let deserialized: Status = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized, status);
let status_none_pid = Status {
name: "x".to_string(),
state: State::Starting,
pid: None,
};
let serialized_none_pid = serde_json::to_string(&status_none_pid).unwrap();
assert_eq!(
serialized_none_pid,
r#"{"name":"x","state":"starting","pid":null}"#
);
let deserialized_none_pid: Status = serde_json::from_str(&serialized_none_pid).unwrap();
assert_eq!(deserialized_none_pid, status_none_pid);
}
#[test]
fn a_spec_prints_its_image_as_a_table() {
let pinned = Spec {
image: ImageSpec {
name: Image::parse("ghcr.io/org/name:latest").unwrap(),
digest: Some(Digest::parse(&format!("sha256:{}", "a".repeat(64))).unwrap()),
},
resources: Resources {
cpus: Some(2),
..Resources::default()
},
..Spec::default()
};
let text = pinned.to_toml().unwrap();
assert!(
text.contains(&format!(
"[image]\nname = \"ghcr.io/org/name:latest\"\ndigest = \"sha256:{}\"\n",
"a".repeat(64)
)),
"{text}"
);
assert_eq!(toml::from_str::<Spec>(&text).unwrap(), pinned);
let unpinned = Spec {
image: Image::parse("ghcr.io/org/name:latest").unwrap().into(),
..pinned.clone()
};
let text = unpinned.to_toml().unwrap();
assert!(!text.contains("digest"), "{text}");
assert_eq!(toml::from_str::<Spec>(&text).unwrap(), unpinned);
assert!(toml::from_str::<Spec>("mounts = []\n").is_err());
let without_name = format!("[image]\ndigest = \"sha256:{}\"\n", "a".repeat(64));
assert!(toml::from_str::<Spec>(&without_name).is_err());
let mut listed = pinned;
listed.egress.allow = vec![Pattern::parse("api.github.com").unwrap()];
let text = listed.to_toml().unwrap();
assert!(
text.contains("[egress]\nallow = [\"api.github.com\"]\ndeny = []\n"),
"{text}"
);
assert_eq!(toml::from_str::<Spec>(&text).unwrap(), listed);
}
#[test]
fn satisfies_counts_the_digest_only_when_the_request_names_one() {
let request = Spec {
image: Image::parse("ghcr.io/org/name:latest").unwrap().into(),
resources: Resources {
cpus: Some(2),
..Resources::default()
},
..Spec::default()
};
let pinned_to = |hex: &str| Spec {
image: ImageSpec {
digest: Some(Digest::parse(&format!("sha256:{}", hex.repeat(64))).unwrap()),
..request.image.clone()
},
..request.clone()
};
let stored = pinned_to("a");
assert!(stored.satisfies(&request));
assert!(stored.satisfies(&pinned_to("a")));
assert!(!stored.satisfies(&pinned_to("b")));
let more_cpus = Spec {
resources: Resources {
cpus: Some(3),
..Resources::default()
},
..request.clone()
};
assert!(!stored.satisfies(&more_cpus));
let other_image = Spec {
image: Image::parse("ghcr.io/org/other:latest").unwrap().into(),
..request.clone()
};
assert!(!stored.satisfies(&other_image));
}
}