1use 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#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
24pub struct DeploymentResourceId {
25 kind: ResourceKind,
26 name: String,
27}
28
29impl DeploymentResourceId {
30 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 #[must_use]
46 pub const fn kind(&self) -> ResourceKind {
47 self.kind
48 }
49
50 #[must_use]
52 pub fn name(&self) -> &str {
53 &self.name
54 }
55}
56
57#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct DeploymentConnectionReference(String);
66
67impl DeploymentConnectionReference {
68 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 #[must_use]
82 pub fn as_str(&self) -> &str {
83 &self.0
84 }
85}
86
87#[derive(Clone, Eq, PartialEq)]
89pub struct SensitiveInputReference(String);
90
91impl SensitiveInputReference {
92 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 #[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#[derive(Clone, Debug, Eq, PartialEq)]
128pub struct ImageIntent {
129 identity: DeploymentResourceId,
130 source: ImageSource,
131 pull_policy: ImagePullPolicy,
132}
133
134#[derive(Clone, Copy, Debug, Eq, PartialEq)]
136#[non_exhaustive]
137pub enum ImagePullPolicy {
138 Always,
140 Missing,
142 Never,
144 Newer,
146}
147
148impl ImagePullPolicy {
149 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
163#[non_exhaustive]
164pub enum ImageSourceClassification {
165 Portable,
167 Local,
169 Unqualified,
171 Tagless,
173}
174
175#[derive(Clone, Debug, Eq, PartialEq)]
177pub struct ImageSource {
178 spelling: String,
179 classification: ImageSourceClassification,
180}
181
182impl ImageSource {
183 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 #[must_use]
201 pub fn as_str(&self) -> &str {
202 &self.spelling
203 }
204
205 #[must_use]
207 pub const fn classification(&self) -> ImageSourceClassification {
208 self.classification
209 }
210}
211
212impl ImageIntent {
213 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 #[must_use]
238 pub fn identity(&self) -> &DeploymentResourceId {
239 &self.identity
240 }
241
242 #[must_use]
244 pub fn source(&self) -> &ImageSource {
245 &self.source
246 }
247
248 #[must_use]
250 pub const fn pull_policy(&self) -> ImagePullPolicy {
251 self.pull_policy
252 }
253}
254
255#[derive(Clone, Debug, Eq, PartialEq)]
257pub struct NetworkIntent {
258 identity: DeploymentResourceId,
259 subnets: Vec<NetworkSubnet>,
260 routes: Vec<NetworkRoute>,
261}
262
263impl NetworkIntent {
264 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 pub fn add_subnet(&mut self, subnet: NetworkSubnet) -> PodmanLensResult<()> {
284 add_subnet(&mut self.subnets, subnet)
285 }
286
287 pub fn add_route(&mut self, route: NetworkRoute) -> PodmanLensResult<()> {
293 add_route(&mut self.routes, route)
294 }
295
296 #[must_use]
298 pub fn identity(&self) -> &DeploymentResourceId {
299 &self.identity
300 }
301
302 #[must_use]
304 pub fn subnets(&self) -> &[NetworkSubnet] {
305 &self.subnets
306 }
307
308 #[must_use]
310 pub fn routes(&self) -> &[NetworkRoute] {
311 &self.routes
312 }
313}
314#[derive(Clone, Debug, Eq, PartialEq)]
316pub struct VolumeIntent {
317 identity: DeploymentResourceId,
318 uid: Option<UnixId>,
319 gid: Option<UnixId>,
320}
321
322impl VolumeIntent {
323 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 pub fn set_uid(&mut self, uid: UnixId) -> PodmanLensResult<()> {
343 set_volume_owner(&mut self.uid, uid)
344 }
345
346 pub fn set_gid(&mut self, gid: UnixId) -> PodmanLensResult<()> {
352 set_volume_owner(&mut self.gid, gid)
353 }
354
355 #[must_use]
357 pub fn identity(&self) -> &DeploymentResourceId {
358 &self.identity
359 }
360
361 #[must_use]
363 pub const fn uid(&self) -> Option<UnixId> {
364 self.uid
365 }
366
367 #[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#[derive(Clone, Debug, Eq, PartialEq)]
387pub struct ExternalPrecondition {
388 identity: DeploymentResourceId,
389}
390
391impl ExternalPrecondition {
392 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 #[must_use]
407 pub fn identity(&self) -> &DeploymentResourceId {
408 &self.identity
409 }
410}
411
412#[derive(Clone, Debug, Eq, PartialEq)]
414pub struct SecretIntent {
415 identity: DeploymentResourceId,
416 material: SensitiveInputReference,
417}
418
419impl SecretIntent {
420 pub fn new(identity: DeploymentResourceId, material: SensitiveInputReference) -> PodmanLensResult<Self> {
426 require_kind(&identity, ResourceKind::Secret)?;
427 Ok(Self { identity, material })
428 }
429
430 #[must_use]
432 pub fn identity(&self) -> &DeploymentResourceId {
433 &self.identity
434 }
435
436 #[must_use]
438 pub fn material(&self) -> &SensitiveInputReference {
439 &self.material
440 }
441}
442
443#[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 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 pub fn add_network(&mut self, network: NetworkAttachment) -> PodmanLensResult<()> {
480 add_attachment(&mut self.networks, network)
481 }
482
483 pub fn add_port(&mut self, port: PortMapping) -> PodmanLensResult<()> {
489 add_port(&mut self.ports, port)
490 }
491
492 #[must_use]
494 pub fn dns_mut(&mut self) -> &mut DnsConfiguration {
495 &mut self.dns
496 }
497
498 pub fn add_host_alias(&mut self, host: HostAlias) -> PodmanLensResult<()> {
504 add_host(&mut self.hosts, host)
505 }
506
507 pub fn add_infra_mount(&mut self, mount: impl Into<MountIntent>) {
512 self.infra_mounts.push(mount.into());
513 }
514
515 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 #[must_use]
530 pub fn identity(&self) -> &DeploymentResourceId {
531 &self.identity
532 }
533
534 #[must_use]
536 pub fn networks(&self) -> &[NetworkAttachment] {
537 &self.networks
538 }
539
540 #[must_use]
542 pub fn ports(&self) -> &[PortMapping] {
543 &self.ports
544 }
545
546 #[must_use]
548 pub fn dns(&self) -> &DnsConfiguration {
549 &self.dns
550 }
551
552 #[must_use]
554 pub fn host_aliases(&self) -> &[HostAlias] {
555 &self.hosts
556 }
557
558 #[must_use]
560 pub fn infra_mounts(&self) -> &[MountIntent] {
561 &self.infra_mounts
562 }
563
564 #[must_use]
566 pub fn members(&self) -> &[DeploymentResourceId] {
567 &self.members
568 }
569}
570
571#[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 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 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 pub fn add_network(&mut self, network: NetworkAttachment) -> PodmanLensResult<()> {
637 add_attachment(&mut self.networks, network)
638 }
639
640 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 pub fn add_port(&mut self, port: PortMapping) -> PodmanLensResult<()> {
666 add_port(&mut self.ports, port)
667 }
668
669 #[must_use]
671 pub fn dns_mut(&mut self) -> &mut DnsConfiguration {
672 &mut self.dns
673 }
674
675 pub fn add_host_alias(&mut self, host: HostAlias) -> PodmanLensResult<()> {
681 add_host(&mut self.hosts, host)
682 }
683
684 pub fn add_mount(&mut self, mount: impl Into<MountIntent>) {
686 self.mounts.push(mount.into());
687 }
688
689 pub fn add_secret_grant(&mut self, grant: SecretGrant) {
691 self.secret_grants.push(grant);
692 }
693
694 #[must_use]
696 pub fn identity(&self) -> &DeploymentResourceId {
697 &self.identity
698 }
699
700 #[must_use]
702 pub fn image(&self) -> &DeploymentResourceId {
703 &self.image
704 }
705
706 #[must_use]
708 pub fn pod(&self) -> Option<&DeploymentResourceId> {
709 self.pod.as_ref()
710 }
711
712 #[must_use]
714 pub fn networks(&self) -> &[NetworkAttachment] {
715 &self.networks
716 }
717
718 #[must_use]
720 pub fn network_order(&self) -> Option<&[DeploymentResourceId]> {
721 self.network_order.as_deref()
722 }
723
724 #[must_use]
726 pub fn ports(&self) -> &[PortMapping] {
727 &self.ports
728 }
729
730 #[must_use]
732 pub fn dns(&self) -> &DnsConfiguration {
733 &self.dns
734 }
735
736 #[must_use]
738 pub fn host_aliases(&self) -> &[HostAlias] {
739 &self.hosts
740 }
741
742 #[must_use]
744 pub fn mounts(&self) -> &[MountIntent] {
745 &self.mounts
746 }
747
748 #[must_use]
750 pub fn secret_grants(&self) -> &[SecretGrant] {
751 &self.secret_grants
752 }
753
754 #[must_use]
756 pub fn settings(&self) -> &ContainerSettings {
757 &self.settings
758 }
759
760 #[must_use]
762 pub fn settings_mut(&mut self) -> &mut ContainerSettings {
763 &mut self.settings
764 }
765
766 #[must_use]
768 pub fn runtime(&self) -> &ContainerRuntimeSettings {
769 &self.runtime
770 }
771
772 #[must_use]
774 pub fn runtime_mut(&mut self) -> &mut ContainerRuntimeSettings {
775 &mut self.runtime
776 }
777}
778
779#[derive(Clone, Debug, Eq, PartialEq)]
781#[non_exhaustive]
782pub enum DeploymentResource {
783 ExternalPrecondition(ExternalPrecondition),
785 Image(ImageIntent),
787 Network(NetworkIntent),
789 Volume(VolumeIntent),
791 Secret(SecretIntent),
793 Pod(PodIntent),
795 Container(ContainerIntent),
797}
798
799impl DeploymentResource {
800 #[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#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
820pub struct StartupDependency {
821 predecessor: DeploymentResourceId,
822 dependent: DeploymentResourceId,
823}
824
825impl StartupDependency {
826 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 #[must_use]
839 pub fn predecessor(&self) -> &DeploymentResourceId {
840 &self.predecessor
841 }
842
843 #[must_use]
845 pub fn dependent(&self) -> &DeploymentResourceId {
846 &self.dependent
847 }
848}
849
850#[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 #[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 pub fn set_connection(&mut self, connection: DeploymentConnectionReference) {
873 self.connection = Some(connection);
874 }
875
876 pub fn add_resource(&mut self, resource: DeploymentResource) {
881 self.resources.push(resource);
882 }
883
884 pub fn add_startup_dependency(&mut self, dependency: StartupDependency) {
886 self.startup_dependencies.push(dependency);
887 }
888
889 #[must_use]
891 pub fn target(&self) -> &TargetProfile {
892 &self.target
893 }
894
895 #[must_use]
897 pub fn connection(&self) -> Option<&DeploymentConnectionReference> {
898 self.connection.as_ref()
899 }
900
901 #[must_use]
903 pub fn resources(&self) -> &[DeploymentResource] {
904 &self.resources
905 }
906
907 #[must_use]
909 pub fn startup_dependencies(&self) -> &[StartupDependency] {
910 &self.startup_dependencies
911 }
912}
913
914#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
916#[non_exhaustive]
917pub enum SemanticOperationAction {
918 EnsureImage,
920 Create,
922 StartPod,
924 StartContainer,
926}
927
928#[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 #[must_use]
942 pub const fn action(&self) -> SemanticOperationAction {
943 self.action
944 }
945
946 #[must_use]
948 pub fn resource(&self) -> &DeploymentResourceId {
949 &self.resource
950 }
951}
952
953#[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 #[must_use]
965 pub fn id(&self) -> &DeploymentOperationId {
966 &self.id
967 }
968
969 #[must_use]
975 pub fn resource_intent(&self) -> &DeploymentResource {
976 &self.resource_intent
977 }
978
979 #[must_use]
981 pub fn depends_on(&self) -> &[DeploymentOperationId] {
982 &self.depends_on
983 }
984
985 #[must_use]
987 pub const fn image_pull_policy(&self) -> Option<ImagePullPolicy> {
988 self.image_pull_policy
989 }
990}
991
992#[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#[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 #[must_use]
1068 pub const fn code(&self) -> DiagnosticCode {
1069 self.code
1070 }
1071
1072 #[must_use]
1074 pub const fn message(&self) -> &'static str {
1075 Diagnostic::new(self.code).message()
1076 }
1077
1078 #[must_use]
1080 pub fn subject(&self) -> Option<&DeploymentResourceId> {
1081 self.subject.as_ref()
1082 }
1083
1084 #[must_use]
1086 pub fn related(&self) -> &[DeploymentResourceId] {
1087 &self.related
1088 }
1089
1090 #[must_use]
1092 pub const fn field(&self) -> Option<&'static str> {
1093 self.field
1094 }
1095
1096 #[must_use]
1101 pub const fn occurrence(&self) -> Option<usize> {
1102 self.occurrence
1103 }
1104
1105 #[must_use]
1107 pub const fn count(&self) -> Option<usize> {
1108 self.count
1109 }
1110}
1111
1112#[derive(Clone, Debug, Eq, PartialEq)]
1114pub struct PlanningOutcome {
1115 plan: Option<DeploymentPlan>,
1116 findings: Vec<PlanningFinding>,
1117}
1118
1119impl PlanningOutcome {
1120 #[must_use]
1122 pub fn plan(&self) -> Option<&DeploymentPlan> {
1123 self.plan.as_ref()
1124 }
1125
1126 #[must_use]
1128 pub fn findings(&self) -> &[PlanningFinding] {
1129 &self.findings
1130 }
1131
1132 #[must_use]
1134 pub const fn is_success(&self) -> bool {
1135 self.plan.is_some()
1136 }
1137}
1138
1139impl DeploymentPlan {
1140 #[must_use]
1142 pub fn target(&self) -> &TargetProfile {
1143 &self.target
1144 }
1145
1146 #[must_use]
1148 pub fn connection(&self) -> Option<&DeploymentConnectionReference> {
1149 self.connection.as_ref()
1150 }
1151
1152 #[must_use]
1157 pub fn external_preconditions(&self) -> &[ExternalPrecondition] {
1158 &self.external_preconditions
1159 }
1160
1161 #[must_use]
1163 pub fn operations(&self) -> &[DeploymentOperation] {
1164 &self.operations
1165 }
1166}
1167
1168#[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)] fn 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)] fn 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}