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 InvalidShortComponent(&'static str),
21 InvalidSelinuxBind,
23 DuplicateField(&'static str),
25 DuplicateName {
27 kind: &'static str,
29 name: String,
31 },
32 InvalidPort,
34 UnrepresentableSctpHostIp,
36 MissingService,
38 InternalInvariant(&'static str),
40}
41
42impl fmt::Display for GenerationError {
43 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44 match self {
45 Self::EmptyValue(kind) => write!(formatter, "generated {kind} must not be empty"),
46 Self::ContainsNul(kind) => write!(formatter, "generated {kind} must not contain a NUL byte"),
47 Self::InvalidEnvironmentName => formatter.write_str("generated environment name must not contain `=`"),
48 Self::InvalidShortComponent(kind) => {
49 write!(formatter, "generated {kind} contains its reserved short-form separator")
50 }
51 Self::InvalidSelinuxBind => formatter
52 .write_str("generated SELinux bind source and target must not contain the short-syntax `:` separator"),
53 Self::DuplicateField(field) => write!(formatter, "generated field `{field}` was configured more than once"),
54 Self::DuplicateName { kind, name } => {
55 write!(formatter, "generated {kind} `{name}` was added more than once")
56 }
57 Self::InvalidPort => formatter.write_str("generated container target port must be greater than zero"),
58 Self::UnrepresentableSctpHostIp => formatter.write_str(
59 "generated SCTP port with a host address also requires a published port for Compose short syntax",
60 ),
61 Self::MissingService => formatter.write_str("generated Compose project requires at least one service"),
62 Self::InternalInvariant(stage) => write!(formatter, "generated Compose document failed {stage} validation"),
63 }
64 }
65}
66
67impl Error for GenerationError {}
68
69#[derive(Clone, Eq, PartialEq)]
71pub struct GeneratedString {
72 value: String,
73 sensitive: bool,
74}
75
76impl GeneratedString {
77 pub fn plain(value: impl Into<String>) -> Result<Self, GenerationError> {
83 Self::new(value.into(), false)
84 }
85
86 pub fn sensitive(value: impl Into<String>) -> Result<Self, GenerationError> {
92 Self::new(value.into(), true)
93 }
94
95 fn new(value: String, sensitive: bool) -> Result<Self, GenerationError> {
96 if value.contains('\0') {
97 return Err(GenerationError::ContainsNul("string"));
98 }
99 Ok(Self { value, sensitive })
100 }
101
102 #[must_use]
104 pub fn expose(&self) -> &str {
105 &self.value
106 }
107
108 #[must_use]
110 pub const fn is_sensitive(&self) -> bool {
111 self.sensitive
112 }
113}
114
115impl fmt::Debug for GeneratedString {
116 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117 formatter
118 .debug_struct("GeneratedString")
119 .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
120 .field("sensitive", &self.sensitive)
121 .finish()
122 }
123}
124
125#[derive(Clone, Debug, Eq, PartialEq)]
127#[non_exhaustive]
128pub enum GeneratedCommand {
129 Exec(Vec<GeneratedString>),
131 Shell(GeneratedString),
133 Empty,
135}
136
137#[derive(Clone, Debug, Eq, PartialEq)]
139pub struct GeneratedEnvironment {
140 name: String,
141 value: Option<GeneratedString>,
142}
143
144#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct GeneratedLabel {
147 name: String,
148 value: GeneratedString,
149}
150
151impl GeneratedLabel {
152 pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
159 Ok(Self {
160 name: required("label name", name.into())?,
161 value,
162 })
163 }
164
165 #[must_use]
167 pub fn name(&self) -> &str {
168 &self.name
169 }
170
171 #[must_use]
173 pub const fn value(&self) -> &GeneratedString {
174 &self.value
175 }
176}
177
178impl GeneratedEnvironment {
179 pub fn literal(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
185 Ok(Self {
186 name: environment_name(name.into())?,
187 value: Some(value),
188 })
189 }
190
191 pub fn host(name: impl Into<String>) -> Result<Self, GenerationError> {
197 Ok(Self {
198 name: environment_name(name.into())?,
199 value: None,
200 })
201 }
202
203 #[must_use]
205 pub fn name(&self) -> &str {
206 &self.name
207 }
208
209 #[must_use]
211 pub const fn value(&self) -> Option<&GeneratedString> {
212 self.value.as_ref()
213 }
214}
215
216#[derive(Clone, Debug, Eq, PartialEq)]
218pub struct GeneratedExtraHost {
219 hostname: String,
220 address: String,
221}
222
223impl GeneratedExtraHost {
224 pub fn new(hostname: impl Into<String>, address: impl Into<String>) -> Result<Self, GenerationError> {
230 let hostname = short_component("extra-host hostname", hostname.into(), '=')?;
231 let address = short_component("extra-host address", address.into(), '=')?;
232 Ok(Self { hostname, address })
233 }
234
235 #[must_use]
237 pub fn hostname(&self) -> &str {
238 &self.hostname
239 }
240
241 #[must_use]
243 pub fn address(&self) -> &str {
244 &self.address
245 }
246}
247
248#[derive(Clone, Copy, Debug, Eq, PartialEq)]
250#[non_exhaustive]
251pub enum GeneratedProtocol {
252 Tcp,
254 Udp,
256 Sctp,
258}
259
260impl GeneratedProtocol {
261 const fn as_str(self) -> &'static str {
262 match self {
263 Self::Tcp => "tcp",
264 Self::Udp => "udp",
265 Self::Sctp => "sctp",
266 }
267 }
268}
269
270#[derive(Clone, Debug, Eq, PartialEq)]
272pub struct GeneratedPort {
273 target: u16,
274 published: Option<u16>,
275 host_ip: Option<String>,
276 protocol: GeneratedProtocol,
277}
278
279impl GeneratedPort {
280 pub fn new(
288 target: u16,
289 published: Option<u16>,
290 host_ip: Option<String>,
291 protocol: GeneratedProtocol,
292 ) -> Result<Self, GenerationError> {
293 if target == 0 {
294 return Err(GenerationError::InvalidPort);
295 }
296 if let Some(host_ip) = host_ip.as_deref() {
297 required("port host address", host_ip.to_owned())?;
298 if protocol == GeneratedProtocol::Sctp && published.is_none() {
299 return Err(GenerationError::UnrepresentableSctpHostIp);
300 }
301 }
302 Ok(Self {
303 target,
304 published,
305 host_ip,
306 protocol,
307 })
308 }
309
310 #[must_use]
312 pub const fn target(&self) -> u16 {
313 self.target
314 }
315
316 #[must_use]
318 pub const fn published(&self) -> Option<u16> {
319 self.published
320 }
321
322 #[must_use]
324 pub fn host_ip(&self) -> Option<&str> {
325 self.host_ip.as_deref()
326 }
327
328 #[must_use]
330 pub const fn protocol(&self) -> GeneratedProtocol {
331 self.protocol
332 }
333}
334
335#[derive(Clone, Copy, Debug, Eq, PartialEq)]
337#[non_exhaustive]
338pub enum GeneratedSelinux {
339 Private,
341 Shared,
343}
344
345impl GeneratedSelinux {
346 const fn as_str(self) -> &'static str {
347 match self {
348 Self::Private => "Z",
349 Self::Shared => "z",
350 }
351 }
352}
353
354#[derive(Clone, Debug, Eq, PartialEq)]
355enum GeneratedMountKind {
356 Volume {
357 source: String,
358 },
359 Bind {
360 source: String,
361 selinux: Option<GeneratedSelinux>,
362 },
363 Anonymous,
364}
365
366#[derive(Clone, Debug, Eq, PartialEq)]
368pub struct GeneratedMount {
369 kind: GeneratedMountKind,
370 target: String,
371 read_only: bool,
372}
373
374impl GeneratedMount {
375 pub fn volume(
381 source: impl Into<String>,
382 target: impl Into<String>,
383 read_only: bool,
384 ) -> Result<Self, GenerationError> {
385 Ok(Self {
386 kind: GeneratedMountKind::Volume {
387 source: required("volume source", source.into())?,
388 },
389 target: required("mount target", target.into())?,
390 read_only,
391 })
392 }
393
394 pub fn bind(
401 source: impl Into<String>,
402 target: impl Into<String>,
403 read_only: bool,
404 selinux: Option<GeneratedSelinux>,
405 ) -> Result<Self, GenerationError> {
406 let source = required("bind source", source.into())?;
407 let target = required("mount target", target.into())?;
408 if selinux.is_some() && (source.contains(':') || target.contains(':')) {
409 return Err(GenerationError::InvalidSelinuxBind);
410 }
411 Ok(Self {
412 kind: GeneratedMountKind::Bind { source, selinux },
413 target,
414 read_only,
415 })
416 }
417
418 pub fn anonymous(target: impl Into<String>, read_only: bool) -> Result<Self, GenerationError> {
424 Ok(Self {
425 kind: GeneratedMountKind::Anonymous,
426 target: required("mount target", target.into())?,
427 read_only,
428 })
429 }
430
431 #[must_use]
433 pub fn target(&self) -> &str {
434 &self.target
435 }
436
437 #[must_use]
439 pub const fn read_only(&self) -> bool {
440 self.read_only
441 }
442}
443
444#[derive(Clone, Debug, Eq, PartialEq)]
446pub struct GeneratedNetworkAttachment {
447 name: String,
448 aliases: Vec<String>,
449}
450
451impl GeneratedNetworkAttachment {
452 pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
458 Ok(Self {
459 name: required("network name", name.into())?,
460 aliases: Vec::new(),
461 })
462 }
463
464 pub fn add_alias(&mut self, alias: impl Into<String>) -> Result<(), GenerationError> {
470 self.aliases.push(required("network alias", alias.into())?);
471 Ok(())
472 }
473
474 #[must_use]
476 pub fn name(&self) -> &str {
477 &self.name
478 }
479
480 #[must_use]
482 pub fn aliases(&self) -> &[String] {
483 &self.aliases
484 }
485}
486
487#[derive(Clone, Debug, Eq, PartialEq)]
489pub struct GeneratedResource {
490 name: String,
491 external: bool,
492 custom_name: Option<String>,
493}
494
495impl GeneratedResource {
496 pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
502 Ok(Self {
503 name: required("resource name", name.into())?,
504 external: false,
505 custom_name: None,
506 })
507 }
508
509 pub fn external(name: impl Into<String>) -> Result<Self, GenerationError> {
515 Ok(Self {
516 name: required("resource name", name.into())?,
517 external: true,
518 custom_name: None,
519 })
520 }
521
522 pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
530 let name = required("custom resource name", name.into())?;
531 set_once(&mut self.custom_name, name, "resource name")
532 }
533
534 #[must_use]
536 pub fn name(&self) -> &str {
537 &self.name
538 }
539
540 #[must_use]
542 pub const fn is_external(&self) -> bool {
543 self.external
544 }
545
546 #[must_use]
548 pub fn custom_name(&self) -> Option<&str> {
549 self.custom_name.as_deref()
550 }
551}
552
553#[derive(Clone, Debug, Eq, PartialEq)]
555pub struct GeneratedService {
556 name: String,
557 image: Option<GeneratedString>,
558 command: Option<GeneratedCommand>,
559 environment: Vec<GeneratedEnvironment>,
560 labels: Vec<GeneratedLabel>,
561 user: Option<GeneratedString>,
562 userns_mode: Option<GeneratedString>,
563 group_add: Vec<GeneratedString>,
564 working_dir: Option<GeneratedString>,
565 read_only: Option<bool>,
566 extra_hosts: Vec<GeneratedExtraHost>,
567 ports: Vec<GeneratedPort>,
568 mounts: Vec<GeneratedMount>,
569 networks: Vec<GeneratedNetworkAttachment>,
570}
571
572impl GeneratedService {
573 pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
579 Ok(Self {
580 name: required("service name", name.into())?,
581 image: None,
582 command: None,
583 environment: Vec::new(),
584 labels: Vec::new(),
585 user: None,
586 userns_mode: None,
587 group_add: Vec::new(),
588 working_dir: None,
589 read_only: None,
590 extra_hosts: Vec::new(),
591 ports: Vec::new(),
592 mounts: Vec::new(),
593 networks: Vec::new(),
594 })
595 }
596
597 #[must_use]
599 pub fn name(&self) -> &str {
600 &self.name
601 }
602
603 pub fn set_image(&mut self, image: GeneratedString) -> Result<(), GenerationError> {
610 require_generated_string("service image", &image)?;
611 set_once(&mut self.image, image, "image")
612 }
613
614 pub fn set_command(&mut self, command: GeneratedCommand) -> Result<(), GenerationError> {
620 set_once(&mut self.command, command, "command")
621 }
622
623 pub fn add_environment(&mut self, environment: GeneratedEnvironment) {
625 self.environment.push(environment);
626 }
627
628 pub fn add_label(&mut self, label: GeneratedLabel) -> Result<(), GenerationError> {
634 if self.labels.iter().any(|candidate| candidate.name == label.name) {
635 return Err(GenerationError::DuplicateName {
636 kind: "service label",
637 name: label.name,
638 });
639 }
640 self.labels.push(label);
641 Ok(())
642 }
643
644 pub fn set_user(&mut self, user: GeneratedString) -> Result<(), GenerationError> {
650 set_once(&mut self.user, user, "user")
651 }
652
653 pub fn set_userns_mode(&mut self, mode: GeneratedString) -> Result<(), GenerationError> {
660 require_generated_string("user namespace mode", &mode)?;
661 set_once(&mut self.userns_mode, mode, "userns_mode")
662 }
663
664 pub fn add_supplementary_group(&mut self, group: GeneratedString) -> Result<(), GenerationError> {
670 require_generated_string("supplementary group", &group)?;
671 self.group_add.push(group);
672 Ok(())
673 }
674
675 pub fn set_working_dir(&mut self, directory: GeneratedString) -> Result<(), GenerationError> {
682 require_generated_string("working directory", &directory)?;
683 set_once(&mut self.working_dir, directory, "working_dir")
684 }
685
686 pub fn set_read_only(&mut self, read_only: bool) -> Result<(), GenerationError> {
692 set_once(&mut self.read_only, read_only, "read_only")
693 }
694
695 pub fn add_extra_host(&mut self, host: GeneratedExtraHost) {
697 self.extra_hosts.push(host);
698 }
699
700 pub fn add_port(&mut self, port: GeneratedPort) {
702 self.ports.push(port);
703 }
704
705 pub fn add_mount(&mut self, mount: GeneratedMount) {
707 self.mounts.push(mount);
708 }
709
710 pub fn add_network(&mut self, network: GeneratedNetworkAttachment) -> Result<(), GenerationError> {
716 if self.networks.iter().any(|candidate| candidate.name == network.name) {
717 return Err(GenerationError::DuplicateName {
718 kind: "service network",
719 name: network.name,
720 });
721 }
722 self.networks.push(network);
723 Ok(())
724 }
725
726 fn is_sensitive(&self) -> bool {
727 self.image.as_ref().is_some_and(GeneratedString::is_sensitive)
728 || self.command.as_ref().is_some_and(command_is_sensitive)
729 || self
730 .environment
731 .iter()
732 .filter_map(GeneratedEnvironment::value)
733 .any(GeneratedString::is_sensitive)
734 || self.labels.iter().any(|label| label.value.is_sensitive())
735 || [self.user.as_ref(), self.userns_mode.as_ref(), self.working_dir.as_ref()]
736 .into_iter()
737 .flatten()
738 .any(GeneratedString::is_sensitive)
739 || self.group_add.iter().any(GeneratedString::is_sensitive)
740 }
741}
742
743#[derive(Clone, Debug, Default, Eq, PartialEq)]
745pub struct ComposeDocumentBuilder {
746 name: Option<String>,
747 services: Vec<GeneratedService>,
748 networks: Vec<GeneratedResource>,
749 volumes: Vec<GeneratedResource>,
750}
751
752impl ComposeDocumentBuilder {
753 #[must_use]
755 pub const fn new() -> Self {
756 Self {
757 name: None,
758 services: Vec::new(),
759 networks: Vec::new(),
760 volumes: Vec::new(),
761 }
762 }
763
764 pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
770 let name = required("project name", name.into())?;
771 set_once(&mut self.name, name, "name")
772 }
773
774 pub fn add_service(&mut self, service: GeneratedService) -> Result<(), GenerationError> {
780 insert_named(&mut self.services, service, "service", GeneratedService::name)
781 }
782
783 pub fn add_network(&mut self, network: GeneratedResource) -> Result<(), GenerationError> {
789 insert_named(&mut self.networks, network, "network", GeneratedResource::name)
790 }
791
792 pub fn add_volume(&mut self, volume: GeneratedResource) -> Result<(), GenerationError> {
798 insert_named(&mut self.volumes, volume, "volume", GeneratedResource::name)
799 }
800
801 pub fn build(self, source_id: SourceId) -> Result<GeneratedComposeDocument, GenerationError> {
808 if self.services.is_empty() {
809 return Err(GenerationError::MissingService);
810 }
811 let sensitive = self.services.iter().any(GeneratedService::is_sensitive);
812 let text = render_document(&self);
813 let syntax = SyntaxDocument::parse(source_id, text.clone())
814 .map_err(|_| GenerationError::InternalInvariant("syntax-tree"))?;
815 if !syntax.is_valid() {
816 return Err(GenerationError::InternalInvariant("syntax"));
817 }
818 let model = ComposeDocument::parse(syntax.document());
819 if !model.is_valid() {
820 return Err(GenerationError::InternalInvariant("typed-model"));
821 }
822 let document = model
823 .document()
824 .cloned()
825 .ok_or(GenerationError::InternalInvariant("document-root"))?;
826 Ok(GeneratedComposeDocument {
827 text,
828 sensitive,
829 document,
830 })
831 }
832}
833
834#[derive(Clone, Eq, PartialEq)]
836pub struct GeneratedComposeDocument {
837 text: String,
838 sensitive: bool,
839 document: ComposeDocument,
840}
841
842impl GeneratedComposeDocument {
843 #[must_use]
845 pub fn text(&self) -> &str {
846 &self.text
847 }
848
849 #[must_use]
851 pub const fn document(&self) -> &ComposeDocument {
852 &self.document
853 }
854
855 #[must_use]
857 pub const fn is_sensitive(&self) -> bool {
858 self.sensitive
859 }
860}
861
862impl fmt::Debug for GeneratedComposeDocument {
863 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
864 formatter
865 .debug_struct("GeneratedComposeDocument")
866 .field("text", &if self.sensitive { "<redacted>" } else { &self.text })
867 .field("sensitive", &self.sensitive)
868 .field("document", &if self.sensitive { "<redacted>" } else { "validated" })
869 .finish()
870 }
871}
872
873fn render_document(project: &ComposeDocumentBuilder) -> String {
874 let mut output = String::new();
875 if let Some(name) = &project.name {
876 output.push_str("name: ");
877 write_quoted(&mut output, name);
878 output.push('\n');
879 }
880 output.push_str("services:\n");
881 for service in &project.services {
882 write_indent(&mut output, 1);
883 write_quoted(&mut output, &service.name);
884 output.push_str(":\n");
885 render_service(&mut output, service);
886 }
887 render_resources(&mut output, "networks", &project.networks);
888 render_resources(&mut output, "volumes", &project.volumes);
889 output
890}
891
892fn render_service(output: &mut String, service: &GeneratedService) {
893 render_optional_string(output, "image", service.image.as_ref());
894 if let Some(command) = &service.command {
895 render_command(output, command);
896 }
897 render_environment(output, &service.environment);
898 render_labels(output, &service.labels);
899 render_optional_string(output, "user", service.user.as_ref());
900 render_optional_string(output, "userns_mode", service.userns_mode.as_ref());
901 render_string_sequence(output, "group_add", &service.group_add);
902 render_optional_string(output, "working_dir", service.working_dir.as_ref());
903 if let Some(read_only) = service.read_only {
904 write_field(output, 2, "read_only");
905 output.push_str(if read_only { "true\n" } else { "false\n" });
906 }
907 render_extra_hosts(output, &service.extra_hosts);
908 render_ports(output, &service.ports);
909 render_mounts(output, &service.mounts);
910 render_networks(output, &service.networks);
911}
912
913fn render_optional_string(output: &mut String, key: &str, value: Option<&GeneratedString>) {
914 if let Some(value) = value {
915 write_field(output, 2, key);
916 write_quoted(output, value.expose());
917 output.push('\n');
918 }
919}
920
921fn render_command(output: &mut String, command: &GeneratedCommand) {
922 match command {
923 GeneratedCommand::Exec(arguments) if arguments.is_empty() => output.push_str(" command: []\n"),
924 GeneratedCommand::Exec(arguments) => render_string_sequence(output, "command", arguments),
925 GeneratedCommand::Shell(command) => render_optional_string(output, "command", Some(command)),
926 GeneratedCommand::Empty => output.push_str(" command: []\n"),
927 }
928}
929
930fn render_environment(output: &mut String, environment: &[GeneratedEnvironment]) {
931 if environment.is_empty() {
932 return;
933 }
934 output.push_str(" environment:\n");
935 for variable in environment {
936 output.push_str(" - ");
937 let value = variable.value.as_ref().map_or_else(
938 || variable.name.clone(),
939 |value| format!("{}={}", variable.name, value.expose()),
940 );
941 write_quoted(output, &value);
942 output.push('\n');
943 }
944}
945
946fn render_labels(output: &mut String, labels: &[GeneratedLabel]) {
947 if labels.is_empty() {
948 return;
949 }
950 output.push_str(" labels:\n");
951 for label in labels {
952 output.push_str(" ");
953 write_quoted(output, &label.name);
954 output.push_str(": ");
955 write_quoted(output, label.value.expose());
956 output.push('\n');
957 }
958}
959
960fn render_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
961 if values.is_empty() {
962 return;
963 }
964 write_indent(output, 2);
965 output.push_str(key);
966 output.push_str(":\n");
967 for value in values {
968 output.push_str(" - ");
969 write_quoted(output, value.expose());
970 output.push('\n');
971 }
972}
973
974fn render_extra_hosts(output: &mut String, hosts: &[GeneratedExtraHost]) {
975 if hosts.is_empty() {
976 return;
977 }
978 output.push_str(" extra_hosts:\n");
979 for host in hosts {
980 output.push_str(" - ");
981 write_quoted(output, &format!("{}={}", host.hostname, host.address));
982 output.push('\n');
983 }
984}
985
986fn render_ports(output: &mut String, ports: &[GeneratedPort]) {
987 if ports.is_empty() {
988 return;
989 }
990 output.push_str(" ports:\n");
991 for port in ports {
992 if port.protocol == GeneratedProtocol::Sctp {
993 render_short_sctp_port(output, port);
994 continue;
995 }
996 output.push_str(" - target: ");
997 output.push_str(&port.target.to_string());
998 output.push('\n');
999 if let Some(published) = port.published {
1000 output.push_str(" published: ");
1001 write_quoted(output, &published.to_string());
1002 output.push('\n');
1003 }
1004 if let Some(host_ip) = &port.host_ip {
1005 output.push_str(" host_ip: ");
1006 write_quoted(output, host_ip);
1007 output.push('\n');
1008 }
1009 output.push_str(" protocol: ");
1010 write_quoted(output, port.protocol.as_str());
1011 output.push('\n');
1012 }
1013}
1014
1015fn render_short_sctp_port(output: &mut String, port: &GeneratedPort) {
1016 let mut value = String::new();
1017 if let Some(host_ip) = &port.host_ip {
1018 if host_ip.contains(':') && !(host_ip.starts_with('[') && host_ip.ends_with(']')) {
1019 value.push('[');
1020 value.push_str(host_ip);
1021 value.push(']');
1022 } else {
1023 value.push_str(host_ip);
1024 }
1025 value.push(':');
1026 }
1027 if let Some(published) = port.published {
1028 value.push_str(&published.to_string());
1029 value.push(':');
1030 }
1031 value.push_str(&port.target.to_string());
1032 value.push_str("/sctp");
1033
1034 output.push_str(" - ");
1035 write_quoted(output, &value);
1036 output.push('\n');
1037}
1038
1039fn render_mounts(output: &mut String, mounts: &[GeneratedMount]) {
1040 if mounts.is_empty() {
1041 return;
1042 }
1043 output.push_str(" volumes:\n");
1044 for mount in mounts {
1045 match &mount.kind {
1046 GeneratedMountKind::Bind {
1047 source,
1048 selinux: Some(selinux),
1049 } => render_selinux_bind(output, source, mount, *selinux),
1050 kind => render_long_mount(output, kind, mount),
1051 }
1052 }
1053}
1054
1055fn render_selinux_bind(output: &mut String, source: &str, mount: &GeneratedMount, selinux: GeneratedSelinux) {
1056 let mut value = format!("{source}:{}:{}", mount.target, selinux.as_str());
1057 if mount.read_only {
1058 value.push_str(",ro");
1059 }
1060 output.push_str(" - ");
1061 write_quoted(output, &value);
1062 output.push('\n');
1063}
1064
1065fn render_long_mount(output: &mut String, kind: &GeneratedMountKind, mount: &GeneratedMount) {
1066 let (mount_type, source) = match kind {
1067 GeneratedMountKind::Volume { source } => ("volume", Some(source.as_str())),
1068 GeneratedMountKind::Bind { source, selinux: None } => ("bind", Some(source.as_str())),
1069 GeneratedMountKind::Anonymous => ("volume", None),
1070 GeneratedMountKind::Bind { selinux: Some(_), .. } => return,
1071 };
1072 output.push_str(" - type: ");
1073 write_quoted(output, mount_type);
1074 output.push('\n');
1075 if let Some(source) = source {
1076 output.push_str(" source: ");
1077 write_quoted(output, source);
1078 output.push('\n');
1079 }
1080 output.push_str(" target: ");
1081 write_quoted(output, &mount.target);
1082 output.push('\n');
1083 if mount.read_only {
1084 output.push_str(" read_only: true\n");
1085 }
1086}
1087
1088fn render_networks(output: &mut String, networks: &[GeneratedNetworkAttachment]) {
1089 if networks.is_empty() {
1090 return;
1091 }
1092 output.push_str(" networks:\n");
1093 for network in networks {
1094 output.push_str(" ");
1095 write_quoted(output, &network.name);
1096 if network.aliases.is_empty() {
1097 output.push_str(": {}\n");
1098 } else {
1099 output.push_str(":\n aliases:\n");
1100 for alias in &network.aliases {
1101 output.push_str(" - ");
1102 write_quoted(output, alias);
1103 output.push('\n');
1104 }
1105 }
1106 }
1107}
1108
1109fn render_resources(output: &mut String, section: &str, resources: &[GeneratedResource]) {
1110 if resources.is_empty() {
1111 return;
1112 }
1113 output.push_str(section);
1114 output.push_str(":\n");
1115 for resource in resources {
1116 output.push_str(" ");
1117 write_quoted(output, &resource.name);
1118 if !resource.external && resource.custom_name.is_none() {
1119 output.push_str(": {}\n");
1120 continue;
1121 }
1122 output.push_str(":\n");
1123 if let Some(custom_name) = &resource.custom_name {
1124 output.push_str(" name: ");
1125 write_quoted(output, custom_name);
1126 output.push('\n');
1127 }
1128 if resource.external {
1129 output.push_str(" external: true\n");
1130 }
1131 }
1132}
1133
1134fn write_field(output: &mut String, depth: usize, key: &str) {
1135 write_indent(output, depth);
1136 output.push_str(key);
1137 output.push_str(": ");
1138}
1139
1140fn write_indent(output: &mut String, depth: usize) {
1141 for _ in 0..depth {
1142 output.push_str(" ");
1143 }
1144}
1145
1146fn required(kind: &'static str, value: String) -> Result<String, GenerationError> {
1147 if value.is_empty() {
1148 return Err(GenerationError::EmptyValue(kind));
1149 }
1150 if value.contains('\0') {
1151 return Err(GenerationError::ContainsNul(kind));
1152 }
1153 Ok(value)
1154}
1155
1156fn require_generated_string(kind: &'static str, value: &GeneratedString) -> Result<(), GenerationError> {
1157 if value.expose().is_empty() {
1158 return Err(GenerationError::EmptyValue(kind));
1159 }
1160 Ok(())
1161}
1162
1163fn environment_name(value: String) -> Result<String, GenerationError> {
1164 let value = required("environment name", value)?;
1165 if value.contains('=') {
1166 return Err(GenerationError::InvalidEnvironmentName);
1167 }
1168 Ok(value)
1169}
1170
1171fn short_component(kind: &'static str, value: String, separator: char) -> Result<String, GenerationError> {
1172 let value = required(kind, value)?;
1173 if value.contains(separator) {
1174 return Err(GenerationError::InvalidShortComponent(kind));
1175 }
1176 Ok(value)
1177}
1178
1179fn set_once<T>(slot: &mut Option<T>, value: T, field: &'static str) -> Result<(), GenerationError> {
1180 if slot.is_some() {
1181 return Err(GenerationError::DuplicateField(field));
1182 }
1183 *slot = Some(value);
1184 Ok(())
1185}
1186
1187fn insert_named<T>(
1188 values: &mut Vec<T>,
1189 value: T,
1190 kind: &'static str,
1191 name: impl Fn(&T) -> &str,
1192) -> Result<(), GenerationError> {
1193 let value_name = name(&value);
1194 if values.iter().any(|candidate| name(candidate) == value_name) {
1195 return Err(GenerationError::DuplicateName {
1196 kind,
1197 name: value_name.to_owned(),
1198 });
1199 }
1200 values.push(value);
1201 Ok(())
1202}
1203
1204fn command_is_sensitive(command: &GeneratedCommand) -> bool {
1205 match command {
1206 GeneratedCommand::Exec(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
1207 GeneratedCommand::Shell(command) => command.is_sensitive(),
1208 GeneratedCommand::Empty => false,
1209 }
1210}