use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::flex;
#[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 SecretDef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(
default,
deserialize_with = "flex::bool",
skip_serializing_if = "std::ops::Not::not"
)]
#[schemars(with = "flex::BoolOrString")]
pub external: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub age: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub driver: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh: Option<String>,
}
impl SecretDef {
pub fn validate(&self) -> std::result::Result<(), String> {
let sources = [
self.file.is_some(),
self.environment.is_some(),
self.external,
self.age.is_some(),
self.driver.is_some(),
];
if sources.iter().filter(|s| **s).count() != 1 {
return Err(
"needs exactly one of file, environment, external, age, or driver (with name)"
.into(),
);
}
if self.name.is_some() && !self.external && self.driver.is_none() {
return Err("name goes with external or driver".into());
}
if self.driver.is_some() && self.name.as_deref().is_none_or(str::is_empty) {
return Err("driver needs name: the driver's reference to the secret".into());
}
if self.external {
if let Some(n) = &self.name {
crate::secrets::validate_name(n).map_err(|e| e.to_string())?;
}
}
if self.age.as_deref().is_some_and(|a| a.trim().is_empty()) {
return Err("age is empty".into());
}
if let Some(r) = &self.refresh {
if self.driver.is_none() {
return Err("refresh goes with driver".into());
}
let d = flex::parse_duration(r).map_err(|e| format!("refresh: {e}"))?;
if d < std::time::Duration::from_secs(10) {
return Err(format!("refresh {r:?}: at least 10s"));
}
}
Ok(())
}
pub fn store_name<'a>(&'a self, key: &'a str) -> Option<&'a str> {
self.external.then(|| self.name.as_deref().unwrap_or(key))
}
pub fn refresh_interval(&self) -> std::time::Duration {
self.refresh
.as_deref()
.and_then(|r| flex::parse_duration(r).ok())
.unwrap_or(DEFAULT_SECRET_REFRESH)
}
pub fn is_client_side(&self) -> bool {
self.file.is_some() || self.environment.is_some()
}
pub fn source_kind(&self) -> &'static str {
if self.file.is_some() {
"file"
} else if self.environment.is_some() {
"environment"
} else if self.external {
"external"
} else if self.age.is_some() {
"age"
} else if self.driver.is_some() {
"driver"
} else {
"none"
}
}
}
pub const DEFAULT_SECRET_REFRESH: std::time::Duration = std::time::Duration::from_secs(3600);
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Environment {
pub vars: BTreeMap<String, String>,
pub secrets: BTreeMap<String, String>,
}
impl Environment {
pub fn is_empty(&self) -> bool {
self.vars.is_empty() && self.secrets.is_empty()
}
}
impl std::ops::Deref for Environment {
type Target = BTreeMap<String, String>;
fn deref(&self) -> &Self::Target {
&self.vars
}
}
impl std::ops::DerefMut for Environment {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.vars
}
}
impl<'a> IntoIterator for &'a Environment {
type Item = (&'a String, &'a String);
type IntoIter = std::collections::btree_map::Iter<'a, String, String>;
fn into_iter(self) -> Self::IntoIter {
self.vars.iter()
}
}
impl From<BTreeMap<String, String>> for Environment {
fn from(vars: BTreeMap<String, String>) -> Self {
Environment {
vars,
secrets: BTreeMap::new(),
}
}
}
impl Serialize for Environment {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut m = s.serialize_map(None)?;
let mut keys: Vec<&String> = self.vars.keys().chain(self.secrets.keys()).collect();
keys.sort();
keys.dedup();
for k in keys {
match (self.vars.get(k), self.secrets.get(k)) {
(Some(v), _) => m.serialize_entry(k, v)?,
(None, Some(sec)) => {
m.serialize_entry(k, &BTreeMap::from([("secret", sec.as_str())]))?
}
(None, None) => {}
}
}
m.end()
}
}
impl<'de> Deserialize<'de> for Environment {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
let mut env = Environment::default();
match flex::EnvMapOrList::deserialize(d)? {
flex::EnvMapOrList::Map(m) => {
for (k, v) in m {
match v {
flex::EnvValue::Scalar(v) => {
env.vars.insert(k, v.into_string());
}
flex::EnvValue::Secret { secret } if secret.is_empty() => {
return Err(D::Error::custom(format!(
"environment {k}: secret needs a top-level secret's name"
)));
}
flex::EnvValue::Secret { secret } => {
env.secrets.insert(k, secret);
}
}
}
}
flex::EnvMapOrList::List(l) => {
for item in l {
let Some((k, v)) = item.split_once('=') else {
return Err(D::Error::custom(format!(
"environment entry {item:?} has no value: write {item}=VALUE"
)));
};
env.vars.insert(k.to_string(), v.to_string());
}
}
}
Ok(env)
}
}
#[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,
}
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 MountType {
#[default]
Bind,
Volume,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct VolumeSpec {
#[serde(rename = "type")]
pub mount_type: MountType,
pub source: String,
pub target: String,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub read_only: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub external: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub pool: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device: Option<String>,
#[serde(skip_serializing_if = "VolumeOptions::is_default")]
pub volume: VolumeOptions,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub options: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VolumeOptions {
#[serde(
default,
deserialize_with = "flex::bool",
skip_serializing_if = "std::ops::Not::not"
)]
#[schemars(with = "flex::BoolOrString")]
pub nocopy: bool,
}
impl VolumeOptions {
fn is_default(&self) -> bool {
*self == Self::default()
}
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[allow(dead_code)]
pub(crate) struct VolumeMount {
#[serde(default, rename = "type")]
mount_type: Option<MountType>,
source: String,
target: String,
#[serde(default, deserialize_with = "flex::bool")]
#[schemars(with = "flex::BoolOrString")]
read_only: bool,
#[serde(default, deserialize_with = "flex::bool")]
#[schemars(with = "flex::BoolOrString")]
external: bool,
#[serde(default)]
pool: Option<String>,
#[serde(default, deserialize_with = "flex::opt_string")]
#[schemars(with = "Option<flex::IntOrString>")]
owner: Option<String>,
#[serde(default)]
device: Option<String>,
#[serde(default)]
volume: VolumeOptions,
#[serde(default, deserialize_with = "flex::string_map")]
#[schemars(with = "BTreeMap<String, flex::Scalar>")]
options: BTreeMap<String, String>,
}
pub(crate) fn is_host_path(source: &str) -> bool {
source.starts_with('/') || source.starts_with('.') || source.starts_with('~')
}
impl From<VolumeMount> for VolumeSpec {
fn from(m: VolumeMount) -> Self {
let mount_type = m.mount_type.unwrap_or(if is_host_path(&m.source) {
MountType::Bind
} else {
MountType::Volume
});
VolumeSpec {
mount_type,
source: m.source,
target: m.target,
read_only: m.read_only,
external: m.external,
pool: m.pool,
owner: m.owner,
device: m.device,
volume: m.volume,
options: m.options,
}
}
}
impl<'de> Deserialize<'de> for VolumeSpec {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
match serde_json::Value::deserialize(d)? {
serde_json::Value::String(s) => {
crate::shorthand::volume(&s).map_err(|e| D::Error::custom(e.to_string()))
}
v @ serde_json::Value::Object(_) => serde_json::from_value::<VolumeMount>(v)
.map(Into::into)
.map_err(|e| D::Error::custom(format!("volume: {e}"))),
other => Err(D::Error::custom(format!(
"volume: expected SOURCE:TARGET[:OPTIONS] or {{type, source, target, ...}}, got {other}"
))),
}
}
}
impl JsonSchema for VolumeSpec {
fn schema_name() -> std::borrow::Cow<'static, str> {
"VolumeSpec".into()
}
fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
let long = g.subschema_for::<VolumeMount>();
schemars::json_schema!({
"description": "A mount: `SOURCE:TARGET[:OPTIONS]` or the long form.",
"oneOf": [
{
"type": "string",
"description": "SOURCE:TARGET[:OPTIONS]. OPTIONS is a comma list of ro, rw, owner=USER, device=NAME, pool=POOL, external."
},
long
]
})
}
}
#[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,
}
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)),
}
};
Ok(Some(HealthProbe {
argv,
interval: dur(&self.interval, 30)?,
timeout: dur(&self.timeout, 30)?,
retries: self.retries.unwrap_or(3).max(1),
start_period: dur(&self.start_period, 0)?,
start_interval: dur(&self.start_interval, 5)?,
}))
}
}
#[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>,
}
#[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>,
},
}
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}")
})? {
SecretRefRepr::Name(source) => Ok(SecretRef {
source,
..Default::default()
}),
SecretRefRepr::Long {
source,
target,
uid,
gid,
mode,
} => Ok(SecretRef {
source,
target,
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))
.collect()
}
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(),
}
}
}
pub struct Volume;
impl Volume {
pub fn bind(host_path: impl Into<String>) -> VolumeSpec {
VolumeSpec {
mount_type: MountType::Bind,
source: host_path.into(),
..Default::default()
}
}
pub fn named(name: impl Into<String>) -> VolumeSpec {
VolumeSpec {
mount_type: MountType::Volume,
source: name.into(),
..Default::default()
}
}
}
impl VolumeSpec {
pub fn external(mut self, external: bool) -> Self {
self.external = external;
self
}
pub fn read_only(mut self, ro: bool) -> Self {
self.read_only = ro;
self
}
pub fn owner(mut self, owner: impl Into<String>) -> Self {
self.owner = Some(owner.into());
self
}
pub fn device(mut self, name: impl Into<String>) -> Self {
self.device = Some(name.into());
self
}
pub fn pool(mut self, pool: impl Into<String>) -> Self {
self.pool = Some(pool.into());
self
}
pub fn nocopy(mut self, nocopy: bool) -> Self {
self.volume.nocopy = nocopy;
self
}
pub fn option(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
self.options.insert(k.into(), v.into());
self
}
}
pub struct PortBinding;
impl PortBinding {
pub fn host(listen: impl Into<String>, connect: impl Into<String>) -> PortSpec {
PortSpec {
bind: PortBind::Host,
listen: listen.into(),
connect: connect.into(),
..Default::default()
}
}
pub fn guest(listen: impl Into<String>, connect: impl Into<String>) -> PortSpec {
PortSpec {
bind: PortBind::Guest,
listen: listen.into(),
connect: connect.into(),
..Default::default()
}
}
}
impl PortSpec {
pub fn name(mut self, n: impl Into<String>) -> Self {
self.name = Some(n.into());
self
}
pub fn search(mut self, n: u16) -> Self {
self.search = Some(n);
self
}
}
impl SandboxSpec {
pub fn new(name: impl Into<String>, image: impl Into<String>) -> Self {
SandboxSpec {
name: Some(name.into()),
image: image.into(),
..Default::default()
}
}
pub fn cpus(mut self, cpus: impl ToString) -> Self {
self.cpus = Some(cpus.to_string());
self
}
pub fn cpuset(mut self, set: impl Into<String>) -> Self {
self.cpuset = Some(set.into());
self
}
pub fn memory(mut self, m: impl Into<String>) -> Self {
self.memory = Some(m.into());
self
}
pub fn storage(mut self, pool: impl Into<String>) -> Self {
self.storage = Some(pool.into());
self
}
pub fn idmap(mut self, idmap: IdmapSpec) -> Self {
self.idmap = Some(idmap);
self
}
pub fn privileged(mut self, p: bool) -> Self {
self.privileged = Some(p);
self
}
pub fn label(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
self.labels.insert(k.into(), v.into());
self
}
pub fn env(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
self.env.insert(k.into(), v.into());
self
}
pub fn egress(mut self, e: crate::egress::EgressSpec) -> Self {
self.egress = Some(e);
self
}
pub fn volume(mut self, guest_path: impl Into<String>, mut vol: VolumeSpec) -> Self {
vol.target = guest_path.into();
self.volumes.push(vol);
self
}
pub fn port(mut self, p: PortSpec) -> Self {
self.ports.push(p);
self
}
pub fn ready(mut self, checks: Vec<ReadyCheck>) -> Self {
self.ready = Some(checks);
self
}
pub fn ready_timeout(mut self, t: impl Into<String>) -> Self {
self.ready_timeout = Some(t.into());
self
}
pub fn user(mut self, u: impl Into<String>) -> Self {
self.user = Some(u.into());
self
}
pub fn working_dir(mut self, c: impl Into<String>) -> Self {
self.working_dir = Some(c.into());
self
}
pub fn raw_config(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
self.raw_config.insert(k.into(), v.into());
self
}
pub fn raw_device(mut self, name: impl Into<String>, props: BTreeMap<String, String>) -> Self {
self.raw_devices.insert(name.into(), props);
self
}
}
pub fn compose_schema() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(ComposeFile)).expect("schema serializes")
}
#[cfg(test)]
mod tests;