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