Skip to main content

boxferry_runtime/
reconstruct.rs

1//! Pure mapping from effective runtime observations to application intent.
2
3use 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/// Caller-selected treatment of effective values that may originate from image defaults.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21#[non_exhaustive]
22pub enum OverrideReconstruction {
23    /// Preserve every supported effective value as an explicit neutral-model value.
24    PreserveObservedState,
25    /// Compare effective container values with linked image defaults and retain inferred overrides.
26    InferImageOverrides,
27}
28
29/// Pure runtime-snapshot importer with an explicit override-reconstruction policy.
30///
31/// This importer performs no inspection itself. Docker and Podman adapters must construct a
32/// [`RuntimeSnapshot`] from effective native responses, use stable redacted source identities,
33/// and supply complete observed collections explicitly. Runtime reconstruction always reports
34/// application-level uncertainty because effective state cannot prove the original definition.
35#[derive(Clone, Debug)]
36pub struct RuntimeImporter {
37    override_reconstruction: OverrideReconstruction,
38    resolutions: RuntimeResolutions,
39    codes: Codes,
40}
41
42impl RuntimeImporter {
43    /// Creates an importer with an explicit override-reconstruction policy.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`InvalidDiagnosticCode`] only when a code embedded in this adapter is invalid.
48    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    /// Returns the caller-selected override-reconstruction policy.
68    #[must_use]
69    pub const fn override_reconstruction(&self) -> OverrideReconstruction {
70        self.override_reconstruction
71    }
72
73    /// Applies finite caller-owned lifecycle resolutions during reconstruction.
74    #[must_use]
75    pub fn with_resolutions(mut self, resolutions: RuntimeResolutions) -> Self {
76        self.resolutions = resolutions;
77        self
78    }
79
80    /// Returns the exact caller-owned lifecycle resolutions used by this importer.
81    #[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}