hyperi_rustlib/deployment/
contract.rs1use serde::{Deserialize, Serialize};
12
13use super::keda::KedaContract;
14use super::native_deps::NativeDepsContract;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum ImageProfile {
30 #[default]
32 Production,
33 Development,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct DeploymentContract {
46 #[serde(default = "default_schema_version")]
48 pub schema_version: u32,
49
50 pub app_name: String,
52
53 #[serde(default)]
55 pub binary_name: String,
56
57 #[serde(default)]
59 pub description: String,
60
61 pub metrics_port: u16,
63
64 pub health: HealthContract,
66
67 pub env_prefix: String,
70
71 pub metric_prefix: String,
73
74 pub config_mount_path: String,
76
77 #[serde(default = "default_image_registry")]
79 pub image_registry: String,
80
81 #[serde(default)]
83 pub extra_ports: Vec<PortContract>,
84
85 #[serde(default)]
87 pub entrypoint_args: Vec<String>,
88
89 #[serde(default)]
91 pub secrets: Vec<SecretGroupContract>,
92
93 #[serde(default)]
95 pub default_config: Option<serde_json::Value>,
96
97 #[serde(default)]
99 pub depends_on: Vec<String>,
100
101 pub keda: Option<KedaContract>,
103
104 #[serde(default = "default_base_image")]
107 pub base_image: String,
108
109 #[serde(default)]
115 pub native_deps: NativeDepsContract,
116
117 #[serde(default)]
122 pub image_profile: ImageProfile,
123
124 #[serde(default)]
126 pub oci_labels: OciLabels,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct OciLabels {
135 #[serde(default)]
137 pub title: String,
138 #[serde(default)]
140 pub description: String,
141 #[serde(default = "default_vendor")]
143 pub vendor: String,
144 #[serde(default = "default_license")]
146 pub licenses: String,
147}
148
149impl Default for OciLabels {
150 fn default() -> Self {
151 Self {
152 title: String::new(),
153 description: String::new(),
154 vendor: default_vendor(),
155 licenses: default_license(),
156 }
157 }
158}
159
160fn default_vendor() -> String {
161 "HYPERI PTY LIMITED".to_string()
162}
163
164fn default_license() -> String {
165 "BUSL-1.1".to_string()
166}
167
168fn default_schema_version() -> u32 {
169 2
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct HealthContract {
175 pub liveness_path: String,
177
178 pub readiness_path: String,
180
181 pub metrics_path: String,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct PortContract {
188 pub name: String,
190 pub port: u16,
192 #[serde(default = "default_protocol")]
194 pub protocol: String,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct SecretGroupContract {
200 pub group_name: String,
203
204 pub env_vars: Vec<SecretEnvContract>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct SecretEnvContract {
211 pub env_var: String,
213
214 pub key_name: String,
217
218 pub secret_key: String,
220}
221
222fn default_base_image() -> String {
223 "ubuntu:24.04".to_string()
224}
225
226fn default_image_registry() -> String {
227 "ghcr.io/hyperi-io".to_string()
228}
229
230fn default_protocol() -> String {
231 "TCP".to_string()
232}
233
234impl DeploymentContract {
235 #[must_use]
237 pub fn binary(&self) -> &str {
238 if self.binary_name.is_empty() {
239 &self.app_name
240 } else {
241 &self.binary_name
242 }
243 }
244
245 #[must_use]
247 pub fn config_filename(&self) -> &str {
248 self.config_mount_path
249 .rsplit('/')
250 .next()
251 .unwrap_or("config.yaml")
252 }
253
254 #[must_use]
256 pub fn config_dir(&self) -> &str {
257 self.config_mount_path
258 .rsplit_once('/')
259 .map_or("/etc", |(dir, _)| dir)
260 }
261
262 #[must_use]
264 pub fn to_json(&self) -> String {
265 serde_json::to_string_pretty(self).unwrap_or_default()
266 }
267
268 #[must_use]
270 pub fn to_yaml(&self) -> String {
271 serde_yaml_ng::to_string(self).unwrap_or_default()
272 }
273
274 #[must_use]
279 pub fn with_dev_profile(&self) -> Self {
280 let mut dev = self.clone();
281 dev.image_profile = ImageProfile::Development;
282 dev
283 }
284}
285
286impl Default for HealthContract {
287 fn default() -> Self {
288 Self {
289 liveness_path: "/healthz".to_string(),
290 readiness_path: "/readyz".to_string(),
291 metrics_path: "/metrics".to_string(),
292 }
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn test_health_contract_defaults() {
302 let h = HealthContract::default();
303 assert_eq!(h.liveness_path, "/healthz");
304 assert_eq!(h.readiness_path, "/readyz");
305 assert_eq!(h.metrics_path, "/metrics");
306 }
307
308 #[test]
309 fn test_contract_to_json() {
310 let contract = DeploymentContract {
311 app_name: "test-app".into(),
312 metrics_port: 9090,
313 health: HealthContract::default(),
314 env_prefix: "TEST_APP".into(),
315 metric_prefix: "test".into(),
316 config_mount_path: "/etc/test/config.yaml".into(),
317 keda: None,
318 binary_name: String::new(),
319 description: String::new(),
320 image_registry: default_image_registry(),
321 extra_ports: vec![],
322 entrypoint_args: vec![],
323 secrets: vec![],
324 default_config: None,
325 depends_on: vec![],
326 base_image: "ubuntu:24.04".into(),
327 native_deps: NativeDepsContract::default(),
328 image_profile: ImageProfile::default(),
329 schema_version: 2,
330 oci_labels: OciLabels::default(),
331 };
332 let json = contract.to_json();
333 assert!(json.contains("test-app"));
334 assert!(json.contains("9090"));
335 }
336
337 #[test]
338 fn test_contract_roundtrip_json() {
339 let contract = DeploymentContract {
340 app_name: "roundtrip".into(),
341 metrics_port: 8080,
342 health: HealthContract::default(),
343 env_prefix: "RT".into(),
344 metric_prefix: "rt".into(),
345 config_mount_path: "/config.yaml".into(),
346 keda: None,
347 binary_name: String::new(),
348 description: String::new(),
349 image_registry: default_image_registry(),
350 extra_ports: vec![],
351 entrypoint_args: vec![],
352 secrets: vec![],
353 default_config: None,
354 depends_on: vec![],
355 base_image: "ubuntu:24.04".into(),
356 native_deps: NativeDepsContract::default(),
357 image_profile: ImageProfile::default(),
358 schema_version: 2,
359 oci_labels: OciLabels::default(),
360 };
361 let json = contract.to_json();
362 let parsed: DeploymentContract = serde_json::from_str(&json).unwrap();
363 assert_eq!(parsed.app_name, "roundtrip");
364 assert_eq!(parsed.metrics_port, 8080);
365 }
366
367 #[test]
368 fn test_binary_name_fallback() {
369 let contract = DeploymentContract {
370 app_name: "my-app".into(),
371 binary_name: String::new(),
372 metrics_port: 9090,
373 health: HealthContract::default(),
374 env_prefix: "MY_APP".into(),
375 metric_prefix: "app".into(),
376 config_mount_path: "/etc/app/config.yaml".into(),
377 keda: None,
378 description: String::new(),
379 image_registry: default_image_registry(),
380 extra_ports: vec![],
381 entrypoint_args: vec![],
382 secrets: vec![],
383 default_config: None,
384 depends_on: vec![],
385 base_image: "ubuntu:24.04".into(),
386 native_deps: NativeDepsContract::default(),
387 image_profile: ImageProfile::default(),
388 schema_version: 2,
389 oci_labels: OciLabels::default(),
390 };
391 assert_eq!(contract.binary(), "my-app");
392 }
393
394 #[test]
395 fn test_config_filename() {
396 let contract = DeploymentContract {
397 app_name: "test".into(),
398 config_mount_path: "/etc/dfe/loader.yaml".into(),
399 metrics_port: 9090,
400 health: HealthContract::default(),
401 env_prefix: "T".into(),
402 metric_prefix: "t".into(),
403 keda: None,
404 binary_name: String::new(),
405 description: String::new(),
406 image_registry: default_image_registry(),
407 extra_ports: vec![],
408 entrypoint_args: vec![],
409 secrets: vec![],
410 default_config: None,
411 depends_on: vec![],
412 base_image: "ubuntu:24.04".into(),
413 native_deps: NativeDepsContract::default(),
414 image_profile: ImageProfile::default(),
415 schema_version: 2,
416 oci_labels: OciLabels::default(),
417 };
418 assert_eq!(contract.config_filename(), "loader.yaml");
419 assert_eq!(contract.config_dir(), "/etc/dfe");
420 }
421}