1use std::{error::Error, fmt};
4
5use crate::{model::ComposeDocument, source::SourceId, syntax::SyntaxDocument};
6
7use super::write_quoted;
8
9#[derive(Clone, Debug, Eq, PartialEq)]
11#[non_exhaustive]
12pub enum GenerationError {
13 EmptyValue(&'static str),
15 ContainsNul(&'static str),
17 InvalidEnvironmentName,
19 InvalidContainerName,
21 InvalidShortComponent(&'static str),
23 InvalidSelinuxBind,
25 DuplicateField(&'static str),
27 DuplicateName {
29 kind: &'static str,
31 name: String,
33 },
34 InvalidPort,
36 UnrepresentableSctpHostIp,
38 MissingService,
40 InternalInvariant(&'static str),
42}
43
44impl fmt::Display for GenerationError {
45 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46 match self {
47 Self::EmptyValue(kind) => write!(formatter, "generated {kind} must not be empty"),
48 Self::ContainsNul(kind) => write!(formatter, "generated {kind} must not contain a NUL byte"),
49 Self::InvalidEnvironmentName => formatter.write_str("generated environment name must not contain `=`"),
50 Self::InvalidContainerName => {
51 formatter.write_str("generated container name must match `[a-zA-Z0-9][a-zA-Z0-9_.-]+`")
52 }
53 Self::InvalidShortComponent(kind) => {
54 write!(formatter, "generated {kind} contains its reserved short-form separator")
55 }
56 Self::InvalidSelinuxBind => formatter
57 .write_str("generated SELinux bind source and target must not contain the short-syntax `:` separator"),
58 Self::DuplicateField(field) => write!(formatter, "generated field `{field}` was configured more than once"),
59 Self::DuplicateName { kind, name } => {
60 write!(formatter, "generated {kind} `{name}` was added more than once")
61 }
62 Self::InvalidPort => formatter.write_str("generated container target port must be greater than zero"),
63 Self::UnrepresentableSctpHostIp => formatter.write_str(
64 "generated SCTP port with a host address also requires a published port for Compose short syntax",
65 ),
66 Self::MissingService => formatter.write_str("generated Compose project requires at least one service"),
67 Self::InternalInvariant(stage) => write!(formatter, "generated Compose document failed {stage} validation"),
68 }
69 }
70}
71
72impl Error for GenerationError {}
73
74#[derive(Clone, Eq, PartialEq)]
76pub struct GeneratedString {
77 value: String,
78 sensitive: bool,
79}
80
81impl GeneratedString {
82 pub fn plain(value: impl Into<String>) -> Result<Self, GenerationError> {
88 Self::new(value.into(), false)
89 }
90
91 pub fn sensitive(value: impl Into<String>) -> Result<Self, GenerationError> {
97 Self::new(value.into(), true)
98 }
99
100 fn new(value: String, sensitive: bool) -> Result<Self, GenerationError> {
101 if value.contains('\0') {
102 return Err(GenerationError::ContainsNul("string"));
103 }
104 Ok(Self { value, sensitive })
105 }
106
107 #[must_use]
109 pub fn expose(&self) -> &str {
110 &self.value
111 }
112
113 #[must_use]
115 pub const fn is_sensitive(&self) -> bool {
116 self.sensitive
117 }
118}
119
120impl fmt::Debug for GeneratedString {
121 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122 formatter
123 .debug_struct("GeneratedString")
124 .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
125 .field("sensitive", &self.sensitive)
126 .finish()
127 }
128}
129
130#[derive(Clone, Debug, Eq, PartialEq)]
132#[non_exhaustive]
133pub enum GeneratedCommand {
134 Exec(Vec<GeneratedString>),
136 Shell(GeneratedString),
138 Empty,
140}
141
142#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144#[non_exhaustive]
145pub enum GeneratedRestartPolicy {
146 No,
148 Always,
150 OnFailure {
152 maximum_retries: Option<u64>,
154 },
155 UnlessStopped,
157}
158
159#[derive(Clone, Debug, Eq, PartialEq)]
161pub struct GeneratedEnvironment {
162 name: String,
163 value: Option<GeneratedString>,
164}
165
166#[derive(Clone, Copy, Debug, Eq, PartialEq)]
168#[non_exhaustive]
169pub enum GeneratedEnvironmentFileFormat {
170 Raw,
172}
173
174#[derive(Clone, Debug, Eq, PartialEq)]
176#[non_exhaustive]
177pub enum GeneratedEnvironmentFile {
178 Short(GeneratedString),
180 Long {
182 path: GeneratedString,
184 required: Option<bool>,
186 format: Option<GeneratedEnvironmentFileFormat>,
188 },
189}
190
191#[derive(Clone, Debug, Eq, PartialEq)]
193pub struct GeneratedLabel {
194 name: String,
195 value: GeneratedString,
196}
197
198impl GeneratedLabel {
199 pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
206 Ok(Self {
207 name: required("label name", name.into())?,
208 value,
209 })
210 }
211
212 #[must_use]
214 pub fn name(&self) -> &str {
215 &self.name
216 }
217
218 #[must_use]
220 pub const fn value(&self) -> &GeneratedString {
221 &self.value
222 }
223}
224
225impl GeneratedEnvironment {
226 pub fn literal(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
232 Ok(Self {
233 name: environment_name(name.into())?,
234 value: Some(value),
235 })
236 }
237
238 pub fn host(name: impl Into<String>) -> Result<Self, GenerationError> {
244 Ok(Self {
245 name: environment_name(name.into())?,
246 value: None,
247 })
248 }
249
250 #[must_use]
252 pub fn name(&self) -> &str {
253 &self.name
254 }
255
256 #[must_use]
258 pub const fn value(&self) -> Option<&GeneratedString> {
259 self.value.as_ref()
260 }
261}
262
263impl GeneratedEnvironmentFile {
264 pub fn short(path: GeneratedString) -> Result<Self, GenerationError> {
271 require_generated_string("environment-file path", &path)?;
272 Ok(Self::Short(path))
273 }
274
275 pub fn long(
282 path: GeneratedString,
283 required: Option<bool>,
284 format: Option<GeneratedEnvironmentFileFormat>,
285 ) -> Result<Self, GenerationError> {
286 require_generated_string("environment-file path", &path)?;
287 Ok(Self::Long { path, required, format })
288 }
289
290 #[must_use]
292 pub const fn path(&self) -> &GeneratedString {
293 match self {
294 Self::Short(path) | Self::Long { path, .. } => path,
295 }
296 }
297
298 #[must_use]
300 pub const fn required(&self) -> Option<bool> {
301 match self {
302 Self::Short(_) => None,
303 Self::Long { required, .. } => *required,
304 }
305 }
306
307 #[must_use]
309 pub const fn format(&self) -> Option<GeneratedEnvironmentFileFormat> {
310 match self {
311 Self::Short(_) => None,
312 Self::Long { format, .. } => *format,
313 }
314 }
315
316 #[must_use]
318 pub const fn is_sensitive(&self) -> bool {
319 self.path().is_sensitive()
320 }
321}
322
323#[derive(Clone, Debug, Eq, PartialEq)]
325pub struct GeneratedExtraHost {
326 hostname: String,
327 address: String,
328}
329
330impl GeneratedExtraHost {
331 pub fn new(hostname: impl Into<String>, address: impl Into<String>) -> Result<Self, GenerationError> {
337 let hostname = short_component("extra-host hostname", hostname.into(), '=')?;
338 let address = short_component("extra-host address", address.into(), '=')?;
339 Ok(Self { hostname, address })
340 }
341
342 #[must_use]
344 pub fn hostname(&self) -> &str {
345 &self.hostname
346 }
347
348 #[must_use]
350 pub fn address(&self) -> &str {
351 &self.address
352 }
353}
354
355#[derive(Clone, Copy, Debug, Eq, PartialEq)]
357#[non_exhaustive]
358pub enum GeneratedProtocol {
359 Tcp,
361 Udp,
363 Sctp,
365}
366
367impl GeneratedProtocol {
368 const fn as_str(self) -> &'static str {
369 match self {
370 Self::Tcp => "tcp",
371 Self::Udp => "udp",
372 Self::Sctp => "sctp",
373 }
374 }
375}
376
377#[derive(Clone, Debug, Eq, PartialEq)]
379pub struct GeneratedPort {
380 target: u16,
381 published: Option<u16>,
382 host_ip: Option<String>,
383 protocol: GeneratedProtocol,
384}
385
386impl GeneratedPort {
387 pub fn new(
395 target: u16,
396 published: Option<u16>,
397 host_ip: Option<String>,
398 protocol: GeneratedProtocol,
399 ) -> Result<Self, GenerationError> {
400 if target == 0 {
401 return Err(GenerationError::InvalidPort);
402 }
403 if let Some(host_ip) = host_ip.as_deref() {
404 required("port host address", host_ip.to_owned())?;
405 if protocol == GeneratedProtocol::Sctp && published.is_none() {
406 return Err(GenerationError::UnrepresentableSctpHostIp);
407 }
408 }
409 Ok(Self {
410 target,
411 published,
412 host_ip,
413 protocol,
414 })
415 }
416
417 #[must_use]
419 pub const fn target(&self) -> u16 {
420 self.target
421 }
422
423 #[must_use]
425 pub const fn published(&self) -> Option<u16> {
426 self.published
427 }
428
429 #[must_use]
431 pub fn host_ip(&self) -> Option<&str> {
432 self.host_ip.as_deref()
433 }
434
435 #[must_use]
437 pub const fn protocol(&self) -> GeneratedProtocol {
438 self.protocol
439 }
440}
441
442#[derive(Clone, Copy, Debug, Eq, PartialEq)]
444#[non_exhaustive]
445pub enum GeneratedSelinux {
446 Private,
448 Shared,
450}
451
452impl GeneratedSelinux {
453 const fn as_str(self) -> &'static str {
454 match self {
455 Self::Private => "Z",
456 Self::Shared => "z",
457 }
458 }
459}
460
461#[derive(Clone, Debug, Eq, PartialEq)]
462enum GeneratedMountKind {
463 Volume {
464 source: String,
465 },
466 Bind {
467 source: String,
468 selinux: Option<GeneratedSelinux>,
469 },
470 Anonymous,
471}
472
473#[derive(Clone, Debug, Eq, PartialEq)]
475pub struct GeneratedMount {
476 kind: GeneratedMountKind,
477 target: String,
478 read_only: bool,
479}
480
481impl GeneratedMount {
482 pub fn volume(
488 source: impl Into<String>,
489 target: impl Into<String>,
490 read_only: bool,
491 ) -> Result<Self, GenerationError> {
492 Ok(Self {
493 kind: GeneratedMountKind::Volume {
494 source: required("volume source", source.into())?,
495 },
496 target: required("mount target", target.into())?,
497 read_only,
498 })
499 }
500
501 pub fn bind(
508 source: impl Into<String>,
509 target: impl Into<String>,
510 read_only: bool,
511 selinux: Option<GeneratedSelinux>,
512 ) -> Result<Self, GenerationError> {
513 let source = required("bind source", source.into())?;
514 let target = required("mount target", target.into())?;
515 if selinux.is_some() && (source.contains(':') || target.contains(':')) {
516 return Err(GenerationError::InvalidSelinuxBind);
517 }
518 Ok(Self {
519 kind: GeneratedMountKind::Bind { source, selinux },
520 target,
521 read_only,
522 })
523 }
524
525 pub fn anonymous(target: impl Into<String>, read_only: bool) -> Result<Self, GenerationError> {
531 Ok(Self {
532 kind: GeneratedMountKind::Anonymous,
533 target: required("mount target", target.into())?,
534 read_only,
535 })
536 }
537
538 #[must_use]
540 pub fn target(&self) -> &str {
541 &self.target
542 }
543
544 #[must_use]
546 pub const fn read_only(&self) -> bool {
547 self.read_only
548 }
549}
550
551#[derive(Clone, Debug, Eq, PartialEq)]
553pub struct GeneratedNetworkAttachment {
554 name: String,
555 aliases: Vec<String>,
556}
557
558impl GeneratedNetworkAttachment {
559 pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
565 Ok(Self {
566 name: required("network name", name.into())?,
567 aliases: Vec::new(),
568 })
569 }
570
571 pub fn add_alias(&mut self, alias: impl Into<String>) -> Result<(), GenerationError> {
577 self.aliases.push(required("network alias", alias.into())?);
578 Ok(())
579 }
580
581 #[must_use]
583 pub fn name(&self) -> &str {
584 &self.name
585 }
586
587 #[must_use]
589 pub fn aliases(&self) -> &[String] {
590 &self.aliases
591 }
592}
593
594#[derive(Clone, Debug, Eq, PartialEq)]
596pub struct GeneratedResource {
597 name: String,
598 external: bool,
599 custom_name: Option<String>,
600}
601
602impl GeneratedResource {
603 pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
609 Ok(Self {
610 name: required("resource name", name.into())?,
611 external: false,
612 custom_name: None,
613 })
614 }
615
616 pub fn external(name: impl Into<String>) -> Result<Self, GenerationError> {
622 Ok(Self {
623 name: required("resource name", name.into())?,
624 external: true,
625 custom_name: None,
626 })
627 }
628
629 pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
637 let name = required("custom resource name", name.into())?;
638 set_once(&mut self.custom_name, name, "resource name")
639 }
640
641 #[must_use]
643 pub fn name(&self) -> &str {
644 &self.name
645 }
646
647 #[must_use]
649 pub const fn is_external(&self) -> bool {
650 self.external
651 }
652
653 #[must_use]
655 pub fn custom_name(&self) -> Option<&str> {
656 self.custom_name.as_deref()
657 }
658}
659
660#[derive(Clone, Debug, Eq, PartialEq)]
662pub struct GeneratedService {
663 name: String,
664 container_name: Option<GeneratedString>,
665 image: Option<GeneratedString>,
666 command: Option<GeneratedCommand>,
667 environment_files: Vec<GeneratedEnvironmentFile>,
668 environment: Vec<GeneratedEnvironment>,
669 labels: Vec<GeneratedLabel>,
670 user: Option<GeneratedString>,
671 userns_mode: Option<GeneratedString>,
672 group_add: Vec<GeneratedString>,
673 working_dir: Option<GeneratedString>,
674 read_only: Option<bool>,
675 restart: Option<GeneratedRestartPolicy>,
676 extra_hosts: Vec<GeneratedExtraHost>,
677 ports: Vec<GeneratedPort>,
678 mounts: Vec<GeneratedMount>,
679 networks: Vec<GeneratedNetworkAttachment>,
680}
681
682impl GeneratedService {
683 pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
689 Ok(Self {
690 name: required("service name", name.into())?,
691 container_name: None,
692 image: None,
693 command: None,
694 environment_files: Vec::new(),
695 environment: Vec::new(),
696 labels: Vec::new(),
697 user: None,
698 userns_mode: None,
699 group_add: Vec::new(),
700 working_dir: None,
701 read_only: None,
702 restart: None,
703 extra_hosts: Vec::new(),
704 ports: Vec::new(),
705 mounts: Vec::new(),
706 networks: Vec::new(),
707 })
708 }
709
710 #[must_use]
712 pub fn name(&self) -> &str {
713 &self.name
714 }
715
716 pub fn set_container_name(&mut self, name: GeneratedString) -> Result<(), GenerationError> {
724 if !valid_container_name(name.expose()) {
725 return Err(GenerationError::InvalidContainerName);
726 }
727 set_once(&mut self.container_name, name, "container_name")
728 }
729
730 pub fn set_image(&mut self, image: GeneratedString) -> Result<(), GenerationError> {
737 require_generated_string("service image", &image)?;
738 set_once(&mut self.image, image, "image")
739 }
740
741 pub fn set_command(&mut self, command: GeneratedCommand) -> Result<(), GenerationError> {
747 set_once(&mut self.command, command, "command")
748 }
749
750 pub fn add_environment_file(&mut self, environment_file: GeneratedEnvironmentFile) {
752 self.environment_files.push(environment_file);
753 }
754
755 pub fn add_environment(&mut self, environment: GeneratedEnvironment) {
757 self.environment.push(environment);
758 }
759
760 pub fn add_label(&mut self, label: GeneratedLabel) -> Result<(), GenerationError> {
766 if self.labels.iter().any(|candidate| candidate.name == label.name) {
767 return Err(GenerationError::DuplicateName {
768 kind: "service label",
769 name: label.name,
770 });
771 }
772 self.labels.push(label);
773 Ok(())
774 }
775
776 pub fn set_user(&mut self, user: GeneratedString) -> Result<(), GenerationError> {
782 set_once(&mut self.user, user, "user")
783 }
784
785 pub fn set_userns_mode(&mut self, mode: GeneratedString) -> Result<(), GenerationError> {
792 require_generated_string("user namespace mode", &mode)?;
793 set_once(&mut self.userns_mode, mode, "userns_mode")
794 }
795
796 pub fn add_supplementary_group(&mut self, group: GeneratedString) -> Result<(), GenerationError> {
802 require_generated_string("supplementary group", &group)?;
803 self.group_add.push(group);
804 Ok(())
805 }
806
807 pub fn set_working_dir(&mut self, directory: GeneratedString) -> Result<(), GenerationError> {
814 require_generated_string("working directory", &directory)?;
815 set_once(&mut self.working_dir, directory, "working_dir")
816 }
817
818 pub fn set_read_only(&mut self, read_only: bool) -> Result<(), GenerationError> {
824 set_once(&mut self.read_only, read_only, "read_only")
825 }
826
827 pub fn set_restart(&mut self, restart: GeneratedRestartPolicy) -> Result<(), GenerationError> {
833 set_once(&mut self.restart, restart, "restart")
834 }
835
836 pub fn add_extra_host(&mut self, host: GeneratedExtraHost) {
838 self.extra_hosts.push(host);
839 }
840
841 pub fn add_port(&mut self, port: GeneratedPort) {
843 self.ports.push(port);
844 }
845
846 pub fn add_mount(&mut self, mount: GeneratedMount) {
848 self.mounts.push(mount);
849 }
850
851 pub fn add_network(&mut self, network: GeneratedNetworkAttachment) -> Result<(), GenerationError> {
857 if self.networks.iter().any(|candidate| candidate.name == network.name) {
858 return Err(GenerationError::DuplicateName {
859 kind: "service network",
860 name: network.name,
861 });
862 }
863 self.networks.push(network);
864 Ok(())
865 }
866
867 fn is_sensitive(&self) -> bool {
868 self.image.as_ref().is_some_and(GeneratedString::is_sensitive)
869 || self.command.as_ref().is_some_and(command_is_sensitive)
870 || self
871 .environment_files
872 .iter()
873 .any(GeneratedEnvironmentFile::is_sensitive)
874 || self
875 .environment
876 .iter()
877 .filter_map(GeneratedEnvironment::value)
878 .any(GeneratedString::is_sensitive)
879 || self.labels.iter().any(|label| label.value.is_sensitive())
880 || [self.user.as_ref(), self.userns_mode.as_ref(), self.working_dir.as_ref()]
881 .into_iter()
882 .flatten()
883 .any(GeneratedString::is_sensitive)
884 || self.group_add.iter().any(GeneratedString::is_sensitive)
885 }
886}
887
888#[derive(Clone, Debug, Default, Eq, PartialEq)]
890pub struct ComposeDocumentBuilder {
891 name: Option<String>,
892 services: Vec<GeneratedService>,
893 networks: Vec<GeneratedResource>,
894 volumes: Vec<GeneratedResource>,
895}
896
897impl ComposeDocumentBuilder {
898 #[must_use]
900 pub const fn new() -> Self {
901 Self {
902 name: None,
903 services: Vec::new(),
904 networks: Vec::new(),
905 volumes: Vec::new(),
906 }
907 }
908
909 pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
915 let name = required("project name", name.into())?;
916 set_once(&mut self.name, name, "name")
917 }
918
919 pub fn add_service(&mut self, service: GeneratedService) -> Result<(), GenerationError> {
925 insert_named(&mut self.services, service, "service", GeneratedService::name)
926 }
927
928 pub fn add_network(&mut self, network: GeneratedResource) -> Result<(), GenerationError> {
934 insert_named(&mut self.networks, network, "network", GeneratedResource::name)
935 }
936
937 pub fn add_volume(&mut self, volume: GeneratedResource) -> Result<(), GenerationError> {
943 insert_named(&mut self.volumes, volume, "volume", GeneratedResource::name)
944 }
945
946 pub fn build(self, source_id: SourceId) -> Result<GeneratedComposeDocument, GenerationError> {
953 if self.services.is_empty() {
954 return Err(GenerationError::MissingService);
955 }
956 let sensitive = self.services.iter().any(GeneratedService::is_sensitive);
957 let text = render_document(&self);
958 let syntax = SyntaxDocument::parse(source_id, text.clone())
959 .map_err(|_| GenerationError::InternalInvariant("syntax-tree"))?;
960 if !syntax.is_valid() {
961 return Err(GenerationError::InternalInvariant("syntax"));
962 }
963 let model = ComposeDocument::parse(syntax.document());
964 if !model.is_valid() {
965 return Err(GenerationError::InternalInvariant("typed-model"));
966 }
967 let document = model
968 .document()
969 .cloned()
970 .ok_or(GenerationError::InternalInvariant("document-root"))?;
971 Ok(GeneratedComposeDocument {
972 text,
973 sensitive,
974 document,
975 })
976 }
977}
978
979#[derive(Clone, Eq, PartialEq)]
981pub struct GeneratedComposeDocument {
982 text: String,
983 sensitive: bool,
984 document: ComposeDocument,
985}
986
987impl GeneratedComposeDocument {
988 #[must_use]
990 pub fn text(&self) -> &str {
991 &self.text
992 }
993
994 #[must_use]
996 pub const fn document(&self) -> &ComposeDocument {
997 &self.document
998 }
999
1000 #[must_use]
1002 pub const fn is_sensitive(&self) -> bool {
1003 self.sensitive
1004 }
1005}
1006
1007impl fmt::Debug for GeneratedComposeDocument {
1008 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1009 formatter
1010 .debug_struct("GeneratedComposeDocument")
1011 .field("text", &if self.sensitive { "<redacted>" } else { &self.text })
1012 .field("sensitive", &self.sensitive)
1013 .field("document", &if self.sensitive { "<redacted>" } else { "validated" })
1014 .finish()
1015 }
1016}
1017
1018fn render_document(project: &ComposeDocumentBuilder) -> String {
1019 let mut output = String::new();
1020 if let Some(name) = &project.name {
1021 output.push_str("name: ");
1022 write_quoted(&mut output, name);
1023 output.push('\n');
1024 }
1025 output.push_str("services:\n");
1026 for service in &project.services {
1027 write_indent(&mut output, 1);
1028 write_quoted(&mut output, &service.name);
1029 output.push_str(":\n");
1030 render_service(&mut output, service);
1031 }
1032 render_resources(&mut output, "networks", &project.networks);
1033 render_resources(&mut output, "volumes", &project.volumes);
1034 output
1035}
1036
1037fn render_service(output: &mut String, service: &GeneratedService) {
1038 render_optional_string(output, "container_name", service.container_name.as_ref());
1039 render_optional_string(output, "image", service.image.as_ref());
1040 if let Some(command) = &service.command {
1041 render_command(output, command);
1042 }
1043 render_environment_files(output, &service.environment_files);
1044 render_environment(output, &service.environment);
1045 render_labels(output, &service.labels);
1046 render_optional_string(output, "user", service.user.as_ref());
1047 render_optional_string(output, "userns_mode", service.userns_mode.as_ref());
1048 render_string_sequence(output, "group_add", &service.group_add);
1049 render_optional_string(output, "working_dir", service.working_dir.as_ref());
1050 if let Some(read_only) = service.read_only {
1051 write_field(output, 2, "read_only");
1052 output.push_str(if read_only { "true\n" } else { "false\n" });
1053 }
1054 if let Some(restart) = service.restart {
1055 render_restart(output, restart);
1056 }
1057 render_extra_hosts(output, &service.extra_hosts);
1058 render_ports(output, &service.ports);
1059 render_mounts(output, &service.mounts);
1060 render_networks(output, &service.networks);
1061}
1062
1063fn render_restart(output: &mut String, restart: GeneratedRestartPolicy) {
1064 write_field(output, 2, "restart");
1065 let value = match restart {
1066 GeneratedRestartPolicy::No => "no".to_owned(),
1067 GeneratedRestartPolicy::Always => "always".to_owned(),
1068 GeneratedRestartPolicy::OnFailure { maximum_retries: None } => "on-failure".to_owned(),
1069 GeneratedRestartPolicy::OnFailure {
1070 maximum_retries: Some(maximum_retries),
1071 } => format!("on-failure:{maximum_retries}"),
1072 GeneratedRestartPolicy::UnlessStopped => "unless-stopped".to_owned(),
1073 };
1074 write_quoted(output, &value);
1075 output.push('\n');
1076}
1077
1078fn render_optional_string(output: &mut String, key: &str, value: Option<&GeneratedString>) {
1079 if let Some(value) = value {
1080 write_field(output, 2, key);
1081 write_quoted(output, value.expose());
1082 output.push('\n');
1083 }
1084}
1085
1086fn render_command(output: &mut String, command: &GeneratedCommand) {
1087 match command {
1088 GeneratedCommand::Exec(arguments) if arguments.is_empty() => output.push_str(" command: []\n"),
1089 GeneratedCommand::Exec(arguments) => render_string_sequence(output, "command", arguments),
1090 GeneratedCommand::Shell(command) => render_optional_string(output, "command", Some(command)),
1091 GeneratedCommand::Empty => output.push_str(" command: []\n"),
1092 }
1093}
1094
1095fn render_environment(output: &mut String, environment: &[GeneratedEnvironment]) {
1096 if environment.is_empty() {
1097 return;
1098 }
1099 output.push_str(" environment:\n");
1100 for variable in environment {
1101 output.push_str(" - ");
1102 let value = variable.value.as_ref().map_or_else(
1103 || variable.name.clone(),
1104 |value| format!("{}={}", variable.name, value.expose()),
1105 );
1106 write_quoted(output, &value);
1107 output.push('\n');
1108 }
1109}
1110
1111fn render_environment_files(output: &mut String, environment_files: &[GeneratedEnvironmentFile]) {
1112 if environment_files.is_empty() {
1113 return;
1114 }
1115 output.push_str(" env_file:\n");
1116 for environment_file in environment_files {
1117 match environment_file {
1118 GeneratedEnvironmentFile::Short(path) => {
1119 output.push_str(" - ");
1120 write_quoted(output, path.expose());
1121 output.push('\n');
1122 }
1123 GeneratedEnvironmentFile::Long { path, required, format } => {
1124 output.push_str(" - path: ");
1125 write_quoted(output, path.expose());
1126 output.push('\n');
1127 if let Some(required) = required {
1128 output.push_str(" required: ");
1129 output.push_str(if *required { "true\n" } else { "false\n" });
1130 }
1131 if let Some(format) = format {
1132 output.push_str(" format: ");
1133 write_quoted(
1134 output,
1135 match format {
1136 GeneratedEnvironmentFileFormat::Raw => "raw",
1137 },
1138 );
1139 output.push('\n');
1140 }
1141 }
1142 }
1143 }
1144}
1145
1146fn render_labels(output: &mut String, labels: &[GeneratedLabel]) {
1147 if labels.is_empty() {
1148 return;
1149 }
1150 output.push_str(" labels:\n");
1151 for label in labels {
1152 output.push_str(" ");
1153 write_quoted(output, &label.name);
1154 output.push_str(": ");
1155 write_quoted(output, label.value.expose());
1156 output.push('\n');
1157 }
1158}
1159
1160fn render_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
1161 if values.is_empty() {
1162 return;
1163 }
1164 write_indent(output, 2);
1165 output.push_str(key);
1166 output.push_str(":\n");
1167 for value in values {
1168 output.push_str(" - ");
1169 write_quoted(output, value.expose());
1170 output.push('\n');
1171 }
1172}
1173
1174fn render_extra_hosts(output: &mut String, hosts: &[GeneratedExtraHost]) {
1175 if hosts.is_empty() {
1176 return;
1177 }
1178 output.push_str(" extra_hosts:\n");
1179 for host in hosts {
1180 output.push_str(" - ");
1181 write_quoted(output, &format!("{}={}", host.hostname, host.address));
1182 output.push('\n');
1183 }
1184}
1185
1186fn render_ports(output: &mut String, ports: &[GeneratedPort]) {
1187 if ports.is_empty() {
1188 return;
1189 }
1190 output.push_str(" ports:\n");
1191 for port in ports {
1192 if port.protocol == GeneratedProtocol::Sctp {
1193 render_short_sctp_port(output, port);
1194 continue;
1195 }
1196 output.push_str(" - target: ");
1197 output.push_str(&port.target.to_string());
1198 output.push('\n');
1199 if let Some(published) = port.published {
1200 output.push_str(" published: ");
1201 write_quoted(output, &published.to_string());
1202 output.push('\n');
1203 }
1204 if let Some(host_ip) = &port.host_ip {
1205 output.push_str(" host_ip: ");
1206 write_quoted(output, host_ip);
1207 output.push('\n');
1208 }
1209 output.push_str(" protocol: ");
1210 write_quoted(output, port.protocol.as_str());
1211 output.push('\n');
1212 }
1213}
1214
1215fn render_short_sctp_port(output: &mut String, port: &GeneratedPort) {
1216 let mut value = String::new();
1217 if let Some(host_ip) = &port.host_ip {
1218 if host_ip.contains(':') && !(host_ip.starts_with('[') && host_ip.ends_with(']')) {
1219 value.push('[');
1220 value.push_str(host_ip);
1221 value.push(']');
1222 } else {
1223 value.push_str(host_ip);
1224 }
1225 value.push(':');
1226 }
1227 if let Some(published) = port.published {
1228 value.push_str(&published.to_string());
1229 value.push(':');
1230 }
1231 value.push_str(&port.target.to_string());
1232 value.push_str("/sctp");
1233
1234 output.push_str(" - ");
1235 write_quoted(output, &value);
1236 output.push('\n');
1237}
1238
1239fn render_mounts(output: &mut String, mounts: &[GeneratedMount]) {
1240 if mounts.is_empty() {
1241 return;
1242 }
1243 output.push_str(" volumes:\n");
1244 for mount in mounts {
1245 match &mount.kind {
1246 GeneratedMountKind::Bind {
1247 source,
1248 selinux: Some(selinux),
1249 } => render_selinux_bind(output, source, mount, *selinux),
1250 kind => render_long_mount(output, kind, mount),
1251 }
1252 }
1253}
1254
1255fn render_selinux_bind(output: &mut String, source: &str, mount: &GeneratedMount, selinux: GeneratedSelinux) {
1256 let mut value = format!("{source}:{}:{}", mount.target, selinux.as_str());
1257 if mount.read_only {
1258 value.push_str(",ro");
1259 }
1260 output.push_str(" - ");
1261 write_quoted(output, &value);
1262 output.push('\n');
1263}
1264
1265fn render_long_mount(output: &mut String, kind: &GeneratedMountKind, mount: &GeneratedMount) {
1266 let (mount_type, source) = match kind {
1267 GeneratedMountKind::Volume { source } => ("volume", Some(source.as_str())),
1268 GeneratedMountKind::Bind { source, selinux: None } => ("bind", Some(source.as_str())),
1269 GeneratedMountKind::Anonymous => ("volume", None),
1270 GeneratedMountKind::Bind { selinux: Some(_), .. } => return,
1271 };
1272 output.push_str(" - type: ");
1273 write_quoted(output, mount_type);
1274 output.push('\n');
1275 if let Some(source) = source {
1276 output.push_str(" source: ");
1277 write_quoted(output, source);
1278 output.push('\n');
1279 }
1280 output.push_str(" target: ");
1281 write_quoted(output, &mount.target);
1282 output.push('\n');
1283 if mount.read_only {
1284 output.push_str(" read_only: true\n");
1285 }
1286}
1287
1288fn render_networks(output: &mut String, networks: &[GeneratedNetworkAttachment]) {
1289 if networks.is_empty() {
1290 return;
1291 }
1292 output.push_str(" networks:\n");
1293 for network in networks {
1294 output.push_str(" ");
1295 write_quoted(output, &network.name);
1296 if network.aliases.is_empty() {
1297 output.push_str(": {}\n");
1298 } else {
1299 output.push_str(":\n aliases:\n");
1300 for alias in &network.aliases {
1301 output.push_str(" - ");
1302 write_quoted(output, alias);
1303 output.push('\n');
1304 }
1305 }
1306 }
1307}
1308
1309fn render_resources(output: &mut String, section: &str, resources: &[GeneratedResource]) {
1310 if resources.is_empty() {
1311 return;
1312 }
1313 output.push_str(section);
1314 output.push_str(":\n");
1315 for resource in resources {
1316 output.push_str(" ");
1317 write_quoted(output, &resource.name);
1318 if !resource.external && resource.custom_name.is_none() {
1319 output.push_str(": {}\n");
1320 continue;
1321 }
1322 output.push_str(":\n");
1323 if let Some(custom_name) = &resource.custom_name {
1324 output.push_str(" name: ");
1325 write_quoted(output, custom_name);
1326 output.push('\n');
1327 }
1328 if resource.external {
1329 output.push_str(" external: true\n");
1330 }
1331 }
1332}
1333
1334fn write_field(output: &mut String, depth: usize, key: &str) {
1335 write_indent(output, depth);
1336 output.push_str(key);
1337 output.push_str(": ");
1338}
1339
1340fn write_indent(output: &mut String, depth: usize) {
1341 for _ in 0..depth {
1342 output.push_str(" ");
1343 }
1344}
1345
1346fn required(kind: &'static str, value: String) -> Result<String, GenerationError> {
1347 if value.is_empty() {
1348 return Err(GenerationError::EmptyValue(kind));
1349 }
1350 if value.contains('\0') {
1351 return Err(GenerationError::ContainsNul(kind));
1352 }
1353 Ok(value)
1354}
1355
1356fn require_generated_string(kind: &'static str, value: &GeneratedString) -> Result<(), GenerationError> {
1357 if value.expose().is_empty() {
1358 return Err(GenerationError::EmptyValue(kind));
1359 }
1360 Ok(())
1361}
1362
1363fn environment_name(value: String) -> Result<String, GenerationError> {
1364 let value = required("environment name", value)?;
1365 if value.contains('=') {
1366 return Err(GenerationError::InvalidEnvironmentName);
1367 }
1368 Ok(value)
1369}
1370
1371fn valid_container_name(value: &str) -> bool {
1372 let mut bytes = value.bytes();
1373 bytes.next().is_some_and(|byte| byte.is_ascii_alphanumeric())
1374 && bytes
1375 .next()
1376 .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
1377 && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
1378}
1379
1380fn short_component(kind: &'static str, value: String, separator: char) -> Result<String, GenerationError> {
1381 let value = required(kind, value)?;
1382 if value.contains(separator) {
1383 return Err(GenerationError::InvalidShortComponent(kind));
1384 }
1385 Ok(value)
1386}
1387
1388fn set_once<T>(slot: &mut Option<T>, value: T, field: &'static str) -> Result<(), GenerationError> {
1389 if slot.is_some() {
1390 return Err(GenerationError::DuplicateField(field));
1391 }
1392 *slot = Some(value);
1393 Ok(())
1394}
1395
1396fn insert_named<T>(
1397 values: &mut Vec<T>,
1398 value: T,
1399 kind: &'static str,
1400 name: impl Fn(&T) -> &str,
1401) -> Result<(), GenerationError> {
1402 let value_name = name(&value);
1403 if values.iter().any(|candidate| name(candidate) == value_name) {
1404 return Err(GenerationError::DuplicateName {
1405 kind,
1406 name: value_name.to_owned(),
1407 });
1408 }
1409 values.push(value);
1410 Ok(())
1411}
1412
1413fn command_is_sensitive(command: &GeneratedCommand) -> bool {
1414 match command {
1415 GeneratedCommand::Exec(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
1416 GeneratedCommand::Shell(command) => command.is_sensitive(),
1417 GeneratedCommand::Empty => false,
1418 }
1419}