mod duration {
use humantime::format_duration;
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
#[allow(clippy::ref_option)]
pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match duration {
Some(d) => serializer.serialize_str(&format_duration(*d).to_string()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
let s: Option<String> = Option::deserialize(deserializer)?;
match s {
Some(s) => humantime::parse_duration(&s)
.map(Some)
.map_err(|e| D::Error::custom(format!("invalid duration: {e}"))),
None => Ok(None),
}
}
pub mod option {
pub use super::*;
}
pub mod required {
use humantime::format_duration;
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&format_duration(*duration).to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
let s: String = String::deserialize(deserializer)?;
humantime::parse_duration(&s)
.map_err(|e| D::Error::custom(format!("invalid duration: {e}")))
}
}
}
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use validator::Validate;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NodeMode {
#[default]
Shared,
Dedicated,
Exclusive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServiceType {
#[default]
Standard,
WasmHttp,
WasmPlugin,
WasmTransformer,
WasmAuthenticator,
WasmRateLimiter,
WasmMiddleware,
WasmRouter,
Job,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum StorageTier {
#[default]
Local,
Cached,
Network,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NodeSelector {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub prefer_labels: HashMap<String, String>,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum OsKind {
Linux,
Windows,
Macos,
}
impl OsKind {
#[must_use]
pub const fn as_oci_str(self) -> &'static str {
match self {
OsKind::Linux => "linux",
OsKind::Windows => "windows",
OsKind::Macos => "darwin",
}
}
#[must_use]
pub fn from_rust_os(s: &str) -> Option<Self> {
match s {
"linux" => Some(Self::Linux),
"windows" => Some(Self::Windows),
"macos" => Some(Self::Macos),
_ => None,
}
}
#[must_use]
pub fn from_oci_str(s: &str) -> Option<Self> {
match s {
"linux" => Some(Self::Linux),
"windows" => Some(Self::Windows),
"darwin" => Some(Self::Macos),
_ => None,
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum ArchKind {
Amd64,
Arm64,
}
impl ArchKind {
#[must_use]
pub const fn as_oci_str(self) -> &'static str {
match self {
ArchKind::Amd64 => "amd64",
ArchKind::Arm64 => "arm64",
}
}
#[must_use]
pub fn from_rust_arch(s: &str) -> Option<Self> {
match s {
"x86_64" => Some(Self::Amd64),
"aarch64" => Some(Self::Arm64),
_ => None,
}
}
}
#[derive(
Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
)]
pub struct TargetPlatform {
pub os: OsKind,
pub arch: ArchKind,
#[serde(default, rename = "osVersion", skip_serializing_if = "Option::is_none")]
pub os_version: Option<String>,
}
impl TargetPlatform {
#[must_use]
pub const fn new(os: OsKind, arch: ArchKind) -> Self {
Self {
os,
arch,
os_version: None,
}
}
#[must_use]
pub fn with_os_version(mut self, v: impl Into<String>) -> Self {
self.os_version = Some(v.into());
self
}
#[must_use]
pub fn as_oci_str(self) -> String {
format!("{}/{}", self.os.as_oci_str(), self.arch.as_oci_str())
}
#[must_use]
pub fn as_detailed_str(&self) -> String {
match &self.os_version {
Some(v) => format!(
"{}/{} (os.version={v})",
self.os.as_oci_str(),
self.arch.as_oci_str()
),
None => format!("{}/{}", self.os.as_oci_str(), self.arch.as_oci_str()),
}
}
}
impl std::fmt::Display for TargetPlatform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.os.as_oci_str(), self.arch.as_oci_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[allow(clippy::struct_excessive_bools)]
pub struct WasmCapabilities {
#[serde(default = "default_true")]
pub config: bool,
#[serde(default = "default_true")]
pub keyvalue: bool,
#[serde(default = "default_true")]
pub logging: bool,
#[serde(default)]
pub secrets: bool,
#[serde(default = "default_true")]
pub metrics: bool,
#[serde(default)]
pub http_client: bool,
#[serde(default)]
pub cli: bool,
#[serde(default)]
pub filesystem: bool,
#[serde(default)]
pub sockets: bool,
}
impl Default for WasmCapabilities {
fn default() -> Self {
Self {
config: true,
keyvalue: true,
logging: true,
secrets: false,
metrics: true,
http_client: false,
cli: false,
filesystem: false,
sockets: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WasmPreopen {
pub source: String,
pub target: String,
#[serde(default)]
pub readonly: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
#[allow(clippy::struct_excessive_bools)]
pub struct WasmConfig {
#[serde(default = "default_min_instances")]
pub min_instances: u32,
#[serde(default = "default_max_instances")]
pub max_instances: u32,
#[serde(default = "default_idle_timeout", with = "duration::required")]
pub idle_timeout: std::time::Duration,
#[serde(default = "default_request_timeout", with = "duration::required")]
pub request_timeout: std::time::Duration,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_memory: Option<String>,
#[serde(default)]
pub max_fuel: u64,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "duration::option"
)]
pub epoch_interval: Option<std::time::Duration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option<WasmCapabilities>,
#[serde(default = "default_true")]
pub allow_http_outgoing: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_hosts: Vec<String>,
#[serde(default)]
pub allow_tcp: bool,
#[serde(default)]
pub allow_udp: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub preopens: Vec<WasmPreopen>,
#[serde(default = "default_true")]
pub kv_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kv_namespace: Option<String>,
#[serde(default = "default_kv_max_value_size")]
pub kv_max_value_size: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<String>,
#[serde(default = "default_true")]
pub precompile: bool,
}
fn default_kv_max_value_size() -> u64 {
1_048_576 }
impl Default for WasmConfig {
fn default() -> Self {
Self {
min_instances: default_min_instances(),
max_instances: default_max_instances(),
idle_timeout: default_idle_timeout(),
request_timeout: default_request_timeout(),
max_memory: None,
max_fuel: 0,
epoch_interval: None,
capabilities: None,
allow_http_outgoing: true,
allowed_hosts: Vec::new(),
allow_tcp: false,
allow_udp: false,
preopens: Vec::new(),
kv_enabled: true,
kv_namespace: None,
kv_max_value_size: default_kv_max_value_size(),
secrets: Vec::new(),
precompile: true,
}
}
}
#[deprecated(note = "Use WasmConfig instead")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WasmHttpConfig {
#[serde(default = "default_min_instances")]
pub min_instances: u32,
#[serde(default = "default_max_instances")]
pub max_instances: u32,
#[serde(default = "default_idle_timeout", with = "duration::required")]
pub idle_timeout: std::time::Duration,
#[serde(default = "default_request_timeout", with = "duration::required")]
pub request_timeout: std::time::Duration,
}
fn default_min_instances() -> u32 {
0
}
fn default_max_instances() -> u32 {
10
}
fn default_idle_timeout() -> std::time::Duration {
std::time::Duration::from_secs(300)
}
fn default_request_timeout() -> std::time::Duration {
std::time::Duration::from_secs(30)
}
#[allow(deprecated)]
impl Default for WasmHttpConfig {
fn default() -> Self {
Self {
min_instances: default_min_instances(),
max_instances: default_max_instances(),
idle_timeout: default_idle_timeout(),
request_timeout: default_request_timeout(),
}
}
}
#[allow(deprecated)]
impl From<WasmHttpConfig> for WasmConfig {
fn from(old: WasmHttpConfig) -> Self {
Self {
min_instances: old.min_instances,
max_instances: old.max_instances,
idle_timeout: old.idle_timeout,
request_timeout: old.request_timeout,
..Default::default()
}
}
}
impl ServiceType {
#[must_use]
pub fn is_wasm(&self) -> bool {
matches!(
self,
ServiceType::WasmHttp
| ServiceType::WasmPlugin
| ServiceType::WasmTransformer
| ServiceType::WasmAuthenticator
| ServiceType::WasmRateLimiter
| ServiceType::WasmMiddleware
| ServiceType::WasmRouter
)
}
#[must_use]
pub fn default_wasm_capabilities(&self) -> Option<WasmCapabilities> {
match self {
ServiceType::WasmHttp | ServiceType::WasmRouter => Some(WasmCapabilities {
config: true,
keyvalue: true,
logging: true,
secrets: false,
metrics: false,
http_client: true,
cli: false,
filesystem: false,
sockets: false,
}),
ServiceType::WasmPlugin => Some(WasmCapabilities {
config: true,
keyvalue: true,
logging: true,
secrets: true,
metrics: true,
http_client: true,
cli: true,
filesystem: true,
sockets: false,
}),
ServiceType::WasmTransformer => Some(WasmCapabilities {
config: false,
keyvalue: false,
logging: true,
secrets: false,
metrics: false,
http_client: false,
cli: true,
filesystem: false,
sockets: false,
}),
ServiceType::WasmAuthenticator => Some(WasmCapabilities {
config: true,
keyvalue: false,
logging: true,
secrets: true,
metrics: false,
http_client: true,
cli: false,
filesystem: false,
sockets: false,
}),
ServiceType::WasmRateLimiter => Some(WasmCapabilities {
config: true,
keyvalue: true,
logging: true,
secrets: false,
metrics: true,
http_client: false,
cli: true,
filesystem: false,
sockets: false,
}),
ServiceType::WasmMiddleware => Some(WasmCapabilities {
config: true,
keyvalue: false,
logging: true,
secrets: false,
metrics: false,
http_client: true,
cli: false,
filesystem: false,
sockets: false,
}),
_ => None,
}
}
}
fn default_api_bind() -> String {
"0.0.0.0:3669".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ApiSpec {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_api_bind")]
pub bind: String,
#[serde(default)]
pub jwt_secret: Option<String>,
#[serde(default = "default_true")]
pub swagger: bool,
}
impl Default for ApiSpec {
fn default() -> Self {
Self {
enabled: true,
bind: default_api_bind(),
jwt_secret: None,
swagger: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
#[serde(deny_unknown_fields)]
pub struct DeploymentSpec {
#[validate(custom(function = "crate::spec::validate::validate_version_wrapper"))]
pub version: String,
#[validate(custom(function = "crate::spec::validate::validate_deployment_name_wrapper"))]
pub deployment: String,
#[serde(default)]
#[validate(nested)]
pub services: HashMap<String, ServiceSpec>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
#[validate(nested)]
pub externals: HashMap<String, ExternalSpec>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub tunnels: HashMap<String, TunnelDefinition>,
#[serde(default)]
pub api: ApiSpec,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
#[serde(deny_unknown_fields)]
pub struct ExternalSpec {
#[validate(length(min = 1, message = "at least one backend address is required"))]
pub backends: Vec<String>,
#[serde(default)]
#[validate(nested)]
pub endpoints: Vec<EndpointSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health: Option<HealthSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TunnelDefinition {
pub from: String,
pub to: String,
pub local_port: u16,
pub remote_port: u16,
#[serde(default)]
pub protocol: TunnelProtocol,
#[serde(default)]
pub expose: ExposeType,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum TunnelProtocol {
#[default]
Tcp,
Udp,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LogsConfig {
#[serde(default = "default_logs_destination")]
pub destination: String,
#[serde(default = "default_logs_max_size")]
pub max_size_bytes: u64,
#[serde(default = "default_logs_retention")]
pub retention_secs: u64,
}
fn default_logs_destination() -> String {
"disk".to_string()
}
fn default_logs_max_size() -> u64 {
100 * 1024 * 1024 }
fn default_logs_retention() -> u64 {
7 * 24 * 60 * 60 }
impl Default for LogsConfig {
fn default() -> Self {
Self {
destination: default_logs_destination(),
max_size_bytes: default_logs_max_size(),
retention_secs: default_logs_retention(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum NetworkMode {
#[default]
Default,
Host,
None,
Bridge {
#[serde(default)]
name: Option<String>,
},
Container { id: String },
}
fn deserialize_network_mode<'de, D>(deserializer: D) -> Result<NetworkMode, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
enum Inner {
Default,
Host,
None,
Bridge {
#[serde(default)]
name: Option<String>,
},
Container {
id: String,
},
}
impl From<Inner> for NetworkMode {
fn from(i: Inner) -> Self {
match i {
Inner::Default => Self::Default,
Inner::Host => Self::Host,
Inner::None => Self::None,
Inner::Bridge { name } => Self::Bridge { name },
Inner::Container { id } => Self::Container { id },
}
}
}
let value = serde_yaml::Value::deserialize(deserializer)?;
if let Some(s) = value.as_str() {
return match s {
"default" => Ok(NetworkMode::Default),
"host" => Ok(NetworkMode::Host),
"none" => Ok(NetworkMode::None),
"bridge" => Ok(NetworkMode::Bridge { name: None }),
_ => {
if let Some(rest) = s.strip_prefix("bridge:") {
if rest.is_empty() {
Ok(NetworkMode::Bridge { name: None })
} else {
Ok(NetworkMode::Bridge {
name: Some(rest.to_string()),
})
}
} else if let Some(rest) = s.strip_prefix("container:") {
if rest.is_empty() {
Err(D::Error::custom(
"network mode \"container:<id>\" requires a non-empty id",
))
} else {
Ok(NetworkMode::Container {
id: rest.to_string(),
})
}
} else {
Err(D::Error::custom(format!("unknown network mode: {s}")))
}
}
};
}
let inner: Inner = serde_yaml::from_value(value).map_err(D::Error::custom)?;
Ok(NetworkMode::from(inner))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct UlimitSpec {
#[serde(default)]
pub soft: i64,
#[serde(default)]
pub hard: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate)]
#[serde(from = "ServiceSpecCompat")]
#[allow(clippy::struct_excessive_bools)]
pub struct ServiceSpec {
#[serde(default = "default_resource_type")]
pub rtype: ResourceType,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[validate(custom(function = "crate::spec::validate::validate_schedule_wrapper"))]
pub schedule: Option<String>,
#[validate(nested)]
pub image: ImageSpec,
#[serde(default)]
#[validate(nested)]
pub resources: ResourcesSpec,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub command: CommandSpec,
#[serde(default)]
pub network: ServiceNetworkSpec,
#[serde(default)]
#[validate(nested)]
pub endpoints: Vec<EndpointSpec>,
#[serde(default)]
#[validate(custom(function = "crate::spec::validate::validate_scale_spec"))]
pub scale: ScaleSpec,
#[serde(default)]
pub depends: Vec<DependsSpec>,
#[serde(default = "default_health")]
pub health: HealthSpec,
#[serde(default)]
pub init: InitSpec,
#[serde(default)]
pub errors: ErrorsSpec,
#[serde(default)]
pub lifecycle: LifecycleSpec,
#[serde(default)]
pub devices: Vec<DeviceSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub storage: Vec<StorageSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub port_mappings: Vec<PortMapping>,
#[serde(default, alias = "cap_add", skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cap_drop: Vec<String>,
#[serde(default)]
pub privileged: bool,
#[serde(default)]
pub node_mode: NodeMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_selector: Option<NodeSelector>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<TargetPlatform>,
#[serde(default)]
pub service_type: ServiceType,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "wasm_http")]
pub wasm: Option<WasmConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logs: Option<LogsConfig>,
#[serde(skip)]
pub host_network: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dns: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_hosts: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart_policy: Option<ContainerRestartPolicy>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_signal: Option<String>,
#[serde(
default,
with = "duration::option",
skip_serializing_if = "Option::is_none"
)]
pub stop_grace_period: Option<std::time::Duration>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub sysctls: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub ulimits: HashMap<String, UlimitSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub security_opt: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ipc_mode: Option<String>,
#[serde(default, deserialize_with = "deserialize_network_mode")]
pub network_mode: NetworkMode,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_groups: Vec<String>,
#[serde(default)]
pub read_only_root_fs: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub init_container: Option<bool>,
#[serde(default)]
pub tty: bool,
#[serde(default)]
pub stdin_open: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub userns_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cgroup_parent: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub expose: Vec<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
#[allow(clippy::struct_excessive_bools)]
struct ServiceSpecCompat {
#[serde(default = "default_resource_type")]
rtype: ResourceType,
#[serde(default)]
schedule: Option<String>,
image: ImageSpec,
#[serde(default)]
resources: ResourcesSpec,
#[serde(default)]
env: HashMap<String, String>,
#[serde(default)]
command: CommandSpec,
#[serde(default)]
network: ServiceNetworkSpec,
#[serde(default)]
endpoints: Vec<EndpointSpec>,
#[serde(default)]
scale: ScaleSpec,
#[serde(default)]
depends: Vec<DependsSpec>,
#[serde(default = "default_health")]
health: HealthSpec,
#[serde(default)]
init: InitSpec,
#[serde(default)]
errors: ErrorsSpec,
#[serde(default)]
lifecycle: LifecycleSpec,
#[serde(default)]
devices: Vec<DeviceSpec>,
#[serde(default)]
storage: Vec<StorageSpec>,
#[serde(default)]
port_mappings: Vec<PortMapping>,
#[serde(default, alias = "cap_add")]
capabilities: Vec<String>,
#[serde(default)]
cap_drop: Vec<String>,
#[serde(default)]
privileged: bool,
#[serde(default)]
node_mode: NodeMode,
#[serde(default)]
node_selector: Option<NodeSelector>,
#[serde(default)]
platform: Option<TargetPlatform>,
#[serde(default)]
service_type: ServiceType,
#[serde(default, alias = "wasm_http")]
wasm: Option<WasmConfig>,
#[serde(default)]
logs: Option<LogsConfig>,
#[serde(default)]
host_network: Option<bool>,
#[serde(default)]
hostname: Option<String>,
#[serde(default)]
dns: Vec<String>,
#[serde(default)]
extra_hosts: Vec<String>,
#[serde(default)]
restart_policy: Option<ContainerRestartPolicy>,
#[serde(default)]
labels: HashMap<String, String>,
#[serde(default)]
user: Option<String>,
#[serde(default)]
stop_signal: Option<String>,
#[serde(default, with = "duration::option")]
stop_grace_period: Option<std::time::Duration>,
#[serde(default)]
sysctls: HashMap<String, String>,
#[serde(default)]
ulimits: HashMap<String, UlimitSpec>,
#[serde(default)]
security_opt: Vec<String>,
#[serde(default)]
pid_mode: Option<String>,
#[serde(default)]
ipc_mode: Option<String>,
#[serde(default, deserialize_with = "deserialize_network_mode")]
network_mode: NetworkMode,
#[serde(default)]
extra_groups: Vec<String>,
#[serde(default)]
read_only_root_fs: bool,
#[serde(default)]
init_container: Option<bool>,
#[serde(default)]
tty: bool,
#[serde(default)]
stdin_open: bool,
#[serde(default)]
userns_mode: Option<String>,
#[serde(default)]
cgroup_parent: Option<String>,
#[serde(default)]
expose: Vec<String>,
}
impl From<ServiceSpecCompat> for ServiceSpec {
fn from(c: ServiceSpecCompat) -> Self {
let network_mode = match (c.host_network, &c.network_mode) {
(Some(true), NetworkMode::Default) => NetworkMode::Host,
_ => c.network_mode,
};
let host_network = c.host_network.unwrap_or(false) || network_mode == NetworkMode::Host;
Self {
rtype: c.rtype,
schedule: c.schedule,
image: c.image,
resources: c.resources,
env: c.env,
command: c.command,
network: c.network,
endpoints: c.endpoints,
scale: c.scale,
depends: c.depends,
health: c.health,
init: c.init,
errors: c.errors,
lifecycle: c.lifecycle,
devices: c.devices,
storage: c.storage,
port_mappings: c.port_mappings,
capabilities: c.capabilities,
cap_drop: c.cap_drop,
privileged: c.privileged,
node_mode: c.node_mode,
node_selector: c.node_selector,
platform: c.platform,
service_type: c.service_type,
wasm: c.wasm,
logs: c.logs,
host_network,
hostname: c.hostname,
dns: c.dns,
extra_hosts: c.extra_hosts,
restart_policy: c.restart_policy,
labels: c.labels,
user: c.user,
stop_signal: c.stop_signal,
stop_grace_period: c.stop_grace_period,
sysctls: c.sysctls,
ulimits: c.ulimits,
security_opt: c.security_opt,
pid_mode: c.pid_mode,
ipc_mode: c.ipc_mode,
network_mode,
extra_groups: c.extra_groups,
read_only_root_fs: c.read_only_root_fs,
init_container: c.init_container,
tty: c.tty,
stdin_open: c.stdin_open,
userns_mode: c.userns_mode,
cgroup_parent: c.cgroup_parent,
expose: c.expose,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct CommandSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entrypoint: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workdir: Option<String>,
}
fn default_resource_type() -> ResourceType {
ResourceType::Service
}
fn default_health() -> HealthSpec {
HealthSpec {
start_grace: Some(std::time::Duration::from_secs(5)),
interval: None,
timeout: None,
retries: 3,
check: HealthCheck::Tcp { port: 0 },
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ResourceType {
Service,
Job,
Cron,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
#[serde(deny_unknown_fields)]
pub struct ImageSpec {
#[serde(with = "crate::image_ref_serde")]
pub name: crate::ImageReference,
#[serde(default = "default_pull_policy")]
pub pull_policy: PullPolicy,
}
fn default_pull_policy() -> PullPolicy {
PullPolicy::IfNotPresent
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PullPolicy {
Always,
Newer,
IfNotPresent,
Never,
}
#[must_use]
pub fn effective_pull_policy(image: &crate::ImageReference, spec_policy: PullPolicy) -> PullPolicy {
match spec_policy {
PullPolicy::Always | PullPolicy::Never | PullPolicy::Newer => spec_policy,
PullPolicy::IfNotPresent => {
if image_is_latest_or_untagged(image) {
PullPolicy::Newer
} else {
PullPolicy::IfNotPresent
}
}
}
}
fn image_is_latest_or_untagged(image: &crate::ImageReference) -> bool {
match image.tag() {
None => true,
Some(tag) => tag == "latest",
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct DeviceSpec {
#[validate(length(min = 1, message = "device path cannot be empty"))]
pub path: String,
#[serde(default = "default_true")]
pub read: bool,
#[serde(default = "default_true")]
pub write: bool,
#[serde(default)]
pub mknod: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
pub enum StorageSpec {
Bind {
source: String,
target: String,
#[serde(default)]
readonly: bool,
},
Named {
name: String,
target: String,
#[serde(default)]
readonly: bool,
#[serde(default)]
tier: StorageTier,
#[serde(default, skip_serializing_if = "Option::is_none")]
size: Option<String>,
},
Anonymous {
target: String,
#[serde(default)]
tier: StorageTier,
},
Tmpfs {
target: String,
#[serde(default)]
size: Option<String>,
#[serde(default)]
mode: Option<u32>,
},
S3 {
bucket: String,
#[serde(default)]
prefix: Option<String>,
target: String,
#[serde(default)]
readonly: bool,
#[serde(default)]
endpoint: Option<String>,
#[serde(default)]
credentials: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, Validate)]
#[serde(deny_unknown_fields)]
pub struct ResourcesSpec {
#[serde(default)]
#[validate(custom(function = "crate::spec::validate::validate_cpu_option_wrapper"))]
pub cpu: Option<f64>,
#[serde(default)]
#[validate(custom(function = "crate::spec::validate::validate_memory_option_wrapper"))]
pub memory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu: Option<GpuSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pids_limit: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpuset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_shares: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_swap: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_reservation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_swappiness: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oom_score_adj: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oom_kill_disable: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blkio_weight: Option<u16>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum SchedulingPolicy {
#[default]
BestEffort,
Gang,
Spread,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum GpuSharingMode {
#[default]
Exclusive,
Mps,
TimeSlice,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
#[serde(deny_unknown_fields)]
pub struct DistributedConfig {
#[serde(default = "default_dist_backend")]
pub backend: String,
#[serde(default = "default_dist_port")]
pub master_port: u16,
}
fn default_dist_backend() -> String {
"nccl".to_string()
}
fn default_dist_port() -> u16 {
29500
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
#[serde(deny_unknown_fields)]
pub struct GpuSpec {
#[serde(default = "default_gpu_count")]
pub count: u32,
#[serde(default = "default_gpu_vendor")]
pub vendor: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scheduling: Option<SchedulingPolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub distributed: Option<DistributedConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sharing: Option<GpuSharingMode>,
}
fn default_gpu_count() -> u32 {
1
}
fn default_gpu_vendor() -> String {
"nvidia".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[derive(Default)]
pub struct ServiceNetworkSpec {
#[serde(default)]
pub overlays: OverlayConfig,
#[serde(default)]
pub join: JoinPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct OverlayConfig {
#[serde(default)]
pub service: OverlaySettings,
#[serde(default)]
pub global: OverlaySettings,
}
impl Default for OverlayConfig {
fn default() -> Self {
Self {
service: OverlaySettings {
enabled: true,
encrypted: true,
isolated: true,
},
global: OverlaySettings {
enabled: true,
encrypted: true,
isolated: false,
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct OverlaySettings {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_encrypted")]
pub encrypted: bool,
#[serde(default)]
pub isolated: bool,
}
fn default_enabled() -> bool {
true
}
fn default_encrypted() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct JoinPolicy {
#[serde(default = "default_join_mode")]
pub mode: JoinMode,
#[serde(default = "default_join_scope")]
pub scope: JoinScope,
}
impl Default for JoinPolicy {
fn default() -> Self {
Self {
mode: default_join_mode(),
scope: default_join_scope(),
}
}
}
fn default_join_mode() -> JoinMode {
JoinMode::Token
}
fn default_join_scope() -> JoinScope {
JoinScope::Service
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum JoinMode {
Open,
Token,
Closed,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum JoinScope {
Service,
Global,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
#[serde(deny_unknown_fields)]
pub struct EndpointSpec {
#[validate(length(min = 1, message = "endpoint name cannot be empty"))]
pub name: String,
pub protocol: Protocol,
#[validate(custom(function = "crate::spec::validate::validate_port_wrapper"))]
pub port: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_port: Option<u16>,
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(default = "default_expose")]
pub expose: ExposeType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream: Option<StreamEndpointConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tunnel: Option<EndpointTunnelConfig>,
}
impl EndpointSpec {
#[must_use]
pub fn target_port(&self) -> u16 {
self.target_port.unwrap_or(self.port)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct EndpointTunnelConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
#[serde(default)]
pub remote_port: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expose: Option<ExposeType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access: Option<TunnelAccessConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct TunnelAccessConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_ttl: Option<String>,
#[serde(default)]
pub audit: bool,
}
fn default_expose() -> ExposeType {
ExposeType::Internal
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
Http,
Https,
Tcp,
Udp,
Websocket,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ExposeType {
Public,
#[default]
Internal,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
pub struct StreamEndpointConfig {
#[serde(default)]
pub tls: bool,
#[serde(default)]
pub proxy_protocol: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_timeout: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_check: Option<StreamHealthCheck>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum StreamHealthCheck {
TcpConnect,
UdpProbe {
request: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
expect: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "mode", rename_all = "lowercase", deny_unknown_fields)]
pub enum ScaleSpec {
#[serde(rename = "adaptive")]
Adaptive {
min: u32,
max: u32,
#[serde(default, with = "duration::option")]
cooldown: Option<std::time::Duration>,
#[serde(default)]
targets: ScaleTargets,
},
#[serde(rename = "fixed")]
Fixed { replicas: u32 },
#[serde(rename = "manual")]
Manual,
}
impl Default for ScaleSpec {
fn default() -> Self {
Self::Adaptive {
min: 1,
max: 10,
cooldown: Some(std::time::Duration::from_secs(30)),
targets: ScaleTargets::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[derive(Default)]
pub struct ScaleTargets {
#[serde(default)]
pub cpu: Option<u8>,
#[serde(default)]
pub memory: Option<u8>,
#[serde(default)]
pub rps: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DependsSpec {
pub service: String,
#[serde(default = "default_condition")]
pub condition: DependencyCondition,
#[serde(default = "default_timeout", with = "duration::option")]
pub timeout: Option<std::time::Duration>,
#[serde(default = "default_on_timeout")]
pub on_timeout: TimeoutAction,
}
fn default_condition() -> DependencyCondition {
DependencyCondition::Healthy
}
#[allow(clippy::unnecessary_wraps)]
fn default_timeout() -> Option<std::time::Duration> {
Some(std::time::Duration::from_secs(300))
}
fn default_on_timeout() -> TimeoutAction {
TimeoutAction::Fail
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DependencyCondition {
Started,
Healthy,
Ready,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TimeoutAction {
Fail,
Warn,
Continue,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct HealthSpec {
#[serde(default, with = "duration::option")]
pub start_grace: Option<std::time::Duration>,
#[serde(default, with = "duration::option")]
pub interval: Option<std::time::Duration>,
#[serde(default, with = "duration::option")]
pub timeout: Option<std::time::Duration>,
#[serde(default = "default_retries")]
pub retries: u32,
pub check: HealthCheck,
}
fn default_retries() -> u32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum HealthCheck {
Tcp {
port: u16,
},
Http {
url: String,
#[serde(default = "default_expect_status")]
expect_status: u16,
},
Command {
command: String,
},
}
fn default_expect_status() -> u16 {
200
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[derive(Default)]
pub struct InitSpec {
#[serde(default)]
pub steps: Vec<InitStep>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct LifecycleSpec {
#[serde(default)]
pub delete_on_exit: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct InitStep {
pub id: String,
pub uses: String,
#[serde(default)]
pub with: InitParams,
#[serde(default)]
pub retry: Option<u32>,
#[serde(default, with = "duration::option")]
pub timeout: Option<std::time::Duration>,
#[serde(default = "default_on_failure")]
pub on_failure: FailureAction,
}
fn default_on_failure() -> FailureAction {
FailureAction::Fail
}
pub type InitParams = std::collections::HashMap<String, serde_json::Value>;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FailureAction {
Fail,
Warn,
Continue,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
#[derive(Default)]
pub struct ErrorsSpec {
#[serde(default)]
pub on_init_failure: InitFailurePolicy,
#[serde(default)]
pub on_panic: PanicPolicy,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct InitFailurePolicy {
#[serde(default = "default_init_action")]
pub action: InitFailureAction,
}
impl Default for InitFailurePolicy {
fn default() -> Self {
Self {
action: default_init_action(),
}
}
}
fn default_init_action() -> InitFailureAction {
InitFailureAction::Fail
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum InitFailureAction {
Fail,
Restart,
Backoff,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PanicPolicy {
#[serde(default = "default_panic_action")]
pub action: PanicAction,
}
impl Default for PanicPolicy {
fn default() -> Self {
Self {
action: default_panic_action(),
}
}
}
fn default_panic_action() -> PanicAction {
PanicAction::Restart
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PanicAction {
Restart,
Shutdown,
Isolate,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct NetworkPolicySpec {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub cidrs: Vec<String>,
#[serde(default)]
pub members: Vec<NetworkMember>,
#[serde(default)]
pub access_rules: Vec<AccessRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NetworkMember {
pub name: String,
#[serde(default)]
pub kind: MemberKind,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum MemberKind {
#[default]
User,
Group,
Node,
Cidr,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AccessRule {
#[serde(default = "wildcard")]
pub service: String,
#[serde(default = "wildcard")]
pub deployment: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ports: Option<Vec<u16>>,
#[serde(default)]
pub action: AccessAction,
}
fn wildcard() -> String {
"*".to_string()
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum AccessAction {
#[default]
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
pub struct BridgeNetwork {
pub id: String,
pub name: String,
#[serde(default)]
pub driver: BridgeNetworkDriver,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subnet: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default)]
pub internal: bool,
#[schema(value_type = String, format = "date-time")]
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum BridgeNetworkDriver {
#[default]
Bridge,
Overlay,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
pub struct BridgeNetworkAttachment {
pub container_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ipv4: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
pub struct RegistryAuth {
pub username: String,
pub password: String,
#[serde(default = "default_registry_auth_type")]
pub auth_type: RegistryAuthType,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum RegistryAuthType {
#[default]
Basic,
Token,
}
#[must_use]
pub fn default_registry_auth_type() -> RegistryAuthType {
RegistryAuthType::Basic
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub struct ContainerRestartPolicy {
pub kind: ContainerRestartKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_attempts: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delay: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ContainerRestartKind {
No,
Always,
UnlessStopped,
OnFailure,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum PortProtocol {
Tcp,
Udp,
}
impl Default for PortProtocol {
fn default() -> Self {
default_port_protocol()
}
}
impl PortProtocol {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
PortProtocol::Tcp => "tcp",
PortProtocol::Udp => "udp",
}
}
}
fn default_port_protocol() -> PortProtocol {
PortProtocol::Tcp
}
fn default_host_ip() -> String {
"0.0.0.0".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub struct PortMapping {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_port: Option<u16>,
pub container_port: u16,
#[serde(default = "default_port_protocol")]
pub protocol: PortProtocol,
#[serde(default = "default_host_ip", skip_serializing_if = "String::is_empty")]
pub host_ip: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn port_mapping_defaults_via_serde() {
let json = r#"{"container_port": 8080}"#;
let m: PortMapping = serde_json::from_str(json).expect("parse minimal PortMapping");
assert_eq!(m.container_port, 8080);
assert_eq!(m.host_port, None);
assert_eq!(m.protocol, PortProtocol::Tcp);
assert_eq!(m.host_ip, "0.0.0.0");
}
#[test]
fn port_mapping_skips_none_host_port_and_empty_host_ip() {
let m = PortMapping {
host_port: None,
container_port: 443,
protocol: PortProtocol::Tcp,
host_ip: String::new(),
};
let s = serde_json::to_string(&m).expect("serialize");
assert!(!s.contains("host_port"), "host_port should be skipped: {s}");
assert!(!s.contains("host_ip"), "host_ip should be skipped: {s}");
assert!(s.contains("\"container_port\":443"));
assert!(s.contains("\"protocol\":\"tcp\""));
}
#[test]
fn test_parse_simple_spec() {
let yaml = r"
version: v1
deployment: test
services:
hello:
rtype: service
image:
name: hello-world:latest
endpoints:
- name: http
protocol: http
port: 8080
expose: public
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.version, "v1");
assert_eq!(spec.deployment, "test");
assert!(spec.services.contains_key("hello"));
}
#[test]
fn test_parse_duration() {
let yaml = r"
version: v1
deployment: test
services:
test:
rtype: service
image:
name: test:latest
health:
timeout: 30s
interval: 1m
start_grace: 5s
check:
type: tcp
port: 8080
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let health = &spec.services["test"].health;
assert_eq!(health.timeout, Some(std::time::Duration::from_secs(30)));
assert_eq!(health.interval, Some(std::time::Duration::from_secs(60)));
assert_eq!(health.start_grace, Some(std::time::Duration::from_secs(5)));
match &health.check {
HealthCheck::Tcp { port } => assert_eq!(*port, 8080),
_ => panic!("Expected TCP health check"),
}
}
#[test]
fn test_parse_adaptive_scale() {
let yaml = r"
version: v1
deployment: test
services:
test:
rtype: service
image:
name: test:latest
scale:
mode: adaptive
min: 2
max: 10
cooldown: 15s
targets:
cpu: 70
rps: 800
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let scale = &spec.services["test"].scale;
match scale {
ScaleSpec::Adaptive {
min,
max,
cooldown,
targets,
} => {
assert_eq!(*min, 2);
assert_eq!(*max, 10);
assert_eq!(*cooldown, Some(std::time::Duration::from_secs(15)));
assert_eq!(targets.cpu, Some(70));
assert_eq!(targets.rps, Some(800));
}
_ => panic!("Expected Adaptive scale mode"),
}
}
#[test]
fn test_node_mode_default() {
let yaml = r"
version: v1
deployment: test
services:
hello:
rtype: service
image:
name: hello-world:latest
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.services["hello"].node_mode, NodeMode::Shared);
assert!(spec.services["hello"].node_selector.is_none());
}
#[test]
fn test_node_mode_dedicated() {
let yaml = r"
version: v1
deployment: test
services:
api:
rtype: service
image:
name: api:latest
node_mode: dedicated
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
}
#[test]
fn test_node_mode_exclusive() {
let yaml = r"
version: v1
deployment: test
services:
database:
rtype: service
image:
name: postgres:15
node_mode: exclusive
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
}
#[test]
fn test_node_selector_with_labels() {
let yaml = r#"
version: v1
deployment: test
services:
ml-worker:
rtype: service
image:
name: ml-worker:latest
node_mode: dedicated
node_selector:
labels:
gpu: "true"
zone: us-east
prefer_labels:
storage: ssd
"#;
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let service = &spec.services["ml-worker"];
assert_eq!(service.node_mode, NodeMode::Dedicated);
let selector = service.node_selector.as_ref().unwrap();
assert_eq!(selector.labels.get("gpu"), Some(&"true".to_string()));
assert_eq!(selector.labels.get("zone"), Some(&"us-east".to_string()));
assert_eq!(
selector.prefer_labels.get("storage"),
Some(&"ssd".to_string())
);
}
#[test]
fn test_node_mode_serialization_roundtrip() {
use serde_json;
let modes = [NodeMode::Shared, NodeMode::Dedicated, NodeMode::Exclusive];
let expected_json = ["\"shared\"", "\"dedicated\"", "\"exclusive\""];
for (mode, expected) in modes.iter().zip(expected_json.iter()) {
let json = serde_json::to_string(mode).unwrap();
assert_eq!(&json, *expected, "Serialization failed for {mode:?}");
let deserialized: NodeMode = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, *mode, "Roundtrip failed for {mode:?}");
}
}
#[test]
fn test_node_selector_empty() {
let yaml = r"
version: v1
deployment: test
services:
api:
rtype: service
image:
name: api:latest
node_selector:
labels: {}
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let selector = spec.services["api"].node_selector.as_ref().unwrap();
assert!(selector.labels.is_empty());
assert!(selector.prefer_labels.is_empty());
}
#[test]
fn test_mixed_node_modes_in_deployment() {
let yaml = r"
version: v1
deployment: test
services:
redis:
rtype: service
image:
name: redis:alpine
# Default shared mode
api:
rtype: service
image:
name: api:latest
node_mode: dedicated
database:
rtype: service
image:
name: postgres:15
node_mode: exclusive
node_selector:
labels:
storage: ssd
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.services["redis"].node_mode, NodeMode::Shared);
assert_eq!(spec.services["api"].node_mode, NodeMode::Dedicated);
assert_eq!(spec.services["database"].node_mode, NodeMode::Exclusive);
let db_selector = spec.services["database"].node_selector.as_ref().unwrap();
assert_eq!(db_selector.labels.get("storage"), Some(&"ssd".to_string()));
}
#[test]
fn test_storage_bind_mount() {
let yaml = r"
version: v1
deployment: test
services:
app:
image:
name: app:latest
storage:
- type: bind
source: /host/data
target: /app/data
readonly: true
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let storage = &spec.services["app"].storage;
assert_eq!(storage.len(), 1);
match &storage[0] {
StorageSpec::Bind {
source,
target,
readonly,
} => {
assert_eq!(source, "/host/data");
assert_eq!(target, "/app/data");
assert!(*readonly);
}
_ => panic!("Expected Bind storage"),
}
}
#[test]
fn test_storage_named_with_tier() {
let yaml = r"
version: v1
deployment: test
services:
app:
image:
name: app:latest
storage:
- type: named
name: my-data
target: /app/data
tier: cached
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let storage = &spec.services["app"].storage;
match &storage[0] {
StorageSpec::Named {
name, target, tier, ..
} => {
assert_eq!(name, "my-data");
assert_eq!(target, "/app/data");
assert_eq!(*tier, StorageTier::Cached);
}
_ => panic!("Expected Named storage"),
}
}
#[test]
fn test_storage_anonymous() {
let yaml = r"
version: v1
deployment: test
services:
app:
image:
name: app:latest
storage:
- type: anonymous
target: /app/cache
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let storage = &spec.services["app"].storage;
match &storage[0] {
StorageSpec::Anonymous { target, tier } => {
assert_eq!(target, "/app/cache");
assert_eq!(*tier, StorageTier::Local); }
_ => panic!("Expected Anonymous storage"),
}
}
#[test]
fn test_storage_tmpfs() {
let yaml = r"
version: v1
deployment: test
services:
app:
image:
name: app:latest
storage:
- type: tmpfs
target: /app/tmp
size: 256Mi
mode: 1777
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let storage = &spec.services["app"].storage;
match &storage[0] {
StorageSpec::Tmpfs { target, size, mode } => {
assert_eq!(target, "/app/tmp");
assert_eq!(size.as_deref(), Some("256Mi"));
assert_eq!(*mode, Some(1777));
}
_ => panic!("Expected Tmpfs storage"),
}
}
#[test]
fn test_storage_s3() {
let yaml = r"
version: v1
deployment: test
services:
app:
image:
name: app:latest
storage:
- type: s3
bucket: my-bucket
prefix: models/
target: /app/models
readonly: true
endpoint: https://s3.us-west-2.amazonaws.com
credentials: aws-creds
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let storage = &spec.services["app"].storage;
match &storage[0] {
StorageSpec::S3 {
bucket,
prefix,
target,
readonly,
endpoint,
credentials,
} => {
assert_eq!(bucket, "my-bucket");
assert_eq!(prefix.as_deref(), Some("models/"));
assert_eq!(target, "/app/models");
assert!(*readonly);
assert_eq!(
endpoint.as_deref(),
Some("https://s3.us-west-2.amazonaws.com")
);
assert_eq!(credentials.as_deref(), Some("aws-creds"));
}
_ => panic!("Expected S3 storage"),
}
}
#[test]
fn test_storage_multiple_types() {
let yaml = r"
version: v1
deployment: test
services:
app:
image:
name: app:latest
storage:
- type: bind
source: /etc/config
target: /app/config
readonly: true
- type: named
name: app-data
target: /app/data
- type: tmpfs
target: /app/tmp
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let storage = &spec.services["app"].storage;
assert_eq!(storage.len(), 3);
assert!(matches!(&storage[0], StorageSpec::Bind { .. }));
assert!(matches!(&storage[1], StorageSpec::Named { .. }));
assert!(matches!(&storage[2], StorageSpec::Tmpfs { .. }));
}
#[test]
fn test_storage_tier_default() {
let yaml = r"
version: v1
deployment: test
services:
app:
image:
name: app:latest
storage:
- type: named
name: data
target: /data
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
match &spec.services["app"].storage[0] {
StorageSpec::Named { tier, .. } => {
assert_eq!(*tier, StorageTier::Local); }
_ => panic!("Expected Named storage"),
}
}
#[test]
fn test_endpoint_tunnel_config_basic() {
let yaml = r"
version: v1
deployment: test
services:
api:
image:
name: api:latest
endpoints:
- name: http
protocol: http
port: 8080
tunnel:
enabled: true
remote_port: 8080
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let endpoint = &spec.services["api"].endpoints[0];
let tunnel = endpoint.tunnel.as_ref().unwrap();
assert!(tunnel.enabled);
assert_eq!(tunnel.remote_port, 8080);
assert!(tunnel.from.is_none());
assert!(tunnel.to.is_none());
}
#[test]
fn test_endpoint_tunnel_config_full() {
let yaml = r"
version: v1
deployment: test
services:
api:
image:
name: api:latest
endpoints:
- name: http
protocol: http
port: 8080
tunnel:
enabled: true
from: node-1
to: ingress-node
remote_port: 9000
expose: public
access:
enabled: true
max_ttl: 4h
audit: true
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let endpoint = &spec.services["api"].endpoints[0];
let tunnel = endpoint.tunnel.as_ref().unwrap();
assert!(tunnel.enabled);
assert_eq!(tunnel.from, Some("node-1".to_string()));
assert_eq!(tunnel.to, Some("ingress-node".to_string()));
assert_eq!(tunnel.remote_port, 9000);
assert_eq!(tunnel.expose, Some(ExposeType::Public));
let access = tunnel.access.as_ref().unwrap();
assert!(access.enabled);
assert_eq!(access.max_ttl, Some("4h".to_string()));
assert!(access.audit);
}
#[test]
fn test_top_level_tunnel_definition() {
let yaml = r"
version: v1
deployment: test
services: {}
tunnels:
db-tunnel:
from: app-node
to: db-node
local_port: 5432
remote_port: 5432
protocol: tcp
expose: internal
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let tunnel = spec.tunnels.get("db-tunnel").unwrap();
assert_eq!(tunnel.from, "app-node");
assert_eq!(tunnel.to, "db-node");
assert_eq!(tunnel.local_port, 5432);
assert_eq!(tunnel.remote_port, 5432);
assert_eq!(tunnel.protocol, TunnelProtocol::Tcp);
assert_eq!(tunnel.expose, ExposeType::Internal);
}
#[test]
fn test_top_level_tunnel_defaults() {
let yaml = r"
version: v1
deployment: test
services: {}
tunnels:
simple-tunnel:
from: node-a
to: node-b
local_port: 3000
remote_port: 3000
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let tunnel = spec.tunnels.get("simple-tunnel").unwrap();
assert_eq!(tunnel.protocol, TunnelProtocol::Tcp); assert_eq!(tunnel.expose, ExposeType::Internal); }
#[test]
fn test_tunnel_protocol_udp() {
let yaml = r"
version: v1
deployment: test
services: {}
tunnels:
udp-tunnel:
from: node-a
to: node-b
local_port: 5353
remote_port: 5353
protocol: udp
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let tunnel = spec.tunnels.get("udp-tunnel").unwrap();
assert_eq!(tunnel.protocol, TunnelProtocol::Udp);
}
#[test]
fn test_endpoint_without_tunnel() {
let yaml = r"
version: v1
deployment: test
services:
api:
image:
name: api:latest
endpoints:
- name: http
protocol: http
port: 8080
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
let endpoint = &spec.services["api"].endpoints[0];
assert!(endpoint.tunnel.is_none());
}
#[test]
fn test_deployment_without_tunnels() {
let yaml = r"
version: v1
deployment: test
services:
api:
image:
name: api:latest
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.tunnels.is_empty());
}
#[test]
fn test_spec_without_api_block_uses_defaults() {
let yaml = r"
version: v1
deployment: test
services:
hello:
image:
name: hello-world:latest
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.api.enabled);
assert_eq!(spec.api.bind, "0.0.0.0:3669");
assert!(spec.api.jwt_secret.is_none());
assert!(spec.api.swagger);
}
#[test]
fn test_spec_with_explicit_api_block() {
let yaml = r#"
version: v1
deployment: test
services:
hello:
image:
name: hello-world:latest
api:
enabled: false
bind: "127.0.0.1:9090"
jwt_secret: "my-secret"
swagger: false
"#;
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(!spec.api.enabled);
assert_eq!(spec.api.bind, "127.0.0.1:9090");
assert_eq!(spec.api.jwt_secret, Some("my-secret".to_string()));
assert!(!spec.api.swagger);
}
#[test]
fn test_spec_with_partial_api_block() {
let yaml = r#"
version: v1
deployment: test
services:
hello:
image:
name: hello-world:latest
api:
bind: "0.0.0.0:3000"
"#;
let spec: DeploymentSpec = serde_yaml::from_str(yaml).unwrap();
assert!(spec.api.enabled); assert_eq!(spec.api.bind, "0.0.0.0:3000");
assert!(spec.api.jwt_secret.is_none()); assert!(spec.api.swagger); }
#[test]
fn test_network_policy_spec_roundtrip() {
let spec = NetworkPolicySpec {
name: "corp-vpn".to_string(),
description: Some("Corporate VPN network".to_string()),
cidrs: vec!["10.200.0.0/16".to_string()],
members: vec![
NetworkMember {
name: "alice".to_string(),
kind: MemberKind::User,
},
NetworkMember {
name: "ops-team".to_string(),
kind: MemberKind::Group,
},
NetworkMember {
name: "node-01".to_string(),
kind: MemberKind::Node,
},
],
access_rules: vec![
AccessRule {
service: "api-gateway".to_string(),
deployment: "*".to_string(),
ports: Some(vec![443, 8080]),
action: AccessAction::Allow,
},
AccessRule {
service: "*".to_string(),
deployment: "staging".to_string(),
ports: None,
action: AccessAction::Deny,
},
],
};
let yaml = serde_yaml::to_string(&spec).unwrap();
let deserialized: NetworkPolicySpec = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(spec, deserialized);
}
#[test]
fn test_network_policy_spec_defaults() {
let yaml = r"
name: minimal
";
let spec: NetworkPolicySpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.name, "minimal");
assert!(spec.description.is_none());
assert!(spec.cidrs.is_empty());
assert!(spec.members.is_empty());
assert!(spec.access_rules.is_empty());
}
#[test]
fn test_access_rule_defaults() {
let yaml = "{}";
let rule: AccessRule = serde_yaml::from_str(yaml).unwrap();
assert_eq!(rule.service, "*");
assert_eq!(rule.deployment, "*");
assert!(rule.ports.is_none());
assert_eq!(rule.action, AccessAction::Allow);
}
#[test]
fn test_member_kind_defaults_to_user() {
let yaml = r"
name: bob
";
let member: NetworkMember = serde_yaml::from_str(yaml).unwrap();
assert_eq!(member.name, "bob");
assert_eq!(member.kind, MemberKind::User);
}
#[test]
fn test_member_kind_variants() {
for (input, expected) in [
("user", MemberKind::User),
("group", MemberKind::Group),
("node", MemberKind::Node),
("cidr", MemberKind::Cidr),
] {
let yaml = format!("name: test\nkind: {input}");
let member: NetworkMember = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(member.kind, expected);
}
}
#[test]
fn test_access_action_variants() {
#[derive(Debug, Deserialize)]
struct Wrapper {
action: AccessAction,
}
let allow: Wrapper = serde_yaml::from_str("action: allow").unwrap();
let deny: Wrapper = serde_yaml::from_str("action: deny").unwrap();
assert_eq!(allow.action, AccessAction::Allow);
assert_eq!(deny.action, AccessAction::Deny);
}
#[test]
fn test_network_policy_spec_default_impl() {
let spec = NetworkPolicySpec::default();
assert_eq!(spec.name, "");
assert!(spec.description.is_none());
assert!(spec.cidrs.is_empty());
assert!(spec.members.is_empty());
assert!(spec.access_rules.is_empty());
}
#[test]
fn container_restart_policy_serde_roundtrip_all_kinds() {
let cases = [
(
ContainerRestartPolicy {
kind: ContainerRestartKind::No,
max_attempts: None,
delay: None,
},
r#"{"kind":"no"}"#,
),
(
ContainerRestartPolicy {
kind: ContainerRestartKind::Always,
max_attempts: None,
delay: Some("500ms".to_string()),
},
r#"{"kind":"always","delay":"500ms"}"#,
),
(
ContainerRestartPolicy {
kind: ContainerRestartKind::UnlessStopped,
max_attempts: None,
delay: None,
},
r#"{"kind":"unless_stopped"}"#,
),
(
ContainerRestartPolicy {
kind: ContainerRestartKind::OnFailure,
max_attempts: Some(5),
delay: None,
},
r#"{"kind":"on_failure","max_attempts":5}"#,
),
];
for (value, expected_json) in &cases {
let serialized = serde_json::to_string(value).expect("serialize");
assert_eq!(&serialized, expected_json, "serialize mismatch");
let round: ContainerRestartPolicy =
serde_json::from_str(&serialized).expect("deserialize");
assert_eq!(&round, value, "roundtrip mismatch");
}
}
#[test]
fn registry_auth_type_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&RegistryAuthType::Basic).unwrap(),
"\"basic\""
);
assert_eq!(
serde_json::to_string(&RegistryAuthType::Token).unwrap(),
"\"token\""
);
}
#[test]
fn registry_auth_default_auth_type_is_basic() {
let json = r#"{"username":"u","password":"p"}"#;
let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
assert_eq!(parsed.auth_type, RegistryAuthType::Basic);
assert_eq!(parsed.username, "u");
assert_eq!(parsed.password, "p");
}
#[test]
fn registry_auth_serde_roundtrip_both_variants() {
for variant in [RegistryAuthType::Basic, RegistryAuthType::Token] {
let cred = RegistryAuth {
username: "ci-bot".to_string(),
password: "s3cret".to_string(),
auth_type: variant,
};
let serialized = serde_json::to_string(&cred).expect("serialize");
let back: RegistryAuth = serde_json::from_str(&serialized).expect("deserialize");
assert_eq!(back, cred, "roundtrip mismatch for {variant:?}");
}
}
#[test]
fn registry_auth_explicit_token_type_parses() {
let json = r#"{"username":"oauth2accesstoken","password":"ghp_abc","auth_type":"token"}"#;
let parsed: RegistryAuth = serde_json::from_str(json).expect("parse");
assert_eq!(parsed.auth_type, RegistryAuthType::Token);
}
#[test]
fn target_platform_as_oci_str() {
assert_eq!(
TargetPlatform::new(OsKind::Linux, ArchKind::Amd64).as_oci_str(),
"linux/amd64"
);
assert_eq!(
TargetPlatform::new(OsKind::Windows, ArchKind::Arm64).as_oci_str(),
"windows/arm64"
);
assert_eq!(
TargetPlatform::new(OsKind::Macos, ArchKind::Arm64).as_oci_str(),
"darwin/arm64"
);
}
#[test]
fn os_kind_from_rust_consts() {
assert_eq!(OsKind::from_rust_os("linux"), Some(OsKind::Linux));
assert_eq!(OsKind::from_rust_os("windows"), Some(OsKind::Windows));
assert_eq!(OsKind::from_rust_os("macos"), Some(OsKind::Macos));
assert_eq!(OsKind::from_rust_os("freebsd"), None);
}
#[test]
fn arch_kind_from_rust_consts() {
assert_eq!(ArchKind::from_rust_arch("x86_64"), Some(ArchKind::Amd64));
assert_eq!(ArchKind::from_rust_arch("aarch64"), Some(ArchKind::Arm64));
assert_eq!(ArchKind::from_rust_arch("riscv64"), None);
}
#[test]
fn service_spec_platform_yaml_round_trip_none() {
let yaml = r"
version: v1
deployment: test
services:
app:
rtype: service
image:
name: nginx:latest
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
assert!(spec.services["app"].platform.is_none());
}
#[test]
fn service_spec_platform_yaml_round_trip_some() {
let yaml = r"
version: v1
deployment: test
services:
app:
rtype: service
image:
name: nginx:latest
platform:
os: windows
arch: amd64
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
assert_eq!(
spec.services["app"].platform,
Some(TargetPlatform::new(OsKind::Windows, ArchKind::Amd64))
);
}
#[test]
fn service_spec_platform_serializes_omitted_when_none() {
let yaml = r"
version: v1
deployment: test
services:
app:
rtype: service
image:
name: nginx:latest
";
let mut spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("yaml parse");
let service = spec.services.get_mut("app").expect("service present");
service.platform = None;
let rendered = serde_yaml::to_string(service).expect("render");
assert!(
!rendered.contains("platform"),
"platform must be omitted when None: {rendered}"
);
}
#[test]
fn target_platform_os_version_builder() {
let p =
TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
assert_eq!(p.os, OsKind::Windows);
assert_eq!(p.arch, ArchKind::Amd64);
}
#[test]
fn target_platform_os_version_yaml_roundtrip() {
let yaml = "os: windows\narch: amd64\nosVersion: 10.0.26100.1\n";
let p: TargetPlatform = serde_yaml::from_str(yaml).expect("yaml parse");
assert_eq!(p.os_version.as_deref(), Some("10.0.26100.1"));
assert_eq!(p.os, OsKind::Windows);
assert_eq!(p.arch, ArchKind::Amd64);
}
#[test]
fn target_platform_os_version_yaml_omits_when_none() {
let p = TargetPlatform::new(OsKind::Linux, ArchKind::Amd64);
let rendered = serde_yaml::to_string(&p).expect("render");
assert!(
!rendered.contains("osVersion"),
"osVersion must be omitted when None: {rendered}"
);
}
#[test]
fn target_platform_as_detailed_str_includes_version() {
let without = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).as_detailed_str();
assert_eq!(without, "windows/amd64");
let with = TargetPlatform::new(OsKind::Windows, ArchKind::Amd64)
.with_os_version("10.0.26100.1")
.as_detailed_str();
assert_eq!(with, "windows/amd64 (os.version=10.0.26100.1)");
}
#[test]
fn target_platform_display_ignores_version() {
let p =
TargetPlatform::new(OsKind::Windows, ArchKind::Amd64).with_os_version("10.0.26100.1");
assert_eq!(format!("{p}"), "windows/amd64");
}
fn fixture_service_spec_full() -> ServiceSpec {
let yaml = r"
version: v1
deployment: phase1-task1
services:
hello:
rtype: service
image:
name: hello-world:latest
";
let spec: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse fixture");
spec.services.get("hello").expect("hello service").clone()
}
#[test]
fn service_spec_round_trip_with_all_new_fields() {
let mut spec = fixture_service_spec_full();
spec.labels
.insert("zlayer.team".to_string(), "platform".to_string());
spec.user = Some("1000:1000".to_string());
spec.stop_signal = Some("SIGTERM".to_string());
spec.stop_grace_period = Some(std::time::Duration::from_secs(30));
spec.sysctls
.insert("net.core.somaxconn".to_string(), "1024".to_string());
spec.ulimits.insert(
"nofile".to_string(),
UlimitSpec {
soft: 65_536,
hard: 65_536,
},
);
spec.security_opt.push("no-new-privileges:true".to_string());
spec.pid_mode = Some("host".to_string());
spec.ipc_mode = Some("private".to_string());
spec.network_mode = NetworkMode::Bridge {
name: Some("custom-net".to_string()),
};
spec.cap_drop.push("NET_RAW".to_string());
spec.extra_groups.push("docker".to_string());
spec.read_only_root_fs = true;
spec.init_container = Some(true);
spec.resources.pids_limit = Some(2048);
spec.resources.cpuset = Some("0-3".to_string());
spec.resources.cpu_shares = Some(1024);
spec.resources.memory_swap = Some("2Gi".to_string());
spec.resources.memory_reservation = Some("256Mi".to_string());
spec.resources.memory_swappiness = Some(10);
spec.resources.oom_score_adj = Some(-500);
spec.resources.oom_kill_disable = Some(false);
spec.resources.blkio_weight = Some(500);
let yaml = serde_yaml::to_string(&spec).expect("serialize");
let round: ServiceSpec = serde_yaml::from_str(&yaml).expect("deserialize");
assert_eq!(spec, round, "round-trip mismatch:\n{yaml}");
}
#[test]
fn network_mode_string_form_round_trip() {
let cases: &[(&str, NetworkMode)] = &[
("default", NetworkMode::Default),
("host", NetworkMode::Host),
("none", NetworkMode::None),
("bridge", NetworkMode::Bridge { name: None }),
(
"bridge:custom",
NetworkMode::Bridge {
name: Some("custom".to_string()),
},
),
(
"container:abc123",
NetworkMode::Container {
id: "abc123".to_string(),
},
),
];
for (input, expected) in cases {
#[derive(Deserialize)]
struct Wrap {
#[serde(deserialize_with = "deserialize_network_mode")]
m: NetworkMode,
}
let yaml = format!("m: \"{input}\"\n");
let parsed: Wrap = serde_yaml::from_str(&yaml).expect("parse network mode");
assert_eq!(&parsed.m, expected, "mismatch for {input}");
}
}
#[test]
fn ulimit_spec_round_trip() {
let u = UlimitSpec {
soft: 1024,
hard: 65_536,
};
let yaml = serde_yaml::to_string(&u).expect("serialize");
let parsed: UlimitSpec = serde_yaml::from_str(&yaml).expect("parse");
assert_eq!(u, parsed);
}
#[test]
fn host_network_true_yaml_promotes_to_network_mode_host() {
let yaml = r"
version: v1
deployment: bc-test
services:
hello:
rtype: service
image:
name: hello-world:latest
host_network: true
";
let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse");
let svc = dep.services.get("hello").expect("hello service");
assert_eq!(svc.network_mode, NetworkMode::Host);
assert!(svc.host_network);
}
#[test]
fn capabilities_yaml_alias_cap_add_round_trip() {
let yaml = r"
version: v1
deployment: cap-test
services:
hello:
rtype: service
image:
name: hello-world:latest
cap_add:
- NET_ADMIN
- SYS_PTRACE
";
let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse cap_add alias");
let svc = dep.services.get("hello").expect("hello service");
assert_eq!(
svc.capabilities,
vec!["NET_ADMIN".to_string(), "SYS_PTRACE".to_string()]
);
}
#[test]
fn lifecycle_omitted_defaults_to_false() {
let yaml = r"
version: v1
deployment: lifecycle-default-test
services:
app:
rtype: service
image:
name: hello-world:latest
";
let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse spec without lifecycle");
let svc = dep.services.get("app").expect("app service");
assert_eq!(svc.lifecycle, LifecycleSpec::default());
assert!(!svc.lifecycle.delete_on_exit);
}
#[test]
fn lifecycle_delete_on_exit_round_trips() {
let yaml = r"
version: v1
deployment: lifecycle-delete-test
services:
app:
rtype: service
image:
name: hello-world:latest
lifecycle:
delete_on_exit: true
";
let dep: DeploymentSpec = serde_yaml::from_str(yaml).expect("parse spec with lifecycle");
let svc = dep.services.get("app").expect("app service");
assert!(svc.lifecycle.delete_on_exit);
let dumped = serde_yaml::to_string(&dep).expect("serialize spec with lifecycle");
let reparsed: DeploymentSpec =
serde_yaml::from_str(&dumped).expect("reparse round-tripped spec");
let reparsed_svc = reparsed.services.get("app").expect("app service after rt");
assert!(reparsed_svc.lifecycle.delete_on_exit);
assert_eq!(svc.lifecycle, reparsed_svc.lifecycle);
}
}