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 optional `export:` manifest
4//! section is turned into concrete values consumed by the emitters. Keeping
5//! all defaults here (namespace derived from the project name, replicas of
6//! one, resources enabled by default) means they are defined and tested once
7//! and shared by every emitter without duplication.
8
9use lightshuttle_manifest::{ExportConfig, ImagePullPolicy};
10
11use crate::model::Target;
12
13/// Environment key fragments that classify a variable as a secret rather than
14/// plain configuration.
15///
16/// Each marker is matched case-insensitively against the full environment
17/// variable key. When a match is found, the emitter routes the variable into
18/// a secret store (a Kubernetes `Secret` or a Helm `stringData` block) and
19/// replaces its value with a placeholder so real credentials never appear in
20/// the exported artifact.
21///
22/// All emitters reference this single slice so the classification stays in
23/// sync across all export targets.
24///
25/// ```rust
26/// use lightshuttle_export::resolve::SECRET_MARKERS;
27///
28/// assert!(SECRET_MARKERS.contains(&"PASSWORD"));
29/// assert!(SECRET_MARKERS.contains(&"TOKEN"));
30/// ```
31pub const SECRET_MARKERS: &[&str] = &[
32    "PASSWORD",
33    "PASSWD",
34    "PASS",
35    "SECRET",
36    "TOKEN",
37    "KEY",
38    "CREDENTIAL",
39    "AUTH",
40    "CERT",
41    "PWD",
42];
43
44/// Default replica count when neither a per-resource nor a per-target
45/// override is set.
46const DEFAULT_REPLICAS: u32 = 1;
47
48/// Default Helm chart version when neither the chart override nor the
49/// project version is set.
50const DEFAULT_CHART_VERSION: &str = "0.1.0";
51
52/// Returns `true` when `resource` should be emitted for `target`.
53///
54/// A resource is included by default. It is excluded only when the manifest
55/// `export:` section contains an explicit `enabled: false` override for the
56/// given resource and target combination.
57///
58/// ```rust
59/// use lightshuttle_export::{Target, resolve::enabled_for};
60///
61/// // Without any export config, every resource is enabled.
62/// assert!(enabled_for(Target::Compose, "db", None));
63/// ```
64#[must_use]
65pub fn enabled_for(target: Target, resource: &str, export: Option<&ExportConfig>) -> bool {
66    let Some(export) = export else { return true };
67    let enabled = match target {
68        Target::Compose => export
69            .compose
70            .as_ref()
71            .and_then(|t| t.resources.get(resource))
72            .and_then(|r| r.enabled),
73        Target::Kubernetes => export
74            .kubernetes
75            .as_ref()
76            .and_then(|t| t.resources.get(resource))
77            .and_then(|r| r.enabled),
78        Target::Helm => export
79            .helm
80            .as_ref()
81            .and_then(|t| t.resources.get(resource))
82            .and_then(|r| r.enabled),
83    };
84    enabled.unwrap_or(true)
85}
86
87/// Returns the replica count for `resource` on `target`.
88///
89/// Resolution order: per-resource override -> per-target default -> `1`.
90/// Compose has no replica concept and always returns `1` regardless of any
91/// override.
92///
93/// ```rust
94/// use lightshuttle_export::{Target, resolve::replicas_for};
95///
96/// // Defaults to 1 when no export config is provided.
97/// assert_eq!(replicas_for(Target::Kubernetes, "api", None), 1);
98/// assert_eq!(replicas_for(Target::Compose, "api", None), 1);
99/// ```
100#[must_use]
101pub fn replicas_for(target: Target, resource: &str, export: Option<&ExportConfig>) -> u32 {
102    let Some(export) = export else {
103        return DEFAULT_REPLICAS;
104    };
105    match target {
106        Target::Compose => DEFAULT_REPLICAS,
107        Target::Kubernetes => export.kubernetes.as_ref().map_or(DEFAULT_REPLICAS, |t| {
108            t.resources
109                .get(resource)
110                .and_then(|r| r.replicas)
111                .or(t.replicas)
112                .unwrap_or(DEFAULT_REPLICAS)
113        }),
114        Target::Helm => export.helm.as_ref().map_or(DEFAULT_REPLICAS, |t| {
115            t.resources
116                .get(resource)
117                .and_then(|r| r.replicas)
118                .or(t.replicas)
119                .unwrap_or(DEFAULT_REPLICAS)
120        }),
121    }
122}
123
124/// Returns the Kubernetes namespace to use for the export.
125///
126/// If the manifest `export.kubernetes.namespace` override is set, it is used
127/// as-is. Otherwise the project name is returned as the default namespace.
128///
129/// ```rust
130/// use lightshuttle_export::resolve::namespace_for;
131///
132/// assert_eq!(namespace_for("my-project", None), "my-project");
133/// ```
134#[must_use]
135pub fn namespace_for(project: &str, export: Option<&ExportConfig>) -> String {
136    export
137        .and_then(|e| e.kubernetes.as_ref())
138        .and_then(|k| k.namespace.clone())
139        .unwrap_or_else(|| project.to_owned())
140}
141
142/// Returns the image pull policy for `resource` on the Kubernetes or Helm target.
143///
144/// Resolution order: per-resource override -> per-target default ->
145/// `IfNotPresent`.
146///
147/// ```rust
148/// use lightshuttle_export::resolve::image_pull_policy_for;
149/// use lightshuttle_manifest::ImagePullPolicy;
150///
151/// assert_eq!(image_pull_policy_for("api", None), ImagePullPolicy::IfNotPresent);
152/// ```
153#[must_use]
154pub fn image_pull_policy_for(resource: &str, export: Option<&ExportConfig>) -> ImagePullPolicy {
155    export
156        .and_then(|e| e.kubernetes.as_ref())
157        .map(|k| {
158            k.resources
159                .get(resource)
160                .and_then(|r| r.image_pull_policy)
161                .or(k.image_pull_policy)
162                .unwrap_or_default()
163        })
164        .unwrap_or_default()
165}
166
167/// Returns the Helm chart name.
168///
169/// If `export.helm.chart_name` is set, it is returned. Otherwise the project
170/// name is used.
171///
172/// ```rust
173/// use lightshuttle_export::resolve::chart_name_for;
174///
175/// assert_eq!(chart_name_for("my-project", None), "my-project");
176/// ```
177#[must_use]
178pub fn chart_name_for(project: &str, export: Option<&ExportConfig>) -> String {
179    export
180        .and_then(|e| e.helm.as_ref())
181        .and_then(|h| h.chart_name.clone())
182        .unwrap_or_else(|| project.to_owned())
183}
184
185/// Returns the Helm chart version string.
186///
187/// Resolution order: `export.helm.chart_version` override -> `project_version`
188/// -> `"0.1.0"`.
189///
190/// ```rust
191/// use lightshuttle_export::resolve::chart_version_for;
192///
193/// assert_eq!(chart_version_for(Some("1.2.3"), None), "1.2.3");
194/// assert_eq!(chart_version_for(None, None), "0.1.0");
195/// ```
196#[must_use]
197pub fn chart_version_for(project_version: Option<&str>, export: Option<&ExportConfig>) -> String {
198    export
199        .and_then(|e| e.helm.as_ref())
200        .and_then(|h| h.chart_version.clone())
201        .or_else(|| project_version.map(ToOwned::to_owned))
202        .unwrap_or_else(|| DEFAULT_CHART_VERSION.to_owned())
203}
204
205/// Sanitise a manifest name into a DNS-1123 compliant label.
206///
207/// Lowercases the input, replaces every character outside `[a-z0-9-]`
208/// with a hyphen, prepends `x` when the result would start with a digit
209/// or a hyphen, and truncates to 63 characters (stripping any trailing
210/// hyphens produced by the truncation).
211#[must_use]
212pub(crate) fn dns_name(name: &str) -> String {
213    let normalized: String = name
214        .to_lowercase()
215        .chars()
216        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
217        .collect();
218    let prefixed = if normalized
219        .chars()
220        .next()
221        .is_none_or(|c| c == '-' || c.is_ascii_digit())
222    {
223        format!("x{normalized}")
224    } else {
225        normalized
226    };
227    let truncated: String = prefixed.chars().take(63).collect();
228    truncated.trim_end_matches('-').to_owned()
229}
230
231#[cfg(test)]
232mod tests {
233    use super::dns_name;
234
235    #[test]
236    fn dns_name_already_valid() {
237        assert_eq!(dns_name("my-service"), "my-service");
238    }
239
240    #[test]
241    fn dns_name_lowercase() {
242        assert_eq!(dns_name("MyService"), "myservice");
243    }
244
245    #[test]
246    fn dns_name_underscores_become_hyphens() {
247        assert_eq!(dns_name("my_service"), "my-service");
248    }
249
250    #[test]
251    fn dns_name_leading_digit_gets_prefix() {
252        assert_eq!(dns_name("1redis"), "x1redis");
253    }
254
255    #[test]
256    fn dns_name_leading_hyphen_gets_prefix() {
257        assert_eq!(dns_name("-leading"), "x-leading");
258    }
259
260    #[test]
261    fn dns_name_trailing_hyphen_stripped() {
262        assert_eq!(dns_name("trailing-"), "trailing");
263    }
264
265    #[test]
266    fn dns_name_truncated_to_63() {
267        let long = "a".repeat(70);
268        assert_eq!(dns_name(&long).len(), 63);
269    }
270
271    #[test]
272    fn dns_name_truncation_strips_trailing_hyphen() {
273        let name = format!("{}-b", "a".repeat(62));
274        let result = dns_name(&name);
275        assert!(
276            !result.ends_with('-'),
277            "must not end with hyphen after truncation"
278        );
279        assert!(result.len() <= 63);
280    }
281}