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}