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, HealthcheckDuration, HealthcheckRetries,
9 HealthcheckTest, HealthcheckTestKind, HostAddress, ImageReference, Ipam, IpamConfig, KeyValueEntry, Labels,
10 Located, LongPort, LongVolumeMount, MountType, NetworkDefinition, Port, SecretDefinition, SelinuxRelabel,
11 ServiceNetwork, ServiceNetworks, ShortExtraHost, ShortPort, ShortVolumeMount, VolumeDefinition, VolumeMount,
12};
13use crate::profiles::ProfileSelection;
14use crate::resolution::{SELECTION_PROJECT_MISMATCH, service_in_scope};
15use crate::source::{SourceId, SourceSpan};
16use std::fmt;
17use std::path::{Path, PathBuf};
18
19pub const PROJECT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.project.expected-form");
21
22pub const PROJECT_MISSING_FIELD: DiagnosticCode = DiagnosticCode::new("compose.project.missing-field");
24
25pub const PROJECT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.project.invalid-value");
27
28#[derive(Clone, PartialEq, Eq)]
30pub struct ProjectValue<T> {
31 value: T,
32 provenance: MergeProvenance,
33 sensitive: bool,
34}
35
36impl<T: fmt::Debug> fmt::Debug for ProjectValue<T> {
37 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38 let mut debug = formatter.debug_struct("ProjectValue");
39 if self.sensitive {
40 debug.field("value", &"<redacted>");
41 } else {
42 debug.field("value", &self.value);
43 }
44 debug
45 .field("provenance", &self.provenance)
46 .field("sensitive", &self.sensitive)
47 .finish()
48 }
49}
50
51impl<T> ProjectValue<T> {
52 fn new(value: T, source: &MergedValue) -> Self {
53 Self {
54 value,
55 provenance: source.provenance().clone(),
56 sensitive: source.is_sensitive(),
57 }
58 }
59
60 #[must_use]
62 pub const fn value(&self) -> &T {
63 &self.value
64 }
65
66 #[must_use]
68 pub const fn provenance(&self) -> &MergeProvenance {
69 &self.provenance
70 }
71
72 #[must_use]
74 pub fn effective_source(&self) -> Option<SourceSpan> {
75 self.provenance.effective_source()
76 }
77
78 #[must_use]
80 pub const fn is_sensitive(&self) -> bool {
81 self.sensitive
82 }
83
84 #[must_use]
86 pub fn into_value(self) -> T {
87 self.value
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct ProjectKey {
94 value: String,
95 sources: Vec<SourceSpan>,
96}
97
98impl ProjectKey {
99 fn from_entry(entry: &MergedEntry) -> Self {
100 Self {
101 value: entry.key().to_owned(),
102 sources: entry.key_sources().to_vec(),
103 }
104 }
105
106 #[must_use]
108 pub fn value(&self) -> &str {
109 &self.value
110 }
111
112 #[must_use]
114 pub fn sources(&self) -> &[SourceSpan] {
115 &self.sources
116 }
117
118 #[must_use]
120 pub fn effective_source(&self) -> Option<SourceSpan> {
121 self.sources.last().copied()
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct ProjectFieldReference {
128 path: Vec<String>,
129 key: ProjectKey,
130 provenance: MergeProvenance,
131 extension: bool,
132 sensitive: bool,
133}
134
135impl ProjectFieldReference {
136 #[must_use]
138 pub fn path(&self) -> &[String] {
139 &self.path
140 }
141
142 #[must_use]
144 pub const fn key(&self) -> &ProjectKey {
145 &self.key
146 }
147
148 #[must_use]
150 pub const fn provenance(&self) -> &MergeProvenance {
151 &self.provenance
152 }
153
154 #[must_use]
156 pub const fn is_extension(&self) -> bool {
157 self.extension
158 }
159
160 #[must_use]
162 pub const fn is_sensitive(&self) -> bool {
163 self.sensitive
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct ProjectEnvironmentEntry {
170 name: ProjectKey,
171 value: ProjectValue<ComposeScalar>,
172 syntax: EntrySyntax,
173}
174
175impl ProjectEnvironmentEntry {
176 #[must_use]
178 pub const fn name(&self) -> &ProjectKey {
179 &self.name
180 }
181
182 #[must_use]
184 pub const fn value(&self) -> &ProjectValue<ComposeScalar> {
185 &self.value
186 }
187
188 #[must_use]
190 pub const fn syntax(&self) -> EntrySyntax {
191 self.syntax
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct ProjectEnvironment {
198 entries: Vec<ProjectEnvironmentEntry>,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct ProjectExtraHost {
204 hostname: ProjectKey,
205 address: ProjectValue<HostAddress>,
206 syntax: EntrySyntax,
207}
208
209impl ProjectExtraHost {
210 #[must_use]
212 pub const fn hostname(&self) -> &ProjectKey {
213 &self.hostname
214 }
215
216 #[must_use]
218 pub const fn address(&self) -> &ProjectValue<HostAddress> {
219 &self.address
220 }
221
222 #[must_use]
224 pub const fn syntax(&self) -> EntrySyntax {
225 self.syntax
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct ProjectExtraHosts {
232 entries: Vec<ProjectExtraHost>,
233}
234
235impl ProjectExtraHosts {
236 #[must_use]
238 pub fn entries(&self) -> &[ProjectExtraHost] {
239 &self.entries
240 }
241}
242
243impl ProjectEnvironment {
244 #[must_use]
246 pub fn entries(&self) -> &[ProjectEnvironmentEntry] {
247 &self.entries
248 }
249
250 #[must_use]
252 pub fn get(&self, name: &str) -> Option<&ProjectEnvironmentEntry> {
253 self.entries.iter().find(|entry| entry.name.value == name)
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct ProjectHealthcheck {
260 test: Option<ProjectValue<HealthcheckTest>>,
261 interval: Option<ProjectValue<HealthcheckDuration>>,
262 timeout: Option<ProjectValue<HealthcheckDuration>>,
263 retries: Option<ProjectValue<HealthcheckRetries>>,
264 start_period: Option<ProjectValue<HealthcheckDuration>>,
265 start_interval: Option<ProjectValue<HealthcheckDuration>>,
266 disable: Option<ProjectValue<BooleanValue>>,
267 unmodeled_fields: Vec<ProjectFieldReference>,
268}
269
270impl ProjectHealthcheck {
271 #[must_use]
273 pub const fn test(&self) -> Option<&ProjectValue<HealthcheckTest>> {
274 self.test.as_ref()
275 }
276
277 #[must_use]
279 pub const fn interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
280 self.interval.as_ref()
281 }
282
283 #[must_use]
285 pub const fn timeout(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
286 self.timeout.as_ref()
287 }
288
289 #[must_use]
291 pub const fn retries(&self) -> Option<&ProjectValue<HealthcheckRetries>> {
292 self.retries.as_ref()
293 }
294
295 #[must_use]
297 pub const fn start_period(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
298 self.start_period.as_ref()
299 }
300
301 #[must_use]
303 pub const fn start_interval(&self) -> Option<&ProjectValue<HealthcheckDuration>> {
304 self.start_interval.as_ref()
305 }
306
307 #[must_use]
309 pub const fn disable(&self) -> Option<&ProjectValue<BooleanValue>> {
310 self.disable.as_ref()
311 }
312
313 #[must_use]
315 pub fn is_disabled(&self) -> bool {
316 matches!(
317 self.disable.as_ref().map(ProjectValue::value),
318 Some(BooleanValue::Literal(true))
319 ) || matches!(
320 self.test.as_ref().and_then(|test| test.value().kind()),
321 Some(HealthcheckTestKind::None)
322 )
323 }
324
325 #[must_use]
327 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
328 &self.unmodeled_fields
329 }
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct ProjectService {
335 name: ProjectKey,
336 provenance: MergeProvenance,
337 image: Option<ProjectValue<ImageReference>>,
338 command: Option<ProjectValue<Command>>,
339 environment: Option<ProjectValue<ProjectEnvironment>>,
340 extra_hosts: Option<ProjectValue<ProjectExtraHosts>>,
341 healthcheck: Option<ProjectValue<ProjectHealthcheck>>,
342 ports: Option<ProjectValue<Vec<ProjectValue<Port>>>>,
343 volumes: Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>>,
344 networks: Option<ProjectValue<ServiceNetworks>>,
345 profiles: Option<ProjectValue<Vec<ProjectValue<String>>>>,
346 unmodeled_fields: Vec<ProjectFieldReference>,
347}
348
349impl ProjectService {
350 #[must_use]
352 pub const fn name(&self) -> &ProjectKey {
353 &self.name
354 }
355
356 #[must_use]
358 pub const fn provenance(&self) -> &MergeProvenance {
359 &self.provenance
360 }
361
362 #[must_use]
364 pub const fn image(&self) -> Option<&ProjectValue<ImageReference>> {
365 self.image.as_ref()
366 }
367
368 #[must_use]
370 pub const fn command(&self) -> Option<&ProjectValue<Command>> {
371 self.command.as_ref()
372 }
373
374 #[must_use]
376 pub const fn environment(&self) -> Option<&ProjectValue<ProjectEnvironment>> {
377 self.environment.as_ref()
378 }
379
380 #[must_use]
382 pub const fn extra_hosts(&self) -> Option<&ProjectValue<ProjectExtraHosts>> {
383 self.extra_hosts.as_ref()
384 }
385
386 #[must_use]
388 pub const fn healthcheck(&self) -> Option<&ProjectValue<ProjectHealthcheck>> {
389 self.healthcheck.as_ref()
390 }
391
392 #[must_use]
394 pub const fn ports(&self) -> Option<&ProjectValue<Vec<ProjectValue<Port>>>> {
395 self.ports.as_ref()
396 }
397
398 #[must_use]
400 pub const fn volumes(&self) -> Option<&ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
401 self.volumes.as_ref()
402 }
403
404 #[must_use]
406 pub const fn networks(&self) -> Option<&ProjectValue<ServiceNetworks>> {
407 self.networks.as_ref()
408 }
409
410 #[must_use]
412 pub const fn profiles(&self) -> Option<&ProjectValue<Vec<ProjectValue<String>>>> {
413 self.profiles.as_ref()
414 }
415
416 #[must_use]
418 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
419 &self.unmodeled_fields
420 }
421}
422
423#[derive(Debug, Clone, PartialEq, Eq)]
425pub struct ProjectResource<T> {
426 name: ProjectKey,
427 definition: ProjectValue<T>,
428}
429
430impl<T> ProjectResource<T> {
431 #[must_use]
433 pub const fn name(&self) -> &ProjectKey {
434 &self.name
435 }
436
437 #[must_use]
439 pub const fn definition(&self) -> &ProjectValue<T> {
440 &self.definition
441 }
442}
443
444#[derive(Debug, Clone, PartialEq, Eq)]
446pub struct ProjectView {
447 source_ids: Vec<SourceId>,
448 base_directory: PathBuf,
449 provenance: MergeProvenance,
450 name: Option<ProjectValue<String>>,
451 services: Vec<ProjectService>,
452 networks: Vec<ProjectResource<NetworkDefinition>>,
453 volumes: Vec<ProjectResource<VolumeDefinition>>,
454 configs: Vec<ProjectResource<ConfigDefinition>>,
455 secrets: Vec<ProjectResource<SecretDefinition>>,
456 unmodeled_fields: Vec<ProjectFieldReference>,
457}
458
459impl ProjectView {
460 #[must_use]
462 pub fn source_ids(&self) -> &[SourceId] {
463 &self.source_ids
464 }
465
466 #[must_use]
468 pub fn base_directory(&self) -> &Path {
469 &self.base_directory
470 }
471
472 #[must_use]
474 pub const fn provenance(&self) -> &MergeProvenance {
475 &self.provenance
476 }
477
478 #[must_use]
480 pub const fn name(&self) -> Option<&ProjectValue<String>> {
481 self.name.as_ref()
482 }
483
484 #[must_use]
486 pub fn services(&self) -> &[ProjectService] {
487 &self.services
488 }
489
490 #[must_use]
492 pub fn service(&self, name: &str) -> Option<&ProjectService> {
493 self.services.iter().find(|service| service.name.value == name)
494 }
495
496 #[must_use]
498 pub fn networks(&self) -> &[ProjectResource<NetworkDefinition>] {
499 &self.networks
500 }
501
502 #[must_use]
504 pub fn volumes(&self) -> &[ProjectResource<VolumeDefinition>] {
505 &self.volumes
506 }
507
508 #[must_use]
510 pub fn configs(&self) -> &[ProjectResource<ConfigDefinition>] {
511 &self.configs
512 }
513
514 #[must_use]
516 pub fn secrets(&self) -> &[ProjectResource<SecretDefinition>] {
517 &self.secrets
518 }
519
520 #[must_use]
522 pub fn unmodeled_fields(&self) -> &[ProjectFieldReference] {
523 &self.unmodeled_fields
524 }
525}
526
527#[derive(Debug, Clone, PartialEq, Eq)]
529pub struct ProjectViewResult {
530 view: Option<ProjectView>,
531 diagnostics: Vec<Diagnostic>,
532}
533
534impl ProjectViewResult {
535 #[must_use]
537 pub const fn view(&self) -> Option<&ProjectView> {
538 self.view.as_ref()
539 }
540
541 #[must_use]
543 pub fn diagnostics(&self) -> &[Diagnostic] {
544 &self.diagnostics
545 }
546
547 #[must_use]
549 pub fn is_valid(&self) -> bool {
550 self.view.is_some()
551 && self
552 .diagnostics
553 .iter()
554 .all(|diagnostic| diagnostic.severity() != Severity::Error)
555 }
556
557 #[must_use]
559 pub fn into_parts(self) -> (Option<ProjectView>, Vec<Diagnostic>) {
560 (self.view, self.diagnostics)
561 }
562}
563
564#[must_use]
569pub fn build_project_view(project: &MergedProject, selection: Option<&ProfileSelection>) -> ProjectViewResult {
570 if selection.is_some_and(|selection| !selection.belongs_to(project)) {
571 return ProjectViewResult {
572 view: None,
573 diagnostics: vec![Diagnostic::new(
574 SELECTION_PROJECT_MISMATCH,
575 Severity::Error,
576 "profile selection does not belong to the merged project",
577 )],
578 };
579 }
580
581 Builder::new(project, selection).build()
582}
583
584struct Builder<'a> {
585 project: &'a MergedProject,
586 selection: Option<&'a ProfileSelection>,
587 diagnostics: Vec<Diagnostic>,
588 root_unmodeled: Vec<ProjectFieldReference>,
589 pending_unmodeled: Vec<ProjectFieldReference>,
590}
591
592impl<'a> Builder<'a> {
593 const fn new(project: &'a MergedProject, selection: Option<&'a ProfileSelection>) -> Self {
594 Self {
595 project,
596 selection,
597 diagnostics: Vec::new(),
598 root_unmodeled: Vec::new(),
599 pending_unmodeled: Vec::new(),
600 }
601 }
602
603 fn build(mut self) -> ProjectViewResult {
604 let root = self.project.root();
605 let entries = root.as_mapping().unwrap_or_default();
606 let mut name = None;
607 let mut services = Vec::new();
608 let mut networks = Vec::new();
609 let mut volumes = Vec::new();
610 let mut configs = Vec::new();
611 let mut secrets = Vec::new();
612
613 for entry in entries {
614 match entry.key() {
615 "name" => name = self.project_string(entry.value(), "project name"),
616 "services" => services = self.services(entry.value()),
617 "networks" => networks = self.network_definitions(entry.value()),
618 "volumes" => volumes = self.volume_definitions(entry.value()),
619 "configs" => configs = self.config_definitions(entry.value()),
620 "secrets" => secrets = self.secret_definitions(entry.value()),
621 _ => self.record_root_unmodeled(&[], entry),
622 }
623 }
624
625 ProjectViewResult {
626 view: Some(ProjectView {
627 source_ids: self.project.source_ids().to_vec(),
628 base_directory: self.project.base_directory().to_path_buf(),
629 provenance: root.provenance().clone(),
630 name,
631 services,
632 networks,
633 volumes,
634 configs,
635 secrets,
636 unmodeled_fields: self.root_unmodeled,
637 }),
638 diagnostics: self.diagnostics,
639 }
640 }
641
642 fn services(&mut self, value: &MergedValue) -> Vec<ProjectService> {
643 let Some(entries) = self.mapping(value, "services must be a mapping") else {
644 return Vec::new();
645 };
646 let selection = self.selection;
647 let mut services = Vec::new();
648 for entry in entries {
649 if service_in_scope(selection, entry.key()) {
650 services.extend(self.service(entry));
651 }
652 }
653 services
654 }
655
656 fn service(&mut self, entry: &MergedEntry) -> Option<ProjectService> {
657 let pending_start = self.pending_unmodeled.len();
658 let value = entry.value();
659 let fields = self.mapping(value, "service definition must be a mapping")?;
660 let mut service = ProjectService {
661 name: ProjectKey::from_entry(entry),
662 provenance: value.provenance().clone(),
663 image: None,
664 command: None,
665 environment: None,
666 extra_hosts: None,
667 healthcheck: None,
668 ports: None,
669 volumes: None,
670 networks: None,
671 profiles: None,
672 unmodeled_fields: Vec::new(),
673 };
674 let path = ["services".to_owned(), entry.key().to_owned()];
675
676 for field in fields {
677 match field.key() {
678 "image" => {
679 service.image = self
680 .project_string(field.value(), "service image")
681 .map(|value| ProjectValue {
682 value: ImageReference::parse(value.value),
683 provenance: value.provenance,
684 sensitive: value.sensitive,
685 });
686 }
687 "command" => service.command = self.command(field.value()),
688 "environment" => service.environment = self.environment(field.value()),
689 "extra_hosts" => service.extra_hosts = self.extra_hosts(field.value()),
690 "healthcheck" => service.healthcheck = self.healthcheck(field.value(), &path),
691 "ports" => service.ports = self.ports(field.value(), &path),
692 "volumes" => service.volumes = self.volumes(field.value(), &path),
693 "networks" => service.networks = self.service_networks(field.value(), &path),
694 "profiles" => service.profiles = self.string_collection(field.value(), "profiles must be a sequence"),
695 _ => service.unmodeled_fields.push(field_reference(&path, field)),
696 }
697 }
698 service
699 .unmodeled_fields
700 .extend(self.pending_unmodeled.drain(pending_start..));
701 Some(service)
702 }
703
704 fn command(&mut self, value: &MergedValue) -> Option<ProjectValue<Command>> {
705 let span = effective_span(value);
706 let command = match value.kind() {
707 MergedValueKind::Null(_) => Command::Null(span),
708 MergedValueKind::Scalar(scalar) => Command::String(Located::new(scalar.value().to_owned(), span)),
709 MergedValueKind::Sequence(values) => {
710 let mut arguments = Vec::new();
711 for value in values {
712 arguments.push(self.located_string(value, "command list item must be a scalar")?);
713 }
714 Command::List {
715 span,
716 values: arguments,
717 }
718 }
719 _ => {
720 self.expected(value, "command must be null, a scalar, or a sequence");
721 return None;
722 }
723 };
724 Some(ProjectValue::new(command, value))
725 }
726
727 fn environment(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectEnvironment>> {
728 let mut entries = Vec::new();
729 match value.kind() {
730 MergedValueKind::Mapping(values) => {
731 for entry in values {
732 let scalar = self.compose_scalar(entry.value(), "environment value must be a scalar or null")?;
733 entries.push(ProjectEnvironmentEntry {
734 name: ProjectKey::from_entry(entry),
735 value: ProjectValue::new(scalar, entry.value()),
736 syntax: entry.syntax(),
737 });
738 }
739 }
740 MergedValueKind::Sequence(values) => {
741 for item in values {
742 let raw = self.located_string(item, "environment list item must be a scalar")?;
743 let (name, scalar, syntax) = raw.value().split_once('=').map_or_else(
744 || (raw.value().clone(), ComposeScalar::Null, EntrySyntax::ListKeyOnly),
745 |(name, value)| {
746 (
747 name.to_owned(),
748 ComposeScalar::String(value.to_owned()),
749 EntrySyntax::ListKeyValue,
750 )
751 },
752 );
753 entries.push(ProjectEnvironmentEntry {
754 name: ProjectKey {
755 value: name,
756 sources: item.provenance().sources().to_vec(),
757 },
758 value: ProjectValue::new(scalar, item),
759 syntax,
760 });
761 }
762 }
763 _ => {
764 self.expected(value, "environment must be a mapping or sequence");
765 return None;
766 }
767 }
768 Some(ProjectValue::new(ProjectEnvironment { entries }, value))
769 }
770
771 fn healthcheck(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<ProjectValue<ProjectHealthcheck>> {
772 let fields = self.mapping(value, "healthcheck must be a mapping")?;
773 let mut healthcheck = ProjectHealthcheck {
774 test: None,
775 interval: None,
776 timeout: None,
777 retries: None,
778 start_period: None,
779 start_interval: None,
780 disable: None,
781 unmodeled_fields: Vec::new(),
782 };
783 let mut path = parent_path.to_vec();
784 path.push("healthcheck".to_owned());
785 for field in fields {
786 match field.key() {
787 "test" => healthcheck.test = self.healthcheck_test(field.value()),
788 "interval" => {
789 healthcheck.interval =
790 self.healthcheck_duration(field.value(), "healthcheck interval must be a scalar");
791 }
792 "timeout" => {
793 healthcheck.timeout =
794 self.healthcheck_duration(field.value(), "healthcheck timeout must be a scalar");
795 }
796 "retries" => healthcheck.retries = self.healthcheck_retries(field.value()),
797 "start_period" => {
798 healthcheck.start_period =
799 self.healthcheck_duration(field.value(), "healthcheck start_period must be a scalar");
800 }
801 "start_interval" => {
802 healthcheck.start_interval =
803 self.healthcheck_duration(field.value(), "healthcheck start_interval must be a scalar");
804 }
805 "disable" => {
806 healthcheck.disable = self
807 .located_boolean(field.value(), "healthcheck disable must be a boolean")
808 .map(|value| ProjectValue::new(value.into_value(), field.value()));
809 }
810 _ => healthcheck.unmodeled_fields.push(field_reference(&path, field)),
811 }
812 }
813 Some(ProjectValue::new(healthcheck, value))
814 }
815
816 fn healthcheck_test(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckTest>> {
817 let span = effective_span(value);
818 let test = match value.kind() {
819 MergedValueKind::Scalar(scalar) => HealthcheckTest::String(Located::new(scalar.value().to_owned(), span)),
820 MergedValueKind::Sequence(values) => {
821 let mut items = Vec::new();
822 for value in values {
823 items.push(self.located_string(value, "healthcheck test item must be a scalar")?);
824 }
825 let kind = items.first().map(|item| HealthcheckTestKind::parse(item.value()));
826 HealthcheckTest::List {
827 span,
828 kind,
829 values: items,
830 }
831 }
832 _ => {
833 self.expected(value, "healthcheck test must be a scalar or sequence");
834 return None;
835 }
836 };
837 Some(ProjectValue::new(test, value))
838 }
839
840 fn healthcheck_duration(
841 &mut self,
842 value: &MergedValue,
843 message: &str,
844 ) -> Option<ProjectValue<HealthcheckDuration>> {
845 let scalar = self.scalar(value, message)?;
846 Some(ProjectValue::new(
847 HealthcheckDuration::parse(scalar.value().to_owned()),
848 value,
849 ))
850 }
851
852 fn healthcheck_retries(&mut self, value: &MergedValue) -> Option<ProjectValue<HealthcheckRetries>> {
853 let scalar = self.scalar(value, "healthcheck retries must be a scalar")?;
854 Some(ProjectValue::new(
855 HealthcheckRetries::parse(scalar.value().to_owned()),
856 value,
857 ))
858 }
859
860 fn extra_hosts(&mut self, value: &MergedValue) -> Option<ProjectValue<ProjectExtraHosts>> {
861 let mut entries = Vec::new();
862 match value.kind() {
863 MergedValueKind::Mapping(values) => {
864 for entry in values {
865 let scalar = self.scalar(entry.value(), "extra_hosts address must be a scalar")?;
866 entries.push(ProjectExtraHost {
867 hostname: ProjectKey::from_entry(entry),
868 address: ProjectValue::new(HostAddress::parse(scalar.value().to_owned()), entry.value()),
869 syntax: EntrySyntax::Mapping,
870 });
871 }
872 }
873 MergedValueKind::Sequence(values) => {
874 for item in values {
875 let raw = self.located_string(item, "extra_hosts list item must be a scalar")?;
876 let parsed = ShortExtraHost::parse(raw);
877 let (Some(hostname), Some(address)) = (parsed.hostname(), parsed.address()) else {
878 self.invalid(
879 effective_span(item),
880 "extra_hosts entry must contain a hostname and address",
881 );
882 continue;
883 };
884 entries.push(ProjectExtraHost {
885 hostname: ProjectKey {
886 value: hostname.to_owned(),
887 sources: item.provenance().sources().to_vec(),
888 },
889 address: ProjectValue::new(address.clone(), item),
890 syntax: EntrySyntax::ListKeyValue,
891 });
892 }
893 }
894 _ => {
895 self.expected(value, "extra_hosts must be a mapping or sequence");
896 return None;
897 }
898 }
899 Some(ProjectValue::new(ProjectExtraHosts { entries }, value))
900 }
901
902 fn project_string(&mut self, value: &MergedValue, description: &str) -> Option<ProjectValue<String>> {
903 let scalar = self.scalar(value, &format!("{description} must be a non-null scalar"))?;
904 Some(ProjectValue::new(scalar.value().to_owned(), value))
905 }
906
907 fn string_collection(
908 &mut self,
909 value: &MergedValue,
910 message: &str,
911 ) -> Option<ProjectValue<Vec<ProjectValue<String>>>> {
912 let Some(values) = value.as_sequence() else {
913 self.expected(value, message);
914 return None;
915 };
916 let mut strings = Vec::new();
917 for value in values {
918 let scalar = self.scalar(value, "sequence item must be a non-null scalar")?;
919 strings.push(ProjectValue::new(scalar.value().to_owned(), value));
920 }
921 Some(ProjectValue::new(strings, value))
922 }
923
924 fn scalar<'value>(
925 &mut self,
926 value: &'value MergedValue,
927 message: &str,
928 ) -> Option<&'value crate::merge::MergedScalar> {
929 let Some(scalar) = value.as_scalar() else {
930 self.expected(value, message);
931 return None;
932 };
933 Some(scalar)
934 }
935
936 fn located_string(&mut self, value: &MergedValue, message: &str) -> Option<Located<String>> {
937 let scalar = self.scalar(value, message)?;
938 Some(Located::new(scalar.value().to_owned(), effective_span(value)))
939 }
940
941 fn compose_scalar(&mut self, value: &MergedValue, message: &str) -> Option<ComposeScalar> {
942 match value.kind() {
943 MergedValueKind::Null(_) => Some(ComposeScalar::Null),
944 MergedValueKind::Scalar(scalar) => Some(match scalar.kind() {
945 MergedScalarKind::String => ComposeScalar::String(scalar.value().to_owned()),
946 MergedScalarKind::Boolean => ComposeScalar::Boolean(scalar.value().eq_ignore_ascii_case("true")),
947 MergedScalarKind::Number => ComposeScalar::Number(scalar.value().to_owned()),
948 }),
949 _ => {
950 self.expected(value, message);
951 None
952 }
953 }
954 }
955
956 fn mapping<'value>(&mut self, value: &'value MergedValue, message: &str) -> Option<&'value [MergedEntry]> {
957 let Some(entries) = value.as_mapping() else {
958 self.expected(value, message);
959 return None;
960 };
961 Some(entries)
962 }
963
964 fn expected(&mut self, value: &MergedValue, message: &str) {
965 self.diagnostics.push(
966 Diagnostic::new(PROJECT_EXPECTED_FORM, Severity::Error, message).with_label(DiagnosticLabel::primary(
967 effective_span(value),
968 "unexpected merged value form",
969 )),
970 );
971 }
972
973 fn missing(&mut self, value: &MergedValue, message: &str) {
974 self.diagnostics.push(
975 Diagnostic::new(PROJECT_MISSING_FIELD, Severity::Error, message).with_label(DiagnosticLabel::primary(
976 effective_span(value),
977 "required field is missing",
978 )),
979 );
980 }
981
982 fn invalid(&mut self, span: SourceSpan, message: &str) {
983 self.diagnostics.push(
984 Diagnostic::new(PROJECT_INVALID_VALUE, Severity::Error, message)
985 .with_label(DiagnosticLabel::primary(span, "invalid native value")),
986 );
987 }
988
989 fn record_root_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
990 self.root_unmodeled.push(field_reference(path, entry));
991 }
992
993 fn record_pending_unmodeled(&mut self, path: &[String], entry: &MergedEntry) {
994 self.pending_unmodeled.push(field_reference(path, entry));
995 }
996}
997
998impl Builder<'_> {
999 fn ports(&mut self, value: &MergedValue, service_path: &[String]) -> Option<ProjectValue<Vec<ProjectValue<Port>>>> {
1000 let Some(values) = value.as_sequence() else {
1001 self.expected(value, "service ports must be a sequence");
1002 return None;
1003 };
1004 let mut ports = Vec::new();
1005 for (index, item) in values.iter().enumerate() {
1006 let mut path = service_path.to_vec();
1007 path.push("ports".to_owned());
1008 path.push(index.to_string());
1009 let port = match item.kind() {
1010 MergedValueKind::Scalar(scalar) => Port::Short(ShortPort::parse(Located::new(
1011 scalar.value().to_owned(),
1012 effective_span(item),
1013 ))),
1014 MergedValueKind::Mapping(fields) => Port::Long(Box::new(self.long_port(item, fields, &path))),
1015 _ => {
1016 self.expected(item, "service port must use scalar short syntax or mapping long syntax");
1017 continue;
1018 }
1019 };
1020 ports.push(ProjectValue::new(port, item));
1021 }
1022 Some(ProjectValue::new(ports, value))
1023 }
1024
1025 fn long_port(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongPort {
1026 let mut port = LongPort::new(effective_span(value));
1027 let mut has_target = false;
1028 for field in fields {
1029 match field.key() {
1030 "target" => {
1031 if let Some(value) = self.located_string(field.value(), "port target must be a scalar") {
1032 port.set_target(value);
1033 has_target = true;
1034 }
1035 }
1036 "published" => self
1037 .located_string(field.value(), "published port must be a scalar")
1038 .into_iter()
1039 .for_each(|value| port.set_published(value)),
1040 "host_ip" => self
1041 .located_string(field.value(), "port host_ip must be a scalar")
1042 .into_iter()
1043 .for_each(|value| port.set_host_ip(value)),
1044 "protocol" => self
1045 .located_string(field.value(), "port protocol must be a scalar")
1046 .into_iter()
1047 .for_each(|value| port.set_protocol(value)),
1048 "app_protocol" => self
1049 .located_string(field.value(), "port app_protocol must be a scalar")
1050 .into_iter()
1051 .for_each(|value| port.set_app_protocol(value)),
1052 "mode" => self
1053 .located_string(field.value(), "port mode must be a scalar")
1054 .into_iter()
1055 .for_each(|value| port.set_mode(value)),
1056 "name" => self
1057 .located_string(field.value(), "port name must be a scalar")
1058 .into_iter()
1059 .for_each(|value| port.set_name(value)),
1060 _ => self.record_pending_unmodeled(path, field),
1061 }
1062 }
1063 if !has_target {
1064 self.missing(value, "long-syntax port is missing `target`");
1065 }
1066 port
1067 }
1068
1069 fn volumes(
1070 &mut self,
1071 value: &MergedValue,
1072 service_path: &[String],
1073 ) -> Option<ProjectValue<Vec<ProjectValue<VolumeMount>>>> {
1074 let Some(values) = value.as_sequence() else {
1075 self.expected(value, "service volumes must be a sequence");
1076 return None;
1077 };
1078 let mut mounts = Vec::new();
1079 for (index, item) in values.iter().enumerate() {
1080 let mut path = service_path.to_vec();
1081 path.push("volumes".to_owned());
1082 path.push(index.to_string());
1083 let mount = match item.kind() {
1084 MergedValueKind::Scalar(scalar) => VolumeMount::Short(ShortVolumeMount::new(Located::new(
1085 scalar.value().to_owned(),
1086 effective_span(item),
1087 ))),
1088 MergedValueKind::Mapping(fields) => VolumeMount::Long(Box::new(self.long_volume(item, fields, &path))),
1089 _ => {
1090 self.expected(
1091 item,
1092 "service volume must use scalar short syntax or mapping long syntax",
1093 );
1094 continue;
1095 }
1096 };
1097 mounts.push(ProjectValue::new(mount, item));
1098 }
1099 Some(ProjectValue::new(mounts, value))
1100 }
1101
1102 fn long_volume(&mut self, value: &MergedValue, fields: &[MergedEntry], path: &[String]) -> LongVolumeMount {
1103 let mut mount = LongVolumeMount::new(effective_span(value));
1104 let mut has_type = false;
1105 let mut has_target = false;
1106 for field in fields {
1107 match field.key() {
1108 "type" => {
1109 if let Some(value) = self.located_string(field.value(), "volume type must be a scalar") {
1110 mount.set_mount_type(Located::new(MountType::from_text(value.value().clone()), value.span()));
1111 has_type = true;
1112 }
1113 }
1114 "source" => self
1115 .located_string(field.value(), "volume source must be a scalar")
1116 .into_iter()
1117 .for_each(|value| mount.set_source(value)),
1118 "target" => {
1119 if let Some(value) = self.located_string(field.value(), "volume target must be a scalar") {
1120 mount.set_target(value);
1121 has_target = true;
1122 }
1123 }
1124 "read_only" => self
1125 .located_boolean(field.value(), "volume read_only must be a boolean")
1126 .into_iter()
1127 .for_each(|value| mount.set_read_only(value)),
1128 "bind" => self
1129 .bind_options(field.value(), path)
1130 .into_iter()
1131 .for_each(|value| mount.set_bind(value)),
1132 _ => self.record_pending_unmodeled(path, field),
1133 }
1134 }
1135 if !has_type {
1136 self.missing(value, "long-syntax volume is missing `type`");
1137 }
1138 if !has_target {
1139 self.missing(value, "long-syntax volume is missing `target`");
1140 }
1141 mount
1142 }
1143
1144 fn bind_options(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<BindOptions> {
1145 let fields = self.mapping(value, "volume bind options must be a mapping")?;
1146 let mut bind = BindOptions::new(effective_span(value));
1147 let mut path = parent_path.to_vec();
1148 path.push("bind".to_owned());
1149 for field in fields {
1150 match field.key() {
1151 "propagation" => self
1152 .located_string(field.value(), "bind propagation must be a scalar")
1153 .into_iter()
1154 .for_each(|value| bind.set_propagation(value)),
1155 "create_host_path" => self
1156 .located_boolean(field.value(), "bind create_host_path must be a boolean")
1157 .into_iter()
1158 .for_each(|value| bind.set_create_host_path(value)),
1159 "selinux" => {
1160 if let Some(value) = self.located_string(field.value(), "bind SELinux mode must be a scalar") {
1161 let mode = match value.value().as_str() {
1162 "z" => Some(SelinuxRelabel::Shared),
1163 "Z" => Some(SelinuxRelabel::Private),
1164 _ => None,
1165 };
1166 if let Some(mode) = mode {
1167 bind.set_selinux(Located::new(mode, value.span()));
1168 } else {
1169 self.invalid(value.span(), "bind SELinux mode must be `z` or `Z`");
1170 }
1171 }
1172 }
1173 _ => self.record_pending_unmodeled(&path, field),
1174 }
1175 }
1176 Some(bind)
1177 }
1178
1179 fn service_networks(
1180 &mut self,
1181 value: &MergedValue,
1182 service_path: &[String],
1183 ) -> Option<ProjectValue<ServiceNetworks>> {
1184 let span = effective_span(value);
1185 let networks = match value.kind() {
1186 MergedValueKind::Sequence(values) => {
1187 let mut names = Vec::new();
1188 for value in values {
1189 names.push(self.located_string(value, "service network name must be a scalar")?);
1190 }
1191 ServiceNetworks::Short { span, names }
1192 }
1193 MergedValueKind::Mapping(entries) => {
1194 let mut networks = Vec::new();
1195 for entry in entries {
1196 let mut path = service_path.to_vec();
1197 path.push("networks".to_owned());
1198 path.push(entry.key().to_owned());
1199 networks.push(self.service_network(entry, &path)?);
1200 }
1201 ServiceNetworks::Long { span, networks }
1202 }
1203 _ => {
1204 self.expected(value, "service networks must be a sequence or mapping");
1205 return None;
1206 }
1207 };
1208 Some(ProjectValue::new(networks, value))
1209 }
1210
1211 fn service_network(&mut self, entry: &MergedEntry, path: &[String]) -> Option<ServiceNetwork> {
1212 let value = entry.value();
1213 let span = effective_span(value);
1214 let mut network = ServiceNetwork::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1215 let fields = match value.kind() {
1216 MergedValueKind::Null(_) => return Some(network),
1217 MergedValueKind::Mapping(fields) => fields,
1218 _ => {
1219 self.expected(value, "service network attachment must be a mapping or null");
1220 return None;
1221 }
1222 };
1223 for field in fields {
1224 match field.key() {
1225 "aliases" => self
1226 .located_string_sequence(field.value(), "network aliases must be a sequence")
1227 .into_iter()
1228 .for_each(|value| network.set_aliases(value)),
1229 "interface_name" => self
1230 .located_string(field.value(), "network interface_name must be a scalar")
1231 .into_iter()
1232 .for_each(|value| network.set_interface_name(value)),
1233 "ipv4_address" => self
1234 .located_string(field.value(), "network ipv4_address must be a scalar")
1235 .into_iter()
1236 .for_each(|value| network.set_ipv4_address(value)),
1237 "ipv6_address" => self
1238 .located_string(field.value(), "network ipv6_address must be a scalar")
1239 .into_iter()
1240 .for_each(|value| network.set_ipv6_address(value)),
1241 "link_local_ips" => self
1242 .located_string_sequence(field.value(), "link_local_ips must be a sequence")
1243 .into_iter()
1244 .for_each(|value| network.set_link_local_ips(value)),
1245 "mac_address" => self
1246 .located_string(field.value(), "network mac_address must be a scalar")
1247 .into_iter()
1248 .for_each(|value| network.set_mac_address(value)),
1249 "driver_opts" => self
1250 .key_value_mapping(field.value(), "network driver_opts must be a mapping")
1251 .into_iter()
1252 .for_each(|value| network.set_driver_opts(value)),
1253 "gw_priority" => self
1254 .located_string(field.value(), "network gw_priority must be a scalar")
1255 .into_iter()
1256 .for_each(|value| network.set_gw_priority(value)),
1257 "priority" => self
1258 .located_string(field.value(), "network priority must be a scalar")
1259 .into_iter()
1260 .for_each(|value| network.set_priority(value)),
1261 _ => self.record_pending_unmodeled(path, field),
1262 }
1263 }
1264 Some(network)
1265 }
1266
1267 fn located_boolean(&mut self, value: &MergedValue, message: &str) -> Option<Located<BooleanValue>> {
1268 let scalar = self.scalar(value, message)?;
1269 let boolean = if scalar.kind() == MergedScalarKind::Boolean {
1270 BooleanValue::Literal(scalar.value().eq_ignore_ascii_case("true"))
1271 } else if scalar.value().contains('$') {
1272 BooleanValue::Expression(scalar.value().to_owned())
1273 } else {
1274 self.invalid(effective_span(value), message);
1275 return None;
1276 };
1277 Some(Located::new(boolean, effective_span(value)))
1278 }
1279
1280 fn located_string_sequence(&mut self, value: &MergedValue, message: &str) -> Option<Vec<Located<String>>> {
1281 let Some(values) = value.as_sequence() else {
1282 self.expected(value, message);
1283 return None;
1284 };
1285 let mut strings = Vec::new();
1286 for value in values {
1287 strings.push(self.located_string(value, "sequence item must be a scalar")?);
1288 }
1289 Some(strings)
1290 }
1291
1292 fn key_value_mapping(&mut self, value: &MergedValue, message: &str) -> Option<Vec<KeyValueEntry>> {
1293 let Some(entries) = value.as_mapping() else {
1294 self.expected(value, message);
1295 return None;
1296 };
1297 let mut values = Vec::new();
1298 for entry in entries {
1299 let scalar = self.compose_scalar(entry.value(), "mapping value must be a scalar or null")?;
1300 let value_span = effective_span(entry.value());
1301 values.push(KeyValueEntry::new(
1302 Located::new(entry.key().to_owned(), entry_span(entry)),
1303 Located::new(scalar, value_span),
1304 value_span,
1305 ));
1306 }
1307 Some(values)
1308 }
1309
1310 fn network_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<NetworkDefinition>> {
1311 let Some(entries) = self.mapping(value, "top-level networks must be a mapping") else {
1312 return Vec::new();
1313 };
1314 entries
1315 .iter()
1316 .filter_map(|entry| {
1317 let definition = self.network_definition(entry)?;
1318 Some(ProjectResource {
1319 name: ProjectKey::from_entry(entry),
1320 definition: ProjectValue::new(definition, entry.value()),
1321 })
1322 })
1323 .collect()
1324 }
1325
1326 fn network_definition(&mut self, entry: &MergedEntry) -> Option<NetworkDefinition> {
1327 let value = entry.value();
1328 let span = effective_span(value);
1329 let mut network = NetworkDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1330 let fields = match value.kind() {
1331 MergedValueKind::Null(_) => return Some(network),
1332 MergedValueKind::Mapping(fields) => fields,
1333 _ => {
1334 self.expected(value, "network definition must be a mapping or null");
1335 return None;
1336 }
1337 };
1338 let path = ["networks".to_owned(), entry.key().to_owned()];
1339 for field in fields {
1340 match field.key() {
1341 "driver" => self
1342 .located_string(field.value(), "network driver must be a scalar")
1343 .into_iter()
1344 .for_each(|value| network.set_driver(value)),
1345 "driver_opts" => self
1346 .key_value_mapping(field.value(), "network driver_opts must be a mapping")
1347 .into_iter()
1348 .for_each(|value| network.set_driver_opts(value)),
1349 "attachable" => self
1350 .located_boolean(field.value(), "network attachable must be a boolean")
1351 .into_iter()
1352 .for_each(|value| network.set_attachable(value)),
1353 "enable_ipv4" => self
1354 .located_boolean(field.value(), "network enable_ipv4 must be a boolean")
1355 .into_iter()
1356 .for_each(|value| network.set_enable_ipv4(value)),
1357 "enable_ipv6" => self
1358 .located_boolean(field.value(), "network enable_ipv6 must be a boolean")
1359 .into_iter()
1360 .for_each(|value| network.set_enable_ipv6(value)),
1361 "external" => self
1362 .located_boolean(field.value(), "network external must be a boolean")
1363 .into_iter()
1364 .for_each(|value| network.set_external(value)),
1365 "internal" => self
1366 .located_boolean(field.value(), "network internal must be a boolean")
1367 .into_iter()
1368 .for_each(|value| network.set_internal(value)),
1369 "ipam" => self
1370 .ipam(field.value(), &path)
1371 .into_iter()
1372 .for_each(|value| network.set_ipam(value)),
1373 "labels" => self
1374 .labels(field.value())
1375 .into_iter()
1376 .for_each(|value| network.set_labels(value)),
1377 "name" => self
1378 .located_string(field.value(), "network custom name must be a scalar")
1379 .into_iter()
1380 .for_each(|value| network.set_custom_name(value)),
1381 _ => self.record_root_unmodeled(&path, field),
1382 }
1383 }
1384 Some(network)
1385 }
1386
1387 fn ipam(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Ipam> {
1388 let fields = self.mapping(value, "network IPAM must be a mapping")?;
1389 let mut ipam = Ipam::new(effective_span(value));
1390 let mut path = parent_path.to_vec();
1391 path.push("ipam".to_owned());
1392 for field in fields {
1393 match field.key() {
1394 "driver" => self
1395 .located_string(field.value(), "IPAM driver must be a scalar")
1396 .into_iter()
1397 .for_each(|value| ipam.set_driver(value)),
1398 "config" => self
1399 .ipam_configs(field.value(), &path)
1400 .into_iter()
1401 .for_each(|value| ipam.set_config(value)),
1402 "options" => self
1403 .key_value_mapping(field.value(), "IPAM options must be a mapping")
1404 .into_iter()
1405 .for_each(|value| ipam.set_options(value)),
1406 _ => self.record_root_unmodeled(&path, field),
1407 }
1408 }
1409 Some(ipam)
1410 }
1411
1412 fn ipam_configs(&mut self, value: &MergedValue, parent_path: &[String]) -> Option<Vec<IpamConfig>> {
1413 let Some(values) = value.as_sequence() else {
1414 self.expected(value, "IPAM config must be a sequence");
1415 return None;
1416 };
1417 let mut configs = Vec::new();
1418 for (index, value) in values.iter().enumerate() {
1419 let Some(fields) = value.as_mapping() else {
1420 self.expected(value, "IPAM config entry must be a mapping");
1421 continue;
1422 };
1423 let mut config = IpamConfig::new(effective_span(value));
1424 let mut path = parent_path.to_vec();
1425 path.push("config".to_owned());
1426 path.push(index.to_string());
1427 for field in fields {
1428 match field.key() {
1429 "subnet" => self
1430 .located_string(field.value(), "IPAM subnet must be a scalar")
1431 .into_iter()
1432 .for_each(|value| config.set_subnet(value)),
1433 "ip_range" => self
1434 .located_string(field.value(), "IPAM ip_range must be a scalar")
1435 .into_iter()
1436 .for_each(|value| config.set_ip_range(value)),
1437 "gateway" => self
1438 .located_string(field.value(), "IPAM gateway must be a scalar")
1439 .into_iter()
1440 .for_each(|value| config.set_gateway(value)),
1441 "aux_addresses" => self
1442 .key_value_mapping(field.value(), "IPAM aux_addresses must be a mapping")
1443 .into_iter()
1444 .for_each(|value| config.set_aux_addresses(value)),
1445 _ => self.record_root_unmodeled(&path, field),
1446 }
1447 }
1448 configs.push(config);
1449 }
1450 Some(configs)
1451 }
1452
1453 fn volume_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<VolumeDefinition>> {
1454 let Some(entries) = self.mapping(value, "top-level volumes must be a mapping") else {
1455 return Vec::new();
1456 };
1457 entries
1458 .iter()
1459 .filter_map(|entry| {
1460 let definition = self.volume_definition(entry)?;
1461 Some(ProjectResource {
1462 name: ProjectKey::from_entry(entry),
1463 definition: ProjectValue::new(definition, entry.value()),
1464 })
1465 })
1466 .collect()
1467 }
1468
1469 fn volume_definition(&mut self, entry: &MergedEntry) -> Option<VolumeDefinition> {
1470 let value = entry.value();
1471 let span = effective_span(value);
1472 let mut volume = VolumeDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1473 let fields = match value.kind() {
1474 MergedValueKind::Null(_) => return Some(volume),
1475 MergedValueKind::Mapping(fields) => fields,
1476 _ => {
1477 self.expected(value, "volume definition must be a mapping or null");
1478 return None;
1479 }
1480 };
1481 let path = ["volumes".to_owned(), entry.key().to_owned()];
1482 for field in fields {
1483 match field.key() {
1484 "driver" => self
1485 .located_string(field.value(), "volume driver must be a scalar")
1486 .into_iter()
1487 .for_each(|value| volume.set_driver(value)),
1488 "driver_opts" => self
1489 .key_value_mapping(field.value(), "volume driver_opts must be a mapping")
1490 .into_iter()
1491 .for_each(|value| volume.set_driver_opts(value)),
1492 "external" => self
1493 .located_boolean(field.value(), "volume external must be a boolean")
1494 .into_iter()
1495 .for_each(|value| volume.set_external(value)),
1496 "labels" => self
1497 .labels(field.value())
1498 .into_iter()
1499 .for_each(|value| volume.set_labels(value)),
1500 "name" => self
1501 .located_string(field.value(), "volume custom name must be a scalar")
1502 .into_iter()
1503 .for_each(|value| volume.set_custom_name(value)),
1504 _ => self.record_root_unmodeled(&path, field),
1505 }
1506 }
1507 Some(volume)
1508 }
1509
1510 fn config_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<ConfigDefinition>> {
1511 let Some(entries) = self.mapping(value, "top-level configs must be a mapping") else {
1512 return Vec::new();
1513 };
1514 entries
1515 .iter()
1516 .filter_map(|entry| {
1517 let definition = self.config_definition(entry)?;
1518 Some(ProjectResource {
1519 name: ProjectKey::from_entry(entry),
1520 definition: ProjectValue::new(definition, entry.value()),
1521 })
1522 })
1523 .collect()
1524 }
1525
1526 fn config_definition(&mut self, entry: &MergedEntry) -> Option<ConfigDefinition> {
1527 let value = entry.value();
1528 let span = effective_span(value);
1529 let mut config = ConfigDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1530 let fields = match value.kind() {
1531 MergedValueKind::Null(_) => return Some(config),
1532 MergedValueKind::Mapping(fields) => fields,
1533 _ => {
1534 self.expected(value, "config definition must be a mapping or null");
1535 return None;
1536 }
1537 };
1538 let path = ["configs".to_owned(), entry.key().to_owned()];
1539 for field in fields {
1540 match field.key() {
1541 "file" => self
1542 .located_string(field.value(), "config file must be a scalar")
1543 .into_iter()
1544 .for_each(|value| config.set_file(value)),
1545 "environment" => self
1546 .located_string(field.value(), "config environment must be a scalar")
1547 .into_iter()
1548 .for_each(|value| config.set_environment(value)),
1549 "content" => self
1550 .located_string(field.value(), "config content must be a scalar")
1551 .into_iter()
1552 .for_each(|value| config.set_content(value)),
1553 "external" => self
1554 .located_boolean(field.value(), "config external must be a boolean")
1555 .into_iter()
1556 .for_each(|value| config.set_external(value)),
1557 "name" => self
1558 .located_string(field.value(), "config custom name must be a scalar")
1559 .into_iter()
1560 .for_each(|value| config.set_custom_name(value)),
1561 _ => self.record_root_unmodeled(&path, field),
1562 }
1563 }
1564 Some(config)
1565 }
1566
1567 fn secret_definitions(&mut self, value: &MergedValue) -> Vec<ProjectResource<SecretDefinition>> {
1568 let Some(entries) = self.mapping(value, "top-level secrets must be a mapping") else {
1569 return Vec::new();
1570 };
1571 entries
1572 .iter()
1573 .filter_map(|entry| {
1574 let definition = self.secret_definition(entry)?;
1575 Some(ProjectResource {
1576 name: ProjectKey::from_entry(entry),
1577 definition: ProjectValue::new(definition, entry.value()),
1578 })
1579 })
1580 .collect()
1581 }
1582
1583 fn secret_definition(&mut self, entry: &MergedEntry) -> Option<SecretDefinition> {
1584 let value = entry.value();
1585 let span = effective_span(value);
1586 let mut secret = SecretDefinition::new(Located::new(entry.key().to_owned(), entry_span(entry)), span);
1587 let fields = match value.kind() {
1588 MergedValueKind::Null(_) => return Some(secret),
1589 MergedValueKind::Mapping(fields) => fields,
1590 _ => {
1591 self.expected(value, "secret definition must be a mapping or null");
1592 return None;
1593 }
1594 };
1595 let path = ["secrets".to_owned(), entry.key().to_owned()];
1596 for field in fields {
1597 match field.key() {
1598 "file" => self
1599 .located_string(field.value(), "secret file must be a scalar")
1600 .into_iter()
1601 .for_each(|value| secret.set_file(value)),
1602 "environment" => self
1603 .located_string(field.value(), "secret environment must be a scalar")
1604 .into_iter()
1605 .for_each(|value| secret.set_environment(value)),
1606 "external" => self
1607 .located_boolean(field.value(), "secret external must be a boolean")
1608 .into_iter()
1609 .for_each(|value| secret.set_external(value)),
1610 "name" => self
1611 .located_string(field.value(), "secret custom name must be a scalar")
1612 .into_iter()
1613 .for_each(|value| secret.set_custom_name(value)),
1614 _ => self.record_root_unmodeled(&path, field),
1615 }
1616 }
1617 Some(secret)
1618 }
1619
1620 fn labels(&mut self, value: &MergedValue) -> Option<Labels> {
1621 let span = effective_span(value);
1622 match value.kind() {
1623 MergedValueKind::Sequence(_) => self
1624 .located_string_sequence(value, "labels must be a scalar sequence")
1625 .map(|values| Labels::List { span, values }),
1626 MergedValueKind::Mapping(_) => self
1627 .key_value_mapping(value, "labels must be a scalar mapping")
1628 .map(|entries| Labels::Map { span, entries }),
1629 _ => {
1630 self.expected(value, "labels must be a sequence or mapping");
1631 None
1632 }
1633 }
1634 }
1635}
1636
1637fn field_reference(path: &[String], entry: &MergedEntry) -> ProjectFieldReference {
1638 let mut complete_path = path.to_vec();
1639 complete_path.push(entry.key().to_owned());
1640 ProjectFieldReference {
1641 path: complete_path,
1642 key: ProjectKey::from_entry(entry),
1643 provenance: entry.value().provenance().clone(),
1644 extension: entry.key().starts_with("x-"),
1645 sensitive: entry.value().is_sensitive(),
1646 }
1647}
1648
1649fn effective_span(value: &MergedValue) -> SourceSpan {
1650 value
1651 .provenance()
1652 .effective_source()
1653 .or_else(|| value.provenance().sources().first().copied())
1654 .unwrap_or_else(|| SourceSpan::from_valid_offsets(SourceId::new(0), 0, 0))
1655}
1656
1657fn entry_span(entry: &MergedEntry) -> SourceSpan {
1658 entry
1659 .key_sources()
1660 .last()
1661 .copied()
1662 .or_else(|| entry.key_sources().first().copied())
1663 .unwrap_or_else(|| effective_span(entry.value()))
1664}