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