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