1use std::convert::TryFrom;
4
5use boxferry_engine::{
6 ConversionKind, ConversionOutcome, ConversionPlan, Diagnostic, DiagnosticCode, DiagnosticField, DiagnosticValue,
7 ExportAdapter, InvalidDiagnosticCode, PlanError, PlatformVersion, Severity, TargetProfile,
8};
9use boxferry_model::{
10 Application, Command, Device, Entrypoint, EnvironmentFileFormat, EnvironmentFileSyntax, EnvironmentValue,
11 HostAddressKind, MountSource, Network, ProtectedString, Protocol, Provenance, ProvenanceKind, PullPolicy,
12 ResourceOwnership, RestartPolicy, SecurityOption, SelinuxRelabel, Service, Sourced, Volume,
13};
14use compose_lens::{
15 model::{MemLimitUnit, ShmSizeUnit},
16 render::{
17 ComposeDocumentBuilder, GeneratedAnnotation, GeneratedCommand, GeneratedComposeDocument, GeneratedDevice,
18 GeneratedDns, GeneratedDnsSearch, GeneratedEntrypoint, GeneratedEnvironment, GeneratedEnvironmentFile,
19 GeneratedEnvironmentFileFormat, GeneratedExtraHost, GeneratedHostname, GeneratedLabel, GeneratedLogging,
20 GeneratedLoggingOption, GeneratedLoggingOptionValue, GeneratedLongDevice, GeneratedMemLimit, GeneratedMount,
21 GeneratedNetworkAttachment, GeneratedNetworkDefinition, GeneratedNetworkDriverOption,
22 GeneratedNetworkDriverOptionValue, GeneratedPidsLimit, GeneratedPort, GeneratedProtocol, GeneratedPullPolicy,
23 GeneratedResource, GeneratedRestartPolicy, GeneratedSelinux, GeneratedService, GeneratedShmSize,
24 GeneratedString, GeneratedSysctl, GeneratedSysctls, GeneratedTmpfs, GeneratedUlimit, GeneratedUlimits,
25 GeneratedVolumeDefinition, GeneratedVolumeDriverOption, GeneratedVolumeDriverOptionValue, GenerationError,
26 },
27 source::SourceId,
28 validation::{
29 CompatibilityClassification, CompatibilityFeature, CompatibilityProfile, ContainerRuntime,
30 ImplementationVersion,
31 },
32};
33
34pub const DOCKER_COMPOSE_TARGET: &str = "docker-compose";
36
37pub const PODMAN_COMPOSE_TARGET: &str = "podman-compose";
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42#[non_exhaustive]
43pub enum ComposeRuntime {
44 DockerEngine(PlatformVersion),
46 Podman(PlatformVersion),
48}
49
50#[derive(Clone, Debug)]
52pub struct ComposeExporter {
53 codes: Codes,
54 runtime: Option<ComposeRuntime>,
55}
56
57impl ComposeExporter {
58 pub fn new() -> Result<Self, InvalidDiagnosticCode> {
64 Ok(Self {
65 codes: Codes {
66 invalid_target: DiagnosticCode::new("BFC0006")?,
67 unsupported: DiagnosticCode::new("BFC0007")?,
68 generation: DiagnosticCode::new("BFC0008")?,
69 compatibility: DiagnosticCode::new("BFC0009")?,
70 },
71 runtime: None,
72 })
73 }
74
75 #[must_use]
77 pub const fn with_runtime(mut self, runtime: ComposeRuntime) -> Self {
78 self.runtime = Some(runtime);
79 self
80 }
81
82 #[must_use]
84 pub const fn runtime(&self) -> Option<ComposeRuntime> {
85 self.runtime
86 }
87}
88
89impl ExportAdapter for ComposeExporter {
90 type Output = GeneratedComposeDocument;
91
92 fn plan(
93 &self,
94 application: &Application,
95 target: &TargetProfile,
96 ) -> Result<ConversionPlan<Self::Output>, PlanError> {
97 let mut mapping = Mapping::new(self, application, target);
98 if mapping.validate_target() {
99 mapping.map_application();
100 }
101 let (candidate, outcomes, diagnostics) = mapping.finish();
102 ConversionPlan::new(candidate, outcomes, diagnostics)
103 }
104}
105
106#[derive(Clone, Debug)]
107struct Codes {
108 invalid_target: DiagnosticCode,
109 unsupported: DiagnosticCode,
110 generation: DiagnosticCode,
111 compatibility: DiagnosticCode,
112}
113
114struct Mapping<'a> {
115 exporter: &'a ComposeExporter,
116 application: &'a Application,
117 target: &'a TargetProfile,
118 compatibility: Option<CompatibilityProfile>,
119 builder: Option<ComposeDocumentBuilder>,
120 outcomes: Vec<ConversionOutcome>,
121 diagnostics: Vec<Diagnostic>,
122 generation_failed: bool,
123}
124
125impl<'a> Mapping<'a> {
126 fn new(exporter: &'a ComposeExporter, application: &'a Application, target: &'a TargetProfile) -> Self {
127 Self {
128 exporter,
129 application,
130 target,
131 compatibility: None,
132 builder: Some(ComposeDocumentBuilder::new()),
133 outcomes: Vec::new(),
134 diagnostics: Vec::new(),
135 generation_failed: false,
136 }
137 }
138
139 fn validate_target(&mut self) -> bool {
140 let versions = self.target.versions();
141 let Some(maximum) = versions.maximum() else {
142 self.invalid(
143 self.exporter.codes.invalid_target.clone(),
144 "target.versions",
145 "Compose output requires one exact provider version",
146 "set the minimum and maximum to the same exact Compose provider version",
147 &[],
148 );
149 return false;
150 };
151 if maximum != versions.minimum() {
152 self.invalid(
153 self.exporter.codes.invalid_target.clone(),
154 "target.versions",
155 "Compose output requires one exact provider version",
156 "ComposeLens compatibility evidence is evaluated for exact provider versions, not a range",
157 &[],
158 );
159 return false;
160 }
161 let Some(version) = implementation_version(versions.minimum()) else {
162 self.invalid(
163 self.exporter.codes.invalid_target.clone(),
164 "target.versions",
165 "Compose provider version is outside the supported numeric range",
166 "each version component must fit into an unsigned 32-bit integer",
167 &[],
168 );
169 return false;
170 };
171 let mut profile = match self.target.implementation() {
172 DOCKER_COMPOSE_TARGET => CompatibilityProfile::docker_compose(version),
173 PODMAN_COMPOSE_TARGET => CompatibilityProfile::podman_compose(version),
174 _ => {
175 self.invalid(
176 self.exporter.codes.invalid_target.clone(),
177 "target.implementation",
178 "Compose output requires a recognized provider",
179 "select `docker-compose` or `podman-compose`; `podman compose` is a wrapper, not a provider",
180 &[],
181 );
182 return false;
183 }
184 };
185 if let Some(runtime) = self.exporter.runtime {
186 let runtime = match runtime {
187 ComposeRuntime::DockerEngine(version) => {
188 implementation_version(version).map(ContainerRuntime::DockerEngine)
189 }
190 ComposeRuntime::Podman(version) => implementation_version(version).map(ContainerRuntime::Podman),
191 };
192 let Some(runtime) = runtime else {
193 self.invalid(
194 self.exporter.codes.invalid_target.clone(),
195 "target.runtime.version",
196 "Compose runtime version is outside the supported numeric range",
197 "each version component must fit into an unsigned 32-bit integer",
198 &[],
199 );
200 return false;
201 };
202 profile = profile.with_runtime(runtime);
203 }
204 self.compatibility = Some(profile);
205 true
206 }
207
208 fn map_application(&mut self) {
209 let mut builder = self.builder.take().unwrap_or_default();
210 if let Err(error) = builder.set_name(self.application.name().as_str()) {
211 self.generation_error("application.name", &error, &[]);
212 } else {
213 self.exact("application.name", &[]);
214 }
215
216 for network in self.application.networks() {
217 self.map_network_definition(network, &mut builder);
218 }
219 for volume in self.application.volumes() {
220 self.map_volume_definition(volume, &mut builder);
221 }
222
223 for acquisition in self.application.image_acquisitions() {
224 self.unsupported(
225 &format!("image_acquisitions.{}", acquisition.value().name().as_str()),
226 "ComposeLens 0.1.16 generation does not expose image-acquisition declarations",
227 acquisition.origins(),
228 );
229 }
230 for build in self.application.image_builds() {
231 self.unsupported(
232 &format!("image_builds.{}", build.value().name().as_str()),
233 "ComposeLens 0.1.16 generation does not expose build declarations",
234 build.origins(),
235 );
236 }
237
238 for config in self.application.configs() {
239 self.unsupported(
240 &format!("configs.{}", config.value().name().as_str()),
241 "the current Compose generation boundary does not yet expose top-level config definitions",
242 config.origins(),
243 );
244 }
245 for secret in self.application.secrets() {
246 self.unsupported(
247 &format!("secrets.{}", secret.value().name().as_str()),
248 "the current Compose generation boundary does not yet expose top-level secret definitions",
249 secret.origins(),
250 );
251 }
252 for group in self.application.service_groups() {
253 self.report_service_group_loss(group);
254 }
255
256 for service in self.application.services() {
257 if let Some(generated) = self.map_service(service) {
258 let subject = format!("services.{}", service.value().name().as_str());
259 if let Err(error) = builder.add_service(generated) {
260 self.generation_error(&subject, &error, service.origins());
261 }
262 }
263 }
264 self.builder = Some(builder);
265 }
266
267 fn map_resource(
268 &mut self,
269 name: &str,
270 ownership: ResourceOwnership,
271 origins: &[Provenance],
272 subject: &str,
273 ) -> Option<GeneratedResource> {
274 let resource = match ownership {
275 ResourceOwnership::Application => GeneratedResource::application(name),
276 ResourceOwnership::External => GeneratedResource::external(name),
277 ResourceOwnership::Implicit => {
278 self.unsupported(
279 subject,
280 "implicit source lifecycle is emitted as an externally managed Compose resource",
281 origins,
282 );
283 GeneratedResource::external(name)
284 }
285 ResourceOwnership::Uncertain => {
286 self.unsupported(
287 subject,
288 "runtime inspection did not determine resource ownership; output conservatively reuses the existing resource",
289 origins,
290 );
291 GeneratedResource::external(name)
292 }
293 _ => {
294 self.unsupported(
295 subject,
296 "resource ownership variant is newer than this Compose adapter",
297 origins,
298 );
299 GeneratedResource::external(name)
300 }
301 };
302 let mut resource = match resource {
303 Ok(resource) => resource,
304 Err(error) => {
305 self.generation_error(subject, &error, origins);
306 return None;
307 }
308 };
309 if origins
310 .iter()
311 .any(|origin| origin.kind() == ProvenanceKind::RuntimeObservation)
312 {
313 if let Err(error) = resource.set_custom_name(name) {
314 self.generation_error(subject, &error, origins);
315 return None;
316 }
317 }
318 if matches!(ownership, ResourceOwnership::Application | ResourceOwnership::External) {
319 self.exact(subject, origins);
320 }
321 Some(resource)
322 }
323
324 #[allow(clippy::too_many_lines)]
325 fn map_network_definition(&mut self, sourced: &Sourced<Network>, builder: &mut ComposeDocumentBuilder) {
326 let network = sourced.value();
327 let subject = format!("networks.{}", network.name().as_str());
328 if network.ownership() != ResourceOwnership::Application {
329 let Some(mut generated) = self.map_resource(
330 network.name().as_str(),
331 network.ownership(),
332 sourced.origins(),
333 &subject,
334 ) else {
335 self.generation_failed = true;
336 return;
337 };
338 if let Some(runtime_name) = network.runtime_name() {
339 if runtime_name.value().is_sensitive() {
340 self.unsupported(
341 &format!("{subject}.name"),
342 "sensitive runtime network names cannot be passed to ComposeLens' plain-string generator",
343 runtime_name.origins(),
344 );
345 } else if let Err(error) = generated.set_custom_name(runtime_name.value().expose()) {
346 self.generation_error(&format!("{subject}.name"), &error, runtime_name.origins());
347 } else {
348 self.exact(format!("{subject}.name"), runtime_name.origins());
349 }
350 }
351 self.report_external_network_configuration(network, &subject);
352 if let Err(error) = builder.add_network(generated) {
353 self.generation_error(&subject, &error, sourced.origins());
354 }
355 return;
356 }
357
358 let mut generated = match GeneratedNetworkDefinition::application(network.name().as_str()) {
359 Ok(value) => value,
360 Err(error) => {
361 self.generation_error(&subject, &error, sourced.origins());
362 return;
363 }
364 };
365 let mut valid = true;
366 if let Some(runtime_name) = network.runtime_name() {
367 if runtime_name.value().is_sensitive() {
368 self.unsupported(
369 &format!("{subject}.name"),
370 "sensitive runtime network names cannot be passed to ComposeLens' plain-string generator",
371 runtime_name.origins(),
372 );
373 } else {
374 match generated.set_custom_name(runtime_name.value().expose()) {
375 Ok(()) => self.exact(format!("{subject}.name"), runtime_name.origins()),
376 Err(error) => {
377 self.generation_error(&format!("{subject}.name"), &error, runtime_name.origins());
378 valid = false;
379 }
380 }
381 }
382 }
383 if let Some(driver) = network.driver() {
384 match generated_string(driver.value()).and_then(|value| generated.set_driver(value)) {
385 Ok(()) => self.exact(format!("{subject}.driver"), driver.origins()),
386 Err(error) => {
387 self.generation_error(&format!("{subject}.driver"), &error, driver.origins());
388 valid = false;
389 }
390 }
391 }
392 if let Some(options) = network.driver_options() {
393 let origins = collection_or_item_origins(options, network.driver_options_origins());
394 let values: Result<Vec<_>, _> = options
395 .iter()
396 .map(|option| {
397 GeneratedNetworkDriverOption::new(
398 option.value().name().value().as_str(),
399 GeneratedNetworkDriverOptionValue::String(generated_string(option.value().value().value())?),
400 )
401 })
402 .collect();
403 match values.and_then(|values| generated.set_driver_opts(values)) {
404 Ok(()) => self.exact(format!("{subject}.driver_opts"), &origins),
405 Err(error) => {
406 self.generation_error(&format!("{subject}.driver_opts"), &error, &origins);
407 valid = false;
408 }
409 }
410 }
411 if let Some(ipv6) = network.ipv6() {
412 match generated.set_enable_ipv6(*ipv6.value()) {
413 Ok(()) => self.exact(format!("{subject}.enable_ipv6"), ipv6.origins()),
414 Err(error) => {
415 self.generation_error(&format!("{subject}.enable_ipv6"), &error, ipv6.origins());
416 valid = false;
417 }
418 }
419 }
420 if let Some(internal) = network.internal() {
421 match generated.set_internal(*internal.value()) {
422 Ok(()) => self.exact(format!("{subject}.internal"), internal.origins()),
423 Err(error) => {
424 self.generation_error(&format!("{subject}.internal"), &error, internal.origins());
425 valid = false;
426 }
427 }
428 }
429 if let Some(labels) = network.labels() {
430 let origins = collection_or_item_origins(labels, network.labels_origins());
431 let values: Result<Vec<_>, _> = labels
432 .iter()
433 .map(|label| {
434 GeneratedLabel::new(label.value().name().as_str(), generated_string(label.value().value())?)
435 })
436 .collect();
437 match values.and_then(|values| generated.set_labels(values)) {
438 Ok(()) => self.exact(format!("{subject}.labels"), &origins),
439 Err(error) => {
440 self.generation_error(&format!("{subject}.labels"), &error, &origins);
441 valid = false;
442 }
443 }
444 }
445 if let Some(driver) = network.ipam_driver() {
446 self.unsupported(
447 &format!("{subject}.ipam.driver"),
448 "ComposeLens 0.1.16 has no generated IPAM definition API",
449 driver.origins(),
450 );
451 }
452 if let Some(configs) = network.ipam_configs() {
453 self.unsupported(
454 &format!("{subject}.ipam.config"),
455 "ComposeLens 0.1.16 has no generated IPAM definition API",
456 &collection_or_item_origins(configs, network.ipam_configs_origins()),
457 );
458 }
459 if valid {
460 if let Err(error) = builder.add_network_definition(generated) {
461 self.generation_error(&subject, &error, sourced.origins());
462 }
463 }
464 }
465
466 fn report_external_network_configuration(&mut self, network: &Network, subject: &str) {
467 let reason = "Compose external networks may only declare their platform name";
468 if let Some(driver) = network.driver() {
469 self.unsupported(&format!("{subject}.driver"), reason, driver.origins());
470 }
471 if let Some(values) = network.driver_options() {
472 self.unsupported(
473 &format!("{subject}.driver_opts"),
474 reason,
475 &collection_or_item_origins(values, network.driver_options_origins()),
476 );
477 }
478 if let Some(value) = network.internal() {
479 self.unsupported(&format!("{subject}.internal"), reason, value.origins());
480 }
481 if let Some(value) = network.ipv6() {
482 self.unsupported(&format!("{subject}.enable_ipv6"), reason, value.origins());
483 }
484 if let Some(driver) = network.ipam_driver() {
485 self.unsupported(&format!("{subject}.ipam.driver"), reason, driver.origins());
486 }
487 if let Some(values) = network.ipam_configs() {
488 self.unsupported(
489 &format!("{subject}.ipam.config"),
490 reason,
491 &collection_or_item_origins(values, network.ipam_configs_origins()),
492 );
493 }
494 if let Some(values) = network.labels() {
495 self.unsupported(
496 &format!("{subject}.labels"),
497 reason,
498 &collection_or_item_origins(values, network.labels_origins()),
499 );
500 }
501 }
502
503 fn map_volume_definition(&mut self, sourced: &Sourced<Volume>, builder: &mut ComposeDocumentBuilder) {
504 let volume = sourced.value();
505 let subject = format!("volumes.{}", volume.name().as_str());
506 self.report_compose_native_only_volume_fields(volume, &subject);
507 if volume.ownership() != ResourceOwnership::Application {
508 let Some(mut generated) = self.map_external_volume_resource(volume, sourced.origins(), &subject) else {
509 self.generation_failed = true;
510 return;
511 };
512 if let Some(runtime_name) = volume.runtime_name() {
513 if runtime_name.value().is_sensitive() {
514 self.unsupported(
515 &format!("{subject}.name"),
516 "sensitive runtime volume names cannot be passed to ComposeLens' plain-string generator",
517 runtime_name.origins(),
518 );
519 } else {
520 match generated.set_custom_name(runtime_name.value().expose()) {
521 Ok(()) => self.exact(format!("{subject}.name"), runtime_name.origins()),
522 Err(error) => {
523 self.generation_error(&format!("{subject}.name"), &error, runtime_name.origins());
524 }
525 }
526 }
527 }
528 self.report_external_volume_configuration(volume, &subject);
529 if let Err(error) = builder.add_volume(generated) {
530 self.generation_error(&subject, &error, sourced.origins());
531 }
532 return;
533 }
534
535 let mut generated = match GeneratedVolumeDefinition::application(volume.name().as_str()) {
536 Ok(value) => value,
537 Err(error) => {
538 self.generation_error(&subject, &error, sourced.origins());
539 return;
540 }
541 };
542 let mut valid = true;
543 if let Some(runtime_name) = volume.runtime_name() {
544 if runtime_name.value().is_sensitive() {
545 self.unsupported(
546 &format!("{subject}.name"),
547 "sensitive runtime volume names cannot be passed to ComposeLens' plain-string generator",
548 runtime_name.origins(),
549 );
550 } else {
551 match generated.set_custom_name(runtime_name.value().expose()) {
552 Ok(()) => self.exact(format!("{subject}.name"), runtime_name.origins()),
553 Err(error) => {
554 self.generation_error(&format!("{subject}.name"), &error, runtime_name.origins());
555 valid = false;
556 }
557 }
558 }
559 }
560 if let Some(driver) = volume.driver() {
561 match generated_string(driver.value()).and_then(|value| generated.set_driver(value)) {
562 Ok(()) => self.exact(format!("{subject}.driver"), driver.origins()),
563 Err(error) => {
564 self.generation_error(&format!("{subject}.driver"), &error, driver.origins());
565 valid = false;
566 }
567 }
568 }
569 self.map_local_volume_driver_options(volume, &mut generated, &subject, &mut valid);
570 if let Some(labels) = volume.labels() {
571 let origins = collection_or_item_origins(labels, volume.labels_origins());
572 let values: Result<Vec<_>, _> = labels
573 .iter()
574 .map(|label| {
575 GeneratedLabel::new(label.value().name().as_str(), generated_string(label.value().value())?)
576 })
577 .collect();
578 match values.and_then(|values| generated.set_labels(values)) {
579 Ok(()) => self.exact(format!("{subject}.labels"), &origins),
580 Err(error) => {
581 self.generation_error(&format!("{subject}.labels"), &error, &origins);
582 valid = false;
583 }
584 }
585 }
586 if valid {
587 if let Err(error) = builder.add_volume_definition(generated) {
588 self.generation_error(&subject, &error, sourced.origins());
589 }
590 }
591 }
592
593 fn map_external_volume_resource(
594 &mut self,
595 volume: &Volume,
596 origins: &[Provenance],
597 subject: &str,
598 ) -> Option<GeneratedResource> {
599 let generated = match volume.ownership() {
600 ResourceOwnership::External => GeneratedResource::external(volume.name().as_str()),
601 ResourceOwnership::Implicit => {
602 self.unsupported(
603 subject,
604 "implicit source lifecycle is emitted as an externally managed Compose resource",
605 origins,
606 );
607 GeneratedResource::external(volume.name().as_str())
608 }
609 ResourceOwnership::Uncertain => {
610 self.unsupported(
611 subject,
612 "runtime inspection did not determine volume ownership; output conservatively reuses the existing volume",
613 origins,
614 );
615 GeneratedResource::external(volume.name().as_str())
616 }
617 ResourceOwnership::Application => return None,
618 _ => {
619 self.unsupported(
620 subject,
621 "volume ownership variant is newer than this Compose adapter",
622 origins,
623 );
624 GeneratedResource::external(volume.name().as_str())
625 }
626 };
627 let mut generated = match generated {
628 Ok(generated) => generated,
629 Err(error) => {
630 self.generation_error(subject, &error, origins);
631 return None;
632 }
633 };
634 if origins
635 .iter()
636 .any(|origin| origin.kind() == ProvenanceKind::RuntimeObservation)
637 && volume.runtime_name().is_none()
638 {
639 if let Err(error) = generated.set_custom_name(volume.name().as_str()) {
640 self.generation_error(subject, &error, origins);
641 return None;
642 }
643 }
644 if matches!(volume.ownership(), ResourceOwnership::External) {
645 self.exact(subject, origins);
646 }
647 Some(generated)
648 }
649
650 fn map_local_volume_driver_options(
651 &mut self,
652 volume: &Volume,
653 generated: &mut GeneratedVolumeDefinition,
654 subject: &str,
655 valid: &mut bool,
656 ) {
657 let fields = [
658 ("type", volume.volume_type()),
659 ("device", volume.device()),
660 ("o", volume.options()),
661 ];
662 if fields.iter().all(|(_, value)| value.is_none()) {
663 return;
664 }
665 let local_driver = volume.driver().is_some_and(|driver| driver.value().expose() == "local");
666 if !local_driver {
667 for (field, value) in fields {
668 if let Some(value) = value {
669 self.unsupported(
670 &format!("{subject}.driver_opts.{field}"),
671 "Compose local-driver options require an explicit `driver: local`",
672 value.origins(),
673 );
674 }
675 }
676 return;
677 }
678 let origins = fields
679 .iter()
680 .filter_map(|(_, value)| value.map(Sourced::origins))
681 .flatten()
682 .cloned()
683 .collect::<Vec<_>>();
684 let values: Result<Vec<_>, _> = fields
685 .into_iter()
686 .filter_map(|(name, value)| {
687 value.map(|value| {
688 GeneratedVolumeDriverOption::new(
689 name,
690 GeneratedVolumeDriverOptionValue::String(generated_string(value.value())?),
691 )
692 })
693 })
694 .collect();
695 match values.and_then(|values| generated.set_driver_opts(values)) {
696 Ok(()) => self.exact(format!("{subject}.driver_opts"), &origins),
697 Err(error) => {
698 self.generation_error(&format!("{subject}.driver_opts"), &error, &origins);
699 *valid = false;
700 }
701 }
702 }
703
704 fn report_external_volume_configuration(&mut self, volume: &Volume, subject: &str) {
705 let reason = "Compose external volumes may only declare platform name";
706 if let Some(driver) = volume.driver() {
707 self.unsupported(&format!("{subject}.driver"), reason, driver.origins());
708 }
709 for (field, value) in [
710 ("type", volume.volume_type()),
711 ("device", volume.device()),
712 ("o", volume.options()),
713 ] {
714 if let Some(value) = value {
715 self.unsupported(&format!("{subject}.driver_opts.{field}"), reason, value.origins());
716 }
717 }
718 if let Some(values) = volume.labels() {
719 self.unsupported(
720 &format!("{subject}.labels"),
721 reason,
722 &collection_or_item_origins(values, volume.labels_origins()),
723 );
724 }
725 }
726
727 fn report_compose_native_only_volume_fields(&mut self, volume: &Volume, subject: &str) {
728 for (name, value) in [
729 ("service_name", volume.service_name()),
730 ("user", volume.user()),
731 ("group", volume.group()),
732 ("uid", volume.uid()),
733 ("gid", volume.gid()),
734 ] {
735 if let Some(value) = value {
736 self.unsupported(
737 &format!("{subject}.{name}"),
738 "Quadlet-only volume setting has no reviewed Compose volume-definition mapping",
739 value.origins(),
740 );
741 }
742 }
743 if let Some(value) = volume.copy() {
744 self.unsupported(
745 &format!("{subject}.copy"),
746 "Quadlet-only volume setting has no reviewed Compose volume-definition mapping",
747 value.origins(),
748 );
749 }
750 for (name, values, origins) in [
751 (
752 "containers_conf_modules",
753 volume.containers_conf_modules(),
754 volume.containers_conf_modules_origins(),
755 ),
756 ("global_args", volume.global_args(), volume.global_args_origins()),
757 ("podman_args", volume.podman_args(), volume.podman_args_origins()),
758 ] {
759 if let Some(values) = values {
760 self.unsupported(
761 &format!("{subject}.{name}"),
762 "Quadlet-only volume setting has no reviewed Compose volume-definition mapping",
763 &collection_or_item_origins(values, origins),
764 );
765 }
766 }
767 if let Some(image) = volume.image_source() {
768 self.unsupported(
769 &format!("{subject}.image"),
770 "Quadlet volume image source has no reviewed Compose volume-definition mapping",
771 image.origins(),
772 );
773 }
774 }
775
776 fn map_service(&mut self, sourced: &Sourced<Service>) -> Option<GeneratedService> {
777 let service = sourced.value();
778 let service_name = service.name().as_str();
779 let service_subject = format!("services.{service_name}");
780 let mut generated = match GeneratedService::new(service_name) {
781 Ok(generated) => generated,
782 Err(error) => {
783 self.generation_error(&service_subject, &error, sourced.origins());
784 return None;
785 }
786 };
787 if let Some(runtime_name) = service.runtime_name() {
788 match generated_string(runtime_name.value()).and_then(|name| generated.set_container_name(name)) {
789 Ok(()) => self.exact(format!("{service_subject}.container_name"), runtime_name.origins()),
790 Err(error) => {
791 self.generation_error(
792 &format!("{service_subject}.container_name"),
793 &error,
794 runtime_name.origins(),
795 );
796 }
797 }
798 }
799 self.report_compose_native_only_service_fields(service, &service_subject);
800 if !self.map_image(service, sourced.origins(), &mut generated, &service_subject) {
801 return None;
802 }
803 self.map_restart_policy(service, &mut generated, &service_subject);
804 self.map_command(service, &mut generated, &service_subject);
805 self.map_released_container_settings(service, &mut generated, &service_subject);
806 self.map_identity(service, &mut generated, &service_subject);
807 self.map_execution_context(service, &mut generated, &service_subject);
808 self.map_dns(service, &mut generated, &service_subject);
809 self.map_security_options(service, &mut generated, &service_subject);
810 self.map_environment_files(service, &mut generated, &service_subject);
811 self.map_environment(service, &mut generated, &service_subject);
812 self.map_labels(service, &mut generated, &service_subject);
813 self.map_host_mappings(service, &mut generated, &service_subject);
814 self.map_ports(service, &mut generated, &service_subject);
815 self.map_mounts(service, &mut generated, &service_subject);
816 self.map_network_attachments(service, &mut generated, &service_subject);
817 self.report_unimplemented_service_fields(service, &service_subject);
818 Some(generated)
819 }
820
821 fn map_restart_policy(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
822 let Some(restart) = service.restart_policy() else {
823 return;
824 };
825 let subject = format!("{service_subject}.restart_policy");
826 let policy = match restart.value() {
827 RestartPolicy::Never => GeneratedRestartPolicy::No,
828 RestartPolicy::Always => GeneratedRestartPolicy::Always,
829 RestartPolicy::OnFailure { maximum_retries } => GeneratedRestartPolicy::OnFailure {
830 maximum_retries: maximum_retries.map(std::num::NonZeroU64::get),
831 },
832 RestartPolicy::UnlessStopped => GeneratedRestartPolicy::UnlessStopped,
833 _ => {
834 self.unsupported(
835 &subject,
836 "restart-policy variant is newer than this Compose adapter",
837 restart.origins(),
838 );
839 return;
840 }
841 };
842 match generated.set_restart(policy) {
843 Ok(()) => self.exact(subject, restart.origins()),
844 Err(error) => self.generation_error(&subject, &error, restart.origins()),
845 }
846 }
847
848 fn map_dns(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
849 if let Some(values) = service.dns_servers() {
850 let subject = format!("{service_subject}.dns");
851 let origins = collection_or_item_origins(values, service.dns_servers_origins());
852 let generated_values: Result<Vec<_>, _> =
853 values.iter().map(|value| generated_string(value.value())).collect();
854 match generated_values.and_then(|values| generated.set_dns(GeneratedDns::List(values))) {
855 Ok(()) if values.is_empty() => self.exact(subject, &origins),
856 Ok(()) if values.iter().any(|value| value.value().expose() == "none") => {
857 self.unsupported(&subject, "DNS `none` has target-specific resolver semantics", &origins);
858 }
859 Ok(()) => self.exact(subject, &origins),
860 Err(error) => self.generation_error(&subject, &error, &origins),
861 }
862 }
863 if let Some(values) = service.dns_options() {
864 let subject = format!("{service_subject}.dns_opt");
865 let origins = collection_or_item_origins(values, service.dns_options_origins());
866 let generated_values: Result<Vec<_>, _> =
867 values.iter().map(|value| generated_string(value.value())).collect();
868 match generated_values.and_then(|values| generated.set_dns_options(values)) {
869 Ok(()) if values.is_empty() => self.exact(subject, &origins),
870 Ok(()) => self.exact(subject, &origins),
871 Err(error) => self.generation_error(&subject, &error, &origins),
872 }
873 }
874 if let Some(values) = service.dns_search_domains() {
875 let subject = format!("{service_subject}.dns_search");
876 let origins = collection_or_item_origins(values, service.dns_search_domains_origins());
877 let generated_values: Result<Vec<_>, _> =
878 values.iter().map(|value| generated_string(value.value())).collect();
879 match generated_values.and_then(|values| generated.set_dns_search(GeneratedDnsSearch::List(values))) {
880 Ok(()) if values.is_empty() => self.exact(subject, &origins),
881 Ok(()) if values.iter().any(|value| value.value().expose() == ".") => self.unsupported(
882 &subject,
883 "DNS search `.` has target-specific resolver semantics",
884 &origins,
885 ),
886 Ok(()) => self.exact(subject, &origins),
887 Err(error) => self.generation_error(&subject, &error, &origins),
888 }
889 }
890 }
891
892 fn map_security_options(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
893 let Some(options) = service.security_options() else {
894 return;
895 };
896 let subject = format!("{service_subject}.security_opt");
897 let origins = collection_or_item_origins(options, service.security_options_origins());
898 let mut generated_options = Vec::with_capacity(options.len());
899 let mut all_exact = true;
900 for (index, option) in options.iter().enumerate() {
901 match security_option_string(option.value()) {
902 Ok(SecurityOptionGeneration::Generated(value)) => generated_options.push(value),
903 Ok(SecurityOptionGeneration::Unsupported(reason)) => {
904 all_exact = false;
905 self.unsupported(&format!("{subject}[{index}]"), reason, option.origins());
906 }
907 Err(error) => {
908 self.generation_error(&subject, &error, &origins);
909 return;
910 }
911 }
912 }
913 match generated.set_security_options(generated_options) {
914 Ok(()) if all_exact => self.exact(subject, &origins),
915 Ok(()) => {}
916 Err(error) => self.generation_error(&subject, &error, &origins),
917 }
918 }
919
920 fn map_image(
921 &mut self,
922 service: &Service,
923 service_origins: &[Provenance],
924 generated: &mut GeneratedService,
925 service_subject: &str,
926 ) -> bool {
927 let Some(image) = service.image() else {
928 self.invalid(
929 self.exporter.codes.generation.clone(),
930 &format!("{service_subject}.image"),
931 "generated Compose service has no runnable source",
932 "the neutral service has neither an image nor a representable build definition",
933 service_origins,
934 );
935 self.generation_failed = true;
936 return false;
937 };
938 let result = GeneratedString::plain(image.value().as_str()).and_then(|image| generated.set_image(image));
939 if let Err(error) = result {
940 self.generation_error(&format!("{service_subject}.image"), &error, image.origins());
941 return false;
942 }
943 if image.value().tag().is_some() && image.value().digest().is_some() {
944 self.compatibility(
945 &format!("{service_subject}.image"),
946 CompatibilityFeature::ImageTagAndDigest,
947 image.origins(),
948 );
949 } else {
950 self.exact(format!("{service_subject}.image"), image.origins());
951 }
952 true
953 }
954
955 fn map_command(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
956 let Some(command) = service.command() else {
957 return;
958 };
959 let command_value = match command.value() {
960 Command::Exec(arguments) => Some(
961 arguments
962 .iter()
963 .map(generated_string)
964 .collect::<Result<Vec<_>, _>>()
965 .map(GeneratedCommand::Exec),
966 ),
967 Command::Shell(value) => Some(generated_string(value).map(GeneratedCommand::Shell)),
968 Command::Empty => Some(Ok(GeneratedCommand::Empty)),
969 _ => {
970 self.unsupported(
971 &format!("{service_subject}.command"),
972 "command variant is newer than this Compose adapter",
973 command.origins(),
974 );
975 None
976 }
977 };
978 if let Some(command_value) = command_value {
979 match command_value.and_then(|command_value| generated.set_command(command_value)) {
980 Ok(()) => self.exact(format!("{service_subject}.command"), command.origins()),
981 Err(error) => self.generation_error(&format!("{service_subject}.command"), &error, command.origins()),
982 }
983 }
984 }
985
986 fn map_execution_context(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
987 if let Some(working_directory) = service.working_directory() {
988 let field = format!("{subject}.working_directory");
989 match generated_string(working_directory.value()).and_then(|value| generated.set_working_dir(value)) {
990 Ok(()) => self.exact(field, working_directory.origins()),
991 Err(error) => self.generation_error(&field, &error, working_directory.origins()),
992 }
993 }
994 if let Some(read_only) = service.read_only_root_filesystem() {
995 let field = format!("{subject}.read_only_root_filesystem");
996 match generated.set_read_only(*read_only.value()) {
997 Ok(()) => self.exact(field, read_only.origins()),
998 Err(error) => self.generation_error(&field, &error, read_only.origins()),
999 }
1000 }
1001 }
1002
1003 fn map_environment(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1004 for environment in service.environment() {
1005 let subject = format!("{service_subject}.environment.{}", environment.value().name().as_str());
1006 match environment.value().value() {
1007 EnvironmentValue::Literal(value) => match generated_string(value)
1008 .and_then(|value| GeneratedEnvironment::literal(environment.value().name().as_str(), value))
1009 {
1010 Ok(value) => {
1011 generated.add_environment(value);
1012 self.exact(subject, environment.origins());
1013 }
1014 Err(error) => self.generation_error(&subject, &error, environment.origins()),
1015 },
1016 EnvironmentValue::Host => match GeneratedEnvironment::host(environment.value().name().as_str()) {
1017 Ok(value) => {
1018 generated.add_environment(value);
1019 self.exact(subject, environment.origins());
1020 }
1021 Err(error) => self.generation_error(&subject, &error, environment.origins()),
1022 },
1023 EnvironmentValue::Unset => self.unsupported(
1024 &subject,
1025 "Compose environment syntax cannot guarantee that a variable is absent from the container",
1026 environment.origins(),
1027 ),
1028 _ => self.unsupported(
1029 &subject,
1030 "environment value variant is newer than this Compose adapter",
1031 environment.origins(),
1032 ),
1033 }
1034 }
1035 }
1036
1037 fn map_environment_files(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1038 for (index, sourced) in service.environment_files().iter().enumerate() {
1039 let subject = format!("{service_subject}.environment_files[{index}]");
1040 let environment_file = sourced.value();
1041 let path = match generated_string(environment_file.path()) {
1042 Ok(path) => path,
1043 Err(error) => {
1044 self.generation_error(&subject, &error, sourced.origins());
1045 continue;
1046 }
1047 };
1048 let value = match environment_file.syntax() {
1049 EnvironmentFileSyntax::Short => {
1050 if environment_file.required().is_some() || environment_file.format().is_some() {
1051 self.unsupported(
1052 &subject,
1053 "short-syntax environment files cannot carry long-syntax options",
1054 sourced.origins(),
1055 );
1056 continue;
1057 }
1058 GeneratedEnvironmentFile::short(path)
1059 }
1060 EnvironmentFileSyntax::Long => {
1061 let format = match environment_file.format().map(Sourced::value) {
1062 Some(EnvironmentFileFormat::Raw) => Some(GeneratedEnvironmentFileFormat::Raw),
1063 Some(_) => {
1064 self.unsupported(
1065 &subject,
1066 "environment-file format variant is newer than this Compose adapter",
1067 sourced.origins(),
1068 );
1069 continue;
1070 }
1071 None => None,
1072 };
1073 GeneratedEnvironmentFile::long(
1074 path,
1075 environment_file.required().map(|required| *required.value()),
1076 format,
1077 )
1078 }
1079 _ => {
1080 self.unsupported(
1081 &subject,
1082 "environment-file syntax variant is newer than this Compose adapter",
1083 sourced.origins(),
1084 );
1085 continue;
1086 }
1087 };
1088 match value {
1089 Ok(value) => {
1090 generated.add_environment_file(value);
1091 self.exact(subject, sourced.origins());
1092 }
1093 Err(error) => self.generation_error(&subject, &error, sourced.origins()),
1094 }
1095 }
1096 }
1097
1098 fn map_labels(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1099 for label in service.labels() {
1100 let name = label.value().name().as_str();
1101 let subject = format!("{service_subject}.labels.{name}");
1102 if is_compose_managed_label(name) {
1103 self.unsupported(
1104 &subject,
1105 "Compose-managed labels cannot be safely re-authored as application metadata",
1106 label.origins(),
1107 );
1108 continue;
1109 }
1110 match generated_string(label.value().value())
1111 .and_then(|value| GeneratedLabel::new(name, value))
1112 .and_then(|value| generated.add_label(value))
1113 {
1114 Ok(()) => self.exact(subject, label.origins()),
1115 Err(error) => self.generation_error(&subject, &error, label.origins()),
1116 }
1117 }
1118 }
1119
1120 fn map_host_mappings(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1121 for (index, host) in service.host_mappings().iter().enumerate() {
1122 let subject = format!("{service_subject}.extra_hosts[{index}]");
1123 match GeneratedExtraHost::new(host.value().hostname().as_str(), host.value().address().raw()) {
1124 Ok(value) => {
1125 generated.add_extra_host(value);
1126 if host.value().address().kind() == HostAddressKind::HostGateway {
1127 self.compatibility(&subject, CompatibilityFeature::HostGatewayToken, host.origins());
1128 } else {
1129 self.exact(subject, host.origins());
1130 }
1131 }
1132 Err(error) => self.generation_error(&subject, &error, host.origins()),
1133 }
1134 }
1135 }
1136
1137 fn map_ports(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1138 for (index, port) in service.ports().iter().enumerate() {
1139 let subject = format!("{service_subject}.ports[{index}]");
1140 let protocol = match port.value().protocol() {
1141 Protocol::Tcp => GeneratedProtocol::Tcp,
1142 Protocol::Udp => GeneratedProtocol::Udp,
1143 Protocol::Sctp => GeneratedProtocol::Sctp,
1144 Protocol::Other(protocol) => {
1145 self.unsupported(
1146 &subject,
1147 &format!("Compose generation does not support protocol `{protocol}`"),
1148 port.origins(),
1149 );
1150 continue;
1151 }
1152 _ => {
1153 self.unsupported(
1154 &subject,
1155 "port protocol variant is newer than this Compose adapter",
1156 port.origins(),
1157 );
1158 continue;
1159 }
1160 };
1161 match GeneratedPort::new(
1162 port.value().container(),
1163 port.value().published(),
1164 port.value().host_address().map(str::to_owned),
1165 protocol,
1166 ) {
1167 Ok(value) => {
1168 generated.add_port(value);
1169 if matches!(port.value().protocol(), Protocol::Sctp) {
1170 self.unsupported(
1171 &subject,
1172 "SCTP syntax is preserved, but the selected provider/runtime pair has no reviewed compatibility evidence",
1173 port.origins(),
1174 );
1175 } else {
1176 self.exact(subject, port.origins());
1177 }
1178 }
1179 Err(error) => self.generation_error(&subject, &error, port.origins()),
1180 }
1181 }
1182 }
1183
1184 fn map_mounts(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1185 for (index, mount) in service.mounts().iter().enumerate() {
1186 let subject = format!("{service_subject}.mounts[{index}]");
1187 let selinux = match mount.value().selinux_relabel() {
1188 Some(SelinuxRelabel::Shared) => Some(GeneratedSelinux::Shared),
1189 Some(SelinuxRelabel::Private) => Some(GeneratedSelinux::Private),
1190 Some(_) => {
1191 self.unsupported(
1192 &subject,
1193 "SELinux relabel variant is newer than this Compose adapter",
1194 mount.origins(),
1195 );
1196 continue;
1197 }
1198 None => None,
1199 };
1200 let value = match mount.value().source() {
1201 MountSource::Volume(source) => {
1202 GeneratedMount::volume(source.as_str(), mount.value().target(), mount.value().read_only())
1203 }
1204 MountSource::HostPath(source) => {
1205 GeneratedMount::bind(source, mount.value().target(), mount.value().read_only(), selinux)
1206 }
1207 MountSource::Anonymous => GeneratedMount::anonymous(mount.value().target(), mount.value().read_only()),
1208 _ => {
1209 self.unsupported(
1210 &subject,
1211 "mount source variant is newer than this Compose adapter",
1212 mount.origins(),
1213 );
1214 continue;
1215 }
1216 };
1217 match value {
1218 Ok(value) => {
1219 generated.add_mount(value);
1220 if mount.value().selinux_relabel().is_some() {
1221 self.compatibility(&subject, CompatibilityFeature::ShortBindSelinuxRelabel, mount.origins());
1222 } else {
1223 self.exact(subject, mount.origins());
1224 }
1225 }
1226 Err(error) => self.generation_error(&subject, &error, mount.origins()),
1227 }
1228 }
1229 }
1230
1231 fn map_network_attachments(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1232 for network in service.networks() {
1233 let subject = format!("{service_subject}.networks.{}", network.value().network().as_str());
1234 match GeneratedNetworkAttachment::new(network.value().network().as_str()) {
1235 Ok(mut value) => {
1236 let mut valid = true;
1237 for (index, alias) in network.value().aliases().iter().enumerate() {
1238 if network
1239 .value()
1240 .alias_sensitivities()
1241 .get(index)
1242 .copied()
1243 .unwrap_or(false)
1244 {
1245 self.unsupported(
1246 &subject,
1247 "sensitive network aliases cannot be passed to ComposeLens' plain-string generator",
1248 network.origins(),
1249 );
1250 valid = false;
1251 break;
1252 }
1253 if let Err(error) = value.add_alias(alias) {
1254 self.generation_error(&subject, &error, network.origins());
1255 valid = false;
1256 break;
1257 }
1258 }
1259 if let Some(address) = network.value().ipv4_address() {
1260 if let Err(error) =
1261 generated_string(address.value()).and_then(|address| value.set_ipv4_address(address))
1262 {
1263 self.generation_error(&subject, &error, address.origins());
1264 valid = false;
1265 }
1266 }
1267 if let Some(address) = network.value().ipv6_address() {
1268 if let Err(error) =
1269 generated_string(address.value()).and_then(|address| value.set_ipv6_address(address))
1270 {
1271 self.generation_error(&subject, &error, address.origins());
1272 valid = false;
1273 }
1274 }
1275 if valid {
1276 match generated.add_network(value) {
1277 Ok(()) => self.exact(subject, network.origins()),
1278 Err(error) => self.generation_error(&subject, &error, network.origins()),
1279 }
1280 }
1281 }
1282 Err(error) => self.generation_error(&subject, &error, network.origins()),
1283 }
1284 }
1285 }
1286
1287 fn map_released_container_settings(
1288 &mut self,
1289 service: &Service,
1290 generated: &mut GeneratedService,
1291 service_subject: &str,
1292 ) {
1293 self.map_entrypoint_lifecycle_and_pull(service, generated, service_subject);
1294 self.map_hostname_pids_shm_memory(service, generated, service_subject);
1295 self.map_collections(service, generated, service_subject);
1296 self.map_annotations_logging_and_reload(service, generated, service_subject);
1297 }
1298
1299 fn map_entrypoint_lifecycle_and_pull(
1300 &mut self,
1301 service: &Service,
1302 generated: &mut GeneratedService,
1303 subject: &str,
1304 ) {
1305 if let Some(entrypoint) = service.entrypoint() {
1306 let field = format!("{subject}.entrypoint");
1307 let value = match entrypoint.value() {
1308 Entrypoint::Exec(values) => values
1309 .iter()
1310 .map(generated_string)
1311 .collect::<Result<Vec<_>, _>>()
1312 .map(GeneratedEntrypoint::List),
1313 Entrypoint::Shell(value) => generated_string(value).map(GeneratedEntrypoint::String),
1314 Entrypoint::Empty => Ok(GeneratedEntrypoint::Empty),
1315 _ => {
1316 self.unsupported(
1317 &field,
1318 "entrypoint variant is newer than this Compose adapter",
1319 entrypoint.origins(),
1320 );
1321 return;
1322 }
1323 };
1324 match value.and_then(|value| generated.set_entrypoint(value)) {
1325 Ok(()) => self.exact(field, entrypoint.origins()),
1326 Err(error) => self.generation_error(&field, &error, entrypoint.origins()),
1327 }
1328 }
1329 if let Some(init) = service.run_init() {
1330 let field = format!("{subject}.init");
1331 match generated.set_init(*init.value()) {
1332 Ok(()) => self.exact(field, init.origins()),
1333 Err(error) => self.generation_error(&field, &error, init.origins()),
1334 }
1335 }
1336 if let Some(timeout) = service.stop_timeout() {
1337 let field = format!("{subject}.stop_grace_period");
1338 match generated_string(&ProtectedString::plain(timeout.value().as_str()))
1339 .and_then(|value| generated.set_stop_grace_period(value))
1340 {
1341 Ok(()) => self.exact(field, timeout.origins()),
1342 Err(error) => self.generation_error(&field, &error, timeout.origins()),
1343 }
1344 }
1345 if let Some(policy) = service.pull_policy() {
1346 let field = format!("{subject}.pull_policy");
1347 let value = match policy.value() {
1348 PullPolicy::Always => Ok(GeneratedPullPolicy::Always),
1349 PullPolicy::Missing => Ok(GeneratedPullPolicy::Missing),
1350 PullPolicy::Never => Ok(GeneratedPullPolicy::Never),
1351 PullPolicy::IfNotPresent => Ok(GeneratedPullPolicy::IfNotPresentAlias),
1352 PullPolicy::Build => Ok(GeneratedPullPolicy::Build),
1353 PullPolicy::Daily => Ok(GeneratedPullPolicy::Daily),
1354 PullPolicy::Weekly => Ok(GeneratedPullPolicy::Weekly),
1355 PullPolicy::Every(value) => generated_string(value).map(GeneratedPullPolicy::Every),
1356 PullPolicy::Raw(_) => {
1357 self.unsupported(
1358 &field,
1359 "provider-specific pull policy cannot be silently normalized for Compose output",
1360 policy.origins(),
1361 );
1362 return;
1363 }
1364 _ => {
1365 self.unsupported(
1366 &field,
1367 "pull-policy variant is newer than this Compose adapter",
1368 policy.origins(),
1369 );
1370 return;
1371 }
1372 };
1373 match value.and_then(|value| generated.set_pull_policy(value)) {
1374 Ok(()) => self.exact(field, policy.origins()),
1375 Err(error) => self.generation_error(&field, &error, policy.origins()),
1376 }
1377 }
1378 }
1379
1380 fn map_hostname_pids_shm_memory(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1381 if let Some(value) = service.hostname() {
1382 let field = format!("{subject}.hostname");
1383 match generated_string(value.value())
1384 .map(GeneratedHostname::Resolved)
1385 .and_then(|value| generated.set_hostname(value))
1386 {
1387 Ok(()) => self.exact(field, value.origins()),
1388 Err(error) => self.generation_error(&field, &error, value.origins()),
1389 }
1390 }
1391 if let Some(value) = service.pids_limit() {
1392 let field = format!("{subject}.pids_limit");
1393 let limit = if value.value().expose() == "-1" {
1394 GeneratedPidsLimit::Unlimited
1395 } else {
1396 GeneratedPidsLimit::Finite(value.value().expose().to_owned())
1397 };
1398 match generated.set_pids_limit(limit) {
1399 Ok(()) => self.exact(field, value.origins()),
1400 Err(error) => self.generation_error(&field, &error, value.origins()),
1401 }
1402 }
1403 if let Some(value) = service.shm_size() {
1404 self.map_sized_value(value.value(), value.origins(), &format!("{subject}.shm_size"), |size| {
1405 generated.set_shm_size(size)
1406 });
1407 }
1408 if let Some(value) = service.memory_limit() {
1409 self.map_memory_value(
1410 value.value(),
1411 value.origins(),
1412 &format!("{subject}.mem_limit"),
1413 |limit| generated.set_mem_limit(limit),
1414 );
1415 }
1416 }
1417
1418 fn map_sized_value<F>(&mut self, value: &ProtectedString, origins: &[Provenance], subject: &str, set: F)
1419 where
1420 F: FnOnce(GeneratedShmSize) -> Result<(), GenerationError>,
1421 {
1422 let Some((amount, unit)) = split_size(value.expose()) else {
1423 self.unsupported(
1424 subject,
1425 "shared-memory size requires a documented lowercase unit",
1426 origins,
1427 );
1428 return;
1429 };
1430 let amount = if value.is_sensitive() {
1431 GeneratedString::sensitive(amount)
1432 } else {
1433 GeneratedString::plain(amount)
1434 };
1435 let Ok(amount) = amount else {
1436 self.unsupported(subject, "invalid shared-memory size", origins);
1437 return;
1438 };
1439 match set(GeneratedShmSize::Explicit { amount, unit }) {
1440 Ok(()) => self.exact(subject, origins),
1441 Err(error) => self.generation_error(subject, &error, origins),
1442 }
1443 }
1444
1445 fn map_memory_value<F>(&mut self, value: &ProtectedString, origins: &[Provenance], subject: &str, set: F)
1446 where
1447 F: FnOnce(GeneratedMemLimit) -> Result<(), GenerationError>,
1448 {
1449 let Some((amount, unit)) = split_memory(value.expose()) else {
1450 self.unsupported(subject, "memory limit requires a documented lowercase unit", origins);
1451 return;
1452 };
1453 let amount = if value.is_sensitive() {
1454 GeneratedString::sensitive(amount)
1455 } else {
1456 GeneratedString::plain(amount)
1457 };
1458 match amount.and_then(|amount| set(GeneratedMemLimit::Explicit { amount, unit })) {
1459 Ok(()) => self.exact(subject, origins),
1460 Err(error) => self.generation_error(subject, &error, origins),
1461 }
1462 }
1463
1464 fn map_collections(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1465 self.map_capabilities(service, generated, subject);
1466 self.map_resource_collections(service, generated, subject);
1467 self.map_devices_signals_and_expose(service, generated, subject);
1468 }
1469
1470 fn map_capabilities(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1471 for (name, values, origins, set) in [
1472 (
1473 "cap_add",
1474 service.cap_add(),
1475 service.cap_add_origins(),
1476 GeneratedService::set_cap_add as fn(&mut GeneratedService, Vec<GeneratedString>) -> _,
1477 ),
1478 (
1479 "cap_drop",
1480 service.cap_drop(),
1481 service.cap_drop_origins(),
1482 GeneratedService::set_cap_drop as fn(&mut GeneratedService, Vec<GeneratedString>) -> _,
1483 ),
1484 ] {
1485 if let Some(values) = values {
1486 let field = format!("{subject}.{name}");
1487 let all_origins = collection_or_item_origins(values, origins);
1488 match values
1489 .iter()
1490 .map(|value| generated_string(value.value()))
1491 .collect::<Result<Vec<_>, _>>()
1492 .and_then(|values| set(generated, values))
1493 {
1494 Ok(()) => self.exact(field, &all_origins),
1495 Err(error) => self.generation_error(&field, &error, &all_origins),
1496 }
1497 }
1498 }
1499 }
1500
1501 fn map_resource_collections(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1502 if let Some(values) = service.tmpfs() {
1503 let field = format!("{subject}.tmpfs");
1504 let origins = collection_or_item_origins(values, service.tmpfs_origins());
1505 match values
1506 .iter()
1507 .map(|value| generated_string(value.value()))
1508 .collect::<Result<Vec<_>, _>>()
1509 .and_then(|values| generated.set_tmpfs(GeneratedTmpfs::List(values)))
1510 {
1511 Ok(()) => self.exact(field, &origins),
1512 Err(error) => self.generation_error(&field, &error, &origins),
1513 }
1514 }
1515 if let Some(values) = service.sysctls() {
1516 let field = format!("{subject}.sysctls");
1517 let origins = collection_or_item_origins(values, service.sysctls_origins());
1518 let values = values
1519 .iter()
1520 .map(|value| {
1521 GeneratedSysctl::new(value.value().name().expose(), generated_string(value.value().value())?)
1522 })
1523 .collect::<Result<Vec<_>, _>>();
1524 match values.and_then(|values| generated.set_sysctls(GeneratedSysctls::Map(values))) {
1525 Ok(()) => self.exact(field, &origins),
1526 Err(error) => self.generation_error(&field, &error, &origins),
1527 }
1528 }
1529 if let Some(values) = service.ulimits() {
1530 let field = format!("{subject}.ulimits");
1531 let origins = resource_limit_collection_origins(values, service.ulimits_origins());
1532 let values = values
1533 .iter()
1534 .map(|value| {
1535 let limit = value.value();
1536 match (limit.soft(), limit.hard()) {
1537 (Some(soft), Some(hard)) if soft.value().expose() == hard.value().expose() => {
1538 GeneratedUlimit::single(limit.name().expose(), generated_string(soft.value())?)
1539 }
1540 (Some(soft), Some(hard)) => GeneratedUlimit::range(
1541 limit.name().expose(),
1542 generated_string(soft.value())?,
1543 generated_string(hard.value())?,
1544 ),
1545 _ => Err(GenerationError::MissingUlimitRangeMember("soft/hard")),
1546 }
1547 })
1548 .collect::<Result<Vec<_>, _>>();
1549 match values
1550 .and_then(GeneratedUlimits::new)
1551 .and_then(|values| generated.set_ulimits(values))
1552 {
1553 Ok(()) => self.exact(field, &origins),
1554 Err(error) => self.generation_error(&field, &error, &origins),
1555 }
1556 }
1557 }
1558
1559 fn map_devices_signals_and_expose(&mut self, service: &Service, generated: &mut GeneratedService, subject: &str) {
1560 if let Some(values) = service.devices() {
1561 let field = format!("{subject}.devices");
1562 let origins = device_collection_origins(values, service.devices_origins());
1563 let values = values
1564 .iter()
1565 .map(|value| match value.value() {
1566 Device::Short(value) => generated_string(value).map(GeneratedDevice::Short),
1567 Device::Long {
1568 source,
1569 target,
1570 permissions,
1571 } => {
1572 let source = source.as_ref().ok_or(GenerationError::InvalidDeviceValue("source"))?;
1573 GeneratedLongDevice::new(
1574 generated_string(source.value())?,
1575 target
1576 .as_ref()
1577 .map(|value| generated_string(value.value()))
1578 .transpose()?,
1579 permissions
1580 .as_ref()
1581 .map(|value| generated_string(value.value()))
1582 .transpose()?,
1583 )
1584 .map(GeneratedDevice::Long)
1585 }
1586 _ => Err(GenerationError::InvalidDeviceValue("variant")),
1587 })
1588 .collect::<Result<Vec<_>, _>>();
1589 match values.and_then(|values| generated.set_devices(values)) {
1590 Ok(()) => self.exact(field, &origins),
1591 Err(error) => self.generation_error(&field, &error, &origins),
1592 }
1593 }
1594 if let Some(value) = service.stop_signal() {
1595 let field = format!("{subject}.stop_signal");
1596 match generated_string(value.value()).and_then(|value| generated.set_stop_signal(value)) {
1597 Ok(()) => self.exact(field, value.origins()),
1598 Err(error) => self.generation_error(&field, &error, value.origins()),
1599 }
1600 }
1601 if let Some(values) = service.exposed_ports() {
1602 let field = format!("{subject}.expose");
1603 let origins = collection_or_item_origins(values, service.exposed_ports_origins());
1604 let values = values
1605 .iter()
1606 .map(|value| {
1607 GeneratedString::plain(format!(
1608 "{}/{}",
1609 value.value().container(),
1610 match value.value().protocol() {
1611 Protocol::Tcp => "tcp",
1612 Protocol::Udp => "udp",
1613 _ => "unsupported",
1614 }
1615 ))
1616 })
1617 .collect::<Result<Vec<_>, _>>();
1618 match values.and_then(|values| generated.set_expose(values)) {
1619 Ok(()) => self.exact(field, &origins),
1620 Err(error) => self.generation_error(&field, &error, &origins),
1621 }
1622 }
1623 }
1624
1625 fn map_annotations_logging_and_reload(
1626 &mut self,
1627 service: &Service,
1628 generated: &mut GeneratedService,
1629 subject: &str,
1630 ) {
1631 if let Some(values) = service.annotations() {
1632 let field = format!("{subject}.annotations");
1633 let origins = collection_or_item_origins(values, service.annotations_origins());
1634 let values = values
1635 .iter()
1636 .map(|value| {
1637 GeneratedAnnotation::new(
1638 value.value().name().value().as_str(),
1639 generated_string(value.value().value().value())?,
1640 )
1641 })
1642 .collect::<Result<Vec<_>, _>>();
1643 match values.and_then(|values| generated.set_annotations(values)) {
1644 Ok(()) => self.exact(field, &origins),
1645 Err(error) => self.generation_error(&field, &error, &origins),
1646 }
1647 }
1648 if let Some(logging) = service.logging() {
1649 let field = format!("{subject}.logging");
1650 let value = logging.value();
1651 let Some(driver) = value.driver() else {
1652 self.unsupported(
1653 &field,
1654 "Compose logging requires a driver; neutral logging retains its absence",
1655 logging.origins(),
1656 );
1657 return;
1658 };
1659 let options = value
1660 .options()
1661 .unwrap_or_default()
1662 .iter()
1663 .map(|option| {
1664 GeneratedLoggingOption::new(
1665 option.value().name().value().as_str(),
1666 GeneratedLoggingOptionValue::String(generated_string(option.value().value().value())?),
1667 )
1668 })
1669 .collect::<Result<Vec<_>, _>>();
1670 match generated_string(driver.value())
1671 .and_then(|driver| options.and_then(|options| GeneratedLogging::new(driver, options)))
1672 .and_then(|value| generated.set_logging(value))
1673 {
1674 Ok(()) if value.options().is_some() => self.exact(field, logging.origins()),
1675 Ok(()) => self.loss(
1676 self.exporter.codes.unsupported.clone(),
1677 &field,
1678 ConversionKind::Approximate,
1679 "generated Compose logging adds an explicit empty options mapping",
1680 "neutral logging omitted options, but ComposeLens GeneratedLogging requires an options collection",
1681 logging.origins(),
1682 ),
1683 Err(error) => self.generation_error(&field, &error, logging.origins()),
1684 }
1685 }
1686 if let Some(reload) = service.reload_action() {
1687 self.unsupported(
1688 &format!("{subject}.reload_action"),
1689 "Compose has no reload command or signal field; reload intent is never emitted as a lifecycle hook",
1690 reload.origins(),
1691 );
1692 }
1693 }
1694
1695 fn report_unimplemented_service_fields(&mut self, service: &Service, service_subject: &str) {
1696 if let Some(healthcheck) = service.healthcheck() {
1697 self.unsupported(
1698 &format!("{service_subject}.healthcheck"),
1699 "the current Compose generation boundary does not yet expose health-check fields",
1700 healthcheck.origins(),
1701 );
1702 }
1703 for (index, dependency) in service.dependencies().iter().enumerate() {
1704 self.unsupported(
1705 &format!("{service_subject}.dependencies[{index}]"),
1706 "the current Compose generation boundary does not yet expose service dependencies",
1707 dependency.origins(),
1708 );
1709 }
1710 for (index, grant) in service.config_grants().iter().enumerate() {
1711 self.unsupported(
1712 &format!("{service_subject}.config_grants[{index}]"),
1713 "the current Compose generation boundary does not yet expose service config grants",
1714 grant.origins(),
1715 );
1716 }
1717 for (index, grant) in service.secret_grants().iter().enumerate() {
1718 self.unsupported(
1719 &format!("{service_subject}.secret_grants[{index}]"),
1720 "the current Compose generation boundary does not yet expose service secret grants",
1721 grant.origins(),
1722 );
1723 }
1724 }
1725
1726 fn report_compose_native_only_service_fields(&mut self, service: &Service, service_subject: &str) {
1727 if let Some(notification) = service.startup_notification() {
1728 self.unsupported(
1729 &format!("{service_subject}.startup_notification"),
1730 "Compose has no service startup-notification field",
1731 notification.origins(),
1732 );
1733 }
1734 if let Some(rootfs) = service.rootfs() {
1735 self.unsupported(
1736 &format!("{service_subject}.rootfs"),
1737 "Compose has no root-filesystem source field; rootfs is never rewritten as an image",
1738 rootfs.origins(),
1739 );
1740 }
1741 if let Some(arguments) = service.podman_args() {
1742 for (index, argument) in arguments.iter().enumerate() {
1743 self.unsupported(
1744 &format!("{service_subject}.podman_args[{index}]"),
1745 "Compose has no native Podman-argument field; authored arguments are never synthesized",
1746 argument.origins(),
1747 );
1748 }
1749 if arguments.is_empty() {
1750 self.unsupported(
1751 &format!("{service_subject}.podman_args"),
1752 "Compose has no native Podman-argument field; authored arguments are never synthesized",
1753 service.podman_args_origins(),
1754 );
1755 }
1756 }
1757 }
1758
1759 fn report_service_group_loss(&mut self, group: &Sourced<boxferry_model::ServiceGroup>) {
1760 let group_subject = format!("service_groups.{}", group.value().name().as_str());
1761 self.unsupported(
1762 &group_subject,
1763 "Compose has no native structural equivalent for a runtime pod or shared namespace group",
1764 group.origins(),
1765 );
1766 let Some(runtime) = group.value().runtime() else {
1767 return;
1768 };
1769 let runtime_subject = format!("{group_subject}.runtime");
1770 self.unsupported(
1771 &runtime_subject,
1772 "Compose has no native service-group runtime; it is never assigned to a member service",
1773 runtime.origins(),
1774 );
1775 let runtime = runtime.value();
1776 if let Some(value) = runtime.runtime_name() {
1777 self.unsupported(
1778 &format!("{runtime_subject}.runtime_name"),
1779 "Compose has no native service-group runtime name",
1780 value.origins(),
1781 );
1782 }
1783 if let Some(value) = runtime.service_name() {
1784 self.unsupported(
1785 &format!("{runtime_subject}.service_name"),
1786 "Compose has no native service-group systemd service name",
1787 value.origins(),
1788 );
1789 }
1790 self.report_group_runtime_collection(
1791 &format!("{runtime_subject}.host_mappings"),
1792 runtime.host_mappings(),
1793 runtime.host_mappings_origins(),
1794 );
1795 self.report_group_runtime_collection(
1796 &format!("{runtime_subject}.ports"),
1797 runtime.ports(),
1798 runtime.ports_origins(),
1799 );
1800 self.report_group_runtime_collection(
1801 &format!("{runtime_subject}.networks"),
1802 runtime.networks(),
1803 runtime.networks_origins(),
1804 );
1805 if let Some(value) = runtime.user_namespace() {
1806 self.unsupported(
1807 &format!("{runtime_subject}.user_namespace"),
1808 "Compose has no native service-group user-namespace field",
1809 value.origins(),
1810 );
1811 }
1812 self.report_group_runtime_collection(
1813 &format!("{runtime_subject}.mounts"),
1814 runtime.mounts(),
1815 runtime.mounts_origins(),
1816 );
1817 if let Some(value) = runtime.shm_size() {
1818 self.unsupported(
1819 &format!("{runtime_subject}.shm_size"),
1820 "Compose has no native service-group shared-memory-size field",
1821 value.origins(),
1822 );
1823 }
1824 if let Some(value) = runtime.exit_policy() {
1825 self.unsupported(
1826 &format!("{runtime_subject}.exit_policy"),
1827 "Compose has no native service-group exit-policy field",
1828 value.origins(),
1829 );
1830 }
1831 if let Some(value) = runtime.stop_timeout() {
1832 self.unsupported(
1833 &format!("{runtime_subject}.stop_timeout"),
1834 "Compose has no native service-group stop-timeout field",
1835 value.origins(),
1836 );
1837 }
1838 }
1839
1840 fn report_group_runtime_collection<T>(
1841 &mut self,
1842 subject: &str,
1843 values: Option<&[Sourced<T>]>,
1844 collection_origins: &[Provenance],
1845 ) {
1846 let Some(values) = values else {
1847 return;
1848 };
1849 if values.is_empty() {
1850 self.unsupported(
1851 subject,
1852 "Compose has no native service-group runtime collection; it is never assigned to a member service",
1853 collection_origins,
1854 );
1855 return;
1856 }
1857 for (index, value) in values.iter().enumerate() {
1858 self.unsupported(
1859 &format!("{subject}[{index}]"),
1860 "Compose has no native service-group runtime collection; it is never assigned to a member service",
1861 value.origins(),
1862 );
1863 }
1864 }
1865
1866 fn map_identity(&mut self, service: &Service, generated: &mut GeneratedService, service_subject: &str) {
1867 match (service.user(), service.group()) {
1868 (Some(user), group) => {
1869 let mut value = user.value().expose().to_owned();
1870 let mut sensitive = user.value().is_sensitive();
1871 let mut origins = user.origins().to_vec();
1872 if let Some(group) = group {
1873 value.push(':');
1874 value.push_str(group.value().expose());
1875 sensitive |= group.value().is_sensitive();
1876 origins.extend_from_slice(group.origins());
1877 }
1878 let value = if sensitive {
1879 GeneratedString::sensitive(value)
1880 } else {
1881 GeneratedString::plain(value)
1882 };
1883 match value.and_then(|value| generated.set_user(value)) {
1884 Ok(()) => self.exact(format!("{service_subject}.user"), &origins),
1885 Err(error) => self.generation_error(&format!("{service_subject}.user"), &error, &origins),
1886 }
1887 }
1888 (None, Some(group)) => self.unsupported(
1889 &format!("{service_subject}.group"),
1890 "Compose combines user and primary group in one field and cannot encode a group without a user",
1891 group.origins(),
1892 ),
1893 (None, None) => {}
1894 }
1895
1896 if let Some(user_namespace) = service.user_namespace() {
1897 let subject = format!("{service_subject}.user_namespace");
1898 match generated_string(user_namespace.value()).and_then(|value| generated.set_userns_mode(value)) {
1899 Ok(()) if is_podman_user_namespace(user_namespace.value().expose()) => self.compatibility(
1900 &subject,
1901 CompatibilityFeature::PodmanUserNamespaceMode,
1902 user_namespace.origins(),
1903 ),
1904 Ok(()) => self.exact(subject, user_namespace.origins()),
1905 Err(error) => self.generation_error(&subject, &error, user_namespace.origins()),
1906 }
1907 }
1908 for (index, group) in service.supplementary_groups().iter().enumerate() {
1909 let subject = format!("{service_subject}.supplementary_groups[{index}]");
1910 match generated_string(group.value()).and_then(|value| generated.add_supplementary_group(value)) {
1911 Ok(()) => self.exact(subject, group.origins()),
1912 Err(error) => self.generation_error(&subject, &error, group.origins()),
1913 }
1914 }
1915 }
1916
1917 fn compatibility(&mut self, subject: &str, feature: CompatibilityFeature, origins: &[Provenance]) {
1918 let Some(profile) = self.compatibility else {
1919 self.invalid(
1920 self.exporter.codes.invalid_target.clone(),
1921 subject,
1922 "Compose compatibility profile is unavailable",
1923 "validate the target before mapping application fields",
1924 origins,
1925 );
1926 return;
1927 };
1928 let rule = profile.classify(feature);
1929 let evidence: Vec<_> = rule.evidence().iter().map(|evidence| evidence.source()).collect();
1930 match rule.classification() {
1931 CompatibilityClassification::Supported | CompatibilityClassification::Extension => {
1932 self.exact(subject, origins);
1933 }
1934 CompatibilityClassification::ImplementationSpecific | CompatibilityClassification::Deprecated => {
1935 self.compatibility_loss(
1936 subject,
1937 ConversionKind::Approximate,
1938 rule.classification(),
1939 rule.explanation(),
1940 &evidence,
1941 origins,
1942 );
1943 }
1944 CompatibilityClassification::Unsupported | CompatibilityClassification::Unknown => {
1945 self.compatibility_loss(
1946 subject,
1947 ConversionKind::Unsupported,
1948 rule.classification(),
1949 rule.explanation(),
1950 &evidence,
1951 origins,
1952 );
1953 }
1954 _ => self.compatibility_loss(
1955 subject,
1956 ConversionKind::Unsupported,
1957 rule.classification(),
1958 rule.explanation(),
1959 &evidence,
1960 origins,
1961 ),
1962 }
1963 }
1964
1965 fn compatibility_loss(
1966 &mut self,
1967 subject: &str,
1968 kind: ConversionKind,
1969 classification: CompatibilityClassification,
1970 reason: &str,
1971 evidence: &[&str],
1972 origins: &[Provenance],
1973 ) {
1974 let classification = format!("{classification:?}").to_ascii_lowercase();
1975 let mut diagnostic = Diagnostic::new(
1976 self.exporter.codes.compatibility.clone(),
1977 Severity::Warning,
1978 "generated Compose construct has a target-specific compatibility constraint",
1979 )
1980 .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject)))
1981 .with_field(DiagnosticField::new(
1982 "classification",
1983 DiagnosticValue::plain(classification),
1984 ))
1985 .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(reason)));
1986 if !evidence.is_empty() {
1987 diagnostic = diagnostic.with_field(DiagnosticField::new(
1988 "evidence",
1989 DiagnosticValue::plain(evidence.join(",")),
1990 ));
1991 }
1992 self.diagnostics.push(diagnostic);
1993 self.push_loss(subject, kind, self.exporter.codes.compatibility.clone(), origins);
1994 }
1995
1996 fn exact(&mut self, subject: impl Into<String>, origins: &[Provenance]) {
1997 let mut outcome = ConversionOutcome::exact(subject);
1998 for origin in origins {
1999 outcome = outcome.with_origin(origin.clone());
2000 }
2001 self.outcomes.push(outcome);
2002 }
2003
2004 fn unsupported(&mut self, subject: &str, reason: &str, origins: &[Provenance]) {
2005 self.loss(
2006 self.exporter.codes.unsupported.clone(),
2007 subject,
2008 ConversionKind::Unsupported,
2009 "neutral application intent is not represented in generated Compose output",
2010 reason,
2011 origins,
2012 );
2013 }
2014
2015 fn generation_error(&mut self, subject: &str, error: &GenerationError, origins: &[Provenance]) {
2016 self.generation_failed = true;
2017 self.invalid(
2018 self.exporter.codes.generation.clone(),
2019 subject,
2020 "Compose document generation failed",
2021 &error.to_string(),
2022 origins,
2023 );
2024 }
2025
2026 fn invalid(&mut self, code: DiagnosticCode, subject: &str, summary: &str, reason: &str, origins: &[Provenance]) {
2027 self.loss(code, subject, ConversionKind::Invalid, summary, reason, origins);
2028 }
2029
2030 fn loss(
2031 &mut self,
2032 code: DiagnosticCode,
2033 subject: &str,
2034 kind: ConversionKind,
2035 summary: &str,
2036 reason: &str,
2037 origins: &[Provenance],
2038 ) {
2039 let severity = if kind == ConversionKind::Invalid {
2040 Severity::Error
2041 } else {
2042 Severity::Warning
2043 };
2044 self.diagnostics.push(
2045 Diagnostic::new(code.clone(), severity, summary)
2046 .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject)))
2047 .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(reason))),
2048 );
2049 self.push_loss(subject, kind, code, origins);
2050 }
2051
2052 fn push_loss(&mut self, subject: &str, kind: ConversionKind, code: DiagnosticCode, origins: &[Provenance]) {
2053 if let Ok(mut outcome) = ConversionOutcome::loss(subject, kind, code) {
2054 for origin in origins {
2055 outcome = outcome.with_origin(origin.clone());
2056 }
2057 self.outcomes.push(outcome);
2058 }
2059 }
2060
2061 fn finish(
2062 mut self,
2063 ) -> (
2064 Option<GeneratedComposeDocument>,
2065 Vec<ConversionOutcome>,
2066 Vec<Diagnostic>,
2067 ) {
2068 let candidate = if self.compatibility.is_some() && !self.generation_failed {
2069 match self.builder.take().unwrap_or_default().build(SourceId::new(1)) {
2070 Ok(document) => Some(document),
2071 Err(error) => {
2072 self.generation_error("application", &error, &[]);
2073 None
2074 }
2075 }
2076 } else {
2077 None
2078 };
2079 (candidate, self.outcomes, self.diagnostics)
2080 }
2081}
2082
2083fn generated_string(value: &ProtectedString) -> Result<GeneratedString, GenerationError> {
2084 if value.is_sensitive() {
2085 GeneratedString::sensitive(value.expose())
2086 } else {
2087 GeneratedString::plain(value.expose())
2088 }
2089}
2090
2091fn split_size(value: &str) -> Option<(&str, ShmSizeUnit)> {
2092 for (suffix, unit) in [
2093 ("b", ShmSizeUnit::B),
2094 ("k", ShmSizeUnit::K),
2095 ("m", ShmSizeUnit::M),
2096 ("g", ShmSizeUnit::G),
2097 ] {
2098 if let Some(amount) = value.strip_suffix(suffix) {
2099 return Some((amount, unit));
2100 }
2101 }
2102 None
2103}
2104
2105fn split_memory(value: &str) -> Option<(&str, MemLimitUnit)> {
2106 for (suffix, unit) in [
2107 ("kb", MemLimitUnit::Kb),
2108 ("mb", MemLimitUnit::Mb),
2109 ("gb", MemLimitUnit::Gb),
2110 ("b", MemLimitUnit::B),
2111 ("k", MemLimitUnit::K),
2112 ("m", MemLimitUnit::M),
2113 ("g", MemLimitUnit::G),
2114 ] {
2115 if let Some(amount) = value.strip_suffix(suffix) {
2116 return Some((amount, unit));
2117 }
2118 }
2119 None
2120}
2121
2122enum SecurityOptionGeneration {
2123 Generated(GeneratedString),
2124 Unsupported(&'static str),
2125}
2126
2127fn security_option_string(option: &SecurityOption) -> Result<SecurityOptionGeneration, GenerationError> {
2128 Ok(match option {
2129 SecurityOption::AppArmor(profile) => {
2130 SecurityOptionGeneration::Generated(prefixed_generated_string("apparmor=", profile)?)
2131 }
2132 SecurityOption::NoNewPrivileges(enabled) => {
2133 SecurityOptionGeneration::Generated(GeneratedString::plain(format!("no-new-privileges:{enabled}"))?)
2134 }
2135 SecurityOption::SeccompProfile(profile) => {
2136 SecurityOptionGeneration::Generated(prefixed_generated_string("seccomp=", profile)?)
2137 }
2138 SecurityOption::SecurityLabelDisable(enabled) => {
2139 if *enabled {
2140 SecurityOptionGeneration::Generated(GeneratedString::plain("label:disable")?)
2141 } else {
2142 SecurityOptionGeneration::Unsupported(
2143 "Compose has no exact `security_opt` spelling that re-enables SELinux label separation",
2144 )
2145 }
2146 }
2147 SecurityOption::SecurityLabelFileType(file_type) => {
2148 SecurityOptionGeneration::Generated(prefixed_generated_string("label:filetype:", file_type)?)
2149 }
2150 SecurityOption::SecurityLabelLevel(level) => {
2151 SecurityOptionGeneration::Generated(prefixed_generated_string("label:level:", level)?)
2152 }
2153 SecurityOption::SecurityLabelNested(enabled) => {
2154 if *enabled {
2155 SecurityOptionGeneration::Generated(GeneratedString::plain("label:nested")?)
2156 } else {
2157 SecurityOptionGeneration::Unsupported(
2158 "Compose has no exact `security_opt` spelling that disables nested SELinux labeling",
2159 )
2160 }
2161 }
2162 SecurityOption::SecurityLabelType(label_type) => {
2163 SecurityOptionGeneration::Generated(prefixed_generated_string("label:type:", label_type)?)
2164 }
2165 SecurityOption::Mask(paths) => SecurityOptionGeneration::Generated(prefixed_generated_string("mask=", paths)?),
2166 SecurityOption::Unmask(paths) => {
2167 SecurityOptionGeneration::Generated(prefixed_generated_string("unmask=", paths)?)
2168 }
2169 _ => SecurityOptionGeneration::Unsupported("security-option variant is newer than this Compose adapter"),
2170 })
2171}
2172
2173fn prefixed_generated_string(prefix: &str, value: &ProtectedString) -> Result<GeneratedString, GenerationError> {
2174 let raw = format!("{prefix}{}", value.expose());
2175 if value.is_sensitive() {
2176 GeneratedString::sensitive(raw)
2177 } else {
2178 GeneratedString::plain(raw)
2179 }
2180}
2181
2182fn collection_or_item_origins<T>(values: &[Sourced<T>], collection_origins: &[Provenance]) -> Vec<Provenance> {
2183 let mut origins = collection_origins.to_vec();
2184 for value in values {
2185 extend_origins(&mut origins, value.origins());
2186 }
2187 origins
2188}
2189
2190fn resource_limit_collection_origins(
2191 values: &[Sourced<boxferry_model::ResourceLimit>],
2192 collection_origins: &[Provenance],
2193) -> Vec<Provenance> {
2194 let mut origins = collection_or_item_origins(values, collection_origins);
2195 for value in values {
2196 for nested in [value.value().soft(), value.value().hard()].into_iter().flatten() {
2197 extend_origins(&mut origins, nested.origins());
2198 }
2199 }
2200 origins
2201}
2202
2203fn device_collection_origins(values: &[Sourced<Device>], collection_origins: &[Provenance]) -> Vec<Provenance> {
2204 let mut origins = collection_or_item_origins(values, collection_origins);
2205 for value in values {
2206 if let Device::Long {
2207 source,
2208 target,
2209 permissions,
2210 } = value.value()
2211 {
2212 for nested in [source.as_ref(), target.as_ref(), permissions.as_ref()]
2213 .into_iter()
2214 .flatten()
2215 {
2216 extend_origins(&mut origins, nested.origins());
2217 }
2218 }
2219 }
2220 origins
2221}
2222
2223fn extend_origins(origins: &mut Vec<Provenance>, additional: &[Provenance]) {
2224 for origin in additional {
2225 if !origins.contains(origin) {
2226 origins.push(origin.clone());
2227 }
2228 }
2229}
2230
2231fn is_compose_managed_label(name: &str) -> bool {
2232 name.starts_with("com.docker.compose.")
2233}
2234
2235fn is_podman_user_namespace(value: &str) -> bool {
2236 ["keep-id", "auto", "nomap"]
2237 .iter()
2238 .any(|mode| value == *mode || value.strip_prefix(mode).is_some_and(|suffix| suffix.starts_with(':')))
2239}
2240
2241fn implementation_version(version: PlatformVersion) -> Option<ImplementationVersion> {
2242 Some(ImplementationVersion::new(
2243 u32::try_from(version.major()).ok()?,
2244 u32::try_from(version.minor()).ok()?,
2245 u32::try_from(version.patch()).ok()?,
2246 ))
2247}