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