Skip to main content

podman_lens/
deployment.rs

1//! Typed, transport-neutral Podman deployment intent and deterministic semantic planning.
2//!
3//! This module deliberately stops before command-line and Libpod HTTP rendering.  A plan says
4//! *what* must be prepared, created, and started, and why one operation precedes another. M6
5//! renders those semantics into version-specific transport representations.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use crate::networking::{
10    DnsConfiguration, HostAlias, NetworkAttachment, NetworkRoute, NetworkSubnet, PortMapping, add_attachment, add_host,
11    add_port, add_route, add_subnet,
12};
13use crate::settings::{ContainerSettings, MountIntent, SecretGrant, UnixId};
14use crate::{
15    CgroupController, ContainerRuntimeSettings, Diagnostic, DiagnosticCode, PodmanLensResult, ResourceKind,
16    TargetExecutionContext, TargetProfile,
17};
18
19const MAX_REFERENCE_BYTES: usize = 256;
20const MAX_CONNECTION_NAME_BYTES: usize = 64;
21
22/// Stable target-side identity for one explicitly declared Podman resource.
23#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
24pub struct DeploymentResourceId {
25    kind: ResourceKind,
26    name: String,
27}
28
29impl DeploymentResourceId {
30    /// Creates a resource identity from an exact, non-empty target-side name.
31    ///
32    /// Names are retained as native spelling. Control characters and oversized values are
33    /// rejected so they cannot become ambiguous operation identifiers or rendered arguments.
34    ///
35    /// # Errors
36    ///
37    /// Returns `PLN0034` when `name` is empty, contains a control character, or is too large.
38    pub fn new(kind: ResourceKind, name: impl Into<String>) -> PodmanLensResult<Self> {
39        let name = name.into();
40        validate_identifier(&name)?;
41        Ok(Self { kind, name })
42    }
43
44    /// Returns the native resource kind.
45    #[must_use]
46    pub const fn kind(&self) -> ResourceKind {
47        self.kind
48    }
49
50    /// Returns the exact target-side resource name.
51    #[must_use]
52    pub fn name(&self) -> &str {
53        &self.name
54    }
55}
56
57/// A non-sensitive, caller-owned Podman connection name for a deployment plan.
58///
59/// It is metadata only. Planning does not discover, open, or validate a connection. A connection
60/// name is deliberately narrower than a connection URI: it is 1–64 ASCII bytes, starts with an
61/// ASCII alphanumeric character, and otherwise contains only ASCII alphanumeric characters,
62/// dots, underscores, or hyphens. Endpoint, path, credential, and token-like spellings therefore
63/// cannot enter a rendered artifact.
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct DeploymentConnectionReference(String);
66
67impl DeploymentConnectionReference {
68    /// Creates a non-sensitive Podman connection name.
69    ///
70    /// # Errors
71    ///
72    /// Returns `PLN0034` when the name is empty, oversized, or outside the safe connection-name
73    /// grammar.
74    pub fn new(value: impl Into<String>) -> PodmanLensResult<Self> {
75        let value = value.into();
76        validate_connection_name(&value)?;
77        Ok(Self(value))
78    }
79
80    /// Returns the caller-selected connection reference.
81    #[must_use]
82    pub fn as_str(&self) -> &str {
83        &self.0
84    }
85}
86
87/// An external reference to secret material that is intentionally absent from a deployment plan.
88#[derive(Clone, Eq, PartialEq)]
89pub struct SensitiveInputReference(String);
90
91impl SensitiveInputReference {
92    /// Creates a reference to externally managed sensitive material.
93    ///
94    /// Literal and encoded payload prefixes are rejected. Encoding is not protection, and a
95    /// deployment plan must never embed secret bytes.
96    ///
97    /// # Errors
98    ///
99    /// Returns `PLN0040` for a likely embedded payload and `PLN0034` for an invalid reference.
100    pub fn new(value: impl Into<String>) -> PodmanLensResult<Self> {
101        let value = value.into();
102        if ["literal:", "plaintext:", "base64:"].iter().any(|prefix| {
103            value
104                .get(..prefix.len())
105                .is_some_and(|start| start.eq_ignore_ascii_case(prefix))
106        }) {
107            return Err(Diagnostic::new(DiagnosticCode::SensitivePayloadEmbedded));
108        }
109        validate_identifier(&value)?;
110        Ok(Self(value))
111    }
112
113    /// Returns the external reference, never secret material itself.
114    #[must_use]
115    pub fn as_str(&self) -> &str {
116        &self.0
117    }
118}
119
120impl std::fmt::Debug for SensitiveInputReference {
121    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        formatter.write_str("SensitiveInputReference([redacted])")
123    }
124}
125
126/// One fully resolved image acquisition intent.
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct ImageIntent {
129    identity: DeploymentResourceId,
130    source: ImageSource,
131    pull_policy: ImagePullPolicy,
132}
133
134/// The explicit image-acquisition policy for a managed image.
135#[derive(Clone, Copy, Debug, Eq, PartialEq)]
136#[non_exhaustive]
137pub enum ImagePullPolicy {
138    /// Always request a fresh image from the declared source.
139    Always,
140    /// Acquire the image only when it is not already available at the target.
141    Missing,
142    /// Never pull; require the exact image to have been made available separately.
143    Never,
144    /// Request a newer image when the target can compare one.
145    Newer,
146}
147
148impl ImagePullPolicy {
149    /// Returns Podman's exact lower-case policy spelling.
150    #[must_use]
151    pub const fn as_str(self) -> &'static str {
152        match self {
153            Self::Always => "always",
154            Self::Missing => "missing",
155            Self::Never => "never",
156            Self::Newer => "newer",
157        }
158    }
159}
160
161/// The portable classification of an image source without changing its spelling.
162#[derive(Clone, Copy, Debug, Eq, PartialEq)]
163#[non_exhaustive]
164pub enum ImageSourceClassification {
165    /// A registry-qualified source with an explicit tag or sha256 digest.
166    Portable,
167    /// An explicitly local source such as `localhost/example:1` or a content ID.
168    Local,
169    /// A repository without an explicit registry hostname.
170    Unqualified,
171    /// A registry-qualified repository without an explicit tag or digest.
172    Tagless,
173}
174
175/// A validated source image spelling retained exactly as provided by the caller.
176#[derive(Clone, Debug, Eq, PartialEq)]
177pub struct ImageSource {
178    spelling: String,
179    classification: ImageSourceClassification,
180}
181
182impl ImageSource {
183    /// Validates and classifies an image source without rewriting it.
184    ///
185    /// # Errors
186    ///
187    /// Returns `PLN0041` for empty, control-containing, credential-bearing, or malformed
188    /// sources.
189    pub fn new(value: impl Into<String>) -> PodmanLensResult<Self> {
190        let spelling = value.into();
191        let classification =
192            classify_image_source(&spelling).ok_or_else(|| Diagnostic::new(DiagnosticCode::InvalidImageReference))?;
193        Ok(Self {
194            spelling,
195            classification,
196        })
197    }
198
199    /// Returns the exact unmodified source spelling.
200    #[must_use]
201    pub fn as_str(&self) -> &str {
202        &self.spelling
203    }
204
205    /// Returns the portability classification without altering this source.
206    #[must_use]
207    pub const fn classification(&self) -> ImageSourceClassification {
208        self.classification
209    }
210}
211
212impl ImageIntent {
213    /// Declares an image that must be available before dependent containers are created.
214    ///
215    /// `source` is an image reference, not a shell fragment or secret-bearing credential.
216    ///
217    /// # Errors
218    ///
219    /// Returns `PLN0034` for an image identity mismatch and `PLN0041` for an invalid source spelling.
220    ///
221    /// The pull policy is required explicitly. There is no hidden default because image
222    /// acquisition changes target state.
223    pub fn new(
224        identity: DeploymentResourceId,
225        source: ImageSource,
226        pull_policy: ImagePullPolicy,
227    ) -> PodmanLensResult<Self> {
228        require_kind(&identity, ResourceKind::Image)?;
229        Ok(Self {
230            identity,
231            source,
232            pull_policy,
233        })
234    }
235
236    /// Returns the image identity.
237    #[must_use]
238    pub fn identity(&self) -> &DeploymentResourceId {
239        &self.identity
240    }
241
242    /// Returns the exact image source reference.
243    #[must_use]
244    pub fn source(&self) -> &ImageSource {
245        &self.source
246    }
247
248    /// Returns the explicit image-acquisition policy.
249    #[must_use]
250    pub const fn pull_policy(&self) -> ImagePullPolicy {
251        self.pull_policy
252    }
253}
254
255/// One typed network creation intent.
256#[derive(Clone, Debug, Eq, PartialEq)]
257pub struct NetworkIntent {
258    identity: DeploymentResourceId,
259    subnets: Vec<NetworkSubnet>,
260    routes: Vec<NetworkRoute>,
261}
262
263impl NetworkIntent {
264    /// Creates one network creation intent with no caller-declared IPAM configuration.
265    ///
266    /// # Errors
267    ///
268    /// Returns `PLN0034` when the identity is not a network identity.
269    pub fn new(identity: DeploymentResourceId) -> PodmanLensResult<Self> {
270        require_kind(&identity, ResourceKind::Network)?;
271        Ok(Self {
272            identity,
273            subnets: Vec::new(),
274            routes: Vec::new(),
275        })
276    }
277
278    /// Adds one subnet/IPAM declaration in declared order.
279    ///
280    /// # Errors
281    ///
282    /// Returns `PLN0035` for duplicate subnets or capacity exhaustion.
283    pub fn add_subnet(&mut self, subnet: NetworkSubnet) -> PodmanLensResult<()> {
284        add_subnet(&mut self.subnets, subnet)
285    }
286
287    /// Adds one static route in declared order.
288    ///
289    /// # Errors
290    ///
291    /// Returns `PLN0035` for duplicate routes or capacity exhaustion.
292    pub fn add_route(&mut self, route: NetworkRoute) -> PodmanLensResult<()> {
293        add_route(&mut self.routes, route)
294    }
295
296    /// Returns the target-side identity.
297    #[must_use]
298    pub fn identity(&self) -> &DeploymentResourceId {
299        &self.identity
300    }
301
302    /// Returns caller-declared IPAM subnets in input order.
303    #[must_use]
304    pub fn subnets(&self) -> &[NetworkSubnet] {
305        &self.subnets
306    }
307
308    /// Returns caller-declared static routes in input order.
309    #[must_use]
310    pub fn routes(&self) -> &[NetworkRoute] {
311        &self.routes
312    }
313}
314/// One typed named-volume creation intent with independently optional ownership fields.
315#[derive(Clone, Debug, Eq, PartialEq)]
316pub struct VolumeIntent {
317    identity: DeploymentResourceId,
318    uid: Option<UnixId>,
319    gid: Option<UnixId>,
320}
321
322impl VolumeIntent {
323    /// Creates one named-volume creation intent without ownership overrides.
324    ///
325    /// # Errors
326    ///
327    /// Returns `PLN0034` when the identity is not a volume identity.
328    pub fn new(identity: DeploymentResourceId) -> PodmanLensResult<Self> {
329        require_kind(&identity, ResourceKind::Volume)?;
330        Ok(Self {
331            identity,
332            uid: None,
333            gid: None,
334        })
335    }
336
337    /// Sets an optional volume-creation UID, preserving a present zero value.
338    ///
339    /// # Errors
340    ///
341    /// Returns `PLN0038` when ownership was already declared.
342    pub fn set_uid(&mut self, uid: UnixId) -> PodmanLensResult<()> {
343        set_volume_owner(&mut self.uid, uid)
344    }
345
346    /// Sets an optional volume-creation GID, preserving a present zero value.
347    ///
348    /// # Errors
349    ///
350    /// Returns `PLN0038` when ownership was already declared.
351    pub fn set_gid(&mut self, gid: UnixId) -> PodmanLensResult<()> {
352        set_volume_owner(&mut self.gid, gid)
353    }
354
355    /// Returns the named-volume identity.
356    #[must_use]
357    pub fn identity(&self) -> &DeploymentResourceId {
358        &self.identity
359    }
360
361    /// Returns a caller-declared UID, distinct from omitted ownership.
362    #[must_use]
363    pub const fn uid(&self) -> Option<UnixId> {
364        self.uid
365    }
366
367    /// Returns a caller-declared GID, distinct from omitted ownership.
368    #[must_use]
369    pub const fn gid(&self) -> Option<UnixId> {
370        self.gid
371    }
372}
373
374fn set_volume_owner(slot: &mut Option<UnixId>, value: UnixId) -> PodmanLensResult<()> {
375    if slot.is_some() {
376        return Err(Diagnostic::new(DiagnosticCode::DeploymentUnsupportedCombination));
377    }
378    *slot = Some(value);
379    Ok(())
380}
381
382/// A visible declaration that one exact prerequisite must already exist outside this plan.
383///
384/// External prerequisites are never inferred from an omitted managed resource. They make an
385/// intentional deployment boundary explicit and never emit a semantic operation.
386#[derive(Clone, Debug, Eq, PartialEq)]
387pub struct ExternalPrecondition {
388    identity: DeploymentResourceId,
389}
390
391impl ExternalPrecondition {
392    /// Declares one exact image, network, volume, or secret that this plan deliberately does not manage.
393    ///
394    /// # Errors
395    ///
396    /// Returns `PLN0042` for container or pod identities, which need managed lifecycle and
397    /// membership validation.
398    pub fn new(identity: DeploymentResourceId) -> PodmanLensResult<Self> {
399        if matches!(identity.kind(), ResourceKind::Container | ResourceKind::Pod) {
400            return Err(Diagnostic::new(DiagnosticCode::InvalidExternalPrecondition));
401        }
402        Ok(Self { identity })
403    }
404
405    /// Returns the exact target-side resource identity.
406    #[must_use]
407    pub fn identity(&self) -> &DeploymentResourceId {
408        &self.identity
409    }
410}
411
412/// One typed secret creation intent with external, non-serializable material.
413#[derive(Clone, Debug, Eq, PartialEq)]
414pub struct SecretIntent {
415    identity: DeploymentResourceId,
416    material: SensitiveInputReference,
417}
418
419impl SecretIntent {
420    /// Declares a secret whose bytes are supplied by an external trusted input at deployment time.
421    ///
422    /// # Errors
423    ///
424    /// Returns `PLN0034` when the identity is not a secret identity.
425    pub fn new(identity: DeploymentResourceId, material: SensitiveInputReference) -> PodmanLensResult<Self> {
426        require_kind(&identity, ResourceKind::Secret)?;
427        Ok(Self { identity, material })
428    }
429
430    /// Returns the target-side secret identity.
431    #[must_use]
432    pub fn identity(&self) -> &DeploymentResourceId {
433        &self.identity
434    }
435
436    /// Returns the external material reference.
437    #[must_use]
438    pub fn material(&self) -> &SensitiveInputReference {
439        &self.material
440    }
441}
442
443/// One typed Podman pod creation intent.
444#[derive(Clone, Debug, Eq, PartialEq)]
445pub struct PodIntent {
446    identity: DeploymentResourceId,
447    networks: Vec<NetworkAttachment>,
448    ports: Vec<PortMapping>,
449    dns: DnsConfiguration,
450    hosts: Vec<HostAlias>,
451    infra_mounts: Vec<MountIntent>,
452    members: Vec<DeploymentResourceId>,
453}
454
455impl PodIntent {
456    /// Creates an empty Podman pod intent.
457    ///
458    /// # Errors
459    ///
460    /// Returns `PLN0034` when the identity is not a pod.
461    pub fn new(identity: DeploymentResourceId) -> PodmanLensResult<Self> {
462        require_kind(&identity, ResourceKind::Pod)?;
463        Ok(Self {
464            identity,
465            networks: Vec::new(),
466            ports: Vec::new(),
467            dns: DnsConfiguration::default(),
468            hosts: Vec::new(),
469            infra_mounts: Vec::new(),
470            members: Vec::new(),
471        })
472    }
473
474    /// Adds one network attachment owned by this pod's infra container.
475    ///
476    /// # Errors
477    ///
478    /// Returns `PLN0034` when the attachment does not name a network identity.
479    pub fn add_network(&mut self, network: NetworkAttachment) -> PodmanLensResult<()> {
480        add_attachment(&mut self.networks, network)
481    }
482
483    /// Adds one port mapping owned by this pod's infra container.
484    ///
485    /// # Errors
486    ///
487    /// Returns `PLN0035` for a duplicate mapping or capacity exhaustion.
488    pub fn add_port(&mut self, port: PortMapping) -> PodmanLensResult<()> {
489        add_port(&mut self.ports, port)
490    }
491
492    /// Returns mutable DNS configuration owned by this pod's infra container.
493    #[must_use]
494    pub fn dns_mut(&mut self) -> &mut DnsConfiguration {
495        &mut self.dns
496    }
497
498    /// Adds one `/etc/hosts` entry owned by this pod's infra container.
499    ///
500    /// # Errors
501    ///
502    /// Returns `PLN0035` for a duplicate alias or capacity exhaustion.
503    pub fn add_host_alias(&mut self, host: HostAlias) -> PodmanLensResult<()> {
504        add_host(&mut self.hosts, host)
505    }
506
507    /// Adds one mount for the Podman pod's infra container.
508    ///
509    /// The mount is not a declaration for every pod member. Member containers use
510    /// [`ContainerIntent::add_mount`] instead.
511    pub fn add_infra_mount(&mut self, mount: impl Into<MountIntent>) {
512        self.infra_mounts.push(mount.into());
513    }
514
515    /// Adds one container that this pod explicitly owns.
516    ///
517    /// The planner verifies the matching container also names this pod.
518    ///
519    /// # Errors
520    ///
521    /// Returns `PLN0034` when `container` is not a container identity.
522    pub fn add_member(&mut self, container: DeploymentResourceId) -> PodmanLensResult<()> {
523        require_kind(&container, ResourceKind::Container)?;
524        self.members.push(container);
525        Ok(())
526    }
527
528    /// Returns the pod identity.
529    #[must_use]
530    pub fn identity(&self) -> &DeploymentResourceId {
531        &self.identity
532    }
533
534    /// Returns declared network prerequisites in input order.
535    #[must_use]
536    pub fn networks(&self) -> &[NetworkAttachment] {
537        &self.networks
538    }
539
540    /// Returns declared pod-owned port mappings in input order.
541    #[must_use]
542    pub fn ports(&self) -> &[PortMapping] {
543        &self.ports
544    }
545
546    /// Returns declared pod-owned DNS configuration.
547    #[must_use]
548    pub fn dns(&self) -> &DnsConfiguration {
549        &self.dns
550    }
551
552    /// Returns declared pod-owned `/etc/hosts` aliases in input order.
553    #[must_use]
554    pub fn host_aliases(&self) -> &[HostAlias] {
555        &self.hosts
556    }
557
558    /// Returns declared infra-container mounts in input order.
559    #[must_use]
560    pub fn infra_mounts(&self) -> &[MountIntent] {
561        &self.infra_mounts
562    }
563
564    /// Returns declared pod members in input order.
565    #[must_use]
566    pub fn members(&self) -> &[DeploymentResourceId] {
567        &self.members
568    }
569}
570
571/// One typed Podman container creation and start intent.
572#[derive(Clone, Debug, Eq, PartialEq)]
573pub struct ContainerIntent {
574    identity: DeploymentResourceId,
575    image: DeploymentResourceId,
576    pod: Option<DeploymentResourceId>,
577    networks: Vec<NetworkAttachment>,
578    network_order: Option<Vec<DeploymentResourceId>>,
579    ports: Vec<PortMapping>,
580    dns: DnsConfiguration,
581    hosts: Vec<HostAlias>,
582    mounts: Vec<MountIntent>,
583    secret_grants: Vec<SecretGrant>,
584    settings: Box<ContainerSettings>,
585    runtime: Box<ContainerRuntimeSettings>,
586}
587
588impl ContainerIntent {
589    /// Creates a container intent with one required fully resolved image prerequisite.
590    ///
591    /// # Errors
592    ///
593    /// Returns `PLN0034` when identities have the wrong resource kinds.
594    pub fn new(identity: DeploymentResourceId, image: DeploymentResourceId) -> PodmanLensResult<Self> {
595        require_kind(&identity, ResourceKind::Container)?;
596        require_kind(&image, ResourceKind::Image)?;
597        Ok(Self {
598            identity,
599            image,
600            pod: None,
601            networks: Vec::new(),
602            network_order: None,
603            ports: Vec::new(),
604            dns: DnsConfiguration::default(),
605            hosts: Vec::new(),
606            mounts: Vec::new(),
607            secret_grants: Vec::new(),
608            settings: Box::default(),
609            runtime: Box::default(),
610        })
611    }
612
613    /// Assigns this container to one explicit pod.
614    ///
615    /// # Errors
616    ///
617    /// Returns `PLN0034` when `pod` is not a pod identity, `PLN0035` when it repeats the
618    /// existing pod, and `PLN0038` when it conflicts with an existing pod assignment.
619    pub fn set_pod(&mut self, pod: DeploymentResourceId) -> PodmanLensResult<()> {
620        require_kind(&pod, ResourceKind::Pod)?;
621        match &self.pod {
622            None => {
623                self.pod = Some(pod);
624                Ok(())
625            }
626            Some(existing) if existing == &pod => Err(Diagnostic::new(DiagnosticCode::DeploymentDuplicateResource)),
627            Some(_) => Err(Diagnostic::new(DiagnosticCode::DeploymentUnsupportedCombination)),
628        }
629    }
630
631    /// Adds a direct network attachment for an unpodded container.
632    ///
633    /// # Errors
634    ///
635    /// Returns `PLN0034` when the attachment does not name a network identity.
636    pub fn add_network(&mut self, network: NetworkAttachment) -> PodmanLensResult<()> {
637        add_attachment(&mut self.networks, network)
638    }
639
640    /// Declares the exact network-connection order for this unpodded container.
641    ///
642    /// An omitted order lets the target choose its native default. An explicit order must contain
643    /// every declared attachment exactly once and is only available on reviewed Podman 6.0 and
644    /// newer targets.
645    ///
646    /// # Errors
647    ///
648    /// Returns `PLN0034` for an empty or non-network order and `PLN0038` when already set.
649    pub fn set_network_order(&mut self, order: Vec<DeploymentResourceId>) -> PodmanLensResult<()> {
650        if order.is_empty() || order.iter().any(|network| network.kind() != ResourceKind::Network) {
651            return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
652        }
653        if self.network_order.is_some() {
654            return Err(Diagnostic::new(DiagnosticCode::DeploymentUnsupportedCombination));
655        }
656        self.network_order = Some(order);
657        Ok(())
658    }
659
660    /// Adds one host-to-container port mapping for an unpodded container.
661    ///
662    /// # Errors
663    ///
664    /// Returns `PLN0035` for a duplicate mapping or capacity exhaustion.
665    pub fn add_port(&mut self, port: PortMapping) -> PodmanLensResult<()> {
666        add_port(&mut self.ports, port)
667    }
668
669    /// Returns mutable DNS configuration for an unpodded container.
670    #[must_use]
671    pub fn dns_mut(&mut self) -> &mut DnsConfiguration {
672        &mut self.dns
673    }
674
675    /// Adds one `/etc/hosts` alias for an unpodded container.
676    ///
677    /// # Errors
678    ///
679    /// Returns `PLN0035` for a duplicate alias or capacity exhaustion.
680    pub fn add_host_alias(&mut self, host: HostAlias) -> PodmanLensResult<()> {
681        add_host(&mut self.hosts, host)
682    }
683
684    /// Adds one typed named-volume, bind, or tmpfs mount.
685    pub fn add_mount(&mut self, mount: impl Into<MountIntent>) {
686        self.mounts.push(mount.into());
687    }
688
689    /// Adds one typed mounted or environment secret grant.
690    pub fn add_secret_grant(&mut self, grant: SecretGrant) {
691        self.secret_grants.push(grant);
692    }
693
694    /// Returns the container identity.
695    #[must_use]
696    pub fn identity(&self) -> &DeploymentResourceId {
697        &self.identity
698    }
699
700    /// Returns the required image prerequisite.
701    #[must_use]
702    pub fn image(&self) -> &DeploymentResourceId {
703        &self.image
704    }
705
706    /// Returns explicit pod membership, when requested.
707    #[must_use]
708    pub fn pod(&self) -> Option<&DeploymentResourceId> {
709        self.pod.as_ref()
710    }
711
712    /// Returns direct network prerequisites.
713    #[must_use]
714    pub fn networks(&self) -> &[NetworkAttachment] {
715        &self.networks
716    }
717
718    /// Returns the explicit declared network order, when one is required.
719    #[must_use]
720    pub fn network_order(&self) -> Option<&[DeploymentResourceId]> {
721        self.network_order.as_deref()
722    }
723
724    /// Returns declared direct port mappings in input order.
725    #[must_use]
726    pub fn ports(&self) -> &[PortMapping] {
727        &self.ports
728    }
729
730    /// Returns declared direct DNS configuration.
731    #[must_use]
732    pub fn dns(&self) -> &DnsConfiguration {
733        &self.dns
734    }
735
736    /// Returns declared direct `/etc/hosts` aliases in input order.
737    #[must_use]
738    pub fn host_aliases(&self) -> &[HostAlias] {
739        &self.hosts
740    }
741
742    /// Returns named-volume mounts.
743    #[must_use]
744    pub fn mounts(&self) -> &[MountIntent] {
745        &self.mounts
746    }
747
748    /// Returns typed secret grants in declared order.
749    #[must_use]
750    pub fn secret_grants(&self) -> &[SecretGrant] {
751        &self.secret_grants
752    }
753
754    /// Returns the typed optional container settings.
755    #[must_use]
756    pub fn settings(&self) -> &ContainerSettings {
757        &self.settings
758    }
759
760    /// Returns mutable typed optional container settings.
761    #[must_use]
762    pub fn settings_mut(&mut self) -> &mut ContainerSettings {
763        &mut self.settings
764    }
765
766    /// Returns typed M6-B3a runtime settings retained for semantic planning only.
767    #[must_use]
768    pub fn runtime(&self) -> &ContainerRuntimeSettings {
769        &self.runtime
770    }
771
772    /// Returns mutable M6-B3a runtime settings retained for semantic planning only.
773    #[must_use]
774    pub fn runtime_mut(&mut self) -> &mut ContainerRuntimeSettings {
775        &mut self.runtime
776    }
777}
778
779/// One fully resolved Podman-native resource declaration.
780#[derive(Clone, Debug, Eq, PartialEq)]
781#[non_exhaustive]
782pub enum DeploymentResource {
783    /// An exact resource intentionally managed outside this plan.
784    ExternalPrecondition(ExternalPrecondition),
785    /// An image that must be available before its consumers are created.
786    Image(ImageIntent),
787    /// A network.
788    Network(NetworkIntent),
789    /// A named volume.
790    Volume(VolumeIntent),
791    /// A secret whose material stays outside the plan.
792    Secret(SecretIntent),
793    /// A Podman pod.
794    Pod(PodIntent),
795    /// A Podman container.
796    Container(ContainerIntent),
797}
798
799impl DeploymentResource {
800    /// Returns the typed resource identity.
801    #[must_use]
802    pub fn identity(&self) -> &DeploymentResourceId {
803        match self {
804            Self::ExternalPrecondition(resource) => resource.identity(),
805            Self::Image(resource) => resource.identity(),
806            Self::Network(resource) => resource.identity(),
807            Self::Volume(resource) => resource.identity(),
808            Self::Secret(resource) => resource.identity(),
809            Self::Pod(resource) => resource.identity(),
810            Self::Container(resource) => resource.identity(),
811        }
812    }
813}
814
815/// A semantic ordering edge between two container start intents.
816///
817/// Pod members are lifted to their pod-start operation. Ordering two containers in the same pod
818/// is rejected because Podman starts that pod as one unit.
819#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
820pub struct StartupDependency {
821    predecessor: DeploymentResourceId,
822    dependent: DeploymentResourceId,
823}
824
825impl StartupDependency {
826    /// Creates a strict start-order edge from `predecessor` to `dependent`.
827    ///
828    /// # Errors
829    ///
830    /// Returns `PLN0034` unless both identities are containers.
831    pub fn new(predecessor: DeploymentResourceId, dependent: DeploymentResourceId) -> PodmanLensResult<Self> {
832        require_kind(&predecessor, ResourceKind::Container)?;
833        require_kind(&dependent, ResourceKind::Container)?;
834        Ok(Self { predecessor, dependent })
835    }
836
837    /// Returns the container that must start first.
838    #[must_use]
839    pub fn predecessor(&self) -> &DeploymentResourceId {
840        &self.predecessor
841    }
842
843    /// Returns the container that starts later.
844    #[must_use]
845    pub fn dependent(&self) -> &DeploymentResourceId {
846        &self.dependent
847    }
848}
849
850/// Explicit input to semantic Podman deployment planning.
851#[derive(Clone, Debug, Eq, PartialEq)]
852pub struct DeploymentIntent {
853    target: TargetProfile,
854    connection: Option<DeploymentConnectionReference>,
855    resources: Vec<DeploymentResource>,
856    startup_dependencies: Vec<StartupDependency>,
857}
858
859impl DeploymentIntent {
860    /// Creates an empty intent for an explicit reviewed target profile.
861    #[must_use]
862    pub fn new(target: TargetProfile) -> Self {
863        Self {
864            target,
865            connection: None,
866            resources: Vec::new(),
867            startup_dependencies: Vec::new(),
868        }
869    }
870
871    /// Records a non-sensitive output-connection reference in this plan.
872    pub fn set_connection(&mut self, connection: DeploymentConnectionReference) {
873        self.connection = Some(connection);
874    }
875
876    /// Adds one fully resolved resource declaration.
877    ///
878    /// Duplicate identities and unresolved prerequisites are reported by [`plan_deployment`], so
879    /// callers may collect all typed declarations before asking the planner to validate them.
880    pub fn add_resource(&mut self, resource: DeploymentResource) {
881        self.resources.push(resource);
882    }
883
884    /// Adds one explicit semantic container-start ordering edge.
885    pub fn add_startup_dependency(&mut self, dependency: StartupDependency) {
886        self.startup_dependencies.push(dependency);
887    }
888
889    /// Returns the explicit reviewed target profile.
890    #[must_use]
891    pub fn target(&self) -> &TargetProfile {
892        &self.target
893    }
894
895    /// Returns the optional non-sensitive output connection reference.
896    #[must_use]
897    pub fn connection(&self) -> Option<&DeploymentConnectionReference> {
898        self.connection.as_ref()
899    }
900
901    /// Returns typed resources in caller-supplied order.
902    #[must_use]
903    pub fn resources(&self) -> &[DeploymentResource] {
904        &self.resources
905    }
906
907    /// Returns explicit semantic start-order edges in caller-supplied order.
908    #[must_use]
909    pub fn startup_dependencies(&self) -> &[StartupDependency] {
910        &self.startup_dependencies
911    }
912}
913
914/// The semantic phase of one deployment operation.
915#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
916#[non_exhaustive]
917pub enum SemanticOperationAction {
918    /// Ensure an explicitly named image is available.
919    EnsureImage,
920    /// Create one native resource.
921    Create,
922    /// Start one managed Podman pod after all its member containers are created.
923    StartPod,
924    /// Start one managed container that does not belong to a pod.
925    StartContainer,
926}
927
928/// Stable typed identity for one semantic operation.
929#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
930pub struct DeploymentOperationId {
931    action: SemanticOperationAction,
932    resource: DeploymentResourceId,
933}
934
935impl DeploymentOperationId {
936    fn new(action: SemanticOperationAction, resource: DeploymentResourceId) -> Self {
937        Self { action, resource }
938    }
939
940    /// Returns the semantic operation phase.
941    #[must_use]
942    pub const fn action(&self) -> SemanticOperationAction {
943        self.action
944    }
945
946    /// Returns the resource operated on.
947    #[must_use]
948    pub fn resource(&self) -> &DeploymentResourceId {
949        &self.resource
950    }
951}
952
953/// One ordered, transport-neutral deployment operation.
954#[derive(Clone, Debug, Eq, PartialEq)]
955pub struct DeploymentOperation {
956    id: DeploymentOperationId,
957    resource_intent: DeploymentResource,
958    depends_on: Vec<DeploymentOperationId>,
959    image_pull_policy: Option<ImagePullPolicy>,
960}
961
962impl DeploymentOperation {
963    /// Returns the stable semantic operation identity.
964    #[must_use]
965    pub fn id(&self) -> &DeploymentOperationId {
966        &self.id
967    }
968
969    /// Returns the complete typed intent rendered by this operation.
970    ///
971    /// This retains semantic source details such as a managed image source or the external
972    /// sensitive-material reference of a secret. The reference is redacted by its own `Debug`
973    /// implementation, and M5 does not serialize operations.
974    #[must_use]
975    pub fn resource_intent(&self) -> &DeploymentResource {
976        &self.resource_intent
977    }
978
979    /// Returns prerequisite operations in deterministic order.
980    #[must_use]
981    pub fn depends_on(&self) -> &[DeploymentOperationId] {
982        &self.depends_on
983    }
984
985    /// Returns the explicit policy for image-acquisition operations.
986    #[must_use]
987    pub const fn image_pull_policy(&self) -> Option<ImagePullPolicy> {
988        self.image_pull_policy
989    }
990}
991
992/// A complete, validated, sequentially executable semantic deployment plan.
993#[derive(Clone, Debug, Eq, PartialEq)]
994pub struct DeploymentPlan {
995    target: TargetProfile,
996    connection: Option<DeploymentConnectionReference>,
997    external_preconditions: Vec<ExternalPrecondition>,
998    operations: Vec<DeploymentOperation>,
999}
1000
1001/// One redacted, structured reason why deployment planning did not produce a plan.
1002#[derive(Clone, Debug, Eq, PartialEq)]
1003pub struct PlanningFinding {
1004    code: DiagnosticCode,
1005    subject: Option<DeploymentResourceId>,
1006    related: Vec<DeploymentResourceId>,
1007    field: Option<&'static str>,
1008    occurrence: Option<usize>,
1009    count: Option<usize>,
1010}
1011
1012impl Ord for PlanningFinding {
1013    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1014        (
1015            self.code.as_str(),
1016            &self.subject,
1017            &self.related,
1018            self.field,
1019            self.occurrence,
1020            self.count,
1021        )
1022            .cmp(&(
1023                other.code.as_str(),
1024                &other.subject,
1025                &other.related,
1026                other.field,
1027                other.occurrence,
1028                other.count,
1029            ))
1030    }
1031}
1032
1033impl PartialOrd for PlanningFinding {
1034    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1035        Some(self.cmp(other))
1036    }
1037}
1038
1039impl PlanningFinding {
1040    fn new(code: DiagnosticCode, subject: Option<DeploymentResourceId>, field: Option<&'static str>) -> Self {
1041        Self::detailed(code, subject, Vec::new(), field, None)
1042    }
1043
1044    fn detailed(
1045        code: DiagnosticCode,
1046        subject: Option<DeploymentResourceId>,
1047        related: Vec<DeploymentResourceId>,
1048        field: Option<&'static str>,
1049        occurrence: Option<usize>,
1050    ) -> Self {
1051        Self {
1052            code,
1053            subject,
1054            related,
1055            field,
1056            occurrence,
1057            count: None,
1058        }
1059    }
1060
1061    fn with_count(mut self, count: usize) -> Self {
1062        self.count = Some(count);
1063        self
1064    }
1065
1066    /// Returns the stable M5 diagnostic code.
1067    #[must_use]
1068    pub const fn code(&self) -> DiagnosticCode {
1069        self.code
1070    }
1071
1072    /// Returns the redacted human-facing explanation associated with [`Self::code`].
1073    #[must_use]
1074    pub const fn message(&self) -> &'static str {
1075        Diagnostic::new(self.code).message()
1076    }
1077
1078    /// Returns the affected target resource, when one exact resource caused the finding.
1079    #[must_use]
1080    pub fn subject(&self) -> Option<&DeploymentResourceId> {
1081        self.subject.as_ref()
1082    }
1083
1084    /// Returns exact safe resource identities related to this finding.
1085    #[must_use]
1086    pub fn related(&self) -> &[DeploymentResourceId] {
1087        &self.related
1088    }
1089
1090    /// Returns the stable intent field that caused the finding, when applicable.
1091    #[must_use]
1092    pub const fn field(&self) -> Option<&'static str> {
1093        self.field
1094    }
1095
1096    /// Returns the one-based position of a duplicate prerequisite or startup edge.
1097    ///
1098    /// Aggregate duplicate resource declarations have no single position; use [`Self::count`]
1099    /// for their cardinality.
1100    #[must_use]
1101    pub const fn occurrence(&self) -> Option<usize> {
1102        self.occurrence
1103    }
1104
1105    /// Returns the number of equivalent conflicting or duplicate declarations, when grouped.
1106    #[must_use]
1107    pub const fn count(&self) -> Option<usize> {
1108        self.count
1109    }
1110}
1111
1112/// The complete structured outcome of semantic deployment planning.
1113#[derive(Clone, Debug, Eq, PartialEq)]
1114pub struct PlanningOutcome {
1115    plan: Option<DeploymentPlan>,
1116    findings: Vec<PlanningFinding>,
1117}
1118
1119impl PlanningOutcome {
1120    /// Returns the semantic plan when no error findings were produced.
1121    #[must_use]
1122    pub fn plan(&self) -> Option<&DeploymentPlan> {
1123        self.plan.as_ref()
1124    }
1125
1126    /// Returns all findings sorted by code, subject, and field.
1127    #[must_use]
1128    pub fn findings(&self) -> &[PlanningFinding] {
1129        &self.findings
1130    }
1131
1132    /// Returns whether planning produced an executable semantic plan.
1133    #[must_use]
1134    pub const fn is_success(&self) -> bool {
1135        self.plan.is_some()
1136    }
1137}
1138
1139impl DeploymentPlan {
1140    /// Returns the explicit reviewed target profile.
1141    #[must_use]
1142    pub fn target(&self) -> &TargetProfile {
1143        &self.target
1144    }
1145
1146    /// Returns the optional non-sensitive output connection reference.
1147    #[must_use]
1148    pub fn connection(&self) -> Option<&DeploymentConnectionReference> {
1149        self.connection.as_ref()
1150    }
1151
1152    /// Returns explicit resource prerequisites intentionally managed outside this plan.
1153    ///
1154    /// Values are deterministically ordered by their typed target-side identity. M5 keeps them
1155    /// as semantic declarations; it does not render or execute them.
1156    #[must_use]
1157    pub fn external_preconditions(&self) -> &[ExternalPrecondition] {
1158        &self.external_preconditions
1159    }
1160
1161    /// Returns operations in authoritative sequential execution order.
1162    #[must_use]
1163    pub fn operations(&self) -> &[DeploymentOperation] {
1164        &self.operations
1165    }
1166}
1167
1168/// Validates explicit typed intent and returns its deterministic semantic deployment plan.
1169///
1170/// The plan never contains a shell fragment, HTTP request, or secret payload. It may retain typed
1171/// caller-declared public and sensitive environment intent so a later renderer can make an explicit
1172/// output decision. Sensitive values and references remain redacted from diagnostics, `Debug`,
1173/// observational snapshots, and serialized deployment artifacts; renderers must block them until a
1174/// safe output representation is available. Every operation is ordered after its declared
1175/// prerequisites. M6 renders supported operations as exact CLI arguments and Libpod API requests.
1176///
1177/// # Findings
1178///
1179/// The outcome uses `PLN0034`–`PLN0044` for invalid intent, duplicate or conflicting declarations,
1180/// missing prerequisites, unsupported combinations, cycles, sensitive payloads, bad image or
1181/// external-precondition declarations, pod membership, and same-pod start ordering.
1182#[must_use]
1183pub fn plan_deployment(intent: &DeploymentIntent) -> PlanningOutcome {
1184    let (resources, mut findings) = index_resources(intent.resources());
1185    validate_resources(&resources, intent.target(), &mut findings);
1186    validate_startup_dependencies(intent, &resources, &mut findings);
1187    if findings.is_empty() {
1188        let mut nodes = BTreeMap::<DeploymentOperationId, BTreeSet<DeploymentOperationId>>::new();
1189        for (identity, resource) in &resources {
1190            if matches!(resource, DeploymentResource::ExternalPrecondition(_)) {
1191                continue;
1192            }
1193            let action = if identity.kind() == ResourceKind::Image {
1194                SemanticOperationAction::EnsureImage
1195            } else {
1196                SemanticOperationAction::Create
1197            };
1198            let id = DeploymentOperationId::new(action, identity.clone());
1199            nodes.insert(id, create_dependencies(resource, &resources));
1200        }
1201        add_start_operations(intent, &resources, &mut nodes);
1202        match topological_operations(nodes, &resources) {
1203            Ok(operations) => {
1204                return PlanningOutcome {
1205                    plan: Some(DeploymentPlan {
1206                        target: intent.target.clone(),
1207                        connection: intent.connection.clone(),
1208                        external_preconditions: external_preconditions(&resources),
1209                        operations,
1210                    }),
1211                    findings,
1212                };
1213            }
1214            Err(operations) => findings.push(PlanningFinding::detailed(
1215                DiagnosticCode::DeploymentCycle,
1216                operations.first().map(|operation| operation.resource().clone()),
1217                operations
1218                    .into_iter()
1219                    .map(|operation| operation.resource().clone())
1220                    .collect(),
1221                Some("startup_dependencies"),
1222                None,
1223            )),
1224        }
1225    }
1226    sort_findings(&mut findings);
1227    PlanningOutcome { plan: None, findings }
1228}
1229
1230fn index_resources(
1231    resources: &[DeploymentResource],
1232) -> (
1233    BTreeMap<DeploymentResourceId, &DeploymentResource>,
1234    Vec<PlanningFinding>,
1235) {
1236    let mut declarations = BTreeMap::<DeploymentResourceId, Vec<&DeploymentResource>>::new();
1237    for resource in resources {
1238        declarations
1239            .entry(resource.identity().clone())
1240            .or_default()
1241            .push(resource);
1242    }
1243    let mut indexed = BTreeMap::new();
1244    let mut findings = Vec::new();
1245    for (identity, declarations) in declarations {
1246        let [first, rest @ ..] = declarations.as_slice() else {
1247            continue;
1248        };
1249        if rest.is_empty() {
1250            indexed.insert(identity, *first);
1251        } else if rest.iter().all(|resource| **resource == **first) {
1252            indexed.insert(identity.clone(), *first);
1253            findings.push(
1254                PlanningFinding::detailed(
1255                    DiagnosticCode::DeploymentDuplicateResource,
1256                    Some(identity),
1257                    Vec::new(),
1258                    Some("resources"),
1259                    None,
1260                )
1261                .with_count(declarations.len()),
1262            );
1263        } else {
1264            findings.push(
1265                PlanningFinding::detailed(
1266                    DiagnosticCode::DeploymentConflictingResource,
1267                    Some(identity),
1268                    Vec::new(),
1269                    Some("resources"),
1270                    None,
1271                )
1272                .with_count(declarations.len()),
1273            );
1274        }
1275    }
1276    (indexed, findings)
1277}
1278
1279fn external_preconditions(
1280    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
1281) -> Vec<ExternalPrecondition> {
1282    resources
1283        .values()
1284        .filter_map(|resource| match resource {
1285            DeploymentResource::ExternalPrecondition(precondition) => Some(precondition.clone()),
1286            _ => None,
1287        })
1288        .collect()
1289}
1290
1291fn validate_resources(
1292    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
1293    target: &TargetProfile,
1294    findings: &mut Vec<PlanningFinding>,
1295) {
1296    for resource in resources.values() {
1297        match resource {
1298            DeploymentResource::ExternalPrecondition(_)
1299            | DeploymentResource::Network(_)
1300            | DeploymentResource::Volume(_)
1301            | DeploymentResource::Image(_) => {}
1302            DeploymentResource::Secret(secret) => {
1303                if secret.material().as_str().is_empty() {
1304                    findings.push(PlanningFinding::new(
1305                        DiagnosticCode::SensitivePayloadEmbedded,
1306                        Some(secret.identity().clone()),
1307                        Some("material"),
1308                    ));
1309                }
1310            }
1311            DeploymentResource::Pod(pod) => {
1312                validate_pod(resources, pod, target.execution_context(), findings);
1313            }
1314            DeploymentResource::Container(container) => {
1315                validate_container(resources, container, target, findings);
1316            }
1317        }
1318    }
1319}
1320
1321fn validate_pod(
1322    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
1323    pod: &PodIntent,
1324    execution_context: TargetExecutionContext,
1325    findings: &mut Vec<PlanningFinding>,
1326) {
1327    validate_network_attachments(pod.networks(), pod.identity(), "networks", findings);
1328    validate_static_network_addresses(pod.networks(), pod.identity(), execution_context, findings);
1329    validate_mounts(resources, pod.infra_mounts(), pod.identity(), "infra_mounts", findings);
1330    validate_distinct(pod.members(), pod.identity(), "members", findings);
1331    for network in pod.networks() {
1332        require_resolved(
1333            resources,
1334            network.network(),
1335            ResourceKind::Network,
1336            pod.identity(),
1337            "networks",
1338            findings,
1339        );
1340    }
1341    for member in pod.members() {
1342        let member_resource = resolved(resources, member, ResourceKind::Container);
1343        if !matches!(member_resource, Some(DeploymentResource::Container(container)) if container.pod() == Some(pod.identity()))
1344        {
1345            findings.push(PlanningFinding::detailed(
1346                DiagnosticCode::DeploymentPodMembership,
1347                Some(pod.identity().clone()),
1348                vec![member.clone()],
1349                Some("members"),
1350                None,
1351            ));
1352        }
1353    }
1354}
1355
1356#[allow(clippy::too_many_lines)] // Every pod/member namespace invariant is validated together.
1357fn validate_container(
1358    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
1359    container: &ContainerIntent,
1360    target: &TargetProfile,
1361    findings: &mut Vec<PlanningFinding>,
1362) {
1363    validate_network_attachments(container.networks(), container.identity(), "networks", findings);
1364    validate_static_network_addresses(
1365        container.networks(),
1366        container.identity(),
1367        target.execution_context(),
1368        findings,
1369    );
1370    validate_mounts(resources, container.mounts(), container.identity(), "mounts", findings);
1371    validate_secret_grants(resources, container.secret_grants(), container, findings);
1372    require_resolved(
1373        resources,
1374        container.image(),
1375        ResourceKind::Image,
1376        container.identity(),
1377        "image",
1378        findings,
1379    );
1380    if let Some(pod) = container.pod() {
1381        if !container.runtime().namespaces().is_empty() {
1382            findings.push(PlanningFinding::new(
1383                DiagnosticCode::DeploymentUnsupportedCombination,
1384                Some(container.identity().clone()),
1385                Some("runtime.namespaces.pod_member"),
1386            ));
1387        }
1388        if container.settings().hostname().is_some() {
1389            findings.push(PlanningFinding::new(
1390                DiagnosticCode::DeploymentUnsupportedCombination,
1391                Some(container.identity().clone()),
1392                Some("hostname"),
1393            ));
1394        }
1395        if !container.networks().is_empty() {
1396            findings.push(PlanningFinding::new(
1397                DiagnosticCode::DeploymentUnsupportedCombination,
1398                Some(container.identity().clone()),
1399                Some("networks"),
1400            ));
1401        }
1402        if !container.ports().is_empty() {
1403            findings.push(PlanningFinding::new(
1404                DiagnosticCode::DeploymentUnsupportedCombination,
1405                Some(container.identity().clone()),
1406                Some("ports"),
1407            ));
1408        }
1409        if !container.dns().servers().is_empty()
1410            || !container.dns().search().is_empty()
1411            || !container.dns().options().is_empty()
1412        {
1413            findings.push(PlanningFinding::new(
1414                DiagnosticCode::DeploymentUnsupportedCombination,
1415                Some(container.identity().clone()),
1416                Some("dns"),
1417            ));
1418        }
1419        if !container.host_aliases().is_empty() {
1420            findings.push(PlanningFinding::new(
1421                DiagnosticCode::DeploymentUnsupportedCombination,
1422                Some(container.identity().clone()),
1423                Some("host_aliases"),
1424            ));
1425        }
1426        if container.network_order().is_some() {
1427            findings.push(PlanningFinding::new(
1428                DiagnosticCode::DeploymentUnsupportedCombination,
1429                Some(container.identity().clone()),
1430                Some("network_order"),
1431            ));
1432        }
1433        if resolved(resources, pod, ResourceKind::Pod).is_none() {
1434            findings.push(PlanningFinding::detailed(
1435                DiagnosticCode::DeploymentUnresolvedPrerequisite,
1436                Some(container.identity().clone()),
1437                vec![pod.clone()],
1438                Some("pod"),
1439                None,
1440            ));
1441        }
1442        if let Some(DeploymentResource::Pod(pod_resource)) = resolved(resources, pod, ResourceKind::Pod) {
1443            if !pod_resource.members().contains(container.identity()) {
1444                findings.push(PlanningFinding::detailed(
1445                    DiagnosticCode::DeploymentPodMembership,
1446                    Some(container.identity().clone()),
1447                    vec![pod.clone()],
1448                    Some("pod"),
1449                    None,
1450                ));
1451            }
1452        }
1453    }
1454    for network in container.networks() {
1455        require_resolved(
1456            resources,
1457            network.network(),
1458            ResourceKind::Network,
1459            container.identity(),
1460            "networks",
1461            findings,
1462        );
1463    }
1464    if let Some(order) = container.network_order() {
1465        let declared = container
1466            .networks()
1467            .iter()
1468            .map(NetworkAttachment::network)
1469            .collect::<BTreeSet<_>>();
1470        let ordered = order.iter().collect::<BTreeSet<_>>();
1471        if declared.len() != container.networks().len() || order.len() != ordered.len() || declared != ordered {
1472            findings.push(PlanningFinding::new(
1473                DiagnosticCode::DeploymentUnsupportedCombination,
1474                Some(container.identity().clone()),
1475                Some("network_order"),
1476            ));
1477        }
1478    }
1479    validate_runtime_settings(container, target, findings);
1480}
1481
1482#[allow(clippy::too_many_lines)] // Health, logging, security, and cgroup invariants share one outcome.
1483fn validate_runtime_settings(container: &ContainerIntent, target: &TargetProfile, findings: &mut Vec<PlanningFinding>) {
1484    let runtime = container.runtime();
1485    if runtime.namespaces().uts() == Some(crate::NamespaceMode::Host) && container.settings().hostname().is_some() {
1486        findings.push(PlanningFinding::new(
1487            DiagnosticCode::DeploymentUnsupportedCombination,
1488            Some(container.identity().clone()),
1489            Some("runtime.namespaces.uts_host_with_hostname"),
1490        ));
1491    }
1492    if runtime.namespaces().cgroup() == Some(crate::NamespaceMode::Private)
1493        && !target
1494            .cgroup_capabilities()
1495            .is_some_and(|evidence| evidence.version() == crate::CgroupVersion::V2)
1496    {
1497        findings.push(PlanningFinding::new(
1498            DiagnosticCode::DeploymentUnsupportedCombination,
1499            Some(container.identity().clone()),
1500            Some("runtime.namespaces.cgroup_private_requires_v2"),
1501        ));
1502    }
1503    if runtime.startup_health().is_some() && !matches!(runtime.health(), Some(crate::HealthCheck::Command(_))) {
1504        findings.push(PlanningFinding::new(
1505            DiagnosticCode::DeploymentUnsupportedCombination,
1506            Some(container.identity().clone()),
1507            Some("runtime.startup_health_requires_health"),
1508        ));
1509    }
1510    if runtime.logging().driver().is_none()
1511        && (runtime.logging().max_size().is_some() || !runtime.logging().journald_labels().is_empty())
1512    {
1513        findings.push(PlanningFinding::new(
1514            DiagnosticCode::DeploymentUnsupportedCombination,
1515            Some(container.identity().clone()),
1516            Some("runtime.logging.driver"),
1517        ));
1518    }
1519    if !runtime.logging().journald_labels().is_empty() && runtime.logging().driver() != Some(crate::LogDriver::Journald)
1520    {
1521        findings.push(PlanningFinding::new(
1522            DiagnosticCode::DeploymentUnsupportedCombination,
1523            Some(container.identity().clone()),
1524            Some("runtime.logging.journald_labels"),
1525        ));
1526    }
1527    if !runtime.logging().journald_labels().is_empty()
1528        && target.podman_version().as_semver() < &semver::Version::new(6, 0, 0)
1529    {
1530        findings.push(PlanningFinding::new(
1531            DiagnosticCode::DeploymentUnsupportedCombination,
1532            Some(container.identity().clone()),
1533            Some("runtime.logging.journald_labels.target_version"),
1534        ));
1535    }
1536    if runtime.logging().max_size().is_some() && runtime.logging().driver() != Some(crate::LogDriver::K8sFile) {
1537        findings.push(PlanningFinding::new(
1538            DiagnosticCode::DeploymentUnsupportedCombination,
1539            Some(container.identity().clone()),
1540            Some("runtime.logging.max_size"),
1541        ));
1542    }
1543    if runtime.security().privileged() == Some(true)
1544        && (!runtime.security().cap_add().is_empty() || !runtime.security().cap_drop().is_empty())
1545    {
1546        findings.push(PlanningFinding::new(
1547            DiagnosticCode::DeploymentUnsupportedCombination,
1548            Some(container.identity().clone()),
1549            Some("runtime.security.privileged_capabilities"),
1550        ));
1551    }
1552    if runtime
1553        .security()
1554        .cap_add()
1555        .iter()
1556        .any(|capability| runtime.security().cap_drop().contains(capability))
1557    {
1558        findings.push(PlanningFinding::new(
1559            DiagnosticCode::DeploymentUnsupportedCombination,
1560            Some(container.identity().clone()),
1561            Some("runtime.security.capability_overlap"),
1562        ));
1563    }
1564    if runtime.security().read_write_tmpfs() == Some(true) && runtime.security().read_only_filesystem() != Some(true) {
1565        findings.push(PlanningFinding::new(
1566            DiagnosticCode::DeploymentUnsupportedCombination,
1567            Some(container.identity().clone()),
1568            Some("runtime.security.read_write_tmpfs"),
1569        ));
1570    }
1571    let resources = runtime.resources();
1572    if resources.rlimits().iter().any(|limit| {
1573        matches!(limit.soft(), crate::RlimitValue::Unlimited) || matches!(limit.hard(), crate::RlimitValue::Unlimited)
1574    }) && target.podman_version().as_semver() < &semver::Version::new(5, 6, 0)
1575    {
1576        findings.push(PlanningFinding::new(
1577            DiagnosticCode::DeploymentUnsupportedCombination,
1578            Some(container.identity().clone()),
1579            Some("runtime.resources.rlimits.unlimited.target_version"),
1580        ));
1581    }
1582    let controls_requested = resources.cpu_shares().is_some()
1583        || resources.cpu_period().is_some()
1584        || resources.cpu_quota().is_some()
1585        || resources.memory_bytes().is_some()
1586        || resources.pids().is_some();
1587    if controls_requested && target.cgroup_capabilities().is_none() {
1588        findings.push(PlanningFinding::new(
1589            DiagnosticCode::DeploymentUnsupportedCombination,
1590            Some(container.identity().clone()),
1591            Some("runtime.resources.cgroup_evidence"),
1592        ));
1593        return;
1594    }
1595    if controls_requested
1596        && target.cgroup_capabilities().is_some_and(|evidence| {
1597            evidence.version() == crate::CgroupVersion::V1
1598                && target.execution_context() != TargetExecutionContext::Rootful
1599        })
1600    {
1601        findings.push(PlanningFinding::new(
1602            DiagnosticCode::DeploymentUnsupportedCombination,
1603            Some(container.identity().clone()),
1604            Some("runtime.resources.cgroup_v1_requires_rootful"),
1605        ));
1606    }
1607    for (configured, controller, field) in [
1608        (
1609            resources.cpu_shares().is_some() || resources.cpu_period().is_some() || resources.cpu_quota().is_some(),
1610            CgroupController::Cpu,
1611            "runtime.resources.cpu",
1612        ),
1613        (
1614            resources.memory_bytes().is_some(),
1615            CgroupController::Memory,
1616            "runtime.resources.memory_bytes",
1617        ),
1618        (
1619            resources.pids().is_some(),
1620            CgroupController::Pids,
1621            "runtime.resources.pids",
1622        ),
1623    ] {
1624        if configured
1625            && !target
1626                .cgroup_capabilities()
1627                .is_some_and(|evidence| evidence.supports(controller))
1628        {
1629            findings.push(PlanningFinding::new(
1630                DiagnosticCode::DeploymentUnsupportedCombination,
1631                Some(container.identity().clone()),
1632                Some(field),
1633            ));
1634        }
1635    }
1636}
1637
1638fn validate_static_network_addresses(
1639    attachments: &[NetworkAttachment],
1640    owner: &DeploymentResourceId,
1641    execution_context: TargetExecutionContext,
1642    findings: &mut Vec<PlanningFinding>,
1643) {
1644    if execution_context == TargetExecutionContext::Rootful {
1645        return;
1646    }
1647    for attachment in attachments {
1648        for (configured, field) in [
1649            (
1650                attachment.static_ipv4().is_some(),
1651                "networks.static_ipv4_requires_rootful",
1652            ),
1653            (
1654                attachment.static_ipv6().is_some(),
1655                "networks.static_ipv6_requires_rootful",
1656            ),
1657            (
1658                attachment.static_mac().is_some(),
1659                "networks.static_mac_requires_rootful",
1660            ),
1661        ] {
1662            if configured {
1663                findings.push(PlanningFinding::new(
1664                    DiagnosticCode::DeploymentUnsupportedCombination,
1665                    Some(owner.clone()),
1666                    Some(field),
1667                ));
1668            }
1669        }
1670    }
1671}
1672
1673fn validate_network_attachments(
1674    attachments: &[NetworkAttachment],
1675    owner: &DeploymentResourceId,
1676    field: &'static str,
1677    findings: &mut Vec<PlanningFinding>,
1678) {
1679    for (index, attachment) in attachments.iter().enumerate() {
1680        if attachments[..index]
1681            .iter()
1682            .any(|previous| previous.network() == attachment.network())
1683        {
1684            findings.push(PlanningFinding::detailed(
1685                DiagnosticCode::DeploymentDuplicateResource,
1686                Some(owner.clone()),
1687                vec![attachment.network().clone()],
1688                Some(field),
1689                Some(index + 1),
1690            ));
1691        }
1692    }
1693}
1694
1695fn validate_mounts(
1696    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
1697    mounts: &[MountIntent],
1698    owner: &DeploymentResourceId,
1699    field: &'static str,
1700    findings: &mut Vec<PlanningFinding>,
1701) {
1702    for (index, mount) in mounts.iter().enumerate() {
1703        if mounts[..index]
1704            .iter()
1705            .any(|previous| previous.destination() == mount.destination())
1706        {
1707            findings.push(PlanningFinding::detailed(
1708                DiagnosticCode::DeploymentDuplicateResource,
1709                Some(owner.clone()),
1710                mount.volume_source().cloned().into_iter().collect(),
1711                Some(field),
1712                Some(index + 1),
1713            ));
1714        }
1715        if let Some(source) = mount.volume_source() {
1716            require_resolved(resources, source, ResourceKind::Volume, owner, field, findings);
1717        }
1718    }
1719}
1720
1721fn validate_secret_grants(
1722    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
1723    grants: &[SecretGrant],
1724    container: &ContainerIntent,
1725    findings: &mut Vec<PlanningFinding>,
1726) {
1727    let mut mount_destinations = BTreeSet::new();
1728    let mut environment_targets = BTreeSet::new();
1729    for (index, grant) in grants.iter().enumerate() {
1730        require_resolved(
1731            resources,
1732            grant.source(),
1733            ResourceKind::Secret,
1734            container.identity(),
1735            "secret_grants",
1736            findings,
1737        );
1738        if let Some(target) = grant.mount_target() {
1739            if !mount_destinations.insert(target.as_str()) {
1740                findings.push(PlanningFinding::detailed(
1741                    DiagnosticCode::DeploymentDuplicateResource,
1742                    Some(container.identity().clone()),
1743                    vec![grant.source().clone()],
1744                    Some("secret_grants.mount_target"),
1745                    Some(index + 1),
1746                ));
1747            }
1748        }
1749        if let Some(target) = grant.environment_target() {
1750            if !environment_targets.insert(target.as_str())
1751                || container
1752                    .settings()
1753                    .environment()
1754                    .iter()
1755                    .any(|assignment| assignment.name() == target)
1756            {
1757                findings.push(PlanningFinding::detailed(
1758                    DiagnosticCode::DeploymentDuplicateResource,
1759                    Some(container.identity().clone()),
1760                    vec![grant.source().clone()],
1761                    Some("secret_grants.environment_target"),
1762                    Some(index + 1),
1763                ));
1764            }
1765        }
1766    }
1767}
1768
1769fn create_dependencies(
1770    resource: &DeploymentResource,
1771    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
1772) -> BTreeSet<DeploymentOperationId> {
1773    let mut dependencies = BTreeSet::new();
1774    match resource {
1775        DeploymentResource::ExternalPrecondition(_)
1776        | DeploymentResource::Image(_)
1777        | DeploymentResource::Network(_)
1778        | DeploymentResource::Volume(_)
1779        | DeploymentResource::Secret(_) => {}
1780        DeploymentResource::Pod(pod) => {
1781            for network in pod.networks() {
1782                if is_managed(resources, network.network()) {
1783                    dependencies.insert(create_operation(network.network()));
1784                }
1785            }
1786            for mount in pod.infra_mounts() {
1787                if let Some(source) = mount.volume_source().filter(|source| is_managed(resources, source)) {
1788                    dependencies.insert(create_operation(source));
1789                }
1790            }
1791        }
1792        DeploymentResource::Container(container) => {
1793            if is_managed(resources, container.image()) {
1794                dependencies.insert(DeploymentOperationId::new(
1795                    SemanticOperationAction::EnsureImage,
1796                    container.image().clone(),
1797                ));
1798            }
1799            if let Some(pod) = container.pod() {
1800                if is_managed(resources, pod) {
1801                    dependencies.insert(create_operation(pod));
1802                }
1803            }
1804            for network in container.networks() {
1805                if is_managed(resources, network.network()) {
1806                    dependencies.insert(create_operation(network.network()));
1807                }
1808            }
1809            for mount in container.mounts() {
1810                if let Some(source) = mount.volume_source().filter(|source| is_managed(resources, source)) {
1811                    dependencies.insert(create_operation(source));
1812                }
1813            }
1814            for grant in container.secret_grants() {
1815                if is_managed(resources, grant.source()) {
1816                    dependencies.insert(create_operation(grant.source()));
1817                }
1818            }
1819        }
1820    }
1821    dependencies
1822}
1823
1824fn add_start_operations(
1825    intent: &DeploymentIntent,
1826    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
1827    nodes: &mut BTreeMap<DeploymentOperationId, BTreeSet<DeploymentOperationId>>,
1828) {
1829    for (identity, resource) in resources {
1830        match resource {
1831            DeploymentResource::Pod(pod) if !pod.members().is_empty() => {
1832                let id = DeploymentOperationId::new(SemanticOperationAction::StartPod, identity.clone());
1833                let mut dependencies = BTreeSet::from([create_operation(identity)]);
1834                for member in pod.members() {
1835                    if is_managed(resources, member) {
1836                        dependencies.insert(create_operation(member));
1837                    }
1838                }
1839                nodes.insert(id, dependencies);
1840            }
1841            DeploymentResource::Container(container) if container.pod().is_none() => {
1842                nodes.insert(
1843                    DeploymentOperationId::new(SemanticOperationAction::StartContainer, identity.clone()),
1844                    BTreeSet::from([create_operation(identity)]),
1845                );
1846            }
1847            _ => {}
1848        }
1849    }
1850    let mut dependencies = BTreeSet::new();
1851    for dependency in intent.startup_dependencies() {
1852        if let (Some(predecessor), Some(dependent)) = (
1853            start_anchor(resources, dependency.predecessor()),
1854            start_anchor(resources, dependency.dependent()),
1855        ) {
1856            dependencies.insert((predecessor, dependent));
1857        }
1858    }
1859    for (predecessor, dependent) in dependencies {
1860        if let Some(values) = nodes.get_mut(&dependent) {
1861            values.insert(predecessor);
1862        }
1863    }
1864}
1865
1866fn validate_startup_dependencies(
1867    intent: &DeploymentIntent,
1868    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
1869    findings: &mut Vec<PlanningFinding>,
1870) {
1871    let mut edges = BTreeMap::<DeploymentOperationId, BTreeSet<DeploymentOperationId>>::new();
1872    let mut seen = BTreeSet::new();
1873    for (index, dependency) in intent.startup_dependencies().iter().enumerate() {
1874        let occurrence = Some(index + 1);
1875        let predecessor = start_anchor(resources, dependency.predecessor());
1876        let dependent = start_anchor(resources, dependency.dependent());
1877        if predecessor.is_none() {
1878            findings.push(PlanningFinding::detailed(
1879                DiagnosticCode::DeploymentUnresolvedPrerequisite,
1880                Some(dependency.predecessor().clone()),
1881                vec![dependency.dependent().clone()],
1882                Some("startup_dependencies"),
1883                occurrence,
1884            ));
1885        }
1886        if dependent.is_none() {
1887            findings.push(PlanningFinding::detailed(
1888                DiagnosticCode::DeploymentUnresolvedPrerequisite,
1889                Some(dependency.dependent().clone()),
1890                vec![dependency.predecessor().clone()],
1891                Some("startup_dependencies"),
1892                occurrence,
1893            ));
1894        }
1895        let (Some(predecessor), Some(dependent)) = (predecessor, dependent) else {
1896            continue;
1897        };
1898        if predecessor == dependent && predecessor.action() == SemanticOperationAction::StartPod {
1899            findings.push(PlanningFinding::detailed(
1900                DiagnosticCode::SamePodStartupDependency,
1901                Some(dependency.dependent().clone()),
1902                vec![dependency.predecessor().clone()],
1903                Some("startup_dependencies"),
1904                occurrence,
1905            ));
1906            continue;
1907        }
1908        if !seen.insert((predecessor.clone(), dependent.clone())) {
1909            findings.push(PlanningFinding::detailed(
1910                DiagnosticCode::DeploymentDuplicateResource,
1911                Some(dependency.dependent().clone()),
1912                vec![dependency.predecessor().clone()],
1913                Some("startup_dependencies"),
1914                occurrence,
1915            ));
1916            continue;
1917        }
1918        edges.entry(predecessor.clone()).or_default();
1919        edges.entry(dependent).or_default().insert(predecessor);
1920    }
1921    if let Some(operations) = operation_cycle(&edges) {
1922        findings.push(PlanningFinding::detailed(
1923            DiagnosticCode::DeploymentCycle,
1924            operations.first().map(|operation| operation.resource().clone()),
1925            operations
1926                .into_iter()
1927                .map(|operation| operation.resource().clone())
1928                .collect(),
1929            Some("startup_dependencies"),
1930            None,
1931        ));
1932    }
1933}
1934
1935fn create_operation(resource: &DeploymentResourceId) -> DeploymentOperationId {
1936    DeploymentOperationId::new(SemanticOperationAction::Create, resource.clone())
1937}
1938
1939fn start_anchor(
1940    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
1941    container: &DeploymentResourceId,
1942) -> Option<DeploymentOperationId> {
1943    let DeploymentResource::Container(intent) = resources.get(container).copied()? else {
1944        return None;
1945    };
1946    match intent.pod() {
1947        Some(pod) if matches!(resources.get(pod), Some(DeploymentResource::Pod(_))) => Some(
1948            DeploymentOperationId::new(SemanticOperationAction::StartPod, pod.clone()),
1949        ),
1950        Some(_) => None,
1951        None => Some(DeploymentOperationId::new(
1952            SemanticOperationAction::StartContainer,
1953            container.clone(),
1954        )),
1955    }
1956}
1957
1958fn topological_operations(
1959    mut nodes: BTreeMap<DeploymentOperationId, BTreeSet<DeploymentOperationId>>,
1960    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
1961) -> Result<Vec<DeploymentOperation>, Vec<DeploymentOperationId>> {
1962    let declared_dependencies = nodes.clone();
1963    let mut result = Vec::with_capacity(nodes.len());
1964    while !nodes.is_empty() {
1965        let Some(id) = nodes
1966            .iter()
1967            .filter(|(_, dependencies)| dependencies.is_empty())
1968            .map(|(id, _)| id.clone())
1969            .min_by_key(operation_sort_key)
1970        else {
1971            return Err(nodes.keys().cloned().collect());
1972        };
1973        let dependencies = nodes
1974            .remove(&id)
1975            .ok_or_else(|| nodes.keys().cloned().collect::<Vec<_>>())?;
1976        for remaining in nodes.values_mut() {
1977            remaining.remove(&id);
1978        }
1979        let declared = declared_dependencies.get(&id).cloned().unwrap_or(dependencies);
1980        let Some(resource_intent) = resources.get(id.resource()) else {
1981            return Err(vec![id]);
1982        };
1983        result.push(DeploymentOperation {
1984            resource_intent: (*resource_intent).clone(),
1985            image_pull_policy: match resources.get(id.resource()) {
1986                Some(DeploymentResource::Image(image)) if id.action() == SemanticOperationAction::EnsureImage => {
1987                    Some(image.pull_policy())
1988                }
1989                _ => None,
1990            },
1991            id,
1992            depends_on: declared.into_iter().collect(),
1993        });
1994    }
1995    Ok(result)
1996}
1997
1998fn operation_cycle(
1999    edges: &BTreeMap<DeploymentOperationId, BTreeSet<DeploymentOperationId>>,
2000) -> Option<Vec<DeploymentOperationId>> {
2001    let mut remaining = edges.clone();
2002    while let Some(id) = remaining
2003        .iter()
2004        .filter(|(_, dependencies)| dependencies.is_empty())
2005        .map(|(id, _)| id.clone())
2006        .min_by_key(operation_sort_key)
2007    {
2008        remaining.remove(&id);
2009        for dependencies in remaining.values_mut() {
2010            dependencies.remove(&id);
2011        }
2012    }
2013    (!remaining.is_empty()).then(|| remaining.into_keys().collect())
2014}
2015
2016fn require_kind(identity: &DeploymentResourceId, expected: ResourceKind) -> PodmanLensResult<()> {
2017    if identity.kind() == expected {
2018        Ok(())
2019    } else {
2020        Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent))
2021    }
2022}
2023
2024fn resolved<'a>(
2025    resources: &'a BTreeMap<DeploymentResourceId, &DeploymentResource>,
2026    identity: &DeploymentResourceId,
2027    expected: ResourceKind,
2028) -> Option<&'a DeploymentResource> {
2029    (identity.kind() == expected)
2030        .then(|| resources.get(identity).copied())
2031        .flatten()
2032}
2033
2034fn require_resolved(
2035    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
2036    reference: &DeploymentResourceId,
2037    expected: ResourceKind,
2038    subject: &DeploymentResourceId,
2039    field: &'static str,
2040    findings: &mut Vec<PlanningFinding>,
2041) {
2042    if resolved(resources, reference, expected).is_none() {
2043        findings.push(PlanningFinding::detailed(
2044            DiagnosticCode::DeploymentUnresolvedPrerequisite,
2045            Some(subject.clone()),
2046            vec![reference.clone()],
2047            Some(field),
2048            None,
2049        ));
2050    }
2051}
2052
2053fn validate_distinct(
2054    values: &[DeploymentResourceId],
2055    subject: &DeploymentResourceId,
2056    field: &'static str,
2057    findings: &mut Vec<PlanningFinding>,
2058) {
2059    let mut distinct = BTreeSet::new();
2060    for (index, value) in values.iter().enumerate() {
2061        if !distinct.insert(value) {
2062            findings.push(PlanningFinding::detailed(
2063                DiagnosticCode::DeploymentDuplicateResource,
2064                Some(subject.clone()),
2065                vec![value.clone()],
2066                Some(field),
2067                Some(index + 1),
2068            ));
2069        }
2070    }
2071}
2072
2073fn is_managed(
2074    resources: &BTreeMap<DeploymentResourceId, &DeploymentResource>,
2075    identity: &DeploymentResourceId,
2076) -> bool {
2077    !matches!(
2078        resources.get(identity),
2079        Some(DeploymentResource::ExternalPrecondition(_))
2080    )
2081}
2082
2083fn classify_image_source(value: &str) -> Option<ImageSourceClassification> {
2084    if value.is_empty()
2085        || value.len() > MAX_REFERENCE_BYTES
2086        || value
2087            .chars()
2088            .any(|character| character.is_control() || character.is_whitespace())
2089        || value.contains(['@', '\\']) && !value.contains("@sha256:")
2090        || value.contains("//")
2091        || value.contains('@') && value.matches('@').count() != 1
2092    {
2093        return None;
2094    }
2095    if is_image_id(value) {
2096        return Some(ImageSourceClassification::Local);
2097    }
2098    let (name, digest) = match value.split_once('@') {
2099        Some((name, digest)) if !name.is_empty() && is_sha256_digest(digest) => (name, Some(digest)),
2100        Some(_) => return None,
2101        None => (value, None),
2102    };
2103    let components = name.split('/').collect::<Vec<_>>();
2104    if components.iter().any(|component| component.is_empty()) {
2105        return None;
2106    }
2107    let first = components[0];
2108    let registry_qualified =
2109        components.len() > 1 && (first == "localhost" || first.contains('.') || first.contains(':'));
2110    let (registry, repository) = if registry_qualified {
2111        if components.len() < 2 || !is_valid_registry(first) {
2112            return None;
2113        }
2114        (Some(first), &components[1..])
2115    } else {
2116        (None, &components[..])
2117    };
2118    let (last, tag) = split_tag(repository.last()?);
2119    if !repository[..repository.len() - 1]
2120        .iter()
2121        .copied()
2122        .chain(std::iter::once(last))
2123        .all(is_repository_component)
2124    {
2125        return None;
2126    }
2127    if tag.is_some_and(|tag| !is_tag(tag)) {
2128        return None;
2129    }
2130    if digest.is_some() && tag.is_some() {
2131        return None;
2132    }
2133    match (registry, tag, digest) {
2134        (Some("localhost"), _, _) => Some(ImageSourceClassification::Local),
2135        (Some(_), Some(_), None) | (Some(_), None, Some(_)) => Some(ImageSourceClassification::Portable),
2136        (Some(_), None, None) => Some(ImageSourceClassification::Tagless),
2137        (None, _, _) => Some(ImageSourceClassification::Unqualified),
2138        _ => None,
2139    }
2140}
2141
2142fn is_valid_registry(value: &str) -> bool {
2143    if value == "localhost" {
2144        return true;
2145    }
2146    let (host, port) = value.rsplit_once(':').unwrap_or((value, ""));
2147    !host.is_empty()
2148        && host.split('.').all(|label| {
2149            !label.is_empty()
2150                && label.len() <= 63
2151                && label
2152                    .as_bytes()
2153                    .first()
2154                    .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
2155                && label
2156                    .as_bytes()
2157                    .last()
2158                    .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
2159                && label
2160                    .bytes()
2161                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
2162        })
2163        && (port.is_empty() || (port.parse::<u16>().is_ok_and(|port| port != 0)))
2164}
2165
2166fn is_image_id(value: &str) -> bool {
2167    let hex = value.strip_prefix("sha256:").unwrap_or(value);
2168    (hex.len() == 64 || (value.starts_with("sha256:") && hex.len() == 64))
2169        && hex.bytes().all(|byte| byte.is_ascii_hexdigit())
2170}
2171
2172fn split_tag(repository: &str) -> (&str, Option<&str>) {
2173    let Some((name, tag)) = repository.rsplit_once(':') else {
2174        return (repository, None);
2175    };
2176    if tag.is_empty() {
2177        (repository, None)
2178    } else {
2179        (name, Some(tag))
2180    }
2181}
2182
2183fn is_repository_component(value: &str) -> bool {
2184    !value.is_empty()
2185        && value.as_bytes().first().is_some_and(u8::is_ascii_alphanumeric)
2186        && value
2187            .bytes()
2188            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-'))
2189}
2190
2191fn is_tag(value: &str) -> bool {
2192    !value.is_empty()
2193        && value.len() <= 128
2194        && value
2195            .as_bytes()
2196            .first()
2197            .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
2198        && value
2199            .bytes()
2200            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
2201}
2202
2203fn is_sha256_digest(value: &str) -> bool {
2204    value.len() == 71 && value.starts_with("sha256:") && value[7..].bytes().all(|byte| byte.is_ascii_hexdigit())
2205}
2206
2207fn operation_sort_key(id: &DeploymentOperationId) -> (u8, DeploymentResourceId) {
2208    let rank = match (id.action(), id.resource().kind()) {
2209        (SemanticOperationAction::Create, ResourceKind::Network) => 0,
2210        (SemanticOperationAction::Create, ResourceKind::Volume) => 1,
2211        (SemanticOperationAction::Create, ResourceKind::Secret) => 2,
2212        (SemanticOperationAction::EnsureImage, ResourceKind::Image) => 3,
2213        (SemanticOperationAction::Create, ResourceKind::Pod) => 4,
2214        (SemanticOperationAction::Create, ResourceKind::Container) => 5,
2215        (SemanticOperationAction::StartPod | SemanticOperationAction::StartContainer, _) => 6,
2216        _ => 7,
2217    };
2218    (rank, id.resource().clone())
2219}
2220
2221fn sort_findings(findings: &mut Vec<PlanningFinding>) {
2222    findings.sort_unstable();
2223    findings.dedup();
2224}
2225
2226fn validate_identifier(value: &str) -> PodmanLensResult<()> {
2227    if value.is_empty() || value.len() > MAX_REFERENCE_BYTES || value.chars().any(char::is_control) {
2228        Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent))
2229    } else {
2230        Ok(())
2231    }
2232}
2233
2234fn validate_connection_name(value: &str) -> PodmanLensResult<()> {
2235    let mut bytes = value.bytes();
2236    let Some(first) = bytes.next() else {
2237        return Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent));
2238    };
2239    if value.len() > MAX_CONNECTION_NAME_BYTES
2240        || !first.is_ascii_alphanumeric()
2241        || !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
2242    {
2243        Err(Diagnostic::new(DiagnosticCode::InvalidDeploymentIntent))
2244    } else {
2245        Ok(())
2246    }
2247}