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