Skip to main content

hyperi_rustlib/deployment/
contract.rs

1// Project:   hyperi-rustlib
2// File:      src/deployment/contract.rs
3// Purpose:   Deployment contract types
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Deployment contract types.
10
11use serde::{Deserialize, Serialize};
12
13use super::keda::KedaContract;
14use super::native_deps::NativeDepsContract;
15
16/// Container image profile -- controls what goes into the generated Dockerfile.
17///
18/// Both profiles use the same linking strategy (dynamic). The difference is
19/// optimisation level, debug tooling, and image metadata.
20///
21/// # Image tagging convention
22///
23/// | Profile | Tag | Example |
24/// |---------|-----|---------|
25/// | `Production` | `:<version>`, `:latest` | `dfe-loader:1.15.0` |
26/// | `Development` | `:<version>-dev`, `:latest-dev` | `dfe-loader:1.15.0-dev` |
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum ImageProfile {
30    /// Minimal production image -- stripped binary, no debug tools.
31    #[default]
32    Production,
33    /// Development image -- includes diagnostic tools (bash, strace, tcpdump,
34    /// procps, dnsutils, net-tools). Same binary, same linking.
35    Development,
36}
37
38/// Deployment-facing contract points derived from the app config cascade.
39///
40/// Apps build this from their `Config::default()`. Validation functions
41/// compare Helm charts and Dockerfiles against these values. Generation
42/// functions create deployment artifacts (Dockerfile, Helm chart, Compose
43/// fragment) from scratch.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct DeploymentContract {
46    /// Contract schema version. CI checks this and fails if unsupported.
47    #[serde(default = "default_schema_version")]
48    pub schema_version: u32,
49
50    /// Application name (e.g., "dfe-loader") -- matched against Chart.yaml `name`.
51    pub app_name: String,
52
53    /// Binary name (e.g., "dfe-loader"). Defaults to app_name if empty.
54    #[serde(default)]
55    pub binary_name: String,
56
57    /// One-line description for Chart.yaml.
58    #[serde(default)]
59    pub description: String,
60
61    /// Metrics/health listen port (e.g., 9090).
62    pub metrics_port: u16,
63
64    /// Health probe endpoint paths.
65    pub health: HealthContract,
66
67    /// Environment variable prefix (e.g., "DFE_LOADER").
68    /// Used with `__` nesting for figment config cascade.
69    pub env_prefix: String,
70
71    /// Prometheus metric namespace/prefix (e.g., "loader").
72    pub metric_prefix: String,
73
74    /// Config file mount path (e.g., "/etc/dfe/loader.yaml").
75    pub config_mount_path: String,
76
77    /// Container registry base (e.g., "ghcr.io/hyperi-io").
78    #[serde(default = "default_image_registry")]
79    pub image_registry: String,
80
81    /// Additional ports beyond metrics (e.g., HTTP data port for receiver).
82    #[serde(default)]
83    pub extra_ports: Vec<PortContract>,
84
85    /// Default ENTRYPOINT args (e.g., `["--config", "/etc/dfe/loader.yaml"]`).
86    #[serde(default)]
87    pub entrypoint_args: Vec<String>,
88
89    /// Secret groups injected from K8s Secrets.
90    #[serde(default)]
91    pub secrets: Vec<SecretGroupContract>,
92
93    /// App-specific config YAML for values.yaml (serialised as serde_json::Value).
94    #[serde(default)]
95    pub default_config: Option<serde_json::Value>,
96
97    /// Docker Compose service dependencies (e.g., `["kafka", "clickhouse"]`).
98    #[serde(default)]
99    pub depends_on: Vec<String>,
100
101    /// KEDA autoscaling contract (None if KEDA not used).
102    pub keda: Option<KedaContract>,
103
104    /// Base container image for the runtime stage.
105    // I don't like doing it this way but it's the best compromise option
106    #[serde(default = "default_base_image")]
107    pub base_image: String,
108
109    /// Runtime native dependencies for the container image.
110    ///
111    /// Use [`NativeDepsContract::for_rustlib_features`] to auto-populate from
112    /// hyperi-rustlib feature flags. The Dockerfile generator emits the correct
113    /// APT repo setup and package installation commands.
114    #[serde(default)]
115    pub native_deps: NativeDepsContract,
116
117    /// Image profile -- production (minimal) or development (debug tools).
118    ///
119    /// Defaults to [`ImageProfile::Production`]. Use [`with_dev_profile`](Self::with_dev_profile)
120    /// to derive a development variant from an existing contract.
121    #[serde(default)]
122    pub image_profile: ImageProfile,
123
124    /// OCI image labels (static -- dynamic labels injected by CI at build time).
125    #[serde(default)]
126    pub oci_labels: OciLabels,
127}
128
129/// OCI image labels for the container.
130///
131/// Static labels are set from the contract. Dynamic labels (source, revision,
132/// version, created) are injected by CI at build time via `--build-arg`.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct OciLabels {
135    /// Image title (defaults to app_name).
136    #[serde(default)]
137    pub title: String,
138    /// Image description.
139    #[serde(default)]
140    pub description: String,
141    /// Image vendor.
142    #[serde(default = "default_vendor")]
143    pub vendor: String,
144    /// License identifier.
145    #[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/// Health probe endpoint paths.
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct HealthContract {
175    /// Liveness probe path (e.g., "/healthz").
176    pub liveness_path: String,
177
178    /// Readiness probe path (e.g., "/readyz").
179    pub readiness_path: String,
180
181    /// Prometheus metrics path (e.g., "/metrics").
182    pub metrics_path: String,
183}
184
185/// Additional container port beyond the metrics port.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct PortContract {
188    /// Port name (e.g., "http").
189    pub name: String,
190    /// Port number (e.g., 8080).
191    pub port: u16,
192    /// Protocol (default: "TCP").
193    #[serde(default = "default_protocol")]
194    pub protocol: String,
195}
196
197/// A group of secrets from the same K8s Secret (e.g., "kafka", "clickhouse").
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct SecretGroupContract {
200    /// Group name (e.g., "kafka", "clickhouse").
201    /// Used in values.yaml section name and helper template names.
202    pub group_name: String,
203
204    /// Environment variables injected from this secret group.
205    pub env_vars: Vec<SecretEnvContract>,
206}
207
208/// A single environment variable sourced from a K8s Secret.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct SecretEnvContract {
211    /// Full env var name (e.g., "DFE_LOADER__KAFKA__PASSWORD").
212    pub env_var: String,
213
214    /// Key name in values.yaml secretKeys and default values
215    /// (e.g., "password", "username").
216    pub key_name: String,
217
218    /// Default K8s secret key name (e.g., "kafka-password").
219    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    /// Get the effective binary name (falls back to app_name).
236    #[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    /// Get the config file name from the mount path (e.g., "loader.yaml").
246    #[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    /// Get the config mount directory (e.g., "/etc/dfe").
255    #[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    /// Serialise the contract to JSON for `--emit-contract` CLI support.
263    #[must_use]
264    pub fn to_json(&self) -> String {
265        serde_json::to_string_pretty(self).unwrap_or_default()
266    }
267
268    /// Serialise the contract to YAML.
269    #[must_use]
270    pub fn to_yaml(&self) -> String {
271        serde_yaml_ng::to_string(self).unwrap_or_default()
272    }
273
274    /// Return a clone with [`ImageProfile::Development`] set.
275    ///
276    /// Useful for generating both production and dev Dockerfiles from a single
277    /// contract definition.
278    #[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}