1use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::merge::{
5 EntrySyntax, MergeProvenance, MergedEntry, MergedProject, MergedScalarKind, MergedValue, MergedValueKind,
6};
7use crate::model::{
8 BindOptions, BooleanValue, Command, ComposeScalar, ConfigDefinition, DependencyCondition, HealthcheckDuration,
9 HealthcheckRetries, HealthcheckTest, HealthcheckTestKind, HostAddress, ImageReference, Ipam, IpamConfig,
10 KeyValueEntry, Labels, Located, LongPort, LongVolumeMount, MountType, NetworkDefinition, Port, SecretDefinition,
11 SelinuxRelabel, ServiceNetwork, ServiceNetworks, ShortExtraHost, ShortPort, ShortVolumeMount, UserNamespaceMode,
12 UserSpec, VolumeDefinition, VolumeMount,
13};
14use crate::profiles::ProfileSelection;
15use crate::resolution::{SELECTION_PROJECT_MISMATCH, service_in_scope};
16use crate::source::{SourceId, SourceSpan};
17use std::fmt;
18use std::path::{Path, PathBuf};
19
20pub const PROJECT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.project.expected-form");
22
23pub const PROJECT_MISSING_FIELD: DiagnosticCode = DiagnosticCode::new("compose.project.missing-field");
25
26pub const PROJECT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.project.invalid-value");
28
29#[derive(Clone, PartialEq, Eq)]
31pub struct ProjectValue<T> {
32 value: T,
33 provenance: MergeProvenance,
34 sensitive: bool,
35}
36
37impl<T: fmt::Debug> fmt::Debug for ProjectValue<T> {
38 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39 let mut debug = formatter.debug_struct("ProjectValue");
40 if self.sensitive {
41 debug.field("value", &"<redacted>");
42 } else {
43 debug.field("value", &self.value);
44 }
45 debug
46 .field("provenance", &self.provenance)
47 .field("sensitive", &self.sensitive)
48 .finish()
49 }
50}
51
52impl<T> ProjectValue<T> {
53 fn new(value: T, source: &MergedValue) -> Self {
54 Self {
55 value,
56 provenance: source.provenance().clone(),
57 sensitive: source.is_sensitive(),
58 }
59 }
60
61 #[must_use]
63 pub const fn value(&self) -> &T {
64 &self.value
65 }
66
67 #[must_use]
69 pub const fn provenance(&self) -> &MergeProvenance {
70 &self.provenance
71 }
72
73 #[must_use]
75 pub fn effective_source(&self) -> Option<SourceSpan> {
76 self.provenance.effective_source()
77 }
78
79 #[must_use]
81 pub const fn is_sensitive(&self) -> bool {
82 self.sensitive
83 }
84
85 #[must_use]
87 pub fn into_value(self) -> T {
88 self.value
89 }
90}
91
92#[derive(Clone, PartialEq, Eq)]
94pub struct ProjectKey {
95 value: String,
96 sources: Vec<SourceSpan>,
97 sensitive: bool,
98}
99
100impl fmt::Debug for ProjectKey {
101 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102 formatter
103 .debug_struct("ProjectKey")
104 .field("value", &if self.sensitive { "<redacted>" } else { &self.value })
105 .field("sources", &self.sources)
106 .field("sensitive", &self.sensitive)
107 .finish()
108 }
109}
110
111impl ProjectKey {
112 fn from_entry(entry: &MergedEntry) -> Self {
113 Self {
114 value: entry.key().to_owned(),
115 sources: entry.key_sources().to_vec(),
116 sensitive: entry.is_key_sensitive(),
117 }
118 }
119
120 fn from_value(value: String, source: &MergedValue) -> Self {
121 Self {
122 value,
123 sources: source.provenance().sources().to_vec(),
124 sensitive: source.is_sensitive(),
125 }
126 }
127
128 #[must_use]
130 pub fn value(&self) -> &str {
131 &self.value
132 }
133
134 #[must_use]
136 pub fn sources(&self) -> &[SourceSpan] {
137 &self.sources
138 }
139
140 #[must_use]
142 pub fn effective_source(&self) -> Option<SourceSpan> {
143 self.sources.last().copied()
144 }
145
146 #[must_use]
148 pub const fn is_sensitive(&self) -> bool {
149 self.sensitive
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct ProjectServiceDependency {
156 service: ProjectKey,
157 condition: Option<ProjectValue<DependencyCondition>>,
158 restart: Option<ProjectValue<BooleanValue>>,
159 required: Option<ProjectValue<BooleanValue>>,
160 unmodeled_fields: Vec<ProjectFieldReference>,
161}
162
163impl ProjectServiceDependency {
164 #[must_use]
166 pub const fn service(&self) -> &ProjectKey {
167 &self.service
168 }
169
170 #[must_use]
172 pub const fn condition(&self) -> Option<&ProjectValue<DependencyCondition>> {
173 self.condition.as_ref()
174 }
175
176 #[must_use]
178 pub const fn restart(&self) -> Option<&ProjectValue<BooleanValue>> {
179 self.restart.as_ref()
180 }
181
182 #[must_use]
184 pub const fn required(&self) -> Option<&ProjectValue<BooleanValue>> {
185 self.required.as_ref()
186 }
187
188 #[must_use]
190 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
191 &self.unmodeled_fields
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum ProjectDependsOn {
198 Short(Vec<ProjectValue<ProjectServiceDependency>>),
200 Long(Vec<ProjectValue<ProjectServiceDependency>>),
202}
203
204impl ProjectDependsOn {
205 #[must_use]
207 pub fn services(&self) -> &[ProjectValue<ProjectServiceDependency>] {
208 match self {
209 Self::Short(services) | Self::Long(services) => services,
210 }
211 }
212
213 #[must_use]
215 pub const fn is_long(&self) -> bool {
216 matches!(self, Self::Long(_))
217 }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct ProjectFieldReference {
223 path: Vec<String>,
224 key: ProjectKey,
225 provenance: MergeProvenance,
226 extension: bool,
227 sensitive: bool,
228}
229
230impl ProjectFieldReference {
231 #[must_use]
233 pub fn path(&self) -> &[String] {
234 &self.path
235 }
236
237 #[must_use]
239 pub const fn key(&self) -> &ProjectKey {
240 &self.key
241 }
242
243 #[must_use]
245 pub const fn provenance(&self) -> &MergeProvenance {
246 &self.provenance
247 }
248
249 #[must_use]
251 pub const fn is_extension(&self) -> bool {
252 self.extension
253 }
254
255 #[must_use]
257 pub const fn is_sensitive(&self) -> bool {
258 self.sensitive
259 }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct ProjectEnvironmentEntry {
265 name: ProjectKey,
266 value: ProjectValue<ComposeScalar>,
267 syntax: EntrySyntax,
268}
269
270impl ProjectEnvironmentEntry {
271 #[must_use]
273 pub const fn name(&self) -> &ProjectKey {
274 &self.name
275 }
276
277 #[must_use]
279 pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
280 &self.value
281 }
282
283 #[must_use]
285 pub const fn syntax(&self) -> EntrySyntax {
286 self.syntax
287 }
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
292pub struct ProjectEnvironment {
293 entries: Vec<ProjectEnvironmentEntry>,
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct ProjectLabelEntry {
299 name: ProjectKey,
300 value: ProjectValue<ComposeScalar>,
301 syntax: EntrySyntax,
302}
303
304impl ProjectLabelEntry {
305 #[must_use]
307 pub const fn name(&self) -> &ProjectKey {
308 &self.name
309 }
310
311 #[must_use]
316 pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
317 &self.value
318 }
319
320 #[must_use]
322 pub const fn syntax(&self) -> EntrySyntax {
323 self.syntax
324 }
325}
326
327#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct ProjectLabels {
330 entries: Vec<ProjectLabelEntry>,
331}
332
333impl ProjectLabels {
334 #[must_use]
336 pub fn entries(&self) -> &[ProjectLabelEntry] {
337 &self.entries
338 }
339
340 #[must_use]
342 pub fn get(&self, name: &str) -> Option<&ProjectLabelEntry> {
343 self.entries.iter().find(|entry| entry.name.value == name)
344 }
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct ProjectExtraHost {
350 hostname: ProjectKey,
351 address: ProjectValue<HostAddress>,
352 syntax: EntrySyntax,
353}
354
355impl ProjectExtraHost {
356 #[must_use]
358 pub const fn hostname(&self) -> &ProjectKey {
359 &self.hostname
360 }
361
362 #[must_use]
364 pub const fn address(&self) -> &ProjectValue<HostAddress> {
365 &self.address
366 }
367
368 #[must_use]
370 pub const fn syntax(&self) -> EntrySyntax {
371 self.syntax
372 }
373}
374
375#[derive(Debug, Clone, PartialEq, Eq)]
377pub struct ProjectExtraHosts {
378 entries: Vec<ProjectExtraHost>,
379}
380
381impl ProjectExtraHosts {
382 #[must_use]
384 pub fn entries(&self) -> &[ProjectExtraHost] {
385 &self.entries
386 }
387}
388
389impl ProjectEnvironment {
390 #[must_use]
392 pub fn entries(&self) -> &[ProjectEnvironmentEntry] {
393 &self.entries
394 }
395
396 #[must_use]
398 pub fn get(&self, name: &str) -> Option<&ProjectEnvironmentEntry> {
399 self.entries.iter().find(|entry| entry.name.value == name)
400 }
401}
402
403#[derive(Debug, Clone, PartialEq, Eq)]
405pub struct ProjectHealthcheck {
406 test: Option<ProjectValue<HealthcheckTest>>,
407 interval: Option<ProjectValue<HealthcheckDuration>>,
408 timeout: Option<ProjectValue<HealthcheckDuration>>,
409 retries: Option<ProjectValue<HealthcheckRetries>>,
410 start_period: Option<ProjectValue<HealthcheckDuration>>,
411 start_interval: Option<ProjectValue<HealthcheckDuration>>,
412 disable: Option<ProjectValue<BooleanValue>>,
413 unmodeled_fields: Vec<ProjectFieldReference>,
414}
415
416#[derive(Debug, Clone, PartialEq, Eq)]
418pub struct ProjectLongGrant {
419 source: Option<ProjectValue<String>>,
420 target: Option<ProjectValue<String>>,
421 uid: Option<ProjectValue<String>>,
422 gid: Option<ProjectValue<String>>,
423 mode: Option<ProjectValue<String>>,
424 unmodeled_fields: Vec<ProjectFieldReference>,
425}
426
427impl ProjectLongGrant {
428 #[must_use]
430 pub const fn source(&self) -> Option<&ProjectValue<String>> {
431 self.source.as_ref()
432 }
433
434 #[must_use]
436 pub const fn target(&self) -> Option<&ProjectValue<String>> {
437 self.target.as_ref()
438 }
439
440 #[must_use]
442 pub const fn uid(&self) -> Option<&ProjectValue<String>> {
443 self.uid.as_ref()
444 }
445
446 #[must_use]
448 pub const fn gid(&self) -> Option<&ProjectValue<String>> {
449 self.gid.as_ref()
450 }
451
452 #[must_use]
454 pub const fn mode(&self) -> Option<&ProjectValue<String>> {
455 self.mode.as_ref()
456 }
457
458 #[must_use]
460 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
461 &self.unmodeled_fields
462 }
463}
464
465#[derive(Debug, Clone, PartialEq, Eq)]
467pub enum ProjectGrant {
468 Short(String),
470 Long(Box<ProjectLongGrant>),
472}
473
474impl ProjectHealthcheck {
475 #[must_use]
477 pub const fn test(&self) -> Option<&ProjectValue<HealthcheckTest>> {
478 self.test.as_ref()
479 }
480
481 #[must_use]
483 pub const fn interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
484 self.interval.as_ref()
485 }
486
487 #[must_use]
489 pub const fn timeout(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
490 self.timeout.as_ref()
491 }
492
493 #[must_use]
495 pub const fn retries(&self) -> Option<&ProjectValue<HealthcheckRetries>> {
496 self.retries.as_ref()
497 }
498
499 #[must_use]
501 pub const fn start_period(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
502 self.start_period.as_ref()
503 }
504
505 #[must_use]
507 pub const fn start_interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
508 self.start_interval.as_ref()
509 }
510
511 #[must_use]
513 pub const fn disable(&self) -> Option<&ProjectValue<BooleanValue>> {
514 self.disable.as_ref()
515 }
516
517 #[must_use]
519 pub fn is_disabled(&self) -> bool {
520 matches!(
521 self.disable.as_ref().map(ProjectValue::value),
522 Some(BooleanValue::Literal(true))
523 ) || matches!(
524 self.test.as_ref().and_then(|test| test.value().kind()),
525 Some(HealthcheckTestKind::None)
526 )
527 }
528
529 #[must_use]
531 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
532 &self.unmodeled_fields
533 }
534}
535
536#[derive(Debug, Clone, PartialEq, Eq)]
538pub struct ProjectService {
539 name: ProjectKey,
540 provenance: MergeProvenance,
541 image: Option<ProjectValue<ImageReference>>,
542 command: Option<ProjectValue<Command>>,
543 environment: Option<ProjectValue<ProjectEnvironment>>,
544 labels: Option<ProjectValue<ProjectLabels>>,
545 extra_hosts: Option<ProjectValue<ProjectExtraHosts>>,
546 user: Option<ProjectValue<UserSpec>>,
547 userns_mode: Option<ProjectValue<UserNamespaceMode>>,
548 group_add: Option<ProjectValue<Vec<ProjectValue<String>>>>,
549 working_dir: Option<ProjectValue<String>>,
550 read_only: Option<ProjectValue<BooleanValue>>,
551 healthcheck: Option<ProjectValue<ProjectHealthcheck>>,
552 depends_on: Option<ProjectValue<ProjectDependsOn>>,
553 ports: Option<ProjectValue<Vec<ProjectValue<Port>>>>,
554 volumes: Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>>,
555 configs: Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>,
556 secrets: Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>>,
557 networks: Option<ProjectValue<ServiceNetworks>>,
558 profiles: Option<ProjectValue<Vec<ProjectValue<String>>>>,
559 unmodeled_fields: Vec<ProjectFieldReference>,
560}
561
562impl ProjectService {
563 #[must_use]
565 pub const fn name(&self) -> &ProjectKey {
566 &self.name
567 }
568
569 #[must_use]
571 pub const fn provenance(&self) -> &MergeProvenance {
572 &self.provenance
573 }
574
575 #[must_use]
577 pub const fn image(&self) -> Option<&ProjectValue<ImageReference>> {
578 self.image.as_ref()
579 }
580
581 #[must_use]
583 pub const fn command(&self) -> Option<&ProjectValue<Command>> {
584 self.command.as_ref()
585 }
586
587 #[must_use]
589 pub const fn environment(&self) -> Option<&ProjectValue<ProjectEnvironment>> {
590 self.environment.as_ref()
591 }
592
593 #[must_use]
595 pub const fn labels(&self) -> Option<&ProjectValue<ProjectLabels>> {
596 self.labels.as_ref()
597 }
598
599 #[must_use]
601 pub const fn extra_hosts(&self) -> Option<&ProjectValue<ProjectExtraHosts>> {
602 self.extra_hosts.as_ref()
603 }
604
605 #[must_use]
607 pub const fn user(&self) -> Option<&ProjectValue<UserSpec>> {
608 self.user.as_ref()
609 }
610
611 #[must_use]
613 pub const fn userns_mode(&self) -> Option<&ProjectValue<UserNamespaceMode>> {
614 self.userns_mode.as_ref()
615 }
616
617 #[must_use]
619 pub const fn group_add(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
620 self.group_add.as_ref()
621 }
622
623 #[must_use]
625 pub const fn working_dir(&self) -> Option<&ProjectValue<String>> {
626 self.working_dir.as_ref()
627 }
628
629 #[must_use]
631 pub const fn read_only(&self) -> Option<&ProjectValue<BooleanValue>> {
632 self.read_only.as_ref()
633 }
634
635 #[must_use]
637 pub const fn healthcheck(&self) -> Option<&ProjectValue<ProjectHealthcheck>> {
638 self.healthcheck.as_ref()
639 }
640
641 #[must_use]
643 pub const fn depends_on(&self) -> Option<&ProjectValue<ProjectDependsOn>> {
644 self.depends_on.as_ref()
645 }
646
647 #[must_use]
649 pub const fn ports(&self) -> Option<&ProjectValue<Vec<ProjectValue<Port>>>> {
650 self.ports.as_ref()
651 }
652
653 #[must_use]
655 pub const fn volumes(&self) -> Option<&ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
656 self.volumes.as_ref()
657 }
658
659 #[must_use]
661 pub const fn configs(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
662 self.configs.as_ref()
663 }
664
665 #[must_use]
667 pub const fn secrets(&self) -> Option<&ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
668 self.secrets.as_ref()
669 }
670
671 #[must_use]
673 pub const fn networks(&self) -> Option<&ProjectValue<ServiceNetworks>> {
674 self.networks.as_ref()
675 }
676
677 #[must_use]
679 pub const fn profiles(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
680 self.profiles.as_ref()
681 }
682
683 #[must_use]
685 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
686 &self.unmodeled_fields
687 }
688}
689
690#[derive(Debug, Clone, PartialEq, Eq)]
692pub struct ProjectResource<T> {
693 name: ProjectKey,
694 definition: ProjectValue<T>,
695}
696
697impl<T> ProjectResource<T> {
698 #[must_use]
700 pub const fn name(&self) -> &ProjectKey {
701 &self.name
702 }
703
704 #[must_use]
706 pub const fn definition(&self) -> &ProjectValue<T> {
707 &self.definition
708 }
709}
710
711#[derive(Debug, Clone, PartialEq, Eq)]
713pub struct ProjectView {
714 source_ids: Vec<SourceId>,
715 base_directory: PathBuf,
716 provenance: MergeProvenance,
717 name: Option<ProjectValue<String>>,
718 services: Vec<ProjectService>,
719 networks: Vec<ProjectResource<NetworkDefinition>>,
720 volumes: Vec<ProjectResource<VolumeDefinition>>,
721 configs: Vec<ProjectResource<ConfigDefinition>>,
722 secrets: Vec<ProjectResource<SecretDefinition>>,
723 unmodeled_fields: Vec<ProjectFieldReference>,
724}
725
726impl ProjectView {
727 #[must_use]
729 pub fn source_ids(&self) -> &[SourceId] {
730 &self.source_ids
731 }
732
733 #[must_use]
735 pub fn base_directory(&self) -> &Path {
736 &self.base_directory
737 }
738
739 #[must_use]
741 pub const fn provenance(&self) -> &MergeProvenance {
742 &self.provenance
743 }
744
745 #[must_use]
747 pub const fn name(&self) -> Option<&ProjectValue<String>> {
748 self.name.as_ref()
749 }
750
751 #[must_use]
753 pub fn services(&self) -> &[ProjectService] {
754 &self.services
755 }
756
757 #[must_use]
759 pub fn service(&self, name: &str) -> Option<&ProjectService> {
760 self.services.iter().find(|service| service.name.value == name)
761 }
762
763 #[must_use]
765 pub fn networks(&self) -> &[ProjectResource<NetworkDefinition>] {
766 &self.networks
767 }
768
769 #[must_use]
771 pub fn volumes(&self) -> &[ProjectResource<VolumeDefinition>] {
772 &self.volumes
773 }
774
775 #[must_use]
777 pub fn configs(&self) -> &[ProjectResource<ConfigDefinition>] {
778 &self.configs
779 }
780
781 #[must_use]
783 pub fn secrets(&self) -> &[ProjectResource<SecretDefinition>] {
784 &self.secrets
785 }
786
787 #[must_use]
789 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
790 &self.unmodeled_fields
791 }
792}
793
794#[derive(Debug, Clone, PartialEq, Eq)]
796pub struct ProjectViewResult {
797 view: Option<ProjectView>,
798 diagnostics: Vec<Diagnostic>,
799}
800
801impl ProjectViewResult {
802 #[must_use]
804 pub const fn view(&self) -> Option<&ProjectView> {
805 self.view.as_ref()
806 }
807
808 #[must_use]
810 pub fn diagnostics(&self) -> &[Diagnostic] {
811 &self.diagnostics
812 }
813
814 #[must_use]
816 pub fn is_valid(&self) -> bool {
817 self.view.is_some()
818 && self
819 .diagnostics
820 .iter()
821 .all(|diagnostic| diagnostic.severity() != Severity::Error)
822 }
823
824 #[must_use]
826 pub fn into_parts(self) -> (Option<ProjectView>, Vec<Diagnostic>) {
827 (self.view, self.diagnostics)
828 }
829}
830
831#[must_use]
836pub fn build_project_view(project: &MergedProject, selection: Option<&ProfileSelection>) -> ProjectViewResult {
837 if selection.is_some_and(|selection| !selection.belongs_to(project)) {
838 return ProjectViewResult {
839 view: None,
840 diagnostics: vec![Diagnostic::new(
841 SELECTION_PROJECT_MISMATCH,
842 Severity::Error,
843 "profile selection does not belong to the merged project",
844 )],
845 };
846 }
847
848 Builder::new(project, selection).build()
849}
850
851struct Builder<'a> {
852 project: &'a MergedProject,
853 selection: Option<&'a ProfileSelection>,
854 diagnostics: Vec<Diagnostic>,
855 root_unmodeled: Vec<ProjectFieldReference>,
856 pending_unmodeled: Vec<ProjectFieldReference>,
857}
858
859impl<'a> Builder<'a> {
860 const fn new(project: &'a MergedProject, selection: Option<&'a ProfileSelection>) -> Self {
861 Self {
862 project,
863 selection,
864 diagnostics: Vec::new(),
865 root_unmodeled: Vec::new(),
866 pending_unmodeled: Vec::new(),
867 }
868 }
869
870 fn build(mut self) -> ProjectViewResult {
871 let root = self.project.root();
872 let entries = root.as_mapping().unwrap_or_default();
873 let mut name = None;
874 let mut services = Vec::new();
875 let mut networks = Vec::new();
876 let mut volumes = Vec::new();
877 let mut configs = Vec::new();
878 let mut secrets = Vec::new();
879
880 for entry in entries {
881 match entry.key() {
882 "name" => name = self.project_string(entry.value(), "project name"),
883 "services" => services = self.services(entry.value()),
884 "networks" => networks = self.network_definitions(entry.value()),
885 "volumes" => volumes = self.volume_definitions(entry.value()),
886 "configs" => configs = self.config_definitions(entry.value()),
887 "secrets" => secrets = self.secret_definitions(entry.value()),
888 _ => self.record_root_unmodeled(&[], entry),
889 }
890 }
891
892 ProjectViewResult {
893 view: Some(ProjectView {
894 source_ids: self.project.source_ids().to_vec(),
895 base_directory: self.project.base_directory().to_path_buf(),
896 provenance: root.provenance().clone(),
897 name,
898 services,
899 networks,
900 volumes,
901 configs,
902 secrets,
903 unmodeled_fields: self.root_unmodeled,
904 }),
905 diagnostics: self.diagnostics,
906 }
907 }
908
909 fn services(&mut self, value: &MergedValue) -> Vec<ProjectService> {
910 let Some(entries) = self.mapping(value, "services must be a mapping") else {
911 return Vec::new();
912 };
913 let selection = self.selection;
914 let mut services = Vec::new();
915 for entry in entries {
916 if service_in_scope(selection, entry.key()) {
917 services.extend(self.service(entry));
918 }
919 }
920 services
921 }
922
923 fn service(&mut self, entry: &MergedEntry) -> Option<ProjectService> {
924 let pending_start = self.pending_unmodeled.len();
925 let value = entry.value();
926 let fields = self.mapping(value, "service definition must be a mapping")?;
927 let mut service = ProjectService {
928 name: ProjectKey::from_entry(entry),
929 provenance: value.provenance().clone(),
930 image: None,
931 command: None,
932 environment: None,
933 labels: None,
934 extra_hosts: None,
935 user: None,
936 userns_mode: None,
937 group_add: None,
938 working_dir: None,
939 read_only: None,
940 healthcheck: None,
941 depends_on: None,
942 ports: None,
943 volumes: None,
944 configs: None,
945 secrets: None,
946 networks: None,
947 profiles: None,
948 unmodeled_fields: Vec::new(),
949 };
950 let path = ["services".to_owned(), entry.key().to_owned()];
951
952 for field in fields {
953 match field.key() {
954 "image" => {
955 service.image = self
956 .project_string(field.value(), "service image")
957 .map(|value| ProjectValue {
958 value: ImageReference::parse(value.value),
959 provenance: value.provenance,
960 sensitive: value.sensitive,
961 });
962 }
963 "command" => service.command = self.command(field.value()),
964 "environment" => service.environment = self.environment(field.value()),
965 "labels" => service.labels = self.service_labels(field.value()),
966 "extra_hosts" => service.extra_hosts = self.extra_hosts(field.value()),
967 "user" => service.user = self.user(field.value()),
968 "userns_mode" => service.userns_mode = self.userns_mode(field.value()),
969 "group_add" => {
970 service.group_add = self.string_collection(field.value(), "group_add must be a sequence");
971 }
972 "working_dir" => {
973 service.working_dir = self.project_string(field.value(), "service working directory");
974 }
975 "read_only" => {
976 service.read_only = self
977 .located_boolean(field.value(), "service read_only must be a boolean")
978 .map(|value| ProjectValue::new(value.into_value(), field.value()));
979 }
980 "healthcheck" => service.healthcheck = self.healthcheck(field.value(), &path),
981 "depends_on" => service.depends_on = self.depends_on(field.value(), &path),
982 "ports" => service.ports = self.ports(field.value(), &path),
983 "volumes" => service.volumes = self.volumes(field.value(), &path),
984 "configs" => service.configs = self.grants(field.value(), &path, "config"),
985 "secrets" => service.secrets = self.grants(field.value(), &path, "secret"),
986 "networks" => service.networks = self.service_networks(field.value(), &path),
987 "profiles" => service.profiles = self.string_collection(field.value(), "profiles must be a sequence"),
988 _ => service.unmodeled_fields.push(field_reference(&path, field)),
989 }
990 }
991 service
992 .unmodeled_fields
993 .extend(self.pending_unmodeled.drain(pending_start..));
994 Some(service)
995 }
996
997 fn command(&mut self, value: &MergedValue) -> Option<ProjectValue<Command>> {
998 let span = effective_span(value);
999 let command = match value.kind() {
1000 MergedValueKind::Null(_) => Command::Null(span),
1001 MergedValueKind::Scalar(scalar) => Command::String(Located::new(scalar.value().to_owned(), span)),
1002 MergedValueKind::Sequence(values) => {
1003 let mut arguments = Vec::new();
1004 for value in values {
1005 arguments.push(self.located_string(value, "command list item must be a scalar")?);
1006 }
1007 Command::List {
1008 span,
1009 values: arguments,
1010 }
1011 }
1012 _ => {
1013 self.expected(value, "command must be null, a scalar, or a sequence");
1014 return None;
1015 }
1016 };
1017 Some(ProjectValue::new(command, value))
1018 }
1019
1020 fn user(&mut self, value: &MergedValue) -> Option<ProjectValue<UserSpec>> {
1021 let raw = self.project_string(value, "service user")?;
1022 Some(ProjectValue {
1023 value: UserSpec::parse(Located::new(raw.value, effective_span(value))),
1024 provenance: raw.provenance,
1025 sensitive: raw.sensitive,
1026 })
1027 }
1028
1029 fn userns_mode(&mut self, value: &MergedValue) -> Option<ProjectValue<UserNamespaceMode>> {
1030 let raw = self.project_string(value, "service user namespace mode")?;
1031 Some(ProjectValue {
1032 value: UserNamespaceMode::parse(Located::new(raw.value, effective_span(value))),
1033 provenance: raw.provenance,
1034 sensitive: raw.sensitive,
1035 })
1036 }
1037
1038 fn environment(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectEnvironment>> {
1039 let mut entries = Vec::new();
1040 match value.kind() {
1041 MergedValueKind::Mapping(values) => {
1042 for entry in values {
1043 let scalar = self.compose_scalar(entry.value(), "environment value must be a scalar or null")?;
1044 entries.push(ProjectEnvironmentEntry {
1045 name: ProjectKey::from_entry(entry),
1046 value: ProjectValue::new(scalar, entry.value()),
1047 syntax: entry.syntax(),
1048 });
1049 }
1050 }
1051 MergedValueKind::Sequence(values) => {
1052 for item in values {
1053 let raw = self.located_string(item, "environment list item must be a scalar")?;
1054 let (name, scalar, syntax) = raw.value().split_once('=').map_or_else(
1055 || (raw.value().clone(), ComposeScalar::Null, EntrySyntax::ListKeyOnly),
1056 |(name, value)| {
1057 (
1058 name.to_owned(),
1059 ComposeScalar::String(value.to_owned()),
1060 EntrySyntax::ListKeyValue,
1061 )
1062 },
1063 );
1064 entries.push(ProjectEnvironmentEntry {
1065 name: ProjectKey {
1066 value: name,
1067 sources: item.provenance().sources().to_vec(),
1068 sensitive: item.is_sensitive(),
1069 },
1070 value: ProjectValue::new(scalar, item),
1071 syntax,
1072 });
1073 }
1074 }
1075 _ => {
1076 self.expected(value, "environment must be a mapping or sequence");
1077 return None;
1078 }
1079 }
1080 Some(ProjectValue::new(ProjectEnvironment { entries }, value))
1081 }
1082
1083 fn service_labels(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectLabels>> {
1084 let mut entries = Vec::new();
1085 match value.kind() {
1086 MergedValueKind::Mapping(values) => {
1087 for entry in values {
1088 let scalar = if entry.syntax() == EntrySyntax::ListKeyOnly {
1089 ComposeScalar::String(String::new())
1090 } else {
1091 self.compose_scalar(entry.value(), "label value must be a scalar or null")?
1092 };
1093 entries.push(ProjectLabelEntry {
1094 name: ProjectKey::from_entry(entry),
1095 value: ProjectValue::new(scalar, entry.value()),
1096 syntax: entry.syntax(),
1097 });
1098 }
1099 }
1100 MergedValueKind::Sequence(values) => {
1101 for item in values {
1102 let raw = self.located_string(item, "label list item must be a scalar")?;
1103 let (name, value, syntax) = raw.value().split_once('=').map_or_else(
1104 || (raw.value().clone(), String::new(), EntrySyntax::ListKeyOnly),
1105 |(name, value)| (name.to_owned(), value.to_owned(), EntrySyntax::ListKeyValue),
1106 );
1107 entries.push(ProjectLabelEntry {
1108 name: ProjectKey::from_value(name, item),
1109 value: ProjectValue::new(ComposeScalar::String(value), item),
1110 syntax,
1111 });
1112 }
1113 }
1114 _ => {
1115 self.expected(value, "labels must be a mapping or sequence");
1116 return None;
1117 }
1118 }
1119 Some(ProjectValue::new(ProjectLabels { entries }, value))
1120 }
1121
1122 fn healthcheck(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ProjectValue<ProjectHealthcheck>> {
1123 let fields = self.mapping(value, "healthcheck must be a mapping")?;
1124 let mut healthcheck = ProjectHealthcheck {
1125 test: None,
1126 interval: None,
1127 timeout: None,
1128 retries: None,
1129 start_period: None,
1130 start_interval: None,
1131 disable: None,
1132 unmodeled_fields: Vec::new(),
1133 };
1134 let mut path = parent_path.to_vec();
1135 path.push("healthcheck".to_owned());
1136 for field in fields {
1137 match field.key() {
1138 "test" => healthcheck.test = self.healthcheck_test(field.value()),
1139 "interval" => {
1140 healthcheck.interval =
1141 self.healthcheck_duration(field.value(), "healthcheck interval must be a scalar");
1142 }
1143 "timeout" => {
1144 healthcheck.timeout =
1145 self.healthcheck_duration(field.value(), "healthcheck timeout must be a scalar");
1146 }
1147 "retries" => healthcheck.retries = self.healthcheck_retries(field.value()),
1148 "start_period" => {
1149 healthcheck.start_period =
1150 self.healthcheck_duration(field.value(), "healthcheck start_period must be a scalar");
1151 }
1152 "start_interval" => {
1153 healthcheck.start_interval =
1154 self.healthcheck_duration(field.value(), "healthcheck start_interval must be a scalar");
1155 }
1156 "disable" => {
1157 healthcheck.disable = self
1158 .located_boolean(field.value(), "healthcheck disable must be a boolean")
1159 .map(|value| ProjectValue::new(value.into_value(), field.value()));
1160 }
1161 _ => healthcheck.unmodeled_fields.push(field_reference(&path, field)),
1162 }
1163 }
1164 Some(ProjectValue::new(healthcheck, value))
1165 }
1166
1167 fn depends_on(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ProjectValue<ProjectDependsOn>> {
1168 let dependencies = match value.kind() {
1169 MergedValueKind::Sequence(values) => {
1170 let mut dependencies = Vec::new();
1171 for value in values {
1172 let Some(service) = self.project_string(value, "dependency service name") else {
1173 continue;
1174 };
1175 let dependency = ProjectServiceDependency {
1176 service: ProjectKey::from_value(service.value, value),
1177 condition: None,
1178 restart: None,
1179 required: None,
1180 unmodeled_fields: Vec::new(),
1181 };
1182 dependencies.push(ProjectValue::new(dependency, value));
1183 }
1184 ProjectDependsOn::Short(dependencies)
1185 }
1186 MergedValueKind::Mapping(entries) => {
1187 let mut dependencies = Vec::new();
1188 let mut path = parent_path.to_vec();
1189 path.push("depends_on".to_owned());
1190 for entry in entries {
1191 let mut dependency = ProjectServiceDependency {
1192 service: ProjectKey::from_entry(entry),
1193 condition: None,
1194 restart: None,
1195 required: None,
1196 unmodeled_fields: Vec::new(),
1197 };
1198 let fields = match entry.value().kind() {
1199 MergedValueKind::Null(_) => &[][..],
1200 MergedValueKind::Mapping(fields) => fields.as_slice(),
1201 _ => {
1202 self.expected(entry.value(), "long dependency options must be a mapping or null");
1203 continue;
1204 }
1205 };
1206 let mut dependency_path = path.clone();
1207 dependency_path.push(entry.key().to_owned());
1208 for field in fields {
1209 match field.key() {
1210 "condition" => {
1211 let Some(condition) = self.project_string(field.value(), "dependency condition") else {
1212 continue;
1213 };
1214 let parsed = DependencyCondition::parse(condition.value);
1215 if !parsed.is_known() {
1216 self.invalid(
1217 effective_span(field.value()),
1218 "dependency condition is not defined by Compose",
1219 );
1220 }
1221 dependency.condition = Some(ProjectValue {
1222 value: parsed,
1223 provenance: condition.provenance,
1224 sensitive: condition.sensitive,
1225 });
1226 }
1227 "restart" => {
1228 dependency.restart = self
1229 .located_boolean(field.value(), "dependency restart must be a boolean")
1230 .map(|value| ProjectValue::new(value.into_value(), field.value()));
1231 }
1232 "required" => {
1233 dependency.required = self
1234 .located_boolean(field.value(), "dependency required must be a boolean")
1235 .map(|value| ProjectValue::new(value.into_value(), field.value()));
1236 }
1237 _ => dependency
1238 .unmodeled_fields
1239 .push(field_reference(&dependency_path, field)),
1240 }
1241 }
1242 dependencies.push(ProjectValue::new(dependency, entry.value()));
1243 }
1244 ProjectDependsOn::Long(dependencies)
1245 }
1246 _ => {
1247 self.expected(value, "depends_on must be a sequence or mapping");
1248 return None;
1249 }
1250 };
1251 Some(ProjectValue::new(dependencies, value))
1252 }
1253
1254 fn healthcheck_test(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckTest>> {
1255 let span = effective_span(value);
1256 let test = match value.kind() {
1257 MergedValueKind::Scalar(scalar) => HealthcheckTest::String(Located::new(scalar.value().to_owned(), span)),
1258 MergedValueKind::Sequence(values) => {
1259 let mut items = Vec::new();
1260 for value in values {
1261 items.push(self.located_string(value, "healthcheck test item must be a scalar")?);
1262 }
1263 let kind = items.first().map(|item| HealthcheckTestKind::parse(item.value()));
1264 HealthcheckTest::List {
1265 span,
1266 kind,
1267 values: items,
1268 }
1269 }
1270 _ => {
1271 self.expected(value, "healthcheck test must be a scalar or sequence");
1272 return None;
1273 }
1274 };
1275 Some(ProjectValue::new(test, value))
1276 }
1277
1278 fn healthcheck_duration(
1279 &mut self,
1280 value: &MergedValue,
1281 message: &str,
1282 ) -> Option<ProjectValue<HealthcheckDuration>> {
1283 let scalar = self.scalar(value, message)?;
1284 Some(ProjectValue::new(
1285 HealthcheckDuration::parse(scalar.value().to_owned()),
1286 value,
1287 ))
1288 }
1289
1290 fn healthcheck_retries(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckRetries>> {
1291 let scalar = self.scalar(value, "healthcheck retries must be a scalar")?;
1292 Some(ProjectValue::new(
1293 HealthcheckRetries::parse(scalar.value().to_owned()),
1294 value,
1295 ))
1296 }
1297
1298 fn extra_hosts(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectExtraHosts>> {
1299 let mut entries = Vec::new();
1300 match value.kind() {
1301 MergedValueKind::Mapping(values) => {
1302 for entry in values {
1303 let scalar = self.scalar(entry.value(), "extra_hosts address must be a scalar")?;
1304 entries.push(ProjectExtraHost {
1305 hostname: ProjectKey::from_entry(entry),
1306 address: ProjectValue::new(HostAddress::parse(scalar.value().to_owned()), entry.value()),
1307 syntax: EntrySyntax::Mapping,
1308 });
1309 }
1310 }
1311 MergedValueKind::Sequence(values) => {
1312 for item in values {
1313 let raw = self.located_string(item, "extra_hosts list item must be a scalar")?;
1314 let parsed = ShortExtraHost::parse(raw);
1315 let (Some(hostname), Some(address)) = (parsed.hostname(), parsed.address()) else {
1316 self.invalid(
1317 effective_span(item),
1318 "extra_hosts entry must contain a hostname and address",
1319 );
1320 continue;
1321 };
1322 entries.push(ProjectExtraHost {
1323 hostname: ProjectKey {
1324 value: hostname.to_owned(),
1325 sources: item.provenance().sources().to_vec(),
1326 sensitive: item.is_sensitive(),
1327 },
1328 address: ProjectValue::new(address.clone(), item),
1329 syntax: EntrySyntax::ListKeyValue,
1330 });
1331 }
1332 }
1333 _ => {
1334 self.expected(value, "extra_hosts must be a mapping or sequence");
1335 return None;
1336 }
1337 }
1338 Some(ProjectValue::new(ProjectExtraHosts { entries }, value))
1339 }
1340
1341 fn project_string(&mut self, value: &MergedValue, description: &str) -> Option<ProjectValue<String>> {
1342 let scalar = self.scalar(value, &format!("{description} must be a non-null scalar"))?;
1343 Some(ProjectValue::new(scalar.value().to_owned(), value))
1344 }
1345
1346 fn string_collection(
1347 &mut self,
1348 value: &MergedValue,
1349 message: &str,
1350 ) -> Option<ProjectValue<Vec<ProjectValue<String>>>> {
1351 let Some(values) = value.as_sequence() else {
1352 self.expected(value, message);
1353 return None;
1354 };
1355 let mut strings = Vec::new();
1356 for value in values {
1357 let scalar = self.scalar(value, "sequence item must be a non-null scalar")?;
1358 strings.push(ProjectValue::new(scalar.value().to_owned(), value));
1359 }
1360 Some(ProjectValue::new(strings, value))
1361 }
1362
1363 fn scalar<'value>(
1364 &mut self,
1365 value: &'value MergedValue,
1366 message: &str,
1367 ) -> Option<&'value crate::merge::MergedScalar> {
1368 let Some(scalar) = value.as_scalar() else {
1369 self.expected(value, message);
1370 return None;
1371 };
1372 Some(scalar)
1373 }
1374
1375 fn located_string(&mut self, value: &MergedValue, message: &str) -> Option<Located<String>> {
1376 let scalar = self.scalar(value, message)?;
1377 Some(Located::new(scalar.value().to_owned(), effective_span(value)))
1378 }
1379
1380 fn compose_scalar(&mut self, value: &MergedValue, message: &str) -> Option<ComposeScalar> {
1381 match value.kind() {
1382 MergedValueKind::Null(_) => Some(ComposeScalar::Null),
1383 MergedValueKind::Scalar(scalar) => Some(match scalar.kind() {
1384 MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
1385 MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
1386 MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
1387 }),
1388 _ => {
1389 self.expected(value, message);
1390 None
1391 }
1392 }
1393 }
1394
1395 fn mapping<'value>(&mut self, value: &'value MergedValue, message: &str) -> Option<&'value [MergedEntry]> {
1396 let Some(entries) = value.as_mapping() else {
1397 self.expected(value, message);
1398 return None;
1399 };
1400 Some(entries)
1401 }
1402
1403 fn expected(&mut self, value: &MergedValue, message: &str) {
1404 self.diagnostics.push(
1405 Diagnostic::new(PROJECT_EXPECTED_FORM, Severity::Error, message).with_label(DiagnosticLabel::primary(
1406 effective_span(value),
1407 "unexpected merged value form",
1408 )),
1409 );
1410 }
1411
1412 fn missing(&mut self, value: &MergedValue, message: &str) {
1413 self.diagnostics.push(
1414 Diagnostic::new(PROJECT_MISSING_FIELD, Severity::Error, message).with_label(DiagnosticLabel::primary(
1415 effective_span(value),
1416 "required field is missing",
1417 )),
1418 );
1419 }
1420
1421 fn invalid(&mut self, span: SourceSpan, message: &str) {
1422 self.diagnostics.push(
1423 Diagnostic::new(PROJECT_INVALID_VALUE, Severity::Error, message)
1424 .with_label(DiagnosticLabel::primary(span, "invalid native value")),
1425 );
1426 }
1427
1428 fn record_root_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
1429 self.root_unmodeled.push(field_reference(path, entry));
1430 }
1431
1432 fn record_pending_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
1433 self.pending_unmodeled.push(field_reference(path, entry));
1434 }
1435}
1436
1437impl Builder<'_> {
1438 fn ports(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<Vec<ProjectValue<Port>>>> {
1439 let Some(values) = value.as_sequence() else {
1440 self.expected(value, "service ports must be a sequence");
1441 return None;
1442 };
1443 let mut ports = Vec::new();
1444 for (index, item) in values.iter().enumerate() {
1445 let mut path = service_path.to_vec();
1446 path.push("ports".to_owned());
1447 path.push(index.to_string());
1448 let port = match item.kind() {
1449 MergedValueKind::Scalar(scalar) => Port::Short(ShortPort::parse(Located::new(
1450 scalar.value().to_owned(),
1451 effective_span(item),
1452 ))),
1453 MergedValueKind::Mapping(fields) => Port::Long(Box::new(self.long_port(item, fields, &path))),
1454 _ => {
1455 self.expected(item, "service port must use scalar short syntax or mapping long syntax");
1456 continue;
1457 }
1458 };
1459 ports.push(ProjectValue::new(port, item));
1460 }
1461 Some(ProjectValue::new(ports, value))
1462 }
1463
1464 fn long_port(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongPort {
1465 let mut port = LongPort::new(effective_span(value));
1466 let mut has_target = false;
1467 for field in fields {
1468 match field.key() {
1469 "target" => {
1470 if let Some(value) = self.located_string(field.value(), "port target must be a scalar") {
1471 port.set_target(value);
1472 has_target = true;
1473 }
1474 }
1475 "published" => self
1476 .located_string(field.value(), "published port must be a scalar")
1477 .into_iter()
1478 .for_each(|value| port.set_published(value)),
1479 "host_ip" => self
1480 .located_string(field.value(), "port host_ip must be a scalar")
1481 .into_iter()
1482 .for_each(|value| port.set_host_ip(value)),
1483 "protocol" => self
1484 .located_string(field.value(), "port protocol must be a scalar")
1485 .into_iter()
1486 .for_each(|value| port.set_protocol(value)),
1487 "app_protocol" => self
1488 .located_string(field.value(), "port app_protocol must be a scalar")
1489 .into_iter()
1490 .for_each(|value| port.set_app_protocol(value)),
1491 "mode" => self
1492 .located_string(field.value(), "port mode must be a scalar")
1493 .into_iter()
1494 .for_each(|value| port.set_mode(value)),
1495 "name" => self
1496 .located_string(field.value(), "port name must be a scalar")
1497 .into_iter()
1498 .for_each(|value| port.set_name(value)),
1499 _ => self.record_pending_unmodeled(path, field),
1500 }
1501 }
1502 if !has_target {
1503 self.missing(value, "long-syntax port is missing `target`");
1504 }
1505 port
1506 }
1507
1508 fn volumes(
1509 &mut self,
1510 value: &MergedValue,
1511 service_path: &[String],
1512 ) -> Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
1513 let Some(values) = value.as_sequence() else {
1514 self.expected(value, "service volumes must be a sequence");
1515 return None;
1516 };
1517 let mut mounts = Vec::new();
1518 for (index, item) in values.iter().enumerate() {
1519 let mut path = service_path.to_vec();
1520 path.push("volumes".to_owned());
1521 path.push(index.to_string());
1522 let mount = match item.kind() {
1523 MergedValueKind::Scalar(scalar) => VolumeMount::Short(ShortVolumeMount::new(Located::new(
1524 scalar.value().to_owned(),
1525 effective_span(item),
1526 ))),
1527 MergedValueKind::Mapping(fields) => VolumeMount::Long(Box::new(self.long_volume(item, fields, &path))),
1528 _ => {
1529 self.expected(
1530 item,
1531 "service volume must use scalar short syntax or mapping long syntax",
1532 );
1533 continue;
1534 }
1535 };
1536 mounts.push(ProjectValue::new(mount, item));
1537 }
1538 Some(ProjectValue::new(mounts, value))
1539 }
1540
1541 fn long_volume(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongVolumeMount {
1542 let mut mount = LongVolumeMount::new(effective_span(value));
1543 let mut has_type = false;
1544 let mut has_target = false;
1545 for field in fields {
1546 match field.key() {
1547 "type" => {
1548 if let Some(value) = self.located_string(field.value(), "volume type must be a scalar") {
1549 mount.set_mount_type(Located::new(MountType::from_text(value.value().clone()), value.span()));
1550 has_type = true;
1551 }
1552 }
1553 "source" => self
1554 .located_string(field.value(), "volume source must be a scalar")
1555 .into_iter()
1556 .for_each(|value| mount.set_source(value)),
1557 "target" => {
1558 if let Some(value) = self.located_string(field.value(), "volume target must be a scalar") {
1559 mount.set_target(value);
1560 has_target = true;
1561 }
1562 }
1563 "read_only" => self
1564 .located_boolean(field.value(), "volume read_only must be a boolean")
1565 .into_iter()
1566 .for_each(|value| mount.set_read_only(value)),
1567 "bind" => self
1568 .bind_options(field.value(), path)
1569 .into_iter()
1570 .for_each(|value| mount.set_bind(value)),
1571 _ => self.record_pending_unmodeled(path, field),
1572 }
1573 }
1574 if !has_type {
1575 self.missing(value, "long-syntax volume is missing `type`");
1576 }
1577 if !has_target {
1578 self.missing(value, "long-syntax volume is missing `target`");
1579 }
1580 mount
1581 }
1582
1583 fn grants(
1584 &mut self,
1585 value: &MergedValue,
1586 service_path: &[String],
1587 kind: &str,
1588 ) -> Option<ProjectValue<Vec<ProjectValue<ProjectGrant>>>> {
1589 let Some(values) = value.as_sequence() else {
1590 self.expected(value, &format!("service {kind}s must be a sequence"));
1591 return None;
1592 };
1593 let mut grants = Vec::new();
1594 for (index, item) in values.iter().enumerate() {
1595 let mut path = service_path.to_vec();
1596 path.push(format!("{kind}s"));
1597 path.push(index.to_string());
1598 let grant = match item.kind() {
1599 MergedValueKind::Scalar(scalar) => ProjectGrant::Short(scalar.value().to_owned()),
1600 MergedValueKind::Mapping(fields) => {
1601 ProjectGrant::Long(Box::new(self.long_grant(item, fields, &path, kind)))
1602 }
1603 _ => {
1604 self.expected(
1605 item,
1606 &format!("service {kind} must use scalar short syntax or mapping long syntax"),
1607 );
1608 continue;
1609 }
1610 };
1611 grants.push(ProjectValue::new(grant, item));
1612 }
1613 Some(ProjectValue::new(grants, value))
1614 }
1615
1616 fn long_grant(
1617 &mut self,
1618 value: &MergedValue,
1619 fields: &[MergedEntry],
1620 path: &[String],
1621 kind: &str,
1622 ) -> ProjectLongGrant {
1623 let mut grant = ProjectLongGrant {
1624 source: None,
1625 target: None,
1626 uid: None,
1627 gid: None,
1628 mode: None,
1629 unmodeled_fields: Vec::new(),
1630 };
1631 for field in fields {
1632 let parsed = match field.key() {
1633 "source" => self.project_string(field.value(), &format!("{kind} source")),
1634 "target" => self.project_string(field.value(), &format!("{kind} target")),
1635 "uid" => self.project_string(field.value(), &format!("{kind} uid")),
1636 "gid" => self.project_string(field.value(), &format!("{kind} gid")),
1637 "mode" => self.project_string(field.value(), &format!("{kind} mode")),
1638 _ => {
1639 grant.unmodeled_fields.push(field_reference(path, field));
1640 continue;
1641 }
1642 };
1643 match field.key() {
1644 "source" => grant.source = parsed,
1645 "target" => grant.target = parsed,
1646 "uid" => grant.uid = parsed,
1647 "gid" => grant.gid = parsed,
1648 "mode" => grant.mode = parsed,
1649 _ => unreachable!("unrecognized grant fields continue before assignment"),
1650 }
1651 }
1652 if grant.source.is_none() {
1653 self.missing(value, &format!("long-syntax {kind} is missing `source`"));
1654 }
1655 grant
1656 }
1657
1658 fn bind_options(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<BindOptions> {
1659 let fields = self.mapping(value, "volume bind options must be a mapping")?;
1660 let mut bind = BindOptions::new(effective_span(value));
1661 let mut path = parent_path.to_vec();
1662 path.push("bind".to_owned());
1663 for field in fields {
1664 match field.key() {
1665 "propagation" => self
1666 .located_string(field.value(), "bind propagation must be a scalar")
1667 .into_iter()
1668 .for_each(|value| bind.set_propagation(value)),
1669 "create_host_path" => self
1670 .located_boolean(field.value(), "bind create_host_path must be a boolean")
1671 .into_iter()
1672 .for_each(|value| bind.set_create_host_path(value)),
1673 "selinux" => {
1674 if let Some(value) = self.located_string(field.value(), "bind SELinux mode must be a scalar") {
1675 let mode = match value.value().as_str() {
1676 "z" => Some(SelinuxRelabel::Shared),
1677 "Z" => Some(SelinuxRelabel::Private),
1678 _ => None,
1679 };
1680 if let Some(mode) = mode {
1681 bind.set_selinux(Located::new(mode, value.span()));
1682 } else {
1683 self.invalid(value.span(), "bind SELinux mode must be `z` or `Z`");
1684 }
1685 }
1686 }
1687 _ => self.record_pending_unmodeled(&path, field),
1688 }
1689 }
1690 Some(bind)
1691 }
1692
1693 fn service_networks(
1694 &mut self,
1695 value: &MergedValue,
1696 service_path: &[String],
1697 ) -> Option<ProjectValue<ServiceNetworks>> {
1698 let span = effective_span(value);
1699 let networks = match value.kind() {
1700 MergedValueKind::Sequence(values) => {
1701 let mut names = Vec::new();
1702 for value in values {
1703 names.push(self.located_string(value, "service network name must be a scalar")?);
1704 }
1705 ServiceNetworks::Short { span, names }
1706 }
1707 MergedValueKind::Mapping(entries) => {
1708 let mut networks = Vec::new();
1709 for entry in entries {
1710 let mut path = service_path.to_vec();
1711 path.push("networks".to_owned());
1712 path.push(entry.key().to_owned());
1713 networks.push(self.service_network(entry, &path)?);
1714 }
1715 ServiceNetworks::Long { span, networks }
1716 }
1717 _ => {
1718 self.expected(value, "service networks must be a sequence or mapping");
1719 return None;
1720 }
1721 };
1722 Some(ProjectValue::new(networks, value))
1723 }
1724
1725 fn service_network(&mut self, entry: &MergedEntry, path: &[String]) -> Option<ServiceNetwork> {
1726 let value = entry.value();
1727 let span = effective_span(value);
1728 let mut network = ServiceNetwork::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1729 let fields = match value.kind() {
1730 MergedValueKind::Null(_) => return Some(network),
1731 MergedValueKind::Mapping(fields) => fields,
1732 _ => {
1733 self.expected(value, "service network attachment must be a mapping or null");
1734 return None;
1735 }
1736 };
1737 for field in fields {
1738 match field.key() {
1739 "aliases" => self
1740 .located_string_sequence(field.value(), "network aliases must be a sequence")
1741 .into_iter()
1742 .for_each(|value| network.set_aliases(value)),
1743 "interface_name" => self
1744 .located_string(field.value(), "network interface_name must be a scalar")
1745 .into_iter()
1746 .for_each(|value| network.set_interface_name(value)),
1747 "ipv4_address" => self
1748 .located_string(field.value(), "network ipv4_address must be a scalar")
1749 .into_iter()
1750 .for_each(|value| network.set_ipv4_address(value)),
1751 "ipv6_address" => self
1752 .located_string(field.value(), "network ipv6_address must be a scalar")
1753 .into_iter()
1754 .for_each(|value| network.set_ipv6_address(value)),
1755 "link_local_ips" => self
1756 .located_string_sequence(field.value(), "link_local_ips must be a sequence")
1757 .into_iter()
1758 .for_each(|value| network.set_link_local_ips(value)),
1759 "mac_address" => self
1760 .located_string(field.value(), "network mac_address must be a scalar")
1761 .into_iter()
1762 .for_each(|value| network.set_mac_address(value)),
1763 "driver_opts" => self
1764 .key_value_mapping(field.value(), "network driver_opts must be a mapping")
1765 .into_iter()
1766 .for_each(|value| network.set_driver_opts(value)),
1767 "gw_priority" => self
1768 .located_string(field.value(), "network gw_priority must be a scalar")
1769 .into_iter()
1770 .for_each(|value| network.set_gw_priority(value)),
1771 "priority" => self
1772 .located_string(field.value(), "network priority must be a scalar")
1773 .into_iter()
1774 .for_each(|value| network.set_priority(value)),
1775 _ => self.record_pending_unmodeled(path, field),
1776 }
1777 }
1778 Some(network)
1779 }
1780
1781 fn located_boolean(&mut self, value: &MergedValue, message: &str) -> Option<Located<BooleanValue>> {
1782 let scalar = self.scalar(value, message)?;
1783 let boolean = if scalar.kind() == MergedScalarKind::Boolean {
1784 BooleanValue::Literal(scalar.value().eq_ignore_ascii_case("true"))
1785 } else if scalar.value().contains('$') {
1786 BooleanValue::Expression(scalar.value().to_owned())
1787 } else {
1788 self.invalid(effective_span(value), message);
1789 return None;
1790 };
1791 Some(Located::new(boolean, effective_span(value)))
1792 }
1793
1794 fn located_string_sequence(&mut self, value: &MergedValue, message: &str) -> Option<Vec<Located<String>>> {
1795 let Some(values) = value.as_sequence() else {
1796 self.expected(value, message);
1797 return None;
1798 };
1799 let mut strings = Vec::new();
1800 for value in values {
1801 strings.push(self.located_string(value, "sequence item must be a scalar")?);
1802 }
1803 Some(strings)
1804 }
1805
1806 fn key_value_mapping(&mut self, value: &MergedValue, message: &str) -> Option<Vec<KeyValueEntry>> {
1807 let Some(entries) = value.as_mapping() else {
1808 self.expected(value, message);
1809 return None;
1810 };
1811 let mut values = Vec::new();
1812 for entry in entries {
1813 let scalar = self.compose_scalar(entry.value(), "mapping value must be a scalar or null")?;
1814 let value_span = effective_span(entry.value());
1815 values.push(KeyValueEntry::new(
1816 Located::new(entry.key().to_owned(), entry_span(entry)),
1817 Located::new(scalar, value_span),
1818 value_span,
1819 ));
1820 }
1821 Some(values)
1822 }
1823
1824 fn network_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<NetworkDefinition>> {
1825 let Some(entries) = self.mapping(value, "top-level networks must be a mapping") else {
1826 return Vec::new();
1827 };
1828 entries
1829 .iter()
1830 .filter_map(|entry| {
1831 let definition = self.network_definition(entry)?;
1832 Some(ProjectResource {
1833 name: ProjectKey::from_entry(entry),
1834 definition: ProjectValue::new(definition, entry.value()),
1835 })
1836 })
1837 .collect()
1838 }
1839
1840 fn network_definition(&mut self, entry: &MergedEntry) -> Option<NetworkDefinition> {
1841 let value = entry.value();
1842 let span = effective_span(value);
1843 let mut network = NetworkDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1844 let fields = match value.kind() {
1845 MergedValueKind::Null(_) => return Some(network),
1846 MergedValueKind::Mapping(fields) => fields,
1847 _ => {
1848 self.expected(value, "network definition must be a mapping or null");
1849 return None;
1850 }
1851 };
1852 let path = ["networks".to_owned(), entry.key().to_owned()];
1853 for field in fields {
1854 match field.key() {
1855 "driver" => self
1856 .located_string(field.value(), "network driver must be a scalar")
1857 .into_iter()
1858 .for_each(|value| network.set_driver(value)),
1859 "driver_opts" => self
1860 .key_value_mapping(field.value(), "network driver_opts must be a mapping")
1861 .into_iter()
1862 .for_each(|value| network.set_driver_opts(value)),
1863 "attachable" => self
1864 .located_boolean(field.value(), "network attachable must be a boolean")
1865 .into_iter()
1866 .for_each(|value| network.set_attachable(value)),
1867 "enable_ipv4" => self
1868 .located_boolean(field.value(), "network enable_ipv4 must be a boolean")
1869 .into_iter()
1870 .for_each(|value| network.set_enable_ipv4(value)),
1871 "enable_ipv6" => self
1872 .located_boolean(field.value(), "network enable_ipv6 must be a boolean")
1873 .into_iter()
1874 .for_each(|value| network.set_enable_ipv6(value)),
1875 "external" => self
1876 .located_boolean(field.value(), "network external must be a boolean")
1877 .into_iter()
1878 .for_each(|value| network.set_external(value)),
1879 "internal" => self
1880 .located_boolean(field.value(), "network internal must be a boolean")
1881 .into_iter()
1882 .for_each(|value| network.set_internal(value)),
1883 "ipam" => self
1884 .ipam(field.value(), &path)
1885 .into_iter()
1886 .for_each(|value| network.set_ipam(value)),
1887 "labels" => self
1888 .labels(field.value())
1889 .into_iter()
1890 .for_each(|value| network.set_labels(value)),
1891 "name" => self
1892 .located_string(field.value(), "network custom name must be a scalar")
1893 .into_iter()
1894 .for_each(|value| network.set_custom_name(value)),
1895 _ => self.record_root_unmodeled(&path, field),
1896 }
1897 }
1898 Some(network)
1899 }
1900
1901 fn ipam(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Ipam> {
1902 let fields = self.mapping(value, "network IPAM must be a mapping")?;
1903 let mut ipam = Ipam::new(effective_span(value));
1904 let mut path = parent_path.to_vec();
1905 path.push("ipam".to_owned());
1906 for field in fields {
1907 match field.key() {
1908 "driver" => self
1909 .located_string(field.value(), "IPAM driver must be a scalar")
1910 .into_iter()
1911 .for_each(|value| ipam.set_driver(value)),
1912 "config" => self
1913 .ipam_configs(field.value(), &path)
1914 .into_iter()
1915 .for_each(|value| ipam.set_config(value)),
1916 "options" => self
1917 .key_value_mapping(field.value(), "IPAM options must be a mapping")
1918 .into_iter()
1919 .for_each(|value| ipam.set_options(value)),
1920 _ => self.record_root_unmodeled(&path, field),
1921 }
1922 }
1923 Some(ipam)
1924 }
1925
1926 fn ipam_configs(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Vec<IpamConfig>> {
1927 let Some(values) = value.as_sequence() else {
1928 self.expected(value, "IPAM config must be a sequence");
1929 return None;
1930 };
1931 let mut configs = Vec::new();
1932 for (index, value) in values.iter().enumerate() {
1933 let Some(fields) = value.as_mapping() else {
1934 self.expected(value, "IPAM config entry must be a mapping");
1935 continue;
1936 };
1937 let mut config = IpamConfig::new(effective_span(value));
1938 let mut path = parent_path.to_vec();
1939 path.push("config".to_owned());
1940 path.push(index.to_string());
1941 for field in fields {
1942 match field.key() {
1943 "subnet" => self
1944 .located_string(field.value(), "IPAM subnet must be a scalar")
1945 .into_iter()
1946 .for_each(|value| config.set_subnet(value)),
1947 "ip_range" => self
1948 .located_string(field.value(), "IPAM ip_range must be a scalar")
1949 .into_iter()
1950 .for_each(|value| config.set_ip_range(value)),
1951 "gateway" => self
1952 .located_string(field.value(), "IPAM gateway must be a scalar")
1953 .into_iter()
1954 .for_each(|value| config.set_gateway(value)),
1955 "aux_addresses" => self
1956 .key_value_mapping(field.value(), "IPAM aux_addresses must be a mapping")
1957 .into_iter()
1958 .for_each(|value| config.set_aux_addresses(value)),
1959 _ => self.record_root_unmodeled(&path, field),
1960 }
1961 }
1962 configs.push(config);
1963 }
1964 Some(configs)
1965 }
1966
1967 fn volume_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<VolumeDefinition>> {
1968 let Some(entries) = self.mapping(value, "top-level volumes must be a mapping") else {
1969 return Vec::new();
1970 };
1971 entries
1972 .iter()
1973 .filter_map(|entry| {
1974 let definition = self.volume_definition(entry)?;
1975 Some(ProjectResource {
1976 name: ProjectKey::from_entry(entry),
1977 definition: ProjectValue::new(definition, entry.value()),
1978 })
1979 })
1980 .collect()
1981 }
1982
1983 fn volume_definition(&mut self, entry: &MergedEntry) -> Option<VolumeDefinition> {
1984 let value = entry.value();
1985 let span = effective_span(value);
1986 let mut volume = VolumeDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1987 let fields = match value.kind() {
1988 MergedValueKind::Null(_) => return Some(volume),
1989 MergedValueKind::Mapping(fields) => fields,
1990 _ => {
1991 self.expected(value, "volume definition must be a mapping or null");
1992 return None;
1993 }
1994 };
1995 let path = ["volumes".to_owned(), entry.key().to_owned()];
1996 for field in fields {
1997 match field.key() {
1998 "driver" => self
1999 .located_string(field.value(), "volume driver must be a scalar")
2000 .into_iter()
2001 .for_each(|value| volume.set_driver(value)),
2002 "driver_opts" => self
2003 .key_value_mapping(field.value(), "volume driver_opts must be a mapping")
2004 .into_iter()
2005 .for_each(|value| volume.set_driver_opts(value)),
2006 "external" => self
2007 .located_boolean(field.value(), "volume external must be a boolean")
2008 .into_iter()
2009 .for_each(|value| volume.set_external(value)),
2010 "labels" => self
2011 .labels(field.value())
2012 .into_iter()
2013 .for_each(|value| volume.set_labels(value)),
2014 "name" => self
2015 .located_string(field.value(), "volume custom name must be a scalar")
2016 .into_iter()
2017 .for_each(|value| volume.set_custom_name(value)),
2018 _ => self.record_root_unmodeled(&path, field),
2019 }
2020 }
2021 Some(volume)
2022 }
2023
2024 fn config_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<ConfigDefinition>> {
2025 let Some(entries) = self.mapping(value, "top-level configs must be a mapping") else {
2026 return Vec::new();
2027 };
2028 entries
2029 .iter()
2030 .filter_map(|entry| {
2031 let definition = self.config_definition(entry)?;
2032 Some(ProjectResource {
2033 name: ProjectKey::from_entry(entry),
2034 definition: ProjectValue::new(definition, entry.value()),
2035 })
2036 })
2037 .collect()
2038 }
2039
2040 fn config_definition(&mut self, entry: &MergedEntry) -> Option<ConfigDefinition> {
2041 let value = entry.value();
2042 let span = effective_span(value);
2043 let mut config = ConfigDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
2044 let fields = match value.kind() {
2045 MergedValueKind::Null(_) => return Some(config),
2046 MergedValueKind::Mapping(fields) => fields,
2047 _ => {
2048 self.expected(value, "config definition must be a mapping or null");
2049 return None;
2050 }
2051 };
2052 let path = ["configs".to_owned(), entry.key().to_owned()];
2053 for field in fields {
2054 match field.key() {
2055 "file" => self
2056 .located_string(field.value(), "config file must be a scalar")
2057 .into_iter()
2058 .for_each(|value| config.set_file(value)),
2059 "environment" => self
2060 .located_string(field.value(), "config environment must be a scalar")
2061 .into_iter()
2062 .for_each(|value| config.set_environment(value)),
2063 "content" => self
2064 .located_string(field.value(), "config content must be a scalar")
2065 .into_iter()
2066 .for_each(|value| config.set_content(value)),
2067 "external" => self
2068 .located_boolean(field.value(), "config external must be a boolean")
2069 .into_iter()
2070 .for_each(|value| config.set_external(value)),
2071 "name" => self
2072 .located_string(field.value(), "config custom name must be a scalar")
2073 .into_iter()
2074 .for_each(|value| config.set_custom_name(value)),
2075 _ => self.record_root_unmodeled(&path, field),
2076 }
2077 }
2078 Some(config)
2079 }
2080
2081 fn secret_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<SecretDefinition>> {
2082 let Some(entries) = self.mapping(value, "top-level secrets must be a mapping") else {
2083 return Vec::new();
2084 };
2085 entries
2086 .iter()
2087 .filter_map(|entry| {
2088 let definition = self.secret_definition(entry)?;
2089 Some(ProjectResource {
2090 name: ProjectKey::from_entry(entry),
2091 definition: ProjectValue::new(definition, entry.value()),
2092 })
2093 })
2094 .collect()
2095 }
2096
2097 fn secret_definition(&mut self, entry: &MergedEntry) -> Option<SecretDefinition> {
2098 let value = entry.value();
2099 let span = effective_span(value);
2100 let mut secret = SecretDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
2101 let fields = match value.kind() {
2102 MergedValueKind::Null(_) => return Some(secret),
2103 MergedValueKind::Mapping(fields) => fields,
2104 _ => {
2105 self.expected(value, "secret definition must be a mapping or null");
2106 return None;
2107 }
2108 };
2109 let path = ["secrets".to_owned(), entry.key().to_owned()];
2110 for field in fields {
2111 match field.key() {
2112 "file" => self
2113 .located_string(field.value(), "secret file must be a scalar")
2114 .into_iter()
2115 .for_each(|value| secret.set_file(value)),
2116 "environment" => self
2117 .located_string(field.value(), "secret environment must be a scalar")
2118 .into_iter()
2119 .for_each(|value| secret.set_environment(value)),
2120 "external" => self
2121 .located_boolean(field.value(), "secret external must be a boolean")
2122 .into_iter()
2123 .for_each(|value| secret.set_external(value)),
2124 "name" => self
2125 .located_string(field.value(), "secret custom name must be a scalar")
2126 .into_iter()
2127 .for_each(|value| secret.set_custom_name(value)),
2128 _ => self.record_root_unmodeled(&path, field),
2129 }
2130 }
2131 Some(secret)
2132 }
2133
2134 fn labels(&mut self, value: &MergedValue) -> Option<Labels> {
2135 let span = effective_span(value);
2136 match value.kind() {
2137 MergedValueKind::Sequence(_) => self
2138 .located_string_sequence(value, "labels must be a scalar sequence")
2139 .map(|values| Labels::List { span, values }),
2140 MergedValueKind::Mapping(_) => self
2141 .key_value_mapping(value, "labels must be a scalar mapping")
2142 .map(|entries| Labels::Map { span, entries }),
2143 _ => {
2144 self.expected(value, "labels must be a sequence or mapping");
2145 None
2146 }
2147 }
2148 }
2149}
2150
2151fn field_reference(path: &[String], entry: &MergedEntry) -> ProjectFieldReference {
2152 let mut complete_path = path.to_vec();
2153 complete_path.push(entry.key().to_owned());
2154 ProjectFieldReference {
2155 path: complete_path,
2156 key: ProjectKey::from_entry(entry),
2157 provenance: entry.value().provenance().clone(),
2158 extension: entry.key().starts_with("x-"),
2159 sensitive: entry.value().is_sensitive(),
2160 }
2161}
2162
2163fn effective_span(value: &MergedValue) -> SourceSpan {
2164 value
2165 .provenance()
2166 .effective_source()
2167 .or_else(|| value.provenance().sources().first().copied())
2168 .unwrap_or_else(|| SourceSpan::from_valid_offsets(SourceId::new(0), 0, 0))
2169}
2170
2171fn entry_span(entry: &MergedEntry) -> SourceSpan {
2172 entry
2173 .key_sources()
2174 .last()
2175 .copied()
2176 .or_else(|| entry.key_sources().first().copied())
2177 .unwrap_or_else(|| effective_span(entry.value()))
2178}