1use std::collections::BTreeSet;
4
5use boxferry_engine::{
6 ConversionKind, ConversionOutcome, Diagnostic, DiagnosticCode, DiagnosticField, DiagnosticValue, ImportAdapter,
7 ImportResult, InvalidDiagnosticCode, Severity,
8};
9use boxferry_model::{
10 Application, Command, EnvironmentValue, EnvironmentVariable, Healthcheck, MetadataLabel, ModelError, MountSource,
11 Network, ProtectedString, Provenance, ResourceOwnership, Service, ServiceGroup, SourceId, Sourced, Volume,
12};
13
14use crate::{
15 ContainerObservation, EffectiveCommand, ImageObservation, PodObservation, RuntimeEnvironmentVariable,
16 RuntimeHealthcheck, RuntimeMetadataLabel, RuntimeResolutions, RuntimeSnapshot,
17};
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21#[non_exhaustive]
22pub enum OverrideReconstruction {
23 PreserveObservedState,
25 InferImageOverrides,
27}
28
29#[derive(Clone, Debug)]
36pub struct RuntimeImporter {
37 override_reconstruction: OverrideReconstruction,
38 resolutions: RuntimeResolutions,
39 codes: Codes,
40}
41
42impl RuntimeImporter {
43 pub fn new(override_reconstruction: OverrideReconstruction) -> Result<Self, InvalidDiagnosticCode> {
49 Ok(Self {
50 override_reconstruction,
51 resolutions: RuntimeResolutions::new(),
52 codes: Codes {
53 reconstruction_uncertain: DiagnosticCode::new("BFR0001")?,
54 inferred_override: DiagnosticCode::new("BFR0002")?,
55 comparison_incomplete: DiagnosticCode::new("BFR0003")?,
56 ownership_uncertain: DiagnosticCode::new("BFR0004")?,
57 pod_relationship: DiagnosticCode::new("BFR0005")?,
58 image_missing: DiagnosticCode::new("BFR0006")?,
59 invalid_model: DiagnosticCode::new("BFR0007")?,
60 group_relationship_conflict: DiagnosticCode::new("BFR0008")?,
61 lifecycle_resolution: DiagnosticCode::new("BFR0009")?,
62 runtime_managed_metadata: DiagnosticCode::new("BFR0010")?,
63 },
64 })
65 }
66
67 #[must_use]
69 pub const fn override_reconstruction(&self) -> OverrideReconstruction {
70 self.override_reconstruction
71 }
72
73 #[must_use]
75 pub fn with_resolutions(mut self, resolutions: RuntimeResolutions) -> Self {
76 self.resolutions = resolutions;
77 self
78 }
79
80 #[must_use]
82 pub const fn resolutions(&self) -> &RuntimeResolutions {
83 &self.resolutions
84 }
85}
86
87impl ImportAdapter for RuntimeImporter {
88 type Source = RuntimeSnapshot;
89
90 fn import(&self, source: &Self::Source) -> ImportResult {
91 let mut mapping = Mapping::new(&self.codes, self.override_reconstruction, &self.resolutions, source);
92 let mut application = Application::new(source.application_name().clone());
93
94 mapping.report_reconstruction_uncertainty();
95 mapping.map_resources(&mut application);
96 for container in source.containers() {
97 mapping.map_container(&mut application, container);
98 }
99 mapping.map_service_groups(&mut application);
100
101 ImportResult::new(Some(application), mapping.outcomes, mapping.diagnostics)
102 }
103}
104
105#[derive(Clone, Debug)]
106struct Codes {
107 reconstruction_uncertain: DiagnosticCode,
108 inferred_override: DiagnosticCode,
109 comparison_incomplete: DiagnosticCode,
110 ownership_uncertain: DiagnosticCode,
111 pod_relationship: DiagnosticCode,
112 image_missing: DiagnosticCode,
113 invalid_model: DiagnosticCode,
114 group_relationship_conflict: DiagnosticCode,
115 lifecycle_resolution: DiagnosticCode,
116 runtime_managed_metadata: DiagnosticCode,
117}
118
119#[derive(Clone, Copy)]
120struct LossExplanation<'a> {
121 summary: &'static str,
122 reason: &'a str,
123 action: &'static str,
124}
125
126impl<'a> LossExplanation<'a> {
127 const fn new(summary: &'static str, reason: &'a str, action: &'static str) -> Self {
128 Self {
129 summary,
130 reason,
131 action,
132 }
133 }
134}
135
136struct Mapping<'a> {
137 codes: &'a Codes,
138 override_reconstruction: OverrideReconstruction,
139 resolutions: &'a RuntimeResolutions,
140 source: &'a RuntimeSnapshot,
141 outcomes: Vec<ConversionOutcome>,
142 diagnostics: Vec<Diagnostic>,
143}
144
145impl<'a> Mapping<'a> {
146 const fn new(
147 codes: &'a Codes,
148 override_reconstruction: OverrideReconstruction,
149 resolutions: &'a RuntimeResolutions,
150 source: &'a RuntimeSnapshot,
151 ) -> Self {
152 Self {
153 codes,
154 override_reconstruction,
155 resolutions,
156 source,
157 outcomes: Vec::new(),
158 diagnostics: Vec::new(),
159 }
160 }
161
162 fn report_reconstruction_uncertainty(&mut self) {
163 let mut origins = Vec::new();
164 for container in self.source.containers() {
165 origins.push(runtime_origin(container.source_id()));
166 if let Some(evidence) = container.creation_evidence() {
167 origins.push(runtime_origin(evidence.source_id()));
168 }
169 }
170 origins.extend(
171 self.source
172 .networks()
173 .iter()
174 .map(|network| runtime_origin(network.source_id())),
175 );
176 origins.extend(
177 self.source
178 .volumes()
179 .iter()
180 .map(|volume| runtime_origin(volume.source_id())),
181 );
182 for pod in self.source.pods() {
183 origins.push(runtime_origin(pod.source_id()));
184 if let Some(evidence) = pod.creation_evidence() {
185 origins.push(runtime_origin(evidence.source_id()));
186 }
187 }
188 self.loss(
189 self.codes.reconstruction_uncertain.clone(),
190 "application.reconstruction",
191 ConversionKind::Approximate,
192 LossExplanation::new(
193 "runtime inspection cannot prove the original authored definition",
194 "effective state may contain image defaults, runtime defaults, generated values, or later changes",
195 "review the reconstructed definition and its field-level decisions before deployment",
196 ),
197 origins,
198 );
199 }
200
201 fn map_resources(&mut self, application: &mut Application) {
202 let mut networks = BTreeSet::new();
203 for network in self.source.networks() {
204 networks.insert(network.name().as_str().to_owned());
205 let origin = runtime_origin(network.source_id());
206 self.add_network(application, network.name().clone(), origin, false);
207 }
208
209 let mut volumes = BTreeSet::new();
210 for volume in self.source.volumes() {
211 volumes.insert(volume.name().as_str().to_owned());
212 let origin = runtime_origin(volume.source_id());
213 self.add_volume(application, volume.name().clone(), origin, false);
214 }
215
216 for container in self.source.containers() {
217 for attachment in container.networks() {
218 if networks.insert(attachment.network().as_str().to_owned()) {
219 self.add_network(
220 application,
221 attachment.network().clone(),
222 runtime_origin(container.source_id()),
223 true,
224 );
225 }
226 }
227 for mount in container.mounts() {
228 let MountSource::Volume(name) = mount.source() else {
229 continue;
230 };
231 if volumes.insert(name.as_str().to_owned()) {
232 self.add_volume(application, name.clone(), runtime_origin(container.source_id()), true);
233 }
234 }
235 }
236 }
237
238 fn add_network(
239 &mut self,
240 application: &mut Application,
241 name: boxferry_model::Identifier,
242 origin: Provenance,
243 synthesized: bool,
244 ) {
245 let subject = format!("networks.{}", name.as_str());
246 let resolution = self.resolutions.network_ownership(&name);
247 let ownership = resolution.map_or(ResourceOwnership::Uncertain, |value| *value.value());
248 let mut origins = vec![origin.clone()];
249 if let Some(resolution) = resolution {
250 origins.extend_from_slice(resolution.origins());
251 }
252 let network = sourced_with_origins(Network::new(name, ownership), origins.clone());
253 if let Err(error) = application.add_network(network) {
254 self.invalid_model(&subject, &error, origins);
255 return;
256 }
257 if resolution.is_some() {
258 self.lifecycle_resolution(subject, "network", synthesized, origins);
259 return;
260 }
261 let reason = if synthesized {
262 "a container relationship referenced this network, but the snapshot did not include its own inspection"
263 } else {
264 "inspection proves that the network exists but not whether generated definitions should create or reuse it"
265 };
266 self.loss(
267 self.codes.ownership_uncertain.clone(),
268 subject,
269 ConversionKind::Approximate,
270 LossExplanation::new(
271 "runtime resource lifecycle ownership is uncertain",
272 reason,
273 "choose application-owned or external lifecycle before generating deployable output",
274 ),
275 vec![origin],
276 );
277 }
278
279 fn add_volume(
280 &mut self,
281 application: &mut Application,
282 name: boxferry_model::Identifier,
283 origin: Provenance,
284 synthesized: bool,
285 ) {
286 let subject = format!("volumes.{}", name.as_str());
287 let resolution = self.resolutions.volume_ownership(&name);
288 let ownership = resolution.map_or(ResourceOwnership::Uncertain, |value| *value.value());
289 let mut origins = vec![origin.clone()];
290 if let Some(resolution) = resolution {
291 origins.extend_from_slice(resolution.origins());
292 }
293 let volume = sourced_with_origins(Volume::new(name, ownership), origins.clone());
294 if let Err(error) = application.add_volume(volume) {
295 self.invalid_model(&subject, &error, origins);
296 return;
297 }
298 if resolution.is_some() {
299 self.lifecycle_resolution(subject, "volume", synthesized, origins);
300 return;
301 }
302 let reason = if synthesized {
303 "a container relationship referenced this volume, but the snapshot did not include its own inspection"
304 } else {
305 "inspection proves that the volume exists but not whether generated definitions should create or reuse it"
306 };
307 self.loss(
308 self.codes.ownership_uncertain.clone(),
309 subject,
310 ConversionKind::Approximate,
311 LossExplanation::new(
312 "runtime resource lifecycle ownership is uncertain",
313 reason,
314 "choose application-owned or external lifecycle before generating deployable output",
315 ),
316 vec![origin],
317 );
318 }
319
320 fn map_container(&mut self, application: &mut Application, container: &ContainerObservation) {
321 let service_name = container.name().as_str();
322 let service_subject = format!("services.{service_name}");
323 let container_origin = runtime_origin(container.source_id());
324 let mut service = Service::new(container.name().clone());
325 service.set_runtime_name(Sourced::from_source(
326 ProtectedString::plain(service_name),
327 container_origin.clone(),
328 ));
329 self.exact(
330 format!("{service_subject}.runtime_name"),
331 vec![container_origin.clone()],
332 );
333
334 if let Some(image) = container.image() {
335 service.set_image(Sourced::from_source(image.clone(), container_origin.clone()));
336 self.exact(format!("{service_subject}.image"), vec![container_origin.clone()]);
337 } else {
338 self.loss(
339 self.codes.image_missing.clone(),
340 format!("{service_subject}.image"),
341 ConversionKind::Unsupported,
342 LossExplanation::new(
343 "runtime container has no reconstructable image reference",
344 "the supplied observation did not contain an image reference suitable for a reusable definition",
345 "supply a reviewed image reference or rebuild the container from an authored source definition",
346 ),
347 vec![container_origin.clone()],
348 );
349 }
350
351 match self.override_reconstruction {
352 OverrideReconstruction::PreserveObservedState => {
353 self.preserve_effective_values(&mut service, container, &service_subject);
354 }
355 OverrideReconstruction::InferImageOverrides => {
356 self.infer_overrides(&mut service, container, &service_subject);
357 }
358 }
359
360 if let Some(restart_policy) = container.restart_policy() {
361 service.set_restart_policy(Sourced::from_source(restart_policy, container_origin.clone()));
362 self.exact(
363 format!("{service_subject}.restart_policy"),
364 vec![container_origin.clone()],
365 );
366 }
367
368 if let Some(read_only) = container.read_only_root_filesystem() {
369 service.set_read_only_root_filesystem(Sourced::from_source(read_only, container_origin.clone()));
370 self.exact(
371 format!("{service_subject}.read_only_root_filesystem"),
372 vec![container_origin.clone()],
373 );
374 }
375
376 for (index, port) in container.ports().iter().enumerate() {
377 service.add_port(Sourced::from_source(port.clone(), container_origin.clone()));
378 self.exact(
379 format!("{service_subject}.ports[{index}]"),
380 vec![container_origin.clone()],
381 );
382 }
383 for (index, mount) in container.mounts().iter().enumerate() {
384 service.add_mount(Sourced::from_source(mount.clone(), container_origin.clone()));
385 self.exact(
386 format!("{service_subject}.mounts[{index}]"),
387 vec![container_origin.clone()],
388 );
389 }
390 for attachment in container.networks() {
391 let subject = format!("{service_subject}.networks.{}", attachment.network().as_str());
392 service.add_network(Sourced::from_source(attachment.clone(), container_origin.clone()));
393 self.exact(subject, vec![container_origin.clone()]);
394 }
395
396 let sourced_service = Sourced::from_source(service, container_origin.clone());
397 if let Err(error) = application.add_service(sourced_service) {
398 self.invalid_model(&service_subject, &error, vec![container_origin]);
399 }
400 }
401
402 fn preserve_effective_values(
403 &mut self,
404 service: &mut Service,
405 container: &ContainerObservation,
406 service_subject: &str,
407 ) {
408 let origin = runtime_origin(container.source_id());
409 if let Some(command) = container.command() {
410 service.set_command(Sourced::from_source(neutral_command(command), origin.clone()));
411 self.exact(format!("{service_subject}.command"), vec![origin.clone()]);
412 } else {
413 self.comparison_incomplete(
414 format!("{service_subject}.command"),
415 "the effective container command was not supplied",
416 vec![origin.clone()],
417 );
418 }
419
420 if let Some(environment) = container.environment() {
421 for variable in environment {
422 service.add_environment(Sourced::from_source(neutral_environment(variable), origin.clone()));
423 self.exact(
424 format!("{service_subject}.environment.{}", variable.name().as_str()),
425 vec![origin.clone()],
426 );
427 }
428 } else {
429 self.comparison_incomplete(
430 format!("{service_subject}.environment"),
431 "the effective container environment was not supplied",
432 vec![origin.clone()],
433 );
434 }
435
436 if let Some(labels) = container.labels() {
437 for label in labels {
438 service.add_label(Sourced::from_source(neutral_label(label), origin.clone()));
439 let subject = format!("{service_subject}.labels.{}", label.name().as_str());
440 if is_compose_managed_label(label) {
441 self.runtime_managed_metadata(subject, vec![origin.clone()]);
442 } else {
443 self.exact(subject, vec![origin.clone()]);
444 }
445 }
446 } else {
447 self.comparison_incomplete(
448 format!("{service_subject}.labels"),
449 "the effective container metadata labels were not supplied",
450 vec![origin.clone()],
451 );
452 }
453
454 if let Some(user) = container.user() {
455 set_neutral_identity(service, &Sourced::from_source(user.clone(), origin.clone()));
456 self.exact(format!("{service_subject}.user"), vec![origin.clone()]);
457 if identity_group(user).is_some() {
458 self.exact(format!("{service_subject}.group"), vec![origin.clone()]);
459 }
460 }
461 if let Some(working_directory) = container.working_directory() {
462 service.set_working_directory(Sourced::from_source(working_directory.clone(), origin.clone()));
463 self.exact(format!("{service_subject}.working_directory"), vec![origin.clone()]);
464 }
465 if let Some(healthcheck) = container.healthcheck().filter(|healthcheck| !healthcheck.is_empty()) {
466 service.set_healthcheck(Sourced::from_source(
467 neutral_healthcheck(healthcheck, std::slice::from_ref(&origin)),
468 origin.clone(),
469 ));
470 self.report_exact_healthcheck(service_subject, healthcheck, &origin);
471 }
472 }
473
474 fn infer_overrides(&mut self, service: &mut Service, container: &ContainerObservation, service_subject: &str) {
475 let image = container
476 .image_source_id()
477 .and_then(|source_id| self.source.images().iter().find(|image| image.source_id() == source_id));
478 let Some(image) = image else {
479 self.preserve_without_image_defaults(service, container, service_subject);
480 return;
481 };
482
483 self.infer_command(service, container, image, service_subject);
484 self.infer_environment(service, container, image, service_subject);
485 self.infer_labels(service, container, image, service_subject);
486 self.infer_identity(service, container, image, service_subject);
487 self.infer_healthcheck(service, container, image, service_subject);
488 self.infer_protected_override(
489 format!("{service_subject}.working_directory"),
490 container.working_directory(),
491 image.working_directory(),
492 container,
493 image,
494 |value| service.set_working_directory(value),
495 );
496 }
497
498 fn infer_healthcheck(
499 &mut self,
500 service: &mut Service,
501 container: &ContainerObservation,
502 image: &ImageObservation,
503 service_subject: &str,
504 ) {
505 let container_origin = runtime_origin(container.source_id());
506 let image_origin = runtime_origin(image.source_id());
507 let decision_origin = decision_origin(container.source_id());
508 let origins = vec![container_origin, image_origin, decision_origin];
509
510 let (container_healthcheck, image_healthcheck) = match (container.healthcheck(), image.healthcheck()) {
511 (Some(container_healthcheck), Some(image_healthcheck)) => (container_healthcheck, image_healthcheck),
512 (Some(container_healthcheck), None) if !container_healthcheck.is_empty() => {
513 service.set_healthcheck(sourced_with_origins(
514 neutral_healthcheck(container_healthcheck, &origins),
515 origins.clone(),
516 ));
517 self.comparison_incomplete(
518 format!("{service_subject}.healthcheck"),
519 "image health-check data was not supplied for comparison",
520 origins,
521 );
522 return;
523 }
524 (None, Some(image_healthcheck)) if !image_healthcheck.is_empty() => {
525 self.comparison_incomplete(
526 format!("{service_subject}.healthcheck"),
527 "effective container health-check data was not supplied for comparison",
528 origins,
529 );
530 return;
531 }
532 _ => return,
533 };
534
535 let mut retained = RuntimeHealthcheck::new();
536 if container_healthcheck.disabled() == Some(true) {
537 self.infer_health_bool_field(
538 format!("{service_subject}.healthcheck.disable"),
539 container_healthcheck.disabled(),
540 image_healthcheck.disabled(),
541 &origins,
542 |value| retained.set_disabled(value),
543 );
544 if !retained.is_empty() {
545 service.set_healthcheck(sourced_with_origins(neutral_healthcheck(&retained, &origins), origins));
546 }
547 return;
548 }
549 self.infer_health_field(
550 format!("{service_subject}.healthcheck.test"),
551 container_healthcheck.command(),
552 image_healthcheck.command(),
553 &origins,
554 |value| retained.set_command(value),
555 );
556 self.infer_health_bool_field(
557 format!("{service_subject}.healthcheck.disable"),
558 container_healthcheck.disabled(),
559 image_healthcheck.disabled(),
560 &origins,
561 |value| retained.set_disabled(value),
562 );
563 self.infer_health_field(
564 format!("{service_subject}.healthcheck.interval"),
565 container_healthcheck.interval(),
566 image_healthcheck.interval(),
567 &origins,
568 |value| retained.set_interval(value),
569 );
570 self.infer_health_field(
571 format!("{service_subject}.healthcheck.timeout"),
572 container_healthcheck.timeout(),
573 image_healthcheck.timeout(),
574 &origins,
575 |value| retained.set_timeout(value),
576 );
577 self.infer_health_field(
578 format!("{service_subject}.healthcheck.retries"),
579 container_healthcheck.retries(),
580 image_healthcheck.retries(),
581 &origins,
582 |value| retained.set_retries(value),
583 );
584 self.infer_health_field(
585 format!("{service_subject}.healthcheck.start_period"),
586 container_healthcheck.start_period(),
587 image_healthcheck.start_period(),
588 &origins,
589 |value| retained.set_start_period(value),
590 );
591 self.infer_health_field(
592 format!("{service_subject}.healthcheck.start_interval"),
593 container_healthcheck.start_interval(),
594 image_healthcheck.start_interval(),
595 &origins,
596 |value| retained.set_start_interval(value),
597 );
598
599 if !retained.is_empty() {
600 service.set_healthcheck(sourced_with_origins(neutral_healthcheck(&retained, &origins), origins));
601 }
602 }
603
604 fn infer_health_field<T: Clone + Eq>(
605 &mut self,
606 subject: String,
607 container_value: Option<&T>,
608 image_value: Option<&T>,
609 origins: &[Provenance],
610 retain: impl FnOnce(T),
611 ) {
612 match (container_value, image_value) {
613 (Some(value), image_value) => {
614 let matches_default = image_value == Some(value);
615 if !matches_default {
616 retain(value.clone());
617 }
618 self.inferred_override(subject, matches_default, origins.to_vec());
619 }
620 (None, Some(_)) => self.comparison_incomplete(
621 subject,
622 "an image health-check default is absent from the effective container observation",
623 origins.to_vec(),
624 ),
625 (None, None) => {}
626 }
627 }
628
629 fn infer_health_bool_field(
630 &mut self,
631 subject: String,
632 container_value: Option<bool>,
633 image_value: Option<bool>,
634 origins: &[Provenance],
635 retain: impl FnOnce(bool),
636 ) {
637 match (container_value, image_value) {
638 (Some(value), image_value) => {
639 let matches_default = image_value == Some(value);
640 if !matches_default {
641 retain(value);
642 }
643 self.inferred_override(subject, matches_default, origins.to_vec());
644 }
645 (None, Some(_)) => self.comparison_incomplete(
646 subject,
647 "an image health-check default is absent from the effective container observation",
648 origins.to_vec(),
649 ),
650 (None, None) => {}
651 }
652 }
653
654 fn report_exact_healthcheck(
655 &mut self,
656 service_subject: &str,
657 healthcheck: &RuntimeHealthcheck,
658 origin: &Provenance,
659 ) {
660 for field in [
661 healthcheck.command().map(|_| "test"),
662 healthcheck.disabled().map(|_| "disable"),
663 healthcheck.interval().map(|_| "interval"),
664 healthcheck.timeout().map(|_| "timeout"),
665 healthcheck.retries().map(|_| "retries"),
666 healthcheck.start_period().map(|_| "start_period"),
667 healthcheck.start_interval().map(|_| "start_interval"),
668 ]
669 .into_iter()
670 .flatten()
671 {
672 self.exact(format!("{service_subject}.healthcheck.{field}"), vec![origin.clone()]);
673 }
674 }
675
676 fn infer_command(
677 &mut self,
678 service: &mut Service,
679 container: &ContainerObservation,
680 image: &ImageObservation,
681 service_subject: &str,
682 ) {
683 let container_origin = runtime_origin(container.source_id());
684 let image_origin = runtime_origin(image.source_id());
685 let decision_origin = decision_origin(container.source_id());
686 let origins = vec![container_origin.clone(), image_origin.clone(), decision_origin.clone()];
687
688 let (Some(command), Some(image_command)) = (container.command(), image.command()) else {
689 if let Some(command) = container.command() {
690 service.set_command(sourced_with_origins(neutral_command(command), origins.clone()));
691 }
692 self.comparison_incomplete(
693 format!("{service_subject}.command"),
694 "container or image command data was not supplied for comparison",
695 origins,
696 );
697 return;
698 };
699
700 if command != image_command {
701 service.set_command(sourced_with_origins(neutral_command(command), origins.clone()));
702 }
703 self.inferred_override(format!("{service_subject}.command"), command == image_command, origins);
704 }
705
706 fn infer_environment(
707 &mut self,
708 service: &mut Service,
709 container: &ContainerObservation,
710 image: &ImageObservation,
711 service_subject: &str,
712 ) {
713 let container_origin = runtime_origin(container.source_id());
714 let image_origin = runtime_origin(image.source_id());
715 let decision_origin = decision_origin(container.source_id());
716 let (Some(environment), Some(image_environment)) = (container.environment(), image.environment()) else {
717 if let Some(environment) = container.environment() {
718 for variable in environment {
719 service.add_environment(sourced_with_origins(
720 neutral_environment(variable),
721 vec![container_origin.clone(), decision_origin.clone()],
722 ));
723 }
724 }
725 self.comparison_incomplete(
726 format!("{service_subject}.environment"),
727 "container or image environment data was not supplied for comparison",
728 vec![container_origin, image_origin, decision_origin],
729 );
730 return;
731 };
732
733 for variable in environment {
734 let image_variable = image_environment
735 .iter()
736 .rev()
737 .find(|candidate| candidate.name() == variable.name());
738 let matches_default = image_variable == Some(variable);
739 let origins = vec![container_origin.clone(), image_origin.clone(), decision_origin.clone()];
740 if !matches_default {
741 service.add_environment(sourced_with_origins(neutral_environment(variable), origins.clone()));
742 }
743 self.inferred_override(
744 format!("{service_subject}.environment.{}", variable.name().as_str()),
745 matches_default,
746 origins,
747 );
748 }
749
750 for image_variable in image_environment {
751 if environment
752 .iter()
753 .all(|variable| variable.name() != image_variable.name())
754 {
755 self.loss(
756 self.codes.comparison_incomplete.clone(),
757 format!("{service_subject}.environment.{}", image_variable.name().as_str()),
758 ConversionKind::Unsupported,
759 LossExplanation::new(
760 "an image environment default is absent from the effective container environment",
761 "inspection cannot establish how the image default was removed",
762 "review whether the generated definition needs an explicit target-specific unset operation",
763 ),
764 vec![container_origin.clone(), image_origin.clone(), decision_origin.clone()],
765 );
766 }
767 }
768 }
769
770 fn infer_labels(
771 &mut self,
772 service: &mut Service,
773 container: &ContainerObservation,
774 image: &ImageObservation,
775 service_subject: &str,
776 ) {
777 let container_origin = runtime_origin(container.source_id());
778 let image_origin = runtime_origin(image.source_id());
779 let decision_origin = decision_origin(container.source_id());
780 let (Some(labels), Some(image_labels)) = (container.labels(), image.labels()) else {
781 if let Some(labels) = container.labels() {
782 for label in labels {
783 service.add_label(sourced_with_origins(
784 neutral_label(label),
785 vec![container_origin.clone(), decision_origin.clone()],
786 ));
787 if is_compose_managed_label(label) {
788 self.runtime_managed_metadata(
789 format!("{service_subject}.labels.{}", label.name().as_str()),
790 vec![container_origin.clone(), decision_origin.clone()],
791 );
792 }
793 }
794 }
795 self.comparison_incomplete(
796 format!("{service_subject}.labels"),
797 "container or image metadata-label data was not supplied for comparison",
798 vec![container_origin, image_origin, decision_origin],
799 );
800 return;
801 };
802
803 for label in labels {
804 let image_label = image_labels.iter().find(|candidate| candidate.name() == label.name());
805 let matches_default = image_label == Some(label);
806 let origins = vec![container_origin.clone(), image_origin.clone(), decision_origin.clone()];
807 if !matches_default {
808 service.add_label(sourced_with_origins(neutral_label(label), origins.clone()));
809 }
810 let subject = format!("{service_subject}.labels.{}", label.name().as_str());
811 if !matches_default && is_compose_managed_label(label) {
812 self.runtime_managed_metadata(subject, origins);
813 } else {
814 self.inferred_override(subject, matches_default, origins);
815 }
816 }
817
818 for image_label in image_labels {
819 if labels.iter().all(|label| label.name() != image_label.name()) {
820 self.loss(
821 self.codes.comparison_incomplete.clone(),
822 format!("{service_subject}.labels.{}", image_label.name().as_str()),
823 ConversionKind::Unsupported,
824 LossExplanation::new(
825 "an image metadata-label default is absent from the effective container labels",
826 "inspection cannot establish how the inherited image label was removed",
827 "review whether generated output needs an explicit target-specific empty or replacement label",
828 ),
829 vec![container_origin.clone(), image_origin.clone(), decision_origin.clone()],
830 );
831 }
832 }
833 }
834
835 fn infer_protected_override(
836 &mut self,
837 subject: String,
838 container_value: Option<&ProtectedString>,
839 image_value: Option<&ProtectedString>,
840 container: &ContainerObservation,
841 image: &ImageObservation,
842 set_value: impl FnOnce(Sourced<ProtectedString>),
843 ) {
844 let container_origin = runtime_origin(container.source_id());
845 let image_origin = runtime_origin(image.source_id());
846 let decision_origin = decision_origin(container.source_id());
847 let origins = vec![container_origin, image_origin, decision_origin];
848
849 match (container_value, image_value) {
850 (Some(value), image_value) => {
851 let matches_default = image_value == Some(value);
852 if !matches_default {
853 set_value(sourced_with_origins(value.clone(), origins.clone()));
854 }
855 self.inferred_override(subject, matches_default, origins);
856 }
857 (None, Some(_)) => self.loss(
858 self.codes.comparison_incomplete.clone(),
859 subject,
860 ConversionKind::Unsupported,
861 LossExplanation::new(
862 "an image default is absent from the effective container observation",
863 "inspection cannot establish whether the value was cleared or omitted by the native response",
864 "review whether generated output needs an explicit target-specific reset",
865 ),
866 origins,
867 ),
868 (None, None) => {}
869 }
870 }
871
872 fn infer_identity(
873 &mut self,
874 service: &mut Service,
875 container: &ContainerObservation,
876 image: &ImageObservation,
877 service_subject: &str,
878 ) {
879 let container_origin = runtime_origin(container.source_id());
880 let image_origin = runtime_origin(image.source_id());
881 let decision_origin = decision_origin(container.source_id());
882 let origins = vec![container_origin, image_origin, decision_origin];
883
884 match (container.user(), image.user()) {
885 (Some(value), image_value) => {
886 let matches_default = image_value == Some(value);
887 if !matches_default {
888 set_neutral_identity(service, &sourced_with_origins(value.clone(), origins.clone()));
889 }
890 self.inferred_override(format!("{service_subject}.user"), matches_default, origins.clone());
891 if identity_group(value).is_some() || image_value.and_then(identity_group).is_some() {
892 self.inferred_override(format!("{service_subject}.group"), matches_default, origins);
893 }
894 }
895 (None, Some(image_value)) => {
896 for field in if identity_group(image_value).is_some() {
897 [Some("user"), Some("group")]
898 } else {
899 [Some("user"), None]
900 }
901 .into_iter()
902 .flatten()
903 {
904 self.loss(
905 self.codes.comparison_incomplete.clone(),
906 format!("{service_subject}.{field}"),
907 ConversionKind::Unsupported,
908 LossExplanation::new(
909 "an image identity default is absent from the effective container observation",
910 "inspection cannot establish whether the identity was cleared or omitted by the native response",
911 "review whether generated output needs an explicit target-specific identity reset",
912 ),
913 origins.clone(),
914 );
915 }
916 }
917 (None, None) => {}
918 }
919 }
920
921 fn preserve_without_image_defaults(
922 &mut self,
923 service: &mut Service,
924 container: &ContainerObservation,
925 service_subject: &str,
926 ) {
927 let runtime_origin = runtime_origin(container.source_id());
928 let decision_origin = decision_origin(container.source_id());
929 if let Some(command) = container.command() {
930 service.set_command(sourced_with_origins(
931 neutral_command(command),
932 vec![runtime_origin.clone(), decision_origin.clone()],
933 ));
934 }
935 if let Some(environment) = container.environment() {
936 for variable in environment {
937 service.add_environment(sourced_with_origins(
938 neutral_environment(variable),
939 vec![runtime_origin.clone(), decision_origin.clone()],
940 ));
941 }
942 }
943 if let Some(labels) = container.labels() {
944 for label in labels {
945 service.add_label(sourced_with_origins(
946 neutral_label(label),
947 vec![runtime_origin.clone(), decision_origin.clone()],
948 ));
949 if is_compose_managed_label(label) {
950 self.runtime_managed_metadata(
951 format!("{service_subject}.labels.{}", label.name().as_str()),
952 vec![runtime_origin.clone(), decision_origin.clone()],
953 );
954 }
955 }
956 }
957 if let Some(user) = container.user() {
958 set_neutral_identity(
959 service,
960 &sourced_with_origins(user.clone(), vec![runtime_origin.clone(), decision_origin.clone()]),
961 );
962 }
963 if let Some(working_directory) = container.working_directory() {
964 service.set_working_directory(sourced_with_origins(
965 working_directory.clone(),
966 vec![runtime_origin.clone(), decision_origin.clone()],
967 ));
968 }
969 if let Some(healthcheck) = container.healthcheck().filter(|healthcheck| !healthcheck.is_empty()) {
970 let origins = vec![runtime_origin.clone(), decision_origin.clone()];
971 service.set_healthcheck(sourced_with_origins(
972 neutral_healthcheck(healthcheck, &origins),
973 origins,
974 ));
975 }
976 self.loss(
977 self.codes.comparison_incomplete.clone(),
978 format!("{service_subject}.overrides"),
979 ConversionKind::Approximate,
980 LossExplanation::new(
981 "image defaults were unavailable for override reconstruction",
982 "no matching complete image observation was linked to the container",
983 "inspect the exact image and review which preserved effective values are true overrides",
984 ),
985 vec![runtime_origin, decision_origin],
986 );
987 }
988
989 fn inferred_override(&mut self, subject: String, matches_default: bool, origins: Vec<Provenance>) {
990 let reason = if matches_default {
991 "the effective container value matches the inspected image default and was omitted"
992 } else {
993 "the effective container value differs from the inspected image default and was retained as an override"
994 };
995 self.loss(
996 self.codes.inferred_override.clone(),
997 subject,
998 ConversionKind::Approximate,
999 LossExplanation::new(
1000 "runtime value was classified by comparing container and image observations",
1001 reason,
1002 "review the inferred override because inspection cannot establish original author intent",
1003 ),
1004 origins,
1005 );
1006 }
1007
1008 fn runtime_managed_metadata(&mut self, subject: String, origins: Vec<Provenance>) {
1009 self.loss(
1010 self.codes.runtime_managed_metadata.clone(),
1011 subject,
1012 ConversionKind::Unsupported,
1013 LossExplanation::new(
1014 "runtime-managed orchestration metadata cannot be re-authored safely",
1015 "the observed label uses Compose's reserved com.docker.compose namespace",
1016 "omit it from authored output or replace it with reviewed application-owned metadata",
1017 ),
1018 origins,
1019 );
1020 }
1021
1022 fn comparison_incomplete(&mut self, subject: String, reason: &'static str, origins: Vec<Provenance>) {
1023 self.loss(
1024 self.codes.comparison_incomplete.clone(),
1025 subject,
1026 ConversionKind::Approximate,
1027 LossExplanation::new(
1028 "runtime override reconstruction is incomplete",
1029 reason,
1030 "supply complete container and image inspection fields or review the preserved effective values",
1031 ),
1032 origins,
1033 );
1034 }
1035
1036 fn map_service_groups(&mut self, application: &mut Application) {
1037 for pod in self.source.pods() {
1038 self.map_service_group(application, pod);
1039 }
1040
1041 for container in self.source.containers() {
1042 let Some(pod_source_id) = container.pod_source_id() else {
1043 continue;
1044 };
1045 let Some(pod) = self.source.pods().iter().find(|pod| pod.source_id() == pod_source_id) else {
1046 self.loss(
1047 self.codes.pod_relationship.clone(),
1048 format!("services.{}.service_group", container.name().as_str()),
1049 ConversionKind::Unsupported,
1050 LossExplanation::new(
1051 "container references a service group missing from the runtime snapshot",
1052 "the relationship cannot be reconstructed without the pod observation",
1053 "include the referenced pod inspection and review target grouping",
1054 ),
1055 vec![runtime_origin(container.source_id())],
1056 );
1057 continue;
1058 };
1059 if pod.members().contains(container.source_id()) {
1060 continue;
1061 }
1062 self.group_relationship_conflict(
1063 format!("services.{}.service_group", container.name().as_str()),
1064 "the container references this pod, but the pod does not list the container as a member",
1065 vec![runtime_origin(container.source_id())],
1066 );
1067 }
1068 }
1069
1070 fn map_service_group(&mut self, application: &mut Application, pod: &PodObservation) {
1071 let group_subject = format!("service_groups.{}", pod.name().as_str());
1072 let group_origin = runtime_origin(pod.source_id());
1073 let resolution = self.resolutions.service_group_ownership(pod.name());
1074 let ownership = resolution.map_or(ResourceOwnership::Uncertain, |value| *value.value());
1075 let mut group = ServiceGroup::new(pod.name().clone(), ownership);
1076
1077 for (index, member_source_id) in pod.members().iter().enumerate() {
1078 let member_subject = format!("{group_subject}.members[{index}]");
1079 let Some(container) = self
1080 .source
1081 .containers()
1082 .iter()
1083 .find(|container| container.source_id() == member_source_id)
1084 else {
1085 self.loss(
1086 self.codes.pod_relationship.clone(),
1087 member_subject,
1088 ConversionKind::Unsupported,
1089 LossExplanation::new(
1090 "runtime service-group member is missing from the snapshot",
1091 "the pod observation references a container that was not supplied",
1092 "include the referenced container inspection or remove the stale pod relationship",
1093 ),
1094 vec![group_origin.clone()],
1095 );
1096 continue;
1097 };
1098
1099 let member_origins = vec![group_origin.clone(), runtime_origin(container.source_id())];
1100 let member = sourced_with_origins(container.name().clone(), member_origins.clone());
1101 if let Err(error) = group.add_member(member) {
1102 self.invalid_model(&member_subject, &error, member_origins);
1103 continue;
1104 }
1105 match container.pod_source_id() {
1106 Some(container_pod) if container_pod == pod.source_id() => {
1107 self.exact(member_subject, member_origins);
1108 }
1109 Some(_) => self.group_relationship_conflict(
1110 member_subject,
1111 "the pod lists this container, but the container references a different pod",
1112 member_origins,
1113 ),
1114 None => self.group_relationship_conflict(
1115 member_subject,
1116 "the pod lists this container, but the container has no matching pod relationship",
1117 member_origins,
1118 ),
1119 }
1120 }
1121
1122 let mut group_origins = vec![group_origin.clone()];
1123 if let Some(resolution) = resolution {
1124 group_origins.extend_from_slice(resolution.origins());
1125 }
1126 let sourced_group = sourced_with_origins(group, group_origins.clone());
1127 if let Err(error) = application.add_service_group(sourced_group) {
1128 self.invalid_model(&group_subject, &error, group_origins);
1129 } else if resolution.is_some() {
1130 self.lifecycle_resolution(
1131 format!("{group_subject}.lifecycle"),
1132 "service group",
1133 false,
1134 group_origins,
1135 );
1136 } else {
1137 self.loss(
1138 self.codes.pod_relationship.clone(),
1139 format!("{group_subject}.lifecycle"),
1140 ConversionKind::Approximate,
1141 LossExplanation::new(
1142 "runtime service-group lifecycle and target semantics are uncertain",
1143 "inspection proves structural membership but not which namespaces or future definition should own the group",
1144 "review the group and select explicit lifecycle and target grouping policies",
1145 ),
1146 vec![group_origin],
1147 );
1148 }
1149 }
1150
1151 fn group_relationship_conflict(&mut self, subject: String, reason: &'static str, origins: Vec<Provenance>) {
1152 self.loss(
1153 self.codes.group_relationship_conflict.clone(),
1154 subject,
1155 ConversionKind::Invalid,
1156 LossExplanation::new(
1157 "runtime service-group observations contradict each other",
1158 reason,
1159 "recapture a consistent pod and container inspection set before generating output",
1160 ),
1161 origins,
1162 );
1163 }
1164
1165 fn lifecycle_resolution(
1166 &mut self,
1167 subject: String,
1168 resource_kind: &'static str,
1169 synthesized: bool,
1170 origins: Vec<Provenance>,
1171 ) {
1172 let reason = if synthesized {
1173 "the resource was synthesized from a container relationship and its lifecycle was selected by an explicit caller override"
1174 } else {
1175 "runtime inspection established the resource and an explicit caller override selected its lifecycle ownership"
1176 };
1177 self.loss(
1178 self.codes.lifecycle_resolution.clone(),
1179 subject,
1180 ConversionKind::Approximate,
1181 LossExplanation::new(
1182 "runtime resource lifecycle was resolved by the caller",
1183 reason,
1184 match resource_kind {
1185 "service group" => "review the selected service-group ownership and target grouping semantics",
1186 _ => "review the selected resource ownership before deploying generated output",
1187 },
1188 ),
1189 origins,
1190 );
1191 }
1192
1193 fn invalid_model(&mut self, subject: &str, error: &ModelError, origins: Vec<Provenance>) {
1194 self.loss(
1195 self.codes.invalid_model.clone(),
1196 subject.to_owned(),
1197 ConversionKind::Invalid,
1198 LossExplanation::new(
1199 "runtime observation could not enter the neutral application model",
1200 &error.to_string(),
1201 "correct the runtime adapter's resource naming or observation mapping",
1202 ),
1203 origins,
1204 );
1205 }
1206
1207 fn exact(&mut self, subject: String, origins: Vec<Provenance>) {
1208 let mut outcome = ConversionOutcome::exact(subject);
1209 for origin in origins {
1210 outcome = outcome.with_origin(origin);
1211 }
1212 self.outcomes.push(outcome);
1213 }
1214
1215 fn loss(
1216 &mut self,
1217 code: DiagnosticCode,
1218 subject: impl Into<String>,
1219 kind: ConversionKind,
1220 explanation: LossExplanation<'_>,
1221 origins: Vec<Provenance>,
1222 ) {
1223 let subject = subject.into();
1224 let diagnostic = Diagnostic::new(code.clone(), severity(kind), explanation.summary)
1225 .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(subject.clone())))
1226 .with_field(DiagnosticField::new(
1227 "runtime",
1228 DiagnosticValue::plain(self.source.implementation().as_str()),
1229 ))
1230 .with_field(DiagnosticField::new(
1231 "reason",
1232 DiagnosticValue::plain(explanation.reason),
1233 ))
1234 .with_field(DiagnosticField::new(
1235 "action",
1236 DiagnosticValue::plain(explanation.action),
1237 ));
1238 if let Ok(mut outcome) = ConversionOutcome::loss(subject, kind, code) {
1239 for origin in origins {
1240 outcome = outcome.with_origin(origin);
1241 }
1242 self.outcomes.push(outcome);
1243 self.diagnostics.push(diagnostic);
1244 }
1245 }
1246}
1247
1248fn runtime_origin(source_id: &SourceId) -> Provenance {
1249 Provenance::runtime_observation(source_id.clone())
1250}
1251
1252fn decision_origin(source_id: &SourceId) -> Provenance {
1253 Provenance::conversion_decision(source_id.clone())
1254}
1255
1256fn neutral_command(command: &EffectiveCommand) -> Command {
1257 match command {
1258 EffectiveCommand::Exec(arguments) => Command::Exec(arguments.clone()),
1259 EffectiveCommand::Empty => Command::Empty,
1260 }
1261}
1262
1263fn neutral_environment(variable: &RuntimeEnvironmentVariable) -> EnvironmentVariable {
1264 EnvironmentVariable::new(
1265 variable.name().clone(),
1266 EnvironmentValue::Literal(variable.value().clone()),
1267 )
1268}
1269
1270fn neutral_label(label: &RuntimeMetadataLabel) -> MetadataLabel {
1271 MetadataLabel::new(label.name().clone(), label.value().clone())
1272}
1273
1274fn is_compose_managed_label(label: &RuntimeMetadataLabel) -> bool {
1275 label.name().as_str().starts_with("com.docker.compose.")
1276}
1277
1278fn neutral_healthcheck(healthcheck: &RuntimeHealthcheck, origins: &[Provenance]) -> Healthcheck {
1279 let mut neutral = Healthcheck::new();
1280 if let Some(command) = healthcheck.command() {
1281 neutral.set_command(sourced_with_origins(command.clone(), origins.to_vec()));
1282 }
1283 if let Some(disabled) = healthcheck.disabled() {
1284 neutral.set_disabled(sourced_with_origins(disabled, origins.to_vec()));
1285 }
1286 if let Some(interval) = healthcheck.interval() {
1287 neutral.set_interval(sourced_with_origins(interval.clone(), origins.to_vec()));
1288 }
1289 if let Some(timeout) = healthcheck.timeout() {
1290 neutral.set_timeout(sourced_with_origins(timeout.clone(), origins.to_vec()));
1291 }
1292 if let Some(retries) = healthcheck.retries() {
1293 neutral.set_retries(sourced_with_origins(retries.clone(), origins.to_vec()));
1294 }
1295 if let Some(start_period) = healthcheck.start_period() {
1296 neutral.set_start_period(sourced_with_origins(start_period.clone(), origins.to_vec()));
1297 }
1298 if let Some(start_interval) = healthcheck.start_interval() {
1299 neutral.set_start_interval(sourced_with_origins(start_interval.clone(), origins.to_vec()));
1300 }
1301 neutral
1302}
1303
1304fn set_neutral_identity(service: &mut Service, identity: &Sourced<ProtectedString>) {
1305 let origins = identity.origins().to_vec();
1306 let (user, group) = identity
1307 .value()
1308 .expose()
1309 .split_once(':')
1310 .map_or((identity.value().expose(), None), |(user, group)| {
1311 (user, (!group.is_empty()).then_some(group))
1312 });
1313 service.set_user(sourced_with_origins(ProtectedString::sensitive(user), origins.clone()));
1314 if let Some(group) = group {
1315 service.set_group(sourced_with_origins(ProtectedString::sensitive(group), origins));
1316 }
1317}
1318
1319fn identity_group(identity: &ProtectedString) -> Option<&str> {
1320 identity
1321 .expose()
1322 .split_once(':')
1323 .and_then(|(_, group)| (!group.is_empty()).then_some(group))
1324}
1325
1326fn sourced_with_origins<T>(value: T, origins: Vec<Provenance>) -> Sourced<T> {
1327 let mut origins = origins.into_iter();
1328 let Some(first) = origins.next() else {
1329 return Sourced::generated(value);
1330 };
1331 let mut sourced = Sourced::from_source(value, first);
1332 for origin in origins {
1333 sourced.add_origin(origin);
1334 }
1335 sourced
1336}
1337
1338const fn severity(kind: ConversionKind) -> Severity {
1339 match kind {
1340 ConversionKind::Invalid => Severity::Error,
1341 ConversionKind::Exact => Severity::Note,
1342 _ => Severity::Warning,
1343 }
1344}