Skip to main content

boxferry_podman/
export.rs

1//! Neutral application model to reviewable Podman deployment artifacts.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    error::Error,
6    fmt,
7};
8
9use boxferry_engine::{
10    ConversionKind, ConversionOutcome, ConversionPlan, Diagnostic, DiagnosticCode, DiagnosticField, DiagnosticValue,
11    ExportAdapter, InvalidDiagnosticCode, NativeFinding, PlanError, PlatformVersion, RuleId, Severity, TargetProfile,
12};
13use boxferry_model::{
14    Application, Command, Entrypoint, EnvironmentValue, MountSource, ProtectedString, PullPolicy, ResourceOwnership,
15    RestartPolicy, Service,
16};
17use podman_lens::artifact::deployment_v1;
18use podman_lens::{
19    AbsoluteContainerPath, ArgumentArray, ContainerHostname, ContainerIntent, ContainerUser, ContainerWorkdir,
20    DeploymentEnvironmentValue, DeploymentIntent, DeploymentResource, DeploymentResourceId, EnvironmentAssignment,
21    EnvironmentName, ExternalPrecondition, ImageIntent, ImagePullPolicy, ImageSource, Label, LabelKey, MountAccess,
22    NamedVolumeCopyMode, NamedVolumeMount, NetworkAttachment, NetworkIntent, ObservedApiVersion, ObservedPodmanVersion,
23    PlanningFinding, PodIntent, PublicEnvironmentValue, PublicLabelValue, RenderingFinding, ResourceKind, SecretIntent,
24    SensitiveInputReference, StartupDependency, TargetExecutionContext, TmpfsMount, VolumeIntent, plan_deployment,
25    render_deployment,
26};
27
28use crate::PodmanOutput;
29
30/// Target implementation name accepted by the Podman exporter.
31pub const PODMAN_TARGET: &str = "podman";
32
33const REVIEWED_VERSIONS: [PlatformVersion; 7] = [
34    PlatformVersion::new(5, 4, 0),
35    PlatformVersion::new(5, 5, 0),
36    PlatformVersion::new(5, 6, 0),
37    PlatformVersion::new(5, 7, 0),
38    PlatformVersion::new(5, 8, 6),
39    PlatformVersion::new(6, 0, 0),
40    PlatformVersion::new(6, 1, 0),
41];
42
43/// Returns exact Podman engine versions backed by `PodmanLens` rendering evidence.
44#[must_use]
45pub const fn reviewed_podman_versions() -> &'static [PlatformVersion] {
46    &REVIEWED_VERSIONS
47}
48
49/// A `BoxFerry` target resolved to one exact reviewed Podman engine and API pair.
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct ResolvedPodmanTarget {
52    version: PlatformVersion,
53    profile: podman_lens::TargetProfile,
54}
55
56impl ResolvedPodmanTarget {
57    /// Returns the exact reviewed engine version selected from the requested range.
58    #[must_use]
59    pub const fn version(&self) -> PlatformVersion {
60        self.version
61    }
62
63    /// Returns the validated `PodmanLens` engine/API profile.
64    #[must_use]
65    pub const fn profile(&self) -> &podman_lens::TargetProfile {
66        &self.profile
67    }
68}
69
70/// Failure to resolve a `BoxFerry` target to reviewed Podman rendering evidence.
71#[derive(Clone, Debug, Eq, PartialEq)]
72#[non_exhaustive]
73pub enum PodmanTargetError {
74    /// The requested implementation was not Podman.
75    ImplementationMismatch,
76    /// The requested version range contains no exact reviewed renderer target.
77    NoReviewedVersion,
78    /// `PodmanLens` rejected the embedded reviewed engine/API pair.
79    InvalidReviewedProfile(podman_lens::Diagnostic),
80}
81
82impl fmt::Display for PodmanTargetError {
83    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84        match self {
85            Self::ImplementationMismatch => formatter.write_str("target implementation must be podman"),
86            Self::NoReviewedVersion => formatter.write_str("target range contains no reviewed Podman renderer version"),
87            Self::InvalidReviewedProfile(error) => {
88                write!(formatter, "reviewed Podman target profile is invalid: {error}")
89            }
90        }
91    }
92}
93
94impl Error for PodmanTargetError {}
95
96/// Selects the newest exact reviewed Podman version in the requested inclusive range.
97///
98/// # Errors
99///
100/// Returns [`PodmanTargetError`] for another implementation, an unsupported range, or an
101/// internally inconsistent embedded `PodmanLens` target pair.
102pub fn resolve_podman_target(target: &TargetProfile) -> Result<ResolvedPodmanTarget, PodmanTargetError> {
103    if target.implementation() != PODMAN_TARGET {
104        return Err(PodmanTargetError::ImplementationMismatch);
105    }
106    let version = REVIEWED_VERSIONS
107        .iter()
108        .rev()
109        .copied()
110        .find(|version| target.versions().contains(*version))
111        .ok_or(PodmanTargetError::NoReviewedVersion)?;
112    let spelling = version.to_string();
113    let podman_version = ObservedPodmanVersion::parse(&spelling).map_err(PodmanTargetError::InvalidReviewedProfile)?;
114    let api_version = ObservedApiVersion::parse(&spelling).map_err(PodmanTargetError::InvalidReviewedProfile)?;
115    let profile = podman_lens::TargetProfile::new(podman_version, api_version)
116        .map_err(PodmanTargetError::InvalidReviewedProfile)?;
117    Ok(ResolvedPodmanTarget { version, profile })
118}
119
120/// Loss-aware Podman exporter producing only review artifacts.
121///
122/// Public service runtime names become target container identities without changing
123/// neutral logical identifiers. Protected names, invalid native identities, collisions,
124/// and references without logical service declarations reject the complete candidate.
125#[derive(Clone, Debug)]
126pub struct PodmanExporter {
127    codes: Codes,
128    execution_context: TargetExecutionContext,
129}
130
131impl PodmanExporter {
132    /// Creates an exporter without assuming rootful or rootless execution context.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`InvalidDiagnosticCode`] only if a repository-owned diagnostic code is malformed.
137    pub fn new() -> Result<Self, InvalidDiagnosticCode> {
138        Ok(Self {
139            codes: Codes {
140                invalid_target: RuleId::PodmanTargetInvalid.definition().diagnostic_code()?,
141                unsupported: RuleId::PodmanOutputUnsupported.definition().diagnostic_code()?,
142                planning: RuleId::PodmanPlanningFailed.definition().diagnostic_code()?,
143            },
144            execution_context: TargetExecutionContext::Unknown,
145        })
146    }
147
148    /// Attaches caller-proven target privilege context used by `PodmanLens` capability checks.
149    #[must_use]
150    pub const fn with_execution_context(mut self, context: TargetExecutionContext) -> Self {
151        self.execution_context = context;
152        self
153    }
154
155    /// Returns the caller-selected target privilege context.
156    #[must_use]
157    pub const fn execution_context(&self) -> TargetExecutionContext {
158        self.execution_context
159    }
160}
161
162impl ExportAdapter for PodmanExporter {
163    type Output = PodmanOutput;
164
165    fn plan(
166        &self,
167        application: &Application,
168        target: &TargetProfile,
169    ) -> Result<ConversionPlan<Self::Output>, PlanError> {
170        let (mut evidence_outcomes, mut evidence_diagnostics) =
171            boxferry_engine::retained_native_evidence_losses(application, &self.codes.unsupported)?;
172        let (metadata_outcomes, metadata_diagnostics) =
173            boxferry_engine::vendor_metadata_losses(application, &self.codes.unsupported)?;
174        evidence_outcomes.extend(metadata_outcomes);
175        evidence_diagnostics.extend(metadata_diagnostics);
176        let resolved = match resolve_podman_target(target) {
177            Ok(resolved) => resolved,
178            Err(error) => {
179                let versions = target.versions();
180                let maximum = versions
181                    .maximum()
182                    .map_or_else(|| "unbounded".to_owned(), |version| version.to_string());
183                let supported = reviewed_podman_versions()
184                    .iter()
185                    .map(ToString::to_string)
186                    .collect::<Vec<_>>()
187                    .join(", ");
188                let reason = error.to_string();
189                let diagnostic = Diagnostic::new(self.codes.invalid_target.clone(), Severity::Error, &reason)
190                    .with_field(DiagnosticField::new("subject", DiagnosticValue::plain("target.podman")))
191                    .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(reason)))
192                    .with_field(DiagnosticField::new("decision", DiagnosticValue::plain("rejected")))
193                    .with_field(DiagnosticField::new(
194                        "requested_minimum",
195                        DiagnosticValue::plain(versions.minimum().to_string()),
196                    ))
197                    .with_field(DiagnosticField::new(
198                        "requested_maximum",
199                        DiagnosticValue::plain(maximum),
200                    ))
201                    .with_field(DiagnosticField::new(
202                        "reviewed_targets",
203                        DiagnosticValue::plain(supported),
204                    ));
205                let target_outcome = ConversionOutcome::loss(
206                    "target.podman",
207                    ConversionKind::Invalid,
208                    self.codes.invalid_target.clone(),
209                )?;
210                let mut outcomes = vec![target_outcome];
211                outcomes.extend(evidence_outcomes);
212                let mut diagnostics = vec![diagnostic];
213                diagnostics.extend(evidence_diagnostics);
214                return ConversionPlan::new(None, outcomes, diagnostics);
215            }
216        };
217
218        let mut profile = resolved.profile().clone();
219        profile.set_execution_context(self.execution_context);
220        let mut mapping = Mapping::new(self, application, DeploymentIntent::new(profile));
221        mapping.outcomes.extend(evidence_outcomes);
222        mapping.diagnostics.extend(evidence_diagnostics);
223        mapping.map_application();
224        let Some(intent) = mapping.intent.take() else {
225            return ConversionPlan::new(None, mapping.outcomes, mapping.diagnostics);
226        };
227
228        let planning = plan_deployment(&intent);
229        if !planning.findings().is_empty() {
230            for finding in planning.findings() {
231                mapping.native_planning_error(finding);
232            }
233        }
234        let Some(plan) = planning.plan() else {
235            return ConversionPlan::new(None, mapping.outcomes, mapping.diagnostics);
236        };
237
238        let rendering = render_deployment(plan);
239        if !rendering.findings().is_empty() {
240            for finding in rendering.findings() {
241                mapping.native_rendering_error(finding);
242            }
243        }
244        let Some(rendering) = rendering.rendering() else {
245            return ConversionPlan::new(None, mapping.outcomes, mapping.diagnostics);
246        };
247
248        let Ok(deployment_json) = serde_json::to_string_pretty(&deployment_v1::deployment(rendering)) else {
249            mapping.error(
250                "deployment.serialize",
251                "Podman deployment artifact serialization failed",
252            );
253            return ConversionPlan::new(None, mapping.outcomes, mapping.diagnostics);
254        };
255        let output = PodmanOutput::new(resolved.version(), deployment_json, rendering.shell_script());
256        mapping.exact("deployment.artifacts");
257        ConversionPlan::new(Some(output), mapping.outcomes, mapping.diagnostics)
258    }
259}
260
261#[derive(Clone, Debug)]
262struct Codes {
263    invalid_target: DiagnosticCode,
264    unsupported: DiagnosticCode,
265    planning: DiagnosticCode,
266}
267
268struct Mapping<'a> {
269    exporter: &'a PodmanExporter,
270    application: &'a Application,
271    intent: Option<DeploymentIntent>,
272    volume_identities: BTreeMap<String, String>,
273    container_identities: BTreeMap<String, DeploymentResourceId>,
274    outcomes: Vec<ConversionOutcome>,
275    diagnostics: Vec<Diagnostic>,
276}
277
278impl<'a> Mapping<'a> {
279    const fn new(exporter: &'a PodmanExporter, application: &'a Application, intent: DeploymentIntent) -> Self {
280        Self {
281            exporter,
282            application,
283            intent: Some(intent),
284            volume_identities: BTreeMap::new(),
285            container_identities: BTreeMap::new(),
286            outcomes: Vec::new(),
287            diagnostics: Vec::new(),
288        }
289    }
290
291    fn map_application(&mut self) {
292        self.exact("application");
293        self.prepare_volume_identities();
294        self.prepare_container_identities();
295        self.map_networks();
296        self.map_volumes();
297        self.map_secrets();
298        self.map_groups();
299        self.map_services();
300        self.map_dependencies();
301    }
302
303    fn prepare_container_identities(&mut self) {
304        let mut owners = BTreeMap::<String, String>::new();
305        for service in self.application.services() {
306            let service = service.value();
307            let logical_name = service.name().as_str();
308            let subject = format!("services.{logical_name}.runtime_name");
309            let target_name = match service.runtime_name() {
310                Some(name) if name.value().is_sensitive() => {
311                    self.error(
312                        subject,
313                        "Protected Podman container runtime names cannot be emitted as target resource identities",
314                    );
315                    continue;
316                }
317                Some(name) => name.value().expose(),
318                None => logical_name,
319            };
320            let identity = match DeploymentResourceId::new(ResourceKind::Container, target_name) {
321                Ok(identity) => identity,
322                Err(error) => {
323                    self.native_error(&subject, error.code().as_str(), error.message(), "mapping");
324                    continue;
325                }
326            };
327            if let Some(first_logical_name) = owners.get(target_name) {
328                self.error(
329                    subject,
330                    format!(
331                        "Podman container identity resolves to the same name as logical service {first_logical_name:?}"
332                    ),
333                );
334            } else {
335                owners.insert(target_name.to_owned(), logical_name.to_owned());
336            }
337            self.container_identities.insert(logical_name.to_owned(), identity);
338        }
339    }
340
341    fn map_networks(&mut self) {
342        for network in self.application.networks() {
343            let network = network.value();
344            let subject = format!("networks.{}", network.name().as_str());
345            let result = self
346                .resource_id(ResourceKind::Network, network.name().as_str())
347                .and_then(|id| match network.ownership() {
348                    ResourceOwnership::Application => NetworkIntent::new(id).map(DeploymentResource::Network),
349                    ResourceOwnership::External | ResourceOwnership::Implicit | ResourceOwnership::Uncertain => {
350                        ExternalPrecondition::new(id).map(DeploymentResource::ExternalPrecondition)
351                    }
352                    _ => ExternalPrecondition::new(id).map(DeploymentResource::ExternalPrecondition),
353                });
354            self.add_resource_result(&subject, result);
355            if network.runtime_name().is_some() {
356                self.unsupported(
357                    format!("{subject}.runtime_name"),
358                    "PodmanLens network intent does not expose neutral runtime names; no automatic promotion exists",
359                );
360            }
361            if network.driver().is_some() {
362                self.unsupported(
363                    format!("{subject}.driver"),
364                    "PodmanLens network intent does not expose neutral network drivers; no automatic promotion exists",
365                );
366            }
367            if network.driver_options().is_some_and(|options| !options.is_empty()) {
368                self.unsupported(
369                    format!("{subject}.driver_options"),
370                    "PodmanLens network intent does not expose neutral driver options; no automatic promotion exists",
371                );
372            }
373            if network.labels().is_some_and(|labels| !labels.is_empty()) {
374                self.unsupported(
375                    format!("{subject}.labels"),
376                    "PodmanLens network intent does not expose neutral labels; no automatic promotion exists",
377                );
378            }
379            if network.internal().is_some() {
380                self.unsupported(
381                    format!("{subject}.internal"),
382                    "PodmanLens network intent does not expose neutral internal state; no automatic promotion exists",
383                );
384            }
385            if network.ipv6().is_some() {
386                self.unsupported(
387                    format!("{subject}.ipv6"),
388                    "PodmanLens network intent does not expose neutral IPv6 state; no automatic promotion exists",
389                );
390            }
391            if network.ipam_driver().is_some() {
392                self.unsupported(
393                    format!("{subject}.ipam_driver"),
394                    "PodmanLens network intent does not expose neutral IPAM drivers; no automatic promotion exists",
395                );
396            }
397            if network.ipam_configs().is_some() {
398                self.unsupported(
399                    format!("{subject}.ipam_configs"),
400                    "PodmanLens network intent does not expose neutral IPAM configuration rows; no automatic promotion exists",
401                );
402            }
403        }
404    }
405
406    fn map_volumes(&mut self) {
407        for volume in self.application.volumes() {
408            let volume = volume.value();
409            let subject = format!("volumes.{}", volume.name().as_str());
410            let Some(identity) = self.volume_identities.get(volume.name().as_str()).cloned() else {
411                continue;
412            };
413            let result = self
414                .resource_id(ResourceKind::Volume, &identity)
415                .and_then(|id| match volume.ownership() {
416                    ResourceOwnership::Application => VolumeIntent::new(id).map(DeploymentResource::Volume),
417                    ResourceOwnership::External | ResourceOwnership::Implicit | ResourceOwnership::Uncertain => {
418                        ExternalPrecondition::new(id).map(DeploymentResource::ExternalPrecondition)
419                    }
420                    _ => ExternalPrecondition::new(id).map(DeploymentResource::ExternalPrecondition),
421                });
422            self.add_resource_result(&subject, result);
423            if volume.driver().is_some()
424                || volume.device().is_some()
425                || volume.volume_type().is_some()
426                || volume.options().is_some()
427                || volume.labels().is_some()
428                || volume.user().is_some()
429                || volume.group().is_some()
430                || volume.uid().is_some()
431                || volume.gid().is_some()
432                || volume.image_source().is_some()
433            {
434                self.unsupported(
435                    format!("{subject}.settings"),
436                    "PodmanLens volume intent does not yet expose all neutral volume settings",
437                );
438            }
439        }
440    }
441
442    fn prepare_volume_identities(&mut self) {
443        let declared_names = self
444            .application
445            .volumes()
446            .iter()
447            .map(|sourced| sourced.value().name().as_str().to_owned())
448            .collect::<BTreeSet<_>>();
449        let identities = self
450            .application
451            .volumes()
452            .iter()
453            .map(|sourced| {
454                let volume = sourced.value();
455                let logical_name = volume.name().as_str().to_owned();
456                let target_name = match volume.runtime_name() {
457                    None => Some(logical_name.clone()),
458                    Some(runtime_name) if runtime_name.value().is_sensitive() => None,
459                    Some(runtime_name) => Some(runtime_name.value().expose().to_owned()),
460                };
461                (logical_name, target_name)
462            })
463            .collect::<Vec<_>>();
464        let mut owners = BTreeMap::<String, String>::new();
465        for (logical_name, target_name) in identities {
466            let Some(target_name) = target_name else {
467                self.error(
468                    format!("volumes.{logical_name}.runtime_name"),
469                    "Protected Podman volume runtime names cannot be emitted as target resource identities",
470                );
471                continue;
472            };
473            self.volume_identities.insert(logical_name.clone(), target_name.clone());
474            if let Some(first_logical_name) = owners.get(&target_name) {
475                self.error(
476                    format!("volumes.{logical_name}.runtime_name"),
477                    format!(
478                        "Podman volume identity resolves to the same name as logical volume {first_logical_name:?}"
479                    ),
480                );
481            } else {
482                owners.insert(target_name, logical_name);
483            }
484        }
485
486        let missing_references = self
487            .application
488            .services()
489            .iter()
490            .flat_map(|service| {
491                service
492                    .value()
493                    .mounts()
494                    .iter()
495                    .enumerate()
496                    .filter_map(|(index, mount)| match mount.value().source() {
497                        MountSource::Volume(name) if !declared_names.contains(name.as_str()) => Some(format!(
498                            "services.{}.mounts[{index}].source",
499                            service.value().name().as_str()
500                        )),
501                        _ => None,
502                    })
503            })
504            .collect::<Vec<_>>();
505        for subject in missing_references {
506            self.error(subject, "Named volume mount has no matching logical volume declaration");
507        }
508    }
509
510    fn map_secrets(&mut self) {
511        for secret in self.application.secrets() {
512            let secret = secret.value();
513            let subject = format!("secrets.{}", secret.name().as_str());
514            let result = self
515                .resource_id(ResourceKind::Secret, secret.name().as_str())
516                .and_then(|id| match secret.ownership() {
517                    ResourceOwnership::Application => {
518                        let reference =
519                            SensitiveInputReference::new(format!("boxferry-secret-{}", secret.name().as_str()))?;
520                        SecretIntent::new(id, reference).map(DeploymentResource::Secret)
521                    }
522                    ResourceOwnership::External | ResourceOwnership::Implicit | ResourceOwnership::Uncertain => {
523                        ExternalPrecondition::new(id).map(DeploymentResource::ExternalPrecondition)
524                    }
525                    _ => ExternalPrecondition::new(id).map(DeploymentResource::ExternalPrecondition),
526                });
527            self.add_resource_result(&subject, result);
528            if secret.runtime_name().is_some() {
529                self.unsupported(
530                    format!("{subject}.runtime_name"),
531                    "runtime secret names are not yet represented by PodmanLens deployment identities",
532                );
533            }
534            if secret.ownership() == ResourceOwnership::Application && secret.material().is_none() {
535                self.unsupported(
536                    format!("{subject}.material"),
537                    "application-managed secret requires caller-supplied deployment material",
538                );
539            }
540        }
541    }
542
543    fn map_groups(&mut self) {
544        for group in self.application.service_groups() {
545            let group = group.value();
546            let subject = format!("groups.{}", group.name().as_str());
547            if group.ownership() != ResourceOwnership::Application {
548                self.unsupported(
549                    format!("{subject}.ownership"),
550                    "runtime-imported or externally owned pod topology is not a Podman external precondition",
551                );
552                self.unsupported(
553                    format!("{subject}.members"),
554                    "runtime-imported pod membership is omitted unless pod lifecycle ownership is authored",
555                );
556                continue;
557            }
558            let result = self
559                .resource_id(ResourceKind::Pod, group.name().as_str())
560                .and_then(|id| match group.ownership() {
561                    ResourceOwnership::Application => {
562                        let mut pod = PodIntent::new(id)?;
563                        for member in group.members() {
564                            pod.add_member(self.resource_id(ResourceKind::Container, member.value().as_str())?)?;
565                        }
566                        Ok(DeploymentResource::Pod(pod))
567                    }
568                    _ => ExternalPrecondition::new(id).map(DeploymentResource::ExternalPrecondition),
569                });
570            self.add_resource_result(&subject, result);
571            if group.runtime().is_some() {
572                self.unsupported(
573                    format!("{subject}.runtime"),
574                    "PodmanLens pod intent does not yet expose neutral group runtime settings",
575                );
576            }
577        }
578    }
579
580    fn map_services(&mut self) {
581        for service in self.application.services() {
582            self.map_service(service.value());
583        }
584    }
585
586    fn map_service(&mut self, service: &Service) {
587        let subject = format!("services.{}", service.name().as_str());
588        let Some(image) = service.image() else {
589            self.error(
590                format!("{subject}.image"),
591                "Podman container export requires an image reference",
592            );
593            return;
594        };
595        let image_name = format!("{}-image", service.name().as_str());
596        let image_result = (|| {
597            let id = self.resource_id(ResourceKind::Image, &image_name)?;
598            let source = ImageSource::new(image.value().as_str())?;
599            let policy = match service.pull_policy().map(boxferry_model::Sourced::value) {
600                Some(PullPolicy::Always) => ImagePullPolicy::Always,
601                Some(PullPolicy::Never) => ImagePullPolicy::Never,
602                Some(PullPolicy::Missing) | None => ImagePullPolicy::Missing,
603                Some(_) => {
604                    self.unsupported(
605                        format!("{subject}.pull_policy"),
606                        "future neutral pull policy is not reviewed for Podman",
607                    );
608                    ImagePullPolicy::Missing
609                }
610            };
611            ImageIntent::new(id, source, policy).map(DeploymentResource::Image)
612        })();
613        self.add_resource_result(&format!("{subject}.image"), image_result);
614
615        let container_result = (|| {
616            let container_id = self.resource_id(ResourceKind::Container, service.name().as_str())?;
617            let image_id = self.resource_id(ResourceKind::Image, &image_name)?;
618            let mut container = ContainerIntent::new(container_id, image_id)?;
619            if let Some(group) = self.group_for(service.name().as_str()) {
620                container.set_pod(self.resource_id(ResourceKind::Pod, group)?)?;
621            }
622            self.map_service_settings(service, &mut container)?;
623            self.map_service_mounts(service, &mut container)?;
624            self.map_service_networks(service, &mut container);
625            Ok(DeploymentResource::Container(container))
626        })();
627        if container_result.is_ok() && service.runtime_name().is_some() {
628            self.exact(format!("{subject}.runtime_name"));
629        }
630        self.add_resource_result(&subject, container_result);
631        self.report_unmapped_service_fields(service, &subject);
632    }
633
634    #[allow(clippy::too_many_lines, reason = "keeps one container settings transaction atomic")]
635    fn map_service_settings(
636        &mut self,
637        service: &Service,
638        container: &mut ContainerIntent,
639    ) -> podman_lens::PodmanLensResult<()> {
640        if let Some(command) = service.command() {
641            match command.value() {
642                Command::Exec(arguments) => container
643                    .settings_mut()
644                    .set_command(ArgumentArray::new(expose(arguments))?)?,
645                Command::Shell(command) => {
646                    container
647                        .settings_mut()
648                        .set_command(ArgumentArray::new(["/bin/sh", "-c", command.expose()])?)?;
649                }
650                Command::Empty => self.unsupported(
651                    format!("services.{}.command", service.name().as_str()),
652                    "PodmanLens requires nonempty command arrays",
653                ),
654                _ => self.unsupported(
655                    format!("services.{}.command", service.name().as_str()),
656                    "future neutral command variant is not reviewed for Podman",
657                ),
658            }
659        }
660        if let Some(entrypoint) = service.entrypoint() {
661            match entrypoint.value() {
662                Entrypoint::Exec(arguments) => container
663                    .settings_mut()
664                    .set_entrypoint(ArgumentArray::new(expose(arguments))?)?,
665                Entrypoint::Shell(command) => {
666                    container.settings_mut().set_entrypoint(ArgumentArray::new([
667                        "/bin/sh",
668                        "-c",
669                        command.expose(),
670                    ])?)?;
671                }
672                Entrypoint::Empty => self.unsupported(
673                    format!("services.{}.entrypoint", service.name().as_str()),
674                    "PodmanLens requires nonempty entrypoint arrays",
675                ),
676                _ => self.unsupported(
677                    format!("services.{}.entrypoint", service.name().as_str()),
678                    "future neutral entrypoint variant is not reviewed for Podman",
679                ),
680            }
681        }
682        if let Some(user) = service.user() {
683            let spelling = match service.group() {
684                Some(group) => format!("{}:{}", user.value().expose(), group.value().expose()),
685                None => user.value().expose().to_owned(),
686            };
687            container.settings_mut().set_user(ContainerUser::new(spelling)?)?;
688        } else if service.group().is_some() {
689            self.unsupported(
690                format!("services.{}.group", service.name().as_str()),
691                "Podman user syntax cannot express a group without a user",
692            );
693        }
694        if let Some(workdir) = service.working_directory() {
695            let spelling = workdir.value().expose();
696            let normalized = if spelling.len() > 1 {
697                spelling.trim_end_matches('/')
698            } else {
699                spelling
700            };
701            match AbsoluteContainerPath::new(normalized)
702                .and_then(|path| container.settings_mut().set_workdir(ContainerWorkdir::new(path)))
703            {
704                Ok(()) => {}
705                Err(_) => self.unsupported(
706                    format!("services.{}.working_directory", service.name().as_str()),
707                    "PodmanLens requires a normalized absolute container working directory",
708                ),
709            }
710        }
711        if let Some(hostname) = service.hostname() {
712            container
713                .settings_mut()
714                .set_hostname(ContainerHostname::new(hostname.value().expose())?)?;
715        }
716        for label in service.labels() {
717            let label = label.value();
718            let key = LabelKey::new(label.name().as_str())?;
719            match PublicLabelValue::new(label.value().expose()) {
720                Ok(value) => container.settings_mut().add_label(Label::new(key, value))?,
721                Err(_) => self.unsupported(
722                    format!("services.{}.labels.{}", service.name().as_str(), label.name().as_str()),
723                    "PodmanLens cannot render this label value within its bounded public-value contract",
724                ),
725            }
726        }
727        let mut environments = service.environment().iter().collect::<Vec<_>>();
728        environments.sort_by(|left, right| left.value().name().as_str().cmp(right.value().name().as_str()));
729        for environment in environments {
730            let value = match environment.value().value() {
731                EnvironmentValue::Literal(value) if !value.is_sensitive() => {
732                    DeploymentEnvironmentValue::Public(PublicEnvironmentValue::new(value.expose())?)
733                }
734                EnvironmentValue::UnresolvedSource { .. } => {
735                    self.unsupported(
736                        format!(
737                            "services.{}.environment.{}",
738                            service.name().as_str(),
739                            environment.value().name().as_str()
740                        ),
741                        "resolve authored source environment syntax before Podman output",
742                    );
743                    continue;
744                }
745                EnvironmentValue::Deferred(_) => {
746                    self.unsupported(
747                        format!(
748                            "services.{}.environment.{}",
749                            service.name().as_str(),
750                            environment.value().name().as_str()
751                        ),
752                        "deferred service execution expressions require explicit resolution before Podman output",
753                    );
754                    continue;
755                }
756                EnvironmentValue::Required => {
757                    self.unsupported(
758                        format!(
759                            "services.{}.environment.{}",
760                            service.name().as_str(),
761                            environment.value().name().as_str()
762                        ),
763                        "environment value was withheld; supply this named value on the target before use",
764                    );
765                    continue;
766                }
767                EnvironmentValue::Literal(_) | EnvironmentValue::Host | EnvironmentValue::Unset => {
768                    self.unsupported(format!("services.{}.environment.{}", service.name().as_str(), environment.value().name().as_str()), "environment value requires external or sensitive-input planning not available from the neutral field");
769                    continue;
770                }
771                _ => {
772                    self.unsupported(
773                        format!("services.{}.environment", service.name().as_str()),
774                        "future neutral environment value is not reviewed for Podman",
775                    );
776                    continue;
777                }
778            };
779            container.settings_mut().add_environment(EnvironmentAssignment::new(
780                EnvironmentName::new(environment.value().name().as_str())?,
781                value,
782            ))?;
783        }
784        if let Some(restart) = service.restart_policy() {
785            let policy = match restart.value() {
786                RestartPolicy::Never => podman_lens::RestartPolicy::No,
787                RestartPolicy::Always => podman_lens::RestartPolicy::Always,
788                RestartPolicy::OnFailure { maximum_retries: None } => podman_lens::RestartPolicy::OnFailure,
789                RestartPolicy::UnlessStopped => podman_lens::RestartPolicy::UnlessStopped,
790                RestartPolicy::OnFailure {
791                    maximum_retries: Some(_),
792                } => {
793                    self.unsupported(
794                        format!("services.{}.restart_policy", service.name().as_str()),
795                        "PodmanLens restart intent does not retain finite retry count",
796                    );
797                    podman_lens::RestartPolicy::OnFailure
798                }
799                _ => {
800                    self.unsupported(
801                        format!("services.{}.restart_policy", service.name().as_str()),
802                        "future neutral restart policy is not reviewed for Podman",
803                    );
804                    podman_lens::RestartPolicy::No
805                }
806            };
807            container.settings_mut().set_restart_policy(policy)?;
808        }
809        Ok(())
810    }
811
812    fn map_service_mounts(
813        &mut self,
814        service: &Service,
815        container: &mut ContainerIntent,
816    ) -> podman_lens::PodmanLensResult<()> {
817        for (index, mount) in service.mounts().iter().enumerate() {
818            let access = if mount.value().read_only() {
819                MountAccess::ReadOnly
820            } else {
821                MountAccess::ReadWrite
822            };
823            let destination = AbsoluteContainerPath::new(mount.value().target())?;
824            match mount.value().source() {
825                MountSource::Volume(name) => {
826                    let Some(identity) = self.volume_identities.get(name.as_str()) else {
827                        continue;
828                    };
829                    let source = self.resource_id(ResourceKind::Volume, identity)?;
830                    container.add_mount(NamedVolumeMount::new(
831                        source,
832                        destination,
833                        access,
834                        NamedVolumeCopyMode::Copy,
835                    )?);
836                }
837                MountSource::HostPath(_) => self.unsupported(
838                    format!(
839                        "services.{}.mounts[{index}].source",
840                        service.name().as_str()
841                    ),
842                    "PodmanLens deployment intent does not expose host bind sources; no automatic promotion exists",
843                ),
844                MountSource::Anonymous => self.unsupported(
845                    format!(
846                        "services.{}.mounts[{index}].source",
847                        service.name().as_str()
848                    ),
849                    "PodmanLens deployment intent does not expose anonymous mount sources; no automatic promotion exists",
850                ),
851                _ => self.unsupported(
852                    format!(
853                        "services.{}.mounts[{index}].source",
854                        service.name().as_str()
855                    ),
856                    "future neutral mount source is not reviewed for Podman; no automatic promotion exists",
857                ),
858            }
859            if mount.value().selinux_relabel().is_some() {
860                self.unsupported(
861                    format!("services.{}.mounts[{index}].selinux_relabel", service.name().as_str()),
862                    "PodmanLens named-volume intent does not expose SELinux relabel mode",
863                );
864            }
865        }
866        if let Some(tmpfs_mounts) = service.tmpfs() {
867            if tmpfs_mounts.is_empty() {
868                self.exact(format!("services.{}.tmpfs", service.name().as_str()));
869            }
870            for (index, tmpfs) in tmpfs_mounts.iter().enumerate() {
871                let subject = format!("services.{}.tmpfs[{index}]", service.name().as_str());
872                if tmpfs.value().is_sensitive() {
873                    self.error(
874                        subject,
875                        "sensitive tmpfs destination cannot be rendered without exposing protected input",
876                    );
877                    continue;
878                }
879                let spelling = tmpfs.value().expose();
880                let (destination, options) = spelling
881                    .split_once(':')
882                    .map_or((spelling, None), |(destination, options)| (destination, Some(options)));
883                let destination = AbsoluteContainerPath::new(destination)?;
884                container.add_mount(TmpfsMount::new(destination, MountAccess::ReadWrite));
885                if options.is_some() {
886                    self.unsupported(
887                        format!("{subject}.options"),
888                        "tmpfs options are not represented by the current PodmanLens tmpfs intent",
889                    );
890                } else {
891                    self.exact(subject);
892                }
893            }
894        }
895
896        Ok(())
897    }
898
899    fn map_service_networks(&mut self, service: &Service, container: &mut ContainerIntent) {
900        for (index, network) in service.networks().iter().enumerate() {
901            let subject = format!("services.{}.networks[{index}]", service.name().as_str());
902            let attachment = self
903                .resource_id(ResourceKind::Network, network.value().network().as_str())
904                .and_then(NetworkAttachment::new);
905            let mut attachment = match attachment {
906                Ok(attachment) => attachment,
907                Err(error) => {
908                    self.native_error(&subject, error.code().as_str(), error.message(), "mapping");
909                    continue;
910                }
911            };
912            let mut aliases_valid = true;
913            for (alias_index, alias) in network.value().aliases().iter().enumerate() {
914                if let Err(error) = attachment.add_alias(alias) {
915                    self.native_error(
916                        &format!("{subject}.aliases[{alias_index}]"),
917                        error.code().as_str(),
918                        error.message(),
919                        "mapping",
920                    );
921                    aliases_valid = false;
922                    break;
923                }
924            }
925            if !aliases_valid {
926                continue;
927            }
928            if network.value().ipv4_address().is_some() || network.value().ipv6_address().is_some() {
929                self.unsupported(
930                    format!("services.{}.networks[{index}].addresses", service.name().as_str()),
931                    "static neutral network addresses are not yet mapped to PodmanLens attachment intent",
932                );
933            }
934            if let Err(error) = container.add_network(attachment) {
935                self.native_error(&subject, error.code().as_str(), error.message(), "mapping");
936            }
937        }
938    }
939
940    fn map_dependencies(&mut self) {
941        for service in self.application.services() {
942            for (index, dependency) in service.value().dependencies().iter().enumerate() {
943                let result = (|| {
944                    let predecessor =
945                        self.resource_id(ResourceKind::Container, dependency.value().service().as_str())?;
946                    let dependent = self.resource_id(ResourceKind::Container, service.value().name().as_str())?;
947                    StartupDependency::new(predecessor, dependent)
948                })();
949                match result {
950                    Ok(edge) => {
951                        if let Some(intent) = &mut self.intent {
952                            intent.add_startup_dependency(edge);
953                        }
954                        self.exact(format!(
955                            "services.{}.dependencies[{index}]",
956                            service.value().name().as_str()
957                        ));
958                    }
959                    Err(error) => self.native_error(
960                        &format!("services.{}.dependencies[{index}]", service.value().name().as_str()),
961                        error.code().as_str(),
962                        error.message(),
963                        "mapping",
964                    ),
965                }
966                if dependency.value().condition().is_some()
967                    || dependency.value().restart().is_some()
968                    || dependency.value().required().is_some()
969                {
970                    self.unsupported(
971                        format!(
972                            "services.{}.dependencies[{index}].options",
973                            service.value().name().as_str()
974                        ),
975                        "Podman startup edges do not represent Compose dependency options",
976                    );
977                }
978            }
979        }
980    }
981
982    fn report_unmapped_service_fields(&mut self, service: &Service, subject: &str) {
983        for (field, present) in [
984            ("execution_environment", service.execution_environment().is_some()),
985            ("rootfs", service.rootfs().is_some()),
986            ("image_acquisition", service.image_acquisition().is_some()),
987            ("image_build", service.image_build().is_some()),
988            ("startup_notification", service.startup_notification().is_some()),
989            ("run_init", service.run_init().is_some()),
990            ("stop_timeout", service.stop_timeout().is_some()),
991            ("memory_limit", service.memory_limit().is_some()),
992            ("exposed_ports", service.exposed_ports().is_some()),
993            ("healthcheck", service.healthcheck().is_some()),
994            ("annotations", service.annotations().is_some()),
995            ("logging", service.logging().is_some()),
996            ("reload_action", service.reload_action().is_some()),
997            ("user_namespace", service.user_namespace().is_some()),
998            ("supplementary_groups", !service.supplementary_groups().is_empty()),
999            (
1000                "read_only_root_filesystem",
1001                service.read_only_root_filesystem().is_some(),
1002            ),
1003            ("dns_servers", service.dns_servers().is_some()),
1004            ("dns_options", service.dns_options().is_some()),
1005            ("dns_search_domains", service.dns_search_domains().is_some()),
1006            ("security_options", service.security_options().is_some()),
1007            ("pids_limit", service.pids_limit().is_some()),
1008            ("shm_size", service.shm_size().is_some()),
1009            ("cap_add", service.cap_add().is_some()),
1010            ("cap_drop", service.cap_drop().is_some()),
1011            ("sysctls", service.sysctls().is_some()),
1012            ("ulimits", service.ulimits().is_some()),
1013            ("devices", service.devices().is_some()),
1014            ("stop_signal", service.stop_signal().is_some()),
1015            ("environment_files", !service.environment_files().is_empty()),
1016            ("host_mappings", !service.host_mappings().is_empty()),
1017            ("ports", !service.ports().is_empty()),
1018            ("config_grants", !service.config_grants().is_empty()),
1019            ("secret_grants", !service.secret_grants().is_empty()),
1020        ] {
1021            if present {
1022                self.unsupported(
1023                    format!("{subject}.{field}"),
1024                    "neutral service field has no reviewed PodmanLens mapping yet",
1025                );
1026            }
1027        }
1028    }
1029
1030    fn group_for(&self, service: &str) -> Option<&str> {
1031        self.application.service_groups().iter().find_map(|group| {
1032            (group.value().ownership() == ResourceOwnership::Application)
1033                .then_some(group.value())
1034                .and_then(|group| {
1035                    group
1036                        .members()
1037                        .iter()
1038                        .any(|member| member.value().as_str() == service)
1039                        .then(|| group.name().as_str())
1040                })
1041        })
1042    }
1043
1044    fn resource_id(&self, kind: ResourceKind, name: &str) -> podman_lens::PodmanLensResult<DeploymentResourceId> {
1045        if kind == ResourceKind::Container {
1046            // Resolve exactly once: a runtime alias may itself be another logical name.
1047            // Missing declarations must never resolve through native-name coincidence.
1048            return self
1049                .container_identities
1050                .get(name)
1051                .cloned()
1052                .ok_or_else(|| podman_lens::Diagnostic::new(podman_lens::DiagnosticCode::InvalidDeploymentIntent));
1053        }
1054        DeploymentResourceId::new(kind, name)
1055    }
1056
1057    fn add_resource_result(&mut self, subject: &str, result: podman_lens::PodmanLensResult<DeploymentResource>) {
1058        match result {
1059            Ok(resource) => {
1060                if let Some(intent) = &mut self.intent {
1061                    intent.add_resource(resource);
1062                }
1063                self.exact(subject);
1064            }
1065            Err(error) => self.native_error(subject, error.code().as_str(), error.message(), "mapping"),
1066        }
1067    }
1068
1069    fn exact(&mut self, subject: impl Into<String>) {
1070        self.outcomes.push(ConversionOutcome::exact(subject));
1071    }
1072
1073    fn unsupported(&mut self, subject: impl Into<String>, summary: impl Into<String>) {
1074        let subject = subject.into();
1075        let summary = summary.into();
1076        self.diagnostics.push(
1077            Diagnostic::new(
1078                self.exporter.codes.unsupported.clone(),
1079                Severity::Warning,
1080                &summary,
1081            )
1082            .with_field(DiagnosticField::new(
1083                "subject",
1084                DiagnosticValue::plain(&subject),
1085            ))
1086            .with_field(DiagnosticField::new(
1087                "reason",
1088                DiagnosticValue::plain(summary),
1089            ))
1090            .with_field(DiagnosticField::new(
1091                "decision",
1092                DiagnosticValue::plain("omitted"),
1093            ))
1094            .with_field(DiagnosticField::new(
1095                "required_loss_policy",
1096                DiagnosticValue::plain("partial"),
1097            ))
1098            .with_field(DiagnosticField::new(
1099                "available_promotion",
1100                DiagnosticValue::plain("none"),
1101            ))
1102            .with_field(DiagnosticField::new(
1103                "remediation",
1104                DiagnosticValue::plain(
1105                    "Re-author the omitted field in target-compatible configuration or remove it after review; no automatic promotion exists.",
1106                ),
1107            )),
1108        );
1109        if let Ok(outcome) = ConversionOutcome::loss(
1110            subject,
1111            ConversionKind::Unsupported,
1112            self.exporter.codes.unsupported.clone(),
1113        ) {
1114            self.outcomes.push(outcome);
1115        } else {
1116            self.intent = None;
1117        }
1118    }
1119
1120    fn error(&mut self, subject: impl Into<String>, summary: impl Into<String>) {
1121        let subject = subject.into();
1122        let summary = summary.into();
1123        self.diagnostics.push(
1124            Diagnostic::new(self.exporter.codes.planning.clone(), Severity::Error, &summary)
1125                .with_field(DiagnosticField::new("subject", DiagnosticValue::plain(&subject)))
1126                .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(summary)))
1127                .with_field(DiagnosticField::new("decision", DiagnosticValue::plain("rejected"))),
1128        );
1129        if let Ok(outcome) =
1130            ConversionOutcome::loss(subject, ConversionKind::Invalid, self.exporter.codes.planning.clone())
1131        {
1132            self.outcomes.push(outcome);
1133        }
1134        self.intent = None;
1135    }
1136
1137    fn native_error(&mut self, subject: &str, native_code: &str, summary: &str, stage: &'static str) {
1138        let subject_field = DiagnosticField::new("subject", DiagnosticValue::plain(subject));
1139        let native = NativeFinding::new("podman", "podman-lens", native_code, stage, Severity::Error, summary)
1140            .with_field(subject_field.clone())
1141            .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(summary)));
1142        self.diagnostics.push(
1143            Diagnostic::new(
1144                self.exporter.codes.planning.clone(),
1145                Severity::Error,
1146                "PodmanLens could not map, plan, or render deployment intent",
1147            )
1148            .with_field(subject_field)
1149            .with_field(DiagnosticField::new("reason", DiagnosticValue::plain(summary)))
1150            .with_field(DiagnosticField::new("decision", DiagnosticValue::plain("rejected")))
1151            .with_native_finding(native),
1152        );
1153        if let Ok(outcome) =
1154            ConversionOutcome::loss(subject, ConversionKind::Invalid, self.exporter.codes.planning.clone())
1155        {
1156            self.outcomes.push(outcome);
1157        }
1158        self.intent = None;
1159    }
1160
1161    fn native_planning_error(&mut self, finding: &PlanningFinding) {
1162        let subject = finding.subject().map_or_else(
1163            || "deployment.plan".to_owned(),
1164            |resource| format!("{}.{}", resource_kind_name(resource.kind()), resource.name()),
1165        );
1166        let mut fields = vec![
1167            DiagnosticField::new("subject", DiagnosticValue::plain(subject.clone())),
1168            DiagnosticField::new("reason", DiagnosticValue::plain(finding.message())),
1169            DiagnosticField::new("decision", DiagnosticValue::plain("rejected")),
1170        ];
1171        if let Some(resource) = finding.subject() {
1172            fields.push(DiagnosticField::new(
1173                "resource_kind",
1174                DiagnosticValue::plain(resource_kind_name(resource.kind())),
1175            ));
1176            fields.push(DiagnosticField::new(
1177                "resource_name",
1178                DiagnosticValue::plain(resource.name()),
1179            ));
1180        }
1181        if let Some(field) = finding.field() {
1182            fields.push(DiagnosticField::new("intent_field", DiagnosticValue::plain(field)));
1183        }
1184        if !finding.related().is_empty() {
1185            fields.push(DiagnosticField::new(
1186                "related_resources",
1187                DiagnosticValue::plain(
1188                    finding
1189                        .related()
1190                        .iter()
1191                        .map(|resource| format!("{}:{}", resource_kind_name(resource.kind()), resource.name()))
1192                        .collect::<Vec<_>>()
1193                        .join(","),
1194                ),
1195            ));
1196        }
1197        if let Some(occurrence) = finding.occurrence() {
1198            fields.push(DiagnosticField::new(
1199                "occurrence",
1200                DiagnosticValue::plain(occurrence.to_string()),
1201            ));
1202        }
1203        if let Some(count) = finding.count() {
1204            fields.push(DiagnosticField::new("count", DiagnosticValue::plain(count.to_string())));
1205        }
1206
1207        let mut native = NativeFinding::new(
1208            "podman",
1209            "podman-lens",
1210            finding.code().as_str(),
1211            "planning",
1212            Severity::Error,
1213            finding.message(),
1214        );
1215        let mut diagnostic = Diagnostic::new(
1216            self.exporter.codes.planning.clone(),
1217            Severity::Error,
1218            "PodmanLens could not map, plan, or render deployment intent",
1219        );
1220        for field in fields {
1221            native = native.with_field(field.clone());
1222            diagnostic = diagnostic.with_field(field);
1223        }
1224        self.diagnostics.push(diagnostic.with_native_finding(native));
1225        if let Ok(outcome) =
1226            ConversionOutcome::loss(subject, ConversionKind::Invalid, self.exporter.codes.planning.clone())
1227        {
1228            self.outcomes.push(outcome);
1229        }
1230        self.intent = None;
1231    }
1232
1233    fn native_rendering_error(&mut self, finding: &RenderingFinding) {
1234        let subject = finding.subject().map_or_else(
1235            || "deployment.render".to_owned(),
1236            |resource| format!("{}.{}", resource_kind_name(resource.kind()), resource.name()),
1237        );
1238        let mut fields = vec![
1239            DiagnosticField::new("subject", DiagnosticValue::plain(subject.clone())),
1240            DiagnosticField::new("reason", DiagnosticValue::plain(finding.message())),
1241            DiagnosticField::new("decision", DiagnosticValue::plain("rejected")),
1242        ];
1243        if let Some(resource) = finding.subject() {
1244            fields.push(DiagnosticField::new(
1245                "resource_kind",
1246                DiagnosticValue::plain(resource_kind_name(resource.kind())),
1247            ));
1248            fields.push(DiagnosticField::new(
1249                "resource_name",
1250                DiagnosticValue::plain(resource.name()),
1251            ));
1252        }
1253        if let Some(field) = finding.field() {
1254            fields.push(DiagnosticField::new("intent_field", DiagnosticValue::plain(field)));
1255        }
1256
1257        let mut native = NativeFinding::new(
1258            "podman",
1259            "podman-lens",
1260            finding.code().as_str(),
1261            "rendering",
1262            Severity::Error,
1263            finding.message(),
1264        );
1265        let mut diagnostic = Diagnostic::new(
1266            self.exporter.codes.planning.clone(),
1267            Severity::Error,
1268            "PodmanLens could not map, plan, or render deployment intent",
1269        );
1270        for field in fields {
1271            native = native.with_field(field.clone());
1272            diagnostic = diagnostic.with_field(field);
1273        }
1274        self.diagnostics.push(diagnostic.with_native_finding(native));
1275        if let Ok(outcome) =
1276            ConversionOutcome::loss(subject, ConversionKind::Invalid, self.exporter.codes.planning.clone())
1277        {
1278            self.outcomes.push(outcome);
1279        }
1280        self.intent = None;
1281    }
1282}
1283
1284const fn resource_kind_name(kind: ResourceKind) -> &'static str {
1285    match kind {
1286        ResourceKind::Container => "container",
1287        ResourceKind::Pod => "pod",
1288        ResourceKind::Network => "network",
1289        ResourceKind::Volume => "volume",
1290        ResourceKind::Image => "image",
1291        ResourceKind::Secret => "secret",
1292        _ => "resource",
1293    }
1294}
1295
1296fn expose(values: &[ProtectedString]) -> Vec<&str> {
1297    values.iter().map(ProtectedString::expose).collect()
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302    use std::error::Error;
1303
1304    use boxferry_model::{Identifier, ImageReference, ServiceDependency, ServiceGroup, Sourced};
1305    use podman_lens::SemanticOperationAction;
1306
1307    use super::*;
1308
1309    #[test]
1310    fn native_members_dependencies_and_operation_ids_resolve_logical_names_once() -> Result<(), Box<dyn Error>> {
1311        let mut application = Application::new(Identifier::new("identity-test")?);
1312        for (logical, native) in [("web", "worker"), ("worker", "third")] {
1313            let mut service = Service::new(Identifier::new(logical)?);
1314            service.set_image(Sourced::generated(ImageReference::parse("example.invalid/web:1")?));
1315            service.set_runtime_name(Sourced::generated(ProtectedString::plain(native)));
1316            if logical == "worker" {
1317                service.add_dependency(Sourced::generated(ServiceDependency::new(Identifier::new("web")?)));
1318            }
1319            application.add_service(Sourced::generated(service))?;
1320        }
1321        let mut group = ServiceGroup::new(Identifier::new("app-pod")?, ResourceOwnership::Application);
1322        group.add_member(Sourced::generated(Identifier::new("web")?))?;
1323        application.add_service_group(Sourced::generated(group))?;
1324        let target = TargetProfile::new(PODMAN_TARGET, PlatformVersion::new(6, 1, 0), None)?;
1325        let resolved = resolve_podman_target(&target)?;
1326        let exporter = PodmanExporter::new()?;
1327        let mut mapping = Mapping::new(
1328            &exporter,
1329            &application,
1330            DeploymentIntent::new(resolved.profile().clone()),
1331        );
1332        mapping.map_application();
1333        assert!(mapping.diagnostics.is_empty(), "{:?}", mapping.diagnostics);
1334        assert_eq!(mapping.resource_id(ResourceKind::Container, "web")?.name(), "worker");
1335        assert_eq!(mapping.resource_id(ResourceKind::Container, "worker")?.name(), "third");
1336        assert!(mapping.resource_id(ResourceKind::Container, "third").is_err());
1337        let intent = mapping.intent.as_ref().ok_or("mapped intent expected")?;
1338        let edge = intent.startup_dependencies().first().ok_or("dependency expected")?;
1339        assert_eq!(edge.predecessor().name(), "worker");
1340        assert_eq!(edge.dependent().name(), "third");
1341        for resource in intent.resources() {
1342            match resource {
1343                DeploymentResource::Pod(pod) => {
1344                    assert_eq!(
1345                        pod.members(),
1346                        &[DeploymentResourceId::new(ResourceKind::Container, "worker")?]
1347                    );
1348                }
1349                DeploymentResource::Container(container) if container.identity().name() == "worker" => {
1350                    assert_eq!(container.pod().map(DeploymentResourceId::name), Some("app-pod"));
1351                    assert_eq!(container.image().name(), "web-image");
1352                }
1353                DeploymentResource::Container(container) => {
1354                    assert_eq!(container.identity().name(), "third");
1355                    assert!(container.pod().is_none());
1356                    assert_eq!(container.image().name(), "worker-image");
1357                }
1358                _ => {}
1359            }
1360        }
1361        let planning = plan_deployment(intent);
1362        assert!(planning.findings().is_empty(), "{:?}", planning.findings());
1363        let operations = planning.plan().ok_or("native plan expected")?.operations();
1364        let starts = operations
1365            .iter()
1366            .filter(|operation| {
1367                matches!(
1368                    operation.id().action(),
1369                    SemanticOperationAction::StartPod | SemanticOperationAction::StartContainer
1370                )
1371            })
1372            .collect::<Vec<_>>();
1373        assert_eq!(starts.len(), 2);
1374        assert_eq!(starts[0].id().resource().name(), "app-pod");
1375        assert_eq!(starts[1].id().resource().name(), "third");
1376        assert!(starts[1].depends_on().contains(starts[0].id()));
1377        assert!(
1378            starts[0]
1379                .depends_on()
1380                .iter()
1381                .any(|id| id.action() == SemanticOperationAction::Create
1382                    && id.resource().kind() == ResourceKind::Container
1383                    && id.resource().name() == "worker")
1384        );
1385        Ok(())
1386    }
1387}