Skip to main content

lightshuttle_export/
resolve.rs

1//! Pure resolution of per-target defaults and overrides.
2//!
3//! These helpers are the single place where the `export:` section is
4//! turned into concrete values, so the defaults (`namespace` from the
5//! project, `replicas` of one, `enabled` by default) are defined and
6//! tested once and shared by every emitter.
7
8use lightshuttle_manifest::{ExportConfig, ImagePullPolicy};
9
10use crate::model::Target;
11
12/// Environment key fragments that route a variable into a secret store
13/// rather than plain configuration. Matched case-insensitively against
14/// the full key name.
15///
16/// Emitters reference this single slice so the set stays in sync across
17/// all export targets.
18pub const SECRET_MARKERS: &[&str] = &[
19    "PASSWORD",
20    "PASSWD",
21    "PASS",
22    "SECRET",
23    "TOKEN",
24    "KEY",
25    "CREDENTIAL",
26    "AUTH",
27    "CERT",
28    "PWD",
29];
30
31/// Default replica count when neither a per-resource nor a per-target
32/// override is set.
33const DEFAULT_REPLICAS: u32 = 1;
34
35/// Default Helm chart version when neither the chart override nor the
36/// project version is set.
37const DEFAULT_CHART_VERSION: &str = "0.1.0";
38
39/// Whether `resource` is emitted for `target`. A resource is included
40/// unless its per-target override sets `enabled: false`.
41#[must_use]
42pub fn enabled_for(target: Target, resource: &str, export: Option<&ExportConfig>) -> bool {
43    let Some(export) = export else { return true };
44    let enabled = match target {
45        Target::Compose => export
46            .compose
47            .as_ref()
48            .and_then(|t| t.resources.get(resource))
49            .and_then(|r| r.enabled),
50        Target::Kubernetes => export
51            .kubernetes
52            .as_ref()
53            .and_then(|t| t.resources.get(resource))
54            .and_then(|r| r.enabled),
55        Target::Helm => export
56            .helm
57            .as_ref()
58            .and_then(|t| t.resources.get(resource))
59            .and_then(|r| r.enabled),
60    };
61    enabled.unwrap_or(true)
62}
63
64/// Replica count for `resource` on `target`: a per-resource override
65/// wins over the per-target default, which falls back to one. Compose
66/// has no replica concept and always resolves to one.
67#[must_use]
68pub fn replicas_for(target: Target, resource: &str, export: Option<&ExportConfig>) -> u32 {
69    let Some(export) = export else {
70        return DEFAULT_REPLICAS;
71    };
72    match target {
73        Target::Compose => DEFAULT_REPLICAS,
74        Target::Kubernetes => export.kubernetes.as_ref().map_or(DEFAULT_REPLICAS, |t| {
75            t.resources
76                .get(resource)
77                .and_then(|r| r.replicas)
78                .or(t.replicas)
79                .unwrap_or(DEFAULT_REPLICAS)
80        }),
81        Target::Helm => export.helm.as_ref().map_or(DEFAULT_REPLICAS, |t| {
82            t.resources
83                .get(resource)
84                .and_then(|r| r.replicas)
85                .or(t.replicas)
86                .unwrap_or(DEFAULT_REPLICAS)
87        }),
88    }
89}
90
91/// Kubernetes namespace: the override if set, otherwise the project
92/// name.
93#[must_use]
94pub fn namespace_for(project: &str, export: Option<&ExportConfig>) -> String {
95    export
96        .and_then(|e| e.kubernetes.as_ref())
97        .and_then(|k| k.namespace.clone())
98        .unwrap_or_else(|| project.to_owned())
99}
100
101/// Image pull policy for `resource`: a per-resource override wins over
102/// the per-target default, which falls back to `IfNotPresent`.
103#[must_use]
104pub fn image_pull_policy_for(resource: &str, export: Option<&ExportConfig>) -> ImagePullPolicy {
105    export
106        .and_then(|e| e.kubernetes.as_ref())
107        .map(|k| {
108            k.resources
109                .get(resource)
110                .and_then(|r| r.image_pull_policy)
111                .or(k.image_pull_policy)
112                .unwrap_or_default()
113        })
114        .unwrap_or_default()
115}
116
117/// Helm chart name: the override if set, otherwise the project name.
118#[must_use]
119pub fn chart_name_for(project: &str, export: Option<&ExportConfig>) -> String {
120    export
121        .and_then(|e| e.helm.as_ref())
122        .and_then(|h| h.chart_name.clone())
123        .unwrap_or_else(|| project.to_owned())
124}
125
126/// Helm chart version: the override if set, otherwise the project
127/// version, otherwise `0.1.0`.
128#[must_use]
129pub fn chart_version_for(project_version: Option<&str>, export: Option<&ExportConfig>) -> String {
130    export
131        .and_then(|e| e.helm.as_ref())
132        .and_then(|h| h.chart_version.clone())
133        .or_else(|| project_version.map(ToOwned::to_owned))
134        .unwrap_or_else(|| DEFAULT_CHART_VERSION.to_owned())
135}
136
137/// Sanitise a manifest name into a DNS-1123 compliant label.
138///
139/// Lowercases the input, replaces every character outside `[a-z0-9-]`
140/// with a hyphen, prepends `x` when the result would start with a digit
141/// or a hyphen, and truncates to 63 characters (stripping any trailing
142/// hyphens produced by the truncation).
143#[must_use]
144pub(crate) fn dns_name(name: &str) -> String {
145    let normalized: String = name
146        .to_lowercase()
147        .chars()
148        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
149        .collect();
150    let prefixed = if normalized
151        .chars()
152        .next()
153        .is_none_or(|c| c == '-' || c.is_ascii_digit())
154    {
155        format!("x{normalized}")
156    } else {
157        normalized
158    };
159    let truncated: String = prefixed.chars().take(63).collect();
160    truncated.trim_end_matches('-').to_owned()
161}
162
163#[cfg(test)]
164mod tests {
165    use super::dns_name;
166
167    #[test]
168    fn dns_name_already_valid() {
169        assert_eq!(dns_name("my-service"), "my-service");
170    }
171
172    #[test]
173    fn dns_name_lowercase() {
174        assert_eq!(dns_name("MyService"), "myservice");
175    }
176
177    #[test]
178    fn dns_name_underscores_become_hyphens() {
179        assert_eq!(dns_name("my_service"), "my-service");
180    }
181
182    #[test]
183    fn dns_name_leading_digit_gets_prefix() {
184        assert_eq!(dns_name("1redis"), "x1redis");
185    }
186
187    #[test]
188    fn dns_name_leading_hyphen_gets_prefix() {
189        assert_eq!(dns_name("-leading"), "x-leading");
190    }
191
192    #[test]
193    fn dns_name_trailing_hyphen_stripped() {
194        assert_eq!(dns_name("trailing-"), "trailing");
195    }
196
197    #[test]
198    fn dns_name_truncated_to_63() {
199        let long = "a".repeat(70);
200        assert_eq!(dns_name(&long).len(), 63);
201    }
202
203    #[test]
204    fn dns_name_truncation_strips_trailing_hyphen() {
205        let name = format!("{}-b", "a".repeat(62));
206        let result = dns_name(&name);
207        assert!(
208            !result.ends_with('-'),
209            "must not end with hyphen after truncation"
210        );
211        assert!(result.len() <= 63);
212    }
213}