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 aliases.
2394    #[must_use]
2395    pub const fn new(network: Identifier, aliases: Vec<String>) -> Self {
2396        Self {
2397            network,
2398            aliases,
2399            alias_sensitivities: Vec::new(),
2400            alias_origins: Vec::new(),
2401            ipv4_address: None,
2402            ipv6_address: None,
2403        }
2404    }
2405
2406    /// Creates a network attachment from aliases that retain source provenance.
2407    #[must_use]
2408    pub fn with_sourced_aliases(network: Identifier, aliases: Vec<Sourced<ProtectedString>>) -> Self {
2409        let mut attachment = Self::new(network, Vec::new());
2410        attachment.set_aliases_with_provenance(aliases);
2411        attachment
2412    }
2413
2414    /// Returns the application network name.
2415    #[must_use]
2416    pub const fn network(&self) -> &Identifier {
2417        &self.network
2418    }
2419
2420    /// Returns aliases in authored order.
2421    #[must_use]
2422    pub fn aliases(&self) -> &[String] {
2423        &self.aliases
2424    }
2425
2426    /// Returns alias origins in the same order as [`Self::aliases`].
2427    ///
2428    /// Values created through [`Self::new`] have no individual origins; source adapters should
2429    /// use [`Self::with_sourced_aliases`] or [`Self::add_alias`] when provenance is available.
2430    #[must_use]
2431    pub fn alias_origins(&self) -> &[Vec<Provenance>] {
2432        &self.alias_origins
2433    }
2434
2435    /// Returns per-alias sensitivity flags in the same order as [`Self::aliases`].
2436    ///
2437    /// Values created through [`Self::new`] have no explicit sensitive aliases. Target adapters
2438    /// use this boundary to avoid passing protected aliases into native APIs that cannot redact
2439    /// them.
2440    #[must_use]
2441    pub fn alias_sensitivities(&self) -> &[bool] {
2442        &self.alias_sensitivities
2443    }
2444
2445    /// Replaces aliases with ordered provenance-bearing source values.
2446    pub fn set_aliases_with_provenance(&mut self, aliases: Vec<Sourced<ProtectedString>>) {
2447        self.aliases = aliases.iter().map(|alias| alias.value().expose().to_owned()).collect();
2448        self.alias_sensitivities = aliases.iter().map(|alias| alias.value().is_sensitive()).collect();
2449        self.alias_origins = aliases.into_iter().map(|alias| alias.origins().to_vec()).collect();
2450    }
2451
2452    /// Appends one alias and its provenance.
2453    pub fn add_alias(&mut self, alias: &Sourced<ProtectedString>) {
2454        self.aliases.push(alias.value().expose().to_owned());
2455        self.alias_sensitivities.push(alias.value().is_sensitive());
2456        self.alias_origins.push(alias.origins().to_vec());
2457    }
2458
2459    /// Sets the attachment's explicit IPv4 address spelling.
2460    pub fn set_ipv4_address(&mut self, address: Sourced<ProtectedString>) {
2461        self.ipv4_address = Some(address);
2462    }
2463
2464    /// Returns the attachment's explicit IPv4 address spelling.
2465    #[must_use]
2466    pub const fn ipv4_address(&self) -> Option<&Sourced<ProtectedString>> {
2467        self.ipv4_address.as_ref()
2468    }
2469
2470    /// Sets the attachment's explicit IPv6 address spelling.
2471    pub fn set_ipv6_address(&mut self, address: Sourced<ProtectedString>) {
2472        self.ipv6_address = Some(address);
2473    }
2474
2475    /// Returns the attachment's explicit IPv6 address spelling.
2476    #[must_use]
2477    pub const fn ipv6_address(&self) -> Option<&Sourced<ProtectedString>> {
2478        self.ipv6_address.as_ref()
2479    }
2480}
2481
2482impl fmt::Debug for NetworkAttachment {
2483    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2484        let aliases = self
2485            .aliases
2486            .iter()
2487            .enumerate()
2488            .map(|(index, alias)| {
2489                if self.alias_sensitivities.get(index).copied().unwrap_or(false) {
2490                    "[REDACTED]"
2491                } else {
2492                    alias.as_str()
2493                }
2494            })
2495            .collect::<Vec<_>>();
2496        formatter
2497            .debug_struct("NetworkAttachment")
2498            .field("network", &self.network)
2499            .field("aliases", &aliases)
2500            .field("alias_origins", &self.alias_origins)
2501            .field("ipv4_address", &self.ipv4_address)
2502            .field("ipv6_address", &self.ipv6_address)
2503            .finish()
2504    }
2505}
2506
2507/// Readiness state a service dependency must reach before its dependent starts.
2508#[derive(Clone, Debug, Eq, PartialEq)]
2509#[non_exhaustive]
2510pub enum ServiceDependencyCondition {
2511    /// The dependency's service startup completed.
2512    Started,
2513    /// The dependency reported healthy readiness.
2514    Healthy,
2515    /// The dependency exited successfully.
2516    CompletedSuccessfully,
2517    /// A source-specific condition retained for explicit target-side reporting.
2518    Other(ProtectedString),
2519}
2520
2521/// One ordered dependency edge from a service to another application service.
2522///
2523/// Optional fields distinguish source defaults from explicitly authored values. The surrounding
2524/// [`Sourced`] value carries the referenced service-name provenance, while each option retains its
2525/// own field-level provenance.
2526#[derive(Clone, Debug, Eq, PartialEq)]
2527pub struct ServiceDependency {
2528    service: Identifier,
2529    condition: Option<Sourced<ServiceDependencyCondition>>,
2530    restart: Option<Sourced<bool>>,
2531    required: Option<Sourced<bool>>,
2532}
2533
2534/// One raw-preserving kernel-parameter assignment.
2535#[derive(Clone, Debug, Eq, PartialEq)]
2536pub struct KernelParameter {
2537    name: ProtectedString,
2538    value: ProtectedString,
2539}
2540
2541impl KernelParameter {
2542    /// Creates an assignment without interpreting kernel namespaces or privileges.
2543    #[must_use]
2544    pub const fn new(name: ProtectedString, value: ProtectedString) -> Self {
2545        Self { name, value }
2546    }
2547
2548    /// Returns the authored parameter name.
2549    #[must_use]
2550    pub const fn name(&self) -> &ProtectedString {
2551        &self.name
2552    }
2553
2554    /// Returns the authored scalar value spelling.
2555    #[must_use]
2556    pub const fn value(&self) -> &ProtectedString {
2557        &self.value
2558    }
2559}
2560
2561/// One raw-preserving resource-limit declaration.
2562#[derive(Clone, Debug, Eq, PartialEq)]
2563pub struct ResourceLimit {
2564    name: ProtectedString,
2565    soft: Option<Sourced<ProtectedString>>,
2566    hard: Option<Sourced<ProtectedString>>,
2567}
2568
2569impl ResourceLimit {
2570    /// Creates a limit with independently sourced soft and hard values.
2571    #[must_use]
2572    pub const fn new(
2573        name: ProtectedString,
2574        soft: Option<Sourced<ProtectedString>>,
2575        hard: Option<Sourced<ProtectedString>>,
2576    ) -> Self {
2577        Self { name, soft, hard }
2578    }
2579
2580    /// Returns the raw limit name.
2581    #[must_use]
2582    pub const fn name(&self) -> &ProtectedString {
2583        &self.name
2584    }
2585
2586    /// Returns the optional soft value.
2587    #[must_use]
2588    pub const fn soft(&self) -> Option<&Sourced<ProtectedString>> {
2589        self.soft.as_ref()
2590    }
2591
2592    /// Returns the optional hard value.
2593    #[must_use]
2594    pub const fn hard(&self) -> Option<&Sourced<ProtectedString>> {
2595        self.hard.as_ref()
2596    }
2597}
2598
2599/// A service device declaration with its authored syntax retained.
2600#[derive(Clone, Debug, Eq, PartialEq)]
2601#[non_exhaustive]
2602pub enum Device {
2603    /// A raw short device spelling.
2604    Short(ProtectedString),
2605    /// A long device mapping with independently sourced members.
2606    Long {
2607        /// Host-device source spelling.
2608        source: Option<Sourced<ProtectedString>>,
2609        /// Container-device target spelling.
2610        target: Option<Sourced<ProtectedString>>,
2611        /// Raw permission spelling.
2612        permissions: Option<Sourced<ProtectedString>>,
2613    },
2614}
2615
2616/// One format-independent service security option.
2617///
2618/// Native adapters retain ordering and duplicates around this value. They classify any
2619/// source-specific singleton conflicts rather than imposing those rules on the neutral model.
2620#[derive(Clone, Debug, Eq, PartialEq)]
2621#[non_exhaustive]
2622pub enum SecurityOption {
2623    /// Selects an `AppArmor` profile.
2624    AppArmor(ProtectedString),
2625    /// Enables or disables the no-new-privileges security bit.
2626    NoNewPrivileges(bool),
2627    /// Selects a seccomp profile.
2628    SeccompProfile(ProtectedString),
2629    /// Selects whether `SELinux` labeling is disabled (`true` disables labels).
2630    SecurityLabelDisable(bool),
2631    /// Selects the `SELinux` file type.
2632    SecurityLabelFileType(ProtectedString),
2633    /// Selects the `SELinux` level.
2634    SecurityLabelLevel(ProtectedString),
2635    /// Enables or disables nested `SELinux` labeling.
2636    SecurityLabelNested(bool),
2637    /// Selects the `SELinux` type.
2638    SecurityLabelType(ProtectedString),
2639    /// Masks one or more colon-separated container paths, or `ALL`.
2640    Mask(ProtectedString),
2641    /// Unmasks one or more colon-separated container paths, or `ALL`.
2642    Unmask(ProtectedString),
2643}
2644
2645impl ServiceDependency {
2646    /// Creates an edge using source-format defaults for readiness, restart propagation, and
2647    /// requirement strength.
2648    #[must_use]
2649    pub const fn new(service: Identifier) -> Self {
2650        Self {
2651            service,
2652            condition: None,
2653            restart: None,
2654            required: None,
2655        }
2656    }
2657
2658    /// Returns the referenced application service.
2659    #[must_use]
2660    pub const fn service(&self) -> &Identifier {
2661        &self.service
2662    }
2663
2664    /// Sets the explicitly authored readiness condition.
2665    pub fn set_condition(&mut self, condition: Sourced<ServiceDependencyCondition>) {
2666        self.condition = Some(condition);
2667    }
2668
2669    /// Returns the explicitly authored readiness condition, if any.
2670    #[must_use]
2671    pub const fn condition(&self) -> Option<&Sourced<ServiceDependencyCondition>> {
2672        self.condition.as_ref()
2673    }
2674
2675    /// Retains whether source-controlled dependency updates restart the dependent service.
2676    pub fn set_restart(&mut self, restart: Sourced<bool>) {
2677        self.restart = Some(restart);
2678    }
2679
2680    /// Returns the explicit restart-propagation choice, if any.
2681    #[must_use]
2682    pub const fn restart(&self) -> Option<&Sourced<bool>> {
2683        self.restart.as_ref()
2684    }
2685
2686    /// Retains whether absence or failure of the dependency blocks the dependent service.
2687    pub fn set_required(&mut self, required: Sourced<bool>) {
2688        self.required = Some(required);
2689    }
2690
2691    /// Returns the explicit requirement-strength choice, if any.
2692    #[must_use]
2693    pub const fn required(&self) -> Option<&Sourced<bool>> {
2694        self.required.as_ref()
2695    }
2696
2697    /// Returns the effective source requirement, including the default of `true`.
2698    #[must_use]
2699    pub fn is_required(&self) -> bool {
2700        self.required.as_ref().is_none_or(|required| *required.value())
2701    }
2702}
2703
2704/// One application service with ordered attachments and source provenance.
2705#[derive(Clone, Debug, Eq, PartialEq)]
2706pub struct Service {
2707    name: Identifier,
2708    runtime_name: Option<Sourced<ProtectedString>>,
2709    rootfs: Option<Sourced<ProtectedString>>,
2710    image: Option<Sourced<ImageReference>>,
2711    image_acquisition: Option<Sourced<Identifier>>,
2712    image_build: Option<Sourced<Identifier>>,
2713    command: Option<Sourced<Command>>,
2714    startup_notification: Option<Sourced<StartupNotification>>,
2715    entrypoint: Option<Sourced<Entrypoint>>,
2716    run_init: Option<Sourced<bool>>,
2717    stop_timeout: Option<Sourced<StopTimeout>>,
2718    pull_policy: Option<Sourced<PullPolicy>>,
2719    memory_limit: Option<Sourced<ProtectedString>>,
2720    exposed_ports: Option<Vec<Sourced<ExposedPort>>>,
2721    exposed_ports_origins: Vec<Provenance>,
2722    restart_policy: Option<Sourced<RestartPolicy>>,
2723    healthcheck: Option<Sourced<Healthcheck>>,
2724    labels: Vec<Sourced<MetadataLabel>>,
2725    annotations: Option<Vec<Sourced<Annotation>>>,
2726    annotations_origins: Vec<Provenance>,
2727    logging: Option<Sourced<Logging>>,
2728    reload_action: Option<Sourced<ReloadAction>>,
2729    user: Option<Sourced<ProtectedString>>,
2730    group: Option<Sourced<ProtectedString>>,
2731    user_namespace: Option<Sourced<ProtectedString>>,
2732    supplementary_groups: Vec<Sourced<ProtectedString>>,
2733    working_directory: Option<Sourced<ProtectedString>>,
2734    read_only_root_filesystem: Option<Sourced<bool>>,
2735    hostname: Option<Sourced<ProtectedString>>,
2736    dns_servers: Option<Vec<Sourced<ProtectedString>>>,
2737    dns_servers_origins: Vec<Provenance>,
2738    dns_options: Option<Vec<Sourced<ProtectedString>>>,
2739    dns_options_origins: Vec<Provenance>,
2740    dns_search_domains: Option<Vec<Sourced<ProtectedString>>>,
2741    dns_search_domains_origins: Vec<Provenance>,
2742    security_options: Option<Vec<Sourced<SecurityOption>>>,
2743    security_options_origins: Vec<Provenance>,
2744    pids_limit: Option<Sourced<ProtectedString>>,
2745    shm_size: Option<Sourced<ProtectedString>>,
2746    cap_add: Option<Vec<Sourced<ProtectedString>>>,
2747    cap_add_origins: Vec<Provenance>,
2748    cap_drop: Option<Vec<Sourced<ProtectedString>>>,
2749    cap_drop_origins: Vec<Provenance>,
2750    tmpfs: Option<Vec<Sourced<ProtectedString>>>,
2751    tmpfs_origins: Vec<Provenance>,
2752    sysctls: Option<Vec<Sourced<KernelParameter>>>,
2753    sysctls_origins: Vec<Provenance>,
2754    ulimits: Option<Vec<Sourced<ResourceLimit>>>,
2755    ulimits_origins: Vec<Provenance>,
2756    devices: Option<Vec<Sourced<Device>>>,
2757    devices_origins: Vec<Provenance>,
2758    stop_signal: Option<Sourced<ProtectedString>>,
2759    podman_args: Option<Vec<Sourced<ProtectedString>>>,
2760    podman_args_origins: Vec<Provenance>,
2761    environment: Vec<Sourced<EnvironmentVariable>>,
2762    environment_files: Vec<Sourced<EnvironmentFile>>,
2763    host_mappings: Vec<Sourced<HostMapping>>,
2764    ports: Vec<Sourced<Port>>,
2765    mounts: Vec<Sourced<Mount>>,
2766    config_grants: Vec<Sourced<ResourceGrant>>,
2767    secret_grants: Vec<Sourced<ResourceGrant>>,
2768    networks: Vec<Sourced<NetworkAttachment>>,
2769    dependencies: Vec<Sourced<ServiceDependency>>,
2770}
2771
2772impl Service {
2773    /// Creates an empty service shell for incremental adapter mapping.
2774    #[must_use]
2775    pub const fn new(name: Identifier) -> Self {
2776        Self {
2777            name,
2778            runtime_name: None,
2779            rootfs: None,
2780            image: None,
2781            image_acquisition: None,
2782            image_build: None,
2783            command: None,
2784            startup_notification: None,
2785            entrypoint: None,
2786            run_init: None,
2787            stop_timeout: None,
2788            pull_policy: None,
2789            memory_limit: None,
2790            exposed_ports: None,
2791            exposed_ports_origins: Vec::new(),
2792            restart_policy: None,
2793            healthcheck: None,
2794            labels: Vec::new(),
2795            annotations: None,
2796            annotations_origins: Vec::new(),
2797            logging: None,
2798            reload_action: None,
2799            user: None,
2800            group: None,
2801            user_namespace: None,
2802            supplementary_groups: Vec::new(),
2803            working_directory: None,
2804            read_only_root_filesystem: None,
2805            hostname: None,
2806            dns_servers: None,
2807            dns_servers_origins: Vec::new(),
2808            dns_options: None,
2809            dns_options_origins: Vec::new(),
2810            dns_search_domains: None,
2811            dns_search_domains_origins: Vec::new(),
2812            security_options: None,
2813            security_options_origins: Vec::new(),
2814            pids_limit: None,
2815            shm_size: None,
2816            cap_add: None,
2817            cap_add_origins: Vec::new(),
2818            cap_drop: None,
2819            cap_drop_origins: Vec::new(),
2820            tmpfs: None,
2821            tmpfs_origins: Vec::new(),
2822            sysctls: None,
2823            sysctls_origins: Vec::new(),
2824            ulimits: None,
2825            ulimits_origins: Vec::new(),
2826            devices: None,
2827            devices_origins: Vec::new(),
2828            stop_signal: None,
2829            podman_args: None,
2830            podman_args_origins: Vec::new(),
2831            environment: Vec::new(),
2832            environment_files: Vec::new(),
2833            host_mappings: Vec::new(),
2834            ports: Vec::new(),
2835            mounts: Vec::new(),
2836            config_grants: Vec::new(),
2837            secret_grants: Vec::new(),
2838            networks: Vec::new(),
2839            dependencies: Vec::new(),
2840        }
2841    }
2842
2843    /// Returns the service name.
2844    #[must_use]
2845    pub const fn name(&self) -> &Identifier {
2846        &self.name
2847    }
2848
2849    /// Sets an explicit provider/runtime-level container name distinct from the service key.
2850    pub fn set_runtime_name(&mut self, name: Sourced<ProtectedString>) {
2851        self.runtime_name = Some(name);
2852    }
2853
2854    /// Returns the explicit provider/runtime-level container name.
2855    #[must_use]
2856    pub const fn runtime_name(&self) -> Option<&Sourced<ProtectedString>> {
2857        self.runtime_name.as_ref()
2858    }
2859
2860    /// Sets a protected root-filesystem path instead of an image source.
2861    ///
2862    /// # Errors
2863    ///
2864    /// Returns [`ModelError::RootfsImageSourceConflict`] when this service already has an image,
2865    /// image acquisition, or image build reference.
2866    pub fn set_rootfs(&mut self, rootfs: Sourced<ProtectedString>) -> Result<(), ModelError> {
2867        self.ensure_rootfs_is_compatible()?;
2868        self.rootfs = Some(rootfs);
2869        Ok(())
2870    }
2871
2872    /// Returns the protected root-filesystem path, if explicitly authored.
2873    #[must_use]
2874    pub const fn rootfs(&self) -> Option<&Sourced<ProtectedString>> {
2875        self.rootfs.as_ref()
2876    }
2877
2878    /// Sets the optional image reference.
2879    pub fn set_image(&mut self, image: Sourced<ImageReference>) {
2880        self.image = Some(image);
2881    }
2882
2883    /// Returns the optional image reference.
2884    #[must_use]
2885    pub const fn image(&self) -> Option<&Sourced<ImageReference>> {
2886        self.image.as_ref()
2887    }
2888
2889    /// References a separately declared image-acquisition resource.
2890    ///
2891    /// This does not replace [`Self::image`], which remains the runtime container image reference.
2892    pub fn set_image_acquisition(&mut self, acquisition: Sourced<Identifier>) {
2893        self.image_acquisition = Some(acquisition);
2894    }
2895
2896    /// Returns the separately declared image-acquisition resource reference.
2897    #[must_use]
2898    pub const fn image_acquisition(&self) -> Option<&Sourced<Identifier>> {
2899        self.image_acquisition.as_ref()
2900    }
2901
2902    /// References a separately declared image-build resource.
2903    ///
2904    /// This does not replace [`Self::image`] or any container runtime settings.
2905    pub fn set_image_build(&mut self, build: Sourced<Identifier>) {
2906        self.image_build = Some(build);
2907    }
2908
2909    /// Returns the separately declared image-build resource reference.
2910    #[must_use]
2911    pub const fn image_build(&self) -> Option<&Sourced<Identifier>> {
2912        self.image_build.as_ref()
2913    }
2914
2915    /// Sets the command override.
2916    pub fn set_command(&mut self, command: Sourced<Command>) {
2917        self.command = Some(command);
2918    }
2919
2920    /// Returns the command override.
2921    #[must_use]
2922    pub const fn command(&self) -> Option<&Sourced<Command>> {
2923        self.command.as_ref()
2924    }
2925
2926    /// Sets the source-authored startup-notification behavior.
2927    pub fn set_startup_notification(&mut self, notification: Sourced<StartupNotification>) {
2928        self.startup_notification = Some(notification);
2929    }
2930
2931    /// Returns the explicit startup-notification behavior.
2932    #[must_use]
2933    pub const fn startup_notification(&self) -> Option<&Sourced<StartupNotification>> {
2934        self.startup_notification.as_ref()
2935    }
2936
2937    /// Sets the entrypoint override independently from the command override.
2938    pub fn set_entrypoint(&mut self, entrypoint: Sourced<Entrypoint>) {
2939        self.entrypoint = Some(entrypoint);
2940    }
2941
2942    /// Returns the optional entrypoint override.
2943    #[must_use]
2944    pub const fn entrypoint(&self) -> Option<&Sourced<Entrypoint>> {
2945        self.entrypoint.as_ref()
2946    }
2947
2948    /// Sets whether the runtime should run its init process.
2949    pub fn set_run_init(&mut self, run_init: Sourced<bool>) {
2950        self.run_init = Some(run_init);
2951    }
2952
2953    /// Returns the explicit init-process choice.
2954    #[must_use]
2955    pub const fn run_init(&self) -> Option<&Sourced<bool>> {
2956        self.run_init.as_ref()
2957    }
2958
2959    /// Sets the raw stop-grace duration.
2960    pub fn set_stop_timeout(&mut self, timeout: Sourced<StopTimeout>) {
2961        self.stop_timeout = Some(timeout);
2962    }
2963
2964    /// Returns the explicit raw stop-grace duration.
2965    #[must_use]
2966    pub const fn stop_timeout(&self) -> Option<&Sourced<StopTimeout>> {
2967        self.stop_timeout.as_ref()
2968    }
2969
2970    /// Sets the source-independent image pull intent.
2971    pub fn set_pull_policy(&mut self, policy: Sourced<PullPolicy>) {
2972        self.pull_policy = Some(policy);
2973    }
2974
2975    /// Returns the explicit image pull intent.
2976    #[must_use]
2977    pub const fn pull_policy(&self) -> Option<&Sourced<PullPolicy>> {
2978        self.pull_policy.as_ref()
2979    }
2980
2981    /// Sets the raw protected memory-limit spelling.
2982    pub fn set_memory_limit(&mut self, limit: Sourced<ProtectedString>) {
2983        self.memory_limit = Some(limit);
2984    }
2985
2986    /// Returns the raw protected memory-limit spelling.
2987    #[must_use]
2988    pub const fn memory_limit(&self) -> Option<&Sourced<ProtectedString>> {
2989        self.memory_limit.as_ref()
2990    }
2991
2992    /// Sets exposed container ports, preserving omission separately from an explicit empty list.
2993    pub fn set_exposed_ports(&mut self, ports: Vec<Sourced<ExposedPort>>) {
2994        self.exposed_ports = Some(ports);
2995        self.exposed_ports_origins.clear();
2996    }
2997
2998    /// Sets exposed container ports and collection-level provenance.
2999    pub fn set_exposed_ports_with_origins(&mut self, ports: Vec<Sourced<ExposedPort>>, origins: Vec<Provenance>) {
3000        self.exposed_ports = Some(ports);
3001        self.exposed_ports_origins = origins;
3002    }
3003
3004    /// Appends one exposed container port without publishing it to a host.
3005    pub fn add_exposed_port(&mut self, port: Sourced<ExposedPort>) {
3006        self.exposed_ports.get_or_insert_default().push(port);
3007    }
3008
3009    /// Returns exposed container ports in authored order, preserving omitted versus explicit-empty state.
3010    #[must_use]
3011    pub fn exposed_ports(&self) -> Option<&[Sourced<ExposedPort>]> {
3012        self.exposed_ports.as_deref()
3013    }
3014
3015    /// Returns collection-level exposed-port provenance.
3016    #[must_use]
3017    pub fn exposed_ports_origins(&self) -> &[Provenance] {
3018        &self.exposed_ports_origins
3019    }
3020
3021    /// Sets the container-level automatic restart policy.
3022    pub fn set_restart_policy(&mut self, restart_policy: Sourced<RestartPolicy>) {
3023        self.restart_policy = Some(restart_policy);
3024    }
3025
3026    /// Returns the container-level automatic restart policy.
3027    #[must_use]
3028    pub const fn restart_policy(&self) -> Option<&Sourced<RestartPolicy>> {
3029        self.restart_policy.as_ref()
3030    }
3031
3032    /// Sets the service health-check definition.
3033    pub fn set_healthcheck(&mut self, healthcheck: Sourced<Healthcheck>) {
3034        self.healthcheck = Some(healthcheck);
3035    }
3036
3037    /// Returns the optional service health-check definition.
3038    #[must_use]
3039    pub const fn healthcheck(&self) -> Option<&Sourced<Healthcheck>> {
3040        self.healthcheck.as_ref()
3041    }
3042
3043    /// Appends one service metadata label while preserving source order and provenance.
3044    pub fn add_label(&mut self, label: Sourced<MetadataLabel>) {
3045        self.labels.push(label);
3046    }
3047
3048    /// Returns service metadata labels in source order.
3049    #[must_use]
3050    pub fn labels(&self) -> &[Sourced<MetadataLabel>] {
3051        &self.labels
3052    }
3053
3054    /// Sets repeatable annotations while preserving omission separately from an explicit empty list.
3055    pub fn set_annotations(&mut self, annotations: Vec<Sourced<Annotation>>) {
3056        self.annotations = Some(annotations);
3057        self.annotations_origins.clear();
3058    }
3059
3060    /// Appends one annotation in source order.
3061    pub fn add_annotation(&mut self, annotation: Sourced<Annotation>) {
3062        self.annotations.get_or_insert_default().push(annotation);
3063    }
3064
3065    /// Sets repeatable annotations with collection-level provenance.
3066    pub fn set_annotations_with_origins(&mut self, annotations: Vec<Sourced<Annotation>>, origins: Vec<Provenance>) {
3067        self.annotations = Some(annotations);
3068        self.annotations_origins = origins;
3069    }
3070
3071    /// Returns annotations, preserving omitted versus explicit-empty state.
3072    #[must_use]
3073    pub fn annotations(&self) -> Option<&[Sourced<Annotation>]> {
3074        self.annotations.as_deref()
3075    }
3076
3077    /// Returns collection-level annotation provenance.
3078    #[must_use]
3079    pub fn annotations_origins(&self) -> &[Provenance] {
3080        &self.annotations_origins
3081    }
3082
3083    /// Sets provider-specific logging intent.
3084    pub fn set_logging(&mut self, logging: Sourced<Logging>) {
3085        self.logging = Some(logging);
3086    }
3087
3088    /// Returns provider-specific logging intent.
3089    #[must_use]
3090    pub const fn logging(&self) -> Option<&Sourced<Logging>> {
3091        self.logging.as_ref()
3092    }
3093
3094    /// Sets the mutually exclusive reload action.
3095    pub fn set_reload_action(&mut self, reload_action: Sourced<ReloadAction>) {
3096        self.reload_action = Some(reload_action);
3097    }
3098
3099    /// Returns the one explicit reload action, if any.
3100    #[must_use]
3101    pub const fn reload_action(&self) -> Option<&Sourced<ReloadAction>> {
3102        self.reload_action.as_ref()
3103    }
3104
3105    /// Sets the primary identity used inside the service container.
3106    pub fn set_user(&mut self, user: Sourced<ProtectedString>) {
3107        self.user = Some(user);
3108    }
3109
3110    /// Returns the primary identity used inside the service container.
3111    #[must_use]
3112    pub const fn user(&self) -> Option<&Sourced<ProtectedString>> {
3113        self.user.as_ref()
3114    }
3115
3116    /// Sets the primary group used inside the service container.
3117    pub fn set_group(&mut self, group: Sourced<ProtectedString>) {
3118        self.group = Some(group);
3119    }
3120
3121    /// Returns the primary group used inside the service container.
3122    #[must_use]
3123    pub const fn group(&self) -> Option<&Sourced<ProtectedString>> {
3124        self.group.as_ref()
3125    }
3126
3127    /// Sets the requested user-namespace mode without imposing one runtime's grammar.
3128    pub fn set_user_namespace(&mut self, user_namespace: Sourced<ProtectedString>) {
3129        self.user_namespace = Some(user_namespace);
3130    }
3131
3132    /// Returns the raw-preserving user-namespace mode.
3133    #[must_use]
3134    pub const fn user_namespace(&self) -> Option<&Sourced<ProtectedString>> {
3135        self.user_namespace.as_ref()
3136    }
3137
3138    /// Appends one supplementary group in source order.
3139    pub fn add_supplementary_group(&mut self, group: Sourced<ProtectedString>) {
3140        self.supplementary_groups.push(group);
3141    }
3142
3143    /// Returns supplementary groups in source order.
3144    #[must_use]
3145    pub fn supplementary_groups(&self) -> &[Sourced<ProtectedString>] {
3146        &self.supplementary_groups
3147    }
3148
3149    /// Sets the working directory inside the service container.
3150    pub fn set_working_directory(&mut self, working_directory: Sourced<ProtectedString>) {
3151        self.working_directory = Some(working_directory);
3152    }
3153
3154    /// Returns the working directory inside the service container.
3155    #[must_use]
3156    pub const fn working_directory(&self) -> Option<&Sourced<ProtectedString>> {
3157        self.working_directory.as_ref()
3158    }
3159
3160    /// Sets the explicit read-only root-filesystem choice.
3161    pub fn set_read_only_root_filesystem(&mut self, read_only: Sourced<bool>) {
3162        self.read_only_root_filesystem = Some(read_only);
3163    }
3164
3165    /// Returns the explicit read-only root-filesystem choice.
3166    #[must_use]
3167    pub const fn read_only_root_filesystem(&self) -> Option<&Sourced<bool>> {
3168        self.read_only_root_filesystem.as_ref()
3169    }
3170
3171    /// Sets the explicit container hostname without inferring namespace ownership.
3172    pub fn set_hostname(&mut self, hostname: Sourced<ProtectedString>) {
3173        self.hostname = Some(hostname);
3174    }
3175
3176    /// Returns the raw-preserving explicit hostname.
3177    #[must_use]
3178    pub const fn hostname(&self) -> Option<&Sourced<ProtectedString>> {
3179        self.hostname.as_ref()
3180    }
3181
3182    /// Sets ordered DNS servers, preserving omission separately from an explicit empty list.
3183    pub fn set_dns_servers_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3184        self.dns_servers = Some(values);
3185        self.dns_servers_origins = origins;
3186    }
3187
3188    /// Sets ordered DNS servers without separate collection provenance.
3189    pub fn set_dns_servers(&mut self, values: Vec<Sourced<ProtectedString>>) {
3190        self.set_dns_servers_with_origins(values, Vec::new());
3191    }
3192
3193    /// Returns ordered DNS servers when explicitly authored.
3194    #[must_use]
3195    pub fn dns_servers(&self) -> Option<&[Sourced<ProtectedString>]> {
3196        self.dns_servers.as_deref()
3197    }
3198
3199    /// Returns collection provenance for explicitly authored DNS servers.
3200    #[must_use]
3201    pub fn dns_servers_origins(&self) -> &[Provenance] {
3202        &self.dns_servers_origins
3203    }
3204
3205    /// Sets ordered DNS resolver options, preserving omission separately from an explicit empty list.
3206    pub fn set_dns_options_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3207        self.dns_options = Some(values);
3208        self.dns_options_origins = origins;
3209    }
3210
3211    /// Sets ordered DNS resolver options without separate collection provenance.
3212    pub fn set_dns_options(&mut self, values: Vec<Sourced<ProtectedString>>) {
3213        self.set_dns_options_with_origins(values, Vec::new());
3214    }
3215
3216    /// Returns ordered DNS resolver options when explicitly authored.
3217    #[must_use]
3218    pub fn dns_options(&self) -> Option<&[Sourced<ProtectedString>]> {
3219        self.dns_options.as_deref()
3220    }
3221
3222    /// Returns collection provenance for explicitly authored DNS resolver options.
3223    #[must_use]
3224    pub fn dns_options_origins(&self) -> &[Provenance] {
3225        &self.dns_options_origins
3226    }
3227
3228    /// Sets ordered DNS search domains, preserving omission separately from an explicit empty list.
3229    pub fn set_dns_search_domains_with_origins(
3230        &mut self,
3231        values: Vec<Sourced<ProtectedString>>,
3232        origins: Vec<Provenance>,
3233    ) {
3234        self.dns_search_domains = Some(values);
3235        self.dns_search_domains_origins = origins;
3236    }
3237
3238    /// Sets ordered DNS search domains without separate collection provenance.
3239    pub fn set_dns_search_domains(&mut self, values: Vec<Sourced<ProtectedString>>) {
3240        self.set_dns_search_domains_with_origins(values, Vec::new());
3241    }
3242
3243    /// Returns ordered DNS search domains when explicitly authored.
3244    #[must_use]
3245    pub fn dns_search_domains(&self) -> Option<&[Sourced<ProtectedString>]> {
3246        self.dns_search_domains.as_deref()
3247    }
3248
3249    /// Returns collection provenance for explicitly authored DNS search domains.
3250    #[must_use]
3251    pub fn dns_search_domains_origins(&self) -> &[Provenance] {
3252        &self.dns_search_domains_origins
3253    }
3254
3255    /// Sets ordered security options, preserving omission separately from an explicit empty list.
3256    pub fn set_security_options_with_origins(
3257        &mut self,
3258        values: Vec<Sourced<SecurityOption>>,
3259        origins: Vec<Provenance>,
3260    ) {
3261        self.security_options = Some(values);
3262        self.security_options_origins = origins;
3263    }
3264
3265    /// Sets ordered security options without separate collection provenance.
3266    pub fn set_security_options(&mut self, values: Vec<Sourced<SecurityOption>>) {
3267        self.set_security_options_with_origins(values, Vec::new());
3268    }
3269
3270    /// Returns ordered security options when explicitly authored.
3271    #[must_use]
3272    pub fn security_options(&self) -> Option<&[Sourced<SecurityOption>]> {
3273        self.security_options.as_deref()
3274    }
3275
3276    /// Returns collection provenance for explicitly authored security options.
3277    #[must_use]
3278    pub fn security_options_origins(&self) -> &[Provenance] {
3279        &self.security_options_origins
3280    }
3281
3282    /// Sets the raw process-ID limit spelling.
3283    pub fn set_pids_limit(&mut self, limit: Sourced<ProtectedString>) {
3284        self.pids_limit = Some(limit);
3285    }
3286
3287    /// Returns the raw process-ID limit spelling.
3288    #[must_use]
3289    pub const fn pids_limit(&self) -> Option<&Sourced<ProtectedString>> {
3290        self.pids_limit.as_ref()
3291    }
3292
3293    /// Sets the raw shared-memory size spelling.
3294    pub fn set_shm_size(&mut self, size: Sourced<ProtectedString>) {
3295        self.shm_size = Some(size);
3296    }
3297
3298    /// Returns the raw shared-memory size spelling.
3299    #[must_use]
3300    pub const fn shm_size(&self) -> Option<&Sourced<ProtectedString>> {
3301        self.shm_size.as_ref()
3302    }
3303
3304    /// Sets the complete ordered capability-add collection; `Some([])` retains an explicit reset.
3305    pub fn set_cap_add(&mut self, values: Vec<Sourced<ProtectedString>>) {
3306        self.cap_add = Some(values);
3307        self.cap_add_origins.clear();
3308    }
3309
3310    /// Sets capability additions with collection-level provenance for an explicit empty/reset value.
3311    pub fn set_cap_add_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3312        self.cap_add = Some(values);
3313        self.cap_add_origins = origins;
3314    }
3315
3316    /// Returns capability additions, preserving omitted versus explicit-empty state.
3317    #[must_use]
3318    pub fn cap_add(&self) -> Option<&[Sourced<ProtectedString>]> {
3319        self.cap_add.as_deref()
3320    }
3321
3322    /// Returns the collection-level capability-add provenance.
3323    #[must_use]
3324    pub fn cap_add_origins(&self) -> &[Provenance] {
3325        &self.cap_add_origins
3326    }
3327
3328    /// Sets the complete ordered capability-drop collection; `Some([])` retains an explicit reset.
3329    pub fn set_cap_drop(&mut self, values: Vec<Sourced<ProtectedString>>) {
3330        self.cap_drop = Some(values);
3331        self.cap_drop_origins.clear();
3332    }
3333
3334    /// Sets capability removals with collection-level provenance for an explicit empty/reset value.
3335    pub fn set_cap_drop_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3336        self.cap_drop = Some(values);
3337        self.cap_drop_origins = origins;
3338    }
3339
3340    /// Returns capability removals, preserving omitted versus explicit-empty state.
3341    #[must_use]
3342    pub fn cap_drop(&self) -> Option<&[Sourced<ProtectedString>]> {
3343        self.cap_drop.as_deref()
3344    }
3345
3346    /// Returns the collection-level capability-drop provenance.
3347    #[must_use]
3348    pub fn cap_drop_origins(&self) -> &[Provenance] {
3349        &self.cap_drop_origins
3350    }
3351
3352    /// Sets ordered raw temporary-filesystem declarations.
3353    pub fn set_tmpfs(&mut self, values: Vec<Sourced<ProtectedString>>) {
3354        self.tmpfs = Some(values);
3355        self.tmpfs_origins.clear();
3356    }
3357
3358    /// Sets temporary filesystems with collection-level provenance.
3359    pub fn set_tmpfs_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3360        self.tmpfs = Some(values);
3361        self.tmpfs_origins = origins;
3362    }
3363
3364    /// Returns temporary-filesystem declarations, preserving explicit-empty state.
3365    #[must_use]
3366    pub fn tmpfs(&self) -> Option<&[Sourced<ProtectedString>]> {
3367        self.tmpfs.as_deref()
3368    }
3369
3370    /// Returns collection-level temporary-filesystem provenance.
3371    #[must_use]
3372    pub fn tmpfs_origins(&self) -> &[Provenance] {
3373        &self.tmpfs_origins
3374    }
3375
3376    /// Sets ordered raw kernel-parameter assignments.
3377    pub fn set_sysctls(&mut self, values: Vec<Sourced<KernelParameter>>) {
3378        self.sysctls = Some(values);
3379        self.sysctls_origins.clear();
3380    }
3381
3382    /// Sets kernel parameters with collection-level provenance.
3383    pub fn set_sysctls_with_origins(&mut self, values: Vec<Sourced<KernelParameter>>, origins: Vec<Provenance>) {
3384        self.sysctls = Some(values);
3385        self.sysctls_origins = origins;
3386    }
3387
3388    /// Returns kernel-parameter assignments, preserving explicit-empty state.
3389    #[must_use]
3390    pub fn sysctls(&self) -> Option<&[Sourced<KernelParameter>]> {
3391        self.sysctls.as_deref()
3392    }
3393
3394    /// Returns collection-level kernel-parameter provenance.
3395    #[must_use]
3396    pub fn sysctls_origins(&self) -> &[Provenance] {
3397        &self.sysctls_origins
3398    }
3399
3400    /// Sets ordered resource limits.
3401    pub fn set_ulimits(&mut self, values: Vec<Sourced<ResourceLimit>>) {
3402        self.ulimits = Some(values);
3403        self.ulimits_origins.clear();
3404    }
3405
3406    /// Sets resource limits with collection-level provenance.
3407    pub fn set_ulimits_with_origins(&mut self, values: Vec<Sourced<ResourceLimit>>, origins: Vec<Provenance>) {
3408        self.ulimits = Some(values);
3409        self.ulimits_origins = origins;
3410    }
3411
3412    /// Returns resource limits, preserving explicit-empty state.
3413    #[must_use]
3414    pub fn ulimits(&self) -> Option<&[Sourced<ResourceLimit>]> {
3415        self.ulimits.as_deref()
3416    }
3417
3418    /// Returns collection-level resource-limit provenance.
3419    #[must_use]
3420    pub fn ulimits_origins(&self) -> &[Provenance] {
3421        &self.ulimits_origins
3422    }
3423
3424    /// Sets ordered short/long device declarations.
3425    pub fn set_devices(&mut self, values: Vec<Sourced<Device>>) {
3426        self.devices = Some(values);
3427        self.devices_origins.clear();
3428    }
3429
3430    /// Sets devices with collection-level provenance.
3431    pub fn set_devices_with_origins(&mut self, values: Vec<Sourced<Device>>, origins: Vec<Provenance>) {
3432        self.devices = Some(values);
3433        self.devices_origins = origins;
3434    }
3435
3436    /// Returns device declarations, preserving explicit-empty state.
3437    #[must_use]
3438    pub fn devices(&self) -> Option<&[Sourced<Device>]> {
3439        self.devices.as_deref()
3440    }
3441
3442    /// Returns collection-level device provenance.
3443    #[must_use]
3444    pub fn devices_origins(&self) -> &[Provenance] {
3445        &self.devices_origins
3446    }
3447
3448    /// Sets the explicit stop-signal spelling.
3449    pub fn set_stop_signal(&mut self, signal: Sourced<ProtectedString>) {
3450        self.stop_signal = Some(signal);
3451    }
3452
3453    /// Returns the raw explicit stop-signal spelling.
3454    #[must_use]
3455    pub const fn stop_signal(&self) -> Option<&Sourced<ProtectedString>> {
3456        self.stop_signal.as_ref()
3457    }
3458
3459    /// Sets ordered source-authored Podman arguments, retaining an explicit empty collection.
3460    pub fn set_podman_args(&mut self, values: Vec<Sourced<ProtectedString>>) {
3461        self.set_podman_args_with_origins(values, Vec::new());
3462    }
3463
3464    /// Sets ordered source-authored Podman arguments with collection-level provenance.
3465    pub fn set_podman_args_with_origins(&mut self, values: Vec<Sourced<ProtectedString>>, origins: Vec<Provenance>) {
3466        self.podman_args = Some(values);
3467        self.podman_args_origins = origins;
3468    }
3469
3470    /// Appends one protected source-authored Podman argument in source order.
3471    pub fn add_podman_arg(&mut self, value: Sourced<ProtectedString>) {
3472        self.podman_args.get_or_insert_default().push(value);
3473    }
3474
3475    /// Returns source-authored Podman arguments, preserving omitted versus explicit-empty state.
3476    #[must_use]
3477    pub fn podman_args(&self) -> Option<&[Sourced<ProtectedString>]> {
3478        self.podman_args.as_deref()
3479    }
3480
3481    /// Returns collection-level Podman-argument provenance.
3482    #[must_use]
3483    pub fn podman_args_origins(&self) -> &[Provenance] {
3484        &self.podman_args_origins
3485    }
3486
3487    /// Appends an environment entry.
3488    pub fn add_environment(&mut self, value: Sourced<EnvironmentVariable>) {
3489        self.environment.push(value);
3490    }
3491
3492    /// Returns environment entries in authored order.
3493    #[must_use]
3494    pub fn environment(&self) -> &[Sourced<EnvironmentVariable>] {
3495        &self.environment
3496    }
3497
3498    /// Appends an environment-file declaration without reading the referenced file.
3499    pub fn add_environment_file(&mut self, value: Sourced<EnvironmentFile>) {
3500        self.environment_files.push(value);
3501    }
3502
3503    /// Returns environment-file declarations in authored order.
3504    #[must_use]
3505    pub fn environment_files(&self) -> &[Sourced<EnvironmentFile>] {
3506        &self.environment_files
3507    }
3508
3509    /// Appends an explicit hostname-to-address mapping.
3510    pub fn add_host_mapping(&mut self, value: Sourced<HostMapping>) {
3511        self.host_mappings.push(value);
3512    }
3513
3514    /// Returns explicit host mappings in authored order.
3515    #[must_use]
3516    pub fn host_mappings(&self) -> &[Sourced<HostMapping>] {
3517        &self.host_mappings
3518    }
3519
3520    /// Appends a port.
3521    pub fn add_port(&mut self, value: Sourced<Port>) {
3522        self.ports.push(value);
3523    }
3524
3525    /// Returns ports in authored order.
3526    #[must_use]
3527    pub fn ports(&self) -> &[Sourced<Port>] {
3528        &self.ports
3529    }
3530
3531    /// Appends a storage attachment.
3532    pub fn add_mount(&mut self, value: Sourced<Mount>) {
3533        self.mounts.push(value);
3534    }
3535
3536    /// Returns storage attachments in authored order.
3537    #[must_use]
3538    pub fn mounts(&self) -> &[Sourced<Mount>] {
3539        &self.mounts
3540    }
3541
3542    /// Appends a configuration grant in source order.
3543    pub fn add_config_grant(&mut self, value: Sourced<ResourceGrant>) {
3544        self.config_grants.push(value);
3545    }
3546
3547    /// Returns configuration grants in source order.
3548    #[must_use]
3549    pub fn config_grants(&self) -> &[Sourced<ResourceGrant>] {
3550        &self.config_grants
3551    }
3552
3553    /// Appends a secret grant in source order.
3554    pub fn add_secret_grant(&mut self, value: Sourced<ResourceGrant>) {
3555        self.secret_grants.push(value);
3556    }
3557
3558    /// Returns secret grants in source order.
3559    #[must_use]
3560    pub fn secret_grants(&self) -> &[Sourced<ResourceGrant>] {
3561        &self.secret_grants
3562    }
3563
3564    /// Appends a network attachment.
3565    pub fn add_network(&mut self, value: Sourced<NetworkAttachment>) {
3566        self.networks.push(value);
3567    }
3568
3569    /// Replaces one existing network attachment without changing authored order.
3570    ///
3571    /// Returns the previous attachment. This narrow mutation boundary lets importers enrich an
3572    /// attachment only after later native entries establish attachment-scoped details.
3573    ///
3574    /// # Errors
3575    ///
3576    /// Returns [`ModelError::UnknownNetworkAttachmentIndex`] when `index` is outside the ordered
3577    /// attachment collection.
3578    pub fn replace_network(
3579        &mut self,
3580        index: usize,
3581        value: Sourced<NetworkAttachment>,
3582    ) -> Result<Sourced<NetworkAttachment>, ModelError> {
3583        let len = self.networks.len();
3584        let Some(slot) = self.networks.get_mut(index) else {
3585            return Err(ModelError::UnknownNetworkAttachmentIndex { index, len });
3586        };
3587        Ok(std::mem::replace(slot, value))
3588    }
3589
3590    /// Returns network attachments in authored order.
3591    #[must_use]
3592    pub fn networks(&self) -> &[Sourced<NetworkAttachment>] {
3593        &self.networks
3594    }
3595
3596    /// Appends a service dependency in source order.
3597    pub fn add_dependency(&mut self, value: Sourced<ServiceDependency>) {
3598        self.dependencies.push(value);
3599    }
3600
3601    /// Returns service dependencies in source order.
3602    #[must_use]
3603    pub fn dependencies(&self) -> &[Sourced<ServiceDependency>] {
3604        &self.dependencies
3605    }
3606
3607    /// Validates that a root filesystem was not combined with any image source.
3608    ///
3609    /// This is public so adapters that incrementally map services can surface an invalid native
3610    /// combination before inserting it into an [`Application`].
3611    ///
3612    /// # Errors
3613    ///
3614    /// Returns [`ModelError::RootfsImageSourceConflict`] when both forms are present.
3615    pub fn validate_image_source_exclusivity(&self) -> Result<(), ModelError> {
3616        if self.rootfs.is_some() {
3617            self.ensure_rootfs_is_compatible()?;
3618        }
3619        Ok(())
3620    }
3621
3622    fn ensure_rootfs_is_compatible(&self) -> Result<(), ModelError> {
3623        let source = if self.image.is_some() {
3624            Some("image")
3625        } else if self.image_acquisition.is_some() {
3626            Some("image acquisition")
3627        } else if self.image_build.is_some() {
3628            Some("image build")
3629        } else {
3630            None
3631        };
3632        if let Some(source) = source {
3633            return Err(ModelError::RootfsImageSourceConflict {
3634                service: self.name.as_str().to_owned(),
3635                source,
3636            });
3637        }
3638        Ok(())
3639    }
3640}
3641
3642/// One ordered multi-service application graph.
3643#[derive(Clone, Debug, Eq, PartialEq)]
3644pub struct Application {
3645    name: Identifier,
3646    image_acquisitions: Vec<Sourced<ImageAcquisition>>,
3647    image_builds: Vec<Sourced<ImageBuild>>,
3648    services: Vec<Sourced<Service>>,
3649    service_groups: Vec<Sourced<ServiceGroup>>,
3650    volumes: Vec<Sourced<Volume>>,
3651    networks: Vec<Sourced<Network>>,
3652    configs: Vec<Sourced<Config>>,
3653    secrets: Vec<Sourced<Secret>>,
3654}
3655
3656impl Application {
3657    /// Creates an empty application.
3658    #[must_use]
3659    pub const fn new(name: Identifier) -> Self {
3660        Self {
3661            name,
3662            image_acquisitions: Vec::new(),
3663            image_builds: Vec::new(),
3664            services: Vec::new(),
3665            service_groups: Vec::new(),
3666            volumes: Vec::new(),
3667            networks: Vec::new(),
3668            configs: Vec::new(),
3669            secrets: Vec::new(),
3670        }
3671    }
3672
3673    /// Returns the application name.
3674    #[must_use]
3675    pub const fn name(&self) -> &Identifier {
3676        &self.name
3677    }
3678
3679    /// Adds a uniquely named image-acquisition resource while preserving declaration order.
3680    ///
3681    /// # Errors
3682    ///
3683    /// Returns [`ModelError::DuplicateResource`] for a duplicate acquisition name.
3684    pub fn add_image_acquisition(&mut self, acquisition: Sourced<ImageAcquisition>) -> Result<(), ModelError> {
3685        ensure_unique(
3686            "image acquisition",
3687            acquisition.value().name(),
3688            self.image_acquisitions.iter().map(|candidate| candidate.value().name()),
3689        )?;
3690        self.image_acquisitions.push(acquisition);
3691        Ok(())
3692    }
3693
3694    /// Returns image-acquisition resources in declaration order.
3695    #[must_use]
3696    pub fn image_acquisitions(&self) -> &[Sourced<ImageAcquisition>] {
3697        &self.image_acquisitions
3698    }
3699
3700    /// Adds a uniquely named image-build resource while preserving declaration order.
3701    ///
3702    /// # Errors
3703    ///
3704    /// Returns [`ModelError::DuplicateResource`] for a duplicate build name.
3705    pub fn add_image_build(&mut self, build: Sourced<ImageBuild>) -> Result<(), ModelError> {
3706        ensure_unique(
3707            "image build",
3708            build.value().name(),
3709            self.image_builds.iter().map(|candidate| candidate.value().name()),
3710        )?;
3711        self.image_builds.push(build);
3712        Ok(())
3713    }
3714
3715    /// Returns image-build resources in declaration order.
3716    #[must_use]
3717    pub fn image_builds(&self) -> &[Sourced<ImageBuild>] {
3718        &self.image_builds
3719    }
3720
3721    /// Validates every typed image-artifact reference after the complete graph is assembled.
3722    ///
3723    /// Unlike incremental insertion, this validation does not make a source document's
3724    /// declaration order significant. Adapters that receive forward references should add their
3725    /// resources first and invoke this method before treating the application as convertible.
3726    ///
3727    /// # Errors
3728    ///
3729    /// Returns the matching unknown-reference error for a service or image-backed volume.
3730    pub fn validate_image_artifact_references(&self) -> Result<(), ModelError> {
3731        for service in &self.services {
3732            if let Some(acquisition) = service.value().image_acquisition() {
3733                if !self.contains_image_acquisition(acquisition.value()) {
3734                    return Err(ModelError::UnknownImageAcquisitionReference {
3735                        service: service.value().name().as_str().to_owned(),
3736                        acquisition: acquisition.value().as_str().to_owned(),
3737                    });
3738                }
3739            }
3740            if let Some(build) = service.value().image_build() {
3741                if !self.contains_image_build(build.value()) {
3742                    return Err(ModelError::UnknownImageBuildReference {
3743                        service: service.value().name().as_str().to_owned(),
3744                        build: build.value().as_str().to_owned(),
3745                    });
3746                }
3747            }
3748        }
3749        for volume in &self.volumes {
3750            let Some(source) = volume.value().image_source() else {
3751                continue;
3752            };
3753            match source.value() {
3754                VolumeImageSource::Literal(_) => {}
3755                VolumeImageSource::ImageAcquisition(acquisition) => {
3756                    if !self.contains_image_acquisition(acquisition) {
3757                        return Err(ModelError::UnknownVolumeImageAcquisitionReference {
3758                            volume: volume.value().name().as_str().to_owned(),
3759                            acquisition: acquisition.as_str().to_owned(),
3760                        });
3761                    }
3762                }
3763                VolumeImageSource::ImageBuild(build) => {
3764                    if !self.contains_image_build(build) {
3765                        return Err(ModelError::UnknownVolumeImageBuildReference {
3766                            volume: volume.value().name().as_str().to_owned(),
3767                            build: build.as_str().to_owned(),
3768                        });
3769                    }
3770                }
3771            }
3772        }
3773        Ok(())
3774    }
3775
3776    /// Validates explicit format-neutral artifact edges for missing nodes and cycles.
3777    ///
3778    /// The supplied edges must already be typed by a source adapter. In particular, `BoxFerry` does
3779    /// not parse native raw argument, mount, or unit-name text to infer dependencies. Duplicate
3780    /// edges are ignored deterministically, and input order does not affect validation.
3781    ///
3782    /// # Errors
3783    ///
3784    /// Returns a missing-reference error, [`ModelError::UnknownArtifactDependencyNode`], or
3785    /// [`ModelError::ImageArtifactDependencyCycle`].
3786    pub fn validate_image_artifact_dependencies(
3787        &self,
3788        dependencies: &[Sourced<ArtifactDependency>],
3789    ) -> Result<(), ModelError> {
3790        self.validate_image_artifact_references()?;
3791
3792        let mut graph = BTreeMap::<ArtifactDependencyNode, BTreeSet<ArtifactDependencyNode>>::new();
3793        for dependency in dependencies {
3794            let source = dependency.value().source().value();
3795            let target = dependency.value().target().value();
3796            self.validate_artifact_dependency_node(source)?;
3797            self.validate_artifact_dependency_node(target)?;
3798            graph.entry(source.clone()).or_default().insert(target.clone());
3799            graph.entry(target.clone()).or_default();
3800        }
3801
3802        let mut state = BTreeMap::<ArtifactDependencyNode, VisitState>::new();
3803        let mut path = Vec::new();
3804        for node in graph.keys() {
3805            if state.get(node).is_some_and(|state| *state == VisitState::Finished) {
3806                continue;
3807            }
3808            if let Some(cycle) = detect_artifact_cycle(node, &graph, &mut state, &mut path) {
3809                return Err(ModelError::ImageArtifactDependencyCycle {
3810                    nodes: cycle.into_iter().map(|node| node.display_name()).collect(),
3811                });
3812            }
3813        }
3814        Ok(())
3815    }
3816
3817    fn contains_image_acquisition(&self, name: &Identifier) -> bool {
3818        self.image_acquisitions
3819            .iter()
3820            .any(|candidate| candidate.value().name() == name)
3821    }
3822
3823    fn contains_image_build(&self, name: &Identifier) -> bool {
3824        self.image_builds
3825            .iter()
3826            .any(|candidate| candidate.value().name() == name)
3827    }
3828
3829    fn validate_artifact_dependency_node(&self, node: &ArtifactDependencyNode) -> Result<(), ModelError> {
3830        let (kind, name) = node.kind_and_name();
3831        let exists = match node {
3832            ArtifactDependencyNode::Volume(_) => self.volumes.iter().any(|volume| volume.value().name() == name),
3833            ArtifactDependencyNode::ImageAcquisition(_) => self.contains_image_acquisition(name),
3834            ArtifactDependencyNode::ImageBuild(_) => self.contains_image_build(name),
3835        };
3836        if exists {
3837            Ok(())
3838        } else {
3839            Err(ModelError::UnknownArtifactDependencyNode {
3840                kind,
3841                name: name.as_str().to_owned(),
3842            })
3843        }
3844    }
3845
3846    /// Adds a uniquely named service while preserving declaration order.
3847    ///
3848    /// # Errors
3849    ///
3850    /// Returns [`ModelError::DuplicateResource`] for a duplicate service name,
3851    /// [`ModelError::UnknownImageAcquisitionReference`], or
3852    /// [`ModelError::UnknownImageBuildReference`] for an unresolved artifact reference.
3853    pub fn add_service(&mut self, service: Sourced<Service>) -> Result<(), ModelError> {
3854        ensure_unique(
3855            "service",
3856            service.value().name(),
3857            self.services.iter().map(|candidate| candidate.value().name()),
3858        )?;
3859        service.value().validate_image_source_exclusivity()?;
3860        if let Some(acquisition) = service.value().image_acquisition() {
3861            if !self
3862                .image_acquisitions
3863                .iter()
3864                .any(|candidate| candidate.value().name() == acquisition.value())
3865            {
3866                return Err(ModelError::UnknownImageAcquisitionReference {
3867                    service: service.value().name().as_str().to_owned(),
3868                    acquisition: acquisition.value().as_str().to_owned(),
3869                });
3870            }
3871        }
3872        if let Some(build) = service.value().image_build() {
3873            if !self
3874                .image_builds
3875                .iter()
3876                .any(|candidate| candidate.value().name() == build.value())
3877            {
3878                return Err(ModelError::UnknownImageBuildReference {
3879                    service: service.value().name().as_str().to_owned(),
3880                    build: build.value().as_str().to_owned(),
3881                });
3882            }
3883        }
3884        self.services.push(service);
3885        Ok(())
3886    }
3887
3888    /// Returns services in declaration order.
3889    #[must_use]
3890    pub fn services(&self) -> &[Sourced<Service>] {
3891        &self.services
3892    }
3893
3894    /// Adds a uniquely named structural service group.
3895    ///
3896    /// Every referenced service must already exist in the application, and one service may belong
3897    /// to at most one group.
3898    ///
3899    /// # Errors
3900    ///
3901    /// Returns [`ModelError::DuplicateResource`], [`ModelError::UnknownServiceGroupMember`], or
3902    /// [`ModelError::ServiceInMultipleGroups`] when a relationship is ambiguous.
3903    pub fn add_service_group(&mut self, group: Sourced<ServiceGroup>) -> Result<(), ModelError> {
3904        ensure_unique(
3905            "service group",
3906            group.value().name(),
3907            self.service_groups.iter().map(|candidate| candidate.value().name()),
3908        )?;
3909        for member in group.value().members() {
3910            if !self
3911                .services
3912                .iter()
3913                .any(|service| service.value().name() == member.value())
3914            {
3915                return Err(ModelError::UnknownServiceGroupMember {
3916                    group: group.value().name().as_str().to_owned(),
3917                    service: member.value().as_str().to_owned(),
3918                });
3919            }
3920            if let Some(existing) = self.service_groups.iter().find(|candidate| {
3921                candidate
3922                    .value()
3923                    .members()
3924                    .iter()
3925                    .any(|candidate_member| candidate_member.value() == member.value())
3926            }) {
3927                return Err(ModelError::ServiceInMultipleGroups {
3928                    service: member.value().as_str().to_owned(),
3929                    existing: existing.value().name().as_str().to_owned(),
3930                    replacement: group.value().name().as_str().to_owned(),
3931                });
3932            }
3933        }
3934        self.service_groups.push(group);
3935        Ok(())
3936    }
3937
3938    /// Returns structural service groups in source order.
3939    #[must_use]
3940    pub fn service_groups(&self) -> &[Sourced<ServiceGroup>] {
3941        &self.service_groups
3942    }
3943
3944    /// Adds a uniquely named volume while preserving declaration order.
3945    ///
3946    /// # Errors
3947    ///
3948    /// Returns [`ModelError::DuplicateResource`] for a duplicate volume name.
3949    pub fn add_volume(&mut self, volume: Sourced<Volume>) -> Result<(), ModelError> {
3950        ensure_unique(
3951            "volume",
3952            volume.value().name(),
3953            self.volumes.iter().map(|candidate| candidate.value().name()),
3954        )?;
3955        self.volumes.push(volume);
3956        Ok(())
3957    }
3958
3959    /// Returns volumes in declaration order.
3960    #[must_use]
3961    pub fn volumes(&self) -> &[Sourced<Volume>] {
3962        &self.volumes
3963    }
3964
3965    /// Adds a uniquely named network while preserving declaration order.
3966    ///
3967    /// # Errors
3968    ///
3969    /// Returns [`ModelError::DuplicateResource`] for a duplicate network name.
3970    pub fn add_network(&mut self, network: Sourced<Network>) -> Result<(), ModelError> {
3971        ensure_unique(
3972            "network",
3973            network.value().name(),
3974            self.networks.iter().map(|candidate| candidate.value().name()),
3975        )?;
3976        self.networks.push(network);
3977        Ok(())
3978    }
3979
3980    /// Returns networks in declaration order.
3981    #[must_use]
3982    pub fn networks(&self) -> &[Sourced<Network>] {
3983        &self.networks
3984    }
3985
3986    /// Adds a uniquely named configuration while preserving declaration order.
3987    ///
3988    /// # Errors
3989    ///
3990    /// Returns [`ModelError::DuplicateResource`] for a duplicate configuration name.
3991    pub fn add_config(&mut self, config: Sourced<Config>) -> Result<(), ModelError> {
3992        ensure_unique(
3993            "config",
3994            config.value().name(),
3995            self.configs.iter().map(|candidate| candidate.value().name()),
3996        )?;
3997        self.configs.push(config);
3998        Ok(())
3999    }
4000
4001    /// Returns configuration resources in declaration order.
4002    #[must_use]
4003    pub fn configs(&self) -> &[Sourced<Config>] {
4004        &self.configs
4005    }
4006
4007    /// Adds a uniquely named secret while preserving declaration order.
4008    ///
4009    /// # Errors
4010    ///
4011    /// Returns [`ModelError::DuplicateResource`] for a duplicate secret name.
4012    pub fn add_secret(&mut self, secret: Sourced<Secret>) -> Result<(), ModelError> {
4013        ensure_unique(
4014            "secret",
4015            secret.value().name(),
4016            self.secrets.iter().map(|candidate| candidate.value().name()),
4017        )?;
4018        self.secrets.push(secret);
4019        Ok(())
4020    }
4021
4022    /// Returns secret resources in declaration order.
4023    #[must_use]
4024    pub fn secrets(&self) -> &[Sourced<Secret>] {
4025        &self.secrets
4026    }
4027}
4028
4029#[derive(Clone, Copy, Eq, PartialEq)]
4030enum VisitState {
4031    Visiting,
4032    Finished,
4033}
4034
4035fn detect_artifact_cycle(
4036    node: &ArtifactDependencyNode,
4037    graph: &BTreeMap<ArtifactDependencyNode, BTreeSet<ArtifactDependencyNode>>,
4038    state: &mut BTreeMap<ArtifactDependencyNode, VisitState>,
4039    path: &mut Vec<ArtifactDependencyNode>,
4040) -> Option<Vec<ArtifactDependencyNode>> {
4041    if state.get(node).is_some_and(|state| *state == VisitState::Visiting) {
4042        let index = path.iter().position(|candidate| candidate == node)?;
4043        let mut cycle = path[index..].to_vec();
4044        cycle.push(node.clone());
4045        return Some(cycle);
4046    }
4047    if state.get(node).is_some_and(|state| *state == VisitState::Finished) {
4048        return None;
4049    }
4050
4051    state.insert(node.clone(), VisitState::Visiting);
4052    path.push(node.clone());
4053    if let Some(targets) = graph.get(node) {
4054        for target in targets {
4055            if let Some(cycle) = detect_artifact_cycle(target, graph, state, path) {
4056                return Some(cycle);
4057            }
4058        }
4059    }
4060    path.pop();
4061    state.insert(node.clone(), VisitState::Finished);
4062    None
4063}
4064
4065fn ensure_unique<'a>(
4066    kind: &'static str,
4067    name: &Identifier,
4068    existing: impl Iterator<Item = &'a Identifier>,
4069) -> Result<(), ModelError> {
4070    if existing.into_iter().any(|candidate| candidate == name) {
4071        return Err(ModelError::DuplicateResource {
4072            kind,
4073            name: name.as_str().to_owned(),
4074        });
4075    }
4076    Ok(())
4077}
4078
4079fn validate_text(kind: &'static str, value: &str) -> Result<(), ModelError> {
4080    if value.is_empty() {
4081        return Err(ModelError::EmptyValue(kind));
4082    }
4083    validate_no_nul(kind, value)
4084}
4085
4086fn validate_no_nul(kind: &'static str, value: &str) -> Result<(), ModelError> {
4087    if value.contains('\0') {
4088        return Err(ModelError::ContainsNul(kind));
4089    }
4090    Ok(())
4091}
4092
4093#[cfg(test)]
4094mod tests {
4095    use super::{
4096        Annotation, Application, ArtifactDependency, ArtifactDependencyNode, Command, Config, ConfigMaterial, Device,
4097        Entrypoint, EnvironmentFile, EnvironmentFileFormat, EnvironmentFileSyntax, ExposedPort, GroupExitPolicy,
4098        HealthcheckDuration, HealthcheckRetries, HostAddress, HostAddressKind, HostMapping, Identifier,
4099        KernelParameter, Logging, LoggingOption, MetadataLabel, ModelError, Mount, MountSource, Network,
4100        NetworkAttachment, NetworkDriverOption, NetworkIpamConfig, Protocol, PullPolicy, ReloadAction, ResourceGrant,
4101        ResourceGrantSyntax, ResourceLimit, ResourceOwnership, RestartPolicy, Secret, SecretMaterial, SecurityOption,
4102        Service, ServiceDependency, ServiceDependencyCondition, ServiceGroup, ServiceGroupRuntime, StartupNotification,
4103        StopTimeout, Volume, VolumeImageSource,
4104    };
4105    use crate::{ImageAcquisition, ImageBuild, ImageReference, ProtectedString, Sourced};
4106
4107    #[test]
4108    fn preserves_service_order_and_rejects_duplicate_names() -> Result<(), String> {
4109        let mut application = Application::new(id("example")?);
4110        application
4111            .add_service(Sourced::generated(Service::new(id("web")?)))
4112            .map_err(|error| error.to_string())?;
4113        application
4114            .add_service(Sourced::generated(Service::new(id("database")?)))
4115            .map_err(|error| error.to_string())?;
4116
4117        let names: Vec<_> = application
4118            .services()
4119            .iter()
4120            .map(|service| service.value().name().as_str())
4121            .collect();
4122        assert_eq!(names, ["web", "database"]);
4123
4124        let duplicate = application.add_service(Sourced::generated(Service::new(id("web")?)));
4125        assert!(matches!(duplicate, Err(ModelError::DuplicateResource { .. })));
4126        Ok(())
4127    }
4128
4129    #[test]
4130    fn keeps_the_service_key_and_explicit_runtime_name_distinct() -> Result<(), String> {
4131        let mut service = Service::new(id("web")?);
4132        service.set_runtime_name(Sourced::generated(ProtectedString::plain("production-web")));
4133
4134        assert_eq!(service.name().as_str(), "web");
4135        assert_eq!(
4136            service.runtime_name().map(|name| name.value().expose()),
4137            Some("production-web")
4138        );
4139        Ok(())
4140    }
4141
4142    #[test]
4143    fn network_keeps_logical_and_runtime_names_and_literal_flags_distinct() -> Result<(), String> {
4144        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4145        let origin = crate::Provenance::source(source);
4146        let mut network = Network::new(id("frontend")?, ResourceOwnership::Application);
4147        network.set_runtime_name(Sourced::from_source(
4148            ProtectedString::plain("production-frontend"),
4149            origin.clone(),
4150        ));
4151        network.set_driver(Sourced::from_source(ProtectedString::plain("bridge"), origin.clone()));
4152        network.set_internal(Sourced::from_source(true, origin.clone()));
4153        network.set_ipv6(Sourced::from_source(false, origin.clone()));
4154        network.set_ipam_driver(Sourced::from_source(ProtectedString::plain("default"), origin));
4155
4156        assert_eq!(network.name().as_str(), "frontend");
4157        assert_eq!(
4158            network.runtime_name().map(|value| value.value().expose()),
4159            Some("production-frontend")
4160        );
4161        assert_eq!(network.driver().map(|value| value.value().expose()), Some("bridge"));
4162        assert_eq!(network.internal().map(Sourced::value), Some(&true));
4163        assert_eq!(network.ipv6().map(Sourced::value), Some(&false));
4164        assert_eq!(
4165            network.ipam_driver().map(|value| value.value().expose()),
4166            Some("default")
4167        );
4168        Ok(())
4169    }
4170
4171    #[test]
4172    fn network_collections_retain_resets_provenance_and_redact_protected_values() -> Result<(), String> {
4173        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4174        let origin = crate::Provenance::source(source);
4175        let mut network = Network::new(id("frontend")?, ResourceOwnership::Application);
4176        let option = NetworkDriverOption::new(
4177            Sourced::from_source(id("com.example.token")?, origin.clone()),
4178            Sourced::from_source(ProtectedString::sensitive("never-print-this"), origin.clone()),
4179        )
4180        .map_err(|error| error.to_string())?;
4181        let label = MetadataLabel::new(id("com.example.label")?, ProtectedString::sensitive("also-private"));
4182
4183        network
4184            .set_driver_options_with_origins(vec![Sourced::from_source(option, origin.clone())], vec![origin.clone()]);
4185        network.set_labels_with_origins(vec![Sourced::from_source(label, origin.clone())], vec![origin.clone()]);
4186        network.set_ipam_configs_with_origins(Vec::new(), vec![origin]);
4187
4188        assert_eq!(network.driver_options().map(<[_]>::len), Some(1));
4189        assert_eq!(network.labels().map(<[_]>::len), Some(1));
4190        assert_eq!(network.ipam_configs().map(<[_]>::len), Some(0));
4191        assert_eq!(network.driver_options_origins().len(), 1);
4192        assert_eq!(network.labels_origins().len(), 1);
4193        assert_eq!(network.ipam_configs_origins().len(), 1);
4194        let debug = format!("{network:?}");
4195        assert!(!debug.contains("never-print-this"));
4196        assert!(!debug.contains("also-private"));
4197        assert!(debug.contains("[REDACTED]"));
4198
4199        network.set_driver_options(Vec::new());
4200        network.set_labels(Vec::new());
4201        network.set_ipam_configs(Vec::new());
4202        assert_eq!(network.driver_options().map(<[_]>::len), Some(0));
4203        assert_eq!(network.labels().map(<[_]>::len), Some(0));
4204        assert_eq!(network.ipam_configs().map(<[_]>::len), Some(0));
4205        assert!(network.driver_options_origins().is_empty());
4206        assert!(network.labels_origins().is_empty());
4207        assert!(network.ipam_configs_origins().is_empty());
4208        Ok(())
4209    }
4210
4211    #[test]
4212    fn network_ipam_rows_preserve_association_order_and_reject_subnetless_values() -> Result<(), String> {
4213        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4214        let origin = crate::Provenance::source(source);
4215        let mut first = NetworkIpamConfig::new(Sourced::from_source(
4216            ProtectedString::plain("10.10.0.0/24"),
4217            origin.clone(),
4218        ))
4219        .map_err(|error| error.to_string())?;
4220        first
4221            .set_gateway(Sourced::from_source(
4222                ProtectedString::plain("10.10.0.1"),
4223                origin.clone(),
4224            ))
4225            .map_err(|error| error.to_string())?;
4226        let mut second = NetworkIpamConfig::new(Sourced::from_source(
4227            ProtectedString::plain("fd00:10::/64"),
4228            origin.clone(),
4229        ))
4230        .map_err(|error| error.to_string())?;
4231        second
4232            .set_ip_range(Sourced::from_source(
4233                ProtectedString::plain("fd00:10::100/120"),
4234                origin.clone(),
4235            ))
4236            .map_err(|error| error.to_string())?;
4237
4238        let mut network = Network::new(id("frontend")?, ResourceOwnership::Application);
4239        network.set_ipam_configs_with_origins(
4240            vec![
4241                Sourced::from_source(first, origin.clone()),
4242                Sourced::from_source(second, origin),
4243            ],
4244            Vec::new(),
4245        );
4246        let rows = network
4247            .ipam_configs()
4248            .ok_or_else(|| "IPAM configs were omitted".to_owned())?;
4249        assert_eq!(rows.len(), 2);
4250        assert_eq!(rows[0].value().subnet().value().expose(), "10.10.0.0/24");
4251        assert_eq!(
4252            rows[0].value().gateway().map(|value| value.value().expose()),
4253            Some("10.10.0.1")
4254        );
4255        assert_eq!(rows[0].value().ip_range(), None);
4256        assert_eq!(rows[1].value().subnet().value().expose(), "fd00:10::/64");
4257        assert_eq!(rows[1].value().gateway(), None);
4258        assert_eq!(
4259            rows[1].value().ip_range().map(|value| value.value().expose()),
4260            Some("fd00:10::100/120")
4261        );
4262
4263        assert!(matches!(
4264            NetworkIpamConfig::new(Sourced::generated(ProtectedString::plain(""))),
4265            Err(ModelError::EmptyValue("network IPAM subnet"))
4266        ));
4267        assert!(matches!(
4268            NetworkIpamConfig::new(Sourced::generated(ProtectedString::plain("10.0.0.0/24\0bad"))),
4269            Err(ModelError::ContainsNul("network IPAM subnet"))
4270        ));
4271        assert!(matches!(
4272            NetworkDriverOption::new(
4273                Sourced::generated(id("option")?),
4274                Sourced::generated(ProtectedString::plain("bad\0value")),
4275            ),
4276            Err(ModelError::ContainsNul("network driver option value"))
4277        ));
4278        Ok(())
4279    }
4280
4281    #[test]
4282    fn image_artifact_resources_are_ordered_unique_and_referenced_explicitly() -> Result<(), String> {
4283        let mut application = Application::new(id("example")?);
4284        application
4285            .add_image_acquisition(Sourced::generated(ImageAcquisition::new(id("base-image")?)))
4286            .map_err(|error| error.to_string())?;
4287        application
4288            .add_image_build(Sourced::generated(ImageBuild::new(id("web-build")?)))
4289            .map_err(|error| error.to_string())?;
4290
4291        let mut web = Service::new(id("web")?);
4292        web.set_image_acquisition(Sourced::generated(id("base-image")?));
4293        web.set_image_build(Sourced::generated(id("web-build")?));
4294        application
4295            .add_service(Sourced::generated(web))
4296            .map_err(|error| error.to_string())?;
4297
4298        assert_eq!(
4299            application.image_acquisitions()[0].value().name().as_str(),
4300            "base-image"
4301        );
4302        assert_eq!(application.image_builds()[0].value().name().as_str(), "web-build");
4303        assert!(matches!(
4304            application.add_image_build(Sourced::generated(ImageBuild::new(id("web-build")?))),
4305            Err(ModelError::DuplicateResource {
4306                kind: "image build",
4307                ..
4308            })
4309        ));
4310
4311        let mut missing = Service::new(id("missing")?);
4312        missing.set_image_build(Sourced::generated(id("absent-build")?));
4313        assert!(matches!(
4314            application.add_service(Sourced::generated(missing)),
4315            Err(ModelError::UnknownImageBuildReference { .. })
4316        ));
4317        Ok(())
4318    }
4319
4320    #[test]
4321    fn volume_keeps_logical_runtime_and_service_names_and_local_fields_distinct() -> Result<(), String> {
4322        let origin = crate::Provenance::source(crate::SourceId::new("data.volume").map_err(|error| error.to_string())?);
4323        let mut volume = Volume::new(id("data")?, ResourceOwnership::Application);
4324        volume.set_runtime_name(Sourced::from_source(
4325            ProtectedString::plain("production-data"),
4326            origin.clone(),
4327        ));
4328        volume.set_service_name(Sourced::from_source(
4329            ProtectedString::plain("data-volume.service"),
4330            origin.clone(),
4331        ));
4332        volume.set_driver(Sourced::from_source(ProtectedString::plain("local"), origin.clone()));
4333        volume.set_device(Sourced::from_source(
4334            ProtectedString::plain("/srv/data"),
4335            origin.clone(),
4336        ));
4337        volume.set_volume_type(Sourced::from_source(ProtectedString::plain("none"), origin.clone()));
4338        volume.set_options(Sourced::from_source(ProtectedString::plain("bind"), origin.clone()));
4339
4340        assert_eq!(volume.name().as_str(), "data");
4341        assert_eq!(
4342            volume.runtime_name().map(|name| name.value().expose()),
4343            Some("production-data")
4344        );
4345        assert_eq!(
4346            volume.service_name().map(|name| name.value().expose()),
4347            Some("data-volume.service")
4348        );
4349        assert_eq!(volume.driver().map(|value| value.value().expose()), Some("local"));
4350        assert_eq!(volume.device().map(|value| value.value().expose()), Some("/srv/data"));
4351        assert_eq!(volume.volume_type().map(|value| value.value().expose()), Some("none"));
4352        assert_eq!(volume.options().map(|value| value.value().expose()), Some("bind"));
4353        assert_eq!(
4354            volume.options().map(Sourced::origins),
4355            Some(std::slice::from_ref(&origin))
4356        );
4357        Ok(())
4358    }
4359
4360    #[test]
4361    fn volume_preserves_resets_order_protected_values_and_identity_dimensions() -> Result<(), String> {
4362        let origin = crate::Provenance::source(crate::SourceId::new("data.volume").map_err(|error| error.to_string())?);
4363        let mut volume = Volume::new(id("data")?, ResourceOwnership::Application);
4364        assert!(volume.labels().is_none());
4365        assert!(volume.containers_conf_modules().is_none());
4366        assert!(volume.global_args().is_none());
4367        assert!(volume.podman_args().is_none());
4368        volume.set_labels_with_origins(Vec::new(), vec![origin.clone()]);
4369        volume.set_containers_conf_modules_with_origins(Vec::new(), vec![origin.clone()]);
4370        volume.set_global_args_with_origins(
4371            vec![
4372                Sourced::from_source(ProtectedString::plain("--first"), origin.clone()),
4373                Sourced::from_source(ProtectedString::sensitive("--token=never-print"), origin.clone()),
4374            ],
4375            vec![origin.clone()],
4376        );
4377        volume.set_podman_args_with_origins(
4378            vec![
4379                Sourced::from_source(ProtectedString::plain("--replace"), origin.clone()),
4380                Sourced::from_source(ProtectedString::sensitive("--secret=never-print"), origin.clone()),
4381            ],
4382            vec![origin.clone()],
4383        );
4384        volume.set_user(Sourced::from_source(
4385            ProtectedString::plain("named-user"),
4386            origin.clone(),
4387        ));
4388        volume.set_group(Sourced::from_source(
4389            ProtectedString::plain("named-group"),
4390            origin.clone(),
4391        ));
4392        volume.set_uid(Sourced::from_source(ProtectedString::plain("1001"), origin.clone()));
4393        volume.set_gid(Sourced::from_source(ProtectedString::plain("1002"), origin));
4394
4395        assert_eq!(volume.labels().map(<[_]>::len), Some(0));
4396        assert_eq!(volume.containers_conf_modules().map(<[_]>::len), Some(0));
4397        assert_eq!(volume.global_args().map(<[_]>::len), Some(2));
4398        assert_eq!(volume.podman_args().map(<[_]>::len), Some(2));
4399        assert_eq!(volume.user().map(|value| value.value().expose()), Some("named-user"));
4400        assert_eq!(volume.group().map(|value| value.value().expose()), Some("named-group"));
4401        assert_eq!(volume.uid().map(|value| value.value().expose()), Some("1001"));
4402        assert_eq!(volume.gid().map(|value| value.value().expose()), Some("1002"));
4403        let debug = format!("{volume:?}");
4404        assert!(!debug.contains("never-print"));
4405        assert!(debug.contains("[REDACTED]"));
4406        Ok(())
4407    }
4408
4409    #[test]
4410    fn volume_copy_and_image_sources_preserve_absence_and_typed_distinctions() -> Result<(), String> {
4411        let origin =
4412            crate::Provenance::source(crate::SourceId::new("cache.volume").map_err(|error| error.to_string())?);
4413        let mut volume = Volume::new(id("cache")?, ResourceOwnership::Application);
4414        assert_eq!(volume.copy(), None);
4415        volume.set_copy(Sourced::from_source(false, origin.clone()));
4416        assert_eq!(volume.copy().map(Sourced::value), Some(&false));
4417        volume.set_copy(Sourced::from_source(true, origin.clone()));
4418        assert_eq!(volume.copy().map(Sourced::value), Some(&true));
4419
4420        volume
4421            .set_image_source(Sourced::from_source(
4422                VolumeImageSource::Literal(ProtectedString::sensitive("registry.example/private:1")),
4423                origin.clone(),
4424            ))
4425            .map_err(|error| error.to_string())?;
4426        assert!(matches!(
4427            volume.image_source().map(Sourced::value),
4428            Some(VolumeImageSource::Literal(_))
4429        ));
4430        assert!(!format!("{volume:?}").contains("registry.example/private:1"));
4431
4432        volume
4433            .set_image_source(Sourced::from_source(
4434                VolumeImageSource::ImageAcquisition(id("cache-image")?),
4435                origin.clone(),
4436            ))
4437            .map_err(|error| error.to_string())?;
4438        assert!(matches!(
4439            volume.image_source().map(Sourced::value),
4440            Some(VolumeImageSource::ImageAcquisition(name)) if name.as_str() == "cache-image"
4441        ));
4442        volume
4443            .set_image_source(Sourced::from_source(
4444                VolumeImageSource::ImageBuild(id("cache-build")?),
4445                origin,
4446            ))
4447            .map_err(|error| error.to_string())?;
4448        assert!(matches!(
4449            volume.image_source().map(Sourced::value),
4450            Some(VolumeImageSource::ImageBuild(name)) if name.as_str() == "cache-build"
4451        ));
4452        Ok(())
4453    }
4454
4455    #[test]
4456    fn volume_artifact_validation_is_deferred_and_explicit_edges_find_cycles() -> Result<(), String> {
4457        let mut application = Application::new(id("example")?);
4458        let mut volume = Volume::new(id("cache")?, ResourceOwnership::Application);
4459        volume
4460            .set_image_source(Sourced::generated(VolumeImageSource::ImageBuild(id("cache-build")?)))
4461            .map_err(|error| error.to_string())?;
4462        application
4463            .add_volume(Sourced::generated(volume))
4464            .map_err(|error| error.to_string())?;
4465        assert!(matches!(
4466            application.validate_image_artifact_references(),
4467            Err(ModelError::UnknownVolumeImageBuildReference { .. })
4468        ));
4469
4470        application
4471            .add_image_build(Sourced::generated(ImageBuild::new(id("cache-build")?)))
4472            .map_err(|error| error.to_string())?;
4473        application
4474            .validate_image_artifact_references()
4475            .map_err(|error| error.to_string())?;
4476
4477        let volume_node = ArtifactDependencyNode::Volume(id("cache")?);
4478        let build_node = ArtifactDependencyNode::ImageBuild(id("cache-build")?);
4479        let dependencies = vec![
4480            Sourced::generated(ArtifactDependency::new(
4481                Sourced::generated(volume_node.clone()),
4482                Sourced::generated(build_node.clone()),
4483            )),
4484            Sourced::generated(ArtifactDependency::new(
4485                Sourced::generated(build_node),
4486                Sourced::generated(volume_node),
4487            )),
4488        ];
4489        assert!(matches!(
4490            application.validate_image_artifact_dependencies(&dependencies),
4491            Err(ModelError::ImageArtifactDependencyCycle { .. })
4492        ));
4493        let missing = vec![Sourced::generated(ArtifactDependency::new(
4494            Sourced::generated(ArtifactDependencyNode::ImageBuild(id("cache-build")?)),
4495            Sourced::generated(ArtifactDependencyNode::Volume(id("missing")?)),
4496        ))];
4497        assert!(matches!(
4498            application.validate_image_artifact_dependencies(&missing),
4499            Err(ModelError::UnknownArtifactDependencyNode { kind: "volume", .. })
4500        ));
4501        Ok(())
4502    }
4503
4504    #[test]
4505    fn volume_rejects_invalid_literal_image_values() -> Result<(), String> {
4506        let mut volume = Volume::new(id("data")?, ResourceOwnership::Application);
4507        assert!(matches!(
4508            volume.set_image_source(Sourced::generated(VolumeImageSource::Literal(ProtectedString::plain(
4509                ""
4510            )))),
4511            Err(ModelError::EmptyValue("volume image"))
4512        ));
4513        assert!(matches!(
4514            volume.set_image_source(Sourced::generated(VolumeImageSource::Literal(ProtectedString::plain(
4515                "bad\0image"
4516            )))),
4517            Err(ModelError::ContainsNul("volume image"))
4518        ));
4519        Ok(())
4520    }
4521
4522    #[test]
4523    fn collection_resets_retain_explicit_emptiness_and_clear_stale_origins() -> Result<(), String> {
4524        let origin =
4525            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
4526        let mut service = Service::new(id("web")?);
4527
4528        service.set_cap_add_with_origins(Vec::new(), vec![origin.clone()]);
4529        service.set_cap_drop_with_origins(Vec::new(), vec![origin.clone()]);
4530        service.set_tmpfs_with_origins(Vec::new(), vec![origin.clone()]);
4531        service.set_sysctls_with_origins(Vec::new(), vec![origin.clone()]);
4532        service.set_ulimits_with_origins(Vec::new(), vec![origin.clone()]);
4533        service.set_devices_with_origins(Vec::new(), vec![origin]);
4534
4535        assert_eq!(service.cap_add().map(<[_]>::len), Some(0));
4536        assert_eq!(service.cap_drop().map(<[_]>::len), Some(0));
4537        assert_eq!(service.tmpfs().map(<[_]>::len), Some(0));
4538        assert_eq!(service.sysctls().map(<[_]>::len), Some(0));
4539        assert_eq!(service.ulimits().map(<[_]>::len), Some(0));
4540        assert_eq!(service.devices().map(<[_]>::len), Some(0));
4541        assert_eq!(service.cap_add_origins().len(), 1);
4542        assert_eq!(service.cap_drop_origins().len(), 1);
4543        assert_eq!(service.tmpfs_origins().len(), 1);
4544        assert_eq!(service.sysctls_origins().len(), 1);
4545        assert_eq!(service.ulimits_origins().len(), 1);
4546        assert_eq!(service.devices_origins().len(), 1);
4547
4548        service.set_cap_add(Vec::new());
4549        service.set_cap_drop(Vec::new());
4550        service.set_tmpfs(Vec::new());
4551        service.set_sysctls(Vec::<Sourced<KernelParameter>>::new());
4552        service.set_ulimits(Vec::<Sourced<ResourceLimit>>::new());
4553        service.set_devices(Vec::<Sourced<Device>>::new());
4554
4555        assert!(service.cap_add_origins().is_empty());
4556        assert!(service.cap_drop_origins().is_empty());
4557        assert!(service.tmpfs_origins().is_empty());
4558        assert!(service.sysctls_origins().is_empty());
4559        assert!(service.ulimits_origins().is_empty());
4560        assert!(service.devices_origins().is_empty());
4561        Ok(())
4562    }
4563
4564    #[test]
4565    fn restart_policy_keeps_unlimited_and_finite_on_failure_distinct() {
4566        let finite = std::num::NonZeroU64::new(4);
4567        assert_eq!(RestartPolicy::on_failure(None).maximum_retries(), None);
4568        assert_eq!(RestartPolicy::on_failure(finite).maximum_retries(), finite);
4569        assert_eq!(RestartPolicy::Always.maximum_retries(), None);
4570    }
4571
4572    #[test]
4573    fn metadata_labels_preserve_empty_and_protected_values() -> Result<(), String> {
4574        let empty = MetadataLabel::new(id("com.example.empty")?, ProtectedString::plain(""));
4575        let protected = MetadataLabel::new(id("com.example.token")?, ProtectedString::sensitive("never-print-this"));
4576        let mut service = Service::new(id("web")?);
4577        service.add_label(Sourced::generated(empty));
4578        service.add_label(Sourced::generated(protected));
4579
4580        assert_eq!(service.labels()[0].value().value().expose(), "");
4581        let debug = format!("{:?}", service.labels()[1]);
4582        assert!(!debug.contains("never-print-this"));
4583        assert!(debug.contains("[REDACTED]"));
4584        Ok(())
4585    }
4586
4587    #[test]
4588    fn environment_files_preserve_order_options_provenance_and_redaction() -> Result<(), String> {
4589        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4590        let origin = crate::Provenance::source(source);
4591        let mut service = Service::new(id("web")?);
4592        service.add_environment_file(Sourced::from_source(
4593            EnvironmentFile::new(ProtectedString::plain("./base.env"), EnvironmentFileSyntax::Short)
4594                .map_err(|error| error.to_string())?,
4595            origin.clone(),
4596        ));
4597        let mut local = EnvironmentFile::new(ProtectedString::sensitive("./private.env"), EnvironmentFileSyntax::Long)
4598            .map_err(|error| error.to_string())?;
4599        local.set_required(Sourced::from_source(false, origin.clone()));
4600        local.set_format(Sourced::from_source(EnvironmentFileFormat::Raw, origin.clone()));
4601        service.add_environment_file(Sourced::from_source(local, origin));
4602
4603        assert_eq!(service.environment_files().len(), 2);
4604        assert_eq!(service.environment_files()[0].value().path().expose(), "./base.env");
4605        assert_eq!(
4606            service.environment_files()[0].value().syntax(),
4607            EnvironmentFileSyntax::Short
4608        );
4609        assert!(service.environment_files()[0].value().is_required());
4610        let local = service.environment_files()[1].value();
4611        assert_eq!(local.syntax(), EnvironmentFileSyntax::Long);
4612        assert!(!local.is_required());
4613        assert_eq!(local.required().map_or(0, |value| value.origins().len()), 1);
4614        assert!(matches!(
4615            local.format().map(Sourced::value),
4616            Some(EnvironmentFileFormat::Raw)
4617        ));
4618        let debug = format!("{service:?}");
4619        assert!(!debug.contains("private.env"));
4620        assert!(debug.contains("[REDACTED]"));
4621        assert!(matches!(
4622            EnvironmentFile::new(ProtectedString::plain(""), EnvironmentFileSyntax::Short),
4623            Err(ModelError::EmptyValue("environment-file path"))
4624        ));
4625        Ok(())
4626    }
4627
4628    #[test]
4629    fn service_groups_preserve_order_and_reject_ambiguous_membership() -> Result<(), String> {
4630        let mut application = Application::new(id("example")?);
4631        for name in ["web", "worker"] {
4632            application
4633                .add_service(Sourced::generated(Service::new(id(name)?)))
4634                .map_err(|error| error.to_string())?;
4635        }
4636
4637        let mut frontend = ServiceGroup::new(id("frontend")?, ResourceOwnership::Uncertain);
4638        frontend
4639            .add_member(Sourced::generated(id("web")?))
4640            .map_err(|error| error.to_string())?;
4641        assert!(matches!(
4642            frontend.add_member(Sourced::generated(id("web")?)),
4643            Err(ModelError::DuplicateServiceGroupMember { .. })
4644        ));
4645        application
4646            .add_service_group(Sourced::generated(frontend))
4647            .map_err(|error| error.to_string())?;
4648
4649        assert_eq!(application.service_groups()[0].value().name().as_str(), "frontend");
4650        assert_eq!(
4651            application.service_groups()[0].value().members()[0].value().as_str(),
4652            "web"
4653        );
4654
4655        let mut conflicting = ServiceGroup::new(id("backend")?, ResourceOwnership::Application);
4656        conflicting
4657            .add_member(Sourced::generated(id("web")?))
4658            .map_err(|error| error.to_string())?;
4659        assert!(matches!(
4660            application.add_service_group(Sourced::generated(conflicting)),
4661            Err(ModelError::ServiceInMultipleGroups { .. })
4662        ));
4663
4664        let mut missing = ServiceGroup::new(id("missing")?, ResourceOwnership::External);
4665        missing
4666            .add_member(Sourced::generated(id("database")?))
4667            .map_err(|error| error.to_string())?;
4668        assert!(matches!(
4669            application.add_service_group(Sourced::generated(missing)),
4670            Err(ModelError::UnknownServiceGroupMember { .. })
4671        ));
4672        Ok(())
4673    }
4674
4675    #[test]
4676    fn group_runtime_keeps_group_names_and_pod_settings_distinct() -> Result<(), String> {
4677        let source = crate::SourceId::new("pod.pod").map_err(|error| error.to_string())?;
4678        let origin = crate::Provenance::source(source);
4679        let mut group = ServiceGroup::new(id("frontend")?, ResourceOwnership::Application);
4680        let mut runtime = ServiceGroupRuntime::new();
4681        runtime.set_runtime_name(Sourced::from_source(
4682            ProtectedString::plain("production-frontend"),
4683            origin.clone(),
4684        ));
4685        runtime.set_service_name(Sourced::from_source(
4686            ProtectedString::plain("frontend-pod"),
4687            origin.clone(),
4688        ));
4689        runtime.set_host_mappings_with_origins(
4690            vec![Sourced::from_source(
4691                HostMapping::new(
4692                    id("host.docker.internal")?,
4693                    HostAddress::new("host-gateway").map_err(|error| error.to_string())?,
4694                ),
4695                origin.clone(),
4696            )],
4697            vec![origin.clone()],
4698        );
4699        runtime.set_ports_with_origins(Vec::new(), vec![origin.clone()]);
4700        runtime.set_networks_with_origins(
4701            vec![Sourced::from_source(
4702                NetworkAttachment::with_sourced_aliases(
4703                    id("edge")?,
4704                    vec![Sourced::from_source(
4705                        ProtectedString::sensitive("private-alias"),
4706                        origin.clone(),
4707                    )],
4708                ),
4709                origin.clone(),
4710            )],
4711            vec![origin.clone()],
4712        );
4713        runtime.set_user_namespace(Sourced::from_source(ProtectedString::plain("keep-id"), origin.clone()));
4714        runtime.set_mounts_with_origins(
4715            vec![Sourced::from_source(
4716                Mount::new(MountSource::Anonymous, "/cache", false).map_err(|error| error.to_string())?,
4717                origin.clone(),
4718            )],
4719            vec![origin.clone()],
4720        );
4721        runtime.set_shm_size(Sourced::from_source(ProtectedString::sensitive("64m"), origin.clone()));
4722        runtime.set_exit_policy(Sourced::from_source(
4723            GroupExitPolicy::Raw(ProtectedString::sensitive("preserve-this")),
4724            origin.clone(),
4725        ));
4726        runtime.set_stop_timeout(Sourced::from_source(
4727            StopTimeout::new("30s").map_err(|error| error.to_string())?,
4728            origin.clone(),
4729        ));
4730        assert!(matches!(
4731            runtime.replace_network(1, Sourced::generated(NetworkAttachment::new(id("other")?, Vec::new()))),
4732            Err(ModelError::UnknownServiceGroupRuntimeNetworkIndex { index: 1, len: 1 })
4733        ));
4734        group.set_runtime(Sourced::from_source(runtime, origin));
4735
4736        let runtime = group
4737            .runtime()
4738            .ok_or_else(|| "group runtime was omitted".to_owned())?
4739            .value();
4740        assert_eq!(group.name().as_str(), "frontend");
4741        assert_eq!(
4742            runtime.runtime_name().map(|name| name.value().expose()),
4743            Some("production-frontend")
4744        );
4745        assert_eq!(
4746            runtime.service_name().map(|name| name.value().expose()),
4747            Some("frontend-pod")
4748        );
4749        assert_eq!(runtime.host_mappings().map(<[_]>::len), Some(1));
4750        assert_eq!(runtime.ports().map(<[_]>::len), Some(0));
4751        assert_eq!(runtime.networks_origins().len(), 1);
4752        assert_eq!(runtime.mounts().map(<[_]>::len), Some(1));
4753        assert!(matches!(
4754            runtime.exit_policy().map(Sourced::value),
4755            Some(GroupExitPolicy::Raw(_))
4756        ));
4757        let debug = format!("{group:?}");
4758        for sensitive in ["private-alias", "64m", "preserve-this"] {
4759            assert!(!debug.contains(sensitive));
4760        }
4761        assert!(debug.contains("[REDACTED]"));
4762        Ok(())
4763    }
4764
4765    #[test]
4766    fn rootfs_startup_notification_and_podman_args_preserve_safe_contracts() -> Result<(), String> {
4767        let source = crate::SourceId::new("web.container").map_err(|error| error.to_string())?;
4768        let origin = crate::Provenance::source(source);
4769        let mut service = Service::new(id("web")?);
4770        service.set_startup_notification(Sourced::from_source(StartupNotification::Healthy, origin.clone()));
4771        service.set_podman_args_with_origins(
4772            vec![
4773                Sourced::from_source(ProtectedString::plain("--replace"), origin.clone()),
4774                Sourced::from_source(ProtectedString::sensitive("--secret=never-print"), origin.clone()),
4775                Sourced::from_source(ProtectedString::plain("--replace"), origin.clone()),
4776            ],
4777            vec![origin.clone()],
4778        );
4779        assert_eq!(service.podman_args().map(<[_]>::len), Some(3));
4780        assert_eq!(service.podman_args_origins(), std::slice::from_ref(&origin));
4781        assert!(matches!(
4782            service.startup_notification().map(Sourced::value),
4783            Some(StartupNotification::Healthy)
4784        ));
4785        assert!(!format!("{service:?}").contains("never-print"));
4786
4787        let mut with_image = Service::new(id("image-first")?);
4788        with_image.set_image(Sourced::generated(
4789            ImageReference::parse("example.invalid/web:1").map_err(|error| error.to_string())?,
4790        ));
4791        assert!(matches!(
4792            with_image.set_rootfs(Sourced::generated(ProtectedString::plain("/srv/rootfs"))),
4793            Err(ModelError::RootfsImageSourceConflict { source: "image", .. })
4794        ));
4795
4796        let mut with_rootfs = Service::new(id("rootfs-first")?);
4797        with_rootfs
4798            .set_rootfs(Sourced::generated(ProtectedString::sensitive("/private/rootfs")))
4799            .map_err(|error| error.to_string())?;
4800        with_rootfs.set_image_build(Sourced::generated(id("web-build")?));
4801        let mut application = Application::new(id("example")?);
4802        application
4803            .add_image_build(Sourced::generated(ImageBuild::new(id("web-build")?)))
4804            .map_err(|error| error.to_string())?;
4805        assert!(matches!(
4806            application.add_service(Sourced::generated(with_rootfs)),
4807            Err(ModelError::RootfsImageSourceConflict {
4808                source: "image build",
4809                ..
4810            })
4811        ));
4812        Ok(())
4813    }
4814
4815    #[test]
4816    fn validates_raw_preserving_healthcheck_scalars() -> Result<(), String> {
4817        let duration = HealthcheckDuration::new("1m30s").map_err(|error| error.to_string())?;
4818        let retries = HealthcheckRetries::new("003").map_err(|error| error.to_string())?;
4819        assert_eq!(duration.as_str(), "1m30s");
4820        assert_eq!(retries.as_str(), "003");
4821        assert_eq!(
4822            HealthcheckRetries::new("three"),
4823            Err(ModelError::InvalidHealthcheckRetries)
4824        );
4825        assert!(matches!(
4826            HealthcheckDuration::new(""),
4827            Err(ModelError::EmptyValue("health-check duration"))
4828        ));
4829        Ok(())
4830    }
4831
4832    #[test]
4833    fn preserves_ordered_dependency_edges_and_field_provenance() -> Result<(), String> {
4834        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4835        let origin = crate::Provenance::source(source);
4836        let mut service = Service::new(id("web")?);
4837
4838        let mut database = ServiceDependency::new(id("database")?);
4839        database.set_condition(Sourced::from_source(
4840            ServiceDependencyCondition::Healthy,
4841            origin.clone(),
4842        ));
4843        database.set_required(Sourced::from_source(true, origin.clone()));
4844        service.add_dependency(Sourced::from_source(database, origin.clone()));
4845
4846        let cache = ServiceDependency::new(id("cache")?);
4847        assert!(cache.is_required());
4848        service.add_dependency(Sourced::from_source(cache, origin));
4849
4850        assert_eq!(
4851            service
4852                .dependencies()
4853                .iter()
4854                .map(|dependency| dependency.value().service().as_str())
4855                .collect::<Vec<_>>(),
4856            ["database", "cache"]
4857        );
4858        assert!(matches!(
4859            service.dependencies()[0].value().condition().map(Sourced::value),
4860            Some(ServiceDependencyCondition::Healthy)
4861        ));
4862        assert_eq!(service.dependencies()[0].origins().len(), 1);
4863        assert_eq!(
4864            service.dependencies()[0]
4865                .value()
4866                .condition()
4867                .map_or(0, |condition| condition.origins().len()),
4868            1
4869        );
4870        Ok(())
4871    }
4872
4873    #[test]
4874    fn retains_execution_identity_context_order_provenance_and_redaction() -> Result<(), String> {
4875        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4876        let origin = crate::Provenance::source(source);
4877        let mut service = Service::new(id("web")?);
4878
4879        service.set_user(Sourced::from_source(ProtectedString::sensitive("1001"), origin.clone()));
4880        service.set_group(Sourced::from_source(ProtectedString::plain("1002"), origin.clone()));
4881        service.set_user_namespace(Sourced::from_source(ProtectedString::plain("keep-id"), origin.clone()));
4882        service.add_supplementary_group(Sourced::from_source(ProtectedString::plain("audio"), origin.clone()));
4883        service.add_supplementary_group(Sourced::from_source(ProtectedString::plain("44"), origin.clone()));
4884        service.set_working_directory(Sourced::from_source(ProtectedString::plain("/srv/app"), origin.clone()));
4885        service.set_read_only_root_filesystem(Sourced::from_source(true, origin));
4886
4887        assert_eq!(service.user().map(|value| value.value().expose()), Some("1001"));
4888        assert_eq!(service.group().map(|value| value.value().expose()), Some("1002"));
4889        assert_eq!(
4890            service.user_namespace().map(|value| value.value().expose()),
4891            Some("keep-id")
4892        );
4893        assert_eq!(
4894            service
4895                .supplementary_groups()
4896                .iter()
4897                .map(|group| group.value().expose())
4898                .collect::<Vec<_>>(),
4899            ["audio", "44"]
4900        );
4901        assert_eq!(
4902            service.working_directory().map(|value| value.value().expose()),
4903            Some("/srv/app")
4904        );
4905        assert_eq!(service.read_only_root_filesystem().map(Sourced::value), Some(&true));
4906        assert_eq!(service.user().map_or(0, |value| value.origins().len()), 1);
4907        let debug = format!("{service:?}");
4908        assert!(!debug.contains("1001"));
4909        assert!(debug.contains("[REDACTED]"));
4910        Ok(())
4911    }
4912
4913    #[test]
4914    fn retains_config_secret_resources_grants_provenance_and_redaction() -> Result<(), String> {
4915        let source = crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?;
4916        let origin = crate::Provenance::source(source);
4917        let mut application = Application::new(id("example")?);
4918
4919        let mut config = Config::new(id("settings")?, ResourceOwnership::Application);
4920        config.set_material(Sourced::from_source(
4921            ConfigMaterial::Content(ProtectedString::sensitive("private-config")),
4922            origin.clone(),
4923        ));
4924        application
4925            .add_config(Sourced::from_source(config, origin.clone()))
4926            .map_err(|error| error.to_string())?;
4927
4928        let mut secret = Secret::new(id("password")?, ResourceOwnership::External);
4929        secret.set_runtime_name(Sourced::from_source(
4930            ProtectedString::plain("production-password"),
4931            origin.clone(),
4932        ));
4933        secret.set_material(Sourced::from_source(
4934            SecretMaterial::Environment(ProtectedString::sensitive("private-environment-name")),
4935            origin.clone(),
4936        ));
4937        application
4938            .add_secret(Sourced::from_source(secret, origin.clone()))
4939            .map_err(|error| error.to_string())?;
4940
4941        let mut service = Service::new(id("web")?);
4942        service.add_config_grant(Sourced::from_source(
4943            ResourceGrant::new(ProtectedString::plain("settings"), ResourceGrantSyntax::Short)
4944                .map_err(|error| error.to_string())?,
4945            origin.clone(),
4946        ));
4947        let mut secret_grant = ResourceGrant::new(
4948            ProtectedString::sensitive("private-grant-source"),
4949            ResourceGrantSyntax::Long,
4950        )
4951        .map_err(|error| error.to_string())?;
4952        secret_grant.set_target(Sourced::from_source(
4953            ProtectedString::plain("database-password"),
4954            origin.clone(),
4955        ));
4956        secret_grant.set_uid(Sourced::from_source(ProtectedString::plain("1001"), origin.clone()));
4957        secret_grant.set_gid(Sourced::from_source(ProtectedString::plain("1002"), origin.clone()));
4958        secret_grant.set_mode(Sourced::from_source(ProtectedString::plain("0440"), origin.clone()));
4959        service.add_secret_grant(Sourced::from_source(secret_grant, origin.clone()));
4960        application
4961            .add_service(Sourced::from_source(service, origin))
4962            .map_err(|error| error.to_string())?;
4963
4964        assert_eq!(application.configs().len(), 1);
4965        assert_eq!(application.secrets().len(), 1);
4966        assert_eq!(application.services()[0].value().config_grants().len(), 1);
4967        let grant = &application.services()[0].value().secret_grants()[0];
4968        assert_eq!(grant.value().syntax(), ResourceGrantSyntax::Long);
4969        assert_eq!(
4970            grant.value().target().map(|value| value.value().expose()),
4971            Some("database-password")
4972        );
4973        assert_eq!(grant.value().uid().map_or(0, |value| value.origins().len()), 1);
4974        assert_eq!(grant.origins().len(), 1);
4975        let debug = format!("{application:?}");
4976        for secret in ["private-config", "private-environment-name", "private-grant-source"] {
4977            assert!(!debug.contains(secret));
4978        }
4979        assert!(debug.contains("[REDACTED]"));
4980
4981        assert!(matches!(
4982            ResourceGrant::new(ProtectedString::plain(""), ResourceGrantSyntax::Short),
4983            Err(ModelError::EmptyValue("resource grant source"))
4984        ));
4985        assert!(matches!(
4986            application.add_config(Sourced::generated(Config::new(
4987                id("settings")?,
4988                ResourceOwnership::External,
4989            ))),
4990            Err(ModelError::DuplicateResource { kind: "config", .. })
4991        ));
4992        assert!(matches!(
4993            application.add_secret(Sourced::generated(Secret::new(
4994                id("password")?,
4995                ResourceOwnership::External,
4996            ))),
4997            Err(ModelError::DuplicateResource { kind: "secret", .. })
4998        ));
4999        Ok(())
5000    }
5001
5002    #[test]
5003    fn host_mappings_preserve_order_spelling_and_runtime_tokens() -> Result<(), String> {
5004        let mut service = Service::new(id("web")?);
5005        service.add_host_mapping(Sourced::generated(HostMapping::new(
5006            id("host.docker.internal")?,
5007            HostAddress::new("host-gateway").map_err(|error| error.to_string())?,
5008        )));
5009        service.add_host_mapping(Sourced::generated(HostMapping::new(
5010            id("ipv6")?,
5011            HostAddress::new("[::1]").map_err(|error| error.to_string())?,
5012        )));
5013
5014        assert_eq!(service.host_mappings().len(), 2);
5015        assert_eq!(
5016            service.host_mappings()[0].value().address().kind(),
5017            HostAddressKind::HostGateway
5018        );
5019        assert_eq!(service.host_mappings()[1].value().address().raw(), "[::1]");
5020        assert_eq!(
5021            service.host_mappings()[1].value().address().kind(),
5022            HostAddressKind::Ipv6 { bracketed: true }
5023        );
5024        assert!(matches!(HostAddress::new(""), Err(ModelError::EmptyValue(_))));
5025        Ok(())
5026    }
5027
5028    #[test]
5029    fn dns_collections_preserve_order_provenance_and_explicit_empty_state() -> Result<(), String> {
5030        let mut service = Service::new(id("web")?);
5031        assert!(service.dns_servers().is_none());
5032        service.set_dns_servers(Vec::new());
5033        assert!(matches!(service.dns_servers(), Some(values) if values.is_empty()));
5034        service.set_dns_options(vec![
5035            Sourced::generated(ProtectedString::plain("ndots:5")),
5036            Sourced::generated(ProtectedString::sensitive("rotate")),
5037        ]);
5038        service.set_dns_search_domains(vec![Sourced::generated(ProtectedString::plain("example.test"))]);
5039        assert_eq!(
5040            service
5041                .dns_options()
5042                .unwrap_or_default()
5043                .iter()
5044                .map(|value| value.value().expose())
5045                .collect::<Vec<_>>(),
5046            ["ndots:5", "rotate"]
5047        );
5048        assert!(!format!("{service:?}").contains("rotate"));
5049        Ok(())
5050    }
5051
5052    #[test]
5053    fn security_options_preserve_empty_order_duplicates_provenance_and_redaction() -> Result<(), String> {
5054        let origin =
5055            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
5056        let mut service = Service::new(id("web")?);
5057
5058        assert!(service.security_options().is_none());
5059        service.set_security_options_with_origins(Vec::new(), vec![origin.clone()]);
5060        assert_eq!(service.security_options().map(<[_]>::len), Some(0));
5061        assert_eq!(service.security_options_origins(), std::slice::from_ref(&origin));
5062
5063        service.set_security_options_with_origins(
5064            vec![
5065                Sourced::from_source(
5066                    SecurityOption::AppArmor(ProtectedString::sensitive("apparmor-secret")),
5067                    origin.clone(),
5068                ),
5069                Sourced::from_source(SecurityOption::NoNewPrivileges(true), origin.clone()),
5070                Sourced::from_source(
5071                    SecurityOption::SeccompProfile(ProtectedString::sensitive("seccomp-secret")),
5072                    origin.clone(),
5073                ),
5074                Sourced::from_source(SecurityOption::SecurityLabelDisable(false), origin.clone()),
5075                Sourced::from_source(
5076                    SecurityOption::SecurityLabelFileType(ProtectedString::sensitive("file-type-secret")),
5077                    origin.clone(),
5078                ),
5079                Sourced::from_source(
5080                    SecurityOption::SecurityLabelLevel(ProtectedString::sensitive("level-secret")),
5081                    origin.clone(),
5082                ),
5083                Sourced::from_source(SecurityOption::SecurityLabelNested(true), origin.clone()),
5084                Sourced::from_source(
5085                    SecurityOption::SecurityLabelType(ProtectedString::sensitive("type-secret")),
5086                    origin.clone(),
5087                ),
5088                Sourced::from_source(
5089                    SecurityOption::Mask(ProtectedString::sensitive("mask-secret")),
5090                    origin.clone(),
5091                ),
5092                Sourced::from_source(
5093                    SecurityOption::Unmask(ProtectedString::sensitive("unmask-secret")),
5094                    origin.clone(),
5095                ),
5096                Sourced::from_source(
5097                    SecurityOption::Mask(ProtectedString::sensitive("mask-secret")),
5098                    origin.clone(),
5099                ),
5100            ],
5101            vec![origin.clone()],
5102        );
5103
5104        let options = service.security_options().unwrap_or_default();
5105        assert_eq!(options.len(), 11);
5106        assert!(
5107            matches!(options[0].value(), SecurityOption::AppArmor(profile) if profile.expose() == "apparmor-secret")
5108        );
5109        assert!(matches!(options[1].value(), SecurityOption::NoNewPrivileges(true)));
5110        assert!(
5111            matches!(options[2].value(), SecurityOption::SeccompProfile(profile) if profile.expose() == "seccomp-secret")
5112        );
5113        assert!(matches!(
5114            options[3].value(),
5115            SecurityOption::SecurityLabelDisable(false)
5116        ));
5117        assert!(
5118            matches!(options[4].value(), SecurityOption::SecurityLabelFileType(profile) if profile.expose() == "file-type-secret")
5119        );
5120        assert!(
5121            matches!(options[5].value(), SecurityOption::SecurityLabelLevel(profile) if profile.expose() == "level-secret")
5122        );
5123        assert!(matches!(options[6].value(), SecurityOption::SecurityLabelNested(true)));
5124        assert!(
5125            matches!(options[7].value(), SecurityOption::SecurityLabelType(profile) if profile.expose() == "type-secret")
5126        );
5127        assert!(matches!(options[8].value(), SecurityOption::Mask(path) if path.expose() == "mask-secret"));
5128        assert!(matches!(options[9].value(), SecurityOption::Unmask(path) if path.expose() == "unmask-secret"));
5129        assert!(matches!(options[10].value(), SecurityOption::Mask(path) if path.expose() == "mask-secret"));
5130        assert_eq!(options[0].origins(), std::slice::from_ref(&origin));
5131        assert_eq!(service.security_options_origins(), std::slice::from_ref(&origin));
5132
5133        let debug = format!("{service:?}");
5134        for secret in [
5135            "apparmor-secret",
5136            "seccomp-secret",
5137            "file-type-secret",
5138            "level-secret",
5139            "type-secret",
5140            "mask-secret",
5141            "unmask-secret",
5142        ] {
5143            assert!(!debug.contains(secret));
5144        }
5145        assert!(debug.contains("[REDACTED]"));
5146
5147        service.set_security_options(Vec::new());
5148        assert_eq!(service.security_options().map(<[_]>::len), Some(0));
5149        assert!(service.security_options_origins().is_empty());
5150        Ok(())
5151    }
5152
5153    #[test]
5154    fn retains_entrypoint_run_init_stop_pull_memory_and_exposed_port_intent() -> Result<(), String> {
5155        let origin =
5156            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
5157        let mut service = Service::new(id("web")?);
5158        service.set_command(Sourced::from_source(
5159            Command::Exec(vec![ProtectedString::plain("serve")]),
5160            origin.clone(),
5161        ));
5162        service.set_entrypoint(Sourced::from_source(
5163            Entrypoint::Shell(ProtectedString::sensitive("/bin/sh -c private-entrypoint")),
5164            origin.clone(),
5165        ));
5166        service.set_run_init(Sourced::from_source(true, origin.clone()));
5167        service.set_stop_timeout(Sourced::from_source(
5168            StopTimeout::new("01m30s").map_err(|error| error.to_string())?,
5169            origin.clone(),
5170        ));
5171        service.set_pull_policy(Sourced::from_source(
5172            PullPolicy::Every(ProtectedString::sensitive("12h")),
5173            origin.clone(),
5174        ));
5175        service.set_memory_limit(Sourced::from_source(
5176            ProtectedString::sensitive("512MiB"),
5177            origin.clone(),
5178        ));
5179        assert!(service.exposed_ports().is_none());
5180        service.set_exposed_ports_with_origins(Vec::new(), vec![origin.clone()]);
5181        assert_eq!(service.exposed_ports().map(<[_]>::len), Some(0));
5182        assert_eq!(service.exposed_ports_origins(), std::slice::from_ref(&origin));
5183        service.add_exposed_port(Sourced::from_source(
5184            ExposedPort::new(8080, Protocol::Tcp).map_err(|error| error.to_string())?,
5185            origin.clone(),
5186        ));
5187        service.add_exposed_port(Sourced::from_source(
5188            ExposedPort::new(8080, Protocol::Tcp).map_err(|error| error.to_string())?,
5189            origin,
5190        ));
5191
5192        assert!(matches!(service.command().map(Sourced::value), Some(Command::Exec(_))));
5193        assert!(matches!(
5194            service.entrypoint().map(Sourced::value),
5195            Some(Entrypoint::Shell(_))
5196        ));
5197        assert_eq!(service.run_init().map(Sourced::value), Some(&true));
5198        assert_eq!(
5199            service.stop_timeout().map(|timeout| timeout.value().as_str()),
5200            Some("01m30s")
5201        );
5202        assert!(matches!(
5203            service.pull_policy().map(Sourced::value),
5204            Some(PullPolicy::Every(_))
5205        ));
5206        assert_eq!(
5207            service.memory_limit().map(|limit| limit.value().expose()),
5208            Some("512MiB")
5209        );
5210        let exposed_ports = service.exposed_ports().ok_or("missing exposed ports")?;
5211        assert_eq!(exposed_ports.len(), 2);
5212        assert_eq!(exposed_ports[0].value().container(), 8080);
5213        assert_eq!(exposed_ports[0].value().protocol(), &Protocol::Tcp);
5214        assert!(matches!(
5215            ExposedPort::new(0, Protocol::Udp),
5216            Err(ModelError::ZeroContainerPort)
5217        ));
5218        assert!(matches!(
5219            StopTimeout::new(""),
5220            Err(ModelError::EmptyValue("stop timeout"))
5221        ));
5222
5223        let debug = format!("{service:?}");
5224        for secret in ["private-entrypoint", "512MiB", "12h"] {
5225            assert!(!debug.contains(secret));
5226        }
5227        assert!(debug.contains("[REDACTED]"));
5228        Ok(())
5229    }
5230
5231    #[test]
5232    fn annotations_and_logging_preserve_empty_order_field_provenance_and_redaction() -> Result<(), String> {
5233        let origin =
5234            crate::Provenance::source(crate::SourceId::new("quadlet.container").map_err(|error| error.to_string())?);
5235        let mut service = Service::new(id("web")?);
5236
5237        assert!(service.annotations().is_none());
5238        service.set_annotations_with_origins(Vec::new(), vec![origin.clone()]);
5239        assert_eq!(service.annotations().map(<[_]>::len), Some(0));
5240        assert_eq!(service.annotations_origins(), std::slice::from_ref(&origin));
5241
5242        service.set_annotations_with_origins(
5243            vec![
5244                Sourced::from_source(
5245                    Annotation::new(
5246                        Sourced::from_source(id("io.example.first")?, origin.clone()),
5247                        Sourced::from_source(ProtectedString::sensitive("annotation-secret"), origin.clone()),
5248                    ),
5249                    origin.clone(),
5250                ),
5251                Sourced::from_source(
5252                    Annotation::new(
5253                        Sourced::from_source(id("io.example.second")?, origin.clone()),
5254                        Sourced::from_source(ProtectedString::plain(""), origin.clone()),
5255                    ),
5256                    origin.clone(),
5257                ),
5258            ],
5259            vec![origin.clone()],
5260        );
5261        let annotations = service.annotations().unwrap_or_default();
5262        assert_eq!(annotations.len(), 2);
5263        assert_eq!(annotations[0].value().name().value().as_str(), "io.example.first");
5264        assert_eq!(annotations[1].value().value().value().expose(), "");
5265        assert_eq!(annotations[0].value().name().origins(), std::slice::from_ref(&origin));
5266        assert_eq!(annotations[0].value().value().origins(), std::slice::from_ref(&origin));
5267
5268        let mut logging = Logging::new();
5269        assert!(logging.options().is_none());
5270        logging.set_driver(Sourced::from_source(ProtectedString::plain("journald"), origin.clone()));
5271        logging.set_options_with_origins(
5272            vec![
5273                Sourced::from_source(
5274                    LoggingOption::new(
5275                        Sourced::from_source(id("tag")?, origin.clone()),
5276                        Sourced::from_source(ProtectedString::sensitive("logging-secret"), origin.clone()),
5277                    ),
5278                    origin.clone(),
5279                ),
5280                Sourced::from_source(
5281                    LoggingOption::new(
5282                        Sourced::from_source(id("labels")?, origin.clone()),
5283                        Sourced::from_source(ProtectedString::plain(""), origin.clone()),
5284                    ),
5285                    origin.clone(),
5286                ),
5287            ],
5288            vec![origin.clone()],
5289        );
5290        service.set_logging(Sourced::from_source(logging, origin));
5291
5292        let logging = service.logging().map(Sourced::value).ok_or("missing logging")?;
5293        assert_eq!(logging.driver().map(|driver| driver.value().expose()), Some("journald"));
5294        assert_eq!(logging.options().map(<[_]>::len), Some(2));
5295        assert_eq!(
5296            logging.options().unwrap_or_default()[0].value().name().value().as_str(),
5297            "tag"
5298        );
5299        assert_eq!(logging.options_origins().len(), 1);
5300        let debug = format!("{service:?}");
5301        assert!(!debug.contains("annotation-secret"));
5302        assert!(!debug.contains("logging-secret"));
5303        assert!(debug.contains("[REDACTED]"));
5304        Ok(())
5305    }
5306
5307    #[test]
5308    fn network_attachments_keep_legacy_constructor_and_add_source_aware_addresses_aliases() -> Result<(), String> {
5309        let origin =
5310            crate::Provenance::source(crate::SourceId::new("compose.yaml").map_err(|error| error.to_string())?);
5311        let legacy = NetworkAttachment::new(id("legacy")?, vec!["legacy.alias".to_owned()]);
5312        assert_eq!(legacy.aliases(), ["legacy.alias"]);
5313        assert!(legacy.alias_origins().is_empty());
5314
5315        let mut attachment = NetworkAttachment::with_sourced_aliases(
5316            id("frontend")?,
5317            vec![
5318                Sourced::from_source(ProtectedString::plain("web"), origin.clone()),
5319                Sourced::from_source(ProtectedString::sensitive("private-alias"), origin.clone()),
5320            ],
5321        );
5322        attachment.set_ipv4_address(Sourced::from_source(
5323            ProtectedString::plain("192.0.2.10"),
5324            origin.clone(),
5325        ));
5326        attachment.set_ipv6_address(Sourced::from_source(ProtectedString::plain("2001:db8::10"), origin));
5327        let metrics = Sourced::generated(ProtectedString::plain("metrics"));
5328        attachment.add_alias(&metrics);
5329
5330        assert_eq!(attachment.aliases(), ["web", "private-alias", "metrics"]);
5331        assert_eq!(attachment.alias_sensitivities(), [false, true, false]);
5332        assert_eq!(attachment.alias_origins().len(), 3);
5333        assert_eq!(attachment.alias_origins()[0].len(), 1);
5334        assert!(attachment.alias_origins()[2].is_empty());
5335        assert_eq!(
5336            attachment.ipv4_address().map(|address| address.value().expose()),
5337            Some("192.0.2.10")
5338        );
5339        assert_eq!(
5340            attachment.ipv6_address().map(|address| address.value().expose()),
5341            Some("2001:db8::10")
5342        );
5343        let debug = format!("{attachment:?}");
5344        assert!(!debug.contains("private-alias"));
5345        assert!(debug.contains("[REDACTED]"));
5346
5347        let mut service = Service::new(id("web")?);
5348        service.add_network(Sourced::generated(legacy));
5349        let previous = service
5350            .replace_network(0, Sourced::generated(attachment))
5351            .map_err(|error| error.to_string())?;
5352        assert_eq!(previous.value().network().as_str(), "legacy");
5353        assert_eq!(service.networks()[0].value().network().as_str(), "frontend");
5354        assert!(matches!(
5355            service.replace_network(1, Sourced::generated(NetworkAttachment::new(id("unused")?, Vec::new()))),
5356            Err(ModelError::UnknownNetworkAttachmentIndex { index: 1, len: 1 })
5357        ));
5358        Ok(())
5359    }
5360
5361    #[test]
5362    fn reload_action_is_one_explicit_command_or_signal() -> Result<(), String> {
5363        let origin =
5364            crate::Provenance::source(crate::SourceId::new("quadlet.container").map_err(|error| error.to_string())?);
5365        let mut service = Service::new(id("web")?);
5366        service.set_reload_action(Sourced::from_source(
5367            ReloadAction::Command(Command::Exec(vec![ProtectedString::plain("reload")])),
5368            origin.clone(),
5369        ));
5370        assert!(matches!(
5371            service.reload_action().map(Sourced::value),
5372            Some(ReloadAction::Command(Command::Exec(_)))
5373        ));
5374
5375        service.set_reload_action(Sourced::from_source(
5376            ReloadAction::Signal(ProtectedString::sensitive("SIGHUP")),
5377            origin,
5378        ));
5379        assert!(matches!(
5380            service.reload_action().map(Sourced::value),
5381            Some(ReloadAction::Signal(_))
5382        ));
5383        let debug = format!("{service:?}");
5384        assert!(!debug.contains("SIGHUP"));
5385        assert!(debug.contains("[REDACTED]"));
5386        Ok(())
5387    }
5388
5389    fn id(value: &str) -> Result<Identifier, String> {
5390        Identifier::new(value).map_err(|error| error.to_string())
5391    }
5392}