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