1use std::collections::HashMap;
5use std::time::Duration;
6
7use indexmap::IndexMap;
8use lightshuttle_manifest::{
9 Command, ContainerConfig, DockerfileConfig, Healthcheck, PortMapping, PostgresConfig,
10 RedisConfig, ResourceKind, Volume,
11};
12
13use crate::error::{Result, SpecError};
14
15pub type ResourceOutputs = IndexMap<String, String>;
26
27#[derive(Debug, Clone)]
30pub struct ResolvedResource {
31 pub spec: ContainerSpec,
33 pub outputs: ResourceOutputs,
37}
38
39const DEFAULT_PG_VERSION: &str = "16";
40const DEFAULT_PG_USER: &str = "postgres";
41const DEFAULT_PG_PORT: u16 = 5432;
42const DEFAULT_REDIS_VERSION: &str = "7";
43const DEFAULT_REDIS_PORT: u16 = 6379;
44const HEALTHCHECK_DEFAULT_INTERVAL: Duration = Duration::from_secs(5);
45const HEALTHCHECK_DEFAULT_TIMEOUT: Duration = Duration::from_secs(3);
46const HEALTHCHECK_DEFAULT_RETRIES: u32 = 5;
47const HEALTHCHECK_DEFAULT_START_PERIOD: Duration = Duration::from_secs(5);
48
49#[derive(Debug, Clone)]
52pub struct ContainerSpec {
53 pub name: String,
55 pub project: String,
58 pub resource: String,
61 pub image: ImageSource,
63 pub env: HashMap<String, String>,
65 pub ports: Vec<PortBinding>,
67 pub volumes: Vec<VolumeBinding>,
69 pub command: Option<Vec<String>>,
71 pub healthcheck: Option<HealthcheckSpec>,
73 pub working_dir: Option<String>,
75}
76
77#[derive(Debug, Clone)]
79pub enum ImageSource {
80 Pull(String),
82 Build {
84 context: String,
86 dockerfile: String,
88 build_args: HashMap<String, String>,
90 target: Option<String>,
92 tag: String,
94 },
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct PortBinding {
100 pub container_port: u16,
102 pub host_address: Option<String>,
104 pub host_port: u16,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct VolumeBinding {
111 pub source: VolumeSource,
113 pub target: String,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum VolumeSource {
120 HostPath(String),
122 Named(String),
124 Anonymous,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct HealthcheckSpec {
132 pub test: Vec<String>,
134 pub interval: Duration,
136 pub timeout: Duration,
138 pub retries: u32,
140 pub start_period: Duration,
142}
143
144pub fn from_resource(
150 project: &str,
151 resource_name: &str,
152 kind: &ResourceKind,
153) -> Result<ResolvedResource> {
154 let name = format!("{project}_{resource_name}");
155 match kind {
156 ResourceKind::Postgres(c) => spec_postgres(name, project, resource_name, c),
157 ResourceKind::Redis(c) => spec_redis(name, project, resource_name, c),
158 ResourceKind::Container(c) => spec_container(name, project, resource_name, c),
159 ResourceKind::Dockerfile(c) => spec_dockerfile(name, project, resource_name, c),
160 }
161}
162
163#[allow(clippy::needless_pass_by_value)]
164fn spec_postgres(
165 name: String,
166 project: &str,
167 resource_name: &str,
168 c: &PostgresConfig,
169) -> Result<ResolvedResource> {
170 let version = c.version.as_deref().unwrap_or(DEFAULT_PG_VERSION);
171 let image = c
172 .image
173 .clone()
174 .unwrap_or_else(|| format!("postgres:{version}-alpine"));
175 let database = c
176 .database
177 .clone()
178 .unwrap_or_else(|| resource_name.to_owned());
179 let user = c.user.clone().unwrap_or_else(|| DEFAULT_PG_USER.to_owned());
180 let password = c.password.clone().unwrap_or_else(generate_random_password);
181 let port = c.port.unwrap_or(DEFAULT_PG_PORT);
182
183 let mut env = HashMap::new();
184 env.insert("POSTGRES_DB".to_owned(), database);
185 env.insert("POSTGRES_USER".to_owned(), user.clone());
186 env.insert("POSTGRES_PASSWORD".to_owned(), password);
187
188 let ports = vec![PortBinding {
189 container_port: port,
190 host_address: None,
191 host_port: port,
192 }];
193
194 let volumes = volume_to_binding(c.volume.as_ref(), "/var/lib/postgresql/data");
195
196 let healthcheck = c
197 .healthcheck
198 .as_ref()
199 .map(parse_healthcheck)
200 .transpose()?
201 .or_else(|| {
202 Some(HealthcheckSpec {
203 test: vec![
204 "CMD".to_owned(),
205 "pg_isready".to_owned(),
206 "-U".to_owned(),
207 user,
208 ],
209 interval: HEALTHCHECK_DEFAULT_INTERVAL,
210 timeout: HEALTHCHECK_DEFAULT_TIMEOUT,
211 retries: HEALTHCHECK_DEFAULT_RETRIES,
212 start_period: HEALTHCHECK_DEFAULT_START_PERIOD,
213 })
214 });
215
216 let spec = ContainerSpec {
217 name: name.clone(),
218 project: project.to_owned(),
219 resource: resource_name.to_owned(),
220 image: ImageSource::Pull(image),
221 env: env.clone(),
222 ports,
223 volumes,
224 command: None,
225 healthcheck,
226 working_dir: None,
227 };
228
229 let mut outputs = ResourceOutputs::new();
230 outputs.insert("host".to_owned(), name.clone());
231 outputs.insert("port".to_owned(), port.to_string());
232 let user_out = env.get("POSTGRES_USER").cloned().unwrap_or_default();
233 let pwd_out = env.get("POSTGRES_PASSWORD").cloned().unwrap_or_default();
234 let db_out = env.get("POSTGRES_DB").cloned().unwrap_or_default();
235 outputs.insert("user".to_owned(), user_out.clone());
236 outputs.insert("password".to_owned(), pwd_out.clone());
237 outputs.insert("database".to_owned(), db_out.clone());
238 outputs.insert(
239 "url".to_owned(),
240 format!("postgres://{user_out}:{pwd_out}@{name}:{port}/{db_out}"),
241 );
242
243 Ok(ResolvedResource { spec, outputs })
244}
245
246#[allow(clippy::needless_pass_by_value)]
247fn spec_redis(
248 name: String,
249 project: &str,
250 resource_name: &str,
251 c: &RedisConfig,
252) -> Result<ResolvedResource> {
253 let version = c.version.as_deref().unwrap_or(DEFAULT_REDIS_VERSION);
254 let image = c
255 .image
256 .clone()
257 .unwrap_or_else(|| format!("redis:{version}-alpine"));
258 let port = c.port.unwrap_or(DEFAULT_REDIS_PORT);
259
260 let mut command = vec!["redis-server".to_owned()];
261 if let Some(password) = c.password.as_deref()
262 && !password.is_empty()
263 {
264 command.push("--requirepass".to_owned());
265 command.push(password.to_owned());
266 }
267
268 let ports = vec![PortBinding {
269 container_port: port,
270 host_address: None,
271 host_port: port,
272 }];
273
274 let volumes = volume_to_binding(c.volume.as_ref(), "/data");
275
276 let healthcheck = c
277 .healthcheck
278 .as_ref()
279 .map(parse_healthcheck)
280 .transpose()?
281 .or_else(|| {
282 Some(HealthcheckSpec {
283 test: vec!["CMD".to_owned(), "redis-cli".to_owned(), "ping".to_owned()],
284 interval: HEALTHCHECK_DEFAULT_INTERVAL,
285 timeout: HEALTHCHECK_DEFAULT_TIMEOUT,
286 retries: HEALTHCHECK_DEFAULT_RETRIES,
287 start_period: HEALTHCHECK_DEFAULT_START_PERIOD,
288 })
289 });
290
291 let password_out = c.password.clone().unwrap_or_default();
292 let spec = ContainerSpec {
293 name: name.clone(),
294 project: project.to_owned(),
295 resource: resource_name.to_owned(),
296 image: ImageSource::Pull(image),
297 env: HashMap::new(),
298 ports,
299 volumes,
300 command: Some(command),
301 healthcheck,
302 working_dir: None,
303 };
304
305 let mut outputs = ResourceOutputs::new();
306 outputs.insert("host".to_owned(), name.clone());
307 outputs.insert("port".to_owned(), port.to_string());
308 outputs.insert("password".to_owned(), password_out.clone());
309 let url = if password_out.is_empty() {
310 format!("redis://{name}:{port}")
311 } else {
312 format!("redis://:{password_out}@{name}:{port}")
313 };
314 outputs.insert("url".to_owned(), url);
315
316 Ok(ResolvedResource { spec, outputs })
317}
318
319#[allow(clippy::needless_pass_by_value)]
320fn spec_container(
321 name: String,
322 project: &str,
323 resource_name: &str,
324 c: &ContainerConfig,
325) -> Result<ResolvedResource> {
326 let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
327
328 let ports = c
329 .ports
330 .iter()
331 .map(parse_port_mapping)
332 .collect::<Result<Vec<_>>>()?;
333 let volumes = c
334 .volumes
335 .iter()
336 .map(|s| parse_volume_string(s))
337 .collect::<Result<Vec<_>>>()?;
338 let command = c
339 .command
340 .as_ref()
341 .map(parse_command)
342 .filter(|cmd| !cmd.is_empty());
343 let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
344
345 let ports_csv: String = ports
346 .iter()
347 .map(|p| p.container_port.to_string())
348 .collect::<Vec<_>>()
349 .join(",");
350 let spec = ContainerSpec {
351 name: name.clone(),
352 project: project.to_owned(),
353 resource: resource_name.to_owned(),
354 image: ImageSource::Pull(c.image.clone()),
355 env,
356 ports,
357 volumes,
358 command,
359 healthcheck,
360 working_dir: c.working_dir.clone(),
361 };
362
363 let mut outputs = ResourceOutputs::new();
364 outputs.insert("host".to_owned(), name);
365 outputs.insert("ports".to_owned(), ports_csv);
366
367 Ok(ResolvedResource { spec, outputs })
368}
369
370#[allow(clippy::needless_pass_by_value)]
371fn spec_dockerfile(
372 name: String,
373 project: &str,
374 resource_name: &str,
375 c: &DockerfileConfig,
376) -> Result<ResolvedResource> {
377 let tag = format!("lightshuttle/{name}:dev");
378
379 let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
380
381 let build_args: HashMap<String, String> = c
382 .build_args
383 .iter()
384 .map(|(k, v)| (k.clone(), v.clone()))
385 .collect();
386
387 let ports = c
388 .ports
389 .iter()
390 .map(parse_port_mapping)
391 .collect::<Result<Vec<_>>>()?;
392 let volumes = c
393 .volumes
394 .iter()
395 .map(|s| parse_volume_string(s))
396 .collect::<Result<Vec<_>>>()?;
397 let command = c
398 .command
399 .as_ref()
400 .map(parse_command)
401 .filter(|cmd| !cmd.is_empty());
402 let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
403
404 let ports_csv: String = ports
405 .iter()
406 .map(|p| p.container_port.to_string())
407 .collect::<Vec<_>>()
408 .join(",");
409 let spec = ContainerSpec {
410 name: name.clone(),
411 project: project.to_owned(),
412 resource: resource_name.to_owned(),
413 image: ImageSource::Build {
414 context: c.context.clone(),
415 dockerfile: c.dockerfile.clone(),
416 build_args,
417 target: c.target.clone(),
418 tag,
419 },
420 env,
421 ports,
422 volumes,
423 command,
424 healthcheck,
425 working_dir: c.working_dir.clone(),
426 };
427
428 let mut outputs = ResourceOutputs::new();
429 outputs.insert("host".to_owned(), name);
430 outputs.insert("ports".to_owned(), ports_csv);
431
432 Ok(ResolvedResource { spec, outputs })
433}
434
435fn volume_to_binding(volume: Option<&Volume>, target: &str) -> Vec<VolumeBinding> {
436 match volume {
437 None | Some(Volume::Boolean(true)) => vec![VolumeBinding {
438 source: VolumeSource::Anonymous,
439 target: target.to_owned(),
440 }],
441 Some(Volume::Boolean(false)) => Vec::new(),
442 Some(Volume::Named(name)) => vec![VolumeBinding {
443 source: VolumeSource::Named(name.clone()),
444 target: target.to_owned(),
445 }],
446 }
447}
448
449fn parse_port_mapping(mapping: &PortMapping) -> Result<PortBinding> {
450 match mapping {
451 PortMapping::Container(port) => Ok(PortBinding {
452 container_port: *port,
453 host_address: None,
454 host_port: *port,
455 }),
456 PortMapping::Mapping(s) => parse_port_string(s),
457 }
458}
459
460fn parse_port_string(input: &str) -> Result<PortBinding> {
461 let parts: Vec<&str> = input.split(':').collect();
462 match parts.as_slice() {
463 [host_port, container_port] => {
464 let host_port: u16 = host_port
465 .parse()
466 .map_err(|_| SpecError::InvalidSpec(format!("invalid host port `{host_port}`")))?;
467 let container_port: u16 = container_port.parse().map_err(|_| {
468 SpecError::InvalidSpec(format!("invalid container port `{container_port}`"))
469 })?;
470 Ok(PortBinding {
471 container_port,
472 host_address: None,
473 host_port,
474 })
475 }
476 [host_address, host_port, container_port] => {
477 let host_port: u16 = host_port
478 .parse()
479 .map_err(|_| SpecError::InvalidSpec(format!("invalid host port `{host_port}`")))?;
480 let container_port: u16 = container_port.parse().map_err(|_| {
481 SpecError::InvalidSpec(format!("invalid container port `{container_port}`"))
482 })?;
483 Ok(PortBinding {
484 container_port,
485 host_address: Some((*host_address).to_owned()),
486 host_port,
487 })
488 }
489 _ => Err(SpecError::InvalidSpec(format!(
490 "invalid port mapping `{input}`"
491 ))),
492 }
493}
494
495fn parse_volume_string(input: &str) -> Result<VolumeBinding> {
496 let (source, target) = input.split_once(':').ok_or_else(|| {
497 SpecError::InvalidSpec(format!(
498 "invalid volume mapping `{input}`: expected `src:target`"
499 ))
500 })?;
501 let source = if source.starts_with('.') || source.starts_with('/') {
502 VolumeSource::HostPath(source.to_owned())
503 } else {
504 if source.contains(['{', '}']) {
505 return Err(SpecError::InvalidSpec(format!(
506 "volume name `{source}` must not contain '{{' or '}}': unsafe in export templates"
507 )));
508 }
509 VolumeSource::Named(source.to_owned())
510 };
511 Ok(VolumeBinding {
512 source,
513 target: target.to_owned(),
514 })
515}
516
517fn parse_command(command: &Command) -> Vec<String> {
518 match command {
519 Command::Single(s) => vec!["sh".to_owned(), "-c".to_owned(), s.clone()],
520 Command::Args(args) => args.clone(),
521 }
522}
523
524fn parse_healthcheck(hc: &Healthcheck) -> Result<HealthcheckSpec> {
525 Ok(HealthcheckSpec {
526 test: hc.test.clone(),
527 interval: parse_duration(&hc.interval)?,
528 timeout: parse_duration(&hc.timeout)?,
529 retries: hc.retries,
530 start_period: parse_duration(&hc.start_period)?,
531 })
532}
533
534fn parse_duration(input: &str) -> Result<Duration> {
535 let trimmed = input.trim();
536 let (digits, unit) = split_duration(trimmed)
537 .ok_or_else(|| SpecError::InvalidSpec(format!("invalid duration `{input}`")))?;
538 let value: f64 = digits
539 .parse()
540 .map_err(|_| SpecError::InvalidSpec(format!("invalid duration `{input}`")))?;
541 let nanos = match unit {
542 "ns" => value,
543 "us" => value * 1_000.0,
544 "ms" => value * 1_000_000.0,
545 "s" => value * 1_000_000_000.0,
546 "m" => value * 60.0 * 1_000_000_000.0,
547 "h" => value * 3_600.0 * 1_000_000_000.0,
548 _ => {
549 return Err(SpecError::InvalidSpec(format!(
550 "invalid duration unit `{unit}`"
551 )));
552 }
553 };
554 if nanos.is_sign_negative() || !nanos.is_finite() {
555 return Err(SpecError::InvalidSpec(format!(
556 "invalid duration `{input}`"
557 )));
558 }
559 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
560 Ok(Duration::from_nanos(nanos as u64))
561}
562
563fn split_duration(input: &str) -> Option<(&str, &str)> {
564 let bytes = input.as_bytes();
565 let mut idx = 0;
566 while idx < bytes.len() && (bytes[idx].is_ascii_digit() || bytes[idx] == b'.') {
567 idx += 1;
568 }
569 if idx == 0 || idx == bytes.len() {
570 return None;
571 }
572 Some((&input[..idx], &input[idx..]))
573}
574
575fn generate_random_password() -> String {
583 use rand::Rng;
584
585 const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
586 const LEN: usize = 24;
587
588 let mut rng = rand::rng();
589 (0..LEN)
590 .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
591 .collect()
592}
593
594#[cfg(test)]
595mod tests {
596 use super::{
597 VolumeSource, generate_random_password, parse_command, parse_duration, parse_port_string,
598 parse_volume_string,
599 };
600 use lightshuttle_manifest::Command;
601 use std::time::Duration;
602
603 #[test]
604 fn parse_port_string_two_part() {
605 let b = parse_port_string("8080:80").unwrap();
606 assert_eq!(b.host_port, 8080);
607 assert_eq!(b.container_port, 80);
608 assert_eq!(b.host_address, None);
609 }
610
611 #[test]
612 fn parse_port_string_three_part() {
613 let b = parse_port_string("127.0.0.1:8080:80").unwrap();
614 assert_eq!(b.host_port, 8080);
615 assert_eq!(b.container_port, 80);
616 assert_eq!(b.host_address.as_deref(), Some("127.0.0.1"));
617 }
618
619 #[test]
620 fn parse_port_string_single_part_is_error() {
621 assert!(parse_port_string("80").is_err());
622 }
623
624 #[test]
625 fn parse_port_string_non_numeric_is_error() {
626 assert!(parse_port_string("abc:80").is_err());
627 }
628
629 #[test]
630 fn parse_volume_string_named() {
631 let b = parse_volume_string("data:/var/lib/data").unwrap();
632 assert!(matches!(b.source, VolumeSource::Named(_)));
633 assert_eq!(b.target, "/var/lib/data");
634 }
635
636 #[test]
637 fn parse_volume_string_relative_host() {
638 let b = parse_volume_string("./src:/app").unwrap();
639 assert!(matches!(b.source, VolumeSource::HostPath(_)));
640 assert_eq!(b.target, "/app");
641 }
642
643 #[test]
644 fn parse_volume_string_absolute_host() {
645 let b = parse_volume_string("/abs/path:/app").unwrap();
646 assert!(matches!(b.source, VolumeSource::HostPath(_)));
647 assert_eq!(b.target, "/app");
648 }
649
650 #[test]
651 fn parse_volume_string_no_colon_is_error() {
652 assert!(parse_volume_string("nodatahere").is_err());
653 }
654
655 #[test]
656 fn parse_volume_string_braces_in_name_is_error() {
657 assert!(parse_volume_string("my{vol}:/data").is_err());
658 }
659
660 #[test]
661 fn parse_duration_seconds() {
662 assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
663 }
664
665 #[test]
666 fn parse_duration_milliseconds() {
667 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
668 }
669
670 #[test]
671 fn parse_duration_minutes() {
672 assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
673 }
674
675 #[test]
676 fn parse_duration_unknown_unit_is_error() {
677 assert!(parse_duration("10x").is_err());
678 }
679
680 #[test]
681 fn parse_duration_no_unit_is_error() {
682 assert!(parse_duration("10").is_err());
683 }
684
685 #[test]
686 fn parse_duration_no_digits_is_error() {
687 assert!(parse_duration("s").is_err());
688 }
689
690 #[test]
691 fn parse_command_empty_args_produces_empty_vec() {
692 assert!(parse_command(&Command::Args(vec![])).is_empty());
693 }
694
695 #[test]
696 fn parse_command_single_becomes_sh_c() {
697 let v = parse_command(&Command::Single("echo hi".to_owned()));
698 assert_eq!(v, vec!["sh", "-c", "echo hi"]);
699 }
700
701 #[test]
702 fn generated_password_has_expected_shape() {
703 let password = generate_random_password();
704 assert_eq!(password.len(), 24);
705 assert!(
706 password
707 .chars()
708 .all(|c| c.is_ascii_alphanumeric() && !"0O1Il".contains(c)),
709 "password must be unambiguous alphanumeric, got `{password}`"
710 );
711 }
712
713 #[test]
714 fn generated_passwords_are_distinct() {
715 let first = generate_random_password();
718 let second = generate_random_password();
719 assert_ne!(first, second);
720 }
721}