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