1use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::merge::{
5 EntrySyntax, MergeProvenance, MergedEntry, MergedProject, MergedScalarKind, MergedValue, MergedValueKind,
6};
7use crate::model::{
8 ANNOTATIONS_DUPLICATE_NAME, ANNOTATIONS_EMPTY_NAME, ANNOTATIONS_EXPECTED_STRING, ANNOTATIONS_KEY_ONLY, BindOptions,
9 BooleanValue, CAP_ADD_DUPLICATE_ITEM, CAP_DROP_DUPLICATE_ITEM, CapabilityAddItem, CapabilityDropItem, Command,
10 ComposeScalar, ConfigDefinition, DEVICE_EXPECTED_FORM, DEVICE_EXPECTED_STRING, DNS_EXPECTED_FORM,
11 DNS_EXPECTED_STRING, DNS_OPT_DUPLICATE_ITEM, DNS_OPT_EXPECTED_SEQUENCE, DNS_OPT_EXPECTED_STRING,
12 DNS_SEARCH_DUPLICATE_ITEM, DNS_SEARCH_EXPECTED_FORM, DNS_SEARCH_EXPECTED_STRING, DependencyCondition,
13 EXPOSE_DUPLICATE_ITEM, EXPOSE_EXPECTED_SCALAR, EXPOSE_EXPECTED_SEQUENCE, EXPOSE_INVALID_ITEM,
14 EXPOSE_PROVIDER_DEPENDENT, Entrypoint, EnvironmentFileFormat, EnvironmentFileFormatKind, ExposeItemKind,
15 ExposeScalarKind, HealthcheckDuration, HealthcheckRetries, HealthcheckTest, HealthcheckTestKind, HostAddress,
16 Hostname, HostnameKind, ImageReference, Ipam, IpamConfig, KeyValueEntry, Labels, LimitValue, Located, LongPort,
17 LongVolumeMount, MEM_LIMIT_AMBIGUOUS_ZERO, MEM_LIMIT_EXPECTED_VALUE, MEM_LIMIT_PROVIDER_DEPENDENT_STRING,
18 MEM_LIMIT_SCHEMA_NUMBER, MemLimit, MemLimitKind, MemLimitScalarKind, MountType, NetworkDefinition,
19 PIDS_LIMIT_AMBIGUOUS_ZERO, PidsLimit, PidsLimitKind, Port, PullPolicy, RestartPolicy,
20 SECURITY_OPT_APPARMOR_CONFLICT, SECURITY_OPT_APPARMOR_NEAR_MISS, SECURITY_OPT_EMPTY_ITEM,
21 SECURITY_OPT_EXPECTED_SEQUENCE, SECURITY_OPT_EXPECTED_STRING, SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT,
22 SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS, SECURITY_OPT_SECCOMP_CONFLICT, SECURITY_OPT_SECCOMP_NEAR_MISS,
23 SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT, SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS,
24 SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT, SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS,
25 SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT, SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS,
26 SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT, SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS,
27 SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT, SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS, SHM_SIZE_AMBIGUOUS_ZERO,
28 SHM_SIZE_EXPECTED_VALUE, SHM_SIZE_PROVIDER_DEPENDENT_NUMBER, SHM_SIZE_PROVIDER_DEPENDENT_STRING,
29 SYSCTLS_DUPLICATE_ITEM, SYSCTLS_EMPTY_KEY, SYSCTLS_EXPECTED_FORM, SYSCTLS_EXPECTED_SCALAR, SYSCTLS_EXPECTED_STRING,
30 SecretDefinition, SecurityOptionCandidateCounts, SecurityOptionKind, SelinuxRelabel, ServiceNetwork,
31 ServiceNetworks, ShmSize, ShmSizeKind, ShmSizeScalarKind, ShortDevice, ShortExtraHost, ShortPort, ShortVolumeMount,
32 StopGracePeriod, TMPFS_EXPECTED_FORM, TMPFS_EXPECTED_STRING, TMPFS_PROVIDER_DEPENDENT, TmpfsItem, TmpfsItemKind,
33 ULIMIT_INVALID_NAME, ULIMIT_INVALID_VALUE, ULIMIT_MISSING_RANGE_MEMBER, UserNamespaceMode, UserSpec,
34 VolumeDefinition, VolumeMount, classify_expose_item, classify_security_option, security_path_option_diagnostic,
35 valid_ulimit_name,
36};
37use crate::profiles::ProfileSelection;
38use crate::resolution::{SELECTION_PROJECT_MISMATCH, service_in_scope};
39use crate::source::{SourceId, SourceSpan};
40use std::collections::{BTreeMap, BTreeSet};
41use std::fmt;
42use std::path::{Path, PathBuf};
43
44pub const PROJECT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.project.expected-form");
46
47pub const PROJECT_MISSING_FIELD: DiagnosticCode = DiagnosticCode::new("compose.project.missing-field");
49
50pub const PROJECT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.project.invalid-value");
52
53#[derive(Clone, PartialEq, Eq)]
55pub struct ProjectValue<T> {
56 value: T,
57 provenance: MergeProvenance,
58 sensitive: bool,
59}
60
61impl<T: fmt::Debug> fmt::Debug for ProjectValue<T> {
62 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63 let mut debug = formatter.debug_struct("ProjectValue");
64 if self.sensitive {
65 debug.field("value", &"<redacted>");
66 } else {
67 debug.field("value", &self.value);
68 }
69 debug
70 .field("provenance", &self.provenance)
71 .field("sensitive", &self.sensitive)
72 .finish()
73 }
74}
75
76impl<T> ProjectValue<T> {
77 fn new(value: T, source: &MergedValue) -> Self {
78 Self {
79 value,
80 provenance: source.provenance().clone(),
81 sensitive: source.is_sensitive(),
82 }
83 }
84
85 #[must_use]
87 pub const fn value(&self) -> &T {
88 &self.value
89 }
90
91 #[must_use]
93 pub const fn provenance(&self) -> &MergeProvenance {
94 &self.provenance
95 }
96
97 #[must_use]
99 pub fn effective_source(&self) -> Option<SourceSpan> {
100 self.provenance.effective_source()
101 }
102
103 #[must_use]
105 pub const fn is_sensitive(&self) -> bool {
106 self.sensitive
107 }
108
109 #[must_use]
111 pub fn into_value(self) -> T {
112 self.value
113 }
114}
115
116#[derive(Clone, PartialEq, Eq)]
118pub struct ProjectKey {
119 value: String,
120 sources: Vec<SourceSpan>,
121 sensitive: bool,
122}
123
124impl fmt::Debug for ProjectKey {
125 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
126 formatter
127 .debug_struct("ProjectKey")
128 .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
129 .field("sources", &self.sources)
130 .field("sensitive", &self.sensitive)
131 .finish()
132 }
133}
134
135impl ProjectKey {
136 fn from_entry(entry: &MergedEntry) -> Self {
137 Self {
138 value: entry.key().to_owned(),
139 sources: entry.key_sources().to_vec(),
140 sensitive: entry.is_key_sensitive(),
141 }
142 }
143
144 fn from_value(value: String, source: &MergedValue) -> Self {
145 Self {
146 value,
147 sources: source.provenance().sources().to_vec(),
148 sensitive: source.is_sensitive(),
149 }
150 }
151
152 #[must_use]
154 pub fn value(&self) -> &str {
155 &self.value
156 }
157
158 #[must_use]
160 pub fn sources(&self) -> &[SourceSpan] {
161 &self.sources
162 }
163
164 #[must_use]
166 pub fn effective_source(&self) -> Option<SourceSpan> {
167 self.sources.last().copied()
168 }
169
170 #[must_use]
172 pub const fn is_sensitive(&self) -> bool {
173 self.sensitive
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct ProjectServiceDependency {
180 service: ProjectKey,
181 condition: Option<ProjectValue<DependencyCondition>>,
182 restart: Option<ProjectValue<BooleanValue>>,
183 required: Option<ProjectValue<BooleanValue>>,
184 unmodeled_fields: Vec<ProjectFieldReference>,
185}
186
187impl ProjectServiceDependency {
188 #[must_use]
190 pub const fn service(&self) -> &ProjectKey {
191 &self.service
192 }
193
194 #[must_use]
196 pub const fn condition(&self) -> Option<&ProjectValue<DependencyCondition>> {
197 self.condition.as_ref()
198 }
199
200 #[must_use]
202 pub const fn restart(&self) -> Option<&ProjectValue<BooleanValue>> {
203 self.restart.as_ref()
204 }
205
206 #[must_use]
208 pub const fn required(&self) -> Option<&ProjectValue<BooleanValue>> {
209 self.required.as_ref()
210 }
211
212 #[must_use]
214 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
215 &self.unmodeled_fields
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum ProjectDependsOn {
222 Short(Vec<ProjectValue<ProjectServiceDependency>>),
224 Long(Vec<ProjectValue<ProjectServiceDependency>>),
226}
227
228impl ProjectDependsOn {
229 #[must_use]
231 pub fn services(&self) -> &[ProjectValue<ProjectServiceDependency>] {
232 match self {
233 Self::Short(services) | Self::Long(services) => services,
234 }
235 }
236
237 #[must_use]
239 pub const fn is_long(&self) -> bool {
240 matches!(self, Self::Long(_))
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct ProjectFieldReference {
247 path: Vec<String>,
248 key: ProjectKey,
249 provenance: MergeProvenance,
250 extension: bool,
251 sensitive: bool,
252}
253
254impl ProjectFieldReference {
255 #[must_use]
257 pub fn path(&self) -> &[String] {
258 &self.path
259 }
260
261 #[must_use]
263 pub const fn key(&self) -> &ProjectKey {
264 &self.key
265 }
266
267 #[must_use]
269 pub const fn provenance(&self) -> &MergeProvenance {
270 &self.provenance
271 }
272
273 #[must_use]
275 pub const fn is_extension(&self) -> bool {
276 self.extension
277 }
278
279 #[must_use]
281 pub const fn is_sensitive(&self) -> bool {
282 self.sensitive
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct ProjectEnvironmentEntry {
289 name: ProjectKey,
290 value: ProjectValue<ComposeScalar>,
291 syntax: EntrySyntax,
292}
293
294impl ProjectEnvironmentEntry {
295 #[must_use]
297 pub const fn name(&self) -> &ProjectKey {
298 &self.name
299 }
300
301 #[must_use]
303 pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
304 &self.value
305 }
306
307 #[must_use]
309 pub const fn syntax(&self) -> EntrySyntax {
310 self.syntax
311 }
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct ProjectEnvironment {
317 entries: Vec<ProjectEnvironmentEntry>,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum ProjectEnvironmentFile {
323 Short(String),
325 Long(Box<ProjectLongEnvironmentFile>),
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct ProjectLongEnvironmentFile {
332 path: Option<ProjectValue<String>>,
333 required: Option<ProjectValue<BooleanValue>>,
334 format: Option<ProjectValue<EnvironmentFileFormat>>,
335 unmodeled_fields: Vec<ProjectFieldReference>,
336}
337
338impl ProjectLongEnvironmentFile {
339 #[must_use]
341 pub const fn path(&self) -> Option<&ProjectValue<String>> {
342 self.path.as_ref()
343 }
344
345 #[must_use]
347 pub const fn required(&self) -> Option<&ProjectValue<BooleanValue>> {
348 self.required.as_ref()
349 }
350
351 #[must_use]
353 pub const fn format(&self) -> Option<&ProjectValue<EnvironmentFileFormat>> {
354 self.format.as_ref()
355 }
356
357 #[must_use]
359 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
360 &self.unmodeled_fields
361 }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct ProjectLabelEntry {
367 name: ProjectKey,
368 value: ProjectValue<ComposeScalar>,
369 syntax: EntrySyntax,
370}
371
372impl ProjectLabelEntry {
373 #[must_use]
375 pub const fn name(&self) -> &ProjectKey {
376 &self.name
377 }
378
379 #[must_use]
384 pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
385 &self.value
386 }
387
388 #[must_use]
390 pub const fn syntax(&self) -> EntrySyntax {
391 self.syntax
392 }
393}
394
395#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct ProjectLabels {
398 entries: Vec<ProjectLabelEntry>,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct ProjectAnnotationScalar {
404 authored: String,
405 effective: ComposeScalar,
406}
407
408impl ProjectAnnotationScalar {
409 #[must_use]
411 pub fn authored(&self) -> &str {
412 &self.authored
413 }
414
415 #[must_use]
417 pub const fn effective(&self) -> &ComposeScalar {
418 &self.effective
419 }
420}
421
422#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct ProjectAnnotationEntry {
425 name: ProjectKey,
426 value: Option<ProjectValue<ProjectAnnotationScalar>>,
427 raw_list_item: Option<ProjectValue<ProjectAnnotationScalar>>,
428 syntax: EntrySyntax,
429 contributors: Vec<MergeProvenance>,
430}
431
432impl ProjectAnnotationEntry {
433 #[must_use]
435 pub const fn name(&self) -> &ProjectKey {
436 &self.name
437 }
438
439 #[must_use]
443 pub const fn value(&self) -> Option<&ProjectValue<ProjectAnnotationScalar>> {
444 self.value.as_ref()
445 }
446
447 #[must_use]
449 pub const fn raw_list_item(&self) -> Option<&ProjectValue<ProjectAnnotationScalar>> {
450 self.raw_list_item.as_ref()
451 }
452
453 #[must_use]
455 pub const fn syntax(&self) -> EntrySyntax {
456 self.syntax
457 }
458
459 #[must_use]
461 pub fn contributors(&self) -> &[MergeProvenance] {
462 &self.contributors
463 }
464}
465
466#[derive(Debug, Clone, PartialEq, Eq)]
468pub struct ProjectAnnotations {
469 entries: Vec<ProjectAnnotationEntry>,
470}
471
472impl ProjectAnnotations {
473 #[must_use]
475 pub fn entries(&self) -> &[ProjectAnnotationEntry] {
476 &self.entries
477 }
478
479 #[must_use]
481 pub fn get(&self, name: &str) -> Option<&ProjectAnnotationEntry> {
482 self.entries.iter().find(|entry| entry.name.value == name)
483 }
484}
485
486impl ProjectLabels {
487 #[must_use]
489 pub fn entries(&self) -> &[ProjectLabelEntry] {
490 &self.entries
491 }
492
493 #[must_use]
495 pub fn get(&self, name: &str) -> Option<&ProjectLabelEntry> {
496 self.entries.iter().find(|entry| entry.name.value == name)
497 }
498}
499
500#[derive(Debug, Clone, PartialEq, Eq)]
502pub struct ProjectExtraHost {
503 hostname: ProjectKey,
504 address: ProjectValue<HostAddress>,
505 syntax: EntrySyntax,
506}
507
508impl ProjectExtraHost {
509 #[must_use]
511 pub const fn hostname(&self) -> &ProjectKey {
512 &self.hostname
513 }
514
515 #[must_use]
517 pub const fn address(&self) -> &ProjectValue<HostAddress> {
518 &self.address
519 }
520
521 #[must_use]
523 pub const fn syntax(&self) -> EntrySyntax {
524 self.syntax
525 }
526}
527
528#[derive(Debug, Clone, PartialEq, Eq)]
530pub struct ProjectExtraHosts {
531 entries: Vec<ProjectExtraHost>,
532}
533
534impl ProjectExtraHosts {
535 #[must_use]
537 pub fn entries(&self) -> &[ProjectExtraHost] {
538 &self.entries
539 }
540}
541
542impl ProjectEnvironment {
543 #[must_use]
545 pub fn entries(&self) -> &[ProjectEnvironmentEntry] {
546 &self.entries
547 }
548
549 #[must_use]
551 pub fn get(&self, name: &str) -> Option<&ProjectEnvironmentEntry> {
552 self.entries.iter().find(|entry| entry.name.value == name)
553 }
554}
555
556#[derive(Debug, Clone, PartialEq, Eq)]
558pub struct ProjectHealthcheck {
559 test: Option<ProjectValue<HealthcheckTest>>,
560 interval: Option<ProjectValue<HealthcheckDuration>>,
561 timeout: Option<ProjectValue<HealthcheckDuration>>,
562 retries: Option<ProjectValue<HealthcheckRetries>>,
563 start_period: Option<ProjectValue<HealthcheckDuration>>,
564 start_interval: Option<ProjectValue<HealthcheckDuration>>,
565 disable: Option<ProjectValue<BooleanValue>>,
566 unmodeled_fields: Vec<ProjectFieldReference>,
567}
568
569#[derive(Debug, Clone, PartialEq, Eq)]
571pub struct ProjectLongGrant {
572 source: Option<ProjectValue<String>>,
573 target: Option<ProjectValue<String>>,
574 uid: Option<ProjectValue<String>>,
575 gid: Option<ProjectValue<String>>,
576 mode: Option<ProjectValue<String>>,
577 unmodeled_fields: Vec<ProjectFieldReference>,
578}
579
580impl ProjectLongGrant {
581 #[must_use]
583 pub const fn source(&self) -> Option<&ProjectValue<String>> {
584 self.source.as_ref()
585 }
586
587 #[must_use]
589 pub const fn target(&self) -> Option<&ProjectValue<String>> {
590 self.target.as_ref()
591 }
592
593 #[must_use]
595 pub const fn uid(&self) -> Option<&ProjectValue<String>> {
596 self.uid.as_ref()
597 }
598
599 #[must_use]
601 pub const fn gid(&self) -> Option<&ProjectValue<String>> {
602 self.gid.as_ref()
603 }
604
605 #[must_use]
607 pub const fn mode(&self) -> Option<&ProjectValue<String>> {
608 self.mode.as_ref()
609 }
610
611 #[must_use]
613 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
614 &self.unmodeled_fields
615 }
616}
617
618#[derive(Debug, Clone, PartialEq, Eq)]
620pub enum ProjectGrant {
621 Short(String),
623 Long(Box<ProjectLongGrant>),
625}
626
627#[derive(Debug, Clone, PartialEq, Eq)]
629pub struct ProjectLongDevice {
630 source: Option<ProjectValue<String>>,
631 target: Option<ProjectValue<String>>,
632 permissions: Option<ProjectValue<String>>,
633 extension_fields: Vec<ProjectFieldReference>,
634 unknown_fields: Vec<ProjectFieldReference>,
635}
636
637impl ProjectLongDevice {
638 #[must_use]
640 pub const fn source(&self) -> Option<&ProjectValue<String>> {
641 self.source.as_ref()
642 }
643
644 #[must_use]
646 pub const fn target(&self) -> Option<&ProjectValue<String>> {
647 self.target.as_ref()
648 }
649
650 #[must_use]
652 pub const fn permissions(&self) -> Option<&ProjectValue<String>> {
653 self.permissions.as_ref()
654 }
655
656 #[must_use]
658 pub fn extension_fields(&self) -> &[ProjectFieldReference] {
659 &self.extension_fields
660 }
661
662 #[must_use]
664 pub fn unknown_fields(&self) -> &[ProjectFieldReference] {
665 &self.unknown_fields
666 }
667}
668
669#[derive(Debug, Clone, PartialEq, Eq)]
671#[non_exhaustive]
672pub enum ProjectDevice {
673 Short(ShortDevice),
675 Long(ProjectLongDevice),
677}
678
679impl ProjectHealthcheck {
680 #[must_use]
682 pub const fn test(&self) -> Option<&ProjectValue<HealthcheckTest>> {
683 self.test.as_ref()
684 }
685
686 #[must_use]
688 pub const fn interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
689 self.interval.as_ref()
690 }
691
692 #[must_use]
694 pub const fn timeout(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
695 self.timeout.as_ref()
696 }
697
698 #[must_use]
700 pub const fn retries(&self) -> Option<&ProjectValue<HealthcheckRetries>> {
701 self.retries.as_ref()
702 }
703
704 #[must_use]
706 pub const fn start_period(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
707 self.start_period.as_ref()
708 }
709
710 #[must_use]
712 pub const fn start_interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
713 self.start_interval.as_ref()
714 }
715
716 #[must_use]
718 pub const fn disable(&self) -> Option<&ProjectValue<BooleanValue>> {
719 self.disable.as_ref()
720 }
721
722 #[must_use]
724 pub fn is_disabled(&self) -> bool {
725 matches!(
726 self.disable.as_ref().map(ProjectValue::value),
727 Some(BooleanValue::Literal(true))
728 ) || matches!(
729 self.test.as_ref().and_then(|test| test.value().kind()),
730 Some(HealthcheckTestKind::None)
731 )
732 }
733
734 #[must_use]
736 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
737 &self.unmodeled_fields
738 }
739}
740
741#[derive(Debug, Clone, PartialEq, Eq)]
743#[non_exhaustive]
744pub enum ProjectTmpfs {
745 Scalar(ProjectValue<TmpfsItem>),
747 List(Vec<ProjectValue<TmpfsItem>>),
749}
750
751#[derive(Debug, Clone, PartialEq, Eq)]
753#[non_exhaustive]
754pub enum ProjectDns {
755 Scalar(ProjectValue<String>),
757 List(Vec<ProjectValue<String>>),
759}
760
761#[derive(Debug, Clone, PartialEq, Eq)]
763#[non_exhaustive]
764pub enum ProjectDnsSearch {
765 Scalar(ProjectValue<String>),
767 List(Vec<ProjectValue<String>>),
769}
770
771#[derive(Debug, Clone, PartialEq, Eq)]
773pub struct ProjectExposeItem {
774 authored: String,
775 value: String,
776 scalar_kind: ExposeScalarKind,
777 kind: ExposeItemKind,
778}
779
780#[derive(Debug, Clone, PartialEq, Eq)]
782pub struct ProjectSecurityOptionItem {
783 authored: String,
784 value: String,
785 scalar_kind: MergedScalarKind,
786 kind: SecurityOptionKind,
787}
788
789impl ProjectSecurityOptionItem {
790 #[must_use]
792 pub fn authored(&self) -> &str {
793 &self.authored
794 }
795
796 #[must_use]
798 pub fn value(&self) -> &str {
799 &self.value
800 }
801
802 #[must_use]
804 pub const fn scalar_kind(&self) -> MergedScalarKind {
805 self.scalar_kind
806 }
807
808 #[must_use]
810 pub const fn kind(&self) -> &SecurityOptionKind {
811 &self.kind
812 }
813}
814
815impl ProjectExposeItem {
816 #[must_use]
818 pub fn authored(&self) -> &str {
819 &self.authored
820 }
821
822 #[must_use]
824 pub fn value(&self) -> &str {
825 &self.value
826 }
827
828 #[must_use]
830 pub const fn scalar_kind(&self) -> ExposeScalarKind {
831 self.scalar_kind
832 }
833
834 #[must_use]
836 pub const fn kind(&self) -> &ExposeItemKind {
837 &self.kind
838 }
839}
840
841#[derive(Debug, Clone, PartialEq, Eq)]
843pub struct ProjectUlimitScalar {
844 authored: String,
845 value: LimitValue,
846 kind: MergedScalarKind,
847}
848
849impl ProjectUlimitScalar {
850 #[must_use]
852 pub fn authored(&self) -> &str {
853 &self.authored
854 }
855
856 #[must_use]
858 pub const fn value(&self) -> &LimitValue {
859 &self.value
860 }
861
862 #[must_use]
864 pub const fn kind(&self) -> MergedScalarKind {
865 self.kind
866 }
867}
868
869#[derive(Debug, Clone, PartialEq, Eq)]
871pub struct ProjectUlimitRange {
872 soft: Option<ProjectValue<ProjectUlimitScalar>>,
873 hard: Option<ProjectValue<ProjectUlimitScalar>>,
874 unmodeled_fields: Vec<ProjectFieldReference>,
875}
876
877impl ProjectUlimitRange {
878 #[must_use]
880 pub const fn soft(&self) -> Option<&ProjectValue<ProjectUlimitScalar>> {
881 self.soft.as_ref()
882 }
883
884 #[must_use]
886 pub const fn hard(&self) -> Option<&ProjectValue<ProjectUlimitScalar>> {
887 self.hard.as_ref()
888 }
889
890 #[must_use]
892 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
893 &self.unmodeled_fields
894 }
895}
896
897#[derive(Debug, Clone, PartialEq, Eq)]
899#[non_exhaustive]
900pub enum ProjectUlimitValue {
901 Single(ProjectValue<ProjectUlimitScalar>),
903 Range(ProjectUlimitRange),
905}
906
907#[derive(Debug, Clone, PartialEq, Eq)]
909pub struct ProjectUlimit {
910 name: ProjectKey,
911 value: ProjectUlimitValue,
912}
913
914impl ProjectUlimit {
915 #[must_use]
917 pub const fn name(&self) -> &ProjectKey {
918 &self.name
919 }
920
921 #[must_use]
923 pub const fn value(&self) -> &ProjectUlimitValue {
924 &self.value
925 }
926}
927
928#[derive(Debug, Clone, PartialEq, Eq)]
930pub struct ProjectUlimits {
931 entries: Vec<ProjectValue<ProjectUlimit>>,
932}
933
934impl ProjectUlimits {
935 #[must_use]
937 pub fn entries(&self) -> &[ProjectValue<ProjectUlimit>] {
938 &self.entries
939 }
940
941 #[must_use]
943 pub fn is_empty(&self) -> bool {
944 self.entries.is_empty()
945 }
946}
947
948#[derive(Debug, Clone, PartialEq, Eq)]
950pub struct ProjectSysctl {
951 name: ProjectKey,
952 value: ProjectValue<ComposeScalar>,
953}
954
955impl ProjectSysctl {
956 #[must_use]
958 pub const fn name(&self) -> &ProjectKey {
959 &self.name
960 }
961
962 #[must_use]
964 pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
965 &self.value
966 }
967}
968
969#[derive(Debug, Clone, PartialEq, Eq)]
971#[non_exhaustive]
972pub enum ProjectSysctls {
973 Map(Vec<ProjectValue<ProjectSysctl>>),
975 List(Vec<ProjectValue<String>>),
977}
978
979#[derive(Debug, Clone, PartialEq, Eq)]
981pub struct ProjectService {
982 name: ProjectKey,
983 provenance: MergeProvenance,
984 hostname: Option<ProjectValue<Hostname>>,
985 container_name: Option<ProjectValue<String>>,
986 image: Option<ProjectValue<ImageReference>>,
987 entrypoint: Option<ProjectValue<Entrypoint>>,
988 command: Option<ProjectValue<Command>>,
989 init: Option<ProjectValue<BooleanValue>>,
990 environment: Option<ProjectValue<ProjectEnvironment>>,
991 environment_files: Option<ProjectValue<Vec<ProjectValue<ProjectEnvironmentFile>>>>,
992 labels: Option<ProjectValue<ProjectLabels>>,
993 annotations: Option<ProjectValue<ProjectAnnotations>>,
994 extra_hosts: Option<ProjectValue<ProjectExtraHosts>>,
995 user: Option<ProjectValue<UserSpec>>,
996 userns_mode: Option<ProjectValue<UserNamespaceMode>>,
997 group_add: Option<ProjectValue<Vec<ProjectValue<String>>>>,
998 cap_add: Option<ProjectValue<Vec<ProjectValue<CapabilityAddItem>>>>,
999 cap_drop: Option<ProjectValue<Vec<ProjectValue<CapabilityDropItem>>>>,
1000 devices: Option<ProjectValue<Vec<ProjectValue<ProjectDevice>>>>,
1001 dns: Option<ProjectValue<ProjectDns>>,
1002 dns_options: Option<ProjectValue<Vec<ProjectValue<String>>>>,
1003 dns_search: Option<ProjectValue<ProjectDnsSearch>>,
1004 expose: Option<ProjectValue<Vec<ProjectValue<ProjectExposeItem>>>>,
1005 security_options: Option<ProjectValue<Vec<ProjectValue<ProjectSecurityOptionItem>>>>,
1006 working_dir: Option<ProjectValue<String>>,
1007 read_only: Option<ProjectValue<BooleanValue>>,
1008 pids_limit: Option<ProjectValue<PidsLimit>>,
1009 shm_size: Option<ProjectValue<ShmSize>>,
1010 mem_limit: Option<ProjectValue<MemLimit>>,
1011 tmpfs: Option<ProjectValue<ProjectTmpfs>>,
1012 sysctls: Option<ProjectValue<ProjectSysctls>>,
1013 ulimits: Option<ProjectValue<ProjectUlimits>>,
1014 pull_policy: Option<ProjectValue<PullPolicy>>,
1015 restart: Option<ProjectValue<RestartPolicy>>,
1016 stop_signal: Option<ProjectValue<String>>,
1017 stop_grace_period: Option<ProjectValue<StopGracePeriod>>,
1018 healthcheck: Option<ProjectValue<ProjectHealthcheck>>,
1019 depends_on: Option<ProjectValue<ProjectDependsOn>>,
1020 ports: Option<ProjectValue<Vec<ProjectValue<Port>>>>,
1021 volumes: Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>>,
1022 configs: Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>,
1023 secrets: Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>,
1024 networks: Option<ProjectValue<ServiceNetworks>>,
1025 profiles: Option<ProjectValue<Vec<ProjectValue<String>>>>,
1026 unmodeled_fields: Vec<ProjectFieldReference>,
1027}
1028
1029impl ProjectService {
1030 fn from_entry(entry: &MergedEntry) -> Self {
1031 Self {
1032 name: ProjectKey::from_entry(entry),
1033 provenance: entry.value().provenance().clone(),
1034 hostname: None,
1035 container_name: None,
1036 image: None,
1037 entrypoint: None,
1038 command: None,
1039 init: None,
1040 environment: None,
1041 environment_files: None,
1042 labels: None,
1043 annotations: None,
1044 extra_hosts: None,
1045 user: None,
1046 userns_mode: None,
1047 group_add: None,
1048 cap_add: None,
1049 cap_drop: None,
1050 devices: None,
1051 dns: None,
1052 dns_options: None,
1053 dns_search: None,
1054 expose: None,
1055 security_options: None,
1056 working_dir: None,
1057 read_only: None,
1058 pids_limit: None,
1059 shm_size: None,
1060 mem_limit: None,
1061 tmpfs: None,
1062 sysctls: None,
1063 ulimits: None,
1064 pull_policy: None,
1065 restart: None,
1066 stop_signal: None,
1067 stop_grace_period: None,
1068 healthcheck: None,
1069 depends_on: None,
1070 ports: None,
1071 volumes: None,
1072 configs: None,
1073 secrets: None,
1074 networks: None,
1075 profiles: None,
1076 unmodeled_fields: Vec::new(),
1077 }
1078 }
1079
1080 #[must_use]
1082 pub const fn name(&self) -> &ProjectKey {
1083 &self.name
1084 }
1085
1086 #[must_use]
1088 pub const fn provenance(&self) -> &MergeProvenance {
1089 &self.provenance
1090 }
1091
1092 #[must_use]
1094 pub const fn hostname(&self) -> Option<&ProjectValue<Hostname>> {
1095 self.hostname.as_ref()
1096 }
1097
1098 #[must_use]
1100 pub const fn container_name(&self) -> Option<&ProjectValue<String>> {
1101 self.container_name.as_ref()
1102 }
1103
1104 #[must_use]
1106 pub const fn image(&self) -> Option<&ProjectValue<ImageReference>> {
1107 self.image.as_ref()
1108 }
1109
1110 #[must_use]
1112 pub const fn entrypoint(&self) -> Option<&ProjectValue<Entrypoint>> {
1113 self.entrypoint.as_ref()
1114 }
1115
1116 #[must_use]
1118 pub const fn command(&self) -> Option<&ProjectValue<Command>> {
1119 self.command.as_ref()
1120 }
1121
1122 #[must_use]
1124 pub const fn init(&self) -> Option<&ProjectValue<BooleanValue>> {
1125 self.init.as_ref()
1126 }
1127
1128 #[must_use]
1130 pub const fn environment(&self) -> Option<&ProjectValue<ProjectEnvironment>> {
1131 self.environment.as_ref()
1132 }
1133
1134 #[must_use]
1136 pub const fn environment_files(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectEnvironmentFile>>>> {
1137 self.environment_files.as_ref()
1138 }
1139
1140 #[must_use]
1142 pub const fn labels(&self) -> Option<&ProjectValue<ProjectLabels>> {
1143 self.labels.as_ref()
1144 }
1145
1146 #[must_use]
1148 pub const fn annotations(&self) -> Option<&ProjectValue<ProjectAnnotations>> {
1149 self.annotations.as_ref()
1150 }
1151
1152 #[must_use]
1154 pub const fn extra_hosts(&self) -> Option<&ProjectValue<ProjectExtraHosts>> {
1155 self.extra_hosts.as_ref()
1156 }
1157
1158 #[must_use]
1160 pub const fn user(&self) -> Option<&ProjectValue<UserSpec>> {
1161 self.user.as_ref()
1162 }
1163
1164 #[must_use]
1166 pub const fn userns_mode(&self) -> Option<&ProjectValue<UserNamespaceMode>> {
1167 self.userns_mode.as_ref()
1168 }
1169
1170 #[must_use]
1172 pub const fn group_add(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
1173 self.group_add.as_ref()
1174 }
1175
1176 #[must_use]
1181 pub const fn cap_add(&self) -> Option<&ProjectValue<Vec<ProjectValue<CapabilityAddItem>>>> {
1182 self.cap_add.as_ref()
1183 }
1184
1185 #[must_use]
1190 pub const fn cap_drop(&self) -> Option<&ProjectValue<Vec<ProjectValue<CapabilityDropItem>>>> {
1191 self.cap_drop.as_ref()
1192 }
1193
1194 #[must_use]
1198 pub const fn devices(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectDevice>>>> {
1199 self.devices.as_ref()
1200 }
1201
1202 #[must_use]
1204 pub const fn dns(&self) -> Option<&ProjectValue<ProjectDns>> {
1205 self.dns.as_ref()
1206 }
1207
1208 #[must_use]
1213 pub const fn dns_options(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
1214 self.dns_options.as_ref()
1215 }
1216
1217 #[must_use]
1219 pub const fn dns_search(&self) -> Option<&ProjectValue<ProjectDnsSearch>> {
1220 self.dns_search.as_ref()
1221 }
1222
1223 #[must_use]
1228 pub const fn expose(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectExposeItem>>>> {
1229 self.expose.as_ref()
1230 }
1231
1232 #[must_use]
1237 pub const fn security_options(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectSecurityOptionItem>>>> {
1238 self.security_options.as_ref()
1239 }
1240
1241 #[must_use]
1243 pub const fn working_dir(&self) -> Option<&ProjectValue<String>> {
1244 self.working_dir.as_ref()
1245 }
1246
1247 #[must_use]
1249 pub const fn read_only(&self) -> Option<&ProjectValue<BooleanValue>> {
1250 self.read_only.as_ref()
1251 }
1252
1253 #[must_use]
1255 pub const fn pids_limit(&self) -> Option<&ProjectValue<PidsLimit>> {
1256 self.pids_limit.as_ref()
1257 }
1258
1259 #[must_use]
1261 pub const fn shm_size(&self) -> Option<&ProjectValue<ShmSize>> {
1262 self.shm_size.as_ref()
1263 }
1264
1265 #[must_use]
1267 pub const fn mem_limit(&self) -> Option<&ProjectValue<MemLimit>> {
1268 self.mem_limit.as_ref()
1269 }
1270
1271 #[must_use]
1273 pub const fn tmpfs(&self) -> Option<&ProjectValue<ProjectTmpfs>> {
1274 self.tmpfs.as_ref()
1275 }
1276
1277 #[must_use]
1279 pub const fn sysctls(&self) -> Option<&ProjectValue<ProjectSysctls>> {
1280 self.sysctls.as_ref()
1281 }
1282
1283 #[must_use]
1288 pub const fn ulimits(&self) -> Option<&ProjectValue<ProjectUlimits>> {
1289 self.ulimits.as_ref()
1290 }
1291
1292 #[must_use]
1294 pub const fn pull_policy(&self) -> Option<&ProjectValue<PullPolicy>> {
1295 self.pull_policy.as_ref()
1296 }
1297
1298 #[must_use]
1300 pub const fn restart(&self) -> Option<&ProjectValue<RestartPolicy>> {
1301 self.restart.as_ref()
1302 }
1303
1304 #[must_use]
1306 pub const fn stop_signal(&self) -> Option<&ProjectValue<String>> {
1307 self.stop_signal.as_ref()
1308 }
1309
1310 #[must_use]
1312 pub const fn stop_grace_period(&self) -> Option<&ProjectValue<StopGracePeriod>> {
1313 self.stop_grace_period.as_ref()
1314 }
1315
1316 #[must_use]
1318 pub const fn healthcheck(&self) -> Option<&ProjectValue<ProjectHealthcheck>> {
1319 self.healthcheck.as_ref()
1320 }
1321
1322 #[must_use]
1324 pub const fn depends_on(&self) -> Option<&ProjectValue<ProjectDependsOn>> {
1325 self.depends_on.as_ref()
1326 }
1327
1328 #[must_use]
1330 pub const fn ports(&self) -> Option<&ProjectValue<Vec<ProjectValue<Port>>>> {
1331 self.ports.as_ref()
1332 }
1333
1334 #[must_use]
1336 pub const fn volumes(&self) -> Option<&ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
1337 self.volumes.as_ref()
1338 }
1339
1340 #[must_use]
1342 pub const fn configs(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
1343 self.configs.as_ref()
1344 }
1345
1346 #[must_use]
1348 pub const fn secrets(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
1349 self.secrets.as_ref()
1350 }
1351
1352 #[must_use]
1354 pub const fn networks(&self) -> Option<&ProjectValue<ServiceNetworks>> {
1355 self.networks.as_ref()
1356 }
1357
1358 #[must_use]
1360 pub const fn profiles(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
1361 self.profiles.as_ref()
1362 }
1363
1364 #[must_use]
1366 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
1367 &self.unmodeled_fields
1368 }
1369}
1370
1371#[derive(Debug, Clone, PartialEq, Eq)]
1373pub struct ProjectResource<T> {
1374 name: ProjectKey,
1375 definition: ProjectValue<T>,
1376}
1377
1378impl<T> ProjectResource<T> {
1379 #[must_use]
1381 pub const fn name(&self) -> &ProjectKey {
1382 &self.name
1383 }
1384
1385 #[must_use]
1387 pub const fn definition(&self) -> &ProjectValue<T> {
1388 &self.definition
1389 }
1390}
1391
1392#[derive(Debug, Clone, PartialEq, Eq)]
1394pub struct ProjectView {
1395 source_ids: Vec<SourceId>,
1396 base_directory: PathBuf,
1397 provenance: MergeProvenance,
1398 name: Option<ProjectValue<String>>,
1399 services: Vec<ProjectService>,
1400 networks: Vec<ProjectResource<NetworkDefinition>>,
1401 volumes: Vec<ProjectResource<VolumeDefinition>>,
1402 configs: Vec<ProjectResource<ConfigDefinition>>,
1403 secrets: Vec<ProjectResource<SecretDefinition>>,
1404 unmodeled_fields: Vec<ProjectFieldReference>,
1405}
1406
1407impl ProjectView {
1408 #[must_use]
1410 pub fn source_ids(&self) -> &[SourceId] {
1411 &self.source_ids
1412 }
1413
1414 #[must_use]
1416 pub fn base_directory(&self) -> &Path {
1417 &self.base_directory
1418 }
1419
1420 #[must_use]
1422 pub const fn provenance(&self) -> &MergeProvenance {
1423 &self.provenance
1424 }
1425
1426 #[must_use]
1428 pub const fn name(&self) -> Option<&ProjectValue<String>> {
1429 self.name.as_ref()
1430 }
1431
1432 #[must_use]
1434 pub fn services(&self) -> &[ProjectService] {
1435 &self.services
1436 }
1437
1438 #[must_use]
1440 pub fn service(&self, name: &str) -> Option<&ProjectService> {
1441 self.services.iter().find(|service| service.name.value == name)
1442 }
1443
1444 #[must_use]
1446 pub fn networks(&self) -> &[ProjectResource<NetworkDefinition>] {
1447 &self.networks
1448 }
1449
1450 #[must_use]
1452 pub fn volumes(&self) -> &[ProjectResource<VolumeDefinition>] {
1453 &self.volumes
1454 }
1455
1456 #[must_use]
1458 pub fn configs(&self) -> &[ProjectResource<ConfigDefinition>] {
1459 &self.configs
1460 }
1461
1462 #[must_use]
1464 pub fn secrets(&self) -> &[ProjectResource<SecretDefinition>] {
1465 &self.secrets
1466 }
1467
1468 #[must_use]
1470 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
1471 &self.unmodeled_fields
1472 }
1473}
1474
1475#[derive(Debug, Clone, PartialEq, Eq)]
1477pub struct ProjectViewResult {
1478 view: Option<ProjectView>,
1479 diagnostics: Vec<Diagnostic>,
1480}
1481
1482impl ProjectViewResult {
1483 #[must_use]
1485 pub const fn view(&self) -> Option<&ProjectView> {
1486 self.view.as_ref()
1487 }
1488
1489 #[must_use]
1491 pub fn diagnostics(&self) -> &[Diagnostic] {
1492 &self.diagnostics
1493 }
1494
1495 #[must_use]
1497 pub fn is_valid(&self) -> bool {
1498 self.view.is_some()
1499 && self
1500 .diagnostics
1501 .iter()
1502 .all(|diagnostic| diagnostic.severity() != Severity::Error)
1503 }
1504
1505 #[must_use]
1507 pub fn into_parts(self) -> (Option<ProjectView>, Vec<Diagnostic>) {
1508 (self.view, self.diagnostics)
1509 }
1510}
1511
1512#[must_use]
1517pub fn build_project_view(project: &MergedProject, selection: Option<&ProfileSelection>) -> ProjectViewResult {
1518 if selection.is_some_and(|selection| !selection.belongs_to(project)) {
1519 return ProjectViewResult {
1520 view: None,
1521 diagnostics: vec![Diagnostic::new(
1522 SELECTION_PROJECT_MISMATCH,
1523 Severity::Error,
1524 "profile selection does not belong to the merged project",
1525 )],
1526 };
1527 }
1528
1529 Builder::new(project, selection).build()
1530}
1531
1532struct Builder<'a> {
1533 project: &'a MergedProject,
1534 selection: Option<&'a ProfileSelection>,
1535 diagnostics: Vec<Diagnostic>,
1536 root_unmodeled: Vec<ProjectFieldReference>,
1537 pending_unmodeled: Vec<ProjectFieldReference>,
1538}
1539
1540impl<'a> Builder<'a> {
1541 const fn new(project: &'a MergedProject, selection: Option<&'a ProfileSelection>) -> Self {
1542 Self {
1543 project,
1544 selection,
1545 diagnostics: Vec::new(),
1546 root_unmodeled: Vec::new(),
1547 pending_unmodeled: Vec::new(),
1548 }
1549 }
1550
1551 fn build(mut self) -> ProjectViewResult {
1552 let root = self.project.root();
1553 let entries = root.as_mapping().unwrap_or_default();
1554 let mut name = None;
1555 let mut services = Vec::new();
1556 let mut networks = Vec::new();
1557 let mut volumes = Vec::new();
1558 let mut configs = Vec::new();
1559 let mut secrets = Vec::new();
1560
1561 for entry in entries {
1562 match entry.key() {
1563 "name" => name = self.project_string(entry.value(), "project name"),
1564 "services" => services = self.services(entry.value()),
1565 "networks" => networks = self.network_definitions(entry.value()),
1566 "volumes" => volumes = self.volume_definitions(entry.value()),
1567 "configs" => configs = self.config_definitions(entry.value()),
1568 "secrets" => secrets = self.secret_definitions(entry.value()),
1569 _ => self.record_root_unmodeled(&[], entry),
1570 }
1571 }
1572
1573 ProjectViewResult {
1574 view: Some(ProjectView {
1575 source_ids: self.project.source_ids().to_vec(),
1576 base_directory: self.project.base_directory().to_path_buf(),
1577 provenance: root.provenance().clone(),
1578 name,
1579 services,
1580 networks,
1581 volumes,
1582 configs,
1583 secrets,
1584 unmodeled_fields: self.root_unmodeled,
1585 }),
1586 diagnostics: self.diagnostics,
1587 }
1588 }
1589
1590 fn services(&mut self, value: &MergedValue) -> Vec<ProjectService> {
1591 let Some(entries) = self.mapping(value, "services must be a mapping") else {
1592 return Vec::new();
1593 };
1594 let selection = self.selection;
1595 let mut services = Vec::new();
1596 for entry in entries {
1597 if service_in_scope(selection, entry.key()) {
1598 services.extend(self.service(entry));
1599 }
1600 }
1601 services
1602 }
1603
1604 fn service(&mut self, entry: &MergedEntry) -> Option<ProjectService> {
1605 let pending_start = self.pending_unmodeled.len();
1606 let value = entry.value();
1607 let fields = self.mapping(value, "service definition must be a mapping")?;
1608 let mut service = ProjectService::from_entry(entry);
1609 let path = ["services".to_owned(), entry.key().to_owned()];
1610
1611 for field in fields {
1612 match field.key() {
1613 "hostname" => service.hostname = self.hostname(field.value()),
1614 "container_name" => {
1615 service.container_name = self.project_string(field.value(), "service container name");
1616 }
1617 "image" => {
1618 service.image = self
1619 .project_string(field.value(), "service image")
1620 .map(|value| ProjectValue {
1621 value: ImageReference::parse(value.value),
1622 provenance: value.provenance,
1623 sensitive: value.sensitive,
1624 });
1625 }
1626 "entrypoint" => service.entrypoint = self.entrypoint(field.value()),
1627 "command" => service.command = self.command(field.value()),
1628 "init" => {
1629 service.init = self
1630 .located_boolean(field.value(), "service init must be a boolean")
1631 .map(|value| ProjectValue::new(value.into_value(), field.value()));
1632 }
1633 "environment" => service.environment = self.environment(field.value()),
1634 "env_file" => service.environment_files = self.environment_files(field.value(), &path),
1635 "labels" => service.labels = self.service_labels(field.value()),
1636 "annotations" => service.annotations = self.service_annotations(field.value()),
1637 "extra_hosts" => service.extra_hosts = self.extra_hosts(field.value()),
1638 "user" => service.user = self.user(field.value()),
1639 "userns_mode" => service.userns_mode = self.userns_mode(field.value()),
1640 "group_add" => {
1641 service.group_add = self.string_collection(field.value(), "group_add must be a sequence");
1642 }
1643 "cap_add" => service.cap_add = self.capability_add(field.value()),
1644 "cap_drop" => service.cap_drop = self.capability_drop(field.value()),
1645 "devices" => service.devices = self.devices(field.value(), &path),
1646 "dns" => service.dns = self.dns(field.value()),
1647 "dns_opt" => service.dns_options = self.dns_options(field.value()),
1648 "dns_search" => service.dns_search = self.dns_search(field.value()),
1649 "expose" => service.expose = self.expose(field.value()),
1650 "security_opt" => service.security_options = self.security_options(field.value()),
1651 "working_dir" => service.working_dir = self.project_string(field.value(), "service working directory"),
1652 "read_only" => {
1653 service.read_only = self
1654 .located_boolean(field.value(), "service read_only must be a boolean")
1655 .map(|value| ProjectValue::new(value.into_value(), field.value()));
1656 }
1657 "pids_limit" => service.pids_limit = self.pids_limit(field.value()),
1658 "shm_size" => service.shm_size = self.shm_size(field.value()),
1659 "mem_limit" => service.mem_limit = self.mem_limit(field.value()),
1660 "tmpfs" => service.tmpfs = self.tmpfs(field.value()),
1661 "sysctls" => service.sysctls = self.sysctls(field.value()),
1662 "ulimits" => service.ulimits = self.ulimits(field.value(), &path),
1663 "pull_policy" => service.pull_policy = self.pull_policy(field.value()),
1664 "restart" => service.restart = self.restart_policy(field.value()),
1665 "stop_signal" => {
1666 service.stop_signal = self.project_string(field.value(), "service stop signal");
1667 }
1668 "stop_grace_period" => {
1669 service.stop_grace_period = self.stop_grace_period(field.value());
1670 }
1671 "healthcheck" => service.healthcheck = self.healthcheck(field.value(), &path),
1672 "depends_on" => service.depends_on = self.depends_on(field.value(), &path),
1673 "ports" => service.ports = self.ports(field.value(), &path),
1674 "volumes" => service.volumes = self.volumes(field.value(), &path),
1675 "configs" => service.configs = self.grants(field.value(), &path, "config"),
1676 "secrets" => service.secrets = self.grants(field.value(), &path, "secret"),
1677 "networks" => service.networks = self.service_networks(field.value(), &path),
1678 "profiles" => service.profiles = self.string_collection(field.value(), "profiles must be a sequence"),
1679 _ => service.unmodeled_fields.push(field_reference(&path, field)),
1680 }
1681 }
1682 service
1683 .unmodeled_fields
1684 .extend(self.pending_unmodeled.drain(pending_start..));
1685 Some(service)
1686 }
1687
1688 fn hostname(&mut self, value: &MergedValue) -> Option<ProjectValue<Hostname>> {
1689 let scalar = match value.kind() {
1690 MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
1691 _ => {
1692 self.expected(value, "hostname must be a YAML string scalar");
1693 return None;
1694 }
1695 };
1696 let hostname = Hostname::parse(Located::new(scalar.value().to_owned(), effective_span(value)));
1697 if hostname.kind() == &HostnameKind::Invalid {
1698 self.invalid(
1699 effective_span(value),
1700 "hostname must be an ASCII RFC-1123 name of 1 to 253 characters with dot-separated labels of 1 to 63 alphanumeric or hyphen characters; each label must start and end alphanumeric",
1701 );
1702 }
1703 Some(ProjectValue::new(hostname, value))
1704 }
1705
1706 fn restart_policy(&mut self, value: &MergedValue) -> Option<ProjectValue<RestartPolicy>> {
1707 let policy = RestartPolicy::parse(self.located_string(value, "restart must be a non-null scalar")?);
1708 if !policy.is_valid() {
1709 self.invalid(
1710 effective_span(value),
1711 "restart must be `no`, `always`, `on-failure[:max-retries]`, or `unless-stopped`",
1712 );
1713 }
1714 Some(ProjectValue::new(policy, value))
1715 }
1716
1717 fn pids_limit(&mut self, value: &MergedValue) -> Option<ProjectValue<PidsLimit>> {
1718 let scalar = match value.kind() {
1719 MergedValueKind::Scalar(scalar) if scalar.kind() != MergedScalarKind::Boolean => scalar,
1720 _ => {
1721 self.expected(value, "pids_limit must be a number or string scalar");
1722 return None;
1723 }
1724 };
1725 let limit = PidsLimit::parse(Located::new(scalar.value().to_owned(), effective_span(value)));
1726 match limit.kind() {
1727 PidsLimitKind::Zero => self.diagnostics.push(
1728 Diagnostic::new(
1729 PIDS_LIMIT_AMBIGUOUS_ZERO,
1730 Severity::Warning,
1731 "pids_limit zero is preserved as an ambiguous and unportable native state",
1732 )
1733 .with_label(DiagnosticLabel::primary(
1734 effective_span(value),
1735 "ambiguous zero PID limit",
1736 )),
1737 ),
1738 PidsLimitKind::Other => self.invalid(
1739 effective_span(value),
1740 "pids_limit must be `-1`, a positive integral decimal, or interpolation",
1741 ),
1742 _ => {}
1743 }
1744 Some(ProjectValue::new(limit, value))
1745 }
1746
1747 fn shm_size(&mut self, value: &MergedValue) -> Option<ProjectValue<ShmSize>> {
1748 let scalar = match value.kind() {
1749 MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::Number => {
1750 (scalar, ShmSizeScalarKind::Number)
1751 }
1752 MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
1753 (scalar, ShmSizeScalarKind::String)
1754 }
1755 _ => {
1756 self.diagnostics.push(
1757 Diagnostic::new(
1758 SHM_SIZE_EXPECTED_VALUE,
1759 Severity::Error,
1760 "shm_size must be a YAML number or string scalar",
1761 )
1762 .with_label(DiagnosticLabel::primary(
1763 effective_span(value),
1764 "unexpected shared-memory-size form",
1765 )),
1766 );
1767 return None;
1768 }
1769 };
1770 let size = ShmSize::parse(
1771 Located::new(scalar.0.value().to_owned(), effective_span(value)),
1772 scalar.1,
1773 );
1774 let (code, message, label, note) = match size.kind() {
1775 ShmSizeKind::Zero { .. } => (
1776 SHM_SIZE_AMBIGUOUS_ZERO,
1777 "shm_size zero is preserved because Compose does not define its semantics",
1778 "ambiguous zero shared-memory size",
1779 "choose a positive size with an explicit documented lowercase unit",
1780 ),
1781 ShmSizeKind::ProviderDependentNumber => (
1782 SHM_SIZE_PROVIDER_DEPENDENT_NUMBER,
1783 "numeric shm_size is schema-accepted but lacks a documented explicit unit",
1784 "provider-dependent numeric shared-memory size",
1785 "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for portable intent",
1786 ),
1787 ShmSizeKind::ProviderDependentString => (
1788 SHM_SIZE_PROVIDER_DEPENDENT_STRING,
1789 "string shm_size is schema-accepted but falls outside the documented lowercase suffix family",
1790 "provider-dependent string shared-memory size",
1791 "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
1792 ),
1793 ShmSizeKind::Documented { .. } | ShmSizeKind::Expression => {
1794 return Some(ProjectValue::new(size, value));
1795 }
1796 };
1797 self.diagnostics.push(
1798 Diagnostic::new(code, Severity::Warning, message)
1799 .with_label(DiagnosticLabel::primary(effective_span(value), label))
1800 .with_note(note),
1801 );
1802 Some(ProjectValue::new(size, value))
1803 }
1804
1805 fn mem_limit(&mut self, value: &MergedValue) -> Option<ProjectValue<MemLimit>> {
1806 let scalar = match value.kind() {
1807 MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::Number => {
1808 (scalar, MemLimitScalarKind::Number)
1809 }
1810 MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
1811 (scalar, MemLimitScalarKind::String)
1812 }
1813 _ => {
1814 self.diagnostics.push(
1815 Diagnostic::new(
1816 MEM_LIMIT_EXPECTED_VALUE,
1817 Severity::Error,
1818 "mem_limit must be a YAML number or string scalar",
1819 )
1820 .with_label(DiagnosticLabel::primary(
1821 effective_span(value),
1822 "unexpected memory-limit form",
1823 )),
1824 );
1825 return None;
1826 }
1827 };
1828 let limit = MemLimit::parse(
1829 Located::new(scalar.0.value().to_owned(), effective_span(value)),
1830 scalar.1,
1831 );
1832 let (code, message, label, note) = match limit.kind() {
1833 MemLimitKind::Zero { .. } => (
1834 MEM_LIMIT_AMBIGUOUS_ZERO,
1835 "mem_limit zero is preserved without inferring portable runtime behavior",
1836 "ambiguous zero memory limit",
1837 "choose a positive size with an explicit documented lowercase unit",
1838 ),
1839 MemLimitKind::SchemaNumber => (
1840 MEM_LIMIT_SCHEMA_NUMBER,
1841 "numeric mem_limit is schema-accepted but lacks a documented explicit unit",
1842 "schema-only numeric memory limit",
1843 "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for explicit intent",
1844 ),
1845 MemLimitKind::ProviderDependentString => (
1846 MEM_LIMIT_PROVIDER_DEPENDENT_STRING,
1847 "string mem_limit is schema-accepted but falls outside the documented lowercase suffix family",
1848 "provider-dependent string memory limit",
1849 "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
1850 ),
1851 MemLimitKind::Documented { .. } | MemLimitKind::Expression => {
1852 return Some(ProjectValue::new(limit, value));
1853 }
1854 };
1855 self.diagnostics.push(
1856 Diagnostic::new(code, Severity::Warning, message)
1857 .with_label(DiagnosticLabel::primary(effective_span(value), label))
1858 .with_note(note),
1859 );
1860 Some(ProjectValue::new(limit, value))
1861 }
1862
1863 fn dns(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectDns>> {
1864 let form = match value.kind() {
1865 MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
1866 ProjectDns::Scalar(ProjectValue::new(scalar.value().to_owned(), value))
1867 }
1868 MergedValueKind::Sequence(values) => {
1869 let mut items = Vec::new();
1870 for item_value in values {
1871 let MergedValueKind::Scalar(scalar) = item_value.kind() else {
1872 self.diagnostics.push(
1873 Diagnostic::new(
1874 DNS_EXPECTED_STRING,
1875 Severity::Error,
1876 "dns entries must be string scalars",
1877 )
1878 .with_label(DiagnosticLabel::primary(
1879 effective_span(item_value),
1880 "unexpected DNS server list item",
1881 )),
1882 );
1883 continue;
1884 };
1885 if scalar.kind() != MergedScalarKind::String {
1886 self.diagnostics.push(
1887 Diagnostic::new(
1888 DNS_EXPECTED_STRING,
1889 Severity::Error,
1890 "dns entries must be string scalars",
1891 )
1892 .with_label(DiagnosticLabel::primary(
1893 effective_span(item_value),
1894 "unexpected DNS server list item",
1895 )),
1896 );
1897 continue;
1898 }
1899 items.push(ProjectValue::new(scalar.value().to_owned(), item_value));
1900 }
1901 ProjectDns::List(items)
1902 }
1903 _ => {
1904 self.diagnostics.push(
1905 Diagnostic::new(
1906 DNS_EXPECTED_FORM,
1907 Severity::Error,
1908 "dns must be a string scalar or a sequence of string scalars",
1909 )
1910 .with_label(DiagnosticLabel::primary(
1911 effective_span(value),
1912 "unexpected service DNS form",
1913 )),
1914 );
1915 return None;
1916 }
1917 };
1918 Some(ProjectValue::new(form, value))
1919 }
1920
1921 fn dns_options(&mut self, value: &MergedValue) -> Option<ProjectValue<Vec<ProjectValue<String>>>> {
1922 let MergedValueKind::Sequence(values) = value.kind() else {
1923 self.diagnostics.push(
1924 Diagnostic::new(
1925 DNS_OPT_EXPECTED_SEQUENCE,
1926 Severity::Error,
1927 "dns_opt must be a sequence of string scalars",
1928 )
1929 .with_label(DiagnosticLabel::primary(
1930 effective_span(value),
1931 "unexpected service DNS option form",
1932 )),
1933 );
1934 return None;
1935 };
1936 let mut items = Vec::new();
1937 let mut seen = BTreeSet::new();
1938 for item_value in values {
1939 let MergedValueKind::Scalar(scalar) = item_value.kind() else {
1940 self.diagnostics.push(
1941 Diagnostic::new(
1942 DNS_OPT_EXPECTED_STRING,
1943 Severity::Error,
1944 "dns_opt entries must be string scalars",
1945 )
1946 .with_label(DiagnosticLabel::primary(
1947 effective_span(item_value),
1948 "unexpected DNS option list item",
1949 )),
1950 );
1951 continue;
1952 };
1953 if scalar.kind() != MergedScalarKind::String {
1954 self.diagnostics.push(
1955 Diagnostic::new(
1956 DNS_OPT_EXPECTED_STRING,
1957 Severity::Error,
1958 "dns_opt entries must be string scalars",
1959 )
1960 .with_label(DiagnosticLabel::primary(
1961 effective_span(item_value),
1962 "unexpected DNS option list item",
1963 )),
1964 );
1965 continue;
1966 }
1967 if !seen.insert(scalar.value().to_owned()) {
1968 self.diagnostics.push(
1969 Diagnostic::new(
1970 DNS_OPT_DUPLICATE_ITEM,
1971 Severity::Warning,
1972 "dns_opt entries must be unique exact strings",
1973 )
1974 .with_label(DiagnosticLabel::primary(
1975 effective_span(item_value),
1976 "duplicate DNS option retained",
1977 )),
1978 );
1979 }
1980 items.push(ProjectValue::new(scalar.value().to_owned(), item_value));
1981 }
1982 Some(ProjectValue::new(items, value))
1983 }
1984
1985 fn dns_search(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectDnsSearch>> {
1986 let form = match value.kind() {
1987 MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
1988 ProjectDnsSearch::Scalar(ProjectValue::new(scalar.value().to_owned(), value))
1989 }
1990 MergedValueKind::Sequence(values) => {
1991 let mut items = Vec::new();
1992 let mut seen = BTreeSet::new();
1993 for item_value in values {
1994 let MergedValueKind::Scalar(scalar) = item_value.kind() else {
1995 self.diagnostics.push(
1996 Diagnostic::new(
1997 DNS_SEARCH_EXPECTED_STRING,
1998 Severity::Error,
1999 "dns_search entries must be string scalars",
2000 )
2001 .with_label(DiagnosticLabel::primary(
2002 effective_span(item_value),
2003 "unexpected DNS search-domain list item",
2004 )),
2005 );
2006 continue;
2007 };
2008 if scalar.kind() != MergedScalarKind::String {
2009 self.diagnostics.push(
2010 Diagnostic::new(
2011 DNS_SEARCH_EXPECTED_STRING,
2012 Severity::Error,
2013 "dns_search entries must be string scalars",
2014 )
2015 .with_label(DiagnosticLabel::primary(
2016 effective_span(item_value),
2017 "unexpected DNS search-domain list item",
2018 )),
2019 );
2020 continue;
2021 }
2022 if !seen.insert(scalar.value().to_owned()) {
2023 self.diagnostics.push(
2024 Diagnostic::new(
2025 DNS_SEARCH_DUPLICATE_ITEM,
2026 Severity::Warning,
2027 "dns_search schema entries are unique, but duplicate merge behavior is ambiguous",
2028 )
2029 .with_label(DiagnosticLabel::primary(
2030 effective_span(item_value),
2031 "duplicate DNS search domain retained",
2032 )),
2033 );
2034 }
2035 items.push(ProjectValue::new(scalar.value().to_owned(), item_value));
2036 }
2037 ProjectDnsSearch::List(items)
2038 }
2039 _ => {
2040 self.diagnostics.push(
2041 Diagnostic::new(
2042 DNS_SEARCH_EXPECTED_FORM,
2043 Severity::Error,
2044 "dns_search must be a string scalar or a sequence of string scalars",
2045 )
2046 .with_label(DiagnosticLabel::primary(
2047 effective_span(value),
2048 "unexpected service DNS search-domain form",
2049 )),
2050 );
2051 return None;
2052 }
2053 };
2054 Some(ProjectValue::new(form, value))
2055 }
2056
2057 fn expose(&mut self, value: &MergedValue) -> Option<ProjectValue<Vec<ProjectValue<ProjectExposeItem>>>> {
2058 let MergedValueKind::Sequence(values) = value.kind() else {
2059 self.diagnostics.push(
2060 Diagnostic::new(
2061 EXPOSE_EXPECTED_SEQUENCE,
2062 Severity::Error,
2063 "expose must be a sequence of string or number scalars",
2064 )
2065 .with_label(DiagnosticLabel::primary(
2066 effective_span(value),
2067 "unexpected service expose form",
2068 )),
2069 );
2070 return None;
2071 };
2072 let mut items = Vec::new();
2073 let mut seen = Vec::new();
2074 for item_value in values {
2075 let MergedValueKind::Scalar(scalar) = item_value.kind() else {
2076 self.diagnostics.push(
2077 Diagnostic::new(
2078 EXPOSE_EXPECTED_SCALAR,
2079 Severity::Error,
2080 "expose entries must be string or number scalars",
2081 )
2082 .with_label(DiagnosticLabel::primary(
2083 effective_span(item_value),
2084 "unexpected exposed-port item",
2085 )),
2086 );
2087 continue;
2088 };
2089 let scalar_kind = match scalar.kind() {
2090 MergedScalarKind::String => ExposeScalarKind::String,
2091 MergedScalarKind::Number => ExposeScalarKind::Number,
2092 MergedScalarKind::Boolean => {
2093 self.diagnostics.push(
2094 Diagnostic::new(
2095 EXPOSE_EXPECTED_SCALAR,
2096 Severity::Error,
2097 "expose entries must be string or number scalars",
2098 )
2099 .with_label(DiagnosticLabel::primary(
2100 effective_span(item_value),
2101 "unexpected exposed-port item",
2102 )),
2103 );
2104 continue;
2105 }
2106 };
2107 if seen.contains(&(scalar_kind, scalar.value().to_owned())) {
2108 self.diagnostics.push(
2109 Diagnostic::new(
2110 EXPOSE_DUPLICATE_ITEM,
2111 Severity::Warning,
2112 "expose entries must be unique by exact scalar identity",
2113 )
2114 .with_label(DiagnosticLabel::primary(
2115 effective_span(item_value),
2116 "duplicate exposed-port item retained",
2117 )),
2118 );
2119 } else {
2120 seen.push((scalar_kind, scalar.value().to_owned()));
2121 }
2122 let kind = classify_expose_item(scalar.value(), scalar_kind);
2123 self.diagnose_expose_item(&kind, item_value);
2124 items.push(ProjectValue::new(
2125 ProjectExposeItem {
2126 authored: scalar.raw().to_owned(),
2127 value: scalar.value().to_owned(),
2128 scalar_kind,
2129 kind,
2130 },
2131 item_value,
2132 ));
2133 }
2134 Some(ProjectValue::new(items, value))
2135 }
2136
2137 fn diagnose_expose_item(&mut self, kind: &ExposeItemKind, value: &MergedValue) {
2138 let (code, severity, message, label) = match kind {
2139 ExposeItemKind::Documented { .. } | ExposeItemKind::Expression => return,
2140 ExposeItemKind::Sctp { .. } | ExposeItemKind::UnknownProtocol { .. } => (
2141 EXPOSE_PROVIDER_DEPENDENT,
2142 Severity::Warning,
2143 "expose protocol is outside the documented portable `tcp` and `udp` set",
2144 "provider-dependent exposed-port protocol retained",
2145 ),
2146 ExposeItemKind::Malformed => (
2147 EXPOSE_INVALID_ITEM,
2148 Severity::Error,
2149 "expose item must be a decimal port or range with an optional protocol",
2150 "malformed exposed-port item retained",
2151 ),
2152 };
2153 self.diagnostics.push(
2154 Diagnostic::new(code, severity, message).with_label(DiagnosticLabel::primary(effective_span(value), label)),
2155 );
2156 }
2157
2158 fn security_options(
2159 &mut self,
2160 value: &MergedValue,
2161 ) -> Option<ProjectValue<Vec<ProjectValue<ProjectSecurityOptionItem>>>> {
2162 let MergedValueKind::Sequence(values) = value.kind() else {
2163 self.diagnostics.push(
2164 Diagnostic::new(
2165 SECURITY_OPT_EXPECTED_SEQUENCE,
2166 Severity::Error,
2167 "security_opt must be a sequence of string scalars",
2168 )
2169 .with_label(DiagnosticLabel::primary(
2170 effective_span(value),
2171 "unexpected service security-option form",
2172 )),
2173 );
2174 return None;
2175 };
2176 let mut items = Vec::new();
2177 let mut candidates = SecurityOptionCandidateCounts::default();
2178 for item_value in values {
2179 let MergedValueKind::Scalar(scalar) = item_value.kind() else {
2180 self.diagnostics.push(
2181 Diagnostic::new(
2182 SECURITY_OPT_EXPECTED_STRING,
2183 Severity::Error,
2184 "security_opt entries must be string scalars",
2185 )
2186 .with_label(DiagnosticLabel::primary(
2187 effective_span(item_value),
2188 "unexpected security-option item retained in source evidence",
2189 )),
2190 );
2191 continue;
2192 };
2193 if scalar.kind() != MergedScalarKind::String {
2194 self.diagnostics.push(
2195 Diagnostic::new(
2196 SECURITY_OPT_EXPECTED_STRING,
2197 Severity::Error,
2198 "security_opt entries must be string scalars",
2199 )
2200 .with_label(DiagnosticLabel::primary(
2201 effective_span(item_value),
2202 "unexpected security-option scalar kind retained in source evidence",
2203 )),
2204 );
2205 continue;
2206 }
2207 let kind = classify_security_option(scalar.value());
2208 self.diagnose_security_option_item(&kind, effective_span(item_value), &mut candidates);
2209 items.push(ProjectValue::new(
2210 ProjectSecurityOptionItem {
2211 authored: scalar.raw().to_owned(),
2212 value: scalar.value().to_owned(),
2213 scalar_kind: scalar.kind(),
2214 kind,
2215 },
2216 item_value,
2217 ));
2218 }
2219 Some(ProjectValue::new(items, value))
2220 }
2221
2222 fn diagnose_security_option_item(
2223 &mut self,
2224 kind: &SecurityOptionKind,
2225 span: SourceSpan,
2226 candidates: &mut SecurityOptionCandidateCounts,
2227 ) {
2228 let diagnostic = match kind {
2229 SecurityOptionKind::AppArmor { .. } => {
2230 candidates.apparmor += 1;
2231 (candidates.apparmor > 1).then(|| {
2232 Diagnostic::new(
2233 SECURITY_OPT_APPARMOR_CONFLICT,
2234 Severity::Warning,
2235 "multiple AppArmor candidates are retained; a consumer must resolve the conflict explicitly",
2236 )
2237 .with_label(DiagnosticLabel::primary(
2238 span,
2239 "additional effective AppArmor candidate retained",
2240 ))
2241 })
2242 }
2243 SecurityOptionKind::AppArmorNearMiss => Some(
2244 Diagnostic::new(
2245 SECURITY_OPT_APPARMOR_NEAR_MISS,
2246 Severity::Warning,
2247 "AppArmor candidates require exact lowercase `apparmor=<profile>` spelling without whitespace",
2248 )
2249 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2250 ),
2251 SecurityOptionKind::Seccomp { .. } => {
2252 candidates.seccomp += 1;
2253 (candidates.seccomp > 1).then(|| {
2254 Diagnostic::new(
2255 SECURITY_OPT_SECCOMP_CONFLICT,
2256 Severity::Warning,
2257 "multiple seccomp candidates are retained; a consumer must resolve the conflict explicitly",
2258 )
2259 .with_label(DiagnosticLabel::primary(
2260 span,
2261 "additional effective seccomp candidate retained",
2262 ))
2263 })
2264 }
2265 SecurityOptionKind::SeccompNearMiss => Some(
2266 Diagnostic::new(
2267 SECURITY_OPT_SECCOMP_NEAR_MISS,
2268 Severity::Warning,
2269 "seccomp candidates require exact lowercase `seccomp=<profile>` spelling without whitespace",
2270 )
2271 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2272 ),
2273 SecurityOptionKind::NoNewPrivileges { .. } => {
2274 candidates.no_new_privileges += 1;
2275 (candidates.no_new_privileges > 1).then(|| {
2276 Diagnostic::new(
2277 SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT,
2278 Severity::Warning,
2279 "multiple no-new-privileges candidates are retained; a consumer must resolve the conflict explicitly",
2280 )
2281 .with_label(DiagnosticLabel::primary(
2282 span,
2283 "additional effective no-new-privileges candidate retained",
2284 ))
2285 })
2286 }
2287 SecurityOptionKind::NoNewPrivilegesNearMiss => Some(
2288 Diagnostic::new(
2289 SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS,
2290 Severity::Warning,
2291 "no-new-privileges candidates require exact lowercase `no-new-privileges:true` or `no-new-privileges:false` spelling without whitespace",
2292 )
2293 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2294 ),
2295 SecurityOptionKind::Mask { .. }
2296 | SecurityOptionKind::MaskNearMiss
2297 | SecurityOptionKind::Unmask { .. }
2298 | SecurityOptionKind::UnmaskNearMiss => security_path_option_diagnostic(kind, span),
2299 SecurityOptionKind::SecurityLabelDisable { .. }
2300 | SecurityOptionKind::SecurityLabelDisableNearMiss
2301 | SecurityOptionKind::SecurityLabelFileType { .. }
2302 | SecurityOptionKind::SecurityLabelFileTypeNearMiss
2303 | SecurityOptionKind::SecurityLabelLevel { .. }
2304 | SecurityOptionKind::SecurityLabelLevelNearMiss
2305 | SecurityOptionKind::SecurityLabelNested { .. }
2306 | SecurityOptionKind::SecurityLabelNestedNearMiss
2307 | SecurityOptionKind::SecurityLabelType { .. }
2308 | SecurityOptionKind::SecurityLabelTypeNearMiss => {
2309 effective_security_label_diagnostic(kind, span, candidates)
2310 }
2311 SecurityOptionKind::Empty => Some(
2312 Diagnostic::new(
2313 SECURITY_OPT_EMPTY_ITEM,
2314 Severity::Error,
2315 "security_opt entries must not be empty strings",
2316 )
2317 .with_label(DiagnosticLabel::primary(span, "empty security option retained")),
2318 ),
2319 SecurityOptionKind::Expression | SecurityOptionKind::Other => None,
2320 };
2321 if let Some(diagnostic) = diagnostic {
2322 self.diagnostics.push(diagnostic);
2323 }
2324 }
2325
2326 fn tmpfs(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectTmpfs>> {
2327 let form = match value.kind() {
2328 MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => {
2329 let item = self.tmpfs_item(value, scalar.value());
2330 ProjectTmpfs::Scalar(ProjectValue::new(item, value))
2331 }
2332 MergedValueKind::Sequence(values) => {
2333 let mut items = Vec::new();
2334 for item_value in values {
2335 let MergedValueKind::Scalar(scalar) = item_value.kind() else {
2336 self.diagnostics.push(
2337 Diagnostic::new(
2338 TMPFS_EXPECTED_STRING,
2339 Severity::Error,
2340 "tmpfs entries must be string scalars",
2341 )
2342 .with_label(DiagnosticLabel::primary(
2343 effective_span(item_value),
2344 "unexpected temporary-filesystem list item",
2345 )),
2346 );
2347 continue;
2348 };
2349 if scalar.kind() != MergedScalarKind::String {
2350 self.diagnostics.push(
2351 Diagnostic::new(
2352 TMPFS_EXPECTED_STRING,
2353 Severity::Error,
2354 "tmpfs entries must be string scalars",
2355 )
2356 .with_label(DiagnosticLabel::primary(
2357 effective_span(item_value),
2358 "unexpected temporary-filesystem list item",
2359 )),
2360 );
2361 continue;
2362 }
2363 let item = self.tmpfs_item(item_value, scalar.value());
2364 items.push(ProjectValue::new(item, item_value));
2365 }
2366 ProjectTmpfs::List(items)
2367 }
2368 _ => {
2369 self.diagnostics.push(
2370 Diagnostic::new(
2371 TMPFS_EXPECTED_FORM,
2372 Severity::Error,
2373 "tmpfs must be a string scalar or a sequence of string scalars",
2374 )
2375 .with_label(DiagnosticLabel::primary(
2376 effective_span(value),
2377 "unexpected service-level temporary-filesystem form",
2378 )),
2379 );
2380 return None;
2381 }
2382 };
2383 Some(ProjectValue::new(form, value))
2384 }
2385
2386 fn tmpfs_item(&mut self, source: &MergedValue, raw: &str) -> TmpfsItem {
2387 let item = TmpfsItem::parse(Located::new(raw.to_owned(), effective_span(source)));
2388 if item.kind() == TmpfsItemKind::ProviderDependent {
2389 self.diagnostics.push(
2390 Diagnostic::new(
2391 TMPFS_PROVIDER_DEPENDENT,
2392 Severity::Warning,
2393 "tmpfs item is malformed or uses provider- or target-specific options",
2394 )
2395 .with_label(DiagnosticLabel::primary(
2396 effective_span(source),
2397 "provider-dependent temporary-filesystem item",
2398 ))
2399 .with_note("use a non-empty path with only non-empty `mode`, `uid`, or `gid` assignments for documented portable syntax"),
2400 );
2401 }
2402 item
2403 }
2404
2405 fn sysctls(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectSysctls>> {
2406 let form = match value.kind() {
2407 MergedValueKind::Mapping(entries) => ProjectSysctls::Map(self.sysctls_map(entries)),
2408 MergedValueKind::Sequence(items) => ProjectSysctls::List(self.sysctls_list(items)),
2409 _ => {
2410 self.diagnostics.push(
2411 Diagnostic::new(
2412 SYSCTLS_EXPECTED_FORM,
2413 Severity::Error,
2414 "sysctls must be a mapping or a sequence of string scalars",
2415 )
2416 .with_label(DiagnosticLabel::primary(
2417 effective_span(value),
2418 "unexpected service sysctls form",
2419 )),
2420 );
2421 return None;
2422 }
2423 };
2424 Some(ProjectValue::new(form, value))
2425 }
2426
2427 fn sysctls_map(&mut self, entries: &[MergedEntry]) -> Vec<ProjectValue<ProjectSysctl>> {
2428 let mut sysctls = Vec::new();
2429 for entry in entries {
2430 if entry.key().is_empty() {
2431 self.diagnostics.push(
2432 Diagnostic::new(
2433 SYSCTLS_EMPTY_KEY,
2434 Severity::Error,
2435 "sysctls mapping keys must not be empty",
2436 )
2437 .with_label(DiagnosticLabel::primary(entry_span(entry), "empty sysctl name")),
2438 );
2439 continue;
2440 }
2441 let Some(scalar) = self.sysctl_scalar(entry.value()) else {
2442 continue;
2443 };
2444 let sysctl = ProjectSysctl {
2445 name: ProjectKey::from_entry(entry),
2446 value: ProjectValue::new(scalar, entry.value()),
2447 };
2448 sysctls.push(ProjectValue::new(sysctl, entry.value()));
2449 }
2450 sysctls
2451 }
2452
2453 fn sysctl_scalar(&mut self, value: &MergedValue) -> Option<ComposeScalar> {
2454 match value.kind() {
2455 MergedValueKind::Null(_) => Some(ComposeScalar::Null),
2456 MergedValueKind::Scalar(scalar) => Some(match scalar.kind() {
2457 MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
2458 MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
2459 MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
2460 }),
2461 _ => {
2462 self.diagnostics.push(
2463 Diagnostic::new(
2464 SYSCTLS_EXPECTED_SCALAR,
2465 Severity::Error,
2466 "sysctls mapping values must be scalar strings, numbers, booleans, or null",
2467 )
2468 .with_label(DiagnosticLabel::primary(
2469 effective_span(value),
2470 "non-scalar sysctl value",
2471 )),
2472 );
2473 None
2474 }
2475 }
2476 }
2477
2478 fn sysctls_list(&mut self, items: &[MergedValue]) -> Vec<ProjectValue<String>> {
2479 let mut sysctls = Vec::new();
2480 let mut seen = BTreeMap::new();
2481 for item in items {
2482 let MergedValueKind::Scalar(scalar) = item.kind() else {
2483 self.invalid_sysctl_list_item(item);
2484 continue;
2485 };
2486 if scalar.kind() != MergedScalarKind::String {
2487 self.invalid_sysctl_list_item(item);
2488 continue;
2489 }
2490 let span = effective_span(item);
2491 if let Some(first) = seen.get(scalar.value()) {
2492 self.diagnostics.push(
2493 Diagnostic::new(
2494 SYSCTLS_DUPLICATE_ITEM,
2495 Severity::Error,
2496 "effective sysctls list entries must be unique exact strings",
2497 )
2498 .with_label(DiagnosticLabel::primary(span, "duplicate sysctl string"))
2499 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
2500 );
2501 } else {
2502 seen.insert(scalar.value().to_owned(), span);
2503 }
2504 sysctls.push(ProjectValue::new(scalar.value().to_owned(), item));
2505 }
2506 sysctls
2507 }
2508
2509 fn invalid_sysctl_list_item(&mut self, value: &MergedValue) {
2510 self.diagnostics.push(
2511 Diagnostic::new(
2512 SYSCTLS_EXPECTED_STRING,
2513 Severity::Error,
2514 "sysctls list entries must be YAML string scalars",
2515 )
2516 .with_label(DiagnosticLabel::primary(
2517 effective_span(value),
2518 "non-string sysctl list item",
2519 )),
2520 );
2521 }
2522
2523 fn ulimits(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<ProjectUlimits>> {
2524 let Some(entries) = value.as_mapping() else {
2525 self.expected(value, "ulimits must be a mapping");
2526 return None;
2527 };
2528 let mut limits = Vec::new();
2529 let mut path = service_path.to_vec();
2530 path.push("ulimits".to_owned());
2531 for entry in entries {
2532 if !valid_ulimit_name(entry.key()) {
2533 self.diagnostics.push(
2534 Diagnostic::new(
2535 ULIMIT_INVALID_NAME,
2536 Severity::Error,
2537 "ulimit names must contain only lowercase ASCII letters",
2538 )
2539 .with_label(DiagnosticLabel::primary(entry_span(entry), "invalid ulimit name")),
2540 );
2541 self.record_pending_unmodeled(&path, entry);
2542 continue;
2543 }
2544 let Some(limit) = self.ulimit(entry, &path) else {
2545 self.record_pending_unmodeled(&path, entry);
2546 continue;
2547 };
2548 limits.push(ProjectValue::new(limit, entry.value()));
2549 }
2550 Some(ProjectValue::new(ProjectUlimits { entries: limits }, value))
2551 }
2552
2553 fn ulimit(&mut self, entry: &MergedEntry, parent_path: &[String]) -> Option<ProjectUlimit> {
2554 let value = match entry.value().kind() {
2555 MergedValueKind::Scalar(_) => ProjectUlimitValue::Single(
2556 self.ulimit_scalar(entry.value())
2557 .map(|scalar| ProjectValue::new(scalar, entry.value()))?,
2558 ),
2559 MergedValueKind::Mapping(fields) => {
2560 let mut soft = None;
2561 let mut hard = None;
2562 let mut unmodeled_fields = Vec::new();
2563 let mut range_path = parent_path.to_vec();
2564 range_path.push(entry.key().to_owned());
2565 for field in fields {
2566 match field.key() {
2567 "soft" => {
2568 soft = self
2569 .ulimit_scalar(field.value())
2570 .map(|scalar| ProjectValue::new(scalar, field.value()));
2571 }
2572 "hard" => {
2573 hard = self
2574 .ulimit_scalar(field.value())
2575 .map(|scalar| ProjectValue::new(scalar, field.value()));
2576 }
2577 _ => unmodeled_fields.push(field_reference(&range_path, field)),
2578 }
2579 }
2580 if soft.is_none() {
2581 self.diagnostics.push(
2582 Diagnostic::new(
2583 ULIMIT_MISSING_RANGE_MEMBER,
2584 Severity::Error,
2585 "ulimit range is missing required `soft`",
2586 )
2587 .with_label(DiagnosticLabel::primary(
2588 effective_span(entry.value()),
2589 "missing soft limit",
2590 )),
2591 );
2592 }
2593 if hard.is_none() {
2594 self.diagnostics.push(
2595 Diagnostic::new(
2596 ULIMIT_MISSING_RANGE_MEMBER,
2597 Severity::Error,
2598 "ulimit range is missing required `hard`",
2599 )
2600 .with_label(DiagnosticLabel::primary(
2601 effective_span(entry.value()),
2602 "missing hard limit",
2603 )),
2604 );
2605 }
2606 ProjectUlimitValue::Range(ProjectUlimitRange {
2607 soft,
2608 hard,
2609 unmodeled_fields,
2610 })
2611 }
2612 _ => {
2613 self.expected(
2614 entry.value(),
2615 "ulimit must be a number/string scalar or a soft/hard mapping",
2616 );
2617 return None;
2618 }
2619 };
2620 Some(ProjectUlimit {
2621 name: ProjectKey::from_entry(entry),
2622 value,
2623 })
2624 }
2625
2626 fn ulimit_scalar(&mut self, value: &MergedValue) -> Option<ProjectUlimitScalar> {
2627 let Some(scalar) = value.as_scalar() else {
2628 self.expected(value, "ulimit values must be number or string scalars");
2629 return None;
2630 };
2631 if !matches!(scalar.kind(), MergedScalarKind::String | MergedScalarKind::Number) {
2632 self.diagnostics.push(
2633 Diagnostic::new(
2634 ULIMIT_INVALID_VALUE,
2635 Severity::Error,
2636 "ulimit values must be number or string scalars",
2637 )
2638 .with_label(DiagnosticLabel::primary(
2639 effective_span(value),
2640 "invalid ulimit scalar kind",
2641 )),
2642 );
2643 return None;
2644 }
2645 let parsed = LimitValue::parse(scalar.value().to_owned());
2646 if !parsed.is_valid() {
2647 self.diagnostics.push(
2648 Diagnostic::new(
2649 ULIMIT_INVALID_VALUE,
2650 Severity::Error,
2651 "ulimit must be -1, a non-negative integer, or an interpolation expression",
2652 )
2653 .with_label(DiagnosticLabel::primary(effective_span(value), "invalid ulimit value")),
2654 );
2655 }
2656 Some(ProjectUlimitScalar {
2657 authored: scalar.raw().to_owned(),
2658 value: parsed,
2659 kind: scalar.kind(),
2660 })
2661 }
2662
2663 fn pull_policy(&mut self, value: &MergedValue) -> Option<ProjectValue<PullPolicy>> {
2664 let policy = PullPolicy::parse(self.located_string(value, "pull_policy must be a non-null scalar")?);
2665 if !policy.is_recognized() {
2666 self.invalid(
2667 effective_span(value),
2668 "pull_policy must be a documented Compose policy, the retained `if_not_present` alias, schema-only `refresh`, an `every_` interval matching integer `w`, `d`, `h`, `m`, and `s` components, or interpolation",
2669 );
2670 }
2671 Some(ProjectValue::new(policy, value))
2672 }
2673
2674 fn stop_grace_period(&mut self, value: &MergedValue) -> Option<ProjectValue<StopGracePeriod>> {
2675 let scalar = self.scalar(value, "stop_grace_period must be a non-null scalar")?;
2676 let period = StopGracePeriod::parse(scalar.value().to_owned());
2677 if !period.is_valid() {
2678 self.invalid(
2679 effective_span(value),
2680 "stop_grace_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
2681 );
2682 }
2683 Some(ProjectValue::new(period, value))
2684 }
2685
2686 fn command(&mut self, value: &MergedValue) -> Option<ProjectValue<Command>> {
2687 let span = effective_span(value);
2688 let command = match value.kind() {
2689 MergedValueKind::Null(_) => Command::Null(span),
2690 MergedValueKind::Scalar(scalar) => Command::String(Located::new(scalar.value().to_owned(), span)),
2691 MergedValueKind::Sequence(values) => {
2692 let mut arguments = Vec::new();
2693 for value in values {
2694 arguments.push(self.located_string(value, "command list item must be a scalar")?);
2695 }
2696 Command::List {
2697 span,
2698 values: arguments,
2699 }
2700 }
2701 _ => {
2702 self.expected(value, "command must be null, a scalar, or a sequence");
2703 return None;
2704 }
2705 };
2706 Some(ProjectValue::new(command, value))
2707 }
2708
2709 fn entrypoint(&mut self, value: &MergedValue) -> Option<ProjectValue<Entrypoint>> {
2710 let span = effective_span(value);
2711 let entrypoint = match value.kind() {
2712 MergedValueKind::Null(_) => Entrypoint::Null(span),
2713 MergedValueKind::Scalar(scalar) => Entrypoint::String(Located::new(scalar.value().to_owned(), span)),
2714 MergedValueKind::Sequence(values) => {
2715 let mut arguments = Vec::new();
2716 for value in values {
2717 arguments.push(self.located_string(value, "entrypoint list item must be a scalar")?);
2718 }
2719 Entrypoint::List {
2720 span,
2721 values: arguments,
2722 }
2723 }
2724 _ => {
2725 self.expected(value, "entrypoint must be null, a scalar, or a sequence");
2726 return None;
2727 }
2728 };
2729 Some(ProjectValue::new(entrypoint, value))
2730 }
2731
2732 fn user(&mut self, value: &MergedValue) -> Option<ProjectValue<UserSpec>> {
2733 let raw = self.project_string(value, "service user")?;
2734 Some(ProjectValue {
2735 value: UserSpec::parse(Located::new(raw.value, effective_span(value))),
2736 provenance: raw.provenance,
2737 sensitive: raw.sensitive,
2738 })
2739 }
2740
2741 fn userns_mode(&mut self, value: &MergedValue) -> Option<ProjectValue<UserNamespaceMode>> {
2742 let raw = self.project_string(value, "service user namespace mode")?;
2743 Some(ProjectValue {
2744 value: UserNamespaceMode::parse(Located::new(raw.value, effective_span(value))),
2745 provenance: raw.provenance,
2746 sensitive: raw.sensitive,
2747 })
2748 }
2749
2750 fn environment(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectEnvironment>> {
2751 let mut entries = Vec::new();
2752 match value.kind() {
2753 MergedValueKind::Mapping(values) => {
2754 for entry in values {
2755 let scalar = self.compose_scalar(entry.value(), "environment value must be a scalar or null")?;
2756 entries.push(ProjectEnvironmentEntry {
2757 name: ProjectKey::from_entry(entry),
2758 value: ProjectValue::new(scalar, entry.value()),
2759 syntax: entry.syntax(),
2760 });
2761 }
2762 }
2763 MergedValueKind::Sequence(values) => {
2764 for item in values {
2765 let raw = self.located_string(item, "environment list item must be a scalar")?;
2766 let (name, scalar, syntax) = raw.value().split_once('=').map_or_else(
2767 || (raw.value().clone(), ComposeScalar::Null, EntrySyntax::ListKeyOnly),
2768 |(name, value)| {
2769 (
2770 name.to_owned(),
2771 ComposeScalar::String(value.to_owned()),
2772 EntrySyntax::ListKeyValue,
2773 )
2774 },
2775 );
2776 entries.push(ProjectEnvironmentEntry {
2777 name: ProjectKey {
2778 value: name,
2779 sources: item.provenance().sources().to_vec(),
2780 sensitive: item.is_sensitive(),
2781 },
2782 value: ProjectValue::new(scalar, item),
2783 syntax,
2784 });
2785 }
2786 }
2787 _ => {
2788 self.expected(value, "environment must be a mapping or sequence");
2789 return None;
2790 }
2791 }
2792 Some(ProjectValue::new(ProjectEnvironment { entries }, value))
2793 }
2794
2795 fn environment_files(
2796 &mut self,
2797 value: &MergedValue,
2798 service_path: &[String],
2799 ) -> Option<ProjectValue<Vec<ProjectValue<ProjectEnvironmentFile>>>> {
2800 let values = match value.kind() {
2801 MergedValueKind::Scalar(_) => std::slice::from_ref(value),
2802 MergedValueKind::Sequence(values) => values,
2803 _ => {
2804 self.expected(
2805 value,
2806 "env_file must be a scalar path or sequence of short/long entries",
2807 );
2808 return None;
2809 }
2810 };
2811 let mut environment_files = Vec::new();
2812 for (index, item) in values.iter().enumerate() {
2813 let mut path = service_path.to_vec();
2814 path.push("env_file".to_owned());
2815 path.push(index.to_string());
2816 let environment_file = match item.kind() {
2817 MergedValueKind::Scalar(scalar) => ProjectEnvironmentFile::Short(scalar.value().to_owned()),
2818 MergedValueKind::Mapping(fields) => {
2819 ProjectEnvironmentFile::Long(Box::new(self.long_environment_file(item, fields, &path)))
2820 }
2821 _ => {
2822 self.expected(
2823 item,
2824 "env_file item must use scalar short syntax or mapping long syntax",
2825 );
2826 continue;
2827 }
2828 };
2829 environment_files.push(ProjectValue::new(environment_file, item));
2830 }
2831 Some(ProjectValue::new(environment_files, value))
2832 }
2833
2834 fn long_environment_file(
2835 &mut self,
2836 value: &MergedValue,
2837 fields: &[MergedEntry],
2838 path: &[String],
2839 ) -> ProjectLongEnvironmentFile {
2840 let mut environment_file = ProjectLongEnvironmentFile {
2841 path: None,
2842 required: None,
2843 format: None,
2844 unmodeled_fields: Vec::new(),
2845 };
2846 for field in fields {
2847 match field.key() {
2848 "path" => {
2849 environment_file.path = self.project_string(field.value(), "environment-file path");
2850 }
2851 "required" => {
2852 environment_file.required = self
2853 .located_boolean(field.value(), "environment-file required option must be a boolean")
2854 .map(|value| ProjectValue::new(value.into_value(), field.value()));
2855 }
2856 "format" => {
2857 environment_file.format = self.environment_file_format(field.value());
2858 }
2859 _ => environment_file.unmodeled_fields.push(field_reference(path, field)),
2860 }
2861 }
2862 if environment_file.path.is_none() {
2863 self.missing(value, "long-syntax environment file is missing `path`");
2864 }
2865 environment_file
2866 }
2867
2868 fn environment_file_format(&mut self, value: &MergedValue) -> Option<ProjectValue<EnvironmentFileFormat>> {
2869 let raw = self.project_string(value, "environment-file format")?;
2870 let format = EnvironmentFileFormat::parse(Located::new(raw.value, effective_span(value)));
2871 if matches!(format.kind(), EnvironmentFileFormatKind::Other) {
2872 self.invalid(
2873 effective_span(value),
2874 "environment-file format must be `raw` or interpolation",
2875 );
2876 }
2877 Some(ProjectValue {
2878 value: format,
2879 provenance: raw.provenance,
2880 sensitive: raw.sensitive,
2881 })
2882 }
2883
2884 fn service_labels(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectLabels>> {
2885 let mut entries = Vec::new();
2886 match value.kind() {
2887 MergedValueKind::Mapping(values) => {
2888 for entry in values {
2889 let scalar = if entry.syntax() == EntrySyntax::ListKeyOnly {
2890 ComposeScalar::String(String::new())
2891 } else {
2892 self.compose_scalar(entry.value(), "label value must be a scalar or null")?
2893 };
2894 entries.push(ProjectLabelEntry {
2895 name: ProjectKey::from_entry(entry),
2896 value: ProjectValue::new(scalar, entry.value()),
2897 syntax: entry.syntax(),
2898 });
2899 }
2900 }
2901 MergedValueKind::Sequence(values) => {
2902 for item in values {
2903 let raw = self.located_string(item, "label list item must be a scalar")?;
2904 let (name, value, syntax) = raw.value().split_once('=').map_or_else(
2905 || (raw.value().clone(), String::new(), EntrySyntax::ListKeyOnly),
2906 |(name, value)| (name.to_owned(), value.to_owned(), EntrySyntax::ListKeyValue),
2907 );
2908 entries.push(ProjectLabelEntry {
2909 name: ProjectKey::from_value(name, item),
2910 value: ProjectValue::new(ComposeScalar::String(value), item),
2911 syntax,
2912 });
2913 }
2914 }
2915 _ => {
2916 self.expected(value, "labels must be a mapping or sequence");
2917 return None;
2918 }
2919 }
2920 Some(ProjectValue::new(ProjectLabels { entries }, value))
2921 }
2922
2923 fn service_annotations(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectAnnotations>> {
2924 let entries = match value.kind() {
2925 MergedValueKind::Mapping(values) => self.annotation_mapping(values),
2926 MergedValueKind::Sequence(values) => self.annotation_sequence(values),
2927 _ => {
2928 self.expected(value, "annotations must be a mapping or sequence");
2929 return None;
2930 }
2931 };
2932 Some(ProjectValue::new(ProjectAnnotations { entries }, value))
2933 }
2934
2935 fn annotation_mapping(&mut self, values: &[MergedEntry]) -> Vec<ProjectAnnotationEntry> {
2936 let mut entries = Vec::new();
2937 for entry in values {
2938 if entry.key().is_empty() {
2939 self.annotation_finding(
2940 ANNOTATIONS_EMPTY_NAME,
2941 Severity::Error,
2942 entry.value(),
2943 "service annotation name must not be empty",
2944 "empty annotation name",
2945 );
2946 }
2947 if entry
2948 .raw_list_item()
2949 .is_some_and(|raw| raw.kind() != MergedScalarKind::String)
2950 {
2951 self.annotation_finding(
2952 ANNOTATIONS_EXPECTED_STRING,
2953 Severity::Error,
2954 entry.value(),
2955 "annotation list entries must be string scalars",
2956 "non-string annotation item retained",
2957 );
2958 }
2959 let raw_list_item = entry.raw_list_item().map(|raw| ProjectValue {
2960 value: ProjectAnnotationScalar {
2961 authored: raw.raw().to_owned(),
2962 effective: compose_scalar_from_merged(raw),
2963 },
2964 provenance: entry.value().provenance().clone(),
2965 sensitive: raw.is_sensitive(),
2966 });
2967 let value = self.annotation_mapping_value(entry);
2968 let candidate = ProjectAnnotationEntry {
2969 name: ProjectKey::from_entry(entry),
2970 value,
2971 raw_list_item,
2972 syntax: entry.syntax(),
2973 contributors: vec![entry.value().provenance().clone()],
2974 };
2975 self.upsert_annotation(&mut entries, candidate, entry.value());
2976 }
2977 entries
2978 }
2979
2980 fn annotation_mapping_value(&mut self, entry: &MergedEntry) -> Option<ProjectValue<ProjectAnnotationScalar>> {
2981 if entry.syntax() == EntrySyntax::ListKeyOnly {
2982 self.annotation_finding(
2983 ANNOTATIONS_KEY_ONLY,
2984 Severity::Warning,
2985 entry.value(),
2986 "key-only service annotation has no explicit value",
2987 "ambiguous key-only annotation",
2988 );
2989 return None;
2990 }
2991 self.annotation_scalar(entry.value()).map(|scalar| ProjectValue {
2992 value: scalar,
2993 provenance: entry.value().provenance().clone(),
2994 sensitive: entry.value().is_sensitive(),
2995 })
2996 }
2997
2998 fn annotation_sequence(&mut self, values: &[MergedValue]) -> Vec<ProjectAnnotationEntry> {
2999 let mut entries = Vec::new();
3000 for item in values {
3001 let Some(candidate) = self.annotation_list_item(item) else {
3002 continue;
3003 };
3004 self.upsert_annotation(&mut entries, candidate, item);
3005 }
3006 entries
3007 }
3008
3009 fn annotation_list_item(&mut self, item: &MergedValue) -> Option<ProjectAnnotationEntry> {
3010 let Some(scalar) = item.as_scalar() else {
3011 self.invalid_annotation_list_item(item);
3012 return None;
3013 };
3014 if scalar.kind() != MergedScalarKind::String {
3015 self.invalid_annotation_list_item(item);
3016 return None;
3017 }
3018 let raw_list_item = Some(ProjectValue {
3019 value: ProjectAnnotationScalar {
3020 authored: scalar.raw().to_owned(),
3021 effective: ComposeScalar::String(scalar.value().to_owned()),
3022 },
3023 provenance: item.provenance().clone(),
3024 sensitive: scalar.is_sensitive(),
3025 });
3026 let (name, value, syntax) = if let Some((name, value)) = scalar.value().split_once('=') {
3027 (
3028 name.to_owned(),
3029 Some(ProjectValue {
3030 value: ProjectAnnotationScalar {
3031 authored: scalar.raw().to_owned(),
3032 effective: ComposeScalar::String(value.to_owned()),
3033 },
3034 provenance: item.provenance().clone(),
3035 sensitive: scalar.is_sensitive(),
3036 }),
3037 EntrySyntax::ListKeyValue,
3038 )
3039 } else {
3040 self.annotation_finding(
3041 ANNOTATIONS_KEY_ONLY,
3042 Severity::Warning,
3043 item,
3044 "key-only service annotation has no explicit value",
3045 "ambiguous key-only annotation",
3046 );
3047 (scalar.value().to_owned(), None, EntrySyntax::ListKeyOnly)
3048 };
3049 if name.is_empty() {
3050 self.annotation_finding(
3051 ANNOTATIONS_EMPTY_NAME,
3052 Severity::Error,
3053 item,
3054 "service annotation name must not be empty",
3055 "empty annotation name",
3056 );
3057 }
3058 Some(ProjectAnnotationEntry {
3059 name: ProjectKey::from_value(name, item),
3060 value,
3061 raw_list_item,
3062 syntax,
3063 contributors: vec![item.provenance().clone()],
3064 })
3065 }
3066
3067 fn invalid_annotation_list_item(&mut self, item: &MergedValue) {
3068 self.annotation_finding(
3069 ANNOTATIONS_EXPECTED_STRING,
3070 Severity::Error,
3071 item,
3072 "annotation list entries must be string scalars",
3073 "non-string annotation item retained in merged source",
3074 );
3075 }
3076
3077 fn annotation_scalar(&mut self, value: &MergedValue) -> Option<ProjectAnnotationScalar> {
3078 let effective = self.compose_scalar(
3079 value,
3080 "annotation mapping values must be scalar strings, numbers, booleans, or null",
3081 )?;
3082 let authored = match value.kind() {
3083 MergedValueKind::Null(crate::merge::NullStyle::Empty) => String::new(),
3084 MergedValueKind::Null(crate::merge::NullStyle::Explicit) => "null".to_owned(),
3085 MergedValueKind::Scalar(scalar) => scalar.raw().to_owned(),
3086 _ => unreachable!("compose_scalar accepted only scalar or null"),
3087 };
3088 Some(ProjectAnnotationScalar { authored, effective })
3089 }
3090
3091 fn upsert_annotation(
3092 &mut self,
3093 entries: &mut Vec<ProjectAnnotationEntry>,
3094 mut candidate: ProjectAnnotationEntry,
3095 source: &MergedValue,
3096 ) {
3097 if let Some(existing) = entries
3098 .iter_mut()
3099 .find(|entry| entry.name.value == candidate.name.value)
3100 {
3101 self.annotation_finding(
3102 ANNOTATIONS_DUPLICATE_NAME,
3103 Severity::Error,
3104 source,
3105 "service annotation names must be unique",
3106 "later annotation value replaces earlier effective value",
3107 );
3108 for span in candidate.name.sources.drain(..) {
3109 if !existing.name.sources.contains(&span) {
3110 existing.name.sources.push(span);
3111 }
3112 }
3113 existing.name.sensitive |= candidate.name.sensitive;
3114 existing.contributors.append(&mut candidate.contributors);
3115 existing.value = candidate.value;
3116 existing.raw_list_item = candidate.raw_list_item;
3117 existing.syntax = candidate.syntax;
3118 } else {
3119 entries.push(candidate);
3120 }
3121 }
3122
3123 fn annotation_finding(
3124 &mut self,
3125 code: DiagnosticCode,
3126 severity: Severity,
3127 value: &MergedValue,
3128 message: &'static str,
3129 label: &'static str,
3130 ) {
3131 self.diagnostics.push(
3132 Diagnostic::new(code, severity, message).with_label(DiagnosticLabel::primary(effective_span(value), label)),
3133 );
3134 }
3135
3136 fn healthcheck(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ProjectValue<ProjectHealthcheck>> {
3137 let fields = self.mapping(value, "healthcheck must be a mapping")?;
3138 let mut healthcheck = ProjectHealthcheck {
3139 test: None,
3140 interval: None,
3141 timeout: None,
3142 retries: None,
3143 start_period: None,
3144 start_interval: None,
3145 disable: None,
3146 unmodeled_fields: Vec::new(),
3147 };
3148 let mut path = parent_path.to_vec();
3149 path.push("healthcheck".to_owned());
3150 for field in fields {
3151 match field.key() {
3152 "test" => healthcheck.test = self.healthcheck_test(field.value()),
3153 "interval" => {
3154 healthcheck.interval =
3155 self.healthcheck_duration(field.value(), "healthcheck interval must be a scalar");
3156 }
3157 "timeout" => {
3158 healthcheck.timeout =
3159 self.healthcheck_duration(field.value(), "healthcheck timeout must be a scalar");
3160 }
3161 "retries" => healthcheck.retries = self.healthcheck_retries(field.value()),
3162 "start_period" => {
3163 healthcheck.start_period =
3164 self.healthcheck_duration(field.value(), "healthcheck start_period must be a scalar");
3165 }
3166 "start_interval" => {
3167 healthcheck.start_interval =
3168 self.healthcheck_duration(field.value(), "healthcheck start_interval must be a scalar");
3169 }
3170 "disable" => {
3171 healthcheck.disable = self
3172 .located_boolean(field.value(), "healthcheck disable must be a boolean")
3173 .map(|value| ProjectValue::new(value.into_value(), field.value()));
3174 }
3175 _ => healthcheck.unmodeled_fields.push(field_reference(&path, field)),
3176 }
3177 }
3178 Some(ProjectValue::new(healthcheck, value))
3179 }
3180
3181 fn depends_on(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ProjectValue<ProjectDependsOn>> {
3182 let dependencies = match value.kind() {
3183 MergedValueKind::Sequence(values) => {
3184 let mut dependencies = Vec::new();
3185 for value in values {
3186 let Some(service) = self.project_string(value, "dependency service name") else {
3187 continue;
3188 };
3189 let dependency = ProjectServiceDependency {
3190 service: ProjectKey::from_value(service.value, value),
3191 condition: None,
3192 restart: None,
3193 required: None,
3194 unmodeled_fields: Vec::new(),
3195 };
3196 dependencies.push(ProjectValue::new(dependency, value));
3197 }
3198 ProjectDependsOn::Short(dependencies)
3199 }
3200 MergedValueKind::Mapping(entries) => {
3201 let mut dependencies = Vec::new();
3202 let mut path = parent_path.to_vec();
3203 path.push("depends_on".to_owned());
3204 for entry in entries {
3205 let mut dependency = ProjectServiceDependency {
3206 service: ProjectKey::from_entry(entry),
3207 condition: None,
3208 restart: None,
3209 required: None,
3210 unmodeled_fields: Vec::new(),
3211 };
3212 let fields = match entry.value().kind() {
3213 MergedValueKind::Null(_) => &[][..],
3214 MergedValueKind::Mapping(fields) => fields.as_slice(),
3215 _ => {
3216 self.expected(entry.value(), "long dependency options must be a mapping or null");
3217 continue;
3218 }
3219 };
3220 let mut dependency_path = path.clone();
3221 dependency_path.push(entry.key().to_owned());
3222 for field in fields {
3223 match field.key() {
3224 "condition" => {
3225 let Some(condition) = self.project_string(field.value(), "dependency condition") else {
3226 continue;
3227 };
3228 let parsed = DependencyCondition::parse(condition.value);
3229 if !parsed.is_known() {
3230 self.invalid(
3231 effective_span(field.value()),
3232 "dependency condition is not defined by Compose",
3233 );
3234 }
3235 dependency.condition = Some(ProjectValue {
3236 value: parsed,
3237 provenance: condition.provenance,
3238 sensitive: condition.sensitive,
3239 });
3240 }
3241 "restart" => {
3242 dependency.restart = self
3243 .located_boolean(field.value(), "dependency restart must be a boolean")
3244 .map(|value| ProjectValue::new(value.into_value(), field.value()));
3245 }
3246 "required" => {
3247 dependency.required = self
3248 .located_boolean(field.value(), "dependency required must be a boolean")
3249 .map(|value| ProjectValue::new(value.into_value(), field.value()));
3250 }
3251 _ => dependency
3252 .unmodeled_fields
3253 .push(field_reference(&dependency_path, field)),
3254 }
3255 }
3256 dependencies.push(ProjectValue::new(dependency, entry.value()));
3257 }
3258 ProjectDependsOn::Long(dependencies)
3259 }
3260 _ => {
3261 self.expected(value, "depends_on must be a sequence or mapping");
3262 return None;
3263 }
3264 };
3265 Some(ProjectValue::new(dependencies, value))
3266 }
3267
3268 fn healthcheck_test(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckTest>> {
3269 let span = effective_span(value);
3270 let test = match value.kind() {
3271 MergedValueKind::Scalar(scalar) => HealthcheckTest::String(Located::new(scalar.value().to_owned(), span)),
3272 MergedValueKind::Sequence(values) => {
3273 let mut items = Vec::new();
3274 for value in values {
3275 items.push(self.located_string(value, "healthcheck test item must be a scalar")?);
3276 }
3277 let kind = items.first().map(|item| HealthcheckTestKind::parse(item.value()));
3278 HealthcheckTest::List {
3279 span,
3280 kind,
3281 values: items,
3282 }
3283 }
3284 _ => {
3285 self.expected(value, "healthcheck test must be a scalar or sequence");
3286 return None;
3287 }
3288 };
3289 Some(ProjectValue::new(test, value))
3290 }
3291
3292 fn healthcheck_duration(
3293 &mut self,
3294 value: &MergedValue,
3295 message: &str,
3296 ) -> Option<ProjectValue<HealthcheckDuration>> {
3297 let scalar = self.scalar(value, message)?;
3298 Some(ProjectValue::new(
3299 HealthcheckDuration::parse(scalar.value().to_owned()),
3300 value,
3301 ))
3302 }
3303
3304 fn healthcheck_retries(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckRetries>> {
3305 let scalar = self.scalar(value, "healthcheck retries must be a scalar")?;
3306 Some(ProjectValue::new(
3307 HealthcheckRetries::parse(scalar.value().to_owned()),
3308 value,
3309 ))
3310 }
3311
3312 fn extra_hosts(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectExtraHosts>> {
3313 let mut entries = Vec::new();
3314 match value.kind() {
3315 MergedValueKind::Mapping(values) => {
3316 for entry in values {
3317 let scalar = self.scalar(entry.value(), "extra_hosts address must be a scalar")?;
3318 entries.push(ProjectExtraHost {
3319 hostname: ProjectKey::from_entry(entry),
3320 address: ProjectValue::new(HostAddress::parse(scalar.value().to_owned()), entry.value()),
3321 syntax: EntrySyntax::Mapping,
3322 });
3323 }
3324 }
3325 MergedValueKind::Sequence(values) => {
3326 for item in values {
3327 let raw = self.located_string(item, "extra_hosts list item must be a scalar")?;
3328 let parsed = ShortExtraHost::parse(raw);
3329 let (Some(hostname), Some(address)) = (parsed.hostname(), parsed.address()) else {
3330 self.invalid(
3331 effective_span(item),
3332 "extra_hosts entry must contain a hostname and address",
3333 );
3334 continue;
3335 };
3336 entries.push(ProjectExtraHost {
3337 hostname: ProjectKey {
3338 value: hostname.to_owned(),
3339 sources: item.provenance().sources().to_vec(),
3340 sensitive: item.is_sensitive(),
3341 },
3342 address: ProjectValue::new(address.clone(), item),
3343 syntax: EntrySyntax::ListKeyValue,
3344 });
3345 }
3346 }
3347 _ => {
3348 self.expected(value, "extra_hosts must be a mapping or sequence");
3349 return None;
3350 }
3351 }
3352 Some(ProjectValue::new(ProjectExtraHosts { entries }, value))
3353 }
3354
3355 fn project_string(&mut self, value: &MergedValue, description: &str) -> Option<ProjectValue<String>> {
3356 let scalar = self.scalar(value, &format!("{description} must be a non-null scalar"))?;
3357 Some(ProjectValue::new(scalar.value().to_owned(), value))
3358 }
3359
3360 fn string_collection(
3361 &mut self,
3362 value: &MergedValue,
3363 message: &str,
3364 ) -> Option<ProjectValue<Vec<ProjectValue<String>>>> {
3365 let Some(values) = value.as_sequence() else {
3366 self.expected(value, message);
3367 return None;
3368 };
3369 let mut strings = Vec::new();
3370 for value in values {
3371 let scalar = self.scalar(value, "sequence item must be a non-null scalar")?;
3372 strings.push(ProjectValue::new(scalar.value().to_owned(), value));
3373 }
3374 Some(ProjectValue::new(strings, value))
3375 }
3376
3377 fn capability_drop(&mut self, value: &MergedValue) -> Option<ProjectValue<Vec<ProjectValue<CapabilityDropItem>>>> {
3378 let Some(values) = value.as_sequence() else {
3379 self.expected(value, "cap_drop must be a sequence of string scalars");
3380 return None;
3381 };
3382 let mut items = Vec::new();
3383 let mut seen = BTreeMap::new();
3384 for item in values {
3385 let scalar = match item.kind() {
3386 MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
3387 _ => {
3388 self.expected(item, "cap_drop entries must be string scalars");
3389 continue;
3390 }
3391 };
3392 let span = effective_span(item);
3393 if let Some(first) = seen.get(scalar.value()) {
3394 self.diagnostics.push(
3395 Diagnostic::new(
3396 CAP_DROP_DUPLICATE_ITEM,
3397 Severity::Error,
3398 "cap_drop entries must be unique exact strings",
3399 )
3400 .with_label(DiagnosticLabel::primary(span, "duplicate capability string"))
3401 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
3402 );
3403 } else {
3404 seen.insert(scalar.value().to_owned(), span);
3405 }
3406 let typed = CapabilityDropItem::new(Located::new(scalar.value().to_owned(), span));
3407 items.push(ProjectValue::new(typed, item));
3408 }
3409 Some(ProjectValue::new(items, value))
3410 }
3411
3412 fn capability_add(&mut self, value: &MergedValue) -> Option<ProjectValue<Vec<ProjectValue<CapabilityAddItem>>>> {
3413 let Some(values) = value.as_sequence() else {
3414 self.expected(value, "cap_add must be a sequence of string scalars");
3415 return None;
3416 };
3417 let mut items = Vec::new();
3418 let mut seen = BTreeMap::new();
3419 for item in values {
3420 let scalar = match item.kind() {
3421 MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
3422 _ => {
3423 self.expected(item, "cap_add entries must be string scalars");
3424 continue;
3425 }
3426 };
3427 let span = effective_span(item);
3428 if let Some(first) = seen.get(scalar.value()) {
3429 self.diagnostics.push(
3430 Diagnostic::new(
3431 CAP_ADD_DUPLICATE_ITEM,
3432 Severity::Error,
3433 "cap_add entries must be unique exact strings",
3434 )
3435 .with_label(DiagnosticLabel::primary(span, "duplicate capability string"))
3436 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
3437 );
3438 } else {
3439 seen.insert(scalar.value().to_owned(), span);
3440 }
3441 let typed = CapabilityAddItem::new(Located::new(scalar.value().to_owned(), span));
3442 items.push(ProjectValue::new(typed, item));
3443 }
3444 Some(ProjectValue::new(items, value))
3445 }
3446
3447 fn devices(
3448 &mut self,
3449 value: &MergedValue,
3450 service_path: &[String],
3451 ) -> Option<ProjectValue<Vec<ProjectValue<ProjectDevice>>>> {
3452 let Some(values) = value.as_sequence() else {
3453 self.expected(value, "service devices must be a sequence");
3454 return None;
3455 };
3456 let mut devices = Vec::new();
3457 for (index, item) in values.iter().enumerate() {
3458 let mut path = service_path.to_vec();
3459 path.push("devices".to_owned());
3460 path.push(index.to_string());
3461 let device = match item.kind() {
3462 MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => ProjectDevice::Short(
3463 ShortDevice::new(Located::new(scalar.value().to_owned(), effective_span(item))),
3464 ),
3465 MergedValueKind::Mapping(fields) => ProjectDevice::Long(self.long_device(item, fields, &path)),
3466 _ => {
3467 self.diagnostics.push(
3468 Diagnostic::new(
3469 DEVICE_EXPECTED_FORM,
3470 Severity::Error,
3471 "service device must use string short syntax or mapping long syntax",
3472 )
3473 .with_label(DiagnosticLabel::primary(
3474 effective_span(item),
3475 "unsupported device form",
3476 )),
3477 );
3478 continue;
3479 }
3480 };
3481 devices.push(ProjectValue::new(device, item));
3482 }
3483 Some(ProjectValue::new(devices, value))
3484 }
3485
3486 fn long_device(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> ProjectLongDevice {
3487 let mut device = ProjectLongDevice {
3488 source: None,
3489 target: None,
3490 permissions: None,
3491 extension_fields: Vec::new(),
3492 unknown_fields: Vec::new(),
3493 };
3494 for field in fields {
3495 let parsed = match field.key() {
3496 "source" | "target" | "permissions" => self.device_string(field.value(), field.key()),
3497 name if name.starts_with("x-") => {
3498 device.extension_fields.push(field_reference(path, field));
3499 continue;
3500 }
3501 _ => {
3502 device.unknown_fields.push(field_reference(path, field));
3503 continue;
3504 }
3505 };
3506 match field.key() {
3507 "source" => device.source = parsed,
3508 "target" => device.target = parsed,
3509 "permissions" => device.permissions = parsed,
3510 _ => unreachable!("unrecognized device fields continue before assignment"),
3511 }
3512 }
3513 if device.source.is_none() {
3514 self.missing(value, "long-syntax device is missing required string `source`");
3515 }
3516 device
3517 }
3518
3519 fn device_string(&mut self, value: &MergedValue, member: &str) -> Option<ProjectValue<String>> {
3520 let scalar = match value.kind() {
3521 MergedValueKind::Scalar(scalar) if scalar.kind() == MergedScalarKind::String => scalar,
3522 _ => {
3523 self.diagnostics.push(
3524 Diagnostic::new(
3525 DEVICE_EXPECTED_STRING,
3526 Severity::Error,
3527 format!("device {member} must be a string scalar"),
3528 )
3529 .with_label(DiagnosticLabel::primary(
3530 effective_span(value),
3531 "unexpected long-device member form",
3532 )),
3533 );
3534 return None;
3535 }
3536 };
3537 Some(ProjectValue::new(scalar.value().to_owned(), value))
3538 }
3539
3540 fn scalar<'value>(
3541 &mut self,
3542 value: &'value MergedValue,
3543 message: &str,
3544 ) -> Option<&'value crate::merge::MergedScalar> {
3545 let Some(scalar) = value.as_scalar() else {
3546 self.expected(value, message);
3547 return None;
3548 };
3549 Some(scalar)
3550 }
3551
3552 fn located_string(&mut self, value: &MergedValue, message: &str) -> Option<Located<String>> {
3553 let scalar = self.scalar(value, message)?;
3554 Some(Located::new(scalar.value().to_owned(), effective_span(value)))
3555 }
3556
3557 fn compose_scalar(&mut self, value: &MergedValue, message: &str) -> Option<ComposeScalar> {
3558 match value.kind() {
3559 MergedValueKind::Null(_) => Some(ComposeScalar::Null),
3560 MergedValueKind::Scalar(scalar) => Some(match scalar.kind() {
3561 MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
3562 MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
3563 MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
3564 }),
3565 _ => {
3566 self.expected(value, message);
3567 None
3568 }
3569 }
3570 }
3571
3572 fn mapping<'value>(&mut self, value: &'value MergedValue, message: &str) -> Option<&'value [MergedEntry]> {
3573 let Some(entries) = value.as_mapping() else {
3574 self.expected(value, message);
3575 return None;
3576 };
3577 Some(entries)
3578 }
3579
3580 fn expected(&mut self, value: &MergedValue, message: &str) {
3581 self.diagnostics.push(
3582 Diagnostic::new(PROJECT_EXPECTED_FORM, Severity::Error, message).with_label(DiagnosticLabel::primary(
3583 effective_span(value),
3584 "unexpected merged value form",
3585 )),
3586 );
3587 }
3588
3589 fn missing(&mut self, value: &MergedValue, message: &str) {
3590 self.diagnostics.push(
3591 Diagnostic::new(PROJECT_MISSING_FIELD, Severity::Error, message).with_label(DiagnosticLabel::primary(
3592 effective_span(value),
3593 "required field is missing",
3594 )),
3595 );
3596 }
3597
3598 fn invalid(&mut self, span: SourceSpan, message: &str) {
3599 self.diagnostics.push(
3600 Diagnostic::new(PROJECT_INVALID_VALUE, Severity::Error, message)
3601 .with_label(DiagnosticLabel::primary(span, "invalid native value")),
3602 );
3603 }
3604
3605 fn record_root_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
3606 self.root_unmodeled.push(field_reference(path, entry));
3607 }
3608
3609 fn record_pending_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
3610 self.pending_unmodeled.push(field_reference(path, entry));
3611 }
3612}
3613
3614fn effective_security_label_diagnostic(
3615 kind: &SecurityOptionKind,
3616 span: SourceSpan,
3617 candidates: &mut SecurityOptionCandidateCounts,
3618) -> Option<Diagnostic> {
3619 match kind {
3620 SecurityOptionKind::SecurityLabelDisable { .. } | SecurityOptionKind::SecurityLabelDisableNearMiss => {
3621 security_label_disable_diagnostic(kind, span, &mut candidates.security_label_disable)
3622 }
3623 SecurityOptionKind::SecurityLabelFileType { .. } | SecurityOptionKind::SecurityLabelFileTypeNearMiss => {
3624 security_label_filetype_diagnostic(kind, span, &mut candidates.security_label_filetype)
3625 }
3626 SecurityOptionKind::SecurityLabelLevel { .. } | SecurityOptionKind::SecurityLabelLevelNearMiss => {
3627 security_label_level_diagnostic(kind, span, &mut candidates.security_label_level)
3628 }
3629 SecurityOptionKind::SecurityLabelNested { .. } | SecurityOptionKind::SecurityLabelNestedNearMiss => {
3630 security_label_nested_diagnostic(kind, span, &mut candidates.security_label_nested)
3631 }
3632 SecurityOptionKind::SecurityLabelType { .. } | SecurityOptionKind::SecurityLabelTypeNearMiss => {
3633 security_label_type_diagnostic(kind, span, &mut candidates.security_label_type)
3634 }
3635 _ => None,
3636 }
3637}
3638
3639fn security_label_disable_diagnostic(
3640 kind: &SecurityOptionKind,
3641 span: SourceSpan,
3642 candidates: &mut usize,
3643) -> Option<Diagnostic> {
3644 match kind {
3645 SecurityOptionKind::SecurityLabelDisable { .. } => {
3646 *candidates += 1;
3647 (*candidates > 1).then(|| {
3648 Diagnostic::new(
3649 SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT,
3650 Severity::Warning,
3651 "multiple SELinux label-disable candidates are retained; a consumer must resolve the conflict explicitly",
3652 )
3653 .with_label(DiagnosticLabel::primary(
3654 span,
3655 "additional effective SELinux label-disable candidate retained",
3656 ))
3657 })
3658 }
3659 SecurityOptionKind::SecurityLabelDisableNearMiss => Some(
3660 Diagnostic::new(
3661 SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS,
3662 Severity::Warning,
3663 "SELinux label-disable candidates require exact lowercase `label:disable` spelling without whitespace",
3664 )
3665 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3666 ),
3667 _ => None,
3668 }
3669}
3670
3671fn security_label_filetype_diagnostic(
3672 kind: &SecurityOptionKind,
3673 span: SourceSpan,
3674 candidates: &mut usize,
3675) -> Option<Diagnostic> {
3676 match kind {
3677 SecurityOptionKind::SecurityLabelFileType { .. } => {
3678 *candidates += 1;
3679 (*candidates > 1).then(|| {
3680 Diagnostic::new(
3681 SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT,
3682 Severity::Warning,
3683 "multiple SELinux label-filetype candidates are retained; a consumer must resolve the conflict explicitly",
3684 )
3685 .with_label(DiagnosticLabel::primary(
3686 span,
3687 "additional effective SELinux label-filetype candidate retained",
3688 ))
3689 })
3690 }
3691 SecurityOptionKind::SecurityLabelFileTypeNearMiss => Some(
3692 Diagnostic::new(
3693 SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS,
3694 Severity::Warning,
3695 "SELinux label-filetype candidates require exact lowercase `label:filetype:<type>` spelling without whitespace",
3696 )
3697 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3698 ),
3699 _ => None,
3700 }
3701}
3702
3703fn security_label_level_diagnostic(
3704 kind: &SecurityOptionKind,
3705 span: SourceSpan,
3706 candidates: &mut usize,
3707) -> Option<Diagnostic> {
3708 match kind {
3709 SecurityOptionKind::SecurityLabelLevel { .. } => {
3710 *candidates += 1;
3711 (*candidates > 1).then(|| {
3712 Diagnostic::new(
3713 SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT,
3714 Severity::Warning,
3715 "multiple SELinux label-level candidates are retained; a consumer must resolve the conflict explicitly",
3716 )
3717 .with_label(DiagnosticLabel::primary(
3718 span,
3719 "additional effective SELinux label-level candidate retained",
3720 ))
3721 })
3722 }
3723 SecurityOptionKind::SecurityLabelLevelNearMiss => Some(
3724 Diagnostic::new(
3725 SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS,
3726 Severity::Warning,
3727 "SELinux label-level candidates require exact lowercase `label:level:<level>` spelling without whitespace",
3728 )
3729 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3730 ),
3731 _ => None,
3732 }
3733}
3734
3735fn security_label_nested_diagnostic(
3736 kind: &SecurityOptionKind,
3737 span: SourceSpan,
3738 candidates: &mut usize,
3739) -> Option<Diagnostic> {
3740 match kind {
3741 SecurityOptionKind::SecurityLabelNested { .. } => {
3742 *candidates += 1;
3743 (*candidates > 1).then(|| {
3744 Diagnostic::new(
3745 SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT,
3746 Severity::Warning,
3747 "multiple SELinux label-nested candidates are retained; a consumer must resolve the conflict explicitly",
3748 )
3749 .with_label(DiagnosticLabel::primary(
3750 span,
3751 "additional effective SELinux label-nested candidate retained",
3752 ))
3753 })
3754 }
3755 SecurityOptionKind::SecurityLabelNestedNearMiss => Some(
3756 Diagnostic::new(
3757 SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS,
3758 Severity::Warning,
3759 "SELinux label-nested candidates require exact lowercase `label:nested` spelling without whitespace",
3760 )
3761 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3762 ),
3763 _ => None,
3764 }
3765}
3766
3767fn security_label_type_diagnostic(
3768 kind: &SecurityOptionKind,
3769 span: SourceSpan,
3770 candidates: &mut usize,
3771) -> Option<Diagnostic> {
3772 match kind {
3773 SecurityOptionKind::SecurityLabelType { .. } => {
3774 *candidates += 1;
3775 (*candidates > 1).then(|| {
3776 Diagnostic::new(
3777 SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT,
3778 Severity::Warning,
3779 "multiple SELinux label-type candidates are retained; a consumer must resolve the conflict explicitly",
3780 )
3781 .with_label(DiagnosticLabel::primary(
3782 span,
3783 "additional effective SELinux label-type candidate retained",
3784 ))
3785 })
3786 }
3787 SecurityOptionKind::SecurityLabelTypeNearMiss => Some(
3788 Diagnostic::new(
3789 SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS,
3790 Severity::Warning,
3791 "SELinux label-type candidates require exact lowercase `label:type:<type>` spelling with one non-empty whitespace-free type",
3792 )
3793 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3794 ),
3795 _ => None,
3796 }
3797}
3798
3799impl Builder<'_> {
3800 fn ports(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<Vec<ProjectValue<Port>>>> {
3801 let Some(values) = value.as_sequence() else {
3802 self.expected(value, "service ports must be a sequence");
3803 return None;
3804 };
3805 let mut ports = Vec::new();
3806 for (index, item) in values.iter().enumerate() {
3807 let mut path = service_path.to_vec();
3808 path.push("ports".to_owned());
3809 path.push(index.to_string());
3810 let port = match item.kind() {
3811 MergedValueKind::Scalar(scalar) => Port::Short(ShortPort::parse(Located::new(
3812 scalar.value().to_owned(),
3813 effective_span(item),
3814 ))),
3815 MergedValueKind::Mapping(fields) => Port::Long(Box::new(self.long_port(item, fields, &path))),
3816 _ => {
3817 self.expected(item, "service port must use scalar short syntax or mapping long syntax");
3818 continue;
3819 }
3820 };
3821 ports.push(ProjectValue::new(port, item));
3822 }
3823 Some(ProjectValue::new(ports, value))
3824 }
3825
3826 fn long_port(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongPort {
3827 let mut port = LongPort::new(effective_span(value));
3828 let mut has_target = false;
3829 for field in fields {
3830 match field.key() {
3831 "target" => {
3832 if let Some(value) = self.located_string(field.value(), "port target must be a scalar") {
3833 port.set_target(value);
3834 has_target = true;
3835 }
3836 }
3837 "published" => self
3838 .located_string(field.value(), "published port must be a scalar")
3839 .into_iter()
3840 .for_each(|value| port.set_published(value)),
3841 "host_ip" => self
3842 .located_string(field.value(), "port host_ip must be a scalar")
3843 .into_iter()
3844 .for_each(|value| port.set_host_ip(value)),
3845 "protocol" => self
3846 .located_string(field.value(), "port protocol must be a scalar")
3847 .into_iter()
3848 .for_each(|value| port.set_protocol(value)),
3849 "app_protocol" => self
3850 .located_string(field.value(), "port app_protocol must be a scalar")
3851 .into_iter()
3852 .for_each(|value| port.set_app_protocol(value)),
3853 "mode" => self
3854 .located_string(field.value(), "port mode must be a scalar")
3855 .into_iter()
3856 .for_each(|value| port.set_mode(value)),
3857 "name" => self
3858 .located_string(field.value(), "port name must be a scalar")
3859 .into_iter()
3860 .for_each(|value| port.set_name(value)),
3861 _ => self.record_pending_unmodeled(path, field),
3862 }
3863 }
3864 if !has_target {
3865 self.missing(value, "long-syntax port is missing `target`");
3866 }
3867 port
3868 }
3869
3870 fn volumes(
3871 &mut self,
3872 value: &MergedValue,
3873 service_path: &[String],
3874 ) -> Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
3875 let Some(values) = value.as_sequence() else {
3876 self.expected(value, "service volumes must be a sequence");
3877 return None;
3878 };
3879 let mut mounts = Vec::new();
3880 for (index, item) in values.iter().enumerate() {
3881 let mut path = service_path.to_vec();
3882 path.push("volumes".to_owned());
3883 path.push(index.to_string());
3884 let mount = match item.kind() {
3885 MergedValueKind::Scalar(scalar) => VolumeMount::Short(ShortVolumeMount::new(Located::new(
3886 scalar.value().to_owned(),
3887 effective_span(item),
3888 ))),
3889 MergedValueKind::Mapping(fields) => VolumeMount::Long(Box::new(self.long_volume(item, fields, &path))),
3890 _ => {
3891 self.expected(
3892 item,
3893 "service volume must use scalar short syntax or mapping long syntax",
3894 );
3895 continue;
3896 }
3897 };
3898 mounts.push(ProjectValue::new(mount, item));
3899 }
3900 Some(ProjectValue::new(mounts, value))
3901 }
3902
3903 fn long_volume(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongVolumeMount {
3904 let mut mount = LongVolumeMount::new(effective_span(value));
3905 let mut has_type = false;
3906 let mut has_target = false;
3907 for field in fields {
3908 match field.key() {
3909 "type" => {
3910 if let Some(value) = self.located_string(field.value(), "volume type must be a scalar") {
3911 mount.set_mount_type(Located::new(MountType::from_text(value.value().clone()), value.span()));
3912 has_type = true;
3913 }
3914 }
3915 "source" => self
3916 .located_string(field.value(), "volume source must be a scalar")
3917 .into_iter()
3918 .for_each(|value| mount.set_source(value)),
3919 "target" => {
3920 if let Some(value) = self.located_string(field.value(), "volume target must be a scalar") {
3921 mount.set_target(value);
3922 has_target = true;
3923 }
3924 }
3925 "read_only" => self
3926 .located_boolean(field.value(), "volume read_only must be a boolean")
3927 .into_iter()
3928 .for_each(|value| mount.set_read_only(value)),
3929 "bind" => self
3930 .bind_options(field.value(), path)
3931 .into_iter()
3932 .for_each(|value| mount.set_bind(value)),
3933 _ => self.record_pending_unmodeled(path, field),
3934 }
3935 }
3936 if !has_type {
3937 self.missing(value, "long-syntax volume is missing `type`");
3938 }
3939 if !has_target {
3940 self.missing(value, "long-syntax volume is missing `target`");
3941 }
3942 mount
3943 }
3944
3945 fn grants(
3946 &mut self,
3947 value: &MergedValue,
3948 service_path: &[String],
3949 kind: &str,
3950 ) -> Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
3951 let Some(values) = value.as_sequence() else {
3952 self.expected(value, &format!("service {kind}s must be a sequence"));
3953 return None;
3954 };
3955 let mut grants = Vec::new();
3956 for (index, item) in values.iter().enumerate() {
3957 let mut path = service_path.to_vec();
3958 path.push(format!("{kind}s"));
3959 path.push(index.to_string());
3960 let grant = match item.kind() {
3961 MergedValueKind::Scalar(scalar) => ProjectGrant::Short(scalar.value().to_owned()),
3962 MergedValueKind::Mapping(fields) => {
3963 ProjectGrant::Long(Box::new(self.long_grant(item, fields, &path, kind)))
3964 }
3965 _ => {
3966 self.expected(
3967 item,
3968 &format!("service {kind} must use scalar short syntax or mapping long syntax"),
3969 );
3970 continue;
3971 }
3972 };
3973 grants.push(ProjectValue::new(grant, item));
3974 }
3975 Some(ProjectValue::new(grants, value))
3976 }
3977
3978 fn long_grant(
3979 &mut self,
3980 value: &MergedValue,
3981 fields: &[MergedEntry],
3982 path: &[String],
3983 kind: &str,
3984 ) -> ProjectLongGrant {
3985 let mut grant = ProjectLongGrant {
3986 source: None,
3987 target: None,
3988 uid: None,
3989 gid: None,
3990 mode: None,
3991 unmodeled_fields: Vec::new(),
3992 };
3993 for field in fields {
3994 let parsed = match field.key() {
3995 "source" => self.project_string(field.value(), &format!("{kind} source")),
3996 "target" => self.project_string(field.value(), &format!("{kind} target")),
3997 "uid" => self.project_string(field.value(), &format!("{kind} uid")),
3998 "gid" => self.project_string(field.value(), &format!("{kind} gid")),
3999 "mode" => self.project_string(field.value(), &format!("{kind} mode")),
4000 _ => {
4001 grant.unmodeled_fields.push(field_reference(path, field));
4002 continue;
4003 }
4004 };
4005 match field.key() {
4006 "source" => grant.source = parsed,
4007 "target" => grant.target = parsed,
4008 "uid" => grant.uid = parsed,
4009 "gid" => grant.gid = parsed,
4010 "mode" => grant.mode = parsed,
4011 _ => unreachable!("unrecognized grant fields continue before assignment"),
4012 }
4013 }
4014 if grant.source.is_none() {
4015 self.missing(value, &format!("long-syntax {kind} is missing `source`"));
4016 }
4017 grant
4018 }
4019
4020 fn bind_options(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<BindOptions> {
4021 let fields = self.mapping(value, "volume bind options must be a mapping")?;
4022 let mut bind = BindOptions::new(effective_span(value));
4023 let mut path = parent_path.to_vec();
4024 path.push("bind".to_owned());
4025 for field in fields {
4026 match field.key() {
4027 "propagation" => self
4028 .located_string(field.value(), "bind propagation must be a scalar")
4029 .into_iter()
4030 .for_each(|value| bind.set_propagation(value)),
4031 "create_host_path" => self
4032 .located_boolean(field.value(), "bind create_host_path must be a boolean")
4033 .into_iter()
4034 .for_each(|value| bind.set_create_host_path(value)),
4035 "selinux" => {
4036 if let Some(value) = self.located_string(field.value(), "bind SELinux mode must be a scalar") {
4037 let mode = match value.value().as_str() {
4038 "z" => Some(SelinuxRelabel::Shared),
4039 "Z" => Some(SelinuxRelabel::Private),
4040 _ => None,
4041 };
4042 if let Some(mode) = mode {
4043 bind.set_selinux(Located::new(mode, value.span()));
4044 } else {
4045 self.invalid(value.span(), "bind SELinux mode must be `z` or `Z`");
4046 }
4047 }
4048 }
4049 _ => self.record_pending_unmodeled(&path, field),
4050 }
4051 }
4052 Some(bind)
4053 }
4054
4055 fn service_networks(
4056 &mut self,
4057 value: &MergedValue,
4058 service_path: &[String],
4059 ) -> Option<ProjectValue<ServiceNetworks>> {
4060 let span = effective_span(value);
4061 let networks = match value.kind() {
4062 MergedValueKind::Sequence(values) => {
4063 let mut names = Vec::new();
4064 for value in values {
4065 names.push(self.located_string(value, "service network name must be a scalar")?);
4066 }
4067 ServiceNetworks::Short { span, names }
4068 }
4069 MergedValueKind::Mapping(entries) => {
4070 let mut networks = Vec::new();
4071 for entry in entries {
4072 let mut path = service_path.to_vec();
4073 path.push("networks".to_owned());
4074 path.push(entry.key().to_owned());
4075 networks.push(self.service_network(entry, &path)?);
4076 }
4077 ServiceNetworks::Long { span, networks }
4078 }
4079 _ => {
4080 self.expected(value, "service networks must be a sequence or mapping");
4081 return None;
4082 }
4083 };
4084 Some(ProjectValue::new(networks, value))
4085 }
4086
4087 fn service_network(&mut self, entry: &MergedEntry, path: &[String]) -> Option<ServiceNetwork> {
4088 let value = entry.value();
4089 let span = effective_span(value);
4090 let mut network = ServiceNetwork::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
4091 let fields = match value.kind() {
4092 MergedValueKind::Null(_) => return Some(network),
4093 MergedValueKind::Mapping(fields) => fields,
4094 _ => {
4095 self.expected(value, "service network attachment must be a mapping or null");
4096 return None;
4097 }
4098 };
4099 for field in fields {
4100 match field.key() {
4101 "aliases" => self
4102 .located_string_sequence(field.value(), "network aliases must be a sequence")
4103 .into_iter()
4104 .for_each(|value| network.set_aliases(value)),
4105 "interface_name" => self
4106 .located_string(field.value(), "network interface_name must be a scalar")
4107 .into_iter()
4108 .for_each(|value| network.set_interface_name(value)),
4109 "ipv4_address" => self
4110 .located_string(field.value(), "network ipv4_address must be a scalar")
4111 .into_iter()
4112 .for_each(|value| network.set_ipv4_address(value)),
4113 "ipv6_address" => self
4114 .located_string(field.value(), "network ipv6_address must be a scalar")
4115 .into_iter()
4116 .for_each(|value| network.set_ipv6_address(value)),
4117 "link_local_ips" => self
4118 .located_string_sequence(field.value(), "link_local_ips must be a sequence")
4119 .into_iter()
4120 .for_each(|value| network.set_link_local_ips(value)),
4121 "mac_address" => self
4122 .located_string(field.value(), "network mac_address must be a scalar")
4123 .into_iter()
4124 .for_each(|value| network.set_mac_address(value)),
4125 "driver_opts" => self
4126 .key_value_mapping(field.value(), "network driver_opts must be a mapping")
4127 .into_iter()
4128 .for_each(|value| network.set_driver_opts(value)),
4129 "gw_priority" => self
4130 .located_string(field.value(), "network gw_priority must be a scalar")
4131 .into_iter()
4132 .for_each(|value| network.set_gw_priority(value)),
4133 "priority" => self
4134 .located_string(field.value(), "network priority must be a scalar")
4135 .into_iter()
4136 .for_each(|value| network.set_priority(value)),
4137 _ => self.record_pending_unmodeled(path, field),
4138 }
4139 }
4140 Some(network)
4141 }
4142
4143 fn located_boolean(&mut self, value: &MergedValue, message: &str) -> Option<Located<BooleanValue>> {
4144 let scalar = self.scalar(value, message)?;
4145 let boolean = if scalar.kind() == MergedScalarKind::Boolean {
4146 BooleanValue::Literal(scalar.value().eq_ignore_ascii_case("true"))
4147 } else if scalar.value().contains('$') {
4148 BooleanValue::Expression(scalar.value().to_owned())
4149 } else {
4150 self.invalid(effective_span(value), message);
4151 return None;
4152 };
4153 Some(Located::new(boolean, effective_span(value)))
4154 }
4155
4156 fn located_string_sequence(&mut self, value: &MergedValue, message: &str) -> Option<Vec<Located<String>>> {
4157 let Some(values) = value.as_sequence() else {
4158 self.expected(value, message);
4159 return None;
4160 };
4161 let mut strings = Vec::new();
4162 for value in values {
4163 strings.push(self.located_string(value, "sequence item must be a scalar")?);
4164 }
4165 Some(strings)
4166 }
4167
4168 fn key_value_mapping(&mut self, value: &MergedValue, message: &str) -> Option<Vec<KeyValueEntry>> {
4169 let Some(entries) = value.as_mapping() else {
4170 self.expected(value, message);
4171 return None;
4172 };
4173 let mut values = Vec::new();
4174 for entry in entries {
4175 let scalar = self.compose_scalar(entry.value(), "mapping value must be a scalar or null")?;
4176 let value_span = effective_span(entry.value());
4177 values.push(KeyValueEntry::new(
4178 Located::new(entry.key().to_owned(), entry_span(entry)),
4179 Located::new(scalar, value_span),
4180 value_span,
4181 ));
4182 }
4183 Some(values)
4184 }
4185
4186 fn network_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<NetworkDefinition>> {
4187 let Some(entries) = self.mapping(value, "top-level networks must be a mapping") else {
4188 return Vec::new();
4189 };
4190 entries
4191 .iter()
4192 .filter_map(|entry| {
4193 let definition = self.network_definition(entry)?;
4194 Some(ProjectResource {
4195 name: ProjectKey::from_entry(entry),
4196 definition: ProjectValue::new(definition, entry.value()),
4197 })
4198 })
4199 .collect()
4200 }
4201
4202 fn network_definition(&mut self, entry: &MergedEntry) -> Option<NetworkDefinition> {
4203 let value = entry.value();
4204 let span = effective_span(value);
4205 let mut network = NetworkDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
4206 let fields = match value.kind() {
4207 MergedValueKind::Null(_) => return Some(network),
4208 MergedValueKind::Mapping(fields) => fields,
4209 _ => {
4210 self.expected(value, "network definition must be a mapping or null");
4211 return None;
4212 }
4213 };
4214 let path = ["networks".to_owned(), entry.key().to_owned()];
4215 for field in fields {
4216 match field.key() {
4217 "driver" => self
4218 .located_string(field.value(), "network driver must be a scalar")
4219 .into_iter()
4220 .for_each(|value| network.set_driver(value)),
4221 "driver_opts" => self
4222 .key_value_mapping(field.value(), "network driver_opts must be a mapping")
4223 .into_iter()
4224 .for_each(|value| network.set_driver_opts(value)),
4225 "attachable" => self
4226 .located_boolean(field.value(), "network attachable must be a boolean")
4227 .into_iter()
4228 .for_each(|value| network.set_attachable(value)),
4229 "enable_ipv4" => self
4230 .located_boolean(field.value(), "network enable_ipv4 must be a boolean")
4231 .into_iter()
4232 .for_each(|value| network.set_enable_ipv4(value)),
4233 "enable_ipv6" => self
4234 .located_boolean(field.value(), "network enable_ipv6 must be a boolean")
4235 .into_iter()
4236 .for_each(|value| network.set_enable_ipv6(value)),
4237 "external" => self
4238 .located_boolean(field.value(), "network external must be a boolean")
4239 .into_iter()
4240 .for_each(|value| network.set_external(value)),
4241 "internal" => self
4242 .located_boolean(field.value(), "network internal must be a boolean")
4243 .into_iter()
4244 .for_each(|value| network.set_internal(value)),
4245 "ipam" => self
4246 .ipam(field.value(), &path)
4247 .into_iter()
4248 .for_each(|value| network.set_ipam(value)),
4249 "labels" => self
4250 .labels(field.value())
4251 .into_iter()
4252 .for_each(|value| network.set_labels(value)),
4253 "name" => self
4254 .located_string(field.value(), "network custom name must be a scalar")
4255 .into_iter()
4256 .for_each(|value| network.set_custom_name(value)),
4257 _ => self.record_root_unmodeled(&path, field),
4258 }
4259 }
4260 Some(network)
4261 }
4262
4263 fn ipam(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Ipam> {
4264 let fields = self.mapping(value, "network IPAM must be a mapping")?;
4265 let mut ipam = Ipam::new(effective_span(value));
4266 let mut path = parent_path.to_vec();
4267 path.push("ipam".to_owned());
4268 for field in fields {
4269 match field.key() {
4270 "driver" => self
4271 .located_string(field.value(), "IPAM driver must be a scalar")
4272 .into_iter()
4273 .for_each(|value| ipam.set_driver(value)),
4274 "config" => self
4275 .ipam_configs(field.value(), &path)
4276 .into_iter()
4277 .for_each(|value| ipam.set_config(value)),
4278 "options" => self
4279 .key_value_mapping(field.value(), "IPAM options must be a mapping")
4280 .into_iter()
4281 .for_each(|value| ipam.set_options(value)),
4282 _ => self.record_root_unmodeled(&path, field),
4283 }
4284 }
4285 Some(ipam)
4286 }
4287
4288 fn ipam_configs(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Vec<IpamConfig>> {
4289 let Some(values) = value.as_sequence() else {
4290 self.expected(value, "IPAM config must be a sequence");
4291 return None;
4292 };
4293 let mut configs = Vec::new();
4294 for (index, value) in values.iter().enumerate() {
4295 let Some(fields) = value.as_mapping() else {
4296 self.expected(value, "IPAM config entry must be a mapping");
4297 continue;
4298 };
4299 let mut config = IpamConfig::new(effective_span(value));
4300 let mut path = parent_path.to_vec();
4301 path.push("config".to_owned());
4302 path.push(index.to_string());
4303 for field in fields {
4304 match field.key() {
4305 "subnet" => self
4306 .located_string(field.value(), "IPAM subnet must be a scalar")
4307 .into_iter()
4308 .for_each(|value| config.set_subnet(value)),
4309 "ip_range" => self
4310 .located_string(field.value(), "IPAM ip_range must be a scalar")
4311 .into_iter()
4312 .for_each(|value| config.set_ip_range(value)),
4313 "gateway" => self
4314 .located_string(field.value(), "IPAM gateway must be a scalar")
4315 .into_iter()
4316 .for_each(|value| config.set_gateway(value)),
4317 "aux_addresses" => self
4318 .key_value_mapping(field.value(), "IPAM aux_addresses must be a mapping")
4319 .into_iter()
4320 .for_each(|value| config.set_aux_addresses(value)),
4321 _ => self.record_root_unmodeled(&path, field),
4322 }
4323 }
4324 configs.push(config);
4325 }
4326 Some(configs)
4327 }
4328
4329 fn volume_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<VolumeDefinition>> {
4330 let Some(entries) = self.mapping(value, "top-level volumes must be a mapping") else {
4331 return Vec::new();
4332 };
4333 entries
4334 .iter()
4335 .filter_map(|entry| {
4336 let definition = self.volume_definition(entry)?;
4337 Some(ProjectResource {
4338 name: ProjectKey::from_entry(entry),
4339 definition: ProjectValue::new(definition, entry.value()),
4340 })
4341 })
4342 .collect()
4343 }
4344
4345 fn volume_definition(&mut self, entry: &MergedEntry) -> Option<VolumeDefinition> {
4346 let value = entry.value();
4347 let span = effective_span(value);
4348 let mut volume = VolumeDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
4349 let fields = match value.kind() {
4350 MergedValueKind::Null(_) => return Some(volume),
4351 MergedValueKind::Mapping(fields) => fields,
4352 _ => {
4353 self.expected(value, "volume definition must be a mapping or null");
4354 return None;
4355 }
4356 };
4357 let path = ["volumes".to_owned(), entry.key().to_owned()];
4358 for field in fields {
4359 match field.key() {
4360 "driver" => self
4361 .located_string(field.value(), "volume driver must be a scalar")
4362 .into_iter()
4363 .for_each(|value| volume.set_driver(value)),
4364 "driver_opts" => self
4365 .key_value_mapping(field.value(), "volume driver_opts must be a mapping")
4366 .into_iter()
4367 .for_each(|value| volume.set_driver_opts(value)),
4368 "external" => self
4369 .located_boolean(field.value(), "volume external must be a boolean")
4370 .into_iter()
4371 .for_each(|value| volume.set_external(value)),
4372 "labels" => self
4373 .labels(field.value())
4374 .into_iter()
4375 .for_each(|value| volume.set_labels(value)),
4376 "name" => self
4377 .located_string(field.value(), "volume custom name must be a scalar")
4378 .into_iter()
4379 .for_each(|value| volume.set_custom_name(value)),
4380 _ => self.record_root_unmodeled(&path, field),
4381 }
4382 }
4383 Some(volume)
4384 }
4385
4386 fn config_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<ConfigDefinition>> {
4387 let Some(entries) = self.mapping(value, "top-level configs must be a mapping") else {
4388 return Vec::new();
4389 };
4390 entries
4391 .iter()
4392 .filter_map(|entry| {
4393 let definition = self.config_definition(entry)?;
4394 Some(ProjectResource {
4395 name: ProjectKey::from_entry(entry),
4396 definition: ProjectValue::new(definition, entry.value()),
4397 })
4398 })
4399 .collect()
4400 }
4401
4402 fn config_definition(&mut self, entry: &MergedEntry) -> Option<ConfigDefinition> {
4403 let value = entry.value();
4404 let span = effective_span(value);
4405 let mut config = ConfigDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
4406 let fields = match value.kind() {
4407 MergedValueKind::Null(_) => return Some(config),
4408 MergedValueKind::Mapping(fields) => fields,
4409 _ => {
4410 self.expected(value, "config definition must be a mapping or null");
4411 return None;
4412 }
4413 };
4414 let path = ["configs".to_owned(), entry.key().to_owned()];
4415 for field in fields {
4416 match field.key() {
4417 "file" => self
4418 .located_string(field.value(), "config file must be a scalar")
4419 .into_iter()
4420 .for_each(|value| config.set_file(value)),
4421 "environment" => self
4422 .located_string(field.value(), "config environment must be a scalar")
4423 .into_iter()
4424 .for_each(|value| config.set_environment(value)),
4425 "content" => self
4426 .located_string(field.value(), "config content must be a scalar")
4427 .into_iter()
4428 .for_each(|value| config.set_content(value)),
4429 "external" => self
4430 .located_boolean(field.value(), "config external must be a boolean")
4431 .into_iter()
4432 .for_each(|value| config.set_external(value)),
4433 "name" => self
4434 .located_string(field.value(), "config custom name must be a scalar")
4435 .into_iter()
4436 .for_each(|value| config.set_custom_name(value)),
4437 _ => self.record_root_unmodeled(&path, field),
4438 }
4439 }
4440 Some(config)
4441 }
4442
4443 fn secret_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<SecretDefinition>> {
4444 let Some(entries) = self.mapping(value, "top-level secrets must be a mapping") else {
4445 return Vec::new();
4446 };
4447 entries
4448 .iter()
4449 .filter_map(|entry| {
4450 let definition = self.secret_definition(entry)?;
4451 Some(ProjectResource {
4452 name: ProjectKey::from_entry(entry),
4453 definition: ProjectValue::new(definition, entry.value()),
4454 })
4455 })
4456 .collect()
4457 }
4458
4459 fn secret_definition(&mut self, entry: &MergedEntry) -> Option<SecretDefinition> {
4460 let value = entry.value();
4461 let span = effective_span(value);
4462 let mut secret = SecretDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
4463 let fields = match value.kind() {
4464 MergedValueKind::Null(_) => return Some(secret),
4465 MergedValueKind::Mapping(fields) => fields,
4466 _ => {
4467 self.expected(value, "secret definition must be a mapping or null");
4468 return None;
4469 }
4470 };
4471 let path = ["secrets".to_owned(), entry.key().to_owned()];
4472 for field in fields {
4473 match field.key() {
4474 "file" => self
4475 .located_string(field.value(), "secret file must be a scalar")
4476 .into_iter()
4477 .for_each(|value| secret.set_file(value)),
4478 "environment" => self
4479 .located_string(field.value(), "secret environment must be a scalar")
4480 .into_iter()
4481 .for_each(|value| secret.set_environment(value)),
4482 "external" => self
4483 .located_boolean(field.value(), "secret external must be a boolean")
4484 .into_iter()
4485 .for_each(|value| secret.set_external(value)),
4486 "name" => self
4487 .located_string(field.value(), "secret custom name must be a scalar")
4488 .into_iter()
4489 .for_each(|value| secret.set_custom_name(value)),
4490 _ => self.record_root_unmodeled(&path, field),
4491 }
4492 }
4493 Some(secret)
4494 }
4495
4496 fn labels(&mut self, value: &MergedValue) -> Option<Labels> {
4497 let span = effective_span(value);
4498 match value.kind() {
4499 MergedValueKind::Sequence(_) => self
4500 .located_string_sequence(value, "labels must be a scalar sequence")
4501 .map(|values| Labels::List { span, values }),
4502 MergedValueKind::Mapping(_) => self
4503 .key_value_mapping(value, "labels must be a scalar mapping")
4504 .map(|entries| Labels::Map { span, entries }),
4505 _ => {
4506 self.expected(value, "labels must be a sequence or mapping");
4507 None
4508 }
4509 }
4510 }
4511}
4512
4513fn compose_scalar_from_merged(scalar: &crate::merge::MergedScalar) -> ComposeScalar {
4514 match scalar.kind() {
4515 MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
4516 MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
4517 MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
4518 }
4519}
4520
4521fn field_reference(path: &[String], entry: &MergedEntry) -> ProjectFieldReference {
4522 let mut complete_path = path.to_vec();
4523 complete_path.push(entry.key().to_owned());
4524 ProjectFieldReference {
4525 path: complete_path,
4526 key: ProjectKey::from_entry(entry),
4527 provenance: entry.value().provenance().clone(),
4528 extension: entry.key().starts_with("x-"),
4529 sensitive: entry.value().is_sensitive(),
4530 }
4531}
4532
4533fn effective_span(value: &MergedValue) -> SourceSpan {
4534 value
4535 .provenance()
4536 .effective_source()
4537 .or_else(|| value.provenance().sources().first().copied())
4538 .unwrap_or_else(|| SourceSpan::from_valid_offsets(SourceId::new(0), 0, 0))
4539}
4540
4541fn entry_span(entry: &MergedEntry) -> SourceSpan {
4542 entry
4543 .key_sources()
4544 .last()
4545 .copied()
4546 .or_else(|| entry.key_sources().first().copied())
4547 .unwrap_or_else(|| effective_span(entry.value()))
4548}