use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::flex;
mod secret;
pub use secret::{DEFAULT_SECRET_REFRESH, OnChange, SecretAs, SecretDef};
mod env;
pub use env::Environment;
mod mount;
pub(crate) use mount::is_host_path;
pub use mount::{MountType, VolumeOptions, VolumeSpec};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ComposeFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub incus_project: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub volumes: BTreeMap<String, NamedVolumeSpec>,
#[serde(default)]
pub services: BTreeMap<String, SandboxSpec>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub secrets: BTreeMap<String, SecretDef>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NamedVolumeSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pool: Option<String>,
#[serde(
default,
deserialize_with = "flex::string_map",
skip_serializing_if = "BTreeMap::is_empty"
)]
#[schemars(with = "BTreeMap<String, flex::Scalar>")]
pub config: BTreeMap<String, String>,
#[serde(
default,
deserialize_with = "flex::bool",
skip_serializing_if = "std::ops::Not::not"
)]
#[schemars(with = "flex::BoolOrString")]
pub external: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum InstanceType {
#[default]
Container,
#[serde(alias = "vm")]
VirtualMachine,
}
impl JsonSchema for InstanceType {
fn schema_name() -> std::borrow::Cow<'static, str> {
"InstanceType".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"description": "Instance type.",
"oneOf": [
{
"type": "string",
"const": "container",
"description": "A system container (lxc): shares the host kernel, near-zero overhead, idmapped bind mounts, proxies in both directions."
},
{
"type": "string",
"const": "virtual-machine",
"description": "A virtual machine (qemu): its own kernel. Needs a VM image and the incus agent in the guest for exec."
},
{
"type": "string",
"const": "vm",
"description": "Shorthand for virtual-machine."
}
]
})
}
}
impl InstanceType {
pub fn as_api(&self) -> &'static str {
match self {
InstanceType::Container => "container",
InstanceType::VirtualMachine => "virtual-machine",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SandboxSpec {
#[serde(
default,
rename = "container_name",
skip_serializing_if = "Option::is_none"
)]
pub name: Option<String>,
#[serde(default)]
pub image: String,
#[serde(default, rename = "type", skip_serializing_if = "is_default")]
pub instance_type: InstanceType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<String>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub cpus: Option<String>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub cpuset: Option<String>,
#[serde(
default,
rename = "mem_limit",
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub memory: Option<String>,
#[serde(
default,
deserialize_with = "flex::opt_bool",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::BoolOrString>")]
pub privileged: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idmap: Option<IdmapSpec>,
#[serde(
default,
rename = "incus_profiles",
skip_serializing_if = "Option::is_none"
)]
pub profiles: Option<Vec<String>>,
#[serde(
default,
deserialize_with = "flex::string_map_or_list",
skip_serializing_if = "BTreeMap::is_empty"
)]
#[schemars(with = "flex::MapOrList")]
pub labels: BTreeMap<String, String>,
#[serde(
default,
rename = "environment",
skip_serializing_if = "Environment::is_empty"
)]
#[schemars(with = "flex::EnvMapOrList")]
pub env: Environment,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub volumes: Vec<VolumeSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ports: Vec<PortSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ready: Option<Vec<ReadyCheck>>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub ready_timeout: Option<String>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(default, skip_serializing_if = "ExecSpec::is_empty")]
pub exec: ExecSpec,
#[serde(
default,
deserialize_with = "flex::opt_command",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::Command>")]
pub command: Option<Vec<String>>,
#[serde(
default,
deserialize_with = "flex::opt_command",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::Command>")]
pub entrypoint: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart: Option<RestartMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub healthcheck: Option<Healthcheck>,
#[serde(
default,
deserialize_with = "depends_on",
skip_serializing_if = "BTreeMap::is_empty"
)]
#[schemars(with = "DependsOnRepr")]
pub depends_on: BTreeMap<String, Dependency>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deploy: Option<Deploy>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub domains: Vec<DomainSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<SecretRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub egress: Option<crate::egress::EgressSpec>,
#[serde(
default,
deserialize_with = "flex::string_map",
skip_serializing_if = "BTreeMap::is_empty"
)]
#[schemars(with = "BTreeMap<String, flex::Scalar>")]
pub raw_config: BTreeMap<String, String>,
#[serde(
default,
deserialize_with = "flex::string_map_map",
skip_serializing_if = "BTreeMap::is_empty"
)]
#[schemars(with = "BTreeMap<String, BTreeMap<String, flex::Scalar>>")]
pub raw_devices: BTreeMap<String, BTreeMap<String, String>>,
#[serde(skip)]
pub workspace_nesting: bool,
#[serde(skip)]
pub stack_udp: bool,
}
impl SandboxSpec {
pub fn exec_defaults(&self) -> ExecDefaults {
ExecDefaults {
user: self.user.clone(),
cwd: self.working_dir.clone(),
env: self.exec.env.clone(),
login: self.exec.login,
}
}
}
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
*v == T::default()
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DomainSpec {
pub host: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(
default,
deserialize_with = "flex::opt_bool",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::BoolOrString>")]
pub https: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redirect: Option<String>,
#[serde(
default,
deserialize_with = "flex::bool",
skip_serializing_if = "std::ops::Not::not"
)]
#[schemars(with = "flex::BoolOrString")]
pub strip_prefix: bool,
#[serde(
default,
deserialize_with = "flex::bool",
skip_serializing_if = "std::ops::Not::not"
)]
#[schemars(with = "flex::BoolOrString")]
pub www_redirect: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum IdmapSpec {
Mode(IdmapMode),
Map(IdmapMap),
Raw(IdmapRaw),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum IdmapMode {
#[default]
Auto,
None,
Always,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IdmapMap {
#[serde(default)]
pub mode: IdmapMode,
#[serde(default = "default_id")]
pub host_uid: u32,
#[serde(default = "default_id")]
pub host_gid: u32,
#[serde(default = "default_id")]
pub guest_uid: u32,
#[serde(default = "default_id")]
pub guest_gid: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IdmapRaw {
pub raw: String,
}
fn default_id() -> u32 {
1000
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PortBind {
#[default]
Host,
Guest,
}
impl PortBind {
pub fn as_str(&self) -> &'static str {
match self {
PortBind::Host => "host",
PortBind::Guest => "guest",
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PortSpec {
pub name: Option<String>,
pub bind: PortBind,
pub listen: String,
pub connect: String,
pub search: Option<u16>,
pub options: BTreeMap<String, String>,
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct PortMapping {
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
#[schemars(with = "flex::IntOrString")]
target: String,
#[serde(deserialize_with = "flex::string", serialize_with = "port_number")]
#[schemars(with = "flex::IntOrString")]
published: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
host_ip: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
protocol: Option<String>,
#[serde(
default,
deserialize_with = "flex::string_map",
skip_serializing_if = "BTreeMap::is_empty"
)]
#[schemars(with = "BTreeMap<String, flex::Scalar>")]
options: BTreeMap<String, String>,
}
fn port_number<S: serde::Serializer>(p: &str, s: S) -> Result<S::Ok, S::Error> {
match p.parse::<u16>() {
Ok(n) => s.serialize_u16(n),
Err(_) => s.serialize_str(p),
}
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct ProxyPort {
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "is_default")]
bind: PortBind,
#[serde(deserialize_with = "flex::string")]
#[schemars(with = "flex::IntOrString")]
listen: String,
#[serde(deserialize_with = "flex::string")]
#[schemars(with = "flex::IntOrString")]
connect: String,
#[serde(
default,
deserialize_with = "flex::string_map",
skip_serializing_if = "BTreeMap::is_empty"
)]
#[schemars(with = "BTreeMap<String, flex::Scalar>")]
options: BTreeMap<String, String>,
}
impl PortMapping {
fn into_spec(self) -> crate::error::Result<PortSpec> {
let proto = self.protocol.as_deref().unwrap_or("tcp");
let mut p = crate::shorthand::docker_port(
self.host_ip.as_deref(),
&self.published,
&self.target,
proto,
)?;
p.name = self.name;
p.options = self.options;
Ok(p)
}
}
fn connect_port(connect: &str) -> Option<(&str, &str)> {
let (proto, rest) = match connect.split_once(':') {
Some((p @ ("tcp" | "udp"), rest)) => (p, rest),
_ => match connect.rsplit_once('/') {
Some((rest, p @ ("tcp" | "udp"))) => (p, rest),
_ => ("tcp", connect),
},
};
let port = match rest.rsplit_once(':') {
Some(("127.0.0.1" | "0.0.0.0", port)) => port,
Some(_) => return None,
None => rest,
};
port.parse::<u16>().ok().map(|_| (proto, port))
}
impl PortSpec {
fn as_mapping(&self) -> Option<PortMapping> {
if self.bind != PortBind::Host {
return None;
}
let listen = crate::plan::normalize_addr(&self.listen, "127.0.0.1").ok()?;
let (lproto, host, lport) = crate::plan::split_addr(&listen)?;
let (cproto, cport) = connect_port(&self.connect)?;
if lproto != cproto {
return None;
}
let published = match self.search.filter(|n| *n > 0) {
Some(n) => format!("{lport}-{}", lport.checked_add(n)?),
None => lport.to_string(),
};
Some(PortMapping {
name: self.name.clone(),
target: cport.to_string(),
published,
host_ip: (host != "127.0.0.1").then(|| host.trim_matches(['[', ']']).to_string()),
protocol: (lproto != "tcp").then(|| lproto.to_string()),
options: self.options.clone(),
})
}
}
impl Serialize for PortSpec {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
if let Some(m) = self.as_mapping() {
return m.serialize(s);
}
ProxyPort {
name: self.name.clone(),
bind: self.bind,
listen: self.listen.clone(),
connect: self.connect.clone(),
options: self.options.clone(),
}
.serialize(s)
}
}
impl<'de> Deserialize<'de> for PortSpec {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
let v = serde_json::Value::deserialize(d)?;
let custom = |e: String| D::Error::custom(format!("port: {e}"));
match v {
serde_json::Value::String(s) => {
crate::shorthand::docker_short_port(&s).map_err(|e| custom(e.to_string()))
}
serde_json::Value::Number(n) => crate::shorthand::docker_short_port(&n.to_string())
.map_err(|e| custom(e.to_string())),
serde_json::Value::Object(ref m) if m.contains_key("search") => Err(custom(
"search is not an isb key: publish a range instead, e.g. \"5173-5223:5173\" or published: 5173-5223".into(),
)),
serde_json::Value::Object(ref m)
if ["listen", "connect", "bind"].iter().any(|k| m.contains_key(*k)) =>
{
let r: ProxyPort = serde_json::from_value(v).map_err(|e| custom(e.to_string()))?;
Ok(PortSpec {
name: r.name,
bind: r.bind,
listen: r.listen,
connect: r.connect,
search: None,
options: r.options,
})
}
v @ serde_json::Value::Object(_) => serde_json::from_value::<PortMapping>(v)
.map_err(|e| custom(e.to_string()))?
.into_spec()
.map_err(|e| custom(e.to_string())),
other => Err(custom(format!(
"expected [HOST_IP:]PUBLISHED:TARGET[/PROTOCOL], {{target, published, ...}} or {{listen, connect, ...}}, got {other}"
))),
}
}
}
impl JsonSchema for PortSpec {
fn schema_name() -> std::borrow::Cow<'static, str> {
"PortSpec".into()
}
fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
let mapping = g.subschema_for::<PortMapping>();
let proxy = g.subschema_for::<ProxyPort>();
schemars::json_schema!({
"description": "A published port, docker style, or an incus proxy in either direction.",
"oneOf": [
{
"type": "string",
"description": "[HOST_IP:]PUBLISHED:TARGET[/PROTOCOL]. HOST_IP defaults to 127.0.0.1. PUBLISHED may be a range (5173-5223) to take the first free port."
},
mapping,
proxy
]
})
}
}
#[derive(Debug, Clone, PartialEq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReadyCheck {
Running,
Agent,
DefaultRoute,
UserExists(String),
PathWritable(String),
Command(Vec<String>),
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum ReadyRepr {
Name(String),
UserExists { user_exists: String },
PathWritable { path_writable: String },
Command { command: Vec<flex::Scalar> },
}
impl Serialize for ReadyCheck {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
ReadyCheck::Running => ReadyRepr::Name("running".into()),
ReadyCheck::DefaultRoute => ReadyRepr::Name("default_route".into()),
ReadyCheck::Agent => ReadyRepr::Name("agent".into()),
ReadyCheck::UserExists(u) => ReadyRepr::UserExists {
user_exists: u.clone(),
},
ReadyCheck::PathWritable(p) => ReadyRepr::PathWritable {
path_writable: p.clone(),
},
ReadyCheck::Command(c) => ReadyRepr::Command {
command: c.iter().cloned().map(flex::Scalar::String).collect(),
},
}
.serialize(s)
}
}
impl<'de> Deserialize<'de> for ReadyCheck {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
let r = ReadyRepr::deserialize(d).map_err(|_| {
D::Error::custom(
"expected running, agent, default_route, {user_exists: USER}, {path_writable: PATH} or {command: [ARGV...]}",
)
})?;
Ok(match r {
ReadyRepr::Name(n) => match n.as_str() {
"running" => ReadyCheck::Running,
"default_route" => ReadyCheck::DefaultRoute,
"agent" => ReadyCheck::Agent,
other => {
return Err(D::Error::custom(format!(
"unknown readiness check {other:?} (running, agent, default_route, user_exists, path_writable, command)"
)));
}
},
ReadyRepr::UserExists { user_exists } => ReadyCheck::UserExists(user_exists),
ReadyRepr::PathWritable { path_writable } => ReadyCheck::PathWritable(path_writable),
ReadyRepr::Command { command } => {
ReadyCheck::Command(command.into_iter().map(flex::Scalar::into_string).collect())
}
})
}
}
impl std::fmt::Display for ReadyCheck {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReadyCheck::Running => write!(f, "running"),
ReadyCheck::DefaultRoute => write!(f, "default_route"),
ReadyCheck::Agent => write!(f, "agent"),
ReadyCheck::UserExists(u) => write!(f, "user_exists({u})"),
ReadyCheck::PathWritable(p) => write!(f, "path_writable({p})"),
ReadyCheck::Command(c) => write!(f, "command({})", c.join(" ")),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExecSpec {
#[serde(
default,
deserialize_with = "flex::env_map_or_list",
skip_serializing_if = "BTreeMap::is_empty"
)]
#[schemars(with = "flex::MapOrList")]
pub env: BTreeMap<String, String>,
#[serde(
default,
deserialize_with = "flex::bool",
skip_serializing_if = "std::ops::Not::not"
)]
#[schemars(with = "flex::BoolOrString")]
pub login: bool,
}
impl ExecSpec {
pub fn is_empty(&self) -> bool {
self == &ExecSpec::default()
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ExecDefaults {
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(
default,
deserialize_with = "flex::string_map",
skip_serializing_if = "BTreeMap::is_empty"
)]
#[schemars(with = "BTreeMap<String, flex::Scalar>")]
pub env: BTreeMap<String, String>,
#[serde(
default,
deserialize_with = "flex::bool",
skip_serializing_if = "std::ops::Not::not"
)]
#[schemars(with = "flex::BoolOrString")]
pub login: bool,
}
impl ExecDefaults {
pub fn is_empty(&self) -> bool {
self == &ExecDefaults::default()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum RestartMode {
#[default]
No,
Always,
OnFailure,
UnlessStopped,
}
impl<'de> Deserialize<'de> for RestartMode {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
let s = flex::Scalar::deserialize(d)?.into_string();
Ok(match s.as_str() {
"no" | "false" | "" => RestartMode::No,
"always" => RestartMode::Always,
"on-failure" => RestartMode::OnFailure,
"unless-stopped" => RestartMode::UnlessStopped,
other => {
return Err(D::Error::custom(format!(
"unknown restart {other:?} (no, always, on-failure, unless-stopped)"
)));
}
})
}
}
impl RestartMode {
pub fn is_long_running(&self) -> bool {
*self != RestartMode::No
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Healthcheck {
#[serde(
default,
deserialize_with = "health_test",
skip_serializing_if = "Vec::is_empty"
)]
#[schemars(with = "Option<flex::Command>")]
pub test: Vec<String>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub interval: Option<String>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub timeout: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retries: Option<u32>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub start_period: Option<String>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub start_interval: Option<String>,
#[serde(
default,
deserialize_with = "flex::bool",
skip_serializing_if = "std::ops::Not::not"
)]
#[schemars(with = "flex::BoolOrString")]
pub disable: bool,
}
fn health_test<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
match flex::Command::deserialize(d)? {
flex::Command::String(s) => Ok(vec!["CMD-SHELL".into(), s]),
flex::Command::Argv(v) => Ok(v.into_iter().map(flex::Scalar::into_string).collect()),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct HealthProbe {
pub argv: Vec<String>,
pub interval: std::time::Duration,
pub timeout: std::time::Duration,
pub retries: u32,
pub start_period: std::time::Duration,
pub start_interval: std::time::Duration,
pub startup_grace: std::time::Duration,
}
impl HealthProbe {
pub fn default_grace(interval: std::time::Duration, retries: u32) -> std::time::Duration {
(interval * retries * 2).clamp(
std::time::Duration::from_secs(60),
std::time::Duration::from_secs(300),
)
}
pub fn failure_counts(&self, passed: bool, since_start: std::time::Duration) -> bool {
if passed {
since_start >= self.start_period
} else {
since_start >= self.startup_grace
}
}
}
impl Healthcheck {
pub fn probe(&self) -> Result<Option<HealthProbe>, String> {
if self.disable {
return Ok(None);
}
let argv = match self.test.split_first() {
None => return Err("healthcheck needs a test".into()),
Some((k, _)) if k == "NONE" => return Ok(None),
Some((k, rest)) if k == "CMD" => rest.to_vec(),
Some((k, rest)) if k == "CMD-SHELL" => {
if rest.len() != 1 {
return Err("CMD-SHELL takes exactly one shell line".into());
}
vec!["/bin/sh".into(), "-c".into(), rest[0].clone()]
}
Some((k, _)) => {
return Err(format!(
"healthcheck test must start with CMD, CMD-SHELL or NONE, not {k:?} (a plain string is a shell line)"
));
}
};
if argv.is_empty() {
return Err("healthcheck test has no command".into());
}
let dur = |v: &Option<String>, default: u64| -> Result<std::time::Duration, String> {
match v {
Some(s) => flex::parse_duration(s),
None => Ok(std::time::Duration::from_secs(default)),
}
};
let interval = dur(&self.interval, 30)?;
let retries = self.retries.unwrap_or(3).max(1);
let start_period = dur(&self.start_period, 0)?;
Ok(Some(HealthProbe {
argv,
interval,
timeout: dur(&self.timeout, 30)?,
retries,
start_period,
start_interval: dur(&self.start_interval, 5)?,
startup_grace: match &self.start_period {
Some(_) => start_period,
None => HealthProbe::default_grace(interval, retries),
},
}))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum DependCondition {
#[default]
ServiceStarted,
ServiceHealthy,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Dependency {
#[serde(default)]
pub condition: DependCondition,
}
#[derive(Deserialize, JsonSchema)]
#[serde(untagged)]
#[allow(dead_code)]
enum DependsOnRepr {
List(Vec<String>),
Map(BTreeMap<String, Dependency>),
}
fn depends_on<'de, D: serde::Deserializer<'de>>(
d: D,
) -> Result<BTreeMap<String, Dependency>, D::Error> {
Ok(match DependsOnRepr::deserialize(d)? {
DependsOnRepr::List(l) => l.into_iter().map(|s| (s, Dependency::default())).collect(),
DependsOnRepr::Map(m) => m,
})
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Deploy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replicas: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update_config: Option<UpdateConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rollback_config: Option<UpdateConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart_policy: Option<RestartPolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resources: Option<Resources>,
#[serde(
default,
deserialize_with = "flex::string_map_or_list",
skip_serializing_if = "BTreeMap::is_empty"
)]
#[schemars(with = "flex::MapOrList")]
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum UpdateOrder {
#[default]
StopFirst,
StartFirst,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum FailureAction {
#[default]
Pause,
Rollback,
Continue,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct UpdateConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parallelism: Option<u32>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub delay: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_action: Option<FailureAction>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub monitor: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<UpdateOrder>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum RestartCondition {
None,
OnFailure,
#[default]
Any,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RestartPolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub condition: Option<RestartCondition>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub delay: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_attempts: Option<u32>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub window: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Resources {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limits: Option<ResourceLimits>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ResourceLimits {
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub cpus: Option<String>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub memory: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SecretRef {
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gid: Option<u32>,
#[serde(
default,
deserialize_with = "flex::opt_string",
skip_serializing_if = "Option::is_none"
)]
#[schemars(with = "Option<flex::IntOrString>")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_change: Option<OnChange>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum SecretRefRepr {
Name(String),
Long {
source: String,
#[serde(default)]
target: Option<String>,
#[serde(default)]
uid: Option<flex::Scalar>,
#[serde(default)]
gid: Option<flex::Scalar>,
#[serde(default)]
mode: Option<flex::Scalar>,
#[serde(default)]
on_change: Option<OnChange>,
},
}
impl<'de> Deserialize<'de> for SecretRef {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
let id = |v: Option<flex::Scalar>, what: &str| -> Result<Option<u32>, D::Error> {
v.map(|s| {
let s = s.into_string();
s.trim().parse().map_err(|_| {
D::Error::custom(format!("secret {what} must be a number, got {s:?}"))
})
})
.transpose()
};
match SecretRefRepr::deserialize(d).map_err(|_| {
D::Error::custom(
"expected a secret name or {source, target, uid, gid, mode, on_change}",
)
})? {
SecretRefRepr::Name(source) => Ok(SecretRef {
source,
..Default::default()
}),
SecretRefRepr::Long {
source,
target,
uid,
gid,
mode,
on_change,
} => Ok(SecretRef {
source,
target,
on_change,
uid: id(uid, "uid")?,
gid: id(gid, "gid")?,
mode: mode.map(flex::Scalar::into_string),
}),
}
}
}
impl SecretRef {
pub fn guest_path(&self) -> String {
let t = self.target.as_deref().unwrap_or(&self.source);
if t.starts_with('/') {
t.to_string()
} else {
format!("/run/secrets/{t}")
}
}
pub fn file_mode(&self) -> Result<u32, String> {
match &self.mode {
None => Ok(0o400),
Some(m) => u32::from_str_radix(m.trim().trim_start_matches("0o"), 8)
.ok()
.filter(|m| *m <= 0o7777)
.ok_or_else(|| format!("secret mode {m:?} is not an octal mode like 0400")),
}
}
}
impl SandboxSpec {
pub fn secret_keys(&self) -> std::collections::BTreeSet<&str> {
self.secrets
.iter()
.map(|r| r.source.as_str())
.chain(self.env.secrets.values().map(String::as_str))
.chain(self.env.files.values().map(String::as_str))
.collect()
}
pub fn has_secret_files(&self) -> bool {
!self.secrets.is_empty() || !self.env.files.is_empty()
}
pub fn secret_on_change(&self, key: &str) -> Option<OnChange> {
self.secrets
.iter()
.filter(|r| r.source == key)
.filter_map(|r| r.on_change)
.chain(
self.env
.secrets
.iter()
.chain(&self.env.files)
.filter(|(_, k)| *k == key)
.filter_map(|(var, _)| self.env.on_change.get(var).copied()),
)
.max()
}
pub fn long_running(&self) -> bool {
self.restart.is_some_and(|r| r.is_long_running())
}
pub fn replicas(&self) -> u32 {
self.deploy.as_ref().and_then(|d| d.replicas).unwrap_or(1)
}
pub fn health_probe(&self) -> Result<Option<HealthProbe>, String> {
match &self.healthcheck {
None => Ok(None),
Some(h) => h.probe(),
}
}
}
mod builder;
pub use builder::{PortBinding, Volume};
pub fn compose_schema() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(ComposeFile)).expect("schema serializes")
}
#[cfg(test)]
mod tests;