Skip to main content

boxferry_model/
application.rs

1//! Ordered neutral application graph and resource attachments.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    error::Error,
6    fmt,
7    net::IpAddr,
8};
9
10use crate::{ImageAcquisition, ImageBuild, ImageReference, ProtectedString, Provenance, Sourced};
11
12/// Error raised when constructing an invalid neutral model value.
13#[derive(Clone, Debug, Eq, PartialEq)]
14#[non_exhaustive]
15pub enum ModelError {
16    /// A required value was empty.
17    EmptyValue(&'static str),
18    /// A value contained a NUL byte.
19    ContainsNul(&'static str),
20    /// A source byte range ended before it started.
21    ReversedSpan {
22        /// Inclusive start offset.
23        start: usize,
24        /// Exclusive end offset.
25        end: usize,
26    },
27    /// An application already contains a resource of this kind and name.
28    DuplicateResource {
29        /// Neutral resource kind.
30        kind: &'static str,
31        /// Duplicated name.
32        name: String,
33    },
34    /// A service was added to the same group more than once.
35    DuplicateServiceGroupMember {
36        /// Service-group name.
37        group: String,
38        /// Duplicate service name.
39        service: String,
40    },
41    /// A service group referenced a service absent from the application.
42    UnknownServiceGroupMember {
43        /// Service-group name.
44        group: String,
45        /// Missing service name.
46        service: String,
47    },
48    /// A service was assigned to more than one application group.
49    ServiceInMultipleGroups {
50        /// Service name.
51        service: String,
52        /// Existing service-group name.
53        existing: String,
54        /// Conflicting service-group name.
55        replacement: String,
56    },
57    /// A service referenced an image acquisition absent from the application.
58    UnknownImageAcquisitionReference {
59        /// Referencing service name.
60        service: String,
61        /// Missing image-acquisition resource name.
62        acquisition: String,
63    },
64    /// A service referenced an image build absent from the application.
65    UnknownImageBuildReference {
66        /// Referencing service name.
67        service: String,
68        /// Missing image-build resource name.
69        build: String,
70    },
71    /// A volume referenced an image-acquisition resource absent from the application.
72    UnknownVolumeImageAcquisitionReference {
73        /// Referencing volume name.
74        volume: String,
75        /// Missing image-acquisition resource name.
76        acquisition: String,
77    },
78    /// A volume referenced an image-build resource absent from the application.
79    UnknownVolumeImageBuildReference {
80        /// Referencing volume name.
81        volume: String,
82        /// Missing image-build resource name.
83        build: String,
84    },
85    /// An explicitly supplied artifact dependency referred to a resource absent from the application.
86    UnknownArtifactDependencyNode {
87        /// Missing resource kind.
88        kind: &'static str,
89        /// Missing resource name.
90        name: String,
91    },
92    /// Explicit artifact dependencies formed a cycle.
93    ImageArtifactDependencyCycle {
94        /// Stable, resource-qualified members of one detected cycle.
95        nodes: Vec<String>,
96    },
97    /// An image reference had invalid component structure.
98    InvalidImageReference(&'static str),
99    /// A container port was zero.
100    ZeroContainerPort,
101    /// A requested service network-attachment index did not exist.
102    UnknownNetworkAttachmentIndex {
103        /// Requested attachment index.
104        index: usize,
105        /// Number of attachments present when replacement was attempted.
106        len: usize,
107    },
108    /// A requested group-runtime network-attachment index did not exist.
109    UnknownServiceGroupRuntimeNetworkIndex {
110        /// Requested attachment index.
111        index: usize,
112        /// Number of attachments present when replacement was attempted.
113        len: usize,
114    },
115    /// A service combined a root filesystem with an image source.
116    RootfsImageSourceConflict {
117        /// Referencing service name.
118        service: String,
119        /// Conflicting image source kind.
120        source: &'static str,
121    },
122    /// A health-check retry count was not a non-negative decimal integer.
123    InvalidHealthcheckRetries,
124}
125
126impl fmt::Display for ModelError {
127    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            Self::EmptyValue(kind) => write!(formatter, "{kind} must not be empty"),
130            Self::ContainsNul(kind) => write!(formatter, "{kind} must not contain a NUL byte"),
131            Self::ReversedSpan { start, end } => {
132                write!(formatter, "source span end {end} is before start {start}")
133            }
134            Self::DuplicateResource { kind, name } => {
135                write!(formatter, "duplicate {kind} `{name}`")
136            }
137            Self::DuplicateServiceGroupMember { group, service } => {
138                write!(
139                    formatter,
140                    "service group `{group}` contains duplicate member `{service}`"
141                )
142            }
143            Self::UnknownServiceGroupMember { group, service } => {
144                write!(
145                    formatter,
146                    "service group `{group}` references unknown service `{service}`"
147                )
148            }
149            Self::ServiceInMultipleGroups {
150                service,
151                existing,
152                replacement,
153            } => write!(
154                formatter,
155                "service `{service}` belongs to both service groups `{existing}` and `{replacement}`"
156            ),
157            Self::UnknownImageAcquisitionReference { service, acquisition } => write!(
158                formatter,
159                "service `{service}` references unknown image acquisition `{acquisition}`"
160            ),
161            Self::UnknownImageBuildReference { service, build } => {
162                write!(
163                    formatter,
164                    "service `{service}` references unknown image build `{build}`"
165                )
166            }
167            Self::UnknownVolumeImageAcquisitionReference { volume, acquisition } => write!(
168                formatter,
169                "volume `{volume}` references unknown image acquisition `{acquisition}`"
170            ),
171            Self::UnknownVolumeImageBuildReference { volume, build } => {
172                write!(formatter, "volume `{volume}` references unknown image build `{build}`")
173            }
174            Self::UnknownArtifactDependencyNode { kind, name } => {
175                write!(formatter, "artifact dependency references unknown {kind} `{name}`")
176            }
177            Self::ImageArtifactDependencyCycle { nodes } => {
178                write!(formatter, "image-artifact dependency cycle: {}", nodes.join(" -> "))
179            }
180            Self::InvalidImageReference(reason) => write!(formatter, "invalid image reference: {reason}"),
181            Self::ZeroContainerPort => formatter.write_str("container port must not be zero"),
182            Self::UnknownNetworkAttachmentIndex { index, len } => {
183                write!(
184                    formatter,
185                    "network attachment index {index} is outside collection length {len}"
186                )
187            }
188            Self::UnknownServiceGroupRuntimeNetworkIndex { index, len } => {
189                write!(
190                    formatter,
191                    "group-runtime network attachment index {index} is outside collection length {len}"
192                )
193            }
194            Self::RootfsImageSourceConflict { service, source } => write!(
195                formatter,
196                "service `{service}` combines rootfs with image source `{source}`"
197            ),
198            Self::InvalidHealthcheckRetries => {
199                formatter.write_str("health-check retries must be a non-negative decimal integer")
200            }
201        }
202    }
203}
204
205impl Error for ModelError {}
206
207/// Opaque, non-empty application or resource identifier.
208#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
209pub struct Identifier(String);
210
211impl Identifier {
212    /// Creates an identifier without applying native-format naming rules.
213    ///
214    /// # Errors
215    ///
216    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
217    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
218        let value = value.into();
219        validate_text("identifier", &value)?;
220        Ok(Self(value))
221    }
222
223    /// Returns the authored identifier.
224    #[must_use]
225    pub fn as_str(&self) -> &str {
226        &self.0
227    }
228}
229
230/// Who owns a named application resource lifecycle.
231#[derive(Clone, Copy, Debug, Eq, PartialEq)]
232#[non_exhaustive]
233pub enum ResourceOwnership {
234    /// The application declares and owns the resource.
235    Application,
236    /// The target environment owns the resource outside this application.
237    External,
238    /// A source implementation supplied the resource implicitly.
239    Implicit,
240    /// Runtime inspection established the resource but not who should manage its lifecycle.
241    Uncertain,
242}
243
244/// One application-level volume declaration.
245#[derive(Clone, Debug, Eq, PartialEq)]
246pub struct Volume {
247    name: Identifier,
248    ownership: ResourceOwnership,
249    runtime_name: Option<Sourced<ProtectedString>>,
250    service_name: Option<Sourced<ProtectedString>>,
251    driver: Option<Sourced<ProtectedString>>,
252    device: Option<Sourced<ProtectedString>>,
253    type_spelling: Option<Sourced<ProtectedString>>,
254    options: Option<Sourced<ProtectedString>>,
255    labels: Option<Vec<Sourced<MetadataLabel>>>,
256    labels_origins: Vec<Provenance>,
257    copy: Option<Sourced<bool>>,
258    containers_conf_modules: Option<Vec<Sourced<ProtectedString>>>,
259    containers_conf_modules_origins: Vec<Provenance>,
260    global_args: Option<Vec<Sourced<ProtectedString>>>,
261    global_args_origins: Vec<Provenance>,
262    podman_args: Option<Vec<Sourced<ProtectedString>>>,
263    podman_args_origins: Vec<Provenance>,
264    user: Option<Sourced<ProtectedString>>,
265    group: Option<Sourced<ProtectedString>>,
266    uid: Option<Sourced<ProtectedString>>,
267    gid: Option<Sourced<ProtectedString>>,
268    image_source: Option<Sourced<VolumeImageSource>>,
269}
270
271impl Volume {
272    /// Creates a volume declaration without inferring native defaults or names.
273    #[must_use]
274    pub const fn new(name: Identifier, ownership: ResourceOwnership) -> Self {
275        Self {
276            name,
277            ownership,
278            runtime_name: None,
279            service_name: None,
280            driver: None,
281            device: None,
282            type_spelling: None,
283            options: None,
284            labels: None,
285            labels_origins: Vec::new(),
286            copy: None,
287            containers_conf_modules: None,
288            containers_conf_modules_origins: Vec::new(),
289            global_args: None,
290            global_args_origins: Vec::new(),
291            podman_args: None,
292            podman_args_origins: Vec::new(),
293            user: None,
294            group: None,
295            uid: None,
296            gid: None,
297            image_source: None,
298        }
299    }
300
301    /// Returns the neutral resource name.
302    #[must_use]
303    pub const fn name(&self) -> &Identifier {
304        &self.name
305    }
306
307    /// Returns the resource lifecycle owner.
308    #[must_use]
309    pub const fn ownership(&self) -> ResourceOwnership {
310        self.ownership
311    }
312
313    /// Sets the explicit runtime name, distinct from the logical resource key.
314    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
315        self.runtime_name = Some(name);
316    }
317
318    /// Returns the explicit provider/runtime name.
319    #[must_use]
320    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
321        self.runtime_name.as_ref()
322    }
323
324    /// Sets the explicit service-manager unit name without changing its spelling.
325    pub fn set_service_name(&mut self, name: Sourced<ProtectedString>) {
326        self.service_name = Some(name);
327    }
328
329    /// Returns the explicit service-manager unit name.
330    #[must_use]
331    pub const fn service_name(&self) -> Option<&Sourced<ProtectedString>> {
332        self.service_name.as_ref()
333    }
334
335    /// Sets the source-authored volume driver spelling.
336    pub fn set_driver(&mut self, driver: Sourced<ProtectedString>) {
337        self.driver = Some(driver);
338    }
339
340    /// Returns the explicit volume driver spelling.
341    #[must_use]
342    pub const fn driver(&self) -> Option<&Sourced<ProtectedString>> {
343        self.driver.as_ref()
344    }
345
346    /// Sets the source-authored local-driver device spelling.
347    pub fn set_device(&mut self, device: Sourced<ProtectedString>) {
348        self.device = Some(device);
349    }
350
351    /// Returns the explicit local-driver device spelling.
352    #[must_use]
353    pub const fn device(&self) -> Option<&Sourced<ProtectedString>> {
354        self.device.as_ref()
355    }
356
357    /// Sets the source-authored local-driver type spelling.
358    pub fn set_volume_type(&mut self, volume_type: Sourced<ProtectedString>) {
359        self.type_spelling = Some(volume_type);
360    }
361
362    /// Returns the explicit local-driver type spelling.
363    #[must_use]
364    pub const fn volume_type(&self) -> Option<&Sourced<ProtectedString>> {
365        self.type_spelling.as_ref()
366    }
367
368    /// Sets the explicit singleton local-driver options spelling.
369    ///
370    /// This is intentionally not a generic option bag: it retains the reviewed `Options=`
371    /// setting, including the Compose local-driver `driver_opts.o` mapping.
372    pub fn set_options(&mut self, options: Sourced<ProtectedString>) {
373        self.options = Some(options);
374    }
375
376    /// Returns the explicit singleton local-driver options spelling.
377    #[must_use]
378    pub const fn options(&self) -> Option<&Sourced<ProtectedString>> {
379        self.options.as_ref()
380    }
381
382    /// Sets metadata labels, retaining omission separately from an explicit empty reset.
383    pub fn set_labels(&mut self, labels: Vec<Sourced<MetadataLabel>>) {
384        self.set_labels_with_origins(labels, Vec::new());
385    }
386
387    /// Sets metadata labels with collection-level provenance.
388    pub fn set_labels_with_origins(&mut self, labels: Vec<Sourced<MetadataLabel>>, origins: Vec<Provenance>) {
389        self.labels = Some(labels);
390        self.labels_origins = origins;
391    }
392
393    /// Appends one metadata label in source order.
394    pub fn add_label(&mut self, label: Sourced<MetadataLabel>) {
395        self.labels.get_or_insert_default().push(label);
396    }
397
398    /// Returns metadata labels in authored order, preserving omitted versus explicit-empty state.
399    #[must_use]
400    pub fn labels(&self) -> Option<&[Sourced<MetadataLabel>]> {
401        self.labels.as_deref()
402    }
403
404    /// Returns collection-level label provenance.
405    #[must_use]
406    pub fn labels_origins(&self) -> &[Provenance] {
407        &self.labels_origins
408    }
409
410    /// Sets the explicit copy choice without inferring an omitted source default.
411    pub fn set_copy(&mut self, copy: Sourced<bool>) {
412        self.copy = Some(copy);
413    }
414
415    /// Returns the explicit copy choice.
416    #[must_use]
417    pub const fn copy(&self) -> Option<&Sourced<bool>> {
418        self.copy.as_ref()
419    }
420
421    /// Sets ordered protected `containers.conf` modules, retaining an explicit empty reset.
422    pub fn set_containers_conf_modules(&mut self, values: Vec<Sourced<ProtectedString>>) {
423        self.set_containers_conf_modules_with_origins(values, Vec::new());
424    }
425
426    /// Sets ordered protected `containers.conf` modules with collection-level provenance.
427    pub fn set_containers_conf_modules_with_origins(
428        &mut self,
429        values: Vec<Sourced<ProtectedString>>,
430        origins: Vec<Provenance>,
431    ) {
432        self.containers_conf_modules = Some(values);
433        self.containers_conf_modules_origins = origins;
434    }
435
436    /// Returns protected `containers.conf` modules in authored order, if explicitly present.
437    #[must_use]
438    pub fn containers_conf_modules(&self) -> Option<&[Sourced<ProtectedString>]> {
439        self.containers_conf_modules.as_deref()
440    }
441
442    /// Returns collection-level `containers.conf` module provenance.
443    #[must_use]
444    pub fn containers_conf_modules_origins(&self) -> &[Provenance] {
445        &self.containers_conf_modules_origins
446    }
447
448    /// Sets ordered protected global arguments, retaining an explicit empty reset.
449    pub fn set_global_args(&mut self, values: Vec<Sourced<ProtectedString>>) {
450        self.set_global_args_with_origins(values, Vec::new());
451    }
452
453    /// Sets ordered protected global arguments with collection-level provenance.
454    pub fn set_global_args_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
455        self.global_args = Some(values);
456        self.global_args_origins = origins;
457    }
458
459    /// Returns protected global arguments in authored order, if explicitly present.
460    #[must_use]
461    pub fn global_args(&self) -> Option<&[Sourced<ProtectedString>]> {
462        self.global_args.as_deref()
463    }
464
465    /// Returns collection-level global-argument provenance.
466    #[must_use]
467    pub fn global_args_origins(&self) -> &[Provenance] {
468        &self.global_args_origins
469    }
470
471    /// Sets ordered protected raw Podman arguments authored by the source.
472    ///
473    /// These arguments are evidence only. Adapters must not synthesize them from typed settings.
474    pub fn set_podman_args(&mut self, values: Vec<Sourced<ProtectedString>>) {
475        self.set_podman_args_with_origins(values, Vec::new());
476    }
477
478    /// Sets raw Podman arguments with collection-level provenance.
479    pub fn set_podman_args_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
480        self.podman_args = Some(values);
481        self.podman_args_origins = origins;
482    }
483
484    /// Returns source-authored raw Podman arguments in order, if explicitly present.
485    #[must_use]
486    pub fn podman_args(&self) -> Option<&[Sourced<ProtectedString>]> {
487        self.podman_args.as_deref()
488    }
489
490    /// Returns collection-level raw-Podman-argument provenance.
491    #[must_use]
492    pub fn podman_args_origins(&self) -> &[Provenance] {
493        &self.podman_args_origins
494    }
495
496    /// Sets the source-authored volume user identity.
497    pub fn set_user(&mut self, user: Sourced<ProtectedString>) {
498        self.user = Some(user);
499    }
500
501    /// Returns the source-authored volume user identity.
502    #[must_use]
503    pub const fn user(&self) -> Option<&Sourced<ProtectedString>> {
504        self.user.as_ref()
505    }
506
507    /// Sets the source-authored volume group identity.
508    pub fn set_group(&mut self, group: Sourced<ProtectedString>) {
509        self.group = Some(group);
510    }
511
512    /// Returns the source-authored volume group identity.
513    #[must_use]
514    pub const fn group(&self) -> Option<&Sourced<ProtectedString>> {
515        self.group.as_ref()
516    }
517
518    /// Sets the source-authored numeric user-ID spelling.
519    pub fn set_uid(&mut self, uid: Sourced<ProtectedString>) {
520        self.uid = Some(uid);
521    }
522
523    /// Returns the source-authored numeric user-ID spelling.
524    #[must_use]
525    pub const fn uid(&self) -> Option<&Sourced<ProtectedString>> {
526        self.uid.as_ref()
527    }
528
529    /// Sets the source-authored numeric group-ID spelling.
530    pub fn set_gid(&mut self, gid: Sourced<ProtectedString>) {
531        self.gid = Some(gid);
532    }
533
534    /// Returns the source-authored numeric group-ID spelling.
535    #[must_use]
536    pub const fn gid(&self) -> Option<&Sourced<ProtectedString>> {
537        self.gid.as_ref()
538    }
539
540    /// Sets the explicitly selected image source for an image-backed volume.
541    ///
542    /// Artifact references are checked by [`Application::validate_image_artifact_references`]
543    /// after a complete application graph has been assembled.
544    ///
545    /// # Errors
546    ///
547    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`] for an invalid literal
548    /// image source.
549    pub fn set_image_source(&mut self, image_source: Sourced<VolumeImageSource>) -> Result<(), ModelError> {
550        image_source.value().validate()?;
551        self.image_source = Some(image_source);
552        Ok(())
553    }
554
555    /// Returns the explicitly selected image source for an image-backed volume.
556    #[must_use]
557    pub const fn image_source(&self) -> Option<&Sourced<VolumeImageSource>> {
558        self.image_source.as_ref()
559    }
560}
561
562/// The explicit source of an image-backed volume.
563///
564/// Literal images and named image artifacts remain distinct. The literal form is protected so a
565/// private registry location cannot leak through model debug output.
566#[derive(Clone, Debug, Eq, PartialEq)]
567#[non_exhaustive]
568pub enum VolumeImageSource {
569    /// One protected literal image spelling.
570    Literal(ProtectedString),
571    /// One named image-acquisition resource.
572    ImageAcquisition(Identifier),
573    /// One named image-build resource.
574    ImageBuild(Identifier),
575}
576
577impl VolumeImageSource {
578    fn validate(&self) -> Result<(), ModelError> {
579        if let Self::Literal(image) = self {
580            validate_text("volume image", image.expose())?;
581        }
582        Ok(())
583    }
584}
585
586/// A format-neutral node used by explicit image-artifact dependency validation.
587///
588/// Native adapters create values only after they have established a typed reference. This model
589/// does not parse native raw argument or mount spellings to invent an edge.
590#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
591#[non_exhaustive]
592pub enum ArtifactDependencyNode {
593    /// One application volume.
594    Volume(Identifier),
595    /// One image-acquisition resource.
596    ImageAcquisition(Identifier),
597    /// One image-build resource.
598    ImageBuild(Identifier),
599}
600
601impl ArtifactDependencyNode {
602    fn kind_and_name(&self) -> (&'static str, &Identifier) {
603        match self {
604            Self::Volume(name) => ("volume", name),
605            Self::ImageAcquisition(name) => ("image acquisition", name),
606            Self::ImageBuild(name) => ("image build", name),
607        }
608    }
609
610    fn display_name(&self) -> String {
611        let (kind, name) = self.kind_and_name();
612        format!("{kind}:{}", name.as_str())
613    }
614}
615
616/// One explicit, directed image-artifact dependency.
617///
618/// It intentionally carries independently sourced endpoints. Collection-level provenance belongs
619/// to the caller's enclosing source declaration.
620#[derive(Clone, Debug, Eq, PartialEq)]
621pub struct ArtifactDependency {
622    source: Sourced<ArtifactDependencyNode>,
623    target: Sourced<ArtifactDependencyNode>,
624}
625
626impl ArtifactDependency {
627    /// Creates an explicit typed dependency.
628    #[must_use]
629    pub const fn new(source: Sourced<ArtifactDependencyNode>, target: Sourced<ArtifactDependencyNode>) -> Self {
630        Self { source, target }
631    }
632
633    /// Returns the depending node and its provenance.
634    #[must_use]
635    pub const fn source(&self) -> &Sourced<ArtifactDependencyNode> {
636        &self.source
637    }
638
639    /// Returns the required node and its provenance.
640    #[must_use]
641    pub const fn target(&self) -> &Sourced<ArtifactDependencyNode> {
642        &self.target
643    }
644}
645
646/// One application-level network declaration.
647#[derive(Clone, Debug, Eq, PartialEq)]
648pub struct Network {
649    name: Identifier,
650    ownership: ResourceOwnership,
651    runtime_name: Option<Sourced<ProtectedString>>,
652    driver: Option<Sourced<ProtectedString>>,
653    driver_options: Option<Vec<Sourced<NetworkDriverOption>>>,
654    driver_options_origins: Vec<Provenance>,
655    labels: Option<Vec<Sourced<MetadataLabel>>>,
656    labels_origins: Vec<Provenance>,
657    internal: Option<Sourced<bool>>,
658    ipv6: Option<Sourced<bool>>,
659    ipam_driver: Option<Sourced<ProtectedString>>,
660    ipam_configs: Option<Vec<Sourced<NetworkIpamConfig>>>,
661    ipam_configs_origins: Vec<Provenance>,
662}
663
664/// One driver-specific network option with independently sourced key and value.
665///
666/// Values remain protected because provider options can carry deployment-specific credentials or
667/// topology details. They remain separate fields so adapters never need to parse a synthetic
668/// `key=value` assignment.
669#[derive(Clone, Debug, Eq, PartialEq)]
670pub struct NetworkDriverOption {
671    name: Sourced<Identifier>,
672    value: Sourced<ProtectedString>,
673}
674
675impl NetworkDriverOption {
676    /// Creates one driver option with field-level provenance.
677    ///
678    /// # Errors
679    ///
680    /// Returns [`ModelError::ContainsNul`] when the option value contains a NUL byte. An empty
681    /// value is retained because source mappings may explicitly reset a driver option.
682    pub fn new(name: Sourced<Identifier>, value: Sourced<ProtectedString>) -> Result<Self, ModelError> {
683        validate_no_nul("network driver option value", value.value().expose())?;
684        Ok(Self { name, value })
685    }
686
687    /// Returns the driver-option key and its provenance.
688    #[must_use]
689    pub const fn name(&self) -> &Sourced<Identifier> {
690        &self.name
691    }
692
693    /// Returns the protected driver-option value and its provenance.
694    #[must_use]
695    pub const fn value(&self) -> &Sourced<ProtectedString> {
696        &self.value
697    }
698}
699
700/// One explicitly associated IPAM configuration row.
701///
702/// A row never pairs independent native subnet, gateway, and range collections by position:
703/// adapters retain the source association only when it is present. The subnet is required, while
704/// gateway and range remain independently optional and sourced.
705#[derive(Clone, Debug, Eq, PartialEq)]
706pub struct NetworkIpamConfig {
707    subnet: Sourced<ProtectedString>,
708    gateway: Option<Sourced<ProtectedString>>,
709    ip_range: Option<Sourced<ProtectedString>>,
710}
711
712impl NetworkIpamConfig {
713    /// Creates one IPAM row with a required non-empty subnet spelling.
714    ///
715    /// # Errors
716    ///
717    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`] for the subnet.
718    pub fn new(subnet: Sourced<ProtectedString>) -> Result<Self, ModelError> {
719        validate_text("network IPAM subnet", subnet.value().expose())?;
720        Ok(Self {
721            subnet,
722            gateway: None,
723            ip_range: None,
724        })
725    }
726
727    /// Returns the required subnet spelling and its provenance.
728    #[must_use]
729    pub const fn subnet(&self) -> &Sourced<ProtectedString> {
730        &self.subnet
731    }
732
733    /// Sets an explicitly associated gateway spelling.
734    ///
735    /// # Errors
736    ///
737    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
738    pub fn set_gateway(&mut self, gateway: Sourced<ProtectedString>) -> Result<(), ModelError> {
739        validate_text("network IPAM gateway", gateway.value().expose())?;
740        self.gateway = Some(gateway);
741        Ok(())
742    }
743
744    /// Returns the optional gateway spelling and its provenance.
745    #[must_use]
746    pub const fn gateway(&self) -> Option<&Sourced<ProtectedString>> {
747        self.gateway.as_ref()
748    }
749
750    /// Sets an explicitly associated IP-range spelling.
751    ///
752    /// # Errors
753    ///
754    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
755    pub fn set_ip_range(&mut self, ip_range: Sourced<ProtectedString>) -> Result<(), ModelError> {
756        validate_text("network IPAM IP range", ip_range.value().expose())?;
757        self.ip_range = Some(ip_range);
758        Ok(())
759    }
760
761    /// Returns the optional IP-range spelling and its provenance.
762    #[must_use]
763    pub const fn ip_range(&self) -> Option<&Sourced<ProtectedString>> {
764        self.ip_range.as_ref()
765    }
766}
767
768/// Material source for an application-managed configuration resource.
769#[derive(Clone, Debug, Eq, PartialEq)]
770#[non_exhaustive]
771pub enum ConfigMaterial {
772    /// Read configuration bytes from a caller-resolved file source.
773    File(ProtectedString),
774    /// Read configuration bytes from an explicitly supplied environment value.
775    Environment(ProtectedString),
776    /// Use source-authored inline configuration content.
777    Content(ProtectedString),
778}
779
780/// One application-level configuration declaration.
781#[derive(Clone, Debug, Eq, PartialEq)]
782pub struct Config {
783    name: Identifier,
784    ownership: ResourceOwnership,
785    runtime_name: Option<Sourced<ProtectedString>>,
786    material: Option<Sourced<ConfigMaterial>>,
787}
788
789impl Config {
790    /// Creates a configuration declaration without guessing material or a runtime name.
791    #[must_use]
792    pub const fn new(name: Identifier, ownership: ResourceOwnership) -> Self {
793        Self {
794            name,
795            ownership,
796            runtime_name: None,
797            material: None,
798        }
799    }
800
801    /// Returns the neutral resource name.
802    #[must_use]
803    pub const fn name(&self) -> &Identifier {
804        &self.name
805    }
806
807    /// Returns the resource lifecycle owner.
808    #[must_use]
809    pub const fn ownership(&self) -> ResourceOwnership {
810        self.ownership
811    }
812
813    /// Sets a provider/runtime-level name distinct from the neutral resource key.
814    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
815        self.runtime_name = Some(name);
816    }
817
818    /// Returns the explicit provider/runtime-level name.
819    #[must_use]
820    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
821        self.runtime_name.as_ref()
822    }
823
824    /// Sets the application-managed material source.
825    pub fn set_material(&mut self, material: Sourced<ConfigMaterial>) {
826        self.material = Some(material);
827    }
828
829    /// Returns the optional material source.
830    #[must_use]
831    pub const fn material(&self) -> Option<&Sourced<ConfigMaterial>> {
832        self.material.as_ref()
833    }
834}
835
836/// Material source for an application-managed secret resource.
837#[derive(Clone, Debug, Eq, PartialEq)]
838#[non_exhaustive]
839pub enum SecretMaterial {
840    /// Read secret bytes from a caller-resolved file source.
841    File(ProtectedString),
842    /// Read secret bytes from an explicitly supplied environment value.
843    Environment(ProtectedString),
844}
845
846/// One application-level secret declaration.
847#[derive(Clone, Debug, Eq, PartialEq)]
848pub struct Secret {
849    name: Identifier,
850    ownership: ResourceOwnership,
851    runtime_name: Option<Sourced<ProtectedString>>,
852    material: Option<Sourced<SecretMaterial>>,
853}
854
855impl Secret {
856    /// Creates a secret declaration without guessing material or a runtime name.
857    #[must_use]
858    pub const fn new(name: Identifier, ownership: ResourceOwnership) -> Self {
859        Self {
860            name,
861            ownership,
862            runtime_name: None,
863            material: None,
864        }
865    }
866
867    /// Returns the neutral resource name.
868    #[must_use]
869    pub const fn name(&self) -> &Identifier {
870        &self.name
871    }
872
873    /// Returns the resource lifecycle owner.
874    #[must_use]
875    pub const fn ownership(&self) -> ResourceOwnership {
876        self.ownership
877    }
878
879    /// Sets a provider/runtime-level name distinct from the neutral resource key.
880    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
881        self.runtime_name = Some(name);
882    }
883
884    /// Returns the explicit provider/runtime-level name.
885    #[must_use]
886    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
887        self.runtime_name.as_ref()
888    }
889
890    /// Sets the application-managed material source.
891    pub fn set_material(&mut self, material: Sourced<SecretMaterial>) {
892        self.material = Some(material);
893    }
894
895    /// Returns the optional material source.
896    #[must_use]
897    pub const fn material(&self) -> Option<&Sourced<SecretMaterial>> {
898        self.material.as_ref()
899    }
900}
901
902/// Authored syntax family retained for a config or secret grant.
903#[derive(Clone, Copy, Debug, Eq, PartialEq)]
904#[non_exhaustive]
905pub enum ResourceGrantSyntax {
906    /// Resource-name short syntax with source-format defaults.
907    Short,
908    /// Mapping-based syntax with separately authored options.
909    Long,
910}
911
912/// One ordered service grant of a configuration or secret resource.
913///
914/// The containing service collection determines whether this grants a config or secret. Keeping
915/// one shared shape avoids inventing differences between the common source/target/ownership
916/// options while preserving the short/long syntax decision.
917#[derive(Clone, Debug, Eq, PartialEq)]
918pub struct ResourceGrant {
919    source: ProtectedString,
920    syntax: ResourceGrantSyntax,
921    target: Option<Sourced<ProtectedString>>,
922    uid: Option<Sourced<ProtectedString>>,
923    gid: Option<Sourced<ProtectedString>>,
924    mode: Option<Sourced<ProtectedString>>,
925}
926
927impl ResourceGrant {
928    /// Creates a grant with a non-empty source resource name.
929    ///
930    /// # Errors
931    ///
932    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
933    pub fn new(source: ProtectedString, syntax: ResourceGrantSyntax) -> Result<Self, ModelError> {
934        validate_text("resource grant source", source.expose())?;
935        Ok(Self {
936            source,
937            syntax,
938            target: None,
939            uid: None,
940            gid: None,
941            mode: None,
942        })
943    }
944
945    /// Returns the referenced neutral resource name.
946    #[must_use]
947    pub const fn source(&self) -> &ProtectedString {
948        &self.source
949    }
950
951    /// Returns the authored short/long syntax family.
952    #[must_use]
953    pub const fn syntax(&self) -> ResourceGrantSyntax {
954        self.syntax
955    }
956
957    /// Sets the requested container path or environment-variable name.
958    pub fn set_target(&mut self, target: Sourced<ProtectedString>) {
959        self.target = Some(target);
960    }
961
962    /// Returns the explicitly authored target.
963    #[must_use]
964    pub const fn target(&self) -> Option<&Sourced<ProtectedString>> {
965        self.target.as_ref()
966    }
967
968    /// Sets the requested container user-ID spelling.
969    pub fn set_uid(&mut self, uid: Sourced<ProtectedString>) {
970        self.uid = Some(uid);
971    }
972
973    /// Returns the explicitly authored user-ID spelling.
974    #[must_use]
975    pub const fn uid(&self) -> Option<&Sourced<ProtectedString>> {
976        self.uid.as_ref()
977    }
978
979    /// Sets the requested container group-ID spelling.
980    pub fn set_gid(&mut self, gid: Sourced<ProtectedString>) {
981        self.gid = Some(gid);
982    }
983
984    /// Returns the explicitly authored group-ID spelling.
985    #[must_use]
986    pub const fn gid(&self) -> Option<&Sourced<ProtectedString>> {
987        self.gid.as_ref()
988    }
989
990    /// Sets the requested permission-mode spelling.
991    pub fn set_mode(&mut self, mode: Sourced<ProtectedString>) {
992        self.mode = Some(mode);
993    }
994
995    /// Returns the explicitly authored permission-mode spelling.
996    #[must_use]
997    pub const fn mode(&self) -> Option<&Sourced<ProtectedString>> {
998        self.mode.as_ref()
999    }
1000}
1001
1002impl Network {
1003    /// Creates a network declaration without inferring runtime settings or defaults.
1004    #[must_use]
1005    pub const fn new(name: Identifier, ownership: ResourceOwnership) -> Self {
1006        Self {
1007            name,
1008            ownership,
1009            runtime_name: None,
1010            driver: None,
1011            driver_options: None,
1012            driver_options_origins: Vec::new(),
1013            labels: None,
1014            labels_origins: Vec::new(),
1015            internal: None,
1016            ipv6: None,
1017            ipam_driver: None,
1018            ipam_configs: None,
1019            ipam_configs_origins: Vec::new(),
1020        }
1021    }
1022
1023    /// Returns the neutral resource name.
1024    #[must_use]
1025    pub const fn name(&self) -> &Identifier {
1026        &self.name
1027    }
1028
1029    /// Returns the resource lifecycle owner.
1030    #[must_use]
1031    pub const fn ownership(&self) -> ResourceOwnership {
1032        self.ownership
1033    }
1034
1035    /// Sets the explicit provider/runtime name distinct from the logical resource key.
1036    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
1037        self.runtime_name = Some(name);
1038    }
1039
1040    /// Returns the explicit provider/runtime name.
1041    #[must_use]
1042    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
1043        self.runtime_name.as_ref()
1044    }
1045
1046    /// Sets the source-authored network driver spelling.
1047    pub fn set_driver(&mut self, driver: Sourced<ProtectedString>) {
1048        self.driver = Some(driver);
1049    }
1050
1051    /// Returns the explicit network driver spelling.
1052    #[must_use]
1053    pub const fn driver(&self) -> Option<&Sourced<ProtectedString>> {
1054        self.driver.as_ref()
1055    }
1056
1057    /// Sets ordered driver options while retaining explicit emptiness as a reset.
1058    pub fn set_driver_options(&mut self, options: Vec<Sourced<NetworkDriverOption>>) {
1059        self.driver_options = Some(options);
1060        self.driver_options_origins.clear();
1061    }
1062
1063    /// Sets ordered driver options with collection-level provenance.
1064    pub fn set_driver_options_with_origins(
1065        &mut self,
1066        options: Vec<Sourced<NetworkDriverOption>>,
1067        origins: Vec<Provenance>,
1068    ) {
1069        self.driver_options = Some(options);
1070        self.driver_options_origins = origins;
1071    }
1072
1073    /// Appends one driver option in source order.
1074    pub fn add_driver_option(&mut self, option: Sourced<NetworkDriverOption>) {
1075        self.driver_options.get_or_insert_default().push(option);
1076    }
1077
1078    /// Returns ordered driver options, preserving omitted versus explicit-empty state.
1079    #[must_use]
1080    pub fn driver_options(&self) -> Option<&[Sourced<NetworkDriverOption>]> {
1081        self.driver_options.as_deref()
1082    }
1083
1084    /// Returns collection-level driver-option provenance.
1085    #[must_use]
1086    pub fn driver_options_origins(&self) -> &[Provenance] {
1087        &self.driver_options_origins
1088    }
1089
1090    /// Sets ordered network metadata labels while retaining explicit emptiness as a reset.
1091    pub fn set_labels(&mut self, labels: Vec<Sourced<MetadataLabel>>) {
1092        self.labels = Some(labels);
1093        self.labels_origins.clear();
1094    }
1095
1096    /// Sets ordered network metadata labels with collection-level provenance.
1097    pub fn set_labels_with_origins(&mut self, labels: Vec<Sourced<MetadataLabel>>, origins: Vec<Provenance>) {
1098        self.labels = Some(labels);
1099        self.labels_origins = origins;
1100    }
1101
1102    /// Appends one network metadata label in source order.
1103    pub fn add_label(&mut self, label: Sourced<MetadataLabel>) {
1104        self.labels.get_or_insert_default().push(label);
1105    }
1106
1107    /// Returns ordered network labels, preserving omitted versus explicit-empty state.
1108    #[must_use]
1109    pub fn labels(&self) -> Option<&[Sourced<MetadataLabel>]> {
1110        self.labels.as_deref()
1111    }
1112
1113    /// Returns collection-level network-label provenance.
1114    #[must_use]
1115    pub fn labels_origins(&self) -> &[Provenance] {
1116        &self.labels_origins
1117    }
1118
1119    /// Sets the literal `internal` network flag without inferring a source default.
1120    pub fn set_internal(&mut self, internal: Sourced<bool>) {
1121        self.internal = Some(internal);
1122    }
1123
1124    /// Returns the explicitly authored `internal` network flag.
1125    #[must_use]
1126    pub const fn internal(&self) -> Option<&Sourced<bool>> {
1127        self.internal.as_ref()
1128    }
1129
1130    /// Sets the literal IPv6-enable network flag without inferring a source default.
1131    pub fn set_ipv6(&mut self, ipv6: Sourced<bool>) {
1132        self.ipv6 = Some(ipv6);
1133    }
1134
1135    /// Returns the explicitly authored IPv6-enable network flag.
1136    #[must_use]
1137    pub const fn ipv6(&self) -> Option<&Sourced<bool>> {
1138        self.ipv6.as_ref()
1139    }
1140
1141    /// Sets the source-authored IPAM driver spelling.
1142    pub fn set_ipam_driver(&mut self, driver: Sourced<ProtectedString>) {
1143        self.ipam_driver = Some(driver);
1144    }
1145
1146    /// Returns the explicit IPAM driver spelling.
1147    #[must_use]
1148    pub const fn ipam_driver(&self) -> Option<&Sourced<ProtectedString>> {
1149        self.ipam_driver.as_ref()
1150    }
1151
1152    /// Sets ordered associated IPAM rows while retaining explicit emptiness as a reset.
1153    pub fn set_ipam_configs(&mut self, configs: Vec<Sourced<NetworkIpamConfig>>) {
1154        self.ipam_configs = Some(configs);
1155        self.ipam_configs_origins.clear();
1156    }
1157
1158    /// Sets ordered associated IPAM rows with collection-level provenance.
1159    pub fn set_ipam_configs_with_origins(
1160        &mut self,
1161        configs: Vec<Sourced<NetworkIpamConfig>>,
1162        origins: Vec<Provenance>,
1163    ) {
1164        self.ipam_configs = Some(configs);
1165        self.ipam_configs_origins = origins;
1166    }
1167
1168    /// Appends one independently associated IPAM row in source order.
1169    pub fn add_ipam_config(&mut self, config: Sourced<NetworkIpamConfig>) {
1170        self.ipam_configs.get_or_insert_default().push(config);
1171    }
1172
1173    /// Returns ordered associated IPAM rows, preserving omitted versus explicit-empty state.
1174    #[must_use]
1175    pub fn ipam_configs(&self) -> Option<&[Sourced<NetworkIpamConfig>]> {
1176        self.ipam_configs.as_deref()
1177    }
1178
1179    /// Returns collection-level IPAM configuration provenance.
1180    #[must_use]
1181    pub fn ipam_configs_origins(&self) -> &[Provenance] {
1182        &self.ipam_configs_origins
1183    }
1184}
1185
1186/// One structural group of application services.
1187///
1188/// Membership alone does not imply shared Linux namespaces, an infra container, or a target
1189/// workload kind. Source and target adapters must model or report those semantics separately.
1190#[derive(Clone, Debug, Eq, PartialEq)]
1191pub struct ServiceGroup {
1192    name: Identifier,
1193    ownership: ResourceOwnership,
1194    members: Vec<Sourced<Identifier>>,
1195    runtime: Option<Sourced<ServiceGroupRuntime>>,
1196}
1197
1198impl ServiceGroup {
1199    /// Creates an empty structural service group.
1200    #[must_use]
1201    pub const fn new(name: Identifier, ownership: ResourceOwnership) -> Self {
1202        Self {
1203            name,
1204            ownership,
1205            members: Vec::new(),
1206            runtime: None,
1207        }
1208    }
1209
1210    /// Returns the neutral group name.
1211    #[must_use]
1212    pub const fn name(&self) -> &Identifier {
1213        &self.name
1214    }
1215
1216    /// Returns the group lifecycle owner.
1217    #[must_use]
1218    pub const fn ownership(&self) -> ResourceOwnership {
1219        self.ownership
1220    }
1221
1222    /// Appends one uniquely named member in source order.
1223    ///
1224    /// # Errors
1225    ///
1226    /// Returns [`ModelError::DuplicateServiceGroupMember`] when the service was already added.
1227    pub fn add_member(&mut self, member: Sourced<Identifier>) -> Result<(), ModelError> {
1228        if self.members.iter().any(|candidate| candidate.value() == member.value()) {
1229            return Err(ModelError::DuplicateServiceGroupMember {
1230                group: self.name.as_str().to_owned(),
1231                service: member.value().as_str().to_owned(),
1232            });
1233        }
1234        self.members.push(member);
1235        Ok(())
1236    }
1237
1238    /// Returns member service names in source order with relationship provenance.
1239    #[must_use]
1240    pub fn members(&self) -> &[Sourced<Identifier>] {
1241        &self.members
1242    }
1243
1244    /// Sets the optional runtime settings associated with this structural group.
1245    ///
1246    /// These settings do not alter membership semantics. In particular, their presence does not
1247    /// infer namespace sharing for a group that did not author such settings.
1248    pub fn set_runtime(&mut self, runtime: Sourced<ServiceGroupRuntime>) {
1249        self.runtime = Some(runtime);
1250    }
1251
1252    /// Returns the optional native group-runtime settings.
1253    #[must_use]
1254    pub const fn runtime(&self) -> Option<&Sourced<ServiceGroupRuntime>> {
1255        self.runtime.as_ref()
1256    }
1257}
1258
1259/// Pod exit behavior retained without assigning lifecycle semantics to group membership.
1260#[derive(Clone, Debug, Eq, PartialEq)]
1261#[non_exhaustive]
1262pub enum GroupExitPolicy {
1263    /// Stop the pod when a member container exits.
1264    Stop,
1265    /// Keep the pod running when a member container exits.
1266    Continue,
1267    /// Preserve a source-native exit-policy spelling for target-side classification.
1268    Raw(ProtectedString),
1269}
1270
1271/// Native runtime settings owned by one [`ServiceGroup`].
1272///
1273/// The group's logical [`ServiceGroup::name`] remains distinct from an optional runtime pod name
1274/// and the optional systemd service name. All settings are group-scoped; adapters must not assign
1275/// them to an arbitrary member service.
1276#[derive(Clone, Debug, Default, Eq, PartialEq)]
1277pub struct ServiceGroupRuntime {
1278    runtime_name: Option<Sourced<ProtectedString>>,
1279    service_name: Option<Sourced<ProtectedString>>,
1280    host_mappings: Option<Vec<Sourced<HostMapping>>>,
1281    host_mappings_origins: Vec<Provenance>,
1282    ports: Option<Vec<Sourced<Port>>>,
1283    ports_origins: Vec<Provenance>,
1284    networks: Option<Vec<Sourced<NetworkAttachment>>>,
1285    networks_origins: Vec<Provenance>,
1286    user_namespace: Option<Sourced<ProtectedString>>,
1287    mounts: Option<Vec<Sourced<Mount>>>,
1288    mounts_origins: Vec<Provenance>,
1289    shm_size: Option<Sourced<ProtectedString>>,
1290    exit_policy: Option<Sourced<GroupExitPolicy>>,
1291    stop_timeout: Option<Sourced<StopTimeout>>,
1292}
1293
1294impl ServiceGroupRuntime {
1295    /// Creates empty group-runtime settings for incremental adapter mapping.
1296    #[must_use]
1297    pub const fn new() -> Self {
1298        Self {
1299            runtime_name: None,
1300            service_name: None,
1301            host_mappings: None,
1302            host_mappings_origins: Vec::new(),
1303            ports: None,
1304            ports_origins: Vec::new(),
1305            networks: None,
1306            networks_origins: Vec::new(),
1307            user_namespace: None,
1308            mounts: None,
1309            mounts_origins: Vec::new(),
1310            shm_size: None,
1311            exit_policy: None,
1312            stop_timeout: None,
1313        }
1314    }
1315
1316    /// Sets the pod/runtime name distinct from the neutral group key.
1317    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
1318        self.runtime_name = Some(name);
1319    }
1320
1321    /// Returns the explicit pod/runtime name.
1322    #[must_use]
1323    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
1324        self.runtime_name.as_ref()
1325    }
1326
1327    /// Sets the systemd service name distinct from the logical and runtime names.
1328    pub fn set_service_name(&mut self, name: Sourced<ProtectedString>) {
1329        self.service_name = Some(name);
1330    }
1331
1332    /// Returns the explicit systemd service name.
1333    #[must_use]
1334    pub const fn service_name(&self) -> Option<&Sourced<ProtectedString>> {
1335        self.service_name.as_ref()
1336    }
1337
1338    /// Sets pod host mappings, preserving omitted versus explicit-empty state.
1339    pub fn set_host_mappings(&mut self, values: Vec<Sourced<HostMapping>>) {
1340        self.set_host_mappings_with_origins(values, Vec::new());
1341    }
1342
1343    /// Sets pod host mappings with collection-level provenance.
1344    pub fn set_host_mappings_with_origins(&mut self, values: Vec<Sourced<HostMapping>>, origins: Vec<Provenance>) {
1345        self.host_mappings = Some(values);
1346        self.host_mappings_origins = origins;
1347    }
1348
1349    /// Appends one pod host mapping in source order.
1350    pub fn add_host_mapping(&mut self, value: Sourced<HostMapping>) {
1351        self.host_mappings.get_or_insert_default().push(value);
1352    }
1353
1354    /// Returns pod host mappings, preserving omitted versus explicit-empty state.
1355    #[must_use]
1356    pub fn host_mappings(&self) -> Option<&[Sourced<HostMapping>]> {
1357        self.host_mappings.as_deref()
1358    }
1359
1360    /// Returns collection-level pod-host-mapping provenance.
1361    #[must_use]
1362    pub fn host_mappings_origins(&self) -> &[Provenance] {
1363        &self.host_mappings_origins
1364    }
1365
1366    /// Sets pod ports, preserving omitted versus explicit-empty state.
1367    pub fn set_ports(&mut self, values: Vec<Sourced<Port>>) {
1368        self.set_ports_with_origins(values, Vec::new());
1369    }
1370
1371    /// Sets pod ports with collection-level provenance.
1372    pub fn set_ports_with_origins(&mut self, values: Vec<Sourced<Port>>, origins: Vec<Provenance>) {
1373        self.ports = Some(values);
1374        self.ports_origins = origins;
1375    }
1376
1377    /// Appends one pod port in source order.
1378    pub fn add_port(&mut self, value: Sourced<Port>) {
1379        self.ports.get_or_insert_default().push(value);
1380    }
1381
1382    /// Returns pod ports, preserving omitted versus explicit-empty state.
1383    #[must_use]
1384    pub fn ports(&self) -> Option<&[Sourced<Port>]> {
1385        self.ports.as_deref()
1386    }
1387
1388    /// Returns collection-level pod-port provenance.
1389    #[must_use]
1390    pub fn ports_origins(&self) -> &[Provenance] {
1391        &self.ports_origins
1392    }
1393
1394    /// Sets pod network attachments, preserving omitted versus explicit-empty state.
1395    pub fn set_networks(&mut self, values: Vec<Sourced<NetworkAttachment>>) {
1396        self.set_networks_with_origins(values, Vec::new());
1397    }
1398
1399    /// Sets pod network attachments with collection-level provenance.
1400    pub fn set_networks_with_origins(&mut self, values: Vec<Sourced<NetworkAttachment>>, origins: Vec<Provenance>) {
1401        self.networks = Some(values);
1402        self.networks_origins = origins;
1403    }
1404
1405    /// Appends one pod network attachment in source order.
1406    pub fn add_network(&mut self, value: Sourced<NetworkAttachment>) {
1407        self.networks.get_or_insert_default().push(value);
1408    }
1409
1410    /// Replaces one pod network attachment without changing authored order.
1411    ///
1412    /// # Errors
1413    ///
1414    /// Returns [`ModelError::UnknownServiceGroupRuntimeNetworkIndex`] when `index` is outside
1415    /// the explicitly authored attachment collection.
1416    pub fn replace_network(
1417        &mut self,
1418        index: usize,
1419        value: Sourced<NetworkAttachment>,
1420    ) -> Result<Sourced<NetworkAttachment>, ModelError> {
1421        let len = self.networks.as_ref().map_or(0, Vec::len);
1422        let Some(networks) = self.networks.as_mut() else {
1423            return Err(ModelError::UnknownServiceGroupRuntimeNetworkIndex { index, len });
1424        };
1425        let Some(slot) = networks.get_mut(index) else {
1426            return Err(ModelError::UnknownServiceGroupRuntimeNetworkIndex { index, len });
1427        };
1428        Ok(std::mem::replace(slot, value))
1429    }
1430
1431    /// Returns pod network attachments, preserving omitted versus explicit-empty state.
1432    #[must_use]
1433    pub fn networks(&self) -> Option<&[Sourced<NetworkAttachment>]> {
1434        self.networks.as_deref()
1435    }
1436
1437    /// Returns collection-level pod-network provenance.
1438    #[must_use]
1439    pub fn networks_origins(&self) -> &[Provenance] {
1440        &self.networks_origins
1441    }
1442
1443    /// Sets the raw-preserving pod user-namespace mode.
1444    pub fn set_user_namespace(&mut self, value: Sourced<ProtectedString>) {
1445        self.user_namespace = Some(value);
1446    }
1447
1448    /// Returns the explicit pod user-namespace mode.
1449    #[must_use]
1450    pub const fn user_namespace(&self) -> Option<&Sourced<ProtectedString>> {
1451        self.user_namespace.as_ref()
1452    }
1453
1454    /// Sets pod mounts, preserving omitted versus explicit-empty state.
1455    pub fn set_mounts(&mut self, values: Vec<Sourced<Mount>>) {
1456        self.set_mounts_with_origins(values, Vec::new());
1457    }
1458
1459    /// Sets pod mounts with collection-level provenance.
1460    pub fn set_mounts_with_origins(&mut self, values: Vec<Sourced<Mount>>, origins: Vec<Provenance>) {
1461        self.mounts = Some(values);
1462        self.mounts_origins = origins;
1463    }
1464
1465    /// Appends one pod mount in source order.
1466    pub fn add_mount(&mut self, value: Sourced<Mount>) {
1467        self.mounts.get_or_insert_default().push(value);
1468    }
1469
1470    /// Returns pod mounts, preserving omitted versus explicit-empty state.
1471    #[must_use]
1472    pub fn mounts(&self) -> Option<&[Sourced<Mount>]> {
1473        self.mounts.as_deref()
1474    }
1475
1476    /// Returns collection-level pod-mount provenance.
1477    #[must_use]
1478    pub fn mounts_origins(&self) -> &[Provenance] {
1479        &self.mounts_origins
1480    }
1481
1482    /// Sets the raw protected pod shared-memory-size spelling.
1483    pub fn set_shm_size(&mut self, value: Sourced<ProtectedString>) {
1484        self.shm_size = Some(value);
1485    }
1486
1487    /// Returns the raw protected pod shared-memory-size spelling.
1488    #[must_use]
1489    pub const fn shm_size(&self) -> Option<&Sourced<ProtectedString>> {
1490        self.shm_size.as_ref()
1491    }
1492
1493    /// Sets the pod exit policy.
1494    pub fn set_exit_policy(&mut self, value: Sourced<GroupExitPolicy>) {
1495        self.exit_policy = Some(value);
1496    }
1497
1498    /// Returns the explicit pod exit policy.
1499    #[must_use]
1500    pub const fn exit_policy(&self) -> Option<&Sourced<GroupExitPolicy>> {
1501        self.exit_policy.as_ref()
1502    }
1503
1504    /// Sets the raw stop-grace duration for the pod.
1505    pub fn set_stop_timeout(&mut self, value: Sourced<StopTimeout>) {
1506        self.stop_timeout = Some(value);
1507    }
1508
1509    /// Returns the explicit raw pod stop-grace duration.
1510    #[must_use]
1511    pub const fn stop_timeout(&self) -> Option<&Sourced<StopTimeout>> {
1512        self.stop_timeout.as_ref()
1513    }
1514}
1515
1516/// How a service command overrides the image command.
1517#[derive(Clone, Debug, Eq, PartialEq)]
1518#[non_exhaustive]
1519pub enum Command {
1520    /// Execute an argument vector without target-specific shell parsing.
1521    Exec(Vec<ProtectedString>),
1522    /// Execute authored shell text with target-specific shell semantics still unresolved.
1523    Shell(ProtectedString),
1524    /// Explicitly clear the image command.
1525    Empty,
1526}
1527
1528/// Startup notification behavior requested for one service.
1529///
1530/// The variants retain the portable meanings of Quadlet's `Notify=false`, `Notify=true`, and
1531/// `Notify=healthy` without making a target implementation implicit.
1532#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1533#[non_exhaustive]
1534pub enum StartupNotification {
1535    /// The runtime owns readiness notification (`Notify=false`).
1536    Runtime,
1537    /// The application process owns readiness notification (`Notify=true`).
1538    Application,
1539    /// Readiness is reported from the service health check (`Notify=healthy`).
1540    Healthy,
1541}
1542
1543/// How a service overrides the image entrypoint.
1544///
1545/// This remains distinct from [`Command`]: container runtimes combine the two values differently,
1546/// and a source can explicitly clear either image default independently.
1547#[derive(Clone, Debug, Eq, PartialEq)]
1548#[non_exhaustive]
1549pub enum Entrypoint {
1550    /// Execute an argument vector without target-specific shell parsing.
1551    Exec(Vec<ProtectedString>),
1552    /// Execute authored shell text with target-specific shell semantics still unresolved.
1553    Shell(ProtectedString),
1554    /// Explicitly clear the image entrypoint.
1555    Empty,
1556}
1557
1558/// Source-independent image pull intent.
1559///
1560/// Variants beyond the shared policies intentionally retain source-native behavior for a target
1561/// adapter to classify rather than silently reducing it to a different policy.
1562#[derive(Clone, Debug, Eq, PartialEq)]
1563#[non_exhaustive]
1564pub enum PullPolicy {
1565    /// Always acquire the image before starting.
1566    Always,
1567    /// Acquire the image only when it is absent locally.
1568    Missing,
1569    /// Never acquire the image automatically.
1570    Never,
1571    /// A source-specific spelling with semantics similar to [`Self::Missing`].
1572    IfNotPresent,
1573    /// Build an image instead of pulling it.
1574    Build,
1575    /// Refresh the image daily.
1576    Daily,
1577    /// Refresh the image weekly.
1578    Weekly,
1579    /// Refresh using a source-native interval spelling.
1580    Every(ProtectedString),
1581    /// Retain any other target-native policy spelling.
1582    Raw(ProtectedString),
1583}
1584
1585/// Raw-preserving stop grace duration.
1586///
1587/// Source adapters validate native duration grammar; target adapters report spellings their
1588/// selected implementation cannot express.
1589#[derive(Clone, Debug, Eq, PartialEq)]
1590pub struct StopTimeout(String);
1591
1592impl StopTimeout {
1593    /// Creates a non-empty duration spelling without imposing one source format's grammar.
1594    ///
1595    /// # Errors
1596    ///
1597    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
1598    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
1599        let value = value.into();
1600        validate_text("stop timeout", &value)?;
1601        Ok(Self(value))
1602    }
1603
1604    /// Returns the source adapter's retained duration spelling.
1605    #[must_use]
1606    pub fn as_str(&self) -> &str {
1607        &self.0
1608    }
1609}
1610
1611/// A container port exposed for inter-service use without publishing it to a host.
1612#[derive(Clone, Debug, Eq, PartialEq)]
1613pub struct ExposedPort {
1614    container: u16,
1615    protocol: Protocol,
1616}
1617
1618impl ExposedPort {
1619    /// Creates one exposed container port.
1620    ///
1621    /// # Errors
1622    ///
1623    /// Returns [`ModelError::ZeroContainerPort`] when `container` is zero.
1624    pub fn new(container: u16, protocol: Protocol) -> Result<Self, ModelError> {
1625        if container == 0 {
1626            return Err(ModelError::ZeroContainerPort);
1627        }
1628        Ok(Self { container, protocol })
1629    }
1630
1631    /// Returns the container-side port number.
1632    #[must_use]
1633    pub const fn container(&self) -> u16 {
1634        self.container
1635    }
1636
1637    /// Returns the requested transport protocol.
1638    #[must_use]
1639    pub const fn protocol(&self) -> &Protocol {
1640        &self.protocol
1641    }
1642}
1643
1644/// Container-level automatic restart intent.
1645///
1646/// This policy is distinct from Compose dependency restart propagation and orchestrator-level
1647/// deployment restart policies. A limited on-failure policy uses a non-zero retry count so the
1648/// absence of a limit remains distinguishable from an invalid zero-valued limit.
1649#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1650#[non_exhaustive]
1651pub enum RestartPolicy {
1652    /// Never restart the container automatically.
1653    Never,
1654    /// Restart after every container exit.
1655    Always,
1656    /// Restart after a failed container exit, optionally up to a finite retry count.
1657    OnFailure {
1658        /// Maximum number of restart attempts; `None` means no policy-specific limit.
1659        maximum_retries: Option<std::num::NonZeroU64>,
1660    },
1661    /// Restart automatically unless an explicit stop state must survive runtime restart.
1662    UnlessStopped,
1663}
1664
1665impl RestartPolicy {
1666    /// Creates an on-failure policy with an optional non-zero retry limit.
1667    #[must_use]
1668    pub const fn on_failure(maximum_retries: Option<std::num::NonZeroU64>) -> Self {
1669        Self::OnFailure { maximum_retries }
1670    }
1671
1672    /// Returns the finite retry limit of an on-failure policy.
1673    #[must_use]
1674    pub const fn maximum_retries(self) -> Option<std::num::NonZeroU64> {
1675        match self {
1676            Self::OnFailure { maximum_retries } => maximum_retries,
1677            Self::Never | Self::Always | Self::UnlessStopped => None,
1678        }
1679    }
1680}
1681
1682/// How a container runtime executes one service health check.
1683#[derive(Clone, Debug, Eq, PartialEq)]
1684#[non_exhaustive]
1685pub enum HealthcheckCommand {
1686    /// Execute an argument vector without a container shell.
1687    Exec(Vec<ProtectedString>),
1688    /// Execute authored command text through the container shell.
1689    Shell(ProtectedString),
1690}
1691
1692/// Raw-preserving duration shared by container health-check implementations.
1693#[derive(Clone, Debug, Eq, PartialEq)]
1694pub struct HealthcheckDuration(String);
1695
1696impl HealthcheckDuration {
1697    /// Creates a non-empty duration spelling without imposing one source format's grammar.
1698    ///
1699    /// Source adapters remain responsible for validating native duration syntax. Target adapters
1700    /// must report spellings that their selected implementation cannot represent.
1701    ///
1702    /// # Errors
1703    ///
1704    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
1705    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
1706        let value = value.into();
1707        validate_text("health-check duration", &value)?;
1708        Ok(Self(value))
1709    }
1710
1711    /// Returns the source adapter's retained duration spelling.
1712    #[must_use]
1713    pub fn as_str(&self) -> &str {
1714        &self.0
1715    }
1716}
1717
1718/// Raw-preserving non-negative health-check retry count.
1719#[derive(Clone, Debug, Eq, PartialEq)]
1720pub struct HealthcheckRetries(String);
1721
1722impl HealthcheckRetries {
1723    /// Creates a retry count while retaining its authored decimal spelling.
1724    ///
1725    /// # Errors
1726    ///
1727    /// Returns [`ModelError::EmptyValue`], [`ModelError::ContainsNul`], or
1728    /// [`ModelError::InvalidHealthcheckRetries`].
1729    pub fn new(value: impl Into<String>) -> Result<Self, ModelError> {
1730        let value = value.into();
1731        validate_text("health-check retries", &value)?;
1732        if !value.bytes().all(|byte| byte.is_ascii_digit()) {
1733            return Err(ModelError::InvalidHealthcheckRetries);
1734        }
1735        Ok(Self(value))
1736    }
1737
1738    /// Returns the source adapter's retained decimal spelling.
1739    #[must_use]
1740    pub fn as_str(&self) -> &str {
1741        &self.0
1742    }
1743}
1744
1745/// Format-independent service health-check intent with field-level provenance.
1746#[derive(Clone, Debug, Default, Eq, PartialEq)]
1747pub struct Healthcheck {
1748    command: Option<Sourced<HealthcheckCommand>>,
1749    disabled: Option<Sourced<bool>>,
1750    interval: Option<Sourced<HealthcheckDuration>>,
1751    timeout: Option<Sourced<HealthcheckDuration>>,
1752    retries: Option<Sourced<HealthcheckRetries>>,
1753    start_period: Option<Sourced<HealthcheckDuration>>,
1754    start_interval: Option<Sourced<HealthcheckDuration>>,
1755}
1756
1757impl Healthcheck {
1758    /// Creates an empty health-check definition for incremental source-adapter mapping.
1759    #[must_use]
1760    pub const fn new() -> Self {
1761        Self {
1762            command: None,
1763            disabled: None,
1764            interval: None,
1765            timeout: None,
1766            retries: None,
1767            start_period: None,
1768            start_interval: None,
1769        }
1770    }
1771
1772    /// Sets the command executed by the runtime.
1773    pub fn set_command(&mut self, command: Sourced<HealthcheckCommand>) {
1774        self.command = Some(command);
1775    }
1776
1777    /// Returns the optional health command.
1778    #[must_use]
1779    pub const fn command(&self) -> Option<&Sourced<HealthcheckCommand>> {
1780        self.command.as_ref()
1781    }
1782
1783    /// Retains an explicit enable/disable decision.
1784    pub fn set_disabled(&mut self, disabled: Sourced<bool>) {
1785        self.disabled = Some(disabled);
1786    }
1787
1788    /// Returns the explicit disable decision, if the source supplied one.
1789    #[must_use]
1790    pub const fn disabled(&self) -> Option<&Sourced<bool>> {
1791        self.disabled.as_ref()
1792    }
1793
1794    /// Sets the interval between regular checks.
1795    pub fn set_interval(&mut self, interval: Sourced<HealthcheckDuration>) {
1796        self.interval = Some(interval);
1797    }
1798
1799    /// Returns the interval between regular checks.
1800    #[must_use]
1801    pub const fn interval(&self) -> Option<&Sourced<HealthcheckDuration>> {
1802        self.interval.as_ref()
1803    }
1804
1805    /// Sets the maximum duration of one check.
1806    pub fn set_timeout(&mut self, timeout: Sourced<HealthcheckDuration>) {
1807        self.timeout = Some(timeout);
1808    }
1809
1810    /// Returns the maximum duration of one check.
1811    #[must_use]
1812    pub const fn timeout(&self) -> Option<&Sourced<HealthcheckDuration>> {
1813        self.timeout.as_ref()
1814    }
1815
1816    /// Sets the number of failures required before becoming unhealthy.
1817    pub fn set_retries(&mut self, retries: Sourced<HealthcheckRetries>) {
1818        self.retries = Some(retries);
1819    }
1820
1821    /// Returns the failure threshold.
1822    #[must_use]
1823    pub const fn retries(&self) -> Option<&Sourced<HealthcheckRetries>> {
1824        self.retries.as_ref()
1825    }
1826
1827    /// Sets the startup grace period.
1828    pub fn set_start_period(&mut self, start_period: Sourced<HealthcheckDuration>) {
1829        self.start_period = Some(start_period);
1830    }
1831
1832    /// Returns the startup grace period.
1833    #[must_use]
1834    pub const fn start_period(&self) -> Option<&Sourced<HealthcheckDuration>> {
1835        self.start_period.as_ref()
1836    }
1837
1838    /// Sets the check interval used during the startup grace period.
1839    pub fn set_start_interval(&mut self, start_interval: Sourced<HealthcheckDuration>) {
1840        self.start_interval = Some(start_interval);
1841    }
1842
1843    /// Returns the check interval used during the startup grace period.
1844    #[must_use]
1845    pub const fn start_interval(&self) -> Option<&Sourced<HealthcheckDuration>> {
1846        self.start_interval.as_ref()
1847    }
1848}
1849
1850/// Source of an environment variable's value.
1851#[derive(Clone, Debug, Eq, PartialEq)]
1852#[non_exhaustive]
1853pub enum EnvironmentValue {
1854    /// A literal plain or sensitive value.
1855    Literal(ProtectedString),
1856    /// Resolve the value from an explicit caller-provided host environment provider.
1857    Host,
1858    /// Ensure the variable is absent.
1859    Unset,
1860}
1861
1862/// One ordered service environment entry.
1863#[derive(Clone, Debug, Eq, PartialEq)]
1864pub struct EnvironmentVariable {
1865    name: Identifier,
1866    value: EnvironmentValue,
1867}
1868
1869/// Authored syntax family retained for an environment-file declaration.
1870#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1871#[non_exhaustive]
1872pub enum EnvironmentFileSyntax {
1873    /// Path-only short syntax with source-format defaults.
1874    Short,
1875    /// Mapping-based syntax with separately authored options.
1876    Long,
1877}
1878
1879/// Explicit parsing mode requested for an environment file.
1880#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1881#[non_exhaustive]
1882pub enum EnvironmentFileFormat {
1883    /// Preserve values without interpolation or quote processing when the source supports it.
1884    Raw,
1885}
1886
1887/// One ordered environment-file declaration.
1888///
1889/// This value describes source intent only. Importing it never reads the referenced file. A
1890/// caller that wants to materialize environment values must cross a separate filesystem-access
1891/// boundary and apply the source implementation's parsing rules explicitly.
1892#[derive(Clone, Debug, Eq, PartialEq)]
1893pub struct EnvironmentFile {
1894    path: ProtectedString,
1895    syntax: EnvironmentFileSyntax,
1896    required: Option<Sourced<bool>>,
1897    format: Option<Sourced<EnvironmentFileFormat>>,
1898}
1899
1900impl EnvironmentFile {
1901    /// Creates an environment-file declaration with a non-empty path.
1902    ///
1903    /// # Errors
1904    ///
1905    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
1906    pub fn new(path: ProtectedString, syntax: EnvironmentFileSyntax) -> Result<Self, ModelError> {
1907        validate_text("environment-file path", path.expose())?;
1908        Ok(Self {
1909            path,
1910            syntax,
1911            required: None,
1912            format: None,
1913        })
1914    }
1915
1916    /// Returns the source-authored path without resolving or reading it.
1917    #[must_use]
1918    pub const fn path(&self) -> &ProtectedString {
1919        &self.path
1920    }
1921
1922    /// Returns the authored short/long syntax family.
1923    #[must_use]
1924    pub const fn syntax(&self) -> EnvironmentFileSyntax {
1925        self.syntax
1926    }
1927
1928    /// Retains an explicit required/optional choice.
1929    pub fn set_required(&mut self, required: Sourced<bool>) {
1930        self.required = Some(required);
1931    }
1932
1933    /// Returns the explicit required/optional choice, if authored.
1934    #[must_use]
1935    pub const fn required(&self) -> Option<&Sourced<bool>> {
1936        self.required.as_ref()
1937    }
1938
1939    /// Returns whether the source requires the file, including the default of `true`.
1940    #[must_use]
1941    pub fn is_required(&self) -> bool {
1942        self.required.as_ref().is_none_or(|required| *required.value())
1943    }
1944
1945    /// Retains an explicitly selected parsing mode.
1946    pub fn set_format(&mut self, format: Sourced<EnvironmentFileFormat>) {
1947        self.format = Some(format);
1948    }
1949
1950    /// Returns the explicitly selected parsing mode, if authored.
1951    #[must_use]
1952    pub const fn format(&self) -> Option<&Sourced<EnvironmentFileFormat>> {
1953        self.format.as_ref()
1954    }
1955}
1956
1957/// One portable metadata label attached to an application resource.
1958///
1959/// Label names remain opaque because Docker, Podman, Compose, and future targets do not share one
1960/// useful restrictive grammar. Values use [`ProtectedString`] so runtime-derived metadata cannot
1961/// leak through debug output before a caller explicitly authorizes rendering it.
1962#[derive(Clone, Debug, Eq, PartialEq)]
1963pub struct MetadataLabel {
1964    name: Identifier,
1965    value: ProtectedString,
1966}
1967
1968impl MetadataLabel {
1969    /// Creates one metadata label.
1970    #[must_use]
1971    pub const fn new(name: Identifier, value: ProtectedString) -> Self {
1972        Self { name, value }
1973    }
1974
1975    /// Returns the opaque metadata-label name.
1976    #[must_use]
1977    pub const fn name(&self) -> &Identifier {
1978        &self.name
1979    }
1980
1981    /// Returns the protected metadata-label value.
1982    #[must_use]
1983    pub const fn value(&self) -> &ProtectedString {
1984        &self.value
1985    }
1986}
1987
1988/// One protected annotation with independently sourced name and value.
1989///
1990/// Unlike service metadata labels, annotations can be target-scoped. Their opaque spelling and
1991/// field provenance remain available for a later target adapter to make that decision explicitly.
1992#[derive(Clone, Debug, Eq, PartialEq)]
1993pub struct Annotation {
1994    name: Sourced<Identifier>,
1995    value: Sourced<ProtectedString>,
1996}
1997
1998impl Annotation {
1999    /// Creates one annotation with separately retained name and value provenance.
2000    #[must_use]
2001    pub const fn new(name: Sourced<Identifier>, value: Sourced<ProtectedString>) -> Self {
2002        Self { name, value }
2003    }
2004
2005    /// Returns the opaque annotation name and its provenance.
2006    #[must_use]
2007    pub const fn name(&self) -> &Sourced<Identifier> {
2008        &self.name
2009    }
2010
2011    /// Returns the protected annotation value and its provenance.
2012    #[must_use]
2013    pub const fn value(&self) -> &Sourced<ProtectedString> {
2014        &self.value
2015    }
2016}
2017
2018/// One provider-specific logging option with independently sourced name and value.
2019#[derive(Clone, Debug, Eq, PartialEq)]
2020pub struct LoggingOption {
2021    name: Sourced<Identifier>,
2022    value: Sourced<ProtectedString>,
2023}
2024
2025impl LoggingOption {
2026    /// Creates one logging option without imposing a provider's option grammar.
2027    #[must_use]
2028    pub const fn new(name: Sourced<Identifier>, value: Sourced<ProtectedString>) -> Self {
2029        Self { name, value }
2030    }
2031
2032    /// Returns the provider-specific option name and its provenance.
2033    #[must_use]
2034    pub const fn name(&self) -> &Sourced<Identifier> {
2035        &self.name
2036    }
2037
2038    /// Returns the protected provider-specific option value and its provenance.
2039    #[must_use]
2040    pub const fn value(&self) -> &Sourced<ProtectedString> {
2041        &self.value
2042    }
2043}
2044
2045/// Provider-specific logging intent.
2046///
2047/// A missing options collection differs from an explicit empty collection, which can reset
2048/// provider defaults. Option names and values remain opaque and ordered.
2049#[derive(Clone, Debug, Default, Eq, PartialEq)]
2050pub struct Logging {
2051    driver: Option<Sourced<ProtectedString>>,
2052    options: Option<Vec<Sourced<LoggingOption>>>,
2053    options_origins: Vec<Provenance>,
2054}
2055
2056impl Logging {
2057    /// Creates an empty logging declaration for incremental source-adapter mapping.
2058    #[must_use]
2059    pub const fn new() -> Self {
2060        Self {
2061            driver: None,
2062            options: None,
2063            options_origins: Vec::new(),
2064        }
2065    }
2066
2067    /// Sets the provider-specific logging driver spelling.
2068    pub fn set_driver(&mut self, driver: Sourced<ProtectedString>) {
2069        self.driver = Some(driver);
2070    }
2071
2072    /// Returns the explicitly authored logging driver.
2073    #[must_use]
2074    pub const fn driver(&self) -> Option<&Sourced<ProtectedString>> {
2075        self.driver.as_ref()
2076    }
2077
2078    /// Sets ordered logging options while preserving explicit emptiness.
2079    pub fn set_options(&mut self, options: Vec<Sourced<LoggingOption>>) {
2080        self.options = Some(options);
2081        self.options_origins.clear();
2082    }
2083
2084    /// Appends one logging option in source order.
2085    pub fn add_option(&mut self, option: Sourced<LoggingOption>) {
2086        self.options.get_or_insert_default().push(option);
2087    }
2088
2089    /// Sets ordered logging options and collection-level provenance.
2090    pub fn set_options_with_origins(&mut self, options: Vec<Sourced<LoggingOption>>, origins: Vec<Provenance>) {
2091        self.options = Some(options);
2092        self.options_origins = origins;
2093    }
2094
2095    /// Returns ordered logging options, preserving omitted versus explicit-empty state.
2096    #[must_use]
2097    pub fn options(&self) -> Option<&[Sourced<LoggingOption>]> {
2098        self.options.as_deref()
2099    }
2100
2101    /// Returns collection-level logging option provenance.
2102    #[must_use]
2103    pub fn options_origins(&self) -> &[Provenance] {
2104        &self.options_origins
2105    }
2106}
2107
2108/// One mutually exclusive reload action.
2109///
2110/// Reloading is lifecycle control, not a regular command or a lifecycle hook. A service therefore
2111/// retains one explicit action rather than allowing command and signal declarations to conflict.
2112#[derive(Clone, Debug, Eq, PartialEq)]
2113#[non_exhaustive]
2114pub enum ReloadAction {
2115    /// Execute the supplied command to request a reload.
2116    Command(Command),
2117    /// Deliver the supplied signal spelling to request a reload.
2118    Signal(ProtectedString),
2119}
2120
2121impl EnvironmentVariable {
2122    /// Creates an environment entry.
2123    #[must_use]
2124    pub const fn new(name: Identifier, value: EnvironmentValue) -> Self {
2125        Self { name, value }
2126    }
2127
2128    /// Returns the variable name.
2129    #[must_use]
2130    pub const fn name(&self) -> &Identifier {
2131        &self.name
2132    }
2133
2134    /// Returns the unresolved value form.
2135    #[must_use]
2136    pub const fn value(&self) -> &EnvironmentValue {
2137        &self.value
2138    }
2139}
2140
2141/// One raw-preserving address or runtime token used by a service host mapping.
2142#[derive(Clone, Debug, Eq, PartialEq)]
2143pub struct HostAddress {
2144    raw: String,
2145    kind: HostAddressKind,
2146}
2147
2148impl HostAddress {
2149    /// Classifies an authored host-mapping address without normalizing it.
2150    ///
2151    /// # Errors
2152    ///
2153    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`].
2154    pub fn new(raw: impl Into<String>) -> Result<Self, ModelError> {
2155        let raw = raw.into();
2156        validate_text("host mapping address", &raw)?;
2157        let unbracketed = raw
2158            .strip_prefix('[')
2159            .and_then(|value| value.strip_suffix(']'))
2160            .unwrap_or(&raw);
2161        let kind = if raw == "host-gateway" {
2162            HostAddressKind::HostGateway
2163        } else {
2164            match unbracketed.parse::<IpAddr>() {
2165                Ok(IpAddr::V4(_)) => HostAddressKind::Ipv4,
2166                Ok(IpAddr::V6(_)) => HostAddressKind::Ipv6 {
2167                    bracketed: raw.starts_with('[') && raw.ends_with(']'),
2168                },
2169                Err(_) => HostAddressKind::Other,
2170            }
2171        };
2172        Ok(Self { raw, kind })
2173    }
2174
2175    /// Returns the address or runtime token exactly as supplied by the source adapter.
2176    #[must_use]
2177    pub fn raw(&self) -> &str {
2178        &self.raw
2179    }
2180
2181    /// Returns the conservative lexical classification.
2182    #[must_use]
2183    pub const fn kind(&self) -> HostAddressKind {
2184        self.kind
2185    }
2186}
2187
2188/// Conservative kind of one service host-mapping address.
2189#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2190#[non_exhaustive]
2191pub enum HostAddressKind {
2192    /// An IPv4 address.
2193    Ipv4,
2194    /// An IPv6 address, retaining whether its source spelling used brackets.
2195    Ipv6 {
2196        /// Whether the source adapter observed `[::1]` rather than `::1`.
2197        bracketed: bool,
2198    },
2199    /// The runtime-specific `host-gateway` token.
2200    HostGateway,
2201    /// A deferred or implementation-specific value.
2202    Other,
2203}
2204
2205/// One ordered service hostname-to-address mapping.
2206#[derive(Clone, Debug, Eq, PartialEq)]
2207pub struct HostMapping {
2208    hostname: Identifier,
2209    address: HostAddress,
2210}
2211
2212impl HostMapping {
2213    /// Creates a service host mapping.
2214    #[must_use]
2215    pub const fn new(hostname: Identifier, address: HostAddress) -> Self {
2216        Self { hostname, address }
2217    }
2218
2219    /// Returns the hostname written into the target hosts file.
2220    #[must_use]
2221    pub const fn hostname(&self) -> &Identifier {
2222        &self.hostname
2223    }
2224
2225    /// Returns the raw-preserving address or runtime token.
2226    #[must_use]
2227    pub const fn address(&self) -> &HostAddress {
2228        &self.address
2229    }
2230}
2231
2232/// Transport protocol attached to a published port.
2233#[derive(Clone, Debug, Eq, PartialEq)]
2234#[non_exhaustive]
2235pub enum Protocol {
2236    /// Transmission Control Protocol.
2237    Tcp,
2238    /// User Datagram Protocol.
2239    Udp,
2240    /// Stream Control Transmission Protocol.
2241    Sctp,
2242    /// A preserved protocol not yet understood by the neutral model.
2243    Other(String),
2244}
2245
2246/// One container port and its optional single-host publication.
2247#[derive(Clone, Debug, Eq, PartialEq)]
2248pub struct Port {
2249    container: u16,
2250    published: Option<u16>,
2251    host_address: Option<String>,
2252    protocol: Protocol,
2253}
2254
2255impl Port {
2256    /// Creates a port declaration.
2257    ///
2258    /// # Errors
2259    ///
2260    /// Returns [`ModelError::ZeroContainerPort`] when `container` is zero.
2261    pub fn new(
2262        container: u16,
2263        published: Option<u16>,
2264        host_address: Option<String>,
2265        protocol: Protocol,
2266    ) -> Result<Self, ModelError> {
2267        if container == 0 {
2268            return Err(ModelError::ZeroContainerPort);
2269        }
2270        Ok(Self {
2271            container,
2272            published,
2273            host_address,
2274            protocol,
2275        })
2276    }
2277
2278    /// Returns the container port.
2279    #[must_use]
2280    pub const fn container(&self) -> u16 {
2281        self.container
2282    }
2283
2284    /// Returns the optional host port.
2285    #[must_use]
2286    pub const fn published(&self) -> Option<u16> {
2287        self.published
2288    }
2289
2290    /// Returns the optional host address spelling.
2291    #[must_use]
2292    pub fn host_address(&self) -> Option<&str> {
2293        self.host_address.as_deref()
2294    }
2295
2296    /// Returns the protocol.
2297    #[must_use]
2298    pub const fn protocol(&self) -> &Protocol {
2299        &self.protocol
2300    }
2301}
2302
2303/// Storage backing attached to a service.
2304#[derive(Clone, Debug, Eq, PartialEq)]
2305#[non_exhaustive]
2306pub enum MountSource {
2307    /// Application-level named volume.
2308    Volume(Identifier),
2309    /// Authored host path whose target-specific resolution is deferred.
2310    HostPath(String),
2311    /// Anonymous target-managed storage.
2312    Anonymous,
2313}
2314
2315/// `SELinux` label sharing requested for a bind mount.
2316#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2317#[non_exhaustive]
2318pub enum SelinuxRelabel {
2319    /// Share the relabeled content between multiple containers (`z`).
2320    Shared,
2321    /// Give the content a private label for one container (`Z`).
2322    Private,
2323}
2324
2325/// One service storage attachment.
2326#[derive(Clone, Debug, Eq, PartialEq)]
2327pub struct Mount {
2328    source: MountSource,
2329    target: String,
2330    read_only: bool,
2331    selinux_relabel: Option<SelinuxRelabel>,
2332}
2333
2334impl Mount {
2335    /// Creates a storage attachment with a non-empty target path.
2336    ///
2337    /// # Errors
2338    ///
2339    /// Returns [`ModelError::EmptyValue`] or [`ModelError::ContainsNul`] for the target.
2340    pub fn new(source: MountSource, target: impl Into<String>, read_only: bool) -> Result<Self, ModelError> {
2341        let target = target.into();
2342        validate_text("mount target", &target)?;
2343        Ok(Self {
2344            source,
2345            target,
2346            read_only,
2347            selinux_relabel: None,
2348        })
2349    }
2350
2351    /// Returns the source storage kind.
2352    #[must_use]
2353    pub const fn source(&self) -> &MountSource {
2354        &self.source
2355    }
2356
2357    /// Returns the authored container target path.
2358    #[must_use]
2359    pub fn target(&self) -> &str {
2360        &self.target
2361    }
2362
2363    /// Returns whether the target is read-only.
2364    #[must_use]
2365    pub const fn read_only(&self) -> bool {
2366        self.read_only
2367    }
2368
2369    /// Sets the requested `SELinux` relabel mode without erasing the source syntax decision.
2370    pub fn set_selinux_relabel(&mut self, relabel: SelinuxRelabel) {
2371        self.selinux_relabel = Some(relabel);
2372    }
2373
2374    /// Returns the requested `SELinux` relabel mode.
2375    #[must_use]
2376    pub const fn selinux_relabel(&self) -> Option<SelinuxRelabel> {
2377        self.selinux_relabel
2378    }
2379}
2380
2381/// One service attachment to an application network.
2382#[derive(Clone, Eq, PartialEq)]
2383pub struct NetworkAttachment {
2384    network: Identifier,
2385    aliases: Vec<String>,
2386    alias_sensitivities: Vec<bool>,
2387    alias_origins: Vec<Vec<Provenance>>,
2388    ipv4_address: Option<Sourced<ProtectedString>>,
2389    ipv6_address: Option<Sourced<ProtectedString>>,
2390}
2391
2392impl NetworkAttachment {
2393    /// Creates a network attachment with ordered provenance-bearing aliases.
2394    #[must_use]
2395    pub fn new(network: Identifier, aliases: Vec<Sourced<ProtectedString>>) -> Self {
2396        Self {
2397            network,
2398            aliases: aliases.iter().map(|alias| alias.value().expose().to_owned()).collect(),
2399            alias_sensitivities: aliases.iter().map(|alias| alias.value().is_sensitive()).collect(),
2400            alias_origins: aliases.into_iter().map(|alias| alias.origins().to_vec()).collect(),
2401            ipv4_address: None,
2402            ipv6_address: None,
2403        }
2404    }
2405
2406    /// Returns the application network name.
2407    #[must_use]
2408    pub const fn network(&self) -> &Identifier {
2409        &self.network
2410    }
2411
2412    /// Returns aliases in authored order.
2413    #[must_use]
2414    pub fn aliases(&self) -> &[String] {
2415        &self.aliases
2416    }
2417
2418    /// Returns alias origins in the same order as [`Self::aliases`].
2419    #[must_use]
2420    pub fn alias_origins(&self) -> &[Vec<Provenance>] {
2421        &self.alias_origins
2422    }
2423
2424    /// Returns per-alias sensitivity flags in the same order as [`Self::aliases`].
2425    ///
2426    /// Target adapters use this boundary to avoid passing protected aliases into native APIs that
2427    /// cannot redact them.
2428    #[must_use]
2429    pub fn alias_sensitivities(&self) -> &[bool] {
2430        &self.alias_sensitivities
2431    }
2432
2433    /// Replaces aliases with ordered provenance-bearing source values.
2434    pub fn set_aliases_with_provenance(&mut self, aliases: Vec<Sourced<ProtectedString>>) {
2435        self.aliases = aliases.iter().map(|alias| alias.value().expose().to_owned()).collect();
2436        self.alias_sensitivities = aliases.iter().map(|alias| alias.value().is_sensitive()).collect();
2437        self.alias_origins = aliases.into_iter().map(|alias| alias.origins().to_vec()).collect();
2438    }
2439
2440    /// Appends one alias and its provenance.
2441    pub fn add_alias(&mut self, alias: &Sourced<ProtectedString>) {
2442        self.aliases.push(alias.value().expose().to_owned());
2443        self.alias_sensitivities.push(alias.value().is_sensitive());
2444        self.alias_origins.push(alias.origins().to_vec());
2445    }
2446
2447    /// Sets the attachment's explicit IPv4 address spelling.
2448    pub fn set_ipv4_address(&mut self, address: Sourced<ProtectedString>) {
2449        self.ipv4_address = Some(address);
2450    }
2451
2452    /// Returns the attachment's explicit IPv4 address spelling.
2453    #[must_use]
2454    pub const fn ipv4_address(&self) -> Option<&Sourced<ProtectedString>> {
2455        self.ipv4_address.as_ref()
2456    }
2457
2458    /// Sets the attachment's explicit IPv6 address spelling.
2459    pub fn set_ipv6_address(&mut self, address: Sourced<ProtectedString>) {
2460        self.ipv6_address = Some(address);
2461    }
2462
2463    /// Returns the attachment's explicit IPv6 address spelling.
2464    #[must_use]
2465    pub const fn ipv6_address(&self) -> Option<&Sourced<ProtectedString>> {
2466        self.ipv6_address.as_ref()
2467    }
2468}
2469
2470impl fmt::Debug for NetworkAttachment {
2471    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2472        let aliases = self
2473            .aliases
2474            .iter()
2475            .enumerate()
2476            .map(|(index, alias)| {
2477                if self.alias_sensitivities.get(index).copied().unwrap_or(false) {
2478                    "[REDACTED]"
2479                } else {
2480                    alias.as_str()
2481                }
2482            })
2483            .collect::<Vec<_>>();
2484        formatter
2485            .debug_struct("NetworkAttachment")
2486            .field("network", &self.network)
2487            .field("aliases", &aliases)
2488            .field("alias_origins", &self.alias_origins)
2489            .field("ipv4_address", &self.ipv4_address)
2490            .field("ipv6_address", &self.ipv6_address)
2491            .finish()
2492    }
2493}
2494
2495/// Readiness state a service dependency must reach before its dependent starts.
2496#[derive(Clone, Debug, Eq, PartialEq)]
2497#[non_exhaustive]
2498pub enum ServiceDependencyCondition {
2499    /// The dependency's service startup completed.
2500    Started,
2501    /// The dependency reported healthy readiness.
2502    Healthy,
2503    /// The dependency exited successfully.
2504    CompletedSuccessfully,
2505    /// A source-specific condition retained for explicit target-side reporting.
2506    Other(ProtectedString),
2507}
2508
2509/// One ordered dependency edge from a service to another application service.
2510///
2511/// Optional fields distinguish source defaults from explicitly authored values. The surrounding
2512/// [`Sourced`] value carries the referenced service-name provenance, while each option retains its
2513/// own field-level provenance.
2514#[derive(Clone, Debug, Eq, PartialEq)]
2515pub struct ServiceDependency {
2516    service: Identifier,
2517    condition: Option<Sourced<ServiceDependencyCondition>>,
2518    restart: Option<Sourced<bool>>,
2519    required: Option<Sourced<bool>>,
2520}
2521
2522/// One raw-preserving kernel-parameter assignment.
2523#[derive(Clone, Debug, Eq, PartialEq)]
2524pub struct KernelParameter {
2525    name: ProtectedString,
2526    value: ProtectedString,
2527}
2528
2529impl KernelParameter {
2530    /// Creates an assignment without interpreting kernel namespaces or privileges.
2531    #[must_use]
2532    pub const fn new(name: ProtectedString, value: ProtectedString) -> Self {
2533        Self { name, value }
2534    }
2535
2536    /// Returns the authored parameter name.
2537    #[must_use]
2538    pub const fn name(&self) -> &ProtectedString {
2539        &self.name
2540    }
2541
2542    /// Returns the authored scalar value spelling.
2543    #[must_use]
2544    pub const fn value(&self) -> &ProtectedString {
2545        &self.value
2546    }
2547}
2548
2549/// One raw-preserving resource-limit declaration.
2550#[derive(Clone, Debug, Eq, PartialEq)]
2551pub struct ResourceLimit {
2552    name: ProtectedString,
2553    soft: Option<Sourced<ProtectedString>>,
2554    hard: Option<Sourced<ProtectedString>>,
2555}
2556
2557impl ResourceLimit {
2558    /// Creates a limit with independently sourced soft and hard values.
2559    #[must_use]
2560    pub const fn new(
2561        name: ProtectedString,
2562        soft: Option<Sourced<ProtectedString>>,
2563        hard: Option<Sourced<ProtectedString>>,
2564    ) -> Self {
2565        Self { name, soft, hard }
2566    }
2567
2568    /// Returns the raw limit name.
2569    #[must_use]
2570    pub const fn name(&self) -> &ProtectedString {
2571        &self.name
2572    }
2573
2574    /// Returns the optional soft value.
2575    #[must_use]
2576    pub const fn soft(&self) -> Option<&Sourced<ProtectedString>> {
2577        self.soft.as_ref()
2578    }
2579
2580    /// Returns the optional hard value.
2581    #[must_use]
2582    pub const fn hard(&self) -> Option<&Sourced<ProtectedString>> {
2583        self.hard.as_ref()
2584    }
2585}
2586
2587/// A service device declaration with its authored syntax retained.
2588#[derive(Clone, Debug, Eq, PartialEq)]
2589#[non_exhaustive]
2590pub enum Device {
2591    /// A raw short device spelling.
2592    Short(ProtectedString),
2593    /// A long device mapping with independently sourced members.
2594    Long {
2595        /// Host-device source spelling.
2596        source: Option<Sourced<ProtectedString>>,
2597        /// Container-device target spelling.
2598        target: Option<Sourced<ProtectedString>>,
2599        /// Raw permission spelling.
2600        permissions: Option<Sourced<ProtectedString>>,
2601    },
2602}
2603
2604/// One format-independent service security option.
2605///
2606/// Native adapters retain ordering and duplicates around this value. They classify any
2607/// source-specific singleton conflicts rather than imposing those rules on the neutral model.
2608#[derive(Clone, Debug, Eq, PartialEq)]
2609#[non_exhaustive]
2610pub enum SecurityOption {
2611    /// Selects an `AppArmor` profile.
2612    AppArmor(ProtectedString),
2613    /// Enables or disables the no-new-privileges security bit.
2614    NoNewPrivileges(bool),
2615    /// Selects a seccomp profile.
2616    SeccompProfile(ProtectedString),
2617    /// Selects whether `SELinux` labeling is disabled (`true` disables labels).
2618    SecurityLabelDisable(bool),
2619    /// Selects the `SELinux` file type.
2620    SecurityLabelFileType(ProtectedString),
2621    /// Selects the `SELinux` level.
2622    SecurityLabelLevel(ProtectedString),
2623    /// Enables or disables nested `SELinux` labeling.
2624    SecurityLabelNested(bool),
2625    /// Selects the `SELinux` type.
2626    SecurityLabelType(ProtectedString),
2627    /// Masks one or more colon-separated container paths, or `ALL`.
2628    Mask(ProtectedString),
2629    /// Unmasks one or more colon-separated container paths, or `ALL`.
2630    Unmask(ProtectedString),
2631}
2632
2633impl ServiceDependency {
2634    /// Creates an edge using source-format defaults for readiness, restart propagation, and
2635    /// requirement strength.
2636    #[must_use]
2637    pub const fn new(service: Identifier) -> Self {
2638        Self {
2639            service,
2640            condition: None,
2641            restart: None,
2642            required: None,
2643        }
2644    }
2645
2646    /// Returns the referenced application service.
2647    #[must_use]
2648    pub const fn service(&self) -> &Identifier {
2649        &self.service
2650    }
2651
2652    /// Sets the explicitly authored readiness condition.
2653    pub fn set_condition(&mut self, condition: Sourced<ServiceDependencyCondition>) {
2654        self.condition = Some(condition);
2655    }
2656
2657    /// Returns the explicitly authored readiness condition, if any.
2658    #[must_use]
2659    pub const fn condition(&self) -> Option<&Sourced<ServiceDependencyCondition>> {
2660        self.condition.as_ref()
2661    }
2662
2663    /// Retains whether source-controlled dependency updates restart the dependent service.
2664    pub fn set_restart(&mut self, restart: Sourced<bool>) {
2665        self.restart = Some(restart);
2666    }
2667
2668    /// Returns the explicit restart-propagation choice, if any.
2669    #[must_use]
2670    pub const fn restart(&self) -> Option<&Sourced<bool>> {
2671        self.restart.as_ref()
2672    }
2673
2674    /// Retains whether absence or failure of the dependency blocks the dependent service.
2675    pub fn set_required(&mut self, required: Sourced<bool>) {
2676        self.required = Some(required);
2677    }
2678
2679    /// Returns the explicit requirement-strength choice, if any.
2680    #[must_use]
2681    pub const fn required(&self) -> Option<&Sourced<bool>> {
2682        self.required.as_ref()
2683    }
2684
2685    /// Returns the effective source requirement, including the default of `true`.
2686    #[must_use]
2687    pub fn is_required(&self) -> bool {
2688        self.required.as_ref().is_none_or(|required| *required.value())
2689    }
2690}
2691
2692/// One application service with ordered attachments and source provenance.
2693#[derive(Clone, Debug, Eq, PartialEq)]
2694pub struct Service {
2695    name: Identifier,
2696    runtime_name: Option<Sourced<ProtectedString>>,
2697    rootfs: Option<Sourced<ProtectedString>>,
2698    image: Option<Sourced<ImageReference>>,
2699    image_acquisition: Option<Sourced<Identifier>>,
2700    image_build: Option<Sourced<Identifier>>,
2701    command: Option<Sourced<Command>>,
2702    startup_notification: Option<Sourced<StartupNotification>>,
2703    entrypoint: Option<Sourced<Entrypoint>>,
2704    run_init: Option<Sourced<bool>>,
2705    stop_timeout: Option<Sourced<StopTimeout>>,
2706    pull_policy: Option<Sourced<PullPolicy>>,
2707    memory_limit: Option<Sourced<ProtectedString>>,
2708    exposed_ports: Option<Vec<Sourced<ExposedPort>>>,
2709    exposed_ports_origins: Vec<Provenance>,
2710    restart_policy: Option<Sourced<RestartPolicy>>,
2711    healthcheck: Option<Sourced<Healthcheck>>,
2712    labels: Vec<Sourced<MetadataLabel>>,
2713    annotations: Option<Vec<Sourced<Annotation>>>,
2714    annotations_origins: Vec<Provenance>,
2715    logging: Option<Sourced<Logging>>,
2716    reload_action: Option<Sourced<ReloadAction>>,
2717    user: Option<Sourced<ProtectedString>>,
2718    group: Option<Sourced<ProtectedString>>,
2719    user_namespace: Option<Sourced<ProtectedString>>,
2720    supplementary_groups: Vec<Sourced<ProtectedString>>,
2721    working_directory: Option<Sourced<ProtectedString>>,
2722    read_only_root_filesystem: Option<Sourced<bool>>,
2723    hostname: Option<Sourced<ProtectedString>>,
2724    dns_servers: Option<Vec<Sourced<ProtectedString>>>,
2725    dns_servers_origins: Vec<Provenance>,
2726    dns_options: Option<Vec<Sourced<ProtectedString>>>,
2727    dns_options_origins: Vec<Provenance>,
2728    dns_search_domains: Option<Vec<Sourced<ProtectedString>>>,
2729    dns_search_domains_origins: Vec<Provenance>,
2730    security_options: Option<Vec<Sourced<SecurityOption>>>,
2731    security_options_origins: Vec<Provenance>,
2732    pids_limit: Option<Sourced<ProtectedString>>,
2733    shm_size: Option<Sourced<ProtectedString>>,
2734    cap_add: Option<Vec<Sourced<ProtectedString>>>,
2735    cap_add_origins: Vec<Provenance>,
2736    cap_drop: Option<Vec<Sourced<ProtectedString>>>,
2737    cap_drop_origins: Vec<Provenance>,
2738    tmpfs: Option<Vec<Sourced<ProtectedString>>>,
2739    tmpfs_origins: Vec<Provenance>,
2740    sysctls: Option<Vec<Sourced<KernelParameter>>>,
2741    sysctls_origins: Vec<Provenance>,
2742    ulimits: Option<Vec<Sourced<ResourceLimit>>>,
2743    ulimits_origins: Vec<Provenance>,
2744    devices: Option<Vec<Sourced<Device>>>,
2745    devices_origins: Vec<Provenance>,
2746    stop_signal: Option<Sourced<ProtectedString>>,
2747    podman_args: Option<Vec<Sourced<ProtectedString>>>,
2748    podman_args_origins: Vec<Provenance>,
2749    environment: Vec<Sourced<EnvironmentVariable>>,
2750    environment_files: Vec<Sourced<EnvironmentFile>>,
2751    host_mappings: Vec<Sourced<HostMapping>>,
2752    ports: Vec<Sourced<Port>>,
2753    mounts: Vec<Sourced<Mount>>,
2754    config_grants: Vec<Sourced<ResourceGrant>>,
2755    secret_grants: Vec<Sourced<ResourceGrant>>,
2756    networks: Vec<Sourced<NetworkAttachment>>,
2757    dependencies: Vec<Sourced<ServiceDependency>>,
2758}
2759
2760impl Service {
2761    /// Creates an empty service shell for incremental adapter mapping.
2762    #[must_use]
2763    pub const fn new(name: Identifier) -> Self {
2764        Self {
2765            name,
2766            runtime_name: None,
2767            rootfs: None,
2768            image: None,
2769            image_acquisition: None,
2770            image_build: None,
2771            command: None,
2772            startup_notification: None,
2773            entrypoint: None,
2774            run_init: None,
2775            stop_timeout: None,
2776            pull_policy: None,
2777            memory_limit: None,
2778            exposed_ports: None,
2779            exposed_ports_origins: Vec::new(),
2780            restart_policy: None,
2781            healthcheck: None,
2782            labels: Vec::new(),
2783            annotations: None,
2784            annotations_origins: Vec::new(),
2785            logging: None,
2786            reload_action: None,
2787            user: None,
2788            group: None,
2789            user_namespace: None,
2790            supplementary_groups: Vec::new(),
2791            working_directory: None,
2792            read_only_root_filesystem: None,
2793            hostname: None,
2794            dns_servers: None,
2795            dns_servers_origins: Vec::new(),
2796            dns_options: None,
2797            dns_options_origins: Vec::new(),
2798            dns_search_domains: None,
2799            dns_search_domains_origins: Vec::new(),
2800            security_options: None,
2801            security_options_origins: Vec::new(),
2802            pids_limit: None,
2803            shm_size: None,
2804            cap_add: None,
2805            cap_add_origins: Vec::new(),
2806            cap_drop: None,
2807            cap_drop_origins: Vec::new(),
2808            tmpfs: None,
2809            tmpfs_origins: Vec::new(),
2810            sysctls: None,
2811            sysctls_origins: Vec::new(),
2812            ulimits: None,
2813            ulimits_origins: Vec::new(),
2814            devices: None,
2815            devices_origins: Vec::new(),
2816            stop_signal: None,
2817            podman_args: None,
2818            podman_args_origins: Vec::new(),
2819            environment: Vec::new(),
2820            environment_files: Vec::new(),
2821            host_mappings: Vec::new(),
2822            ports: Vec::new(),
2823            mounts: Vec::new(),
2824            config_grants: Vec::new(),
2825            secret_grants: Vec::new(),
2826            networks: Vec::new(),
2827            dependencies: Vec::new(),
2828        }
2829    }
2830
2831    /// Returns the service name.
2832    #[must_use]
2833    pub const fn name(&self) -> &Identifier {
2834        &self.name
2835    }
2836
2837    /// Sets an explicit provider/runtime-level container name distinct from the service key.
2838    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
2839        self.runtime_name = Some(name);
2840    }
2841
2842    /// Returns the explicit provider/runtime-level container name.
2843    #[must_use]
2844    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
2845        self.runtime_name.as_ref()
2846    }
2847
2848    /// Sets a protected root-filesystem path instead of an image source.
2849    ///
2850    /// # Errors
2851    ///
2852    /// Returns [`ModelError::RootfsImageSourceConflict`] when this service already has an image,
2853    /// image acquisition, or image build reference.
2854    pub fn set_rootfs(&mut self, rootfs: Sourced<ProtectedString>) -> Result<(), ModelError> {
2855        self.ensure_rootfs_is_compatible()?;
2856        self.rootfs = Some(rootfs);
2857        Ok(())
2858    }
2859
2860    /// Returns the protected root-filesystem path, if explicitly authored.
2861    #[must_use]
2862    pub const fn rootfs(&self) -> Option<&Sourced<ProtectedString>> {
2863        self.rootfs.as_ref()
2864    }
2865
2866    /// Sets the optional image reference.
2867    pub fn set_image(&mut self, image: Sourced<ImageReference>) {
2868        self.image = Some(image);
2869    }
2870
2871    /// Returns the optional image reference.
2872    #[must_use]
2873    pub const fn image(&self) -> Option<&Sourced<ImageReference>> {
2874        self.image.as_ref()
2875    }
2876
2877    /// References a separately declared image-acquisition resource.
2878    ///
2879    /// This does not replace [`Self::image`], which remains the runtime container image reference.
2880    pub fn set_image_acquisition(&mut self, acquisition: Sourced<Identifier>) {
2881        self.image_acquisition = Some(acquisition);
2882    }
2883
2884    /// Returns the separately declared image-acquisition resource reference.
2885    #[must_use]
2886    pub const fn image_acquisition(&self) -> Option<&Sourced<Identifier>> {
2887        self.image_acquisition.as_ref()
2888    }
2889
2890    /// References a separately declared image-build resource.
2891    ///
2892    /// This does not replace [`Self::image`] or any container runtime settings.
2893    pub fn set_image_build(&mut self, build: Sourced<Identifier>) {
2894        self.image_build = Some(build);
2895    }
2896
2897    /// Returns the separately declared image-build resource reference.
2898    #[must_use]
2899    pub const fn image_build(&self) -> Option<&Sourced<Identifier>> {
2900        self.image_build.as_ref()
2901    }
2902
2903    /// Sets the command override.
2904    pub fn set_command(&mut self, command: Sourced<Command>) {
2905        self.command = Some(command);
2906    }
2907
2908    /// Returns the command override.
2909    #[must_use]
2910    pub const fn command(&self) -> Option<&Sourced<Command>> {
2911        self.command.as_ref()
2912    }
2913
2914    /// Sets the source-authored startup-notification behavior.
2915    pub fn set_startup_notification(&mut self, notification: Sourced<StartupNotification>) {
2916        self.startup_notification = Some(notification);
2917    }
2918
2919    /// Returns the explicit startup-notification behavior.
2920    #[must_use]
2921    pub const fn startup_notification(&self) -> Option<&Sourced<StartupNotification>> {
2922        self.startup_notification.as_ref()
2923    }
2924
2925    /// Sets the entrypoint override independently from the command override.
2926    pub fn set_entrypoint(&mut self, entrypoint: Sourced<Entrypoint>) {
2927        self.entrypoint = Some(entrypoint);
2928    }
2929
2930    /// Returns the optional entrypoint override.
2931    #[must_use]
2932    pub const fn entrypoint(&self) -> Option<&Sourced<Entrypoint>> {
2933        self.entrypoint.as_ref()
2934    }
2935
2936    /// Sets whether the runtime should run its init process.
2937    pub fn set_run_init(&mut self, run_init: Sourced<bool>) {
2938        self.run_init = Some(run_init);
2939    }
2940
2941    /// Returns the explicit init-process choice.
2942    #[must_use]
2943    pub const fn run_init(&self) -> Option<&Sourced<bool>> {
2944        self.run_init.as_ref()
2945    }
2946
2947    /// Sets the raw stop-grace duration.
2948    pub fn set_stop_timeout(&mut self, timeout: Sourced<StopTimeout>) {
2949        self.stop_timeout = Some(timeout);
2950    }
2951
2952    /// Returns the explicit raw stop-grace duration.
2953    #[must_use]
2954    pub const fn stop_timeout(&self) -> Option<&Sourced<StopTimeout>> {
2955        self.stop_timeout.as_ref()
2956    }
2957
2958    /// Sets the source-independent image pull intent.
2959    pub fn set_pull_policy(&mut self, policy: Sourced<PullPolicy>) {
2960        self.pull_policy = Some(policy);
2961    }
2962
2963    /// Returns the explicit image pull intent.
2964    #[must_use]
2965    pub const fn pull_policy(&self) -> Option<&Sourced<PullPolicy>> {
2966        self.pull_policy.as_ref()
2967    }
2968
2969    /// Sets the raw protected memory-limit spelling.
2970    pub fn set_memory_limit(&mut self, limit: Sourced<ProtectedString>) {
2971        self.memory_limit = Some(limit);
2972    }
2973
2974    /// Returns the raw protected memory-limit spelling.
2975    #[must_use]
2976    pub const fn memory_limit(&self) -> Option<&Sourced<ProtectedString>> {
2977        self.memory_limit.as_ref()
2978    }
2979
2980    /// Sets exposed container ports, preserving omission separately from an explicit empty list.
2981    pub fn set_exposed_ports(&mut self, ports: Vec<Sourced<ExposedPort>>) {
2982        self.exposed_ports = Some(ports);
2983        self.exposed_ports_origins.clear();
2984    }
2985
2986    /// Sets exposed container ports and collection-level provenance.
2987    pub fn set_exposed_ports_with_origins(&mut self, ports: Vec<Sourced<ExposedPort>>, origins: Vec<Provenance>) {
2988        self.exposed_ports = Some(ports);
2989        self.exposed_ports_origins = origins;
2990    }
2991
2992    /// Appends one exposed container port without publishing it to a host.
2993    pub fn add_exposed_port(&mut self, port: Sourced<ExposedPort>) {
2994        self.exposed_ports.get_or_insert_default().push(port);
2995    }
2996
2997    /// Returns exposed container ports in authored order, preserving omitted versus explicit-empty state.
2998    #[must_use]
2999    pub fn exposed_ports(&self) -> Option<&[Sourced<ExposedPort>]> {
3000        self.exposed_ports.as_deref()
3001    }
3002
3003    /// Returns collection-level exposed-port provenance.
3004    #[must_use]
3005    pub fn exposed_ports_origins(&self) -> &[Provenance] {
3006        &self.exposed_ports_origins
3007    }
3008
3009    /// Sets the container-level automatic restart policy.
3010    pub fn set_restart_policy(&mut self, restart_policy: Sourced<RestartPolicy>) {
3011        self.restart_policy = Some(restart_policy);
3012    }
3013
3014    /// Returns the container-level automatic restart policy.
3015    #[must_use]
3016    pub const fn restart_policy(&self) -> Option<&Sourced<RestartPolicy>> {
3017        self.restart_policy.as_ref()
3018    }
3019
3020    /// Sets the service health-check definition.
3021    pub fn set_healthcheck(&mut self, healthcheck: Sourced<Healthcheck>) {
3022        self.healthcheck = Some(healthcheck);
3023    }
3024
3025    /// Returns the optional service health-check definition.
3026    #[must_use]
3027    pub const fn healthcheck(&self) -> Option<&Sourced<Healthcheck>> {
3028        self.healthcheck.as_ref()
3029    }
3030
3031    /// Appends one service metadata label while preserving source order and provenance.
3032    pub fn add_label(&mut self, label: Sourced<MetadataLabel>) {
3033        self.labels.push(label);
3034    }
3035
3036    /// Returns service metadata labels in source order.
3037    #[must_use]
3038    pub fn labels(&self) -> &[Sourced<MetadataLabel>] {
3039        &self.labels
3040    }
3041
3042    /// Sets repeatable annotations while preserving omission separately from an explicit empty list.
3043    pub fn set_annotations(&mut self, annotations: Vec<Sourced<Annotation>>) {
3044        self.annotations = Some(annotations);
3045        self.annotations_origins.clear();
3046    }
3047
3048    /// Appends one annotation in source order.
3049    pub fn add_annotation(&mut self, annotation: Sourced<Annotation>) {
3050        self.annotations.get_or_insert_default().push(annotation);
3051    }
3052
3053    /// Sets repeatable annotations with collection-level provenance.
3054    pub fn set_annotations_with_origins(&mut self, annotations: Vec<Sourced<Annotation>>, origins: Vec<Provenance>) {
3055        self.annotations = Some(annotations);
3056        self.annotations_origins = origins;
3057    }
3058
3059    /// Returns annotations, preserving omitted versus explicit-empty state.
3060    #[must_use]
3061    pub fn annotations(&self) -> Option<&[Sourced<Annotation>]> {
3062        self.annotations.as_deref()
3063    }
3064
3065    /// Returns collection-level annotation provenance.
3066    #[must_use]
3067    pub fn annotations_origins(&self) -> &[Provenance] {
3068        &self.annotations_origins
3069    }
3070
3071    /// Sets provider-specific logging intent.
3072    pub fn set_logging(&mut self, logging: Sourced<Logging>) {
3073        self.logging = Some(logging);
3074    }
3075
3076    /// Returns provider-specific logging intent.
3077    #[must_use]
3078    pub const fn logging(&self) -> Option<&Sourced<Logging>> {
3079        self.logging.as_ref()
3080    }
3081
3082    /// Sets the mutually exclusive reload action.
3083    pub fn set_reload_action(&mut self, reload_action: Sourced<ReloadAction>) {
3084        self.reload_action = Some(reload_action);
3085    }
3086
3087    /// Returns the one explicit reload action, if any.
3088    #[must_use]
3089    pub const fn reload_action(&self) -> Option<&Sourced<ReloadAction>> {
3090        self.reload_action.as_ref()
3091    }
3092
3093    /// Sets the primary identity used inside the service container.
3094    pub fn set_user(&mut self, user: Sourced<ProtectedString>) {
3095        self.user = Some(user);
3096    }
3097
3098    /// Returns the primary identity used inside the service container.
3099    #[must_use]
3100    pub const fn user(&self) -> Option<&Sourced<ProtectedString>> {
3101        self.user.as_ref()
3102    }
3103
3104    /// Sets the primary group used inside the service container.
3105    pub fn set_group(&mut self, group: Sourced<ProtectedString>) {
3106        self.group = Some(group);
3107    }
3108
3109    /// Returns the primary group used inside the service container.
3110    #[must_use]
3111    pub const fn group(&self) -> Option<&Sourced<ProtectedString>> {
3112        self.group.as_ref()
3113    }
3114
3115    /// Sets the requested user-namespace mode without imposing one runtime's grammar.
3116    pub fn set_user_namespace(&mut self, user_namespace: Sourced<ProtectedString>) {
3117        self.user_namespace = Some(user_namespace);
3118    }
3119
3120    /// Returns the raw-preserving user-namespace mode.
3121    #[must_use]
3122    pub const fn user_namespace(&self) -> Option<&Sourced<ProtectedString>> {
3123        self.user_namespace.as_ref()
3124    }
3125
3126    /// Appends one supplementary group in source order.
3127    pub fn add_supplementary_group(&mut self, group: Sourced<ProtectedString>) {
3128        self.supplementary_groups.push(group);
3129    }
3130
3131    /// Returns supplementary groups in source order.
3132    #[must_use]
3133    pub fn supplementary_groups(&self) -> &[Sourced<ProtectedString>] {
3134        &self.supplementary_groups
3135    }
3136
3137    /// Sets the working directory inside the service container.
3138    pub fn set_working_directory(&mut self, working_directory: Sourced<ProtectedString>) {
3139        self.working_directory = Some(working_directory);
3140    }
3141
3142    /// Returns the working directory inside the service container.
3143    #[must_use]
3144    pub const fn working_directory(&self) -> Option<&Sourced<ProtectedString>> {
3145        self.working_directory.as_ref()
3146    }
3147
3148    /// Sets the explicit read-only root-filesystem choice.
3149    pub fn set_read_only_root_filesystem(&mut self, read_only: Sourced<bool>) {
3150        self.read_only_root_filesystem = Some(read_only);
3151    }
3152
3153    /// Returns the explicit read-only root-filesystem choice.
3154    #[must_use]
3155    pub const fn read_only_root_filesystem(&self) -> Option<&Sourced<bool>> {
3156        self.read_only_root_filesystem.as_ref()
3157    }
3158
3159    /// Sets the explicit container hostname without inferring namespace ownership.
3160    pub fn set_hostname(&mut self, hostname: Sourced<ProtectedString>) {
3161        self.hostname = Some(hostname);
3162    }
3163
3164    /// Returns the raw-preserving explicit hostname.
3165    #[must_use]
3166    pub const fn hostname(&self) -> Option<&Sourced<ProtectedString>> {
3167        self.hostname.as_ref()
3168    }
3169
3170    /// Sets ordered DNS servers, preserving omission separately from an explicit empty list.
3171    pub fn set_dns_servers_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3172        self.dns_servers = Some(values);
3173        self.dns_servers_origins = origins;
3174    }
3175
3176    /// Sets ordered DNS servers without separate collection provenance.
3177    pub fn set_dns_servers(&mut self, values: Vec<Sourced<ProtectedString>>) {
3178        self.set_dns_servers_with_origins(values, Vec::new());
3179    }
3180
3181    /// Returns ordered DNS servers when explicitly authored.
3182    #[must_use]
3183    pub fn dns_servers(&self) -> Option<&[Sourced<ProtectedString>]> {
3184        self.dns_servers.as_deref()
3185    }
3186
3187    /// Returns collection provenance for explicitly authored DNS servers.
3188    #[must_use]
3189    pub fn dns_servers_origins(&self) -> &[Provenance] {
3190        &self.dns_servers_origins
3191    }
3192
3193    /// Sets ordered DNS resolver options, preserving omission separately from an explicit empty list.
3194    pub fn set_dns_options_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3195        self.dns_options = Some(values);
3196        self.dns_options_origins = origins;
3197    }
3198
3199    /// Sets ordered DNS resolver options without separate collection provenance.
3200    pub fn set_dns_options(&mut self, values: Vec<Sourced<ProtectedString>>) {
3201        self.set_dns_options_with_origins(values, Vec::new());
3202    }
3203
3204    /// Returns ordered DNS resolver options when explicitly authored.
3205    #[must_use]
3206    pub fn dns_options(&self) -> Option<&[Sourced<ProtectedString>]> {
3207        self.dns_options.as_deref()
3208    }
3209
3210    /// Returns collection provenance for explicitly authored DNS resolver options.
3211    #[must_use]
3212    pub fn dns_options_origins(&self) -> &[Provenance] {
3213        &self.dns_options_origins
3214    }
3215
3216    /// Sets ordered DNS search domains, preserving omission separately from an explicit empty list.
3217    pub fn set_dns_search_domains_with_origins(
3218        &mut self,
3219        values: Vec<Sourced<ProtectedString>>,
3220        origins: Vec<Provenance>,
3221    ) {
3222        self.dns_search_domains = Some(values);
3223        self.dns_search_domains_origins = origins;
3224    }
3225
3226    /// Sets ordered DNS search domains without separate collection provenance.
3227    pub fn set_dns_search_domains(&mut self, values: Vec<Sourced<ProtectedString>>) {
3228        self.set_dns_search_domains_with_origins(values, Vec::new());
3229    }
3230
3231    /// Returns ordered DNS search domains when explicitly authored.
3232    #[must_use]
3233    pub fn dns_search_domains(&self) -> Option<&[Sourced<ProtectedString>]> {
3234        self.dns_search_domains.as_deref()
3235    }
3236
3237    /// Returns collection provenance for explicitly authored DNS search domains.
3238    #[must_use]
3239    pub fn dns_search_domains_origins(&self) -> &[Provenance] {
3240        &self.dns_search_domains_origins
3241    }
3242
3243    /// Sets ordered security options, preserving omission separately from an explicit empty list.
3244    pub fn set_security_options_with_origins(
3245        &mut self,
3246        values: Vec<Sourced<SecurityOption>>,
3247        origins: Vec<Provenance>,
3248    ) {
3249        self.security_options = Some(values);
3250        self.security_options_origins = origins;
3251    }
3252
3253    /// Sets ordered security options without separate collection provenance.
3254    pub fn set_security_options(&mut self, values: Vec<Sourced<SecurityOption>>) {
3255        self.set_security_options_with_origins(values, Vec::new());
3256    }
3257
3258    /// Returns ordered security options when explicitly authored.
3259    #[must_use]
3260    pub fn security_options(&self) -> Option<&[Sourced<SecurityOption>]> {
3261        self.security_options.as_deref()
3262    }
3263
3264    /// Returns collection provenance for explicitly authored security options.
3265    #[must_use]
3266    pub fn security_options_origins(&self) -> &[Provenance] {
3267        &self.security_options_origins
3268    }
3269
3270    /// Sets the raw process-ID limit spelling.
3271    pub fn set_pids_limit(&mut self, limit: Sourced<ProtectedString>) {
3272        self.pids_limit = Some(limit);
3273    }
3274
3275    /// Returns the raw process-ID limit spelling.
3276    #[must_use]
3277    pub const fn pids_limit(&self) -> Option<&Sourced<ProtectedString>> {
3278        self.pids_limit.as_ref()
3279    }
3280
3281    /// Sets the raw shared-memory size spelling.
3282    pub fn set_shm_size(&mut self, size: Sourced<ProtectedString>) {
3283        self.shm_size = Some(size);
3284    }
3285
3286    /// Returns the raw shared-memory size spelling.
3287    #[must_use]
3288    pub const fn shm_size(&self) -> Option<&Sourced<ProtectedString>> {
3289        self.shm_size.as_ref()
3290    }
3291
3292    /// Sets the complete ordered capability-add collection; `Some([])` retains an explicit reset.
3293    pub fn set_cap_add(&mut self, values: Vec<Sourced<ProtectedString>>) {
3294        self.cap_add = Some(values);
3295        self.cap_add_origins.clear();
3296    }
3297
3298    /// Sets capability additions with collection-level provenance for an explicit empty/reset value.
3299    pub fn set_cap_add_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3300        self.cap_add = Some(values);
3301        self.cap_add_origins = origins;
3302    }
3303
3304    /// Returns capability additions, preserving omitted versus explicit-empty state.
3305    #[must_use]
3306    pub fn cap_add(&self) -> Option<&[Sourced<ProtectedString>]> {
3307        self.cap_add.as_deref()
3308    }
3309
3310    /// Returns the collection-level capability-add provenance.
3311    #[must_use]
3312    pub fn cap_add_origins(&self) -> &[Provenance] {
3313        &self.cap_add_origins
3314    }
3315
3316    /// Sets the complete ordered capability-drop collection; `Some([])` retains an explicit reset.
3317    pub fn set_cap_drop(&mut self, values: Vec<Sourced<ProtectedString>>) {
3318        self.cap_drop = Some(values);
3319        self.cap_drop_origins.clear();
3320    }
3321
3322    /// Sets capability removals with collection-level provenance for an explicit empty/reset value.
3323    pub fn set_cap_drop_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3324        self.cap_drop = Some(values);
3325        self.cap_drop_origins = origins;
3326    }
3327
3328    /// Returns capability removals, preserving omitted versus explicit-empty state.
3329    #[must_use]
3330    pub fn cap_drop(&self) -> Option<&[Sourced<ProtectedString>]> {
3331        self.cap_drop.as_deref()
3332    }
3333
3334    /// Returns the collection-level capability-drop provenance.
3335    #[must_use]
3336    pub fn cap_drop_origins(&self) -> &[Provenance] {
3337        &self.cap_drop_origins
3338    }
3339
3340    /// Sets ordered raw temporary-filesystem declarations.
3341    pub fn set_tmpfs(&mut self, values: Vec<Sourced<ProtectedString>>) {
3342        self.tmpfs = Some(values);
3343        self.tmpfs_origins.clear();
3344    }
3345
3346    /// Sets temporary filesystems with collection-level provenance.
3347    pub fn set_tmpfs_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3348        self.tmpfs = Some(values);
3349        self.tmpfs_origins = origins;
3350    }
3351
3352    /// Returns temporary-filesystem declarations, preserving explicit-empty state.
3353    #[must_use]
3354    pub fn tmpfs(&self) -> Option<&[Sourced<ProtectedString>]> {
3355        self.tmpfs.as_deref()
3356    }
3357
3358    /// Returns collection-level temporary-filesystem provenance.
3359    #[must_use]
3360    pub fn tmpfs_origins(&self) -> &[Provenance] {
3361        &self.tmpfs_origins
3362    }
3363
3364    /// Sets ordered raw kernel-parameter assignments.
3365    pub fn set_sysctls(&mut self, values: Vec<Sourced<KernelParameter>>) {
3366        self.sysctls = Some(values);
3367        self.sysctls_origins.clear();
3368    }
3369
3370    /// Sets kernel parameters with collection-level provenance.
3371    pub fn set_sysctls_with_origins(&mut self, values: Vec<Sourced<KernelParameter>>, origins: Vec<Provenance>) {
3372        self.sysctls = Some(values);
3373        self.sysctls_origins = origins;
3374    }
3375
3376    /// Returns kernel-parameter assignments, preserving explicit-empty state.
3377    #[must_use]
3378    pub fn sysctls(&self) -> Option<&[Sourced<KernelParameter>]> {
3379        self.sysctls.as_deref()
3380    }
3381
3382    /// Returns collection-level kernel-parameter provenance.
3383    #[must_use]
3384    pub fn sysctls_origins(&self) -> &[Provenance] {
3385        &self.sysctls_origins
3386    }
3387
3388    /// Sets ordered resource limits.
3389    pub fn set_ulimits(&mut self, values: Vec<Sourced<ResourceLimit>>) {
3390        self.ulimits = Some(values);
3391        self.ulimits_origins.clear();
3392    }
3393
3394    /// Sets resource limits with collection-level provenance.
3395    pub fn set_ulimits_with_origins(&mut self, values: Vec<Sourced<ResourceLimit>>, origins: Vec<Provenance>) {
3396        self.ulimits = Some(values);
3397        self.ulimits_origins = origins;
3398    }
3399
3400    /// Returns resource limits, preserving explicit-empty state.
3401    #[must_use]
3402    pub fn ulimits(&self) -> Option<&[Sourced<ResourceLimit>]> {
3403        self.ulimits.as_deref()
3404    }
3405
3406    /// Returns collection-level resource-limit provenance.
3407    #[must_use]
3408    pub fn ulimits_origins(&self) -> &[Provenance] {
3409        &self.ulimits_origins
3410    }
3411
3412    /// Sets ordered short/long device declarations.
3413    pub fn set_devices(&mut self, values: Vec<Sourced<Device>>) {
3414        self.devices = Some(values);
3415        self.devices_origins.clear();
3416    }
3417
3418    /// Sets devices with collection-level provenance.
3419    pub fn set_devices_with_origins(&mut self, values: Vec<Sourced<Device>>, origins: Vec<Provenance>) {
3420        self.devices = Some(values);
3421        self.devices_origins = origins;
3422    }
3423
3424    /// Returns device declarations, preserving explicit-empty state.
3425    #[must_use]
3426    pub fn devices(&self) -> Option<&[Sourced<Device>]> {
3427        self.devices.as_deref()
3428    }
3429
3430    /// Returns collection-level device provenance.
3431    #[must_use]
3432    pub fn devices_origins(&self) -> &[Provenance] {
3433        &self.devices_origins
3434    }
3435
3436    /// Sets the explicit stop-signal spelling.
3437    pub fn set_stop_signal(&mut self, signal: Sourced<ProtectedString>) {
3438        self.stop_signal = Some(signal);
3439    }
3440
3441    /// Returns the raw explicit stop-signal spelling.
3442    #[must_use]
3443    pub const fn stop_signal(&self) -> Option<&Sourced<ProtectedString>> {
3444        self.stop_signal.as_ref()
3445    }
3446
3447    /// Sets ordered source-authored Podman arguments, retaining an explicit empty collection.
3448    pub fn set_podman_args(&mut self, values: Vec<Sourced<ProtectedString>>) {
3449        self.set_podman_args_with_origins(values, Vec::new());
3450    }
3451
3452    /// Sets ordered source-authored Podman arguments with collection-level provenance.
3453    pub fn set_podman_args_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3454        self.podman_args = Some(values);
3455        self.podman_args_origins = origins;
3456    }
3457
3458    /// Appends one protected source-authored Podman argument in source order.
3459    pub fn add_podman_arg(&mut self, value: Sourced<ProtectedString>) {
3460        self.podman_args.get_or_insert_default().push(value);
3461    }
3462
3463    /// Returns source-authored Podman arguments, preserving omitted versus explicit-empty state.
3464    #[must_use]
3465    pub fn podman_args(&self) -> Option<&[Sourced<ProtectedString>]> {
3466        self.podman_args.as_deref()
3467    }
3468
3469    /// Returns collection-level Podman-argument provenance.
3470    #[must_use]
3471    pub fn podman_args_origins(&self) -> &[Provenance] {
3472        &self.podman_args_origins
3473    }
3474
3475    /// Appends an environment entry.
3476    pub fn add_environment(&mut self, value: Sourced<EnvironmentVariable>) {
3477        self.environment.push(value);
3478    }
3479
3480    /// Returns environment entries in authored order.
3481    #[must_use]
3482    pub fn environment(&self) -> &[Sourced<EnvironmentVariable>] {
3483        &self.environment
3484    }
3485
3486    /// Appends an environment-file declaration without reading the referenced file.
3487    pub fn add_environment_file(&mut self, value: Sourced<EnvironmentFile>) {
3488        self.environment_files.push(value);
3489    }
3490
3491    /// Returns environment-file declarations in authored order.
3492    #[must_use]
3493    pub fn environment_files(&self) -> &[Sourced<EnvironmentFile>] {
3494        &self.environment_files
3495    }
3496
3497    /// Appends an explicit hostname-to-address mapping.
3498    pub fn add_host_mapping(&mut self, value: Sourced<HostMapping>) {
3499        self.host_mappings.push(value);
3500    }
3501
3502    /// Returns explicit host mappings in authored order.
3503    #[must_use]
3504    pub fn host_mappings(&self) -> &[Sourced<HostMapping>] {
3505        &self.host_mappings
3506    }
3507
3508    /// Appends a port.
3509    pub fn add_port(&mut self, value: Sourced<Port>) {
3510        self.ports.push(value);
3511    }
3512
3513    /// Returns ports in authored order.
3514    #[must_use]
3515    pub fn ports(&self) -> &[Sourced<Port>] {
3516        &self.ports
3517    }
3518
3519    /// Appends a storage attachment.
3520    pub fn add_mount(&mut self, value: Sourced<Mount>) {
3521        self.mounts.push(value);
3522    }
3523
3524    /// Returns storage attachments in authored order.
3525    #[must_use]
3526    pub fn mounts(&self) -> &[Sourced<Mount>] {
3527        &self.mounts
3528    }
3529
3530    /// Appends a configuration grant in source order.
3531    pub fn add_config_grant(&mut self, value: Sourced<ResourceGrant>) {
3532        self.config_grants.push(value);
3533    }
3534
3535    /// Returns configuration grants in source order.
3536    #[must_use]
3537    pub fn config_grants(&self) -> &[Sourced<ResourceGrant>] {
3538        &self.config_grants
3539    }
3540
3541    /// Appends a secret grant in source order.
3542    pub fn add_secret_grant(&mut self, value: Sourced<ResourceGrant>) {
3543        self.secret_grants.push(value);
3544    }
3545
3546    /// Returns secret grants in source order.
3547    #[must_use]
3548    pub fn secret_grants(&self) -> &[Sourced<ResourceGrant>] {
3549        &self.secret_grants
3550    }
3551
3552    /// Appends a network attachment.
3553    pub fn add_network(&mut self, value: Sourced<NetworkAttachment>) {
3554        self.networks.push(value);
3555    }
3556
3557    /// Replaces one existing network attachment without changing authored order.
3558    ///
3559    /// Returns the previous attachment. This narrow mutation boundary lets importers enrich an
3560    /// attachment only after later native entries establish attachment-scoped details.
3561    ///
3562    /// # Errors
3563    ///
3564    /// Returns [`ModelError::UnknownNetworkAttachmentIndex`] when `index` is outside the ordered
3565    /// attachment collection.
3566    pub fn replace_network(
3567        &mut self,
3568        index: usize,
3569        value: Sourced<NetworkAttachment>,
3570    ) -> Result<Sourced<NetworkAttachment>, ModelError> {
3571        let len = self.networks.len();
3572        let Some(slot) = self.networks.get_mut(index) else {
3573            return Err(ModelError::UnknownNetworkAttachmentIndex { index, len });
3574        };
3575        Ok(std::mem::replace(slot, value))
3576    }
3577
3578    /// Returns network attachments in authored order.
3579    #[must_use]
3580    pub fn networks(&self) -> &[Sourced<NetworkAttachment>] {
3581        &self.networks
3582    }
3583
3584    /// Appends a service dependency in source order.
3585    pub fn add_dependency(&mut self, value: Sourced<ServiceDependency>) {
3586        self.dependencies.push(value);
3587    }
3588
3589    /// Returns service dependencies in source order.
3590    #[must_use]
3591    pub fn dependencies(&self) -> &[Sourced<ServiceDependency>] {
3592        &self.dependencies
3593    }
3594
3595    /// Validates that a root filesystem was not combined with any image source.
3596    ///
3597    /// This is public so adapters that incrementally map services can surface an invalid native
3598    /// combination before inserting it into an [`Application`].
3599    ///
3600    /// # Errors
3601    ///
3602    /// Returns [`ModelError::RootfsImageSourceConflict`] when both forms are present.
3603    pub fn validate_image_source_exclusivity(&self) -> Result<(), ModelError> {
3604        if self.rootfs.is_some() {
3605            self.ensure_rootfs_is_compatible()?;
3606        }
3607        Ok(())
3608    }
3609
3610    fn ensure_rootfs_is_compatible(&self) -> Result<(), ModelError> {
3611        let source = if self.image.is_some() {
3612            Some("image")
3613        } else if self.image_acquisition.is_some() {
3614            Some("image acquisition")
3615        } else if self.image_build.is_some() {
3616            Some("image build")
3617        } else {
3618            None
3619        };
3620        if let Some(source) = source {
3621            return Err(ModelError::RootfsImageSourceConflict {
3622                service: self.name.as_str().to_owned(),
3623                source,
3624            });
3625        }
3626        Ok(())
3627    }
3628}
3629
3630/// One ordered multi-service application graph.
3631#[derive(Clone, Debug, Eq, PartialEq)]
3632pub struct Application {
3633    name: Identifier,
3634    image_acquisitions: Vec<Sourced<ImageAcquisition>>,
3635    image_builds: Vec<Sourced<ImageBuild>>,
3636    services: Vec<Sourced<Service>>,
3637    service_groups: Vec<Sourced<ServiceGroup>>,
3638    volumes: Vec<Sourced<Volume>>,
3639    networks: Vec<Sourced<Network>>,
3640    configs: Vec<Sourced<Config>>,
3641    secrets: Vec<Sourced<Secret>>,
3642}
3643
3644impl Application {
3645    /// Creates an empty application.
3646    #[must_use]
3647    pub const fn new(name: Identifier) -> Self {
3648        Self {
3649            name,
3650            image_acquisitions: Vec::new(),
3651            image_builds: Vec::new(),
3652            services: Vec::new(),
3653            service_groups: Vec::new(),
3654            volumes: Vec::new(),
3655            networks: Vec::new(),
3656            configs: Vec::new(),
3657            secrets: Vec::new(),
3658        }
3659    }
3660
3661    /// Returns the application name.
3662    #[must_use]
3663    pub const fn name(&self) -> &Identifier {
3664        &self.name
3665    }
3666
3667    /// Adds a uniquely named image-acquisition resource while preserving declaration order.
3668    ///
3669    /// # Errors
3670    ///
3671    /// Returns [`ModelError::DuplicateResource`] for a duplicate acquisition name.
3672    pub fn add_image_acquisition(&mut self, acquisition: Sourced<ImageAcquisition>) -> Result<(), ModelError> {
3673        ensure_unique(
3674            "image acquisition",
3675            acquisition.value().name(),
3676            self.image_acquisitions.iter().map(|candidate| candidate.value().name()),
3677        )?;
3678        self.image_acquisitions.push(acquisition);
3679        Ok(())
3680    }
3681
3682    /// Returns image-acquisition resources in declaration order.
3683    #[must_use]
3684    pub fn image_acquisitions(&self) -> &[Sourced<ImageAcquisition>] {
3685        &self.image_acquisitions
3686    }
3687
3688    /// Adds a uniquely named image-build resource while preserving declaration order.
3689    ///
3690    /// # Errors
3691    ///
3692    /// Returns [`ModelError::DuplicateResource`] for a duplicate build name.
3693    pub fn add_image_build(&mut self, build: Sourced<ImageBuild>) -> Result<(), ModelError> {
3694        ensure_unique(
3695            "image build",
3696            build.value().name(),
3697            self.image_builds.iter().map(|candidate| candidate.value().name()),
3698        )?;
3699        self.image_builds.push(build);
3700        Ok(())
3701    }
3702
3703    /// Returns image-build resources in declaration order.
3704    #[must_use]
3705    pub fn image_builds(&self) -> &[Sourced<ImageBuild>] {
3706        &self.image_builds
3707    }
3708
3709    /// Validates every typed image-artifact reference after the complete graph is assembled.
3710    ///
3711    /// Unlike incremental insertion, this validation does not make a source document's
3712    /// declaration order significant. Adapters that receive forward references should add their
3713    /// resources first and invoke this method before treating the application as convertible.
3714    ///
3715    /// # Errors
3716    ///
3717    /// Returns the matching unknown-reference error for a service or image-backed volume.
3718    pub fn validate_image_artifact_references(&self) -> Result<(), ModelError> {
3719        for service in &self.services {
3720            if let Some(acquisition) = service.value().image_acquisition() {
3721                if !self.contains_image_acquisition(acquisition.value()) {
3722                    return Err(ModelError::UnknownImageAcquisitionReference {
3723                        service: service.value().name().as_str().to_owned(),
3724                        acquisition: acquisition.value().as_str().to_owned(),
3725                    });
3726                }
3727            }
3728            if let Some(build) = service.value().image_build() {
3729                if !self.contains_image_build(build.value()) {
3730                    return Err(ModelError::UnknownImageBuildReference {
3731                        service: service.value().name().as_str().to_owned(),
3732                        build: build.value().as_str().to_owned(),
3733                    });
3734                }
3735            }
3736        }
3737        for volume in &self.volumes {
3738            let Some(source) = volume.value().image_source() else {
3739                continue;
3740            };
3741            match source.value() {
3742                VolumeImageSource::Literal(_) => {}
3743                VolumeImageSource::ImageAcquisition(acquisition) => {
3744                    if !self.contains_image_acquisition(acquisition) {
3745                        return Err(ModelError::UnknownVolumeImageAcquisitionReference {
3746                            volume: volume.value().name().as_str().to_owned(),
3747                            acquisition: acquisition.as_str().to_owned(),
3748                        });
3749                    }
3750                }
3751                VolumeImageSource::ImageBuild(build) => {
3752                    if !self.contains_image_build(build) {
3753                        return Err(ModelError::UnknownVolumeImageBuildReference {
3754                            volume: volume.value().name().as_str().to_owned(),
3755                            build: build.as_str().to_owned(),
3756                        });
3757                    }
3758                }
3759            }
3760        }
3761        Ok(())
3762    }
3763
3764    /// Validates explicit format-neutral artifact edges for missing nodes and cycles.
3765    ///
3766    /// The supplied edges must already be typed by a source adapter. In particular, `BoxFerry` does
3767    /// not parse native raw argument, mount, or unit-name text to infer dependencies. Duplicate
3768    /// edges are ignored deterministically, and input order does not affect validation.
3769    ///
3770    /// # Errors
3771    ///
3772    /// Returns a missing-reference error, [`ModelError::UnknownArtifactDependencyNode`], or
3773    /// [`ModelError::ImageArtifactDependencyCycle`].
3774    pub fn validate_image_artifact_dependencies(
3775        &self,
3776        dependencies: &[Sourced<ArtifactDependency>],
3777    ) -> Result<(), ModelError> {
3778        self.validate_image_artifact_references()?;
3779
3780        let mut graph = BTreeMap::<ArtifactDependencyNode, BTreeSet<ArtifactDependencyNode>>::new();
3781        for dependency in dependencies {
3782            let source = dependency.value().source().value();
3783            let target = dependency.value().target().value();
3784            self.validate_artifact_dependency_node(source)?;
3785            self.validate_artifact_dependency_node(target)?;
3786            graph.entry(source.clone()).or_default().insert(target.clone());
3787            graph.entry(target.clone()).or_default();
3788        }
3789
3790        let mut state = BTreeMap::<ArtifactDependencyNode, VisitState>::new();
3791        let mut path = Vec::new();
3792        for node in graph.keys() {
3793            if state.get(node).is_some_and(|state| *state == VisitState::Finished) {
3794                continue;
3795            }
3796            if let Some(cycle) = detect_artifact_cycle(node, &graph, &mut state, &mut path) {
3797                return Err(ModelError::ImageArtifactDependencyCycle {
3798                    nodes: cycle.into_iter().map(|node| node.display_name()).collect(),
3799                });
3800            }
3801        }
3802        Ok(())
3803    }
3804
3805    fn contains_image_acquisition(&self, name: &Identifier) -> bool {
3806        self.image_acquisitions
3807            .iter()
3808            .any(|candidate| candidate.value().name() == name)
3809    }
3810
3811    fn contains_image_build(&self, name: &Identifier) -> bool {
3812        self.image_builds
3813            .iter()
3814            .any(|candidate| candidate.value().name() == name)
3815    }
3816
3817    fn validate_artifact_dependency_node(&self, node: &ArtifactDependencyNode) -> Result<(), ModelError> {
3818        let (kind, name) = node.kind_and_name();
3819        let exists = match node {
3820            ArtifactDependencyNode::Volume(_) => self.volumes.iter().any(|volume| volume.value().name() == name),
3821            ArtifactDependencyNode::ImageAcquisition(_) => self.contains_image_acquisition(name),
3822            ArtifactDependencyNode::ImageBuild(_) => self.contains_image_build(name),
3823        };
3824        if exists {
3825            Ok(())
3826        } else {
3827            Err(ModelError::UnknownArtifactDependencyNode {
3828                kind,
3829                name: name.as_str().to_owned(),
3830            })
3831        }
3832    }
3833
3834    /// Adds a uniquely named service while preserving declaration order.
3835    ///
3836    /// # Errors
3837    ///
3838    /// Returns [`ModelError::DuplicateResource`] for a duplicate service name,
3839    /// [`ModelError::UnknownImageAcquisitionReference`], or
3840    /// [`ModelError::UnknownImageBuildReference`] for an unresolved artifact reference.
3841    pub fn add_service(&mut self, service: Sourced<Service>) -> Result<(), ModelError> {
3842        ensure_unique(
3843            "service",
3844            service.value().name(),
3845            self.services.iter().map(|candidate| candidate.value().name()),
3846        )?;
3847        service.value().validate_image_source_exclusivity()?;
3848        if let Some(acquisition) = service.value().image_acquisition() {
3849            if !self
3850                .image_acquisitions
3851                .iter()
3852                .any(|candidate| candidate.value().name() == acquisition.value())
3853            {
3854                return Err(ModelError::UnknownImageAcquisitionReference {
3855                    service: service.value().name().as_str().to_owned(),
3856                    acquisition: acquisition.value().as_str().to_owned(),
3857                });
3858            }
3859        }
3860        if let Some(build) = service.value().image_build() {
3861            if !self
3862                .image_builds
3863                .iter()
3864                .any(|candidate| candidate.value().name() == build.value())
3865            {
3866                return Err(ModelError::UnknownImageBuildReference {
3867                    service: service.value().name().as_str().to_owned(),
3868                    build: build.value().as_str().to_owned(),
3869                });
3870            }
3871        }
3872        self.services.push(service);
3873        Ok(())
3874    }
3875
3876    /// Returns services in declaration order.
3877    #[must_use]
3878    pub fn services(&self) -> &[Sourced<Service>] {
3879        &self.services
3880    }
3881
3882    /// Adds a uniquely named structural service group.
3883    ///
3884    /// Every referenced service must already exist in the application, and one service may belong
3885    /// to at most one group.
3886    ///
3887    /// # Errors
3888    ///
3889    /// Returns [`ModelError::DuplicateResource`], [`ModelError::UnknownServiceGroupMember`], or
3890    /// [`ModelError::ServiceInMultipleGroups`] when a relationship is ambiguous.
3891    pub fn add_service_group(&mut self, group: Sourced<ServiceGroup>) -> Result<(), ModelError> {
3892        ensure_unique(
3893            "service group",
3894            group.value().name(),
3895            self.service_groups.iter().map(|candidate| candidate.value().name()),
3896        )?;
3897        for member in group.value().members() {
3898            if !self
3899                .services
3900                .iter()
3901                .any(|service| service.value().name() == member.value())
3902            {
3903                return Err(ModelError::UnknownServiceGroupMember {
3904                    group: group.value().name().as_str().to_owned(),
3905                    service: member.value().as_str().to_owned(),
3906                });
3907            }
3908            if let Some(existing) = self.service_groups.iter().find(|candidate| {
3909                candidate
3910                    .value()
3911                    .members()
3912                    .iter()
3913                    .any(|candidate_member| candidate_member.value() == member.value())
3914            }) {
3915                return Err(ModelError::ServiceInMultipleGroups {
3916                    service: member.value().as_str().to_owned(),
3917                    existing: existing.value().name().as_str().to_owned(),
3918                    replacement: group.value().name().as_str().to_owned(),
3919                });
3920            }
3921        }
3922        self.service_groups.push(group);
3923        Ok(())
3924    }
3925
3926    /// Returns structural service groups in source order.
3927    #[must_use]
3928    pub fn service_groups(&self) -> &[Sourced<ServiceGroup>] {
3929        &self.service_groups
3930    }
3931
3932    /// Adds a uniquely named volume while preserving declaration order.
3933    ///
3934    /// # Errors
3935    ///
3936    /// Returns [`ModelError::DuplicateResource`] for a duplicate volume name.
3937    pub fn add_volume(&mut self, volume: Sourced<Volume>) -> Result<(), ModelError> {
3938        ensure_unique(
3939            "volume",
3940            volume.value().name(),
3941            self.volumes.iter().map(|candidate| candidate.value().name()),
3942        )?;
3943        self.volumes.push(volume);
3944        Ok(())
3945    }
3946
3947    /// Returns volumes in declaration order.
3948    #[must_use]
3949    pub fn volumes(&self) -> &[Sourced<Volume>] {
3950        &self.volumes
3951    }
3952
3953    /// Adds a uniquely named network while preserving declaration order.
3954    ///
3955    /// # Errors
3956    ///
3957    /// Returns [`ModelError::DuplicateResource`] for a duplicate network name.
3958    pub fn add_network(&mut self, network: Sourced<Network>) -> Result<(), ModelError> {
3959        ensure_unique(
3960            "network",
3961            network.value().name(),
3962            self.networks.iter().map(|candidate| candidate.value().name()),
3963        )?;
3964        self.networks.push(network);
3965        Ok(())
3966    }
3967
3968    /// Returns networks in declaration order.
3969    #[must_use]
3970    pub fn networks(&self) -> &[Sourced<Network>] {
3971        &self.networks
3972    }
3973
3974    /// Adds a uniquely named configuration while preserving declaration order.
3975    ///
3976    /// # Errors
3977    ///
3978    /// Returns [`ModelError::DuplicateResource`] for a duplicate configuration name.
3979    pub fn add_config(&mut self, config: Sourced<Config>) -> Result<(), ModelError> {
3980        ensure_unique(
3981            "config",
3982            config.value().name(),
3983            self.configs.iter().map(|candidate| candidate.value().name()),
3984        )?;
3985        self.configs.push(config);
3986        Ok(())
3987    }
3988
3989    /// Returns configuration resources in declaration order.
3990    #[must_use]
3991    pub fn configs(&self) -> &[Sourced<Config>] {
3992        &self.configs
3993    }
3994
3995    /// Adds a uniquely named secret while preserving declaration order.
3996    ///
3997    /// # Errors
3998    ///
3999    /// Returns [`ModelError::DuplicateResource`] for a duplicate secret name.
4000    pub fn add_secret(&mut self, secret: Sourced<Secret>) -> Result<(), ModelError> {
4001        ensure_unique(
4002            "secret",
4003            secret.value().name(),
4004            self.secrets.iter().map(|candidate| candidate.value().name()),
4005        )?;
4006        self.secrets.push(secret);
4007        Ok(())
4008    }
4009
4010    /// Returns secret resources in declaration order.
4011    #[must_use]
4012    pub fn secrets(&self) -> &[Sourced<Secret>] {
4013        &self.secrets
4014    }
4015}
4016
4017#[derive(Clone, Copy, Eq, PartialEq)]
4018enum VisitState {
4019    Visiting,
4020    Finished,
4021}
4022
4023fn detect_artifact_cycle(
4024    node: &ArtifactDependencyNode,
4025    graph: &BTreeMap<ArtifactDependencyNode, BTreeSet<ArtifactDependencyNode>>,
4026    state: &mut BTreeMap<ArtifactDependencyNode, VisitState>,
4027    path: &mut Vec<ArtifactDependencyNode>,
4028) -> Option<Vec<ArtifactDependencyNode>> {
4029    if state.get(node).is_some_and(|state| *state == VisitState::Visiting) {
4030        let index = path.iter().position(|candidate| candidate == node)?;
4031        let mut cycle = path[index..].to_vec();
4032        cycle.push(node.clone());
4033        return Some(cycle);
4034    }
4035    if state.get(node).is_some_and(|state| *state == VisitState::Finished) {
4036        return None;
4037    }
4038
4039    state.insert(node.clone(), VisitState::Visiting);
4040    path.push(node.clone());
4041    if let Some(targets) = graph.get(node) {
4042        for target in targets {
4043            if let Some(cycle) = detect_artifact_cycle(target, graph, state, path) {
4044                return Some(cycle);
4045            }
4046        }
4047    }
4048    path.pop();
4049    state.insert(node.clone(), VisitState::Finished);
4050    None
4051}
4052
4053fn ensure_unique<'a>(
4054    kind: &'static str,
4055    name: &Identifier,
4056    existing: impl Iterator<Item = &'a Identifier>,
4057) -> Result<(), ModelError> {
4058    if existing.into_iter().any(|candidate| candidate == name) {
4059        return Err(ModelError::DuplicateResource {
4060            kind,
4061            name: name.as_str().to_owned(),
4062        });
4063    }
4064    Ok(())
4065}
4066
4067fn validate_text(kind: &'static str, value: &str) -> Result<(), ModelError> {
4068    if value.is_empty() {
4069        return Err(ModelError::EmptyValue(kind));
4070    }
4071    validate_no_nul(kind, value)
4072}
4073
4074fn validate_no_nul(kind: &'static str, value: &str) -> Result<(), ModelError> {
4075    if value.contains('\0') {
4076        return Err(ModelError::ContainsNul(kind));
4077    }
4078    Ok(())
4079}
4080
4081#[cfg(test)]
4082mod tests {
4083    use super::{
4084        Annotation, Application, ArtifactDependency, ArtifactDependencyNode, Command, Config, ConfigMaterial, Device,
4085        Entrypoint, EnvironmentFile, EnvironmentFileFormat, EnvironmentFileSyntax, ExposedPort, GroupExitPolicy,
4086        HealthcheckDuration, HealthcheckRetries, HostAddress, HostAddressKind, HostMapping, Identifier,
4087        KernelParameter, Logging, LoggingOption, MetadataLabel, ModelError, Mount, MountSource, Network,
4088        NetworkAttachment, NetworkDriverOption, NetworkIpamConfig, Protocol, PullPolicy, ReloadAction, ResourceGrant,
4089        ResourceGrantSyntax, ResourceLimit, ResourceOwnership, RestartPolicy, Secret, SecretMaterial, SecurityOption,
4090        Service, ServiceDependency, ServiceDependencyCondition, ServiceGroup, ServiceGroupRuntime, StartupNotification,
4091        StopTimeout, Volume, VolumeImageSource,
4092    };
4093    use crate::{ImageAcquisition, ImageBuild, ImageReference, ProtectedString, Sourced};
4094
4095    #[test]
4096    fn preserves_service_order_and_rejects_duplicate_names() -> Result<(), String> {
4097        let mut application = Application::new(id("example")?);
4098        application
4099            .add_service(Sourced::generated(Service::new(id("web")?)))
4100            .map_err(|error| error.to_string())?;
4101        application
4102            .add_service(Sourced::generated(Service::new(id("database")?)))
4103            .map_err(|error| error.to_string())?;
4104
4105        let names: Vec<_> = application
4106            .services()
4107            .iter()
4108            .map(|service| service.value().name().as_str())
4109            .collect();
4110        assert_eq!(names, ["web", "database"]);
4111
4112        let duplicate = application.add_service(Sourced::generated(Service::new(id("web")?)));
4113        assert!(matches!(duplicate, Err(ModelError::DuplicateResource { .. })));
4114        Ok(())
4115    }
4116
4117    #[test]
4118    fn keeps_the_service_key_and_explicit_runtime_name_distinct() -> Result<(), String> {
4119        let mut service = Service::new(id("web")?);
4120        service.set_runtime_name(Sourced::generated(ProtectedString::plain("production-web")));
4121
4122        assert_eq!(service.name().as_str(), "web");
4123        assert_eq!(
4124            service.runtime_name().map(|name| name.value().expose()),
4125            Some("production-web")
4126        );
4127        Ok(())
4128    }
4129
4130    #[test]
4131    fn network_keeps_logical_and_runtime_names_and_literal_flags_distinct() -> Result<(), String> {
4132        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4133        let origin = crate::Provenance::source(source);
4134        let mut network = Network::new(id("frontend")?, ResourceOwnership::Application);
4135        network.set_runtime_name(Sourced::from_source(
4136            ProtectedString::plain("production-frontend"),
4137            origin.clone(),
4138        ));
4139        network.set_driver(Sourced::from_source(ProtectedString::plain("bridge"), origin.clone()));
4140        network.set_internal(Sourced::from_source(true, origin.clone()));
4141        network.set_ipv6(Sourced::from_source(false, origin.clone()));
4142        network.set_ipam_driver(Sourced::from_source(ProtectedString::plain("default"), origin));
4143
4144        assert_eq!(network.name().as_str(), "frontend");
4145        assert_eq!(
4146            network.runtime_name().map(|value| value.value().expose()),
4147            Some("production-frontend")
4148        );
4149        assert_eq!(network.driver().map(|value| value.value().expose()), Some("bridge"));
4150        assert_eq!(network.internal().map(Sourced::value), Some(&true));
4151        assert_eq!(network.ipv6().map(Sourced::value), Some(&false));
4152        assert_eq!(
4153            network.ipam_driver().map(|value| value.value().expose()),
4154            Some("default")
4155        );
4156        Ok(())
4157    }
4158
4159    #[test]
4160    fn network_collections_retain_resets_provenance_and_redact_protected_values() -> Result<(), String> {
4161        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4162        let origin = crate::Provenance::source(source);
4163        let mut network = Network::new(id("frontend")?, ResourceOwnership::Application);
4164        let option = NetworkDriverOption::new(
4165            Sourced::from_source(id("com.example.token")?, origin.clone()),
4166            Sourced::from_source(ProtectedString::sensitive("never-print-this"), origin.clone()),
4167        )
4168        .map_err(|error| error.to_string())?;
4169        let label = MetadataLabel::new(id("com.example.label")?, ProtectedString::sensitive("also-private"));
4170
4171        network
4172            .set_driver_options_with_origins(vec![Sourced::from_source(option, origin.clone())], vec![origin.clone()]);
4173        network.set_labels_with_origins(vec![Sourced::from_source(label, origin.clone())], vec![origin.clone()]);
4174        network.set_ipam_configs_with_origins(Vec::new(), vec![origin]);
4175
4176        assert_eq!(network.driver_options().map(<[_]>::len), Some(1));
4177        assert_eq!(network.labels().map(<[_]>::len), Some(1));
4178        assert_eq!(network.ipam_configs().map(<[_]>::len), Some(0));
4179        assert_eq!(network.driver_options_origins().len(), 1);
4180        assert_eq!(network.labels_origins().len(), 1);
4181        assert_eq!(network.ipam_configs_origins().len(), 1);
4182        let debug = format!("{network:?}");
4183        assert!(!debug.contains("never-print-this"));
4184        assert!(!debug.contains("also-private"));
4185        assert!(debug.contains("[REDACTED]"));
4186
4187        network.set_driver_options(Vec::new());
4188        network.set_labels(Vec::new());
4189        network.set_ipam_configs(Vec::new());
4190        assert_eq!(network.driver_options().map(<[_]>::len), Some(0));
4191        assert_eq!(network.labels().map(<[_]>::len), Some(0));
4192        assert_eq!(network.ipam_configs().map(<[_]>::len), Some(0));
4193        assert!(network.driver_options_origins().is_empty());
4194        assert!(network.labels_origins().is_empty());
4195        assert!(network.ipam_configs_origins().is_empty());
4196        Ok(())
4197    }
4198
4199    #[test]
4200    fn network_ipam_rows_preserve_association_order_and_reject_subnetless_values() -> Result<(), String> {
4201        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4202        let origin = crate::Provenance::source(source);
4203        let mut first = NetworkIpamConfig::new(Sourced::from_source(
4204            ProtectedString::plain("10.10.0.0/24"),
4205            origin.clone(),
4206        ))
4207        .map_err(|error| error.to_string())?;
4208        first
4209            .set_gateway(Sourced::from_source(
4210                ProtectedString::plain("10.10.0.1"),
4211                origin.clone(),
4212            ))
4213            .map_err(|error| error.to_string())?;
4214        let mut second = NetworkIpamConfig::new(Sourced::from_source(
4215            ProtectedString::plain("fd00:10::/64"),
4216            origin.clone(),
4217        ))
4218        .map_err(|error| error.to_string())?;
4219        second
4220            .set_ip_range(Sourced::from_source(
4221                ProtectedString::plain("fd00:10::100/120"),
4222                origin.clone(),
4223            ))
4224            .map_err(|error| error.to_string())?;
4225
4226        let mut network = Network::new(id("frontend")?, ResourceOwnership::Application);
4227        network.set_ipam_configs_with_origins(
4228            vec![
4229                Sourced::from_source(first, origin.clone()),
4230                Sourced::from_source(second, origin),
4231            ],
4232            Vec::new(),
4233        );
4234        let rows = network
4235            .ipam_configs()
4236            .ok_or_else(|| "IPAM configs were omitted".to_owned())?;
4237        assert_eq!(rows.len(), 2);
4238        assert_eq!(rows[0].value().subnet().value().expose(), "10.10.0.0/24");
4239        assert_eq!(
4240            rows[0].value().gateway().map(|value| value.value().expose()),
4241            Some("10.10.0.1")
4242        );
4243        assert_eq!(rows[0].value().ip_range(), None);
4244        assert_eq!(rows[1].value().subnet().value().expose(), "fd00:10::/64");
4245        assert_eq!(rows[1].value().gateway(), None);
4246        assert_eq!(
4247            rows[1].value().ip_range().map(|value| value.value().expose()),
4248            Some("fd00:10::100/120")
4249        );
4250
4251        assert!(matches!(
4252            NetworkIpamConfig::new(Sourced::generated(ProtectedString::plain(""))),
4253            Err(ModelError::EmptyValue("network IPAM subnet"))
4254        ));
4255        assert!(matches!(
4256            NetworkIpamConfig::new(Sourced::generated(ProtectedString::plain("10.0.0.0/24\0bad"))),
4257            Err(ModelError::ContainsNul("network IPAM subnet"))
4258        ));
4259        assert!(matches!(
4260            NetworkDriverOption::new(
4261                Sourced::generated(id("option")?),
4262                Sourced::generated(ProtectedString::plain("bad\0value")),
4263            ),
4264            Err(ModelError::ContainsNul("network driver option value"))
4265        ));
4266        Ok(())
4267    }
4268
4269    #[test]
4270    fn image_artifact_resources_are_ordered_unique_and_referenced_explicitly() -> Result<(), String> {
4271        let mut application = Application::new(id("example")?);
4272        application
4273            .add_image_acquisition(Sourced::generated(ImageAcquisition::new(id("base-image")?)))
4274            .map_err(|error| error.to_string())?;
4275        application
4276            .add_image_build(Sourced::generated(ImageBuild::new(id("web-build")?)))
4277            .map_err(|error| error.to_string())?;
4278
4279        let mut web = Service::new(id("web")?);
4280        web.set_image_acquisition(Sourced::generated(id("base-image")?));
4281        web.set_image_build(Sourced::generated(id("web-build")?));
4282        application
4283            .add_service(Sourced::generated(web))
4284            .map_err(|error| error.to_string())?;
4285
4286        assert_eq!(
4287            application.image_acquisitions()[0].value().name().as_str(),
4288            "base-image"
4289        );
4290        assert_eq!(application.image_builds()[0].value().name().as_str(), "web-build");
4291        assert!(matches!(
4292            application.add_image_build(Sourced::generated(ImageBuild::new(id("web-build")?))),
4293            Err(ModelError::DuplicateResource {
4294                kind: "image build",
4295                ..
4296            })
4297        ));
4298
4299        let mut missing = Service::new(id("missing")?);
4300        missing.set_image_build(Sourced::generated(id("absent-build")?));
4301        assert!(matches!(
4302            application.add_service(Sourced::generated(missing)),
4303            Err(ModelError::UnknownImageBuildReference { .. })
4304        ));
4305        Ok(())
4306    }
4307
4308    #[test]
4309    fn volume_keeps_logical_runtime_and_service_names_and_local_fields_distinct() -> Result<(), String> {
4310        let origin = crate::Provenance::source(crate::SourceId::new("data.volume").map_err(|error| error.to_string())?);
4311        let mut volume = Volume::new(id("data")?, ResourceOwnership::Application);
4312        volume.set_runtime_name(Sourced::from_source(
4313            ProtectedString::plain("production-data"),
4314            origin.clone(),
4315        ));
4316        volume.set_service_name(Sourced::from_source(
4317            ProtectedString::plain("data-volume.service"),
4318            origin.clone(),
4319        ));
4320        volume.set_driver(Sourced::from_source(ProtectedString::plain("local"), origin.clone()));
4321        volume.set_device(Sourced::from_source(
4322            ProtectedString::plain("/srv/data"),
4323            origin.clone(),
4324        ));
4325        volume.set_volume_type(Sourced::from_source(ProtectedString::plain("none"), origin.clone()));
4326        volume.set_options(Sourced::from_source(ProtectedString::plain("bind"), origin.clone()));
4327
4328        assert_eq!(volume.name().as_str(), "data");
4329        assert_eq!(
4330            volume.runtime_name().map(|name| name.value().expose()),
4331            Some("production-data")
4332        );
4333        assert_eq!(
4334            volume.service_name().map(|name| name.value().expose()),
4335            Some("data-volume.service")
4336        );
4337        assert_eq!(volume.driver().map(|value| value.value().expose()), Some("local"));
4338        assert_eq!(volume.device().map(|value| value.value().expose()), Some("/srv/data"));
4339        assert_eq!(volume.volume_type().map(|value| value.value().expose()), Some("none"));
4340        assert_eq!(volume.options().map(|value| value.value().expose()), Some("bind"));
4341        assert_eq!(
4342            volume.options().map(Sourced::origins),
4343            Some(std::slice::from_ref(&origin))
4344        );
4345        Ok(())
4346    }
4347
4348    #[test]
4349    fn volume_preserves_resets_order_protected_values_and_identity_dimensions() -> Result<(), String> {
4350        let origin = crate::Provenance::source(crate::SourceId::new("data.volume").map_err(|error| error.to_string())?);
4351        let mut volume = Volume::new(id("data")?, ResourceOwnership::Application);
4352        assert!(volume.labels().is_none());
4353        assert!(volume.containers_conf_modules().is_none());
4354        assert!(volume.global_args().is_none());
4355        assert!(volume.podman_args().is_none());
4356        volume.set_labels_with_origins(Vec::new(), vec![origin.clone()]);
4357        volume.set_containers_conf_modules_with_origins(Vec::new(), vec![origin.clone()]);
4358        volume.set_global_args_with_origins(
4359            vec![
4360                Sourced::from_source(ProtectedString::plain("--first"), origin.clone()),
4361                Sourced::from_source(ProtectedString::sensitive("--token=never-print"), origin.clone()),
4362            ],
4363            vec![origin.clone()],
4364        );
4365        volume.set_podman_args_with_origins(
4366            vec![
4367                Sourced::from_source(ProtectedString::plain("--replace"), origin.clone()),
4368                Sourced::from_source(ProtectedString::sensitive("--secret=never-print"), origin.clone()),
4369            ],
4370            vec![origin.clone()],
4371        );
4372        volume.set_user(Sourced::from_source(
4373            ProtectedString::plain("named-user"),
4374            origin.clone(),
4375        ));
4376        volume.set_group(Sourced::from_source(
4377            ProtectedString::plain("named-group"),
4378            origin.clone(),
4379        ));
4380        volume.set_uid(Sourced::from_source(ProtectedString::plain("1001"), origin.clone()));
4381        volume.set_gid(Sourced::from_source(ProtectedString::plain("1002"), origin));
4382
4383        assert_eq!(volume.labels().map(<[_]>::len), Some(0));
4384        assert_eq!(volume.containers_conf_modules().map(<[_]>::len), Some(0));
4385        assert_eq!(volume.global_args().map(<[_]>::len), Some(2));
4386        assert_eq!(volume.podman_args().map(<[_]>::len), Some(2));
4387        assert_eq!(volume.user().map(|value| value.value().expose()), Some("named-user"));
4388        assert_eq!(volume.group().map(|value| value.value().expose()), Some("named-group"));
4389        assert_eq!(volume.uid().map(|value| value.value().expose()), Some("1001"));
4390        assert_eq!(volume.gid().map(|value| value.value().expose()), Some("1002"));
4391        let debug = format!("{volume:?}");
4392        assert!(!debug.contains("never-print"));
4393        assert!(debug.contains("[REDACTED]"));
4394        Ok(())
4395    }
4396
4397    #[test]
4398    fn volume_copy_and_image_sources_preserve_absence_and_typed_distinctions() -> Result<(), String> {
4399        let origin =
4400            crate::Provenance::source(crate::SourceId::new("cache.volume").map_err(|error| error.to_string())?);
4401        let mut volume = Volume::new(id("cache")?, ResourceOwnership::Application);
4402        assert_eq!(volume.copy(), None);
4403        volume.set_copy(Sourced::from_source(false, origin.clone()));
4404        assert_eq!(volume.copy().map(Sourced::value), Some(&false));
4405        volume.set_copy(Sourced::from_source(true, origin.clone()));
4406        assert_eq!(volume.copy().map(Sourced::value), Some(&true));
4407
4408        volume
4409            .set_image_source(Sourced::from_source(
4410                VolumeImageSource::Literal(ProtectedString::sensitive("registry.example/private:1")),
4411                origin.clone(),
4412            ))
4413            .map_err(|error| error.to_string())?;
4414        assert!(matches!(
4415            volume.image_source().map(Sourced::value),
4416            Some(VolumeImageSource::Literal(_))
4417        ));
4418        assert!(!format!("{volume:?}").contains("registry.example/private:1"));
4419
4420        volume
4421            .set_image_source(Sourced::from_source(
4422                VolumeImageSource::ImageAcquisition(id("cache-image")?),
4423                origin.clone(),
4424            ))
4425            .map_err(|error| error.to_string())?;
4426        assert!(matches!(
4427            volume.image_source().map(Sourced::value),
4428            Some(VolumeImageSource::ImageAcquisition(name)) if name.as_str() == "cache-image"
4429        ));
4430        volume
4431            .set_image_source(Sourced::from_source(
4432                VolumeImageSource::ImageBuild(id("cache-build")?),
4433                origin,
4434            ))
4435            .map_err(|error| error.to_string())?;
4436        assert!(matches!(
4437            volume.image_source().map(Sourced::value),
4438            Some(VolumeImageSource::ImageBuild(name)) if name.as_str() == "cache-build"
4439        ));
4440        Ok(())
4441    }
4442
4443    #[test]
4444    fn volume_artifact_validation_is_deferred_and_explicit_edges_find_cycles() -> Result<(), String> {
4445        let mut application = Application::new(id("example")?);
4446        let mut volume = Volume::new(id("cache")?, ResourceOwnership::Application);
4447        volume
4448            .set_image_source(Sourced::generated(VolumeImageSource::ImageBuild(id("cache-build")?)))
4449            .map_err(|error| error.to_string())?;
4450        application
4451            .add_volume(Sourced::generated(volume))
4452            .map_err(|error| error.to_string())?;
4453        assert!(matches!(
4454            application.validate_image_artifact_references(),
4455            Err(ModelError::UnknownVolumeImageBuildReference { .. })
4456        ));
4457
4458        application
4459            .add_image_build(Sourced::generated(ImageBuild::new(id("cache-build")?)))
4460            .map_err(|error| error.to_string())?;
4461        application
4462            .validate_image_artifact_references()
4463            .map_err(|error| error.to_string())?;
4464
4465        let volume_node = ArtifactDependencyNode::Volume(id("cache")?);
4466        let build_node = ArtifactDependencyNode::ImageBuild(id("cache-build")?);
4467        let dependencies = vec![
4468            Sourced::generated(ArtifactDependency::new(
4469                Sourced::generated(volume_node.clone()),
4470                Sourced::generated(build_node.clone()),
4471            )),
4472            Sourced::generated(ArtifactDependency::new(
4473                Sourced::generated(build_node),
4474                Sourced::generated(volume_node),
4475            )),
4476        ];
4477        assert!(matches!(
4478            application.validate_image_artifact_dependencies(&dependencies),
4479            Err(ModelError::ImageArtifactDependencyCycle { .. })
4480        ));
4481        let missing = vec![Sourced::generated(ArtifactDependency::new(
4482            Sourced::generated(ArtifactDependencyNode::ImageBuild(id("cache-build")?)),
4483            Sourced::generated(ArtifactDependencyNode::Volume(id("missing")?)),
4484        ))];
4485        assert!(matches!(
4486            application.validate_image_artifact_dependencies(&missing),
4487            Err(ModelError::UnknownArtifactDependencyNode { kind: "volume", .. })
4488        ));
4489        Ok(())
4490    }
4491
4492    #[test]
4493    fn volume_rejects_invalid_literal_image_values() -> Result<(), String> {
4494        let mut volume = Volume::new(id("data")?, ResourceOwnership::Application);
4495        assert!(matches!(
4496            volume.set_image_source(Sourced::generated(VolumeImageSource::Literal(ProtectedString::plain(
4497                ""
4498            )))),
4499            Err(ModelError::EmptyValue("volume image"))
4500        ));
4501        assert!(matches!(
4502            volume.set_image_source(Sourced::generated(VolumeImageSource::Literal(ProtectedString::plain(
4503                "bad\0image"
4504            )))),
4505            Err(ModelError::ContainsNul("volume image"))
4506        ));
4507        Ok(())
4508    }
4509
4510    #[test]
4511    fn collection_resets_retain_explicit_emptiness_and_clear_stale_origins() -> Result<(), String> {
4512        let origin =
4513            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
4514        let mut service = Service::new(id("web")?);
4515
4516        service.set_cap_add_with_origins(Vec::new(), vec![origin.clone()]);
4517        service.set_cap_drop_with_origins(Vec::new(), vec![origin.clone()]);
4518        service.set_tmpfs_with_origins(Vec::new(), vec![origin.clone()]);
4519        service.set_sysctls_with_origins(Vec::new(), vec![origin.clone()]);
4520        service.set_ulimits_with_origins(Vec::new(), vec![origin.clone()]);
4521        service.set_devices_with_origins(Vec::new(), vec![origin]);
4522
4523        assert_eq!(service.cap_add().map(<[_]>::len), Some(0));
4524        assert_eq!(service.cap_drop().map(<[_]>::len), Some(0));
4525        assert_eq!(service.tmpfs().map(<[_]>::len), Some(0));
4526        assert_eq!(service.sysctls().map(<[_]>::len), Some(0));
4527        assert_eq!(service.ulimits().map(<[_]>::len), Some(0));
4528        assert_eq!(service.devices().map(<[_]>::len), Some(0));
4529        assert_eq!(service.cap_add_origins().len(), 1);
4530        assert_eq!(service.cap_drop_origins().len(), 1);
4531        assert_eq!(service.tmpfs_origins().len(), 1);
4532        assert_eq!(service.sysctls_origins().len(), 1);
4533        assert_eq!(service.ulimits_origins().len(), 1);
4534        assert_eq!(service.devices_origins().len(), 1);
4535
4536        service.set_cap_add(Vec::new());
4537        service.set_cap_drop(Vec::new());
4538        service.set_tmpfs(Vec::new());
4539        service.set_sysctls(Vec::<Sourced<KernelParameter>>::new());
4540        service.set_ulimits(Vec::<Sourced<ResourceLimit>>::new());
4541        service.set_devices(Vec::<Sourced<Device>>::new());
4542
4543        assert!(service.cap_add_origins().is_empty());
4544        assert!(service.cap_drop_origins().is_empty());
4545        assert!(service.tmpfs_origins().is_empty());
4546        assert!(service.sysctls_origins().is_empty());
4547        assert!(service.ulimits_origins().is_empty());
4548        assert!(service.devices_origins().is_empty());
4549        Ok(())
4550    }
4551
4552    #[test]
4553    fn restart_policy_keeps_unlimited_and_finite_on_failure_distinct() {
4554        let finite = std::num::NonZeroU64::new(4);
4555        assert_eq!(RestartPolicy::on_failure(None).maximum_retries(), None);
4556        assert_eq!(RestartPolicy::on_failure(finite).maximum_retries(), finite);
4557        assert_eq!(RestartPolicy::Always.maximum_retries(), None);
4558    }
4559
4560    #[test]
4561    fn metadata_labels_preserve_empty_and_protected_values() -> Result<(), String> {
4562        let empty = MetadataLabel::new(id("com.example.empty")?, ProtectedString::plain(""));
4563        let protected = MetadataLabel::new(id("com.example.token")?, ProtectedString::sensitive("never-print-this"));
4564        let mut service = Service::new(id("web")?);
4565        service.add_label(Sourced::generated(empty));
4566        service.add_label(Sourced::generated(protected));
4567
4568        assert_eq!(service.labels()[0].value().value().expose(), "");
4569        let debug = format!("{:?}", service.labels()[1]);
4570        assert!(!debug.contains("never-print-this"));
4571        assert!(debug.contains("[REDACTED]"));
4572        Ok(())
4573    }
4574
4575    #[test]
4576    fn environment_files_preserve_order_options_provenance_and_redaction() -> Result<(), String> {
4577        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4578        let origin = crate::Provenance::source(source);
4579        let mut service = Service::new(id("web")?);
4580        service.add_environment_file(Sourced::from_source(
4581            EnvironmentFile::new(ProtectedString::plain("./base.env"), EnvironmentFileSyntax::Short)
4582                .map_err(|error| error.to_string())?,
4583            origin.clone(),
4584        ));
4585        let mut local = EnvironmentFile::new(ProtectedString::sensitive("./private.env"), EnvironmentFileSyntax::Long)
4586            .map_err(|error| error.to_string())?;
4587        local.set_required(Sourced::from_source(false, origin.clone()));
4588        local.set_format(Sourced::from_source(EnvironmentFileFormat::Raw, origin.clone()));
4589        service.add_environment_file(Sourced::from_source(local, origin));
4590
4591        assert_eq!(service.environment_files().len(), 2);
4592        assert_eq!(service.environment_files()[0].value().path().expose(), "./base.env");
4593        assert_eq!(
4594            service.environment_files()[0].value().syntax(),
4595            EnvironmentFileSyntax::Short
4596        );
4597        assert!(service.environment_files()[0].value().is_required());
4598        let local = service.environment_files()[1].value();
4599        assert_eq!(local.syntax(), EnvironmentFileSyntax::Long);
4600        assert!(!local.is_required());
4601        assert_eq!(local.required().map_or(0, |value| value.origins().len()), 1);
4602        assert!(matches!(
4603            local.format().map(Sourced::value),
4604            Some(EnvironmentFileFormat::Raw)
4605        ));
4606        let debug = format!("{service:?}");
4607        assert!(!debug.contains("private.env"));
4608        assert!(debug.contains("[REDACTED]"));
4609        assert!(matches!(
4610            EnvironmentFile::new(ProtectedString::plain(""), EnvironmentFileSyntax::Short),
4611            Err(ModelError::EmptyValue("environment-file path"))
4612        ));
4613        Ok(())
4614    }
4615
4616    #[test]
4617    fn service_groups_preserve_order_and_reject_ambiguous_membership() -> Result<(), String> {
4618        let mut application = Application::new(id("example")?);
4619        for name in ["web", "worker"] {
4620            application
4621                .add_service(Sourced::generated(Service::new(id(name)?)))
4622                .map_err(|error| error.to_string())?;
4623        }
4624
4625        let mut frontend = ServiceGroup::new(id("frontend")?, ResourceOwnership::Uncertain);
4626        frontend
4627            .add_member(Sourced::generated(id("web")?))
4628            .map_err(|error| error.to_string())?;
4629        assert!(matches!(
4630            frontend.add_member(Sourced::generated(id("web")?)),
4631            Err(ModelError::DuplicateServiceGroupMember { .. })
4632        ));
4633        application
4634            .add_service_group(Sourced::generated(frontend))
4635            .map_err(|error| error.to_string())?;
4636
4637        assert_eq!(application.service_groups()[0].value().name().as_str(), "frontend");
4638        assert_eq!(
4639            application.service_groups()[0].value().members()[0].value().as_str(),
4640            "web"
4641        );
4642
4643        let mut conflicting = ServiceGroup::new(id("backend")?, ResourceOwnership::Application);
4644        conflicting
4645            .add_member(Sourced::generated(id("web")?))
4646            .map_err(|error| error.to_string())?;
4647        assert!(matches!(
4648            application.add_service_group(Sourced::generated(conflicting)),
4649            Err(ModelError::ServiceInMultipleGroups { .. })
4650        ));
4651
4652        let mut missing = ServiceGroup::new(id("missing")?, ResourceOwnership::External);
4653        missing
4654            .add_member(Sourced::generated(id("database")?))
4655            .map_err(|error| error.to_string())?;
4656        assert!(matches!(
4657            application.add_service_group(Sourced::generated(missing)),
4658            Err(ModelError::UnknownServiceGroupMember { .. })
4659        ));
4660        Ok(())
4661    }
4662
4663    #[test]
4664    fn group_runtime_keeps_group_names_and_pod_settings_distinct() -> Result<(), String> {
4665        let source = crate::SourceId::new("pod.pod").map_err(|error| error.to_string())?;
4666        let origin = crate::Provenance::source(source);
4667        let mut group = ServiceGroup::new(id("frontend")?, ResourceOwnership::Application);
4668        let mut runtime = ServiceGroupRuntime::new();
4669        runtime.set_runtime_name(Sourced::from_source(
4670            ProtectedString::plain("production-frontend"),
4671            origin.clone(),
4672        ));
4673        runtime.set_service_name(Sourced::from_source(
4674            ProtectedString::plain("frontend-pod"),
4675            origin.clone(),
4676        ));
4677        runtime.set_host_mappings_with_origins(
4678            vec![Sourced::from_source(
4679                HostMapping::new(
4680                    id("host.docker.internal")?,
4681                    HostAddress::new("host-gateway").map_err(|error| error.to_string())?,
4682                ),
4683                origin.clone(),
4684            )],
4685            vec![origin.clone()],
4686        );
4687        runtime.set_ports_with_origins(Vec::new(), vec![origin.clone()]);
4688        runtime.set_networks_with_origins(
4689            vec![Sourced::from_source(
4690                NetworkAttachment::new(
4691                    id("edge")?,
4692                    vec![Sourced::from_source(
4693                        ProtectedString::sensitive("private-alias"),
4694                        origin.clone(),
4695                    )],
4696                ),
4697                origin.clone(),
4698            )],
4699            vec![origin.clone()],
4700        );
4701        runtime.set_user_namespace(Sourced::from_source(ProtectedString::plain("keep-id"), origin.clone()));
4702        runtime.set_mounts_with_origins(
4703            vec![Sourced::from_source(
4704                Mount::new(MountSource::Anonymous, "/cache", false).map_err(|error| error.to_string())?,
4705                origin.clone(),
4706            )],
4707            vec![origin.clone()],
4708        );
4709        runtime.set_shm_size(Sourced::from_source(ProtectedString::sensitive("64m"), origin.clone()));
4710        runtime.set_exit_policy(Sourced::from_source(
4711            GroupExitPolicy::Raw(ProtectedString::sensitive("preserve-this")),
4712            origin.clone(),
4713        ));
4714        runtime.set_stop_timeout(Sourced::from_source(
4715            StopTimeout::new("30s").map_err(|error| error.to_string())?,
4716            origin.clone(),
4717        ));
4718        assert!(matches!(
4719            runtime.replace_network(1, Sourced::generated(NetworkAttachment::new(id("other")?, Vec::new()))),
4720            Err(ModelError::UnknownServiceGroupRuntimeNetworkIndex { index: 1, len: 1 })
4721        ));
4722        group.set_runtime(Sourced::from_source(runtime, origin));
4723
4724        let runtime = group
4725            .runtime()
4726            .ok_or_else(|| "group runtime was omitted".to_owned())?
4727            .value();
4728        assert_eq!(group.name().as_str(), "frontend");
4729        assert_eq!(
4730            runtime.runtime_name().map(|name| name.value().expose()),
4731            Some("production-frontend")
4732        );
4733        assert_eq!(
4734            runtime.service_name().map(|name| name.value().expose()),
4735            Some("frontend-pod")
4736        );
4737        assert_eq!(runtime.host_mappings().map(<[_]>::len), Some(1));
4738        assert_eq!(runtime.ports().map(<[_]>::len), Some(0));
4739        assert_eq!(runtime.networks_origins().len(), 1);
4740        assert_eq!(runtime.mounts().map(<[_]>::len), Some(1));
4741        assert!(matches!(
4742            runtime.exit_policy().map(Sourced::value),
4743            Some(GroupExitPolicy::Raw(_))
4744        ));
4745        let debug = format!("{group:?}");
4746        for sensitive in ["private-alias", "64m", "preserve-this"] {
4747            assert!(!debug.contains(sensitive));
4748        }
4749        assert!(debug.contains("[REDACTED]"));
4750        Ok(())
4751    }
4752
4753    #[test]
4754    fn rootfs_startup_notification_and_podman_args_preserve_safe_contracts() -> Result<(), String> {
4755        let source = crate::SourceId::new("web.container").map_err(|error| error.to_string())?;
4756        let origin = crate::Provenance::source(source);
4757        let mut service = Service::new(id("web")?);
4758        service.set_startup_notification(Sourced::from_source(StartupNotification::Healthy, origin.clone()));
4759        service.set_podman_args_with_origins(
4760            vec![
4761                Sourced::from_source(ProtectedString::plain("--replace"), origin.clone()),
4762                Sourced::from_source(ProtectedString::sensitive("--secret=never-print"), origin.clone()),
4763                Sourced::from_source(ProtectedString::plain("--replace"), origin.clone()),
4764            ],
4765            vec![origin.clone()],
4766        );
4767        assert_eq!(service.podman_args().map(<[_]>::len), Some(3));
4768        assert_eq!(service.podman_args_origins(), std::slice::from_ref(&origin));
4769        assert!(matches!(
4770            service.startup_notification().map(Sourced::value),
4771            Some(StartupNotification::Healthy)
4772        ));
4773        assert!(!format!("{service:?}").contains("never-print"));
4774
4775        let mut with_image = Service::new(id("image-first")?);
4776        with_image.set_image(Sourced::generated(
4777            ImageReference::parse("example.invalid/web:1").map_err(|error| error.to_string())?,
4778        ));
4779        assert!(matches!(
4780            with_image.set_rootfs(Sourced::generated(ProtectedString::plain("/srv/rootfs"))),
4781            Err(ModelError::RootfsImageSourceConflict { source: "image", .. })
4782        ));
4783
4784        let mut with_rootfs = Service::new(id("rootfs-first")?);
4785        with_rootfs
4786            .set_rootfs(Sourced::generated(ProtectedString::sensitive("/private/rootfs")))
4787            .map_err(|error| error.to_string())?;
4788        with_rootfs.set_image_build(Sourced::generated(id("web-build")?));
4789        let mut application = Application::new(id("example")?);
4790        application
4791            .add_image_build(Sourced::generated(ImageBuild::new(id("web-build")?)))
4792            .map_err(|error| error.to_string())?;
4793        assert!(matches!(
4794            application.add_service(Sourced::generated(with_rootfs)),
4795            Err(ModelError::RootfsImageSourceConflict {
4796                source: "image build",
4797                ..
4798            })
4799        ));
4800        Ok(())
4801    }
4802
4803    #[test]
4804    fn validates_raw_preserving_healthcheck_scalars() -> Result<(), String> {
4805        let duration = HealthcheckDuration::new("1m30s").map_err(|error| error.to_string())?;
4806        let retries = HealthcheckRetries::new("003").map_err(|error| error.to_string())?;
4807        assert_eq!(duration.as_str(), "1m30s");
4808        assert_eq!(retries.as_str(), "003");
4809        assert_eq!(
4810            HealthcheckRetries::new("three"),
4811            Err(ModelError::InvalidHealthcheckRetries)
4812        );
4813        assert!(matches!(
4814            HealthcheckDuration::new(""),
4815            Err(ModelError::EmptyValue("health-check duration"))
4816        ));
4817        Ok(())
4818    }
4819
4820    #[test]
4821    fn preserves_ordered_dependency_edges_and_field_provenance() -> Result<(), String> {
4822        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4823        let origin = crate::Provenance::source(source);
4824        let mut service = Service::new(id("web")?);
4825
4826        let mut database = ServiceDependency::new(id("database")?);
4827        database.set_condition(Sourced::from_source(
4828            ServiceDependencyCondition::Healthy,
4829            origin.clone(),
4830        ));
4831        database.set_required(Sourced::from_source(true, origin.clone()));
4832        service.add_dependency(Sourced::from_source(database, origin.clone()));
4833
4834        let cache = ServiceDependency::new(id("cache")?);
4835        assert!(cache.is_required());
4836        service.add_dependency(Sourced::from_source(cache, origin));
4837
4838        assert_eq!(
4839            service
4840                .dependencies()
4841                .iter()
4842                .map(|dependency| dependency.value().service().as_str())
4843                .collect::<Vec<_>>(),
4844            ["database", "cache"]
4845        );
4846        assert!(matches!(
4847            service.dependencies()[0].value().condition().map(Sourced::value),
4848            Some(ServiceDependencyCondition::Healthy)
4849        ));
4850        assert_eq!(service.dependencies()[0].origins().len(), 1);
4851        assert_eq!(
4852            service.dependencies()[0]
4853                .value()
4854                .condition()
4855                .map_or(0, |condition| condition.origins().len()),
4856            1
4857        );
4858        Ok(())
4859    }
4860
4861    #[test]
4862    fn retains_execution_identity_context_order_provenance_and_redaction() -> Result<(), String> {
4863        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4864        let origin = crate::Provenance::source(source);
4865        let mut service = Service::new(id("web")?);
4866
4867        service.set_user(Sourced::from_source(ProtectedString::sensitive("1001"), origin.clone()));
4868        service.set_group(Sourced::from_source(ProtectedString::plain("1002"), origin.clone()));
4869        service.set_user_namespace(Sourced::from_source(ProtectedString::plain("keep-id"), origin.clone()));
4870        service.add_supplementary_group(Sourced::from_source(ProtectedString::plain("audio"), origin.clone()));
4871        service.add_supplementary_group(Sourced::from_source(ProtectedString::plain("44"), origin.clone()));
4872        service.set_working_directory(Sourced::from_source(ProtectedString::plain("/srv/app"), origin.clone()));
4873        service.set_read_only_root_filesystem(Sourced::from_source(true, origin));
4874
4875        assert_eq!(service.user().map(|value| value.value().expose()), Some("1001"));
4876        assert_eq!(service.group().map(|value| value.value().expose()), Some("1002"));
4877        assert_eq!(
4878            service.user_namespace().map(|value| value.value().expose()),
4879            Some("keep-id")
4880        );
4881        assert_eq!(
4882            service
4883                .supplementary_groups()
4884                .iter()
4885                .map(|group| group.value().expose())
4886                .collect::<Vec<_>>(),
4887            ["audio", "44"]
4888        );
4889        assert_eq!(
4890            service.working_directory().map(|value| value.value().expose()),
4891            Some("/srv/app")
4892        );
4893        assert_eq!(service.read_only_root_filesystem().map(Sourced::value), Some(&true));
4894        assert_eq!(service.user().map_or(0, |value| value.origins().len()), 1);
4895        let debug = format!("{service:?}");
4896        assert!(!debug.contains("1001"));
4897        assert!(debug.contains("[REDACTED]"));
4898        Ok(())
4899    }
4900
4901    #[test]
4902    fn retains_config_secret_resources_grants_provenance_and_redaction() -> Result<(), String> {
4903        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4904        let origin = crate::Provenance::source(source);
4905        let mut application = Application::new(id("example")?);
4906
4907        let mut config = Config::new(id("settings")?, ResourceOwnership::Application);
4908        config.set_material(Sourced::from_source(
4909            ConfigMaterial::Content(ProtectedString::sensitive("private-config")),
4910            origin.clone(),
4911        ));
4912        application
4913            .add_config(Sourced::from_source(config, origin.clone()))
4914            .map_err(|error| error.to_string())?;
4915
4916        let mut secret = Secret::new(id("password")?, ResourceOwnership::External);
4917        secret.set_runtime_name(Sourced::from_source(
4918            ProtectedString::plain("production-password"),
4919            origin.clone(),
4920        ));
4921        secret.set_material(Sourced::from_source(
4922            SecretMaterial::Environment(ProtectedString::sensitive("private-environment-name")),
4923            origin.clone(),
4924        ));
4925        application
4926            .add_secret(Sourced::from_source(secret, origin.clone()))
4927            .map_err(|error| error.to_string())?;
4928
4929        let mut service = Service::new(id("web")?);
4930        service.add_config_grant(Sourced::from_source(
4931            ResourceGrant::new(ProtectedString::plain("settings"), ResourceGrantSyntax::Short)
4932                .map_err(|error| error.to_string())?,
4933            origin.clone(),
4934        ));
4935        let mut secret_grant = ResourceGrant::new(
4936            ProtectedString::sensitive("private-grant-source"),
4937            ResourceGrantSyntax::Long,
4938        )
4939        .map_err(|error| error.to_string())?;
4940        secret_grant.set_target(Sourced::from_source(
4941            ProtectedString::plain("database-password"),
4942            origin.clone(),
4943        ));
4944        secret_grant.set_uid(Sourced::from_source(ProtectedString::plain("1001"), origin.clone()));
4945        secret_grant.set_gid(Sourced::from_source(ProtectedString::plain("1002"), origin.clone()));
4946        secret_grant.set_mode(Sourced::from_source(ProtectedString::plain("0440"), origin.clone()));
4947        service.add_secret_grant(Sourced::from_source(secret_grant, origin.clone()));
4948        application
4949            .add_service(Sourced::from_source(service, origin))
4950            .map_err(|error| error.to_string())?;
4951
4952        assert_eq!(application.configs().len(), 1);
4953        assert_eq!(application.secrets().len(), 1);
4954        assert_eq!(application.services()[0].value().config_grants().len(), 1);
4955        let grant = &application.services()[0].value().secret_grants()[0];
4956        assert_eq!(grant.value().syntax(), ResourceGrantSyntax::Long);
4957        assert_eq!(
4958            grant.value().target().map(|value| value.value().expose()),
4959            Some("database-password")
4960        );
4961        assert_eq!(grant.value().uid().map_or(0, |value| value.origins().len()), 1);
4962        assert_eq!(grant.origins().len(), 1);
4963        let debug = format!("{application:?}");
4964        for secret in ["private-config", "private-environment-name", "private-grant-source"] {
4965            assert!(!debug.contains(secret));
4966        }
4967        assert!(debug.contains("[REDACTED]"));
4968
4969        assert!(matches!(
4970            ResourceGrant::new(ProtectedString::plain(""), ResourceGrantSyntax::Short),
4971            Err(ModelError::EmptyValue("resource grant source"))
4972        ));
4973        assert!(matches!(
4974            application.add_config(Sourced::generated(Config::new(
4975                id("settings")?,
4976                ResourceOwnership::External,
4977            ))),
4978            Err(ModelError::DuplicateResource { kind: "config", .. })
4979        ));
4980        assert!(matches!(
4981            application.add_secret(Sourced::generated(Secret::new(
4982                id("password")?,
4983                ResourceOwnership::External,
4984            ))),
4985            Err(ModelError::DuplicateResource { kind: "secret", .. })
4986        ));
4987        Ok(())
4988    }
4989
4990    #[test]
4991    fn host_mappings_preserve_order_spelling_and_runtime_tokens() -> Result<(), String> {
4992        let mut service = Service::new(id("web")?);
4993        service.add_host_mapping(Sourced::generated(HostMapping::new(
4994            id("host.docker.internal")?,
4995            HostAddress::new("host-gateway").map_err(|error| error.to_string())?,
4996        )));
4997        service.add_host_mapping(Sourced::generated(HostMapping::new(
4998            id("ipv6")?,
4999            HostAddress::new("[::1]").map_err(|error| error.to_string())?,
5000        )));
5001
5002        assert_eq!(service.host_mappings().len(), 2);
5003        assert_eq!(
5004            service.host_mappings()[0].value().address().kind(),
5005            HostAddressKind::HostGateway
5006        );
5007        assert_eq!(service.host_mappings()[1].value().address().raw(), "[::1]");
5008        assert_eq!(
5009            service.host_mappings()[1].value().address().kind(),
5010            HostAddressKind::Ipv6 { bracketed: true }
5011        );
5012        assert!(matches!(HostAddress::new(""), Err(ModelError::EmptyValue(_))));
5013        Ok(())
5014    }
5015
5016    #[test]
5017    fn dns_collections_preserve_order_provenance_and_explicit_empty_state() -> Result<(), String> {
5018        let mut service = Service::new(id("web")?);
5019        assert!(service.dns_servers().is_none());
5020        service.set_dns_servers(Vec::new());
5021        assert!(matches!(service.dns_servers(), Some(values) if values.is_empty()));
5022        service.set_dns_options(vec![
5023            Sourced::generated(ProtectedString::plain("ndots:5")),
5024            Sourced::generated(ProtectedString::sensitive("rotate")),
5025        ]);
5026        service.set_dns_search_domains(vec![Sourced::generated(ProtectedString::plain("example.test"))]);
5027        assert_eq!(
5028            service
5029                .dns_options()
5030                .unwrap_or_default()
5031                .iter()
5032                .map(|value| value.value().expose())
5033                .collect::<Vec<_>>(),
5034            ["ndots:5", "rotate"]
5035        );
5036        assert!(!format!("{service:?}").contains("rotate"));
5037        Ok(())
5038    }
5039
5040    #[test]
5041    fn security_options_preserve_empty_order_duplicates_provenance_and_redaction() -> Result<(), String> {
5042        let origin =
5043            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
5044        let mut service = Service::new(id("web")?);
5045
5046        assert!(service.security_options().is_none());
5047        service.set_security_options_with_origins(Vec::new(), vec![origin.clone()]);
5048        assert_eq!(service.security_options().map(<[_]>::len), Some(0));
5049        assert_eq!(service.security_options_origins(), std::slice::from_ref(&origin));
5050
5051        service.set_security_options_with_origins(
5052            vec![
5053                Sourced::from_source(
5054                    SecurityOption::AppArmor(ProtectedString::sensitive("apparmor-secret")),
5055                    origin.clone(),
5056                ),
5057                Sourced::from_source(SecurityOption::NoNewPrivileges(true), origin.clone()),
5058                Sourced::from_source(
5059                    SecurityOption::SeccompProfile(ProtectedString::sensitive("seccomp-secret")),
5060                    origin.clone(),
5061                ),
5062                Sourced::from_source(SecurityOption::SecurityLabelDisable(false), origin.clone()),
5063                Sourced::from_source(
5064                    SecurityOption::SecurityLabelFileType(ProtectedString::sensitive("file-type-secret")),
5065                    origin.clone(),
5066                ),
5067                Sourced::from_source(
5068                    SecurityOption::SecurityLabelLevel(ProtectedString::sensitive("level-secret")),
5069                    origin.clone(),
5070                ),
5071                Sourced::from_source(SecurityOption::SecurityLabelNested(true), origin.clone()),
5072                Sourced::from_source(
5073                    SecurityOption::SecurityLabelType(ProtectedString::sensitive("type-secret")),
5074                    origin.clone(),
5075                ),
5076                Sourced::from_source(
5077                    SecurityOption::Mask(ProtectedString::sensitive("mask-secret")),
5078                    origin.clone(),
5079                ),
5080                Sourced::from_source(
5081                    SecurityOption::Unmask(ProtectedString::sensitive("unmask-secret")),
5082                    origin.clone(),
5083                ),
5084                Sourced::from_source(
5085                    SecurityOption::Mask(ProtectedString::sensitive("mask-secret")),
5086                    origin.clone(),
5087                ),
5088            ],
5089            vec![origin.clone()],
5090        );
5091
5092        let options = service.security_options().unwrap_or_default();
5093        assert_eq!(options.len(), 11);
5094        assert!(
5095            matches!(options[0].value(), SecurityOption::AppArmor(profile) if profile.expose() == "apparmor-secret")
5096        );
5097        assert!(matches!(options[1].value(), SecurityOption::NoNewPrivileges(true)));
5098        assert!(
5099            matches!(options[2].value(), SecurityOption::SeccompProfile(profile) if profile.expose() == "seccomp-secret")
5100        );
5101        assert!(matches!(
5102            options[3].value(),
5103            SecurityOption::SecurityLabelDisable(false)
5104        ));
5105        assert!(
5106            matches!(options[4].value(), SecurityOption::SecurityLabelFileType(profile) if profile.expose() == "file-type-secret")
5107        );
5108        assert!(
5109            matches!(options[5].value(), SecurityOption::SecurityLabelLevel(profile) if profile.expose() == "level-secret")
5110        );
5111        assert!(matches!(options[6].value(), SecurityOption::SecurityLabelNested(true)));
5112        assert!(
5113            matches!(options[7].value(), SecurityOption::SecurityLabelType(profile) if profile.expose() == "type-secret")
5114        );
5115        assert!(matches!(options[8].value(), SecurityOption::Mask(path) if path.expose() == "mask-secret"));
5116        assert!(matches!(options[9].value(), SecurityOption::Unmask(path) if path.expose() == "unmask-secret"));
5117        assert!(matches!(options[10].value(), SecurityOption::Mask(path) if path.expose() == "mask-secret"));
5118        assert_eq!(options[0].origins(), std::slice::from_ref(&origin));
5119        assert_eq!(service.security_options_origins(), std::slice::from_ref(&origin));
5120
5121        let debug = format!("{service:?}");
5122        for secret in [
5123            "apparmor-secret",
5124            "seccomp-secret",
5125            "file-type-secret",
5126            "level-secret",
5127            "type-secret",
5128            "mask-secret",
5129            "unmask-secret",
5130        ] {
5131            assert!(!debug.contains(secret));
5132        }
5133        assert!(debug.contains("[REDACTED]"));
5134
5135        service.set_security_options(Vec::new());
5136        assert_eq!(service.security_options().map(<[_]>::len), Some(0));
5137        assert!(service.security_options_origins().is_empty());
5138        Ok(())
5139    }
5140
5141    #[test]
5142    fn retains_entrypoint_run_init_stop_pull_memory_and_exposed_port_intent() -> Result<(), String> {
5143        let origin =
5144            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
5145        let mut service = Service::new(id("web")?);
5146        service.set_command(Sourced::from_source(
5147            Command::Exec(vec![ProtectedString::plain("serve")]),
5148            origin.clone(),
5149        ));
5150        service.set_entrypoint(Sourced::from_source(
5151            Entrypoint::Shell(ProtectedString::sensitive("/bin/sh -c private-entrypoint")),
5152            origin.clone(),
5153        ));
5154        service.set_run_init(Sourced::from_source(true, origin.clone()));
5155        service.set_stop_timeout(Sourced::from_source(
5156            StopTimeout::new("01m30s").map_err(|error| error.to_string())?,
5157            origin.clone(),
5158        ));
5159        service.set_pull_policy(Sourced::from_source(
5160            PullPolicy::Every(ProtectedString::sensitive("12h")),
5161            origin.clone(),
5162        ));
5163        service.set_memory_limit(Sourced::from_source(
5164            ProtectedString::sensitive("512MiB"),
5165            origin.clone(),
5166        ));
5167        assert!(service.exposed_ports().is_none());
5168        service.set_exposed_ports_with_origins(Vec::new(), vec![origin.clone()]);
5169        assert_eq!(service.exposed_ports().map(<[_]>::len), Some(0));
5170        assert_eq!(service.exposed_ports_origins(), std::slice::from_ref(&origin));
5171        service.add_exposed_port(Sourced::from_source(
5172            ExposedPort::new(8080, Protocol::Tcp).map_err(|error| error.to_string())?,
5173            origin.clone(),
5174        ));
5175        service.add_exposed_port(Sourced::from_source(
5176            ExposedPort::new(8080, Protocol::Tcp).map_err(|error| error.to_string())?,
5177            origin,
5178        ));
5179
5180        assert!(matches!(service.command().map(Sourced::value), Some(Command::Exec(_))));
5181        assert!(matches!(
5182            service.entrypoint().map(Sourced::value),
5183            Some(Entrypoint::Shell(_))
5184        ));
5185        assert_eq!(service.run_init().map(Sourced::value), Some(&true));
5186        assert_eq!(
5187            service.stop_timeout().map(|timeout| timeout.value().as_str()),
5188            Some("01m30s")
5189        );
5190        assert!(matches!(
5191            service.pull_policy().map(Sourced::value),
5192            Some(PullPolicy::Every(_))
5193        ));
5194        assert_eq!(
5195            service.memory_limit().map(|limit| limit.value().expose()),
5196            Some("512MiB")
5197        );
5198        let exposed_ports = service.exposed_ports().ok_or("missing exposed ports")?;
5199        assert_eq!(exposed_ports.len(), 2);
5200        assert_eq!(exposed_ports[0].value().container(), 8080);
5201        assert_eq!(exposed_ports[0].value().protocol(), &Protocol::Tcp);
5202        assert!(matches!(
5203            ExposedPort::new(0, Protocol::Udp),
5204            Err(ModelError::ZeroContainerPort)
5205        ));
5206        assert!(matches!(
5207            StopTimeout::new(""),
5208            Err(ModelError::EmptyValue("stop timeout"))
5209        ));
5210
5211        let debug = format!("{service:?}");
5212        for secret in ["private-entrypoint", "512MiB", "12h"] {
5213            assert!(!debug.contains(secret));
5214        }
5215        assert!(debug.contains("[REDACTED]"));
5216        Ok(())
5217    }
5218
5219    #[test]
5220    fn annotations_and_logging_preserve_empty_order_field_provenance_and_redaction() -> Result<(), String> {
5221        let origin =
5222            crate::Provenance::source(crate::SourceId::new("quadlet.container").map_err(|error| error.to_string())?);
5223        let mut service = Service::new(id("web")?);
5224
5225        assert!(service.annotations().is_none());
5226        service.set_annotations_with_origins(Vec::new(), vec![origin.clone()]);
5227        assert_eq!(service.annotations().map(<[_]>::len), Some(0));
5228        assert_eq!(service.annotations_origins(), std::slice::from_ref(&origin));
5229
5230        service.set_annotations_with_origins(
5231            vec![
5232                Sourced::from_source(
5233                    Annotation::new(
5234                        Sourced::from_source(id("io.example.first")?, origin.clone()),
5235                        Sourced::from_source(ProtectedString::sensitive("annotation-secret"), origin.clone()),
5236                    ),
5237                    origin.clone(),
5238                ),
5239                Sourced::from_source(
5240                    Annotation::new(
5241                        Sourced::from_source(id("io.example.second")?, origin.clone()),
5242                        Sourced::from_source(ProtectedString::plain(""), origin.clone()),
5243                    ),
5244                    origin.clone(),
5245                ),
5246            ],
5247            vec![origin.clone()],
5248        );
5249        let annotations = service.annotations().unwrap_or_default();
5250        assert_eq!(annotations.len(), 2);
5251        assert_eq!(annotations[0].value().name().value().as_str(), "io.example.first");
5252        assert_eq!(annotations[1].value().value().value().expose(), "");
5253        assert_eq!(annotations[0].value().name().origins(), std::slice::from_ref(&origin));
5254        assert_eq!(annotations[0].value().value().origins(), std::slice::from_ref(&origin));
5255
5256        let mut logging = Logging::new();
5257        assert!(logging.options().is_none());
5258        logging.set_driver(Sourced::from_source(ProtectedString::plain("journald"), origin.clone()));
5259        logging.set_options_with_origins(
5260            vec![
5261                Sourced::from_source(
5262                    LoggingOption::new(
5263                        Sourced::from_source(id("tag")?, origin.clone()),
5264                        Sourced::from_source(ProtectedString::sensitive("logging-secret"), origin.clone()),
5265                    ),
5266                    origin.clone(),
5267                ),
5268                Sourced::from_source(
5269                    LoggingOption::new(
5270                        Sourced::from_source(id("labels")?, origin.clone()),
5271                        Sourced::from_source(ProtectedString::plain(""), origin.clone()),
5272                    ),
5273                    origin.clone(),
5274                ),
5275            ],
5276            vec![origin.clone()],
5277        );
5278        service.set_logging(Sourced::from_source(logging, origin));
5279
5280        let logging = service.logging().map(Sourced::value).ok_or("missing logging")?;
5281        assert_eq!(logging.driver().map(|driver| driver.value().expose()), Some("journald"));
5282        assert_eq!(logging.options().map(<[_]>::len), Some(2));
5283        assert_eq!(
5284            logging.options().unwrap_or_default()[0].value().name().value().as_str(),
5285            "tag"
5286        );
5287        assert_eq!(logging.options_origins().len(), 1);
5288        let debug = format!("{service:?}");
5289        assert!(!debug.contains("annotation-secret"));
5290        assert!(!debug.contains("logging-secret"));
5291        assert!(debug.contains("[REDACTED]"));
5292        Ok(())
5293    }
5294
5295    #[test]
5296    fn network_attachments_retain_alias_provenance_and_redact_sensitive_values() -> Result<(), String> {
5297        let origin =
5298            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
5299        let mut attachment = NetworkAttachment::new(
5300            id("frontend")?,
5301            vec![
5302                Sourced::from_source(ProtectedString::plain("web"), origin.clone()),
5303                Sourced::from_source(ProtectedString::sensitive("private-alias"), origin.clone()),
5304            ],
5305        );
5306        attachment.set_ipv4_address(Sourced::from_source(
5307            ProtectedString::plain("192.0.2.10"),
5308            origin.clone(),
5309        ));
5310        attachment.set_ipv6_address(Sourced::from_source(
5311            ProtectedString::plain("2001:db8::10"),
5312            origin.clone(),
5313        ));
5314        let metrics = Sourced::generated(ProtectedString::plain("metrics"));
5315        attachment.add_alias(&metrics);
5316
5317        assert_eq!(attachment.aliases(), ["web", "private-alias", "metrics"]);
5318        assert_eq!(attachment.alias_sensitivities(), [false, true, false]);
5319        assert_eq!(attachment.alias_origins().len(), 3);
5320        assert_eq!(attachment.alias_origins()[0].len(), 1);
5321        assert_eq!(attachment.alias_origins()[1], std::slice::from_ref(&origin));
5322        assert!(attachment.alias_origins()[2].is_empty());
5323        assert_eq!(
5324            attachment.ipv4_address().map(|address| address.value().expose()),
5325            Some("192.0.2.10")
5326        );
5327        assert_eq!(
5328            attachment.ipv6_address().map(|address| address.value().expose()),
5329            Some("2001:db8::10")
5330        );
5331        let debug = format!("{attachment:?}");
5332        assert!(!debug.contains("private-alias"));
5333        assert!(debug.contains("[REDACTED]"));
5334
5335        let mut service = Service::new(id("web")?);
5336        service.add_network(Sourced::generated(NetworkAttachment::new(
5337            id("previous")?,
5338            vec![Sourced::generated(ProtectedString::plain("previous-alias"))],
5339        )));
5340        let previous = service
5341            .replace_network(0, Sourced::generated(attachment))
5342            .map_err(|error| error.to_string())?;
5343        assert_eq!(previous.value().network().as_str(), "previous");
5344        assert_eq!(service.networks()[0].value().network().as_str(), "frontend");
5345        assert!(matches!(
5346            service.replace_network(1, Sourced::generated(NetworkAttachment::new(id("unused")?, Vec::new()))),
5347            Err(ModelError::UnknownNetworkAttachmentIndex { index: 1, len: 1 })
5348        ));
5349        Ok(())
5350    }
5351
5352    #[test]
5353    fn reload_action_is_one_explicit_command_or_signal() -> Result<(), String> {
5354        let origin =
5355            crate::Provenance::source(crate::SourceId::new("quadlet.container").map_err(|error| error.to_string())?);
5356        let mut service = Service::new(id("web")?);
5357        service.set_reload_action(Sourced::from_source(
5358            ReloadAction::Command(Command::Exec(vec![ProtectedString::plain("reload")])),
5359            origin.clone(),
5360        ));
5361        assert!(matches!(
5362            service.reload_action().map(Sourced::value),
5363            Some(ReloadAction::Command(Command::Exec(_)))
5364        ));
5365
5366        service.set_reload_action(Sourced::from_source(
5367            ReloadAction::Signal(ProtectedString::sensitive("SIGHUP")),
5368            origin,
5369        ));
5370        assert!(matches!(
5371            service.reload_action().map(Sourced::value),
5372            Some(ReloadAction::Signal(_))
5373        ));
5374        let debug = format!("{service:?}");
5375        assert!(!debug.contains("SIGHUP"));
5376        assert!(debug.contains("[REDACTED]"));
5377        Ok(())
5378    }
5379
5380    fn id(value: &str) -> Result<Identifier, String> {
5381        Identifier::new(value).map_err(|error| error.to_string())
5382    }
5383}