1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::types::{
6 DeployStrategy, PlacementConstraint, PullPolicy, Replicas, ResourceLimits, RuntimeKind,
7 VolumeSpec,
8};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ProbeConfig {
13 pub path: String,
15 pub port: Option<u16>,
17 #[serde(default = "default_probe_interval")]
19 pub interval_secs: u64,
20 #[serde(default = "default_probe_timeout")]
22 pub timeout_secs: u64,
23 #[serde(default = "default_probe_failures")]
25 pub failure_threshold: u32,
26 #[serde(default = "default_initial_delay")]
28 pub initial_delay_secs: u64,
29}
30
31fn default_probe_interval() -> u64 {
32 10
33}
34fn default_probe_timeout() -> u64 {
35 3
36}
37fn default_probe_failures() -> u32 {
38 3
39}
40fn default_initial_delay() -> u64 {
41 5
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct BuildConfig {
47 pub repo: String,
49 pub branch: Option<String>,
51 pub dockerfile: Option<String>,
53 pub context: Option<String>,
55}
56
57impl BuildConfig {
58 pub fn branch_or_default(&self) -> &str {
60 self.branch.as_deref().unwrap_or("main")
61 }
62
63 pub fn dockerfile_or_default(&self) -> &str {
65 self.dockerfile.as_deref().unwrap_or("Dockerfile")
66 }
67
68 pub fn context_or_default(&self) -> &str {
70 self.context.as_deref().unwrap_or(".")
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ServicesConfig {
77 pub service: Vec<ServiceConfig>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ServiceConfig {
82 pub name: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub project: Option<String>,
86 #[serde(default)]
87 pub runtime: RuntimeKind,
88 pub image: Option<String>,
90 pub module: Option<String>,
92 #[serde(default)]
93 pub replicas: Replicas,
94 pub port: Option<u16>,
96 pub host_port: Option<u16>,
98 pub domain: Option<String>,
100 #[serde(default)]
103 pub routes: Vec<String>,
104 pub health: Option<String>,
106 pub readiness: Option<ProbeConfig>,
108 pub liveness: Option<ProbeConfig>,
110 #[serde(default)]
111 pub env: HashMap<String, String>,
112 pub resources: Option<ResourceLimits>,
113 pub volume: Option<VolumeSpec>,
114 pub deploy: Option<DeployStrategy>,
115 pub placement: Option<PlacementConstraint>,
116 pub network: Option<String>,
119 #[serde(default)]
121 pub aliases: Vec<String>,
122 #[serde(default)]
124 pub mounts: Vec<String>,
125 #[serde(default)]
127 pub triggers: Vec<String>,
128 pub assets: Option<String>,
130 pub build: Option<BuildConfig>,
133 pub tls_cert: Option<String>,
135 pub tls_key: Option<String>,
137 #[serde(default)]
139 pub internal: bool,
140 #[serde(default)]
142 pub depends_on: Vec<String>,
143 #[serde(default)]
145 pub cmd: Vec<String>,
146 #[serde(default)]
148 pub extra_ports: Vec<String>,
149 #[serde(default)]
155 pub strip_prefix: Option<String>,
156 #[serde(default)]
158 pub pull_policy: PullPolicy,
159}
160
161impl ServiceConfig {
162 pub fn spec_matches(&self, other: &Self) -> bool {
168 self.image == other.image
169 && self.module == other.module
170 && self.env == other.env
171 && self.cmd == other.cmd
172 && self.port == other.port
173 && self.host_port == other.host_port
174 && self.domain == other.domain
175 && self.routes == other.routes
176 && self.volume == other.volume
177 && self.mounts == other.mounts
178 && self.aliases == other.aliases
179 && self.extra_ports == other.extra_ports
180 && self.strip_prefix == other.strip_prefix
181 && self.network == other.network
182 && self.internal == other.internal
183 && self.health == other.health
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn base_config() -> ServiceConfig {
192 ServiceConfig {
193 name: "test-svc".into(),
194 project: None,
195 runtime: RuntimeKind::Container,
196 image: Some("nginx:latest".into()),
197 module: None,
198 replicas: Replicas::Fixed(1),
199 port: Some(80),
200 host_port: None,
201 domain: Some("test.example.com".into()),
202 routes: vec!["/*".into()],
203 health: Some("/healthz".into()),
204 readiness: None,
205 liveness: None,
206 env: HashMap::from([("KEY".into(), "val".into())]),
207 resources: None,
208 volume: None,
209 deploy: None,
210 placement: None,
211 network: Some("web".into()),
212 aliases: vec!["test".into()],
213 mounts: vec!["/host:/container".into()],
214 triggers: vec![],
215 assets: None,
216 build: None,
217 tls_cert: None,
218 tls_key: None,
219 internal: false,
220 depends_on: vec![],
221 cmd: vec![],
222 extra_ports: vec!["8080:80".into()],
223 strip_prefix: Some("/api".into()),
224 pull_policy: Default::default(),
225 }
226 }
227
228 #[test]
229 fn identical_configs_match() {
230 let a = base_config();
231 let b = base_config();
232 assert!(a.spec_matches(&b));
233 }
234
235 #[test]
236 fn image_change_detected() {
237 let a = base_config();
238 let mut b = base_config();
239 b.image = Some("nginx:1.27".into());
240 assert!(!a.spec_matches(&b));
241 }
242
243 #[test]
244 fn env_change_detected() {
245 let a = base_config();
246 let mut b = base_config();
247 b.env.insert("NEW_KEY".into(), "new_val".into());
248 assert!(!a.spec_matches(&b));
249 }
250
251 #[test]
252 fn extra_ports_change_detected() {
253 let a = base_config();
254 let mut b = base_config();
255 b.extra_ports = vec!["9090:90".into()];
256 assert!(!a.spec_matches(&b));
257 }
258
259 #[test]
260 fn mounts_change_detected() {
261 let a = base_config();
262 let mut b = base_config();
263 b.mounts.push("/extra:/path".into());
264 assert!(!a.spec_matches(&b));
265 }
266
267 #[test]
268 fn volume_change_detected() {
269 let a = base_config();
270 let mut b = base_config();
271 b.volume = Some(crate::types::VolumeSpec {
272 path: "/data".into(),
273 size: None,
274 });
275 assert!(!a.spec_matches(&b));
276 }
277
278 #[test]
279 fn domain_change_detected() {
280 let a = base_config();
281 let mut b = base_config();
282 b.domain = Some("new.example.com".into());
283 assert!(!a.spec_matches(&b));
284 }
285
286 #[test]
287 fn aliases_change_detected() {
288 let a = base_config();
289 let mut b = base_config();
290 b.aliases.push("new-alias".into());
291 assert!(!a.spec_matches(&b));
292 }
293
294 #[test]
295 fn strip_prefix_change_detected() {
296 let a = base_config();
297 let mut b = base_config();
298 b.strip_prefix = None;
299 assert!(!a.spec_matches(&b));
300 }
301
302 #[test]
303 fn network_change_detected() {
304 let a = base_config();
305 let mut b = base_config();
306 b.network = Some("internal".into());
307 assert!(!a.spec_matches(&b));
308 }
309
310 #[test]
311 fn internal_flag_change_detected() {
312 let a = base_config();
313 let mut b = base_config();
314 b.internal = true;
315 assert!(!a.spec_matches(&b));
316 }
317
318 #[test]
319 fn port_change_detected() {
320 let a = base_config();
321 let mut b = base_config();
322 b.port = Some(8080);
323 assert!(!a.spec_matches(&b));
324 }
325
326 #[test]
327 fn cmd_change_detected() {
328 let a = base_config();
329 let mut b = base_config();
330 b.cmd = vec!["--debug".into()];
331 assert!(!a.spec_matches(&b));
332 }
333
334 #[test]
335 fn non_spec_fields_ignored() {
336 let a = base_config();
337 let mut b = base_config();
338 b.name = "different-name".into();
340 b.project = Some("other-project".into());
341 b.replicas = Replicas::Fixed(5);
342 assert!(a.spec_matches(&b));
343 }
344
345 #[test]
346 fn unresolved_secret_templates_match() {
347 let mut a = base_config();
348 let mut b = base_config();
349 a.env.insert("TOKEN".into(), "${secrets.MY_TOKEN}".into());
351 b.env.insert("TOKEN".into(), "${secrets.MY_TOKEN}".into());
352 assert!(a.spec_matches(&b));
353 }
354
355 #[test]
356 fn resolved_vs_unresolved_differs() {
357 let mut a = base_config();
358 let mut b = base_config();
359 a.env.insert("TOKEN".into(), "${secrets.MY_TOKEN}".into());
361 b.env
362 .insert("TOKEN".into(), "actual-secret-value-123".into());
363 assert!(!a.spec_matches(&b));
364 }
365}