1use std::{collections::BTreeSet, error::Error, fmt};
4
5use crate::{
6 model::{
7 ComposeDocument, MemLimitUnit, ShmSizeUnit, StopGracePeriod, valid_generated_device_string,
8 valid_generated_mem_amount, valid_generated_shm_amount, valid_generated_tmpfs_item, valid_hostname,
9 valid_positive_pids_decimal, valid_pull_policy_duration, valid_ulimit_name,
10 },
11 source::SourceId,
12 syntax::SyntaxDocument,
13};
14
15use super::write_quoted;
16
17#[derive(Clone, Debug, Eq, PartialEq)]
19#[non_exhaustive]
20pub enum GenerationError {
21 EmptyValue(&'static str),
23 ContainsNul(&'static str),
25 ContainsLineBreak(&'static str),
27 InvalidEnvironmentName,
29 InvalidContainerName,
31 InvalidHostname,
33 InvalidPullPolicyDuration,
35 InvalidPidsLimit,
37 InvalidShmSize,
39 InvalidMemLimit,
41 InvalidTmpfsItem,
43 InvalidDeviceValue(&'static str),
45 InvalidSysctlName,
47 InvalidSysctlValue,
49 InvalidUlimitName,
51 InvalidUlimitValue,
53 MissingUlimitRangeMember(&'static str),
55 InvalidStopGracePeriod,
57 InvalidShortComponent(&'static str),
59 InvalidSelinuxBind,
61 DuplicateField(&'static str),
63 DuplicateName {
65 kind: &'static str,
67 name: String,
69 },
70 DuplicateItem(&'static str),
72 InvalidPort,
74 UnrepresentableSctpHostIp,
76 MissingService,
78 InternalInvariant(&'static str),
80}
81
82impl fmt::Display for GenerationError {
83 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84 match self {
85 Self::EmptyValue(kind) => write!(formatter, "generated {kind} must not be empty"),
86 Self::ContainsNul(kind) => write!(formatter, "generated {kind} must not contain a NUL byte"),
87 Self::ContainsLineBreak(kind) => {
88 write!(formatter, "generated {kind} must not contain a carriage return or line feed")
89 }
90 Self::InvalidEnvironmentName => formatter.write_str("generated environment name must not contain `=`"),
91 Self::InvalidContainerName => {
92 formatter.write_str("generated container name must match `[a-zA-Z0-9][a-zA-Z0-9_.-]+`")
93 }
94 Self::InvalidHostname => formatter.write_str(
95 "generated hostname must be a resolved ASCII RFC-1123 name with labels of 1 to 63 characters and total length at most 253",
96 ),
97 Self::InvalidPullPolicyDuration => formatter.write_str(
98 "generated pull policy duration must match integer `w`, `d`, `h`, `m`, and `s` components",
99 ),
100 Self::InvalidPidsLimit => {
101 formatter.write_str("generated finite PID limit must be a positive integral decimal")
102 }
103 Self::InvalidShmSize => formatter.write_str(
104 "generated shared-memory size must use a canonical positive ASCII-integer amount and an explicit documented lowercase unit",
105 ),
106 Self::InvalidMemLimit => formatter.write_str(
107 "generated memory limit must use a canonical positive ASCII-integer amount and an explicit documented lowercase unit",
108 ),
109 Self::InvalidTmpfsItem => formatter.write_str(
110 "generated tmpfs item must be a non-empty path optionally followed by a colon and non-empty comma-separated raw options",
111 ),
112 Self::InvalidDeviceValue(member) => write!(
113 formatter,
114 "generated device {member} must be a safe resolved single-line string{}",
115 if matches!(*member, "short item" | "source") {
116 " and must not be empty"
117 } else {
118 ""
119 }
120 ),
121 Self::InvalidSysctlName => formatter
122 .write_str("generated sysctl name must be a non-empty resolved single-line string"),
123 Self::InvalidSysctlValue => formatter
124 .write_str("generated sysctl value must be a resolved single-line string"),
125 Self::InvalidUlimitName => formatter
126 .write_str("generated ulimit name must match lowercase ASCII `[a-z]+`"),
127 Self::InvalidUlimitValue => formatter
128 .write_str("generated ulimit value must be `-1` or a non-negative ASCII decimal"),
129 Self::MissingUlimitRangeMember(member) => {
130 write!(formatter, "generated ulimit range is missing required `{member}`")
131 }
132 Self::InvalidStopGracePeriod => formatter.write_str(
133 "generated stop grace period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
134 ),
135 Self::InvalidShortComponent(kind) => {
136 write!(formatter, "generated {kind} contains its reserved short-form separator")
137 }
138 Self::InvalidSelinuxBind => formatter
139 .write_str("generated SELinux bind source and target must not contain the short-syntax `:` separator"),
140 Self::DuplicateField(field) => write!(formatter, "generated field `{field}` was configured more than once"),
141 Self::DuplicateName { kind, name } => {
142 write!(formatter, "generated {kind} `{name}` was added more than once")
143 }
144 Self::DuplicateItem(kind) => write!(formatter, "generated {kind} contains an exact duplicate item"),
145 Self::InvalidPort => formatter.write_str("generated container target port must be greater than zero"),
146 Self::UnrepresentableSctpHostIp => formatter.write_str(
147 "generated SCTP port with a host address also requires a published port for Compose short syntax",
148 ),
149 Self::MissingService => formatter.write_str("generated Compose project requires at least one service"),
150 Self::InternalInvariant(stage) => write!(formatter, "generated Compose document failed {stage} validation"),
151 }
152 }
153}
154
155impl Error for GenerationError {}
156
157#[derive(Clone, Eq, PartialEq)]
159pub struct GeneratedString {
160 value: String,
161 sensitive: bool,
162}
163
164impl GeneratedString {
165 pub fn plain(value: impl Into<String>) -> Result<Self, GenerationError> {
171 Self::new(value.into(), false)
172 }
173
174 pub fn sensitive(value: impl Into<String>) -> Result<Self, GenerationError> {
180 Self::new(value.into(), true)
181 }
182
183 fn new(value: String, sensitive: bool) -> Result<Self, GenerationError> {
184 if value.contains('\0') {
185 return Err(GenerationError::ContainsNul("string"));
186 }
187 Ok(Self { value, sensitive })
188 }
189
190 #[must_use]
192 pub fn expose(&self) -> &str {
193 &self.value
194 }
195
196 #[must_use]
198 pub const fn is_sensitive(&self) -> bool {
199 self.sensitive
200 }
201}
202
203impl fmt::Debug for GeneratedString {
204 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
205 formatter
206 .debug_struct("GeneratedString")
207 .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
208 .field("sensitive", &self.sensitive)
209 .finish()
210 }
211}
212
213#[derive(Clone, Debug, Eq, PartialEq)]
215#[non_exhaustive]
216pub enum GeneratedCommand {
217 Exec(Vec<GeneratedString>),
219 Shell(GeneratedString),
221 Empty,
223}
224
225#[derive(Clone, Debug, Eq, PartialEq)]
227#[non_exhaustive]
228pub enum GeneratedEntrypoint {
229 List(Vec<GeneratedString>),
231 String(GeneratedString),
233 Empty,
235}
236
237#[derive(Clone, Copy, Debug, Eq, PartialEq)]
239#[non_exhaustive]
240pub enum GeneratedRestartPolicy {
241 No,
243 Always,
245 OnFailure {
247 maximum_retries: Option<u64>,
249 },
250 UnlessStopped,
252}
253
254#[derive(Clone, Debug, Eq, PartialEq)]
256#[non_exhaustive]
257pub enum GeneratedPullPolicy {
258 Always,
260 Never,
262 Missing,
264 IfNotPresentAlias,
266 Build,
268 Daily,
270 Weekly,
272 Every(GeneratedString),
274}
275
276#[derive(Clone, Debug, Eq, PartialEq)]
278#[non_exhaustive]
279pub enum GeneratedPidsLimit {
280 Unlimited,
282 Finite(String),
284}
285
286#[derive(Clone, Debug, Eq, PartialEq)]
288#[non_exhaustive]
289pub enum GeneratedShmSize {
290 Explicit {
292 amount: GeneratedString,
294 unit: ShmSizeUnit,
296 },
297}
298
299#[derive(Clone, Debug, Eq, PartialEq)]
301#[non_exhaustive]
302pub enum GeneratedMemLimit {
303 Explicit {
305 amount: GeneratedString,
307 unit: MemLimitUnit,
309 },
310}
311
312#[derive(Clone, Debug, Eq, PartialEq)]
314#[non_exhaustive]
315pub enum GeneratedTmpfs {
316 Scalar(GeneratedString),
318 List(Vec<GeneratedString>),
320}
321
322#[derive(Clone, Debug, Eq, PartialEq)]
324pub struct GeneratedLongDevice {
325 source: GeneratedString,
326 target: Option<GeneratedString>,
327 permissions: Option<GeneratedString>,
328}
329
330impl GeneratedLongDevice {
331 pub fn new(
339 source: GeneratedString,
340 target: Option<GeneratedString>,
341 permissions: Option<GeneratedString>,
342 ) -> Result<Self, GenerationError> {
343 validate_generated_device_member("source", &source, true)?;
344 if let Some(target) = &target {
345 validate_generated_device_member("target", target, false)?;
346 }
347 if let Some(permissions) = &permissions {
348 validate_generated_device_member("permissions", permissions, false)?;
349 }
350 Ok(Self {
351 source,
352 target,
353 permissions,
354 })
355 }
356
357 #[must_use]
359 pub const fn source(&self) -> &GeneratedString {
360 &self.source
361 }
362
363 #[must_use]
365 pub const fn target(&self) -> Option<&GeneratedString> {
366 self.target.as_ref()
367 }
368
369 #[must_use]
371 pub const fn permissions(&self) -> Option<&GeneratedString> {
372 self.permissions.as_ref()
373 }
374
375 fn is_sensitive(&self) -> bool {
376 self.source.is_sensitive()
377 || self.target.as_ref().is_some_and(GeneratedString::is_sensitive)
378 || self.permissions.as_ref().is_some_and(GeneratedString::is_sensitive)
379 }
380}
381
382#[derive(Clone, Debug, Eq, PartialEq)]
384#[non_exhaustive]
385pub enum GeneratedDevice {
386 Short(GeneratedString),
388 Long(GeneratedLongDevice),
390}
391
392impl GeneratedDevice {
393 fn is_sensitive(&self) -> bool {
394 match self {
395 Self::Short(value) => value.is_sensitive(),
396 Self::Long(value) => value.is_sensitive(),
397 }
398 }
399}
400
401#[derive(Clone, Debug, Eq, PartialEq)]
403pub struct GeneratedSysctl {
404 name: String,
405 value: GeneratedString,
406}
407
408impl GeneratedSysctl {
409 pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
417 let name = name.into();
418 if name.is_empty()
419 || name.contains(['\0', '\r', '\n'])
420 || name.contains('$')
421 || value.expose().contains(['\r', '\n', '$'])
422 {
423 return Err(if name.is_empty() || name.contains(['\0', '\r', '\n', '$']) {
424 GenerationError::InvalidSysctlName
425 } else {
426 GenerationError::InvalidSysctlValue
427 });
428 }
429 Ok(Self { name, value })
430 }
431
432 #[must_use]
434 pub fn name(&self) -> &str {
435 &self.name
436 }
437
438 #[must_use]
440 pub const fn value(&self) -> &GeneratedString {
441 &self.value
442 }
443}
444
445#[derive(Clone, Debug, Eq, PartialEq)]
447#[non_exhaustive]
448pub enum GeneratedSysctls {
449 Map(Vec<GeneratedSysctl>),
451 List(Vec<GeneratedString>),
453}
454
455#[derive(Clone, Debug, Eq, PartialEq)]
457#[non_exhaustive]
458pub enum GeneratedUlimitValue {
459 Single(GeneratedString),
461 Range {
463 soft: Option<GeneratedString>,
465 hard: Option<GeneratedString>,
467 },
468}
469
470#[derive(Clone, Debug, Eq, PartialEq)]
472pub struct GeneratedUlimit {
473 name: String,
474 value: GeneratedUlimitValue,
475}
476
477impl GeneratedUlimit {
478 pub fn new(name: impl Into<String>, value: GeneratedUlimitValue) -> Result<Self, GenerationError> {
485 let name = name.into();
486 if !valid_ulimit_name(&name) {
487 return Err(GenerationError::InvalidUlimitName);
488 }
489 match &value {
490 GeneratedUlimitValue::Single(value) => validate_generated_ulimit_value(value)?,
491 GeneratedUlimitValue::Range { soft, hard } => {
492 let soft = soft.as_ref().ok_or(GenerationError::MissingUlimitRangeMember("soft"))?;
493 let hard = hard.as_ref().ok_or(GenerationError::MissingUlimitRangeMember("hard"))?;
494 validate_generated_ulimit_value(soft)?;
495 validate_generated_ulimit_value(hard)?;
496 }
497 }
498 Ok(Self { name, value })
499 }
500
501 pub fn single(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
507 Self::new(name, GeneratedUlimitValue::Single(value))
508 }
509
510 pub fn range(
516 name: impl Into<String>,
517 soft: GeneratedString,
518 hard: GeneratedString,
519 ) -> Result<Self, GenerationError> {
520 Self::new(
521 name,
522 GeneratedUlimitValue::Range {
523 soft: Some(soft),
524 hard: Some(hard),
525 },
526 )
527 }
528
529 #[must_use]
531 pub fn name(&self) -> &str {
532 &self.name
533 }
534
535 #[must_use]
537 pub const fn value(&self) -> &GeneratedUlimitValue {
538 &self.value
539 }
540
541 fn is_sensitive(&self) -> bool {
542 match &self.value {
543 GeneratedUlimitValue::Single(value) => value.is_sensitive(),
544 GeneratedUlimitValue::Range { soft, hard } => {
545 soft.iter().chain(hard.iter()).any(GeneratedString::is_sensitive)
546 }
547 }
548 }
549}
550
551#[derive(Clone, Debug, Eq, PartialEq)]
553pub struct GeneratedUlimits {
554 entries: Vec<GeneratedUlimit>,
555}
556
557impl GeneratedUlimits {
558 pub fn new(entries: Vec<GeneratedUlimit>) -> Result<Self, GenerationError> {
564 let mut seen = BTreeSet::new();
565 for entry in &entries {
566 if !seen.insert(entry.name()) {
567 return Err(GenerationError::DuplicateName {
568 kind: "ulimit",
569 name: entry.name().to_owned(),
570 });
571 }
572 }
573 Ok(Self { entries })
574 }
575
576 #[must_use]
578 pub fn entries(&self) -> &[GeneratedUlimit] {
579 &self.entries
580 }
581
582 #[must_use]
584 pub fn is_empty(&self) -> bool {
585 self.entries.is_empty()
586 }
587}
588
589#[derive(Clone, Debug, Eq, PartialEq)]
591#[non_exhaustive]
592pub enum GeneratedHostname {
593 Resolved(GeneratedString),
595}
596
597#[derive(Clone, Debug, Eq, PartialEq)]
599pub struct GeneratedEnvironment {
600 name: String,
601 value: Option<GeneratedString>,
602}
603
604#[derive(Clone, Copy, Debug, Eq, PartialEq)]
606#[non_exhaustive]
607pub enum GeneratedEnvironmentFileFormat {
608 Raw,
610}
611
612#[derive(Clone, Debug, Eq, PartialEq)]
614#[non_exhaustive]
615pub enum GeneratedEnvironmentFile {
616 Short(GeneratedString),
618 Long {
620 path: GeneratedString,
622 required: Option<bool>,
624 format: Option<GeneratedEnvironmentFileFormat>,
626 },
627}
628
629#[derive(Clone, Debug, Eq, PartialEq)]
631pub struct GeneratedLabel {
632 name: String,
633 value: GeneratedString,
634}
635
636impl GeneratedLabel {
637 pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
644 Ok(Self {
645 name: required("label name", name.into())?,
646 value,
647 })
648 }
649
650 #[must_use]
652 pub fn name(&self) -> &str {
653 &self.name
654 }
655
656 #[must_use]
658 pub const fn value(&self) -> &GeneratedString {
659 &self.value
660 }
661}
662
663impl GeneratedEnvironment {
664 pub fn literal(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
670 Ok(Self {
671 name: environment_name(name.into())?,
672 value: Some(value),
673 })
674 }
675
676 pub fn host(name: impl Into<String>) -> Result<Self, GenerationError> {
682 Ok(Self {
683 name: environment_name(name.into())?,
684 value: None,
685 })
686 }
687
688 #[must_use]
690 pub fn name(&self) -> &str {
691 &self.name
692 }
693
694 #[must_use]
696 pub const fn value(&self) -> Option<&GeneratedString> {
697 self.value.as_ref()
698 }
699}
700
701impl GeneratedEnvironmentFile {
702 pub fn short(path: GeneratedString) -> Result<Self, GenerationError> {
709 require_generated_string("environment-file path", &path)?;
710 Ok(Self::Short(path))
711 }
712
713 pub fn long(
720 path: GeneratedString,
721 required: Option<bool>,
722 format: Option<GeneratedEnvironmentFileFormat>,
723 ) -> Result<Self, GenerationError> {
724 require_generated_string("environment-file path", &path)?;
725 Ok(Self::Long { path, required, format })
726 }
727
728 #[must_use]
730 pub const fn path(&self) -> &GeneratedString {
731 match self {
732 Self::Short(path) | Self::Long { path, .. } => path,
733 }
734 }
735
736 #[must_use]
738 pub const fn required(&self) -> Option<bool> {
739 match self {
740 Self::Short(_) => None,
741 Self::Long { required, .. } => *required,
742 }
743 }
744
745 #[must_use]
747 pub const fn format(&self) -> Option<GeneratedEnvironmentFileFormat> {
748 match self {
749 Self::Short(_) => None,
750 Self::Long { format, .. } => *format,
751 }
752 }
753
754 #[must_use]
756 pub const fn is_sensitive(&self) -> bool {
757 self.path().is_sensitive()
758 }
759}
760
761#[derive(Clone, Debug, Eq, PartialEq)]
763pub struct GeneratedExtraHost {
764 hostname: String,
765 address: String,
766}
767
768impl GeneratedExtraHost {
769 pub fn new(hostname: impl Into<String>, address: impl Into<String>) -> Result<Self, GenerationError> {
775 let hostname = short_component("extra-host hostname", hostname.into(), '=')?;
776 let address = short_component("extra-host address", address.into(), '=')?;
777 Ok(Self { hostname, address })
778 }
779
780 #[must_use]
782 pub fn hostname(&self) -> &str {
783 &self.hostname
784 }
785
786 #[must_use]
788 pub fn address(&self) -> &str {
789 &self.address
790 }
791}
792
793#[derive(Clone, Copy, Debug, Eq, PartialEq)]
795#[non_exhaustive]
796pub enum GeneratedProtocol {
797 Tcp,
799 Udp,
801 Sctp,
803}
804
805impl GeneratedProtocol {
806 const fn as_str(self) -> &'static str {
807 match self {
808 Self::Tcp => "tcp",
809 Self::Udp => "udp",
810 Self::Sctp => "sctp",
811 }
812 }
813}
814
815#[derive(Clone, Debug, Eq, PartialEq)]
817pub struct GeneratedPort {
818 target: u16,
819 published: Option<u16>,
820 host_ip: Option<String>,
821 protocol: GeneratedProtocol,
822}
823
824impl GeneratedPort {
825 pub fn new(
833 target: u16,
834 published: Option<u16>,
835 host_ip: Option<String>,
836 protocol: GeneratedProtocol,
837 ) -> Result<Self, GenerationError> {
838 if target == 0 {
839 return Err(GenerationError::InvalidPort);
840 }
841 if let Some(host_ip) = host_ip.as_deref() {
842 required("port host address", host_ip.to_owned())?;
843 if protocol == GeneratedProtocol::Sctp && published.is_none() {
844 return Err(GenerationError::UnrepresentableSctpHostIp);
845 }
846 }
847 Ok(Self {
848 target,
849 published,
850 host_ip,
851 protocol,
852 })
853 }
854
855 #[must_use]
857 pub const fn target(&self) -> u16 {
858 self.target
859 }
860
861 #[must_use]
863 pub const fn published(&self) -> Option<u16> {
864 self.published
865 }
866
867 #[must_use]
869 pub fn host_ip(&self) -> Option<&str> {
870 self.host_ip.as_deref()
871 }
872
873 #[must_use]
875 pub const fn protocol(&self) -> GeneratedProtocol {
876 self.protocol
877 }
878}
879
880#[derive(Clone, Copy, Debug, Eq, PartialEq)]
882#[non_exhaustive]
883pub enum GeneratedSelinux {
884 Private,
886 Shared,
888}
889
890impl GeneratedSelinux {
891 const fn as_str(self) -> &'static str {
892 match self {
893 Self::Private => "Z",
894 Self::Shared => "z",
895 }
896 }
897}
898
899#[derive(Clone, Debug, Eq, PartialEq)]
900enum GeneratedMountKind {
901 Volume {
902 source: String,
903 },
904 Bind {
905 source: String,
906 selinux: Option<GeneratedSelinux>,
907 },
908 Anonymous,
909}
910
911#[derive(Clone, Debug, Eq, PartialEq)]
913pub struct GeneratedMount {
914 kind: GeneratedMountKind,
915 target: String,
916 read_only: bool,
917}
918
919impl GeneratedMount {
920 pub fn volume(
926 source: impl Into<String>,
927 target: impl Into<String>,
928 read_only: bool,
929 ) -> Result<Self, GenerationError> {
930 Ok(Self {
931 kind: GeneratedMountKind::Volume {
932 source: required("volume source", source.into())?,
933 },
934 target: required("mount target", target.into())?,
935 read_only,
936 })
937 }
938
939 pub fn bind(
946 source: impl Into<String>,
947 target: impl Into<String>,
948 read_only: bool,
949 selinux: Option<GeneratedSelinux>,
950 ) -> Result<Self, GenerationError> {
951 let source = required("bind source", source.into())?;
952 let target = required("mount target", target.into())?;
953 if selinux.is_some() && (source.contains(':') || target.contains(':')) {
954 return Err(GenerationError::InvalidSelinuxBind);
955 }
956 Ok(Self {
957 kind: GeneratedMountKind::Bind { source, selinux },
958 target,
959 read_only,
960 })
961 }
962
963 pub fn anonymous(target: impl Into<String>, read_only: bool) -> Result<Self, GenerationError> {
969 Ok(Self {
970 kind: GeneratedMountKind::Anonymous,
971 target: required("mount target", target.into())?,
972 read_only,
973 })
974 }
975
976 #[must_use]
978 pub fn target(&self) -> &str {
979 &self.target
980 }
981
982 #[must_use]
984 pub const fn read_only(&self) -> bool {
985 self.read_only
986 }
987}
988
989#[derive(Clone, Debug, Eq, PartialEq)]
991pub struct GeneratedNetworkAttachment {
992 name: String,
993 aliases: Vec<String>,
994}
995
996impl GeneratedNetworkAttachment {
997 pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
1003 Ok(Self {
1004 name: required("network name", name.into())?,
1005 aliases: Vec::new(),
1006 })
1007 }
1008
1009 pub fn add_alias(&mut self, alias: impl Into<String>) -> Result<(), GenerationError> {
1015 self.aliases.push(required("network alias", alias.into())?);
1016 Ok(())
1017 }
1018
1019 #[must_use]
1021 pub fn name(&self) -> &str {
1022 &self.name
1023 }
1024
1025 #[must_use]
1027 pub fn aliases(&self) -> &[String] {
1028 &self.aliases
1029 }
1030}
1031
1032#[derive(Clone, Debug, Eq, PartialEq)]
1034pub struct GeneratedResource {
1035 name: String,
1036 external: bool,
1037 custom_name: Option<String>,
1038}
1039
1040impl GeneratedResource {
1041 pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
1047 Ok(Self {
1048 name: required("resource name", name.into())?,
1049 external: false,
1050 custom_name: None,
1051 })
1052 }
1053
1054 pub fn external(name: impl Into<String>) -> Result<Self, GenerationError> {
1060 Ok(Self {
1061 name: required("resource name", name.into())?,
1062 external: true,
1063 custom_name: None,
1064 })
1065 }
1066
1067 pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
1075 let name = required("custom resource name", name.into())?;
1076 set_once(&mut self.custom_name, name, "resource name")
1077 }
1078
1079 #[must_use]
1081 pub fn name(&self) -> &str {
1082 &self.name
1083 }
1084
1085 #[must_use]
1087 pub const fn is_external(&self) -> bool {
1088 self.external
1089 }
1090
1091 #[must_use]
1093 pub fn custom_name(&self) -> Option<&str> {
1094 self.custom_name.as_deref()
1095 }
1096}
1097
1098#[derive(Clone, Debug, Eq, PartialEq)]
1100pub struct GeneratedService {
1101 name: String,
1102 hostname: Option<GeneratedHostname>,
1103 container_name: Option<GeneratedString>,
1104 image: Option<GeneratedString>,
1105 entrypoint: Option<GeneratedEntrypoint>,
1106 command: Option<GeneratedCommand>,
1107 init: Option<bool>,
1108 environment_files: Vec<GeneratedEnvironmentFile>,
1109 environment: Vec<GeneratedEnvironment>,
1110 labels: Vec<GeneratedLabel>,
1111 user: Option<GeneratedString>,
1112 userns_mode: Option<GeneratedString>,
1113 group_add: Vec<GeneratedString>,
1114 cap_add: Option<Vec<GeneratedString>>,
1115 cap_drop: Option<Vec<GeneratedString>>,
1116 devices: Option<Vec<GeneratedDevice>>,
1117 working_dir: Option<GeneratedString>,
1118 read_only: Option<bool>,
1119 pids_limit: Option<GeneratedPidsLimit>,
1120 shm_size: Option<GeneratedShmSize>,
1121 mem_limit: Option<GeneratedMemLimit>,
1122 tmpfs: Option<GeneratedTmpfs>,
1123 sysctls: Option<GeneratedSysctls>,
1124 ulimits: Option<GeneratedUlimits>,
1125 pull_policy: Option<GeneratedPullPolicy>,
1126 restart: Option<GeneratedRestartPolicy>,
1127 stop_signal: Option<GeneratedString>,
1128 stop_grace_period: Option<GeneratedString>,
1129 extra_hosts: Vec<GeneratedExtraHost>,
1130 ports: Vec<GeneratedPort>,
1131 mounts: Vec<GeneratedMount>,
1132 networks: Vec<GeneratedNetworkAttachment>,
1133}
1134
1135impl GeneratedService {
1136 pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
1142 Ok(Self {
1143 name: required("service name", name.into())?,
1144 hostname: None,
1145 container_name: None,
1146 image: None,
1147 entrypoint: None,
1148 command: None,
1149 init: None,
1150 environment_files: Vec::new(),
1151 environment: Vec::new(),
1152 labels: Vec::new(),
1153 user: None,
1154 userns_mode: None,
1155 group_add: Vec::new(),
1156 cap_add: None,
1157 cap_drop: None,
1158 devices: None,
1159 working_dir: None,
1160 read_only: None,
1161 pids_limit: None,
1162 shm_size: None,
1163 mem_limit: None,
1164 tmpfs: None,
1165 sysctls: None,
1166 ulimits: None,
1167 pull_policy: None,
1168 restart: None,
1169 stop_signal: None,
1170 stop_grace_period: None,
1171 extra_hosts: Vec::new(),
1172 ports: Vec::new(),
1173 mounts: Vec::new(),
1174 networks: Vec::new(),
1175 })
1176 }
1177
1178 #[must_use]
1180 pub fn name(&self) -> &str {
1181 &self.name
1182 }
1183
1184 pub fn set_hostname(&mut self, hostname: GeneratedHostname) -> Result<(), GenerationError> {
1192 let GeneratedHostname::Resolved(value) = &hostname;
1193 if !valid_hostname(value.expose()) {
1194 return Err(GenerationError::InvalidHostname);
1195 }
1196 set_once(&mut self.hostname, hostname, "hostname")
1197 }
1198
1199 pub fn set_container_name(&mut self, name: GeneratedString) -> Result<(), GenerationError> {
1207 if !valid_container_name(name.expose()) {
1208 return Err(GenerationError::InvalidContainerName);
1209 }
1210 set_once(&mut self.container_name, name, "container_name")
1211 }
1212
1213 pub fn set_image(&mut self, image: GeneratedString) -> Result<(), GenerationError> {
1220 require_generated_string("service image", &image)?;
1221 set_once(&mut self.image, image, "image")
1222 }
1223
1224 pub fn set_entrypoint(&mut self, entrypoint: GeneratedEntrypoint) -> Result<(), GenerationError> {
1230 set_once(&mut self.entrypoint, entrypoint, "entrypoint")
1231 }
1232
1233 pub fn set_command(&mut self, command: GeneratedCommand) -> Result<(), GenerationError> {
1239 set_once(&mut self.command, command, "command")
1240 }
1241
1242 pub fn set_init(&mut self, init: bool) -> Result<(), GenerationError> {
1248 set_once(&mut self.init, init, "init")
1249 }
1250
1251 pub fn add_environment_file(&mut self, environment_file: GeneratedEnvironmentFile) {
1253 self.environment_files.push(environment_file);
1254 }
1255
1256 pub fn add_environment(&mut self, environment: GeneratedEnvironment) {
1258 self.environment.push(environment);
1259 }
1260
1261 pub fn add_label(&mut self, label: GeneratedLabel) -> Result<(), GenerationError> {
1267 if self.labels.iter().any(|candidate| candidate.name == label.name) {
1268 return Err(GenerationError::DuplicateName {
1269 kind: "service label",
1270 name: label.name,
1271 });
1272 }
1273 self.labels.push(label);
1274 Ok(())
1275 }
1276
1277 pub fn set_user(&mut self, user: GeneratedString) -> Result<(), GenerationError> {
1283 set_once(&mut self.user, user, "user")
1284 }
1285
1286 pub fn set_userns_mode(&mut self, mode: GeneratedString) -> Result<(), GenerationError> {
1293 require_generated_string("user namespace mode", &mode)?;
1294 set_once(&mut self.userns_mode, mode, "userns_mode")
1295 }
1296
1297 pub fn add_supplementary_group(&mut self, group: GeneratedString) -> Result<(), GenerationError> {
1303 require_generated_string("supplementary group", &group)?;
1304 self.group_add.push(group);
1305 Ok(())
1306 }
1307
1308 pub fn set_cap_add(&mut self, capabilities: Vec<GeneratedString>) -> Result<(), GenerationError> {
1321 let mut seen = BTreeSet::new();
1322 for capability in &capabilities {
1323 require_generated_string("cap_add item", capability)?;
1324 if capability.expose().contains('\r') || capability.expose().contains('\n') {
1325 return Err(GenerationError::ContainsLineBreak("cap_add item"));
1326 }
1327 if !seen.insert(capability.expose()) {
1328 return Err(GenerationError::DuplicateItem("cap_add"));
1329 }
1330 }
1331 set_once(&mut self.cap_add, capabilities, "cap_add")
1332 }
1333
1334 #[must_use]
1336 pub fn cap_add(&self) -> Option<&[GeneratedString]> {
1337 self.cap_add.as_deref()
1338 }
1339
1340 pub fn set_cap_drop(&mut self, capabilities: Vec<GeneratedString>) -> Result<(), GenerationError> {
1353 let mut seen = BTreeSet::new();
1354 for capability in &capabilities {
1355 require_generated_string("cap_drop item", capability)?;
1356 if capability.expose().contains('\r') || capability.expose().contains('\n') {
1357 return Err(GenerationError::ContainsLineBreak("cap_drop item"));
1358 }
1359 if !seen.insert(capability.expose()) {
1360 return Err(GenerationError::DuplicateItem("cap_drop"));
1361 }
1362 }
1363 set_once(&mut self.cap_drop, capabilities, "cap_drop")
1364 }
1365
1366 #[must_use]
1368 pub fn cap_drop(&self) -> Option<&[GeneratedString]> {
1369 self.cap_drop.as_deref()
1370 }
1371
1372 pub fn set_devices(&mut self, devices: Vec<GeneratedDevice>) -> Result<(), GenerationError> {
1385 for device in &devices {
1386 match device {
1387 GeneratedDevice::Short(value) => {
1388 validate_generated_device_member("short item", value, true)?;
1389 }
1390 GeneratedDevice::Long(value) => {
1391 validate_generated_device_member("source", value.source(), true)?;
1392 if let Some(target) = value.target() {
1393 validate_generated_device_member("target", target, false)?;
1394 }
1395 if let Some(permissions) = value.permissions() {
1396 validate_generated_device_member("permissions", permissions, false)?;
1397 }
1398 }
1399 }
1400 }
1401 set_once(&mut self.devices, devices, "devices")
1402 }
1403
1404 #[must_use]
1406 pub fn devices(&self) -> Option<&[GeneratedDevice]> {
1407 self.devices.as_deref()
1408 }
1409
1410 pub fn set_working_dir(&mut self, directory: GeneratedString) -> Result<(), GenerationError> {
1417 require_generated_string("working directory", &directory)?;
1418 set_once(&mut self.working_dir, directory, "working_dir")
1419 }
1420
1421 pub fn set_read_only(&mut self, read_only: bool) -> Result<(), GenerationError> {
1427 set_once(&mut self.read_only, read_only, "read_only")
1428 }
1429
1430 pub fn set_pids_limit(&mut self, limit: GeneratedPidsLimit) -> Result<(), GenerationError> {
1438 if let GeneratedPidsLimit::Finite(decimal) = &limit {
1439 if !valid_positive_pids_decimal(decimal) {
1440 return Err(GenerationError::InvalidPidsLimit);
1441 }
1442 }
1443 set_once(&mut self.pids_limit, limit, "pids_limit")
1444 }
1445
1446 pub fn set_shm_size(&mut self, size: GeneratedShmSize) -> Result<(), GenerationError> {
1454 let GeneratedShmSize::Explicit { amount, .. } = &size;
1455 if !valid_generated_shm_amount(amount.expose()) {
1456 return Err(GenerationError::InvalidShmSize);
1457 }
1458 set_once(&mut self.shm_size, size, "shm_size")
1459 }
1460
1461 pub fn set_mem_limit(&mut self, limit: GeneratedMemLimit) -> Result<(), GenerationError> {
1469 let GeneratedMemLimit::Explicit { amount, .. } = &limit;
1470 if !valid_generated_mem_amount(amount.expose()) {
1471 return Err(GenerationError::InvalidMemLimit);
1472 }
1473 set_once(&mut self.mem_limit, limit, "mem_limit")
1474 }
1475
1476 pub fn set_tmpfs(&mut self, tmpfs: GeneratedTmpfs) -> Result<(), GenerationError> {
1487 let items = match &tmpfs {
1488 GeneratedTmpfs::Scalar(item) => std::slice::from_ref(item),
1489 GeneratedTmpfs::List(items) => items.as_slice(),
1490 };
1491 for item in items {
1492 require_generated_string("tmpfs item", item)?;
1493 if item.expose().contains('\r') || item.expose().contains('\n') {
1494 return Err(GenerationError::ContainsLineBreak("tmpfs item"));
1495 }
1496 if !valid_generated_tmpfs_item(item.expose()) {
1497 return Err(GenerationError::InvalidTmpfsItem);
1498 }
1499 }
1500 set_once(&mut self.tmpfs, tmpfs, "tmpfs")
1501 }
1502
1503 #[must_use]
1505 pub const fn tmpfs(&self) -> Option<&GeneratedTmpfs> {
1506 self.tmpfs.as_ref()
1507 }
1508
1509 pub fn set_sysctls(&mut self, sysctls: GeneratedSysctls) -> Result<(), GenerationError> {
1520 let mut seen = BTreeSet::new();
1521 match &sysctls {
1522 GeneratedSysctls::Map(entries) => {
1523 for entry in entries {
1524 if !seen.insert(entry.name()) {
1525 return Err(GenerationError::DuplicateName {
1526 kind: "sysctl",
1527 name: entry.name().to_owned(),
1528 });
1529 }
1530 }
1531 }
1532 GeneratedSysctls::List(items) => {
1533 for item in items {
1534 if item.expose().contains(['\r', '\n', '$']) {
1535 return Err(GenerationError::InvalidSysctlValue);
1536 }
1537 if !seen.insert(item.expose()) {
1538 return Err(GenerationError::DuplicateItem("sysctls"));
1539 }
1540 }
1541 }
1542 }
1543 set_once(&mut self.sysctls, sysctls, "sysctls")
1544 }
1545
1546 #[must_use]
1548 pub const fn sysctls(&self) -> Option<&GeneratedSysctls> {
1549 self.sysctls.as_ref()
1550 }
1551
1552 pub fn set_ulimits(&mut self, ulimits: GeneratedUlimits) -> Result<(), GenerationError> {
1561 set_once(&mut self.ulimits, ulimits, "ulimits")
1562 }
1563
1564 #[must_use]
1566 pub const fn ulimits(&self) -> Option<&GeneratedUlimits> {
1567 self.ulimits.as_ref()
1568 }
1569
1570 pub fn set_pull_policy(&mut self, policy: GeneratedPullPolicy) -> Result<(), GenerationError> {
1577 if let GeneratedPullPolicy::Every(duration) = &policy {
1578 if !valid_pull_policy_duration(duration.expose()) {
1579 return Err(GenerationError::InvalidPullPolicyDuration);
1580 }
1581 }
1582 set_once(&mut self.pull_policy, policy, "pull_policy")
1583 }
1584
1585 pub fn set_restart(&mut self, restart: GeneratedRestartPolicy) -> Result<(), GenerationError> {
1591 set_once(&mut self.restart, restart, "restart")
1592 }
1593
1594 pub fn set_stop_signal(&mut self, signal: GeneratedString) -> Result<(), GenerationError> {
1601 set_once(&mut self.stop_signal, signal, "stop_signal")
1602 }
1603
1604 pub fn set_stop_grace_period(&mut self, period: GeneratedString) -> Result<(), GenerationError> {
1612 if !StopGracePeriod::parse(period.expose().to_owned()).is_valid() {
1613 return Err(GenerationError::InvalidStopGracePeriod);
1614 }
1615 set_once(&mut self.stop_grace_period, period, "stop_grace_period")
1616 }
1617
1618 pub fn add_extra_host(&mut self, host: GeneratedExtraHost) {
1620 self.extra_hosts.push(host);
1621 }
1622
1623 pub fn add_port(&mut self, port: GeneratedPort) {
1625 self.ports.push(port);
1626 }
1627
1628 pub fn add_mount(&mut self, mount: GeneratedMount) {
1630 self.mounts.push(mount);
1631 }
1632
1633 pub fn add_network(&mut self, network: GeneratedNetworkAttachment) -> Result<(), GenerationError> {
1639 if self.networks.iter().any(|candidate| candidate.name == network.name) {
1640 return Err(GenerationError::DuplicateName {
1641 kind: "service network",
1642 name: network.name,
1643 });
1644 }
1645 self.networks.push(network);
1646 Ok(())
1647 }
1648
1649 fn is_sensitive(&self) -> bool {
1650 matches!(
1651 self.hostname.as_ref(),
1652 Some(GeneratedHostname::Resolved(hostname)) if hostname.is_sensitive()
1653 ) || self.image.as_ref().is_some_and(GeneratedString::is_sensitive)
1654 || self.entrypoint.as_ref().is_some_and(entrypoint_is_sensitive)
1655 || self.command.as_ref().is_some_and(command_is_sensitive)
1656 || self
1657 .environment_files
1658 .iter()
1659 .any(GeneratedEnvironmentFile::is_sensitive)
1660 || self
1661 .environment
1662 .iter()
1663 .filter_map(GeneratedEnvironment::value)
1664 .any(GeneratedString::is_sensitive)
1665 || self.labels.iter().any(|label| label.value.is_sensitive())
1666 || matches!(
1667 self.pull_policy.as_ref(),
1668 Some(GeneratedPullPolicy::Every(duration)) if duration.is_sensitive()
1669 )
1670 || matches!(
1671 self.shm_size.as_ref(),
1672 Some(GeneratedShmSize::Explicit { amount, .. }) if amount.is_sensitive()
1673 )
1674 || matches!(
1675 self.mem_limit.as_ref(),
1676 Some(GeneratedMemLimit::Explicit { amount, .. }) if amount.is_sensitive()
1677 )
1678 || match self.tmpfs.as_ref() {
1679 Some(GeneratedTmpfs::Scalar(item)) => item.is_sensitive(),
1680 Some(GeneratedTmpfs::List(items)) => items.iter().any(GeneratedString::is_sensitive),
1681 None => false,
1682 }
1683 || match self.sysctls.as_ref() {
1684 Some(GeneratedSysctls::Map(entries)) => entries.iter().any(|entry| entry.value.is_sensitive()),
1685 Some(GeneratedSysctls::List(items)) => items.iter().any(GeneratedString::is_sensitive),
1686 None => false,
1687 }
1688 || self
1689 .ulimits
1690 .as_ref()
1691 .is_some_and(|limits| limits.entries.iter().any(GeneratedUlimit::is_sensitive))
1692 || [
1693 self.user.as_ref(),
1694 self.userns_mode.as_ref(),
1695 self.working_dir.as_ref(),
1696 self.stop_signal.as_ref(),
1697 self.stop_grace_period.as_ref(),
1698 ]
1699 .into_iter()
1700 .flatten()
1701 .any(GeneratedString::is_sensitive)
1702 || self.group_add.iter().any(GeneratedString::is_sensitive)
1703 || self
1704 .cap_add
1705 .as_ref()
1706 .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
1707 || self
1708 .cap_drop
1709 .as_ref()
1710 .is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
1711 || self
1712 .devices
1713 .as_ref()
1714 .is_some_and(|items| items.iter().any(GeneratedDevice::is_sensitive))
1715 }
1716}
1717
1718#[derive(Clone, Debug, Default, Eq, PartialEq)]
1720pub struct ComposeDocumentBuilder {
1721 name: Option<String>,
1722 services: Vec<GeneratedService>,
1723 networks: Vec<GeneratedResource>,
1724 volumes: Vec<GeneratedResource>,
1725}
1726
1727impl ComposeDocumentBuilder {
1728 #[must_use]
1730 pub const fn new() -> Self {
1731 Self {
1732 name: None,
1733 services: Vec::new(),
1734 networks: Vec::new(),
1735 volumes: Vec::new(),
1736 }
1737 }
1738
1739 pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
1745 let name = required("project name", name.into())?;
1746 set_once(&mut self.name, name, "name")
1747 }
1748
1749 pub fn add_service(&mut self, service: GeneratedService) -> Result<(), GenerationError> {
1755 insert_named(&mut self.services, service, "service", GeneratedService::name)
1756 }
1757
1758 pub fn add_network(&mut self, network: GeneratedResource) -> Result<(), GenerationError> {
1764 insert_named(&mut self.networks, network, "network", GeneratedResource::name)
1765 }
1766
1767 pub fn add_volume(&mut self, volume: GeneratedResource) -> Result<(), GenerationError> {
1773 insert_named(&mut self.volumes, volume, "volume", GeneratedResource::name)
1774 }
1775
1776 pub fn build(self, source_id: SourceId) -> Result<GeneratedComposeDocument, GenerationError> {
1783 if self.services.is_empty() {
1784 return Err(GenerationError::MissingService);
1785 }
1786 let sensitive = self.services.iter().any(GeneratedService::is_sensitive);
1787 let text = render_document(&self);
1788 let syntax = SyntaxDocument::parse(source_id, text.clone())
1789 .map_err(|_| GenerationError::InternalInvariant("syntax-tree"))?;
1790 if !syntax.is_valid() {
1791 return Err(GenerationError::InternalInvariant("syntax"));
1792 }
1793 let model = ComposeDocument::parse(syntax.document());
1794 if !model.is_valid() {
1795 return Err(GenerationError::InternalInvariant("typed-model"));
1796 }
1797 let document = model
1798 .document()
1799 .cloned()
1800 .ok_or(GenerationError::InternalInvariant("document-root"))?;
1801 Ok(GeneratedComposeDocument {
1802 text,
1803 sensitive,
1804 document,
1805 })
1806 }
1807}
1808
1809#[derive(Clone, Eq, PartialEq)]
1811pub struct GeneratedComposeDocument {
1812 text: String,
1813 sensitive: bool,
1814 document: ComposeDocument,
1815}
1816
1817impl GeneratedComposeDocument {
1818 #[must_use]
1820 pub fn text(&self) -> &str {
1821 &self.text
1822 }
1823
1824 #[must_use]
1826 pub const fn document(&self) -> &ComposeDocument {
1827 &self.document
1828 }
1829
1830 #[must_use]
1832 pub const fn is_sensitive(&self) -> bool {
1833 self.sensitive
1834 }
1835}
1836
1837impl fmt::Debug for GeneratedComposeDocument {
1838 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1839 formatter
1840 .debug_struct("GeneratedComposeDocument")
1841 .field("text", &if self.sensitive { "<redacted>" } else { &self.text })
1842 .field("sensitive", &self.sensitive)
1843 .field("document", &if self.sensitive { "<redacted>" } else { "validated" })
1844 .finish()
1845 }
1846}
1847
1848fn render_document(project: &ComposeDocumentBuilder) -> String {
1849 let mut output = String::new();
1850 if let Some(name) = &project.name {
1851 output.push_str("name: ");
1852 write_quoted(&mut output, name);
1853 output.push('\n');
1854 }
1855 output.push_str("services:\n");
1856 for service in &project.services {
1857 write_indent(&mut output, 1);
1858 write_quoted(&mut output, &service.name);
1859 output.push_str(":\n");
1860 render_service(&mut output, service);
1861 }
1862 render_resources(&mut output, "networks", &project.networks);
1863 render_resources(&mut output, "volumes", &project.volumes);
1864 output
1865}
1866
1867fn render_service(output: &mut String, service: &GeneratedService) {
1868 if let Some(GeneratedHostname::Resolved(hostname)) = &service.hostname {
1869 render_optional_string(output, "hostname", Some(hostname));
1870 }
1871 render_optional_string(output, "container_name", service.container_name.as_ref());
1872 render_optional_string(output, "image", service.image.as_ref());
1873 if let Some(entrypoint) = &service.entrypoint {
1874 render_entrypoint(output, entrypoint);
1875 }
1876 if let Some(command) = &service.command {
1877 render_command(output, command);
1878 }
1879 if let Some(init) = service.init {
1880 write_field(output, 2, "init");
1881 output.push_str(if init { "true\n" } else { "false\n" });
1882 }
1883 render_environment_files(output, &service.environment_files);
1884 render_environment(output, &service.environment);
1885 render_labels(output, &service.labels);
1886 render_optional_string(output, "user", service.user.as_ref());
1887 render_optional_string(output, "userns_mode", service.userns_mode.as_ref());
1888 render_string_sequence(output, "group_add", &service.group_add);
1889 if let Some(capabilities) = &service.cap_add {
1890 render_configured_string_sequence(output, "cap_add", capabilities);
1891 }
1892 if let Some(capabilities) = &service.cap_drop {
1893 render_configured_string_sequence(output, "cap_drop", capabilities);
1894 }
1895 render_optional_string(output, "working_dir", service.working_dir.as_ref());
1896 if let Some(read_only) = service.read_only {
1897 write_field(output, 2, "read_only");
1898 output.push_str(if read_only { "true\n" } else { "false\n" });
1899 }
1900 if let Some(pids_limit) = &service.pids_limit {
1901 render_pids_limit(output, pids_limit);
1902 }
1903 if let Some(shm_size) = &service.shm_size {
1904 render_shm_size(output, shm_size);
1905 }
1906 if let Some(mem_limit) = &service.mem_limit {
1907 render_mem_limit(output, mem_limit);
1908 }
1909 if let Some(devices) = &service.devices {
1910 render_devices(output, devices);
1911 }
1912 if let Some(tmpfs) = &service.tmpfs {
1913 render_tmpfs(output, tmpfs);
1914 }
1915 if let Some(sysctls) = &service.sysctls {
1916 render_sysctls(output, sysctls);
1917 }
1918 if let Some(ulimits) = &service.ulimits {
1919 render_ulimits(output, ulimits);
1920 }
1921 if let Some(pull_policy) = &service.pull_policy {
1922 render_pull_policy(output, pull_policy);
1923 }
1924 if let Some(restart) = service.restart {
1925 render_restart(output, restart);
1926 }
1927 render_optional_string(output, "stop_signal", service.stop_signal.as_ref());
1928 render_optional_string(output, "stop_grace_period", service.stop_grace_period.as_ref());
1929 render_extra_hosts(output, &service.extra_hosts);
1930 render_ports(output, &service.ports);
1931 render_mounts(output, &service.mounts);
1932 render_networks(output, &service.networks);
1933}
1934
1935fn render_pids_limit(output: &mut String, limit: &GeneratedPidsLimit) {
1936 write_field(output, 2, "pids_limit");
1937 match limit {
1938 GeneratedPidsLimit::Unlimited => output.push_str("-1\n"),
1939 GeneratedPidsLimit::Finite(decimal) => {
1940 output.push_str(decimal);
1941 output.push('\n');
1942 }
1943 }
1944}
1945
1946fn render_shm_size(output: &mut String, size: &GeneratedShmSize) {
1947 let GeneratedShmSize::Explicit { amount, unit } = size;
1948 write_field(output, 2, "shm_size");
1949 write_quoted(output, &format!("{}{}", amount.expose(), unit.as_str()));
1950 output.push('\n');
1951}
1952
1953fn render_mem_limit(output: &mut String, limit: &GeneratedMemLimit) {
1954 let GeneratedMemLimit::Explicit { amount, unit } = limit;
1955 write_field(output, 2, "mem_limit");
1956 write_quoted(output, &format!("{}{}", amount.expose(), unit.as_str()));
1957 output.push('\n');
1958}
1959
1960fn render_devices(output: &mut String, devices: &[GeneratedDevice]) {
1961 if devices.is_empty() {
1962 output.push_str(" devices: []\n");
1963 return;
1964 }
1965 output.push_str(" devices:\n");
1966 for device in devices {
1967 match device {
1968 GeneratedDevice::Short(value) => {
1969 output.push_str(" - ");
1970 write_quoted(output, value.expose());
1971 output.push('\n');
1972 }
1973 GeneratedDevice::Long(value) => {
1974 output.push_str(" - source: ");
1975 write_quoted(output, value.source().expose());
1976 output.push('\n');
1977 if let Some(target) = value.target() {
1978 output.push_str(" target: ");
1979 write_quoted(output, target.expose());
1980 output.push('\n');
1981 }
1982 if let Some(permissions) = value.permissions() {
1983 output.push_str(" permissions: ");
1984 write_quoted(output, permissions.expose());
1985 output.push('\n');
1986 }
1987 }
1988 }
1989 }
1990}
1991
1992fn render_tmpfs(output: &mut String, tmpfs: &GeneratedTmpfs) {
1993 match tmpfs {
1994 GeneratedTmpfs::Scalar(item) => render_optional_string(output, "tmpfs", Some(item)),
1995 GeneratedTmpfs::List(items) => render_configured_string_sequence(output, "tmpfs", items),
1996 }
1997}
1998
1999fn render_sysctls(output: &mut String, sysctls: &GeneratedSysctls) {
2000 match sysctls {
2001 GeneratedSysctls::Map(entries) if entries.is_empty() => output.push_str(" sysctls: {}\n"),
2002 GeneratedSysctls::Map(entries) => {
2003 output.push_str(" sysctls:\n");
2004 for entry in entries {
2005 write_indent(output, 3);
2006 write_quoted(output, entry.name());
2007 output.push_str(": ");
2008 write_quoted(output, entry.value().expose());
2009 output.push('\n');
2010 }
2011 }
2012 GeneratedSysctls::List(items) => render_configured_string_sequence(output, "sysctls", items),
2013 }
2014}
2015
2016fn render_ulimits(output: &mut String, ulimits: &GeneratedUlimits) {
2017 if ulimits.entries.is_empty() {
2018 output.push_str(" ulimits: {}\n");
2019 return;
2020 }
2021 output.push_str(" ulimits:\n");
2022 for limit in &ulimits.entries {
2023 write_indent(output, 3);
2024 write_quoted(output, limit.name());
2025 match limit.value() {
2026 GeneratedUlimitValue::Single(value) => {
2027 output.push_str(": ");
2028 write_quoted(output, value.expose());
2029 output.push('\n');
2030 }
2031 GeneratedUlimitValue::Range {
2032 soft: Some(soft),
2033 hard: Some(hard),
2034 } => {
2035 output.push_str(":\n");
2036 write_indent(output, 4);
2037 output.push_str("soft: ");
2038 write_quoted(output, soft.expose());
2039 output.push('\n');
2040 write_indent(output, 4);
2041 output.push_str("hard: ");
2042 write_quoted(output, hard.expose());
2043 output.push('\n');
2044 }
2045 GeneratedUlimitValue::Range { .. } => {
2046 unreachable!("generated ulimit ranges are validated during construction")
2047 }
2048 }
2049 }
2050}
2051
2052fn render_pull_policy(output: &mut String, policy: &GeneratedPullPolicy) {
2053 write_field(output, 2, "pull_policy");
2054 let value = match policy {
2055 GeneratedPullPolicy::Always => "always".to_owned(),
2056 GeneratedPullPolicy::Never => "never".to_owned(),
2057 GeneratedPullPolicy::Missing => "missing".to_owned(),
2058 GeneratedPullPolicy::IfNotPresentAlias => "if_not_present".to_owned(),
2059 GeneratedPullPolicy::Build => "build".to_owned(),
2060 GeneratedPullPolicy::Daily => "daily".to_owned(),
2061 GeneratedPullPolicy::Weekly => "weekly".to_owned(),
2062 GeneratedPullPolicy::Every(duration) => format!("every_{}", duration.expose()),
2063 };
2064 write_quoted(output, &value);
2065 output.push('\n');
2066}
2067
2068fn render_entrypoint(output: &mut String, entrypoint: &GeneratedEntrypoint) {
2069 match entrypoint {
2070 GeneratedEntrypoint::List(arguments) if arguments.is_empty() => output.push_str(" entrypoint: []\n"),
2071 GeneratedEntrypoint::List(arguments) => render_string_sequence(output, "entrypoint", arguments),
2072 GeneratedEntrypoint::String(entrypoint) => render_optional_string(output, "entrypoint", Some(entrypoint)),
2073 GeneratedEntrypoint::Empty => output.push_str(" entrypoint: []\n"),
2074 }
2075}
2076
2077fn render_restart(output: &mut String, restart: GeneratedRestartPolicy) {
2078 write_field(output, 2, "restart");
2079 let value = match restart {
2080 GeneratedRestartPolicy::No => "no".to_owned(),
2081 GeneratedRestartPolicy::Always => "always".to_owned(),
2082 GeneratedRestartPolicy::OnFailure { maximum_retries: None } => "on-failure".to_owned(),
2083 GeneratedRestartPolicy::OnFailure {
2084 maximum_retries: Some(maximum_retries),
2085 } => format!("on-failure:{maximum_retries}"),
2086 GeneratedRestartPolicy::UnlessStopped => "unless-stopped".to_owned(),
2087 };
2088 write_quoted(output, &value);
2089 output.push('\n');
2090}
2091
2092fn render_optional_string(output: &mut String, key: &str, value: Option<&GeneratedString>) {
2093 if let Some(value) = value {
2094 write_field(output, 2, key);
2095 write_quoted(output, value.expose());
2096 output.push('\n');
2097 }
2098}
2099
2100fn render_command(output: &mut String, command: &GeneratedCommand) {
2101 match command {
2102 GeneratedCommand::Exec(arguments) if arguments.is_empty() => output.push_str(" command: []\n"),
2103 GeneratedCommand::Exec(arguments) => render_string_sequence(output, "command", arguments),
2104 GeneratedCommand::Shell(command) => render_optional_string(output, "command", Some(command)),
2105 GeneratedCommand::Empty => output.push_str(" command: []\n"),
2106 }
2107}
2108
2109fn render_environment(output: &mut String, environment: &[GeneratedEnvironment]) {
2110 if environment.is_empty() {
2111 return;
2112 }
2113 output.push_str(" environment:\n");
2114 for variable in environment {
2115 output.push_str(" - ");
2116 let value = variable.value.as_ref().map_or_else(
2117 || variable.name.clone(),
2118 |value| format!("{}={}", variable.name, value.expose()),
2119 );
2120 write_quoted(output, &value);
2121 output.push('\n');
2122 }
2123}
2124
2125fn render_environment_files(output: &mut String, environment_files: &[GeneratedEnvironmentFile]) {
2126 if environment_files.is_empty() {
2127 return;
2128 }
2129 output.push_str(" env_file:\n");
2130 for environment_file in environment_files {
2131 match environment_file {
2132 GeneratedEnvironmentFile::Short(path) => {
2133 output.push_str(" - ");
2134 write_quoted(output, path.expose());
2135 output.push('\n');
2136 }
2137 GeneratedEnvironmentFile::Long { path, required, format } => {
2138 output.push_str(" - path: ");
2139 write_quoted(output, path.expose());
2140 output.push('\n');
2141 if let Some(required) = required {
2142 output.push_str(" required: ");
2143 output.push_str(if *required { "true\n" } else { "false\n" });
2144 }
2145 if let Some(format) = format {
2146 output.push_str(" format: ");
2147 write_quoted(
2148 output,
2149 match format {
2150 GeneratedEnvironmentFileFormat::Raw => "raw",
2151 },
2152 );
2153 output.push('\n');
2154 }
2155 }
2156 }
2157 }
2158}
2159
2160fn render_labels(output: &mut String, labels: &[GeneratedLabel]) {
2161 if labels.is_empty() {
2162 return;
2163 }
2164 output.push_str(" labels:\n");
2165 for label in labels {
2166 output.push_str(" ");
2167 write_quoted(output, &label.name);
2168 output.push_str(": ");
2169 write_quoted(output, label.value.expose());
2170 output.push('\n');
2171 }
2172}
2173
2174fn render_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
2175 if values.is_empty() {
2176 return;
2177 }
2178 write_indent(output, 2);
2179 output.push_str(key);
2180 output.push_str(":\n");
2181 for value in values {
2182 output.push_str(" - ");
2183 write_quoted(output, value.expose());
2184 output.push('\n');
2185 }
2186}
2187
2188fn render_configured_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
2189 if values.is_empty() {
2190 write_indent(output, 2);
2191 output.push_str(key);
2192 output.push_str(": []\n");
2193 } else {
2194 render_string_sequence(output, key, values);
2195 }
2196}
2197
2198fn render_extra_hosts(output: &mut String, hosts: &[GeneratedExtraHost]) {
2199 if hosts.is_empty() {
2200 return;
2201 }
2202 output.push_str(" extra_hosts:\n");
2203 for host in hosts {
2204 output.push_str(" - ");
2205 write_quoted(output, &format!("{}={}", host.hostname, host.address));
2206 output.push('\n');
2207 }
2208}
2209
2210fn render_ports(output: &mut String, ports: &[GeneratedPort]) {
2211 if ports.is_empty() {
2212 return;
2213 }
2214 output.push_str(" ports:\n");
2215 for port in ports {
2216 if port.protocol == GeneratedProtocol::Sctp {
2217 render_short_sctp_port(output, port);
2218 continue;
2219 }
2220 output.push_str(" - target: ");
2221 output.push_str(&port.target.to_string());
2222 output.push('\n');
2223 if let Some(published) = port.published {
2224 output.push_str(" published: ");
2225 write_quoted(output, &published.to_string());
2226 output.push('\n');
2227 }
2228 if let Some(host_ip) = &port.host_ip {
2229 output.push_str(" host_ip: ");
2230 write_quoted(output, host_ip);
2231 output.push('\n');
2232 }
2233 output.push_str(" protocol: ");
2234 write_quoted(output, port.protocol.as_str());
2235 output.push('\n');
2236 }
2237}
2238
2239fn render_short_sctp_port(output: &mut String, port: &GeneratedPort) {
2240 let mut value = String::new();
2241 if let Some(host_ip) = &port.host_ip {
2242 if host_ip.contains(':') && !(host_ip.starts_with('[') && host_ip.ends_with(']')) {
2243 value.push('[');
2244 value.push_str(host_ip);
2245 value.push(']');
2246 } else {
2247 value.push_str(host_ip);
2248 }
2249 value.push(':');
2250 }
2251 if let Some(published) = port.published {
2252 value.push_str(&published.to_string());
2253 value.push(':');
2254 }
2255 value.push_str(&port.target.to_string());
2256 value.push_str("/sctp");
2257
2258 output.push_str(" - ");
2259 write_quoted(output, &value);
2260 output.push('\n');
2261}
2262
2263fn render_mounts(output: &mut String, mounts: &[GeneratedMount]) {
2264 if mounts.is_empty() {
2265 return;
2266 }
2267 output.push_str(" volumes:\n");
2268 for mount in mounts {
2269 match &mount.kind {
2270 GeneratedMountKind::Bind {
2271 source,
2272 selinux: Some(selinux),
2273 } => render_selinux_bind(output, source, mount, *selinux),
2274 kind => render_long_mount(output, kind, mount),
2275 }
2276 }
2277}
2278
2279fn render_selinux_bind(output: &mut String, source: &str, mount: &GeneratedMount, selinux: GeneratedSelinux) {
2280 let mut value = format!("{source}:{}:{}", mount.target, selinux.as_str());
2281 if mount.read_only {
2282 value.push_str(",ro");
2283 }
2284 output.push_str(" - ");
2285 write_quoted(output, &value);
2286 output.push('\n');
2287}
2288
2289fn render_long_mount(output: &mut String, kind: &GeneratedMountKind, mount: &GeneratedMount) {
2290 let (mount_type, source) = match kind {
2291 GeneratedMountKind::Volume { source } => ("volume", Some(source.as_str())),
2292 GeneratedMountKind::Bind { source, selinux: None } => ("bind", Some(source.as_str())),
2293 GeneratedMountKind::Anonymous => ("volume", None),
2294 GeneratedMountKind::Bind { selinux: Some(_), .. } => return,
2295 };
2296 output.push_str(" - type: ");
2297 write_quoted(output, mount_type);
2298 output.push('\n');
2299 if let Some(source) = source {
2300 output.push_str(" source: ");
2301 write_quoted(output, source);
2302 output.push('\n');
2303 }
2304 output.push_str(" target: ");
2305 write_quoted(output, &mount.target);
2306 output.push('\n');
2307 if mount.read_only {
2308 output.push_str(" read_only: true\n");
2309 }
2310}
2311
2312fn render_networks(output: &mut String, networks: &[GeneratedNetworkAttachment]) {
2313 if networks.is_empty() {
2314 return;
2315 }
2316 output.push_str(" networks:\n");
2317 for network in networks {
2318 output.push_str(" ");
2319 write_quoted(output, &network.name);
2320 if network.aliases.is_empty() {
2321 output.push_str(": {}\n");
2322 } else {
2323 output.push_str(":\n aliases:\n");
2324 for alias in &network.aliases {
2325 output.push_str(" - ");
2326 write_quoted(output, alias);
2327 output.push('\n');
2328 }
2329 }
2330 }
2331}
2332
2333fn render_resources(output: &mut String, section: &str, resources: &[GeneratedResource]) {
2334 if resources.is_empty() {
2335 return;
2336 }
2337 output.push_str(section);
2338 output.push_str(":\n");
2339 for resource in resources {
2340 output.push_str(" ");
2341 write_quoted(output, &resource.name);
2342 if !resource.external && resource.custom_name.is_none() {
2343 output.push_str(": {}\n");
2344 continue;
2345 }
2346 output.push_str(":\n");
2347 if let Some(custom_name) = &resource.custom_name {
2348 output.push_str(" name: ");
2349 write_quoted(output, custom_name);
2350 output.push('\n');
2351 }
2352 if resource.external {
2353 output.push_str(" external: true\n");
2354 }
2355 }
2356}
2357
2358fn write_field(output: &mut String, depth: usize, key: &str) {
2359 write_indent(output, depth);
2360 output.push_str(key);
2361 output.push_str(": ");
2362}
2363
2364fn write_indent(output: &mut String, depth: usize) {
2365 for _ in 0..depth {
2366 output.push_str(" ");
2367 }
2368}
2369
2370fn required(kind: &'static str, value: String) -> Result<String, GenerationError> {
2371 if value.is_empty() {
2372 return Err(GenerationError::EmptyValue(kind));
2373 }
2374 if value.contains('\0') {
2375 return Err(GenerationError::ContainsNul(kind));
2376 }
2377 Ok(value)
2378}
2379
2380fn require_generated_string(kind: &'static str, value: &GeneratedString) -> Result<(), GenerationError> {
2381 if value.expose().is_empty() {
2382 return Err(GenerationError::EmptyValue(kind));
2383 }
2384 Ok(())
2385}
2386
2387fn validate_generated_device_member(
2388 member: &'static str,
2389 value: &GeneratedString,
2390 require_non_empty: bool,
2391) -> Result<(), GenerationError> {
2392 if valid_generated_device_string(value.expose(), require_non_empty) {
2393 Ok(())
2394 } else {
2395 Err(GenerationError::InvalidDeviceValue(member))
2396 }
2397}
2398
2399fn validate_generated_ulimit_value(value: &GeneratedString) -> Result<(), GenerationError> {
2400 let value = value.expose();
2401 if value.contains(['\r', '\n', '$'])
2402 || (value != "-1" && (value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit())))
2403 {
2404 return Err(GenerationError::InvalidUlimitValue);
2405 }
2406 Ok(())
2407}
2408
2409fn environment_name(value: String) -> Result<String, GenerationError> {
2410 let value = required("environment name", value)?;
2411 if value.contains('=') {
2412 return Err(GenerationError::InvalidEnvironmentName);
2413 }
2414 Ok(value)
2415}
2416
2417fn valid_container_name(value: &str) -> bool {
2418 let mut bytes = value.bytes();
2419 bytes.next().is_some_and(|byte| byte.is_ascii_alphanumeric())
2420 && bytes
2421 .next()
2422 .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
2423 && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
2424}
2425
2426fn short_component(kind: &'static str, value: String, separator: char) -> Result<String, GenerationError> {
2427 let value = required(kind, value)?;
2428 if value.contains(separator) {
2429 return Err(GenerationError::InvalidShortComponent(kind));
2430 }
2431 Ok(value)
2432}
2433
2434fn set_once<T>(slot: &mut Option<T>, value: T, field: &'static str) -> Result<(), GenerationError> {
2435 if slot.is_some() {
2436 return Err(GenerationError::DuplicateField(field));
2437 }
2438 *slot = Some(value);
2439 Ok(())
2440}
2441
2442fn insert_named<T>(
2443 values: &mut Vec<T>,
2444 value: T,
2445 kind: &'static str,
2446 name: impl Fn(&T) -> &str,
2447) -> Result<(), GenerationError> {
2448 let value_name = name(&value);
2449 if values.iter().any(|candidate| name(candidate) == value_name) {
2450 return Err(GenerationError::DuplicateName {
2451 kind,
2452 name: value_name.to_owned(),
2453 });
2454 }
2455 values.push(value);
2456 Ok(())
2457}
2458
2459fn command_is_sensitive(command: &GeneratedCommand) -> bool {
2460 match command {
2461 GeneratedCommand::Exec(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
2462 GeneratedCommand::Shell(command) => command.is_sensitive(),
2463 GeneratedCommand::Empty => false,
2464 }
2465}
2466
2467fn entrypoint_is_sensitive(entrypoint: &GeneratedEntrypoint) -> bool {
2468 match entrypoint {
2469 GeneratedEntrypoint::List(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
2470 GeneratedEntrypoint::String(entrypoint) => entrypoint.is_sensitive(),
2471 GeneratedEntrypoint::Empty => false,
2472 }
2473}