use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::types::{
DeployStrategy, PlacementConstraint, PullPolicy, Replicas, ResourceLimits, RuntimeKind,
VolumeSpec,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeConfig {
pub path: String,
pub port: Option<u16>,
#[serde(default = "default_probe_interval")]
pub interval_secs: u64,
#[serde(default = "default_probe_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_probe_failures")]
pub failure_threshold: u32,
#[serde(default = "default_initial_delay")]
pub initial_delay_secs: u64,
}
fn default_probe_interval() -> u64 {
10
}
fn default_probe_timeout() -> u64 {
3
}
fn default_probe_failures() -> u32 {
3
}
fn default_initial_delay() -> u64 {
5
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildConfig {
pub repo: String,
pub branch: Option<String>,
pub dockerfile: Option<String>,
pub context: Option<String>,
}
impl BuildConfig {
pub fn branch_or_default(&self) -> &str {
self.branch.as_deref().unwrap_or("main")
}
pub fn dockerfile_or_default(&self) -> &str {
self.dockerfile.as_deref().unwrap_or("Dockerfile")
}
pub fn context_or_default(&self) -> &str {
self.context.as_deref().unwrap_or(".")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServicesConfig {
pub service: Vec<ServiceConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceConfig {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
#[serde(default)]
pub runtime: RuntimeKind,
pub image: Option<String>,
pub module: Option<String>,
#[serde(default)]
pub replicas: Replicas,
pub port: Option<u16>,
pub host_port: Option<u16>,
pub domain: Option<String>,
#[serde(default)]
pub routes: Vec<String>,
pub health: Option<String>,
pub readiness: Option<ProbeConfig>,
pub liveness: Option<ProbeConfig>,
#[serde(default)]
pub env: HashMap<String, String>,
pub resources: Option<ResourceLimits>,
pub volume: Option<VolumeSpec>,
pub deploy: Option<DeployStrategy>,
pub placement: Option<PlacementConstraint>,
pub network: Option<String>,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub mounts: Vec<String>,
#[serde(default)]
pub triggers: Vec<String>,
pub assets: Option<String>,
pub build: Option<BuildConfig>,
pub tls_cert: Option<String>,
pub tls_key: Option<String>,
#[serde(default)]
pub internal: bool,
#[serde(default)]
pub depends_on: Vec<String>,
#[serde(default)]
pub cmd: Vec<String>,
#[serde(default)]
pub extra_ports: Vec<String>,
#[serde(default)]
pub strip_prefix: Option<String>,
#[serde(default)]
pub pull_policy: PullPolicy,
}
impl ServiceConfig {
pub fn spec_matches(&self, other: &Self) -> bool {
self.image == other.image
&& self.module == other.module
&& self.env == other.env
&& self.cmd == other.cmd
&& self.port == other.port
&& self.host_port == other.host_port
&& self.domain == other.domain
&& self.routes == other.routes
&& self.volume == other.volume
&& self.mounts == other.mounts
&& self.aliases == other.aliases
&& self.extra_ports == other.extra_ports
&& self.strip_prefix == other.strip_prefix
&& self.network == other.network
&& self.internal == other.internal
&& self.health == other.health
}
}
#[cfg(test)]
mod tests {
use super::*;
fn base_config() -> ServiceConfig {
ServiceConfig {
name: "test-svc".into(),
project: None,
runtime: RuntimeKind::Container,
image: Some("nginx:latest".into()),
module: None,
replicas: Replicas::Fixed(1),
port: Some(80),
host_port: None,
domain: Some("test.example.com".into()),
routes: vec!["/*".into()],
health: Some("/healthz".into()),
readiness: None,
liveness: None,
env: HashMap::from([("KEY".into(), "val".into())]),
resources: None,
volume: None,
deploy: None,
placement: None,
network: Some("web".into()),
aliases: vec!["test".into()],
mounts: vec!["/host:/container".into()],
triggers: vec![],
assets: None,
build: None,
tls_cert: None,
tls_key: None,
internal: false,
depends_on: vec![],
cmd: vec![],
extra_ports: vec!["8080:80".into()],
strip_prefix: Some("/api".into()),
pull_policy: Default::default(),
}
}
#[test]
fn identical_configs_match() {
let a = base_config();
let b = base_config();
assert!(a.spec_matches(&b));
}
#[test]
fn image_change_detected() {
let a = base_config();
let mut b = base_config();
b.image = Some("nginx:1.27".into());
assert!(!a.spec_matches(&b));
}
#[test]
fn env_change_detected() {
let a = base_config();
let mut b = base_config();
b.env.insert("NEW_KEY".into(), "new_val".into());
assert!(!a.spec_matches(&b));
}
#[test]
fn extra_ports_change_detected() {
let a = base_config();
let mut b = base_config();
b.extra_ports = vec!["9090:90".into()];
assert!(!a.spec_matches(&b));
}
#[test]
fn mounts_change_detected() {
let a = base_config();
let mut b = base_config();
b.mounts.push("/extra:/path".into());
assert!(!a.spec_matches(&b));
}
#[test]
fn volume_change_detected() {
let a = base_config();
let mut b = base_config();
b.volume = Some(crate::types::VolumeSpec {
path: "/data".into(),
size: None,
});
assert!(!a.spec_matches(&b));
}
#[test]
fn domain_change_detected() {
let a = base_config();
let mut b = base_config();
b.domain = Some("new.example.com".into());
assert!(!a.spec_matches(&b));
}
#[test]
fn aliases_change_detected() {
let a = base_config();
let mut b = base_config();
b.aliases.push("new-alias".into());
assert!(!a.spec_matches(&b));
}
#[test]
fn strip_prefix_change_detected() {
let a = base_config();
let mut b = base_config();
b.strip_prefix = None;
assert!(!a.spec_matches(&b));
}
#[test]
fn network_change_detected() {
let a = base_config();
let mut b = base_config();
b.network = Some("internal".into());
assert!(!a.spec_matches(&b));
}
#[test]
fn internal_flag_change_detected() {
let a = base_config();
let mut b = base_config();
b.internal = true;
assert!(!a.spec_matches(&b));
}
#[test]
fn port_change_detected() {
let a = base_config();
let mut b = base_config();
b.port = Some(8080);
assert!(!a.spec_matches(&b));
}
#[test]
fn cmd_change_detected() {
let a = base_config();
let mut b = base_config();
b.cmd = vec!["--debug".into()];
assert!(!a.spec_matches(&b));
}
#[test]
fn non_spec_fields_ignored() {
let a = base_config();
let mut b = base_config();
b.name = "different-name".into();
b.project = Some("other-project".into());
b.replicas = Replicas::Fixed(5);
assert!(a.spec_matches(&b));
}
#[test]
fn unresolved_secret_templates_match() {
let mut a = base_config();
let mut b = base_config();
a.env.insert("TOKEN".into(), "${secrets.MY_TOKEN}".into());
b.env.insert("TOKEN".into(), "${secrets.MY_TOKEN}".into());
assert!(a.spec_matches(&b));
}
#[test]
fn resolved_vs_unresolved_differs() {
let mut a = base_config();
let mut b = base_config();
a.env.insert("TOKEN".into(), "${secrets.MY_TOKEN}".into());
b.env
.insert("TOKEN".into(), "actual-secret-value-123".into());
assert!(!a.spec_matches(&b));
}
}