1use std::collections::HashMap;
10use std::time::Duration;
11
12use indexmap::IndexMap;
13use lightshuttle_manifest::{
14 Command, ContainerConfig, DockerfileConfig, Healthcheck, PortMapping, PostgresConfig,
15 RedisConfig, ResourceKind, Volume,
16};
17
18use crate::error::{Result, SpecError};
19
20pub type ResourceOutputs = IndexMap<String, String>;
50
51#[derive(Debug, Clone)]
74pub struct ResolvedResource {
75 pub spec: ContainerSpec,
78 pub outputs: ResourceOutputs,
82}
83
84const DEFAULT_PG_VERSION: &str = "16";
85const DEFAULT_PG_USER: &str = "postgres";
86const DEFAULT_PG_PORT: u16 = 5432;
87const DEFAULT_REDIS_VERSION: &str = "7";
88const DEFAULT_REDIS_PORT: u16 = 6379;
89const HEALTHCHECK_DEFAULT_INTERVAL: Duration = Duration::from_secs(5);
90const HEALTHCHECK_DEFAULT_TIMEOUT: Duration = Duration::from_secs(3);
91const HEALTHCHECK_DEFAULT_RETRIES: u32 = 5;
92const HEALTHCHECK_DEFAULT_START_PERIOD: Duration = Duration::from_secs(5);
93
94#[non_exhaustive]
106#[derive(Debug, Clone)]
107pub struct ContainerSpec {
108 pub name: String,
114 pub project: String,
119 pub resource: String,
124 pub image: ImageSource,
127 pub env: HashMap<String, String>,
129 pub ports: Vec<PortBinding>,
131 pub volumes: Vec<VolumeBinding>,
133 pub entrypoint: Option<Vec<String>>,
140 pub command: Option<Vec<String>>,
145 pub healthcheck: Option<HealthcheckSpec>,
148 pub working_dir: Option<String>,
150}
151
152impl ContainerSpec {
153 #[must_use]
160 pub fn new(name: String, project: String, resource: String, image: ImageSource) -> Self {
161 Self {
162 name,
163 project,
164 resource,
165 image,
166 env: HashMap::new(),
167 ports: Vec::new(),
168 volumes: Vec::new(),
169 entrypoint: None,
170 command: None,
171 healthcheck: None,
172 working_dir: None,
173 }
174 }
175}
176
177#[derive(Debug, Clone)]
202pub enum ImageSource {
203 Pull(String),
208 Build {
213 context: String,
215 dockerfile: String,
217 build_args: HashMap<String, String>,
219 target: Option<String>,
221 tag: String,
224 },
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct PortBinding {
252 pub container_port: u16,
254 pub host_address: Option<String>,
257 pub host_port: u16,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct VolumeBinding {
286 pub source: VolumeSource,
288 pub target: String,
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
297pub enum VolumeSource {
298 HostPath(String),
304 Named(String),
310 Anonymous,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct HealthcheckSpec {
347 pub test: Vec<String>,
352 pub interval: Duration,
354 pub timeout: Duration,
356 pub retries: u32,
359 pub start_period: Duration,
361}
362
363pub fn from_resource(
402 project: &str,
403 resource_name: &str,
404 kind: &ResourceKind,
405) -> Result<ResolvedResource> {
406 let name = format!("{project}_{resource_name}");
407 match kind {
408 ResourceKind::Postgres(c) => spec_postgres(name, project, resource_name, c),
409 ResourceKind::Redis(c) => spec_redis(name, project, resource_name, c),
410 ResourceKind::Container(c) => spec_container(name, project, resource_name, c),
411 ResourceKind::Dockerfile(c) => spec_dockerfile(name, project, resource_name, c),
412 }
413}
414
415#[allow(clippy::needless_pass_by_value)]
416fn spec_postgres(
417 name: String,
418 project: &str,
419 resource_name: &str,
420 c: &PostgresConfig,
421) -> Result<ResolvedResource> {
422 let version = c.version.as_deref().unwrap_or(DEFAULT_PG_VERSION);
423 let image = c
424 .image
425 .clone()
426 .unwrap_or_else(|| format!("postgres:{version}-alpine"));
427 let database = c
428 .database
429 .clone()
430 .unwrap_or_else(|| resource_name.to_owned());
431 let user = c.user.clone().unwrap_or_else(|| DEFAULT_PG_USER.to_owned());
432 let password = c.password.clone().unwrap_or_else(generate_random_password);
433 let port = c.port.unwrap_or(DEFAULT_PG_PORT);
434
435 let mut env = HashMap::new();
436 env.insert("POSTGRES_DB".to_owned(), database);
437 env.insert("POSTGRES_USER".to_owned(), user.clone());
438 env.insert("POSTGRES_PASSWORD".to_owned(), password);
439
440 let ports = vec![PortBinding {
441 container_port: port,
442 host_address: None,
443 host_port: port,
444 }];
445
446 let volumes = volume_to_binding(c.volume.as_ref(), "/var/lib/postgresql/data");
447
448 let healthcheck = c
449 .healthcheck
450 .as_ref()
451 .map(parse_healthcheck)
452 .transpose()?
453 .or_else(|| {
454 Some(HealthcheckSpec {
455 test: vec![
456 "CMD".to_owned(),
457 "pg_isready".to_owned(),
458 "-U".to_owned(),
459 user,
460 ],
461 interval: HEALTHCHECK_DEFAULT_INTERVAL,
462 timeout: HEALTHCHECK_DEFAULT_TIMEOUT,
463 retries: HEALTHCHECK_DEFAULT_RETRIES,
464 start_period: HEALTHCHECK_DEFAULT_START_PERIOD,
465 })
466 });
467
468 let spec = ContainerSpec {
469 name: name.clone(),
470 project: project.to_owned(),
471 resource: resource_name.to_owned(),
472 image: ImageSource::Pull(image),
473 env: env.clone(),
474 ports,
475 volumes,
476 entrypoint: None,
477 command: None,
478 healthcheck,
479 working_dir: None,
480 };
481
482 let mut outputs = ResourceOutputs::new();
483 outputs.insert("host".to_owned(), name.clone());
484 outputs.insert("port".to_owned(), port.to_string());
485 let user_out = env.get("POSTGRES_USER").cloned().unwrap_or_default();
486 let pwd_out = env.get("POSTGRES_PASSWORD").cloned().unwrap_or_default();
487 let db_out = env.get("POSTGRES_DB").cloned().unwrap_or_default();
488 outputs.insert("user".to_owned(), user_out.clone());
489 outputs.insert("password".to_owned(), pwd_out.clone());
490 outputs.insert("database".to_owned(), db_out.clone());
491 outputs.insert(
492 "url".to_owned(),
493 format!("postgres://{user_out}:{pwd_out}@{name}:{port}/{db_out}"),
494 );
495
496 Ok(ResolvedResource { spec, outputs })
497}
498
499#[allow(clippy::needless_pass_by_value)]
500fn spec_redis(
501 name: String,
502 project: &str,
503 resource_name: &str,
504 c: &RedisConfig,
505) -> Result<ResolvedResource> {
506 let version = c.version.as_deref().unwrap_or(DEFAULT_REDIS_VERSION);
507 let image = c
508 .image
509 .clone()
510 .unwrap_or_else(|| format!("redis:{version}-alpine"));
511 let port = c.port.unwrap_or(DEFAULT_REDIS_PORT);
512
513 let mut command = vec!["redis-server".to_owned()];
514 if let Some(password) = c.password.as_deref()
515 && !password.is_empty()
516 {
517 command.push("--requirepass".to_owned());
518 command.push(password.to_owned());
519 }
520
521 let ports = vec![PortBinding {
522 container_port: port,
523 host_address: None,
524 host_port: port,
525 }];
526
527 let volumes = volume_to_binding(c.volume.as_ref(), "/data");
528
529 let healthcheck = c
530 .healthcheck
531 .as_ref()
532 .map(parse_healthcheck)
533 .transpose()?
534 .or_else(|| {
535 Some(HealthcheckSpec {
536 test: vec!["CMD".to_owned(), "redis-cli".to_owned(), "ping".to_owned()],
537 interval: HEALTHCHECK_DEFAULT_INTERVAL,
538 timeout: HEALTHCHECK_DEFAULT_TIMEOUT,
539 retries: HEALTHCHECK_DEFAULT_RETRIES,
540 start_period: HEALTHCHECK_DEFAULT_START_PERIOD,
541 })
542 });
543
544 let password_out = c.password.clone().unwrap_or_default();
545 let spec = ContainerSpec {
546 name: name.clone(),
547 project: project.to_owned(),
548 resource: resource_name.to_owned(),
549 image: ImageSource::Pull(image),
550 env: HashMap::new(),
551 ports,
552 volumes,
553 entrypoint: None,
554 command: Some(command),
555 healthcheck,
556 working_dir: None,
557 };
558
559 let mut outputs = ResourceOutputs::new();
560 outputs.insert("host".to_owned(), name.clone());
561 outputs.insert("port".to_owned(), port.to_string());
562 outputs.insert("password".to_owned(), password_out.clone());
563 let url = if password_out.is_empty() {
564 format!("redis://{name}:{port}")
565 } else {
566 format!("redis://:{password_out}@{name}:{port}")
567 };
568 outputs.insert("url".to_owned(), url);
569
570 Ok(ResolvedResource { spec, outputs })
571}
572
573#[allow(clippy::needless_pass_by_value)]
574fn spec_container(
575 name: String,
576 project: &str,
577 resource_name: &str,
578 c: &ContainerConfig,
579) -> Result<ResolvedResource> {
580 let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
581
582 let ports = c
583 .ports
584 .iter()
585 .map(parse_port_mapping)
586 .collect::<Result<Vec<_>>>()?;
587 let volumes = c
588 .volumes
589 .iter()
590 .map(|s| parse_volume_string(s))
591 .collect::<Result<Vec<_>>>()?;
592 let entrypoint = c.entrypoint.as_ref().map(parse_command);
593 let command = c
594 .command
595 .as_ref()
596 .map(parse_command)
597 .filter(|cmd| !cmd.is_empty());
598 let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
599
600 let ports_csv: String = ports
601 .iter()
602 .map(|p| p.container_port.to_string())
603 .collect::<Vec<_>>()
604 .join(",");
605 let spec = ContainerSpec {
606 name: name.clone(),
607 project: project.to_owned(),
608 resource: resource_name.to_owned(),
609 image: ImageSource::Pull(c.image.clone()),
610 env,
611 ports,
612 volumes,
613 entrypoint,
614 command,
615 healthcheck,
616 working_dir: c.working_dir.clone(),
617 };
618
619 let mut outputs = ResourceOutputs::new();
620 outputs.insert("host".to_owned(), name);
621 outputs.insert("ports".to_owned(), ports_csv);
622
623 Ok(ResolvedResource { spec, outputs })
624}
625
626#[allow(clippy::needless_pass_by_value)]
627fn spec_dockerfile(
628 name: String,
629 project: &str,
630 resource_name: &str,
631 c: &DockerfileConfig,
632) -> Result<ResolvedResource> {
633 let tag = format!("lightshuttle/{name}:dev");
634
635 let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
636
637 let build_args: HashMap<String, String> = c
638 .build_args
639 .iter()
640 .map(|(k, v)| (k.clone(), v.clone()))
641 .collect();
642
643 let ports = c
644 .ports
645 .iter()
646 .map(parse_port_mapping)
647 .collect::<Result<Vec<_>>>()?;
648 let volumes = c
649 .volumes
650 .iter()
651 .map(|s| parse_volume_string(s))
652 .collect::<Result<Vec<_>>>()?;
653 let entrypoint = c.entrypoint.as_ref().map(parse_command);
654 let command = c
655 .command
656 .as_ref()
657 .map(parse_command)
658 .filter(|cmd| !cmd.is_empty());
659 let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
660
661 let ports_csv: String = ports
662 .iter()
663 .map(|p| p.container_port.to_string())
664 .collect::<Vec<_>>()
665 .join(",");
666 let spec = ContainerSpec {
667 name: name.clone(),
668 project: project.to_owned(),
669 resource: resource_name.to_owned(),
670 image: ImageSource::Build {
671 context: c.context.clone(),
672 dockerfile: c.dockerfile.clone(),
673 build_args,
674 target: c.target.clone(),
675 tag,
676 },
677 env,
678 ports,
679 volumes,
680 entrypoint,
681 command,
682 healthcheck,
683 working_dir: c.working_dir.clone(),
684 };
685
686 let mut outputs = ResourceOutputs::new();
687 outputs.insert("host".to_owned(), name);
688 outputs.insert("ports".to_owned(), ports_csv);
689
690 Ok(ResolvedResource { spec, outputs })
691}
692
693fn volume_to_binding(volume: Option<&Volume>, target: &str) -> Vec<VolumeBinding> {
694 match volume {
695 None | Some(Volume::Boolean(true)) => vec![VolumeBinding {
696 source: VolumeSource::Anonymous,
697 target: target.to_owned(),
698 }],
699 Some(Volume::Boolean(false)) => Vec::new(),
700 Some(Volume::Named(name)) => vec![VolumeBinding {
701 source: VolumeSource::Named(name.clone()),
702 target: target.to_owned(),
703 }],
704 }
705}
706
707fn parse_port_mapping(mapping: &PortMapping) -> Result<PortBinding> {
708 match mapping {
709 PortMapping::Container(port) => Ok(PortBinding {
710 container_port: *port,
711 host_address: None,
712 host_port: *port,
713 }),
714 PortMapping::Mapping(s) => parse_port_string(s),
715 }
716}
717
718fn parse_port_string(input: &str) -> Result<PortBinding> {
719 let parts: Vec<&str> = input.split(':').collect();
720 match parts.as_slice() {
721 [host_port, container_port] => {
722 let host_port: u16 = host_port
723 .parse()
724 .map_err(|_| SpecError::InvalidSpec(format!("invalid host port `{host_port}`")))?;
725 let container_port: u16 = container_port.parse().map_err(|_| {
726 SpecError::InvalidSpec(format!("invalid container port `{container_port}`"))
727 })?;
728 Ok(PortBinding {
729 container_port,
730 host_address: None,
731 host_port,
732 })
733 }
734 [host_address, host_port, container_port] => {
735 let host_port: u16 = host_port
736 .parse()
737 .map_err(|_| SpecError::InvalidSpec(format!("invalid host port `{host_port}`")))?;
738 let container_port: u16 = container_port.parse().map_err(|_| {
739 SpecError::InvalidSpec(format!("invalid container port `{container_port}`"))
740 })?;
741 Ok(PortBinding {
742 container_port,
743 host_address: Some((*host_address).to_owned()),
744 host_port,
745 })
746 }
747 _ => Err(SpecError::InvalidSpec(format!(
748 "invalid port mapping `{input}`"
749 ))),
750 }
751}
752
753fn parse_volume_string(input: &str) -> Result<VolumeBinding> {
754 let (source, target) = input.split_once(':').ok_or_else(|| {
755 SpecError::InvalidSpec(format!(
756 "invalid volume mapping `{input}`: expected `src:target`"
757 ))
758 })?;
759 let source = if source.starts_with('.') || source.starts_with('/') {
760 VolumeSource::HostPath(source.to_owned())
761 } else {
762 if source.contains(['{', '}']) {
763 return Err(SpecError::InvalidSpec(format!(
764 "volume name `{source}` must not contain '{{' or '}}': unsafe in export templates"
765 )));
766 }
767 VolumeSource::Named(source.to_owned())
768 };
769 Ok(VolumeBinding {
770 source,
771 target: target.to_owned(),
772 })
773}
774
775fn parse_command(command: &Command) -> Vec<String> {
776 match command {
777 Command::Single(s) => vec!["sh".to_owned(), "-c".to_owned(), s.clone()],
778 Command::Args(args) => args.clone(),
779 }
780}
781
782fn parse_healthcheck(hc: &Healthcheck) -> Result<HealthcheckSpec> {
783 Ok(HealthcheckSpec {
784 test: hc.test.clone(),
785 interval: parse_duration(&hc.interval)?,
786 timeout: parse_duration(&hc.timeout)?,
787 retries: hc.retries,
788 start_period: parse_duration(&hc.start_period)?,
789 })
790}
791
792fn parse_duration(input: &str) -> Result<Duration> {
793 let trimmed = input.trim();
794 let (digits, unit) = split_duration(trimmed)
795 .ok_or_else(|| SpecError::InvalidSpec(format!("invalid duration `{input}`")))?;
796 let value: f64 = digits
797 .parse()
798 .map_err(|_| SpecError::InvalidSpec(format!("invalid duration `{input}`")))?;
799 let nanos = match unit {
800 "ns" => value,
801 "us" => value * 1_000.0,
802 "ms" => value * 1_000_000.0,
803 "s" => value * 1_000_000_000.0,
804 "m" => value * 60.0 * 1_000_000_000.0,
805 "h" => value * 3_600.0 * 1_000_000_000.0,
806 _ => {
807 return Err(SpecError::InvalidSpec(format!(
808 "invalid duration unit `{unit}`"
809 )));
810 }
811 };
812 if nanos.is_sign_negative() || !nanos.is_finite() {
813 return Err(SpecError::InvalidSpec(format!(
814 "invalid duration `{input}`"
815 )));
816 }
817 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
818 Ok(Duration::from_nanos(nanos as u64))
819}
820
821fn split_duration(input: &str) -> Option<(&str, &str)> {
822 let bytes = input.as_bytes();
823 let mut idx = 0;
824 while idx < bytes.len() && (bytes[idx].is_ascii_digit() || bytes[idx] == b'.') {
825 idx += 1;
826 }
827 if idx == 0 || idx == bytes.len() {
828 return None;
829 }
830 Some((&input[..idx], &input[idx..]))
831}
832
833fn generate_random_password() -> String {
841 use rand::Rng;
842
843 const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
844 const LEN: usize = 24;
845
846 let mut rng = rand::rng();
847 (0..LEN)
848 .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
849 .collect()
850}
851
852#[cfg(test)]
853mod tests {
854 use super::{
855 VolumeSource, from_resource, generate_random_password, parse_command, parse_duration,
856 parse_port_string, parse_volume_string,
857 };
858 use lightshuttle_manifest::Command;
859 use std::time::Duration;
860
861 #[test]
862 fn parse_port_string_two_part() {
863 let b = parse_port_string("8080:80").unwrap();
864 assert_eq!(b.host_port, 8080);
865 assert_eq!(b.container_port, 80);
866 assert_eq!(b.host_address, None);
867 }
868
869 #[test]
870 fn parse_port_string_three_part() {
871 let b = parse_port_string("127.0.0.1:8080:80").unwrap();
872 assert_eq!(b.host_port, 8080);
873 assert_eq!(b.container_port, 80);
874 assert_eq!(b.host_address.as_deref(), Some("127.0.0.1"));
875 }
876
877 #[test]
878 fn parse_port_string_single_part_is_error() {
879 assert!(parse_port_string("80").is_err());
880 }
881
882 #[test]
883 fn parse_port_string_non_numeric_is_error() {
884 assert!(parse_port_string("abc:80").is_err());
885 }
886
887 #[test]
888 fn parse_volume_string_named() {
889 let b = parse_volume_string("data:/var/lib/data").unwrap();
890 assert!(matches!(b.source, VolumeSource::Named(_)));
891 assert_eq!(b.target, "/var/lib/data");
892 }
893
894 #[test]
895 fn parse_volume_string_relative_host() {
896 let b = parse_volume_string("./src:/app").unwrap();
897 assert!(matches!(b.source, VolumeSource::HostPath(_)));
898 assert_eq!(b.target, "/app");
899 }
900
901 #[test]
902 fn parse_volume_string_absolute_host() {
903 let b = parse_volume_string("/abs/path:/app").unwrap();
904 assert!(matches!(b.source, VolumeSource::HostPath(_)));
905 assert_eq!(b.target, "/app");
906 }
907
908 #[test]
909 fn parse_volume_string_no_colon_is_error() {
910 assert!(parse_volume_string("nodatahere").is_err());
911 }
912
913 #[test]
914 fn parse_volume_string_braces_in_name_is_error() {
915 assert!(parse_volume_string("my{vol}:/data").is_err());
916 }
917
918 #[test]
919 fn parse_duration_seconds() {
920 assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
921 }
922
923 #[test]
924 fn parse_duration_milliseconds() {
925 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
926 }
927
928 #[test]
929 fn parse_duration_minutes() {
930 assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
931 }
932
933 #[test]
934 fn parse_duration_unknown_unit_is_error() {
935 assert!(parse_duration("10x").is_err());
936 }
937
938 #[test]
939 fn parse_duration_no_unit_is_error() {
940 assert!(parse_duration("10").is_err());
941 }
942
943 #[test]
944 fn parse_duration_no_digits_is_error() {
945 assert!(parse_duration("s").is_err());
946 }
947
948 #[test]
949 fn parse_command_empty_args_produces_empty_vec() {
950 assert!(parse_command(&Command::Args(vec![])).is_empty());
951 }
952
953 #[test]
954 fn parse_command_single_becomes_sh_c() {
955 let v = parse_command(&Command::Single("echo hi".to_owned()));
956 assert_eq!(v, vec!["sh", "-c", "echo hi"]);
957 }
958
959 #[test]
960 fn generated_password_has_expected_shape() {
961 let password = generate_random_password();
962 assert_eq!(password.len(), 24);
963 assert!(
964 password
965 .chars()
966 .all(|c| c.is_ascii_alphanumeric() && !"0O1Il".contains(c)),
967 "password must be unambiguous alphanumeric, got `{password}`"
968 );
969 }
970
971 #[test]
972 fn generated_passwords_are_distinct() {
973 let first = generate_random_password();
976 let second = generate_random_password();
977 assert_ne!(first, second);
978 }
979
980 #[test]
981 fn entrypoint_resolves_to_argv_and_leaves_command_alone() {
982 let yaml = r#"
983project:
984 name: app
985resources:
986 svc:
987 dockerfile:
988 context: .
989 entrypoint: ["sh", "-c"]
990 command: ["echo hi"]
991"#;
992 let manifest = lightshuttle_manifest::Manifest::parse(yaml).expect("manifest parses");
993 let resolved =
994 from_resource("app", "svc", &manifest.resources["svc"]).expect("resolution succeeds");
995 assert_eq!(
996 resolved.spec.entrypoint,
997 Some(vec!["sh".to_owned(), "-c".to_owned()])
998 );
999 assert_eq!(
1000 resolved.spec.command,
1001 Some(vec!["echo hi".to_owned()]),
1002 "resolving an entrypoint must not disturb the command"
1003 );
1004 }
1005
1006 #[test]
1007 fn entrypoint_without_command_leaves_command_none() {
1008 let yaml = r#"
1009project:
1010 name: app
1011resources:
1012 svc:
1013 dockerfile:
1014 context: .
1015 entrypoint: ["sh", "-c", "entrypoint.sh"]
1016"#;
1017 let manifest = lightshuttle_manifest::Manifest::parse(yaml).expect("manifest parses");
1018 let resolved =
1019 from_resource("app", "svc", &manifest.resources["svc"]).expect("resolution succeeds");
1020 assert_eq!(
1021 resolved.spec.entrypoint,
1022 Some(vec![
1023 "sh".to_owned(),
1024 "-c".to_owned(),
1025 "entrypoint.sh".to_owned()
1026 ])
1027 );
1028 assert_eq!(
1029 resolved.spec.command, None,
1030 "entrypoint alone must not synthesise a command: the image CMD, not the manifest, decides what runs"
1031 );
1032 }
1033
1034 #[test]
1035 fn absent_entrypoint_resolves_to_none() {
1036 let yaml = r"
1037project:
1038 name: app
1039resources:
1040 svc:
1041 dockerfile:
1042 context: .
1043";
1044 let manifest = lightshuttle_manifest::Manifest::parse(yaml).expect("manifest parses");
1045 let resolved =
1046 from_resource("app", "svc", &manifest.resources["svc"]).expect("resolution succeeds");
1047 assert_eq!(
1048 resolved.spec.entrypoint, None,
1049 "existing manifests must be unaffected"
1050 );
1051 }
1052
1053 #[test]
1054 fn generated_resources_declare_no_entrypoint() {
1055 let yaml = r"
1056project:
1057 name: app
1058resources:
1059 cache:
1060 redis:
1061 version: '7'
1062 db:
1063 postgres:
1064 version: '16'
1065";
1066 let manifest = lightshuttle_manifest::Manifest::parse(yaml).expect("manifest parses");
1067 for name in ["cache", "db"] {
1068 let resolved =
1069 from_resource("app", name, &manifest.resources[name]).expect("resolution succeeds");
1070 assert_eq!(
1071 resolved.spec.entrypoint, None,
1072 "{name} must keep the image entrypoint"
1073 );
1074 }
1075 let cache = from_resource("app", "cache", &manifest.resources["cache"])
1076 .expect("resolution succeeds");
1077 assert_eq!(
1078 cache.spec.command,
1079 Some(vec!["redis-server".to_owned()]),
1080 "the redis command must be untouched"
1081 );
1082 }
1083}