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