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