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::validate::validate_version_wrapper"))]
pub version: String,
#[validate(custom(function = "crate::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, Serialize, Deserialize, PartialEq, Validate)]
#[serde(deny_unknown_fields)]
pub struct ServiceSpec {
#[serde(default = "default_resource_type")]
pub rtype: ResourceType,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[validate(custom(function = "crate::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::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 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)]
pub capabilities: 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>,
}
#[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 {
#[validate(custom(function = "crate::validate::validate_image_name_wrapper"))]
pub name: String,
#[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,
IfNotPresent,
Never,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Validate)]
#[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::validate::validate_cpu_option_wrapper"))]
pub cpu: Option<f64>,
#[serde(default)]
#[validate(custom(function = "crate::validate::validate_memory_option_wrapper"))]
pub memory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gpu: Option<GpuSpec>,
}
#[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::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, 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");
}
}