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, Debug, Eq, PartialEq)]
168pub struct GeneratedLabel {
169 name: String,
170 value: GeneratedString,
171}
172
173impl GeneratedLabel {
174 pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
181 Ok(Self {
182 name: required("label name", name.into())?,
183 value,
184 })
185 }
186
187 #[must_use]
189 pub fn name(&self) -> &str {
190 &self.name
191 }
192
193 #[must_use]
195 pub const fn value(&self) -> &GeneratedString {
196 &self.value
197 }
198}
199
200impl GeneratedEnvironment {
201 pub fn literal(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
207 Ok(Self {
208 name: environment_name(name.into())?,
209 value: Some(value),
210 })
211 }
212
213 pub fn host(name: impl Into<String>) -> Result<Self, GenerationError> {
219 Ok(Self {
220 name: environment_name(name.into())?,
221 value: None,
222 })
223 }
224
225 #[must_use]
227 pub fn name(&self) -> &str {
228 &self.name
229 }
230
231 #[must_use]
233 pub const fn value(&self) -> Option<&GeneratedString> {
234 self.value.as_ref()
235 }
236}
237
238#[derive(Clone, Debug, Eq, PartialEq)]
240pub struct GeneratedExtraHost {
241 hostname: String,
242 address: String,
243}
244
245impl GeneratedExtraHost {
246 pub fn new(hostname: impl Into<String>, address: impl Into<String>) -> Result<Self, GenerationError> {
252 let hostname = short_component("extra-host hostname", hostname.into(), '=')?;
253 let address = short_component("extra-host address", address.into(), '=')?;
254 Ok(Self { hostname, address })
255 }
256
257 #[must_use]
259 pub fn hostname(&self) -> &str {
260 &self.hostname
261 }
262
263 #[must_use]
265 pub fn address(&self) -> &str {
266 &self.address
267 }
268}
269
270#[derive(Clone, Copy, Debug, Eq, PartialEq)]
272#[non_exhaustive]
273pub enum GeneratedProtocol {
274 Tcp,
276 Udp,
278 Sctp,
280}
281
282impl GeneratedProtocol {
283 const fn as_str(self) -> &'static str {
284 match self {
285 Self::Tcp => "tcp",
286 Self::Udp => "udp",
287 Self::Sctp => "sctp",
288 }
289 }
290}
291
292#[derive(Clone, Debug, Eq, PartialEq)]
294pub struct GeneratedPort {
295 target: u16,
296 published: Option<u16>,
297 host_ip: Option<String>,
298 protocol: GeneratedProtocol,
299}
300
301impl GeneratedPort {
302 pub fn new(
310 target: u16,
311 published: Option<u16>,
312 host_ip: Option<String>,
313 protocol: GeneratedProtocol,
314 ) -> Result<Self, GenerationError> {
315 if target == 0 {
316 return Err(GenerationError::InvalidPort);
317 }
318 if let Some(host_ip) = host_ip.as_deref() {
319 required("port host address", host_ip.to_owned())?;
320 if protocol == GeneratedProtocol::Sctp && published.is_none() {
321 return Err(GenerationError::UnrepresentableSctpHostIp);
322 }
323 }
324 Ok(Self {
325 target,
326 published,
327 host_ip,
328 protocol,
329 })
330 }
331
332 #[must_use]
334 pub const fn target(&self) -> u16 {
335 self.target
336 }
337
338 #[must_use]
340 pub const fn published(&self) -> Option<u16> {
341 self.published
342 }
343
344 #[must_use]
346 pub fn host_ip(&self) -> Option<&str> {
347 self.host_ip.as_deref()
348 }
349
350 #[must_use]
352 pub const fn protocol(&self) -> GeneratedProtocol {
353 self.protocol
354 }
355}
356
357#[derive(Clone, Copy, Debug, Eq, PartialEq)]
359#[non_exhaustive]
360pub enum GeneratedSelinux {
361 Private,
363 Shared,
365}
366
367impl GeneratedSelinux {
368 const fn as_str(self) -> &'static str {
369 match self {
370 Self::Private => "Z",
371 Self::Shared => "z",
372 }
373 }
374}
375
376#[derive(Clone, Debug, Eq, PartialEq)]
377enum GeneratedMountKind {
378 Volume {
379 source: String,
380 },
381 Bind {
382 source: String,
383 selinux: Option<GeneratedSelinux>,
384 },
385 Anonymous,
386}
387
388#[derive(Clone, Debug, Eq, PartialEq)]
390pub struct GeneratedMount {
391 kind: GeneratedMountKind,
392 target: String,
393 read_only: bool,
394}
395
396impl GeneratedMount {
397 pub fn volume(
403 source: impl Into<String>,
404 target: impl Into<String>,
405 read_only: bool,
406 ) -> Result<Self, GenerationError> {
407 Ok(Self {
408 kind: GeneratedMountKind::Volume {
409 source: required("volume source", source.into())?,
410 },
411 target: required("mount target", target.into())?,
412 read_only,
413 })
414 }
415
416 pub fn bind(
423 source: impl Into<String>,
424 target: impl Into<String>,
425 read_only: bool,
426 selinux: Option<GeneratedSelinux>,
427 ) -> Result<Self, GenerationError> {
428 let source = required("bind source", source.into())?;
429 let target = required("mount target", target.into())?;
430 if selinux.is_some() && (source.contains(':') || target.contains(':')) {
431 return Err(GenerationError::InvalidSelinuxBind);
432 }
433 Ok(Self {
434 kind: GeneratedMountKind::Bind { source, selinux },
435 target,
436 read_only,
437 })
438 }
439
440 pub fn anonymous(target: impl Into<String>, read_only: bool) -> Result<Self, GenerationError> {
446 Ok(Self {
447 kind: GeneratedMountKind::Anonymous,
448 target: required("mount target", target.into())?,
449 read_only,
450 })
451 }
452
453 #[must_use]
455 pub fn target(&self) -> &str {
456 &self.target
457 }
458
459 #[must_use]
461 pub const fn read_only(&self) -> bool {
462 self.read_only
463 }
464}
465
466#[derive(Clone, Debug, Eq, PartialEq)]
468pub struct GeneratedNetworkAttachment {
469 name: String,
470 aliases: Vec<String>,
471}
472
473impl GeneratedNetworkAttachment {
474 pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
480 Ok(Self {
481 name: required("network name", name.into())?,
482 aliases: Vec::new(),
483 })
484 }
485
486 pub fn add_alias(&mut self, alias: impl Into<String>) -> Result<(), GenerationError> {
492 self.aliases.push(required("network alias", alias.into())?);
493 Ok(())
494 }
495
496 #[must_use]
498 pub fn name(&self) -> &str {
499 &self.name
500 }
501
502 #[must_use]
504 pub fn aliases(&self) -> &[String] {
505 &self.aliases
506 }
507}
508
509#[derive(Clone, Debug, Eq, PartialEq)]
511pub struct GeneratedResource {
512 name: String,
513 external: bool,
514 custom_name: Option<String>,
515}
516
517impl GeneratedResource {
518 pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
524 Ok(Self {
525 name: required("resource name", name.into())?,
526 external: false,
527 custom_name: None,
528 })
529 }
530
531 pub fn external(name: impl Into<String>) -> Result<Self, GenerationError> {
537 Ok(Self {
538 name: required("resource name", name.into())?,
539 external: true,
540 custom_name: None,
541 })
542 }
543
544 pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
552 let name = required("custom resource name", name.into())?;
553 set_once(&mut self.custom_name, name, "resource name")
554 }
555
556 #[must_use]
558 pub fn name(&self) -> &str {
559 &self.name
560 }
561
562 #[must_use]
564 pub const fn is_external(&self) -> bool {
565 self.external
566 }
567
568 #[must_use]
570 pub fn custom_name(&self) -> Option<&str> {
571 self.custom_name.as_deref()
572 }
573}
574
575#[derive(Clone, Debug, Eq, PartialEq)]
577pub struct GeneratedService {
578 name: String,
579 container_name: Option<GeneratedString>,
580 image: Option<GeneratedString>,
581 command: Option<GeneratedCommand>,
582 environment: Vec<GeneratedEnvironment>,
583 labels: Vec<GeneratedLabel>,
584 user: Option<GeneratedString>,
585 userns_mode: Option<GeneratedString>,
586 group_add: Vec<GeneratedString>,
587 working_dir: Option<GeneratedString>,
588 read_only: Option<bool>,
589 restart: Option<GeneratedRestartPolicy>,
590 extra_hosts: Vec<GeneratedExtraHost>,
591 ports: Vec<GeneratedPort>,
592 mounts: Vec<GeneratedMount>,
593 networks: Vec<GeneratedNetworkAttachment>,
594}
595
596impl GeneratedService {
597 pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
603 Ok(Self {
604 name: required("service name", name.into())?,
605 container_name: None,
606 image: None,
607 command: None,
608 environment: Vec::new(),
609 labels: Vec::new(),
610 user: None,
611 userns_mode: None,
612 group_add: Vec::new(),
613 working_dir: None,
614 read_only: None,
615 restart: None,
616 extra_hosts: Vec::new(),
617 ports: Vec::new(),
618 mounts: Vec::new(),
619 networks: Vec::new(),
620 })
621 }
622
623 #[must_use]
625 pub fn name(&self) -> &str {
626 &self.name
627 }
628
629 pub fn set_container_name(&mut self, name: GeneratedString) -> Result<(), GenerationError> {
637 if !valid_container_name(name.expose()) {
638 return Err(GenerationError::InvalidContainerName);
639 }
640 set_once(&mut self.container_name, name, "container_name")
641 }
642
643 pub fn set_image(&mut self, image: GeneratedString) -> Result<(), GenerationError> {
650 require_generated_string("service image", &image)?;
651 set_once(&mut self.image, image, "image")
652 }
653
654 pub fn set_command(&mut self, command: GeneratedCommand) -> Result<(), GenerationError> {
660 set_once(&mut self.command, command, "command")
661 }
662
663 pub fn add_environment(&mut self, environment: GeneratedEnvironment) {
665 self.environment.push(environment);
666 }
667
668 pub fn add_label(&mut self, label: GeneratedLabel) -> Result<(), GenerationError> {
674 if self.labels.iter().any(|candidate| candidate.name == label.name) {
675 return Err(GenerationError::DuplicateName {
676 kind: "service label",
677 name: label.name,
678 });
679 }
680 self.labels.push(label);
681 Ok(())
682 }
683
684 pub fn set_user(&mut self, user: GeneratedString) -> Result<(), GenerationError> {
690 set_once(&mut self.user, user, "user")
691 }
692
693 pub fn set_userns_mode(&mut self, mode: GeneratedString) -> Result<(), GenerationError> {
700 require_generated_string("user namespace mode", &mode)?;
701 set_once(&mut self.userns_mode, mode, "userns_mode")
702 }
703
704 pub fn add_supplementary_group(&mut self, group: GeneratedString) -> Result<(), GenerationError> {
710 require_generated_string("supplementary group", &group)?;
711 self.group_add.push(group);
712 Ok(())
713 }
714
715 pub fn set_working_dir(&mut self, directory: GeneratedString) -> Result<(), GenerationError> {
722 require_generated_string("working directory", &directory)?;
723 set_once(&mut self.working_dir, directory, "working_dir")
724 }
725
726 pub fn set_read_only(&mut self, read_only: bool) -> Result<(), GenerationError> {
732 set_once(&mut self.read_only, read_only, "read_only")
733 }
734
735 pub fn set_restart(&mut self, restart: GeneratedRestartPolicy) -> Result<(), GenerationError> {
741 set_once(&mut self.restart, restart, "restart")
742 }
743
744 pub fn add_extra_host(&mut self, host: GeneratedExtraHost) {
746 self.extra_hosts.push(host);
747 }
748
749 pub fn add_port(&mut self, port: GeneratedPort) {
751 self.ports.push(port);
752 }
753
754 pub fn add_mount(&mut self, mount: GeneratedMount) {
756 self.mounts.push(mount);
757 }
758
759 pub fn add_network(&mut self, network: GeneratedNetworkAttachment) -> Result<(), GenerationError> {
765 if self.networks.iter().any(|candidate| candidate.name == network.name) {
766 return Err(GenerationError::DuplicateName {
767 kind: "service network",
768 name: network.name,
769 });
770 }
771 self.networks.push(network);
772 Ok(())
773 }
774
775 fn is_sensitive(&self) -> bool {
776 self.image.as_ref().is_some_and(GeneratedString::is_sensitive)
777 || self.command.as_ref().is_some_and(command_is_sensitive)
778 || self
779 .environment
780 .iter()
781 .filter_map(GeneratedEnvironment::value)
782 .any(GeneratedString::is_sensitive)
783 || self.labels.iter().any(|label| label.value.is_sensitive())
784 || [self.user.as_ref(), self.userns_mode.as_ref(), self.working_dir.as_ref()]
785 .into_iter()
786 .flatten()
787 .any(GeneratedString::is_sensitive)
788 || self.group_add.iter().any(GeneratedString::is_sensitive)
789 }
790}
791
792#[derive(Clone, Debug, Default, Eq, PartialEq)]
794pub struct ComposeDocumentBuilder {
795 name: Option<String>,
796 services: Vec<GeneratedService>,
797 networks: Vec<GeneratedResource>,
798 volumes: Vec<GeneratedResource>,
799}
800
801impl ComposeDocumentBuilder {
802 #[must_use]
804 pub const fn new() -> Self {
805 Self {
806 name: None,
807 services: Vec::new(),
808 networks: Vec::new(),
809 volumes: Vec::new(),
810 }
811 }
812
813 pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
819 let name = required("project name", name.into())?;
820 set_once(&mut self.name, name, "name")
821 }
822
823 pub fn add_service(&mut self, service: GeneratedService) -> Result<(), GenerationError> {
829 insert_named(&mut self.services, service, "service", GeneratedService::name)
830 }
831
832 pub fn add_network(&mut self, network: GeneratedResource) -> Result<(), GenerationError> {
838 insert_named(&mut self.networks, network, "network", GeneratedResource::name)
839 }
840
841 pub fn add_volume(&mut self, volume: GeneratedResource) -> Result<(), GenerationError> {
847 insert_named(&mut self.volumes, volume, "volume", GeneratedResource::name)
848 }
849
850 pub fn build(self, source_id: SourceId) -> Result<GeneratedComposeDocument, GenerationError> {
857 if self.services.is_empty() {
858 return Err(GenerationError::MissingService);
859 }
860 let sensitive = self.services.iter().any(GeneratedService::is_sensitive);
861 let text = render_document(&self);
862 let syntax = SyntaxDocument::parse(source_id, text.clone())
863 .map_err(|_| GenerationError::InternalInvariant("syntax-tree"))?;
864 if !syntax.is_valid() {
865 return Err(GenerationError::InternalInvariant("syntax"));
866 }
867 let model = ComposeDocument::parse(syntax.document());
868 if !model.is_valid() {
869 return Err(GenerationError::InternalInvariant("typed-model"));
870 }
871 let document = model
872 .document()
873 .cloned()
874 .ok_or(GenerationError::InternalInvariant("document-root"))?;
875 Ok(GeneratedComposeDocument {
876 text,
877 sensitive,
878 document,
879 })
880 }
881}
882
883#[derive(Clone, Eq, PartialEq)]
885pub struct GeneratedComposeDocument {
886 text: String,
887 sensitive: bool,
888 document: ComposeDocument,
889}
890
891impl GeneratedComposeDocument {
892 #[must_use]
894 pub fn text(&self) -> &str {
895 &self.text
896 }
897
898 #[must_use]
900 pub const fn document(&self) -> &ComposeDocument {
901 &self.document
902 }
903
904 #[must_use]
906 pub const fn is_sensitive(&self) -> bool {
907 self.sensitive
908 }
909}
910
911impl fmt::Debug for GeneratedComposeDocument {
912 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
913 formatter
914 .debug_struct("GeneratedComposeDocument")
915 .field("text", &if self.sensitive { "<redacted>" } else { &self.text })
916 .field("sensitive", &self.sensitive)
917 .field("document", &if self.sensitive { "<redacted>" } else { "validated" })
918 .finish()
919 }
920}
921
922fn render_document(project: &ComposeDocumentBuilder) -> String {
923 let mut output = String::new();
924 if let Some(name) = &project.name {
925 output.push_str("name: ");
926 write_quoted(&mut output, name);
927 output.push('\n');
928 }
929 output.push_str("services:\n");
930 for service in &project.services {
931 write_indent(&mut output, 1);
932 write_quoted(&mut output, &service.name);
933 output.push_str(":\n");
934 render_service(&mut output, service);
935 }
936 render_resources(&mut output, "networks", &project.networks);
937 render_resources(&mut output, "volumes", &project.volumes);
938 output
939}
940
941fn render_service(output: &mut String, service: &GeneratedService) {
942 render_optional_string(output, "container_name", service.container_name.as_ref());
943 render_optional_string(output, "image", service.image.as_ref());
944 if let Some(command) = &service.command {
945 render_command(output, command);
946 }
947 render_environment(output, &service.environment);
948 render_labels(output, &service.labels);
949 render_optional_string(output, "user", service.user.as_ref());
950 render_optional_string(output, "userns_mode", service.userns_mode.as_ref());
951 render_string_sequence(output, "group_add", &service.group_add);
952 render_optional_string(output, "working_dir", service.working_dir.as_ref());
953 if let Some(read_only) = service.read_only {
954 write_field(output, 2, "read_only");
955 output.push_str(if read_only { "true\n" } else { "false\n" });
956 }
957 if let Some(restart) = service.restart {
958 render_restart(output, restart);
959 }
960 render_extra_hosts(output, &service.extra_hosts);
961 render_ports(output, &service.ports);
962 render_mounts(output, &service.mounts);
963 render_networks(output, &service.networks);
964}
965
966fn render_restart(output: &mut String, restart: GeneratedRestartPolicy) {
967 write_field(output, 2, "restart");
968 let value = match restart {
969 GeneratedRestartPolicy::No => "no".to_owned(),
970 GeneratedRestartPolicy::Always => "always".to_owned(),
971 GeneratedRestartPolicy::OnFailure { maximum_retries: None } => "on-failure".to_owned(),
972 GeneratedRestartPolicy::OnFailure {
973 maximum_retries: Some(maximum_retries),
974 } => format!("on-failure:{maximum_retries}"),
975 GeneratedRestartPolicy::UnlessStopped => "unless-stopped".to_owned(),
976 };
977 write_quoted(output, &value);
978 output.push('\n');
979}
980
981fn render_optional_string(output: &mut String, key: &str, value: Option<&GeneratedString>) {
982 if let Some(value) = value {
983 write_field(output, 2, key);
984 write_quoted(output, value.expose());
985 output.push('\n');
986 }
987}
988
989fn render_command(output: &mut String, command: &GeneratedCommand) {
990 match command {
991 GeneratedCommand::Exec(arguments) if arguments.is_empty() => output.push_str(" command: []\n"),
992 GeneratedCommand::Exec(arguments) => render_string_sequence(output, "command", arguments),
993 GeneratedCommand::Shell(command) => render_optional_string(output, "command", Some(command)),
994 GeneratedCommand::Empty => output.push_str(" command: []\n"),
995 }
996}
997
998fn render_environment(output: &mut String, environment: &[GeneratedEnvironment]) {
999 if environment.is_empty() {
1000 return;
1001 }
1002 output.push_str(" environment:\n");
1003 for variable in environment {
1004 output.push_str(" - ");
1005 let value = variable.value.as_ref().map_or_else(
1006 || variable.name.clone(),
1007 |value| format!("{}={}", variable.name, value.expose()),
1008 );
1009 write_quoted(output, &value);
1010 output.push('\n');
1011 }
1012}
1013
1014fn render_labels(output: &mut String, labels: &[GeneratedLabel]) {
1015 if labels.is_empty() {
1016 return;
1017 }
1018 output.push_str(" labels:\n");
1019 for label in labels {
1020 output.push_str(" ");
1021 write_quoted(output, &label.name);
1022 output.push_str(": ");
1023 write_quoted(output, label.value.expose());
1024 output.push('\n');
1025 }
1026}
1027
1028fn render_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
1029 if values.is_empty() {
1030 return;
1031 }
1032 write_indent(output, 2);
1033 output.push_str(key);
1034 output.push_str(":\n");
1035 for value in values {
1036 output.push_str(" - ");
1037 write_quoted(output, value.expose());
1038 output.push('\n');
1039 }
1040}
1041
1042fn render_extra_hosts(output: &mut String, hosts: &[GeneratedExtraHost]) {
1043 if hosts.is_empty() {
1044 return;
1045 }
1046 output.push_str(" extra_hosts:\n");
1047 for host in hosts {
1048 output.push_str(" - ");
1049 write_quoted(output, &format!("{}={}", host.hostname, host.address));
1050 output.push('\n');
1051 }
1052}
1053
1054fn render_ports(output: &mut String, ports: &[GeneratedPort]) {
1055 if ports.is_empty() {
1056 return;
1057 }
1058 output.push_str(" ports:\n");
1059 for port in ports {
1060 if port.protocol == GeneratedProtocol::Sctp {
1061 render_short_sctp_port(output, port);
1062 continue;
1063 }
1064 output.push_str(" - target: ");
1065 output.push_str(&port.target.to_string());
1066 output.push('\n');
1067 if let Some(published) = port.published {
1068 output.push_str(" published: ");
1069 write_quoted(output, &published.to_string());
1070 output.push('\n');
1071 }
1072 if let Some(host_ip) = &port.host_ip {
1073 output.push_str(" host_ip: ");
1074 write_quoted(output, host_ip);
1075 output.push('\n');
1076 }
1077 output.push_str(" protocol: ");
1078 write_quoted(output, port.protocol.as_str());
1079 output.push('\n');
1080 }
1081}
1082
1083fn render_short_sctp_port(output: &mut String, port: &GeneratedPort) {
1084 let mut value = String::new();
1085 if let Some(host_ip) = &port.host_ip {
1086 if host_ip.contains(':') && !(host_ip.starts_with('[') && host_ip.ends_with(']')) {
1087 value.push('[');
1088 value.push_str(host_ip);
1089 value.push(']');
1090 } else {
1091 value.push_str(host_ip);
1092 }
1093 value.push(':');
1094 }
1095 if let Some(published) = port.published {
1096 value.push_str(&published.to_string());
1097 value.push(':');
1098 }
1099 value.push_str(&port.target.to_string());
1100 value.push_str("/sctp");
1101
1102 output.push_str(" - ");
1103 write_quoted(output, &value);
1104 output.push('\n');
1105}
1106
1107fn render_mounts(output: &mut String, mounts: &[GeneratedMount]) {
1108 if mounts.is_empty() {
1109 return;
1110 }
1111 output.push_str(" volumes:\n");
1112 for mount in mounts {
1113 match &mount.kind {
1114 GeneratedMountKind::Bind {
1115 source,
1116 selinux: Some(selinux),
1117 } => render_selinux_bind(output, source, mount, *selinux),
1118 kind => render_long_mount(output, kind, mount),
1119 }
1120 }
1121}
1122
1123fn render_selinux_bind(output: &mut String, source: &str, mount: &GeneratedMount, selinux: GeneratedSelinux) {
1124 let mut value = format!("{source}:{}:{}", mount.target, selinux.as_str());
1125 if mount.read_only {
1126 value.push_str(",ro");
1127 }
1128 output.push_str(" - ");
1129 write_quoted(output, &value);
1130 output.push('\n');
1131}
1132
1133fn render_long_mount(output: &mut String, kind: &GeneratedMountKind, mount: &GeneratedMount) {
1134 let (mount_type, source) = match kind {
1135 GeneratedMountKind::Volume { source } => ("volume", Some(source.as_str())),
1136 GeneratedMountKind::Bind { source, selinux: None } => ("bind", Some(source.as_str())),
1137 GeneratedMountKind::Anonymous => ("volume", None),
1138 GeneratedMountKind::Bind { selinux: Some(_), .. } => return,
1139 };
1140 output.push_str(" - type: ");
1141 write_quoted(output, mount_type);
1142 output.push('\n');
1143 if let Some(source) = source {
1144 output.push_str(" source: ");
1145 write_quoted(output, source);
1146 output.push('\n');
1147 }
1148 output.push_str(" target: ");
1149 write_quoted(output, &mount.target);
1150 output.push('\n');
1151 if mount.read_only {
1152 output.push_str(" read_only: true\n");
1153 }
1154}
1155
1156fn render_networks(output: &mut String, networks: &[GeneratedNetworkAttachment]) {
1157 if networks.is_empty() {
1158 return;
1159 }
1160 output.push_str(" networks:\n");
1161 for network in networks {
1162 output.push_str(" ");
1163 write_quoted(output, &network.name);
1164 if network.aliases.is_empty() {
1165 output.push_str(": {}\n");
1166 } else {
1167 output.push_str(":\n aliases:\n");
1168 for alias in &network.aliases {
1169 output.push_str(" - ");
1170 write_quoted(output, alias);
1171 output.push('\n');
1172 }
1173 }
1174 }
1175}
1176
1177fn render_resources(output: &mut String, section: &str, resources: &[GeneratedResource]) {
1178 if resources.is_empty() {
1179 return;
1180 }
1181 output.push_str(section);
1182 output.push_str(":\n");
1183 for resource in resources {
1184 output.push_str(" ");
1185 write_quoted(output, &resource.name);
1186 if !resource.external && resource.custom_name.is_none() {
1187 output.push_str(": {}\n");
1188 continue;
1189 }
1190 output.push_str(":\n");
1191 if let Some(custom_name) = &resource.custom_name {
1192 output.push_str(" name: ");
1193 write_quoted(output, custom_name);
1194 output.push('\n');
1195 }
1196 if resource.external {
1197 output.push_str(" external: true\n");
1198 }
1199 }
1200}
1201
1202fn write_field(output: &mut String, depth: usize, key: &str) {
1203 write_indent(output, depth);
1204 output.push_str(key);
1205 output.push_str(": ");
1206}
1207
1208fn write_indent(output: &mut String, depth: usize) {
1209 for _ in 0..depth {
1210 output.push_str(" ");
1211 }
1212}
1213
1214fn required(kind: &'static str, value: String) -> Result<String, GenerationError> {
1215 if value.is_empty() {
1216 return Err(GenerationError::EmptyValue(kind));
1217 }
1218 if value.contains('\0') {
1219 return Err(GenerationError::ContainsNul(kind));
1220 }
1221 Ok(value)
1222}
1223
1224fn require_generated_string(kind: &'static str, value: &GeneratedString) -> Result<(), GenerationError> {
1225 if value.expose().is_empty() {
1226 return Err(GenerationError::EmptyValue(kind));
1227 }
1228 Ok(())
1229}
1230
1231fn environment_name(value: String) -> Result<String, GenerationError> {
1232 let value = required("environment name", value)?;
1233 if value.contains('=') {
1234 return Err(GenerationError::InvalidEnvironmentName);
1235 }
1236 Ok(value)
1237}
1238
1239fn valid_container_name(value: &str) -> bool {
1240 let mut bytes = value.bytes();
1241 bytes.next().is_some_and(|byte| byte.is_ascii_alphanumeric())
1242 && bytes
1243 .next()
1244 .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
1245 && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
1246}
1247
1248fn short_component(kind: &'static str, value: String, separator: char) -> Result<String, GenerationError> {
1249 let value = required(kind, value)?;
1250 if value.contains(separator) {
1251 return Err(GenerationError::InvalidShortComponent(kind));
1252 }
1253 Ok(value)
1254}
1255
1256fn set_once<T>(slot: &mut Option<T>, value: T, field: &'static str) -> Result<(), GenerationError> {
1257 if slot.is_some() {
1258 return Err(GenerationError::DuplicateField(field));
1259 }
1260 *slot = Some(value);
1261 Ok(())
1262}
1263
1264fn insert_named<T>(
1265 values: &mut Vec<T>,
1266 value: T,
1267 kind: &'static str,
1268 name: impl Fn(&T) -> &str,
1269) -> Result<(), GenerationError> {
1270 let value_name = name(&value);
1271 if values.iter().any(|candidate| name(candidate) == value_name) {
1272 return Err(GenerationError::DuplicateName {
1273 kind,
1274 name: value_name.to_owned(),
1275 });
1276 }
1277 values.push(value);
1278 Ok(())
1279}
1280
1281fn command_is_sensitive(command: &GeneratedCommand) -> bool {
1282 match command {
1283 GeneratedCommand::Exec(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
1284 GeneratedCommand::Shell(command) => command.is_sensitive(),
1285 GeneratedCommand::Empty => false,
1286 }
1287}