1mod annotation;
4mod capability;
5mod command;
6mod dependency;
7mod device;
8mod dns;
9mod dns_option;
10mod dns_search;
11mod entrypoint;
12mod environment;
13mod expose;
14mod host;
15mod hostname;
16mod identity;
17mod image;
18mod lifecycle;
19mod memory;
20mod network;
21mod pids;
22mod port;
23mod pull;
24mod resource;
25mod restart;
26mod sections;
27mod security_option;
28mod shm;
29mod sysctl;
30mod tmpfs;
31mod ulimit;
32mod value;
33mod volume;
34
35pub use annotation::{Annotations, AnnotationsForm};
36pub use capability::{CapabilityAdd, CapabilityAddItem, CapabilityDrop, CapabilityDropItem};
37pub use command::Command;
38pub use dependency::{
39 DependencyCondition, DependsOn, Healthcheck, HealthcheckDuration, HealthcheckRetries, HealthcheckTest,
40 HealthcheckTestKind, ServiceDependency,
41};
42pub(crate) use device::valid_generated_device_string;
43pub use device::{Device, Devices, LongDevice, ShortDevice, ShortDeviceKind};
44pub use dns::{Dns, DnsForm};
45pub use dns_option::DnsOptions;
46pub use dns_search::{DnsSearch, DnsSearchForm};
47pub use entrypoint::Entrypoint;
48pub use environment::{
49 Environment, EnvironmentFile, EnvironmentFileFormat, EnvironmentFileFormatKind, EnvironmentListEntry,
50 EnvironmentMapEntry, LongEnvironmentFile,
51};
52pub use expose::{Expose, ExposeItem, ExposeItemKind, ExposePort, ExposeProtocol, ExposeScalarKind};
53pub(crate) use expose::{classify_expose_item, valid_generated_expose_item};
54pub use host::{ExtraHostSeparator, ExtraHosts, HostAddress, HostAddressKind, LongExtraHost, ShortExtraHost};
55pub(crate) use hostname::valid_hostname;
56pub use hostname::{Hostname, HostnameKind};
57pub use identity::{IdentityComponent, UserNamespaceMode, UserNamespaceModeKind, UserSpec};
58pub use image::{ImageDigest, ImageReference};
59pub use lifecycle::StopGracePeriod;
60pub(crate) use memory::valid_generated_mem_amount;
61pub use memory::{MemLimit, MemLimitKind, MemLimitScalarKind, MemLimitUnit};
62pub use network::{Ipam, IpamConfig, NetworkDefinition, ServiceNetwork, ServiceNetworks};
63pub(crate) use pids::valid_positive_pids_decimal;
64pub use pids::{PidsLimit, PidsLimitKind};
65pub use port::{LongPort, Port, ShortPort};
66pub(crate) use pull::valid_pull_policy_duration;
67pub use pull::{PullPolicy, PullPolicyKind};
68pub use resource::{ConfigDefinition, ConfigGrant, LongGrant, SecretDefinition, SecretGrant, VolumeDefinition};
69pub use restart::{RestartPolicy, RestartPolicyKind};
70pub use sections::{
71 Build, BuildDefinition, BuildField, BuildFieldKind, DeployDefinition, DeployField, DeployFieldKind,
72};
73pub(crate) use security_option::{SecurityOptionCandidateCounts, classify_security_option};
74pub use security_option::{SecurityOptionItem, SecurityOptionKind, SecurityOptions};
75pub(crate) use shm::valid_generated_shm_amount;
76pub use shm::{ShmSize, ShmSizeKind, ShmSizeScalarKind, ShmSizeUnit};
77pub use sysctl::{Sysctls, SysctlsForm};
78pub(crate) use tmpfs::valid_generated_tmpfs_item;
79pub use tmpfs::{Tmpfs, TmpfsForm, TmpfsItem, TmpfsItemKind};
80pub(crate) use ulimit::valid_ulimit_name;
81pub use ulimit::{LimitValue, Ulimit, UlimitRange, UlimitValue, Ulimits};
82pub use value::{BooleanValue, ComposeScalar, KeyValueEntry, Labels};
83pub use volume::{
84 BindOptions, ContainerPath, ContainerPathKind, LongVolumeMount, MountType, SelinuxRelabel, ShortVolumeMount,
85 VolumeMount, VolumeSyntax,
86};
87
88use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
89use crate::source::{SourceId, SourceSpan};
90use crate::syntax::{SyntaxDocument, scalar_string_from_source};
91use std::collections::{BTreeMap, BTreeSet};
92use yaml_edit::{AnchorRegistry, AsYaml, Mapping, ScalarType, ScalarValue, YamlNode};
93
94pub const DOCUMENT_ROOT_TYPE: DiagnosticCode = DiagnosticCode::new("compose.document.expected-mapping");
96
97pub const MULTIPLE_DOCUMENTS: DiagnosticCode = DiagnosticCode::new("compose.document.multiple-documents");
99
100pub const DUPLICATE_FIELD: DiagnosticCode = DiagnosticCode::new("compose.model.duplicate-field");
102
103pub const EXPECTED_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.model.expected-mapping");
105
106pub const EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.model.expected-sequence");
108
109pub const EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.model.expected-scalar");
111
112pub const EXPECTED_BOOLEAN: DiagnosticCode = DiagnosticCode::new("compose.model.expected-boolean");
114
115pub const EXPECTED_FIELD_FORM: DiagnosticCode = DiagnosticCode::new("compose.model.expected-field-form");
117
118pub const PORT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.port.expected-short-or-long");
120
121pub const PORT_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.port.long.missing-target");
123
124pub const GRANT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.grant.expected-short-or-long");
126
127pub const GRANT_MISSING_SOURCE: DiagnosticCode = DiagnosticCode::new("compose.grant.long.missing-source");
129
130pub const RESOURCE_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.resource.expected-mapping-or-null");
132
133pub const VOLUME_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.volume.expected-short-or-long");
135
136pub const VOLUME_MISSING_TYPE: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-type");
138
139pub const VOLUME_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-target");
141
142pub const VOLUME_INVALID_SELINUX: DiagnosticCode = DiagnosticCode::new("compose.volume.bind.invalid-selinux");
144
145pub const EXTRA_HOST_INVALID_ENTRY: DiagnosticCode = DiagnosticCode::new("compose.extra-hosts.invalid-entry");
147
148pub const ULIMIT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.ulimits.invalid-value");
150
151pub const ULIMIT_INVALID_NAME: DiagnosticCode = DiagnosticCode::new("compose.ulimits.invalid-name");
153
154pub const ULIMIT_MISSING_RANGE_MEMBER: DiagnosticCode = DiagnosticCode::new("compose.ulimits.missing-range-member");
156
157pub const HEALTHCHECK_INVALID_TEST: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-test");
159
160pub const HEALTHCHECK_INVALID_DURATION: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-duration");
162
163pub const HEALTHCHECK_INVALID_RETRIES: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-retries");
165
166pub const RESTART_INVALID_POLICY: DiagnosticCode = DiagnosticCode::new("compose.restart.invalid-policy");
168
169pub const HOSTNAME_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.hostname.expected-string");
171
172pub const HOSTNAME_INVALID: DiagnosticCode = DiagnosticCode::new("compose.hostname.invalid-value");
174
175pub const PIDS_LIMIT_EXPECTED_VALUE: DiagnosticCode =
177 DiagnosticCode::new("compose.pids-limit.expected-number-or-string");
178
179pub const PIDS_LIMIT_INVALID: DiagnosticCode = DiagnosticCode::new("compose.pids-limit.invalid-value");
181
182pub const PIDS_LIMIT_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.pids-limit.ambiguous-zero");
184
185pub const SHM_SIZE_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.shm-size.expected-number-or-string");
187
188pub const SHM_SIZE_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.shm-size.ambiguous-zero");
190
191pub const SHM_SIZE_PROVIDER_DEPENDENT_NUMBER: DiagnosticCode =
193 DiagnosticCode::new("compose.shm-size.provider-dependent-number");
194
195pub const SHM_SIZE_PROVIDER_DEPENDENT_STRING: DiagnosticCode =
197 DiagnosticCode::new("compose.shm-size.provider-dependent-string");
198
199pub const MEM_LIMIT_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.expected-number-or-string");
201
202pub const MEM_LIMIT_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.ambiguous-zero");
204
205pub const MEM_LIMIT_SCHEMA_NUMBER: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.schema-number");
207
208pub const MEM_LIMIT_PROVIDER_DEPENDENT_STRING: DiagnosticCode =
210 DiagnosticCode::new("compose.mem-limit.provider-dependent-string");
211
212pub const PULL_POLICY_INVALID: DiagnosticCode = DiagnosticCode::new("compose.pull-policy.invalid-policy");
214
215pub const STOP_GRACE_PERIOD_INVALID: DiagnosticCode =
217 DiagnosticCode::new("compose.lifecycle.invalid-stop-grace-period");
218
219pub const CAP_DROP_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.expected-sequence");
221
222pub const CAP_DROP_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.expected-string");
224
225pub const CAP_DROP_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.duplicate-item");
227
228pub const CAP_ADD_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.cap-add.expected-sequence");
230
231pub const CAP_ADD_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.cap-add.expected-string");
233
234pub const CAP_ADD_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.cap-add.duplicate-item");
236
237pub const DEVICES_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-sequence");
239
240pub const DEVICE_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-short-or-long");
242
243pub const DEVICE_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-string");
245
246pub const DEVICE_MISSING_SOURCE: DiagnosticCode = DiagnosticCode::new("compose.devices.long.missing-source");
248
249pub const DNS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.dns.expected-string-or-list");
251
252pub const DNS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns.expected-string");
254
255pub const DNS_OPT_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.expected-sequence");
257
258pub const DNS_OPT_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.expected-string");
260
261pub const DNS_OPT_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.duplicate-item");
263
264pub const DNS_SEARCH_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.dns-search.expected-string-or-list");
266
267pub const DNS_SEARCH_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns-search.expected-string");
269
270pub const DNS_SEARCH_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.dns-search.duplicate-item");
272
273pub const EXPOSE_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.expose.expected-sequence");
275
276pub const EXPOSE_EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.expose.expected-string-or-number");
278
279pub const EXPOSE_INVALID_ITEM: DiagnosticCode = DiagnosticCode::new("compose.expose.invalid-item");
281
282pub const EXPOSE_PROVIDER_DEPENDENT: DiagnosticCode = DiagnosticCode::new("compose.expose.provider-dependent-protocol");
284
285pub const EXPOSE_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.expose.duplicate-item");
287
288pub const SECURITY_OPT_EXPECTED_SEQUENCE: DiagnosticCode =
290 DiagnosticCode::new("compose.security-opt.expected-sequence");
291
292pub const SECURITY_OPT_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.security-opt.expected-string");
294
295pub const SECURITY_OPT_EMPTY_ITEM: DiagnosticCode = DiagnosticCode::new("compose.security-opt.empty-item");
297
298pub const SECURITY_OPT_APPARMOR_NEAR_MISS: DiagnosticCode =
300 DiagnosticCode::new("compose.security-opt.apparmor-near-miss");
301
302pub const SECURITY_OPT_APPARMOR_CONFLICT: DiagnosticCode =
304 DiagnosticCode::new("compose.security-opt.apparmor-conflict");
305
306pub const SECURITY_OPT_SECCOMP_NEAR_MISS: DiagnosticCode =
308 DiagnosticCode::new("compose.security-opt.seccomp-near-miss");
309
310pub const SECURITY_OPT_SECCOMP_CONFLICT: DiagnosticCode = DiagnosticCode::new("compose.security-opt.seccomp-conflict");
312
313pub const SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS: DiagnosticCode =
315 DiagnosticCode::new("compose.security-opt.no-new-privileges-near-miss");
316
317pub const SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT: DiagnosticCode =
319 DiagnosticCode::new("compose.security-opt.no-new-privileges-conflict");
320
321pub const SECURITY_OPT_MASK_NEAR_MISS: DiagnosticCode = DiagnosticCode::new("compose.security-opt.mask-near-miss");
323
324pub const SECURITY_OPT_UNMASK_NEAR_MISS: DiagnosticCode = DiagnosticCode::new("compose.security-opt.unmask-near-miss");
326
327pub(crate) fn security_path_option_diagnostic(kind: &SecurityOptionKind, span: SourceSpan) -> Option<Diagnostic> {
328 let (code, message) = match kind {
329 SecurityOptionKind::MaskNearMiss => (
330 SECURITY_OPT_MASK_NEAR_MISS,
331 "mask candidates require exact lowercase `mask=<paths>` spelling with a non-empty whitespace-free payload",
332 ),
333 SecurityOptionKind::UnmaskNearMiss => (
334 SECURITY_OPT_UNMASK_NEAR_MISS,
335 "unmask candidates require exact lowercase `unmask=ALL` or colon-separated slash-prefixed paths without whitespace",
336 ),
337 _ => return None,
338 };
339 Some(
340 Diagnostic::new(code, Severity::Warning, message)
341 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
342 )
343}
344
345pub const SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS: DiagnosticCode =
347 DiagnosticCode::new("compose.security-opt.security-label-disable-near-miss");
348
349pub const SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT: DiagnosticCode =
351 DiagnosticCode::new("compose.security-opt.security-label-disable-conflict");
352
353pub const SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS: DiagnosticCode =
355 DiagnosticCode::new("compose.security-opt.security-label-filetype-near-miss");
356
357pub const SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT: DiagnosticCode =
359 DiagnosticCode::new("compose.security-opt.security-label-filetype-conflict");
360
361pub const SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS: DiagnosticCode =
363 DiagnosticCode::new("compose.security-opt.security-label-level-near-miss");
364
365pub const SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT: DiagnosticCode =
367 DiagnosticCode::new("compose.security-opt.security-label-level-conflict");
368
369pub const SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS: DiagnosticCode =
371 DiagnosticCode::new("compose.security-opt.security-label-nested-near-miss");
372
373pub const SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT: DiagnosticCode =
375 DiagnosticCode::new("compose.security-opt.security-label-nested-conflict");
376
377pub const SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS: DiagnosticCode =
379 DiagnosticCode::new("compose.security-opt.security-label-type-near-miss");
380
381pub const SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT: DiagnosticCode =
383 DiagnosticCode::new("compose.security-opt.security-label-type-conflict");
384
385fn authored_security_label_diagnostic(
386 kind: &SecurityOptionKind,
387 span: SourceSpan,
388 candidates: &mut SecurityOptionCandidateCounts,
389) -> Option<Diagnostic> {
390 match kind {
391 SecurityOptionKind::SecurityLabelDisable { .. } => {
392 candidates.security_label_disable += 1;
393 (candidates.security_label_disable > 1).then(|| {
394 Diagnostic::new(
395 SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT,
396 Severity::Warning,
397 "multiple SELinux label-disable candidates are retained; a consumer must resolve the conflict explicitly",
398 )
399 .with_label(DiagnosticLabel::primary(
400 span,
401 "additional SELinux label-disable candidate retained",
402 ))
403 })
404 }
405 SecurityOptionKind::SecurityLabelDisableNearMiss => Some(
406 Diagnostic::new(
407 SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS,
408 Severity::Warning,
409 "SELinux label-disable candidates require exact lowercase `label:disable` spelling without whitespace",
410 )
411 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
412 ),
413 SecurityOptionKind::SecurityLabelFileType { .. } => {
414 candidates.security_label_filetype += 1;
415 (candidates.security_label_filetype > 1).then(|| {
416 Diagnostic::new(
417 SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT,
418 Severity::Warning,
419 "multiple SELinux label-filetype candidates are retained; a consumer must resolve the conflict explicitly",
420 )
421 .with_label(DiagnosticLabel::primary(
422 span,
423 "additional SELinux label-filetype candidate retained",
424 ))
425 })
426 }
427 SecurityOptionKind::SecurityLabelFileTypeNearMiss => Some(
428 Diagnostic::new(
429 SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS,
430 Severity::Warning,
431 "SELinux label-filetype candidates require exact lowercase `label:filetype:<type>` spelling without whitespace",
432 )
433 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
434 ),
435 SecurityOptionKind::SecurityLabelLevel { .. } => {
436 candidates.security_label_level += 1;
437 (candidates.security_label_level > 1).then(|| {
438 Diagnostic::new(
439 SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT,
440 Severity::Warning,
441 "multiple SELinux label-level candidates are retained; a consumer must resolve the conflict explicitly",
442 )
443 .with_label(DiagnosticLabel::primary(
444 span,
445 "additional SELinux label-level candidate retained",
446 ))
447 })
448 }
449 SecurityOptionKind::SecurityLabelLevelNearMiss => Some(
450 Diagnostic::new(
451 SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS,
452 Severity::Warning,
453 "SELinux label-level candidates require exact lowercase `label:level:<level>` spelling without whitespace",
454 )
455 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
456 ),
457 SecurityOptionKind::SecurityLabelNested { .. } => {
458 candidates.security_label_nested += 1;
459 (candidates.security_label_nested > 1).then(|| {
460 Diagnostic::new(
461 SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT,
462 Severity::Warning,
463 "multiple SELinux label-nested candidates are retained; a consumer must resolve the conflict explicitly",
464 )
465 .with_label(DiagnosticLabel::primary(
466 span,
467 "additional SELinux label-nested candidate retained",
468 ))
469 })
470 }
471 SecurityOptionKind::SecurityLabelNestedNearMiss => Some(
472 Diagnostic::new(
473 SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS,
474 Severity::Warning,
475 "SELinux label-nested candidates require exact lowercase `label:nested` spelling without whitespace",
476 )
477 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
478 ),
479 SecurityOptionKind::SecurityLabelType { .. } | SecurityOptionKind::SecurityLabelTypeNearMiss => {
480 authored_security_label_type_diagnostic(kind, span, &mut candidates.security_label_type)
481 }
482 _ => None,
483 }
484}
485
486fn authored_security_label_type_diagnostic(
487 kind: &SecurityOptionKind,
488 span: SourceSpan,
489 candidates: &mut usize,
490) -> Option<Diagnostic> {
491 match kind {
492 SecurityOptionKind::SecurityLabelType { .. } => {
493 *candidates += 1;
494 (*candidates > 1).then(|| {
495 Diagnostic::new(
496 SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT,
497 Severity::Warning,
498 "multiple SELinux label-type candidates are retained; a consumer must resolve the conflict explicitly",
499 )
500 .with_label(DiagnosticLabel::primary(
501 span,
502 "additional SELinux label-type candidate retained",
503 ))
504 })
505 }
506 SecurityOptionKind::SecurityLabelTypeNearMiss => Some(
507 Diagnostic::new(
508 SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS,
509 Severity::Warning,
510 "SELinux label-type candidates require exact lowercase `label:type:<type>` spelling with one non-empty whitespace-free type",
511 )
512 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
513 ),
514 _ => None,
515 }
516}
517
518pub const ANNOTATIONS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.annotations.expected-map-or-list");
520
521pub const ANNOTATIONS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.annotations.expected-string");
523
524pub const ANNOTATIONS_EMPTY_NAME: DiagnosticCode = DiagnosticCode::new("compose.annotations.empty-name");
526
527pub const ANNOTATIONS_KEY_ONLY: DiagnosticCode = DiagnosticCode::new("compose.annotations.key-only");
529
530pub const ANNOTATIONS_DUPLICATE_NAME: DiagnosticCode = DiagnosticCode::new("compose.annotations.duplicate-name");
532
533pub const TMPFS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.expected-string-or-list");
535
536pub const TMPFS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.expected-string");
538
539pub const TMPFS_PROVIDER_DEPENDENT: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.provider-dependent-item");
541
542pub const SYSCTLS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-map-or-list");
544
545pub const SYSCTLS_EMPTY_KEY: DiagnosticCode = DiagnosticCode::new("compose.sysctls.empty-key");
547
548pub const SYSCTLS_EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-scalar");
550
551pub const SYSCTLS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-string");
553
554pub const SYSCTLS_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.sysctls.duplicate-item");
556
557pub const ENVIRONMENT_FILE_EXPECTED_FORM: DiagnosticCode =
559 DiagnosticCode::new("compose.environment-file.expected-short-or-long");
560
561pub const ENVIRONMENT_FILE_MISSING_PATH: DiagnosticCode =
563 DiagnosticCode::new("compose.environment-file.long.missing-path");
564
565pub const ENVIRONMENT_FILE_INVALID_FORMAT: DiagnosticCode =
567 DiagnosticCode::new("compose.environment-file.invalid-format");
568
569pub const DEPENDENCY_INVALID_CONDITION: DiagnosticCode = DiagnosticCode::new("compose.dependencies.invalid-condition");
571
572pub const DEPENDENCY_MISSING_SERVICE: DiagnosticCode = DiagnosticCode::new("compose.dependencies.missing-service");
574
575pub const DEPENDENCY_MISSING_HEALTHCHECK: DiagnosticCode =
577 DiagnosticCode::new("compose.dependencies.missing-healthcheck");
578
579pub const DEPENDENCY_HEALTHCHECK_UNVERIFIED: DiagnosticCode =
581 DiagnosticCode::new("compose.dependencies.healthcheck-unverified");
582
583#[derive(Debug, Clone, PartialEq, Eq)]
585pub struct Located<T> {
586 value: T,
587 span: SourceSpan,
588}
589
590impl<T> Located<T> {
591 pub(crate) const fn new(value: T, span: SourceSpan) -> Self {
592 Self { value, span }
593 }
594
595 #[must_use]
597 pub const fn value(&self) -> &T {
598 &self.value
599 }
600
601 #[must_use]
603 pub const fn span(&self) -> SourceSpan {
604 self.span
605 }
606
607 #[must_use]
609 pub fn into_value(self) -> T {
610 self.value
611 }
612}
613
614#[derive(Debug, Clone, PartialEq, Eq)]
619pub struct FieldReference {
620 name: Located<String>,
621 span: SourceSpan,
622 value_span: Option<SourceSpan>,
623}
624
625impl FieldReference {
626 #[must_use]
628 pub const fn name(&self) -> &Located<String> {
629 &self.name
630 }
631
632 #[must_use]
634 pub const fn span(&self) -> SourceSpan {
635 self.span
636 }
637
638 #[must_use]
640 pub const fn value_span(&self) -> Option<SourceSpan> {
641 self.value_span
642 }
643}
644
645#[derive(Debug, Clone, PartialEq, Eq)]
647pub struct Service {
648 name: Located<String>,
649 span: SourceSpan,
650 hostname: Option<Hostname>,
651 container_name: Option<Located<String>>,
652 image: Option<Located<ImageReference>>,
653 entrypoint: Option<Entrypoint>,
654 command: Option<Command>,
655 init: Option<Located<BooleanValue>>,
656 environment: Option<Environment>,
657 environment_files: Vec<EnvironmentFile>,
658 labels: Option<Labels>,
659 annotations: Option<Annotations>,
660 extra_hosts: Option<ExtraHosts>,
661 user: Option<UserSpec>,
662 userns_mode: Option<UserNamespaceMode>,
663 group_add: Vec<Located<String>>,
664 cap_add: Option<CapabilityAdd>,
665 cap_drop: Option<CapabilityDrop>,
666 devices: Option<Devices>,
667 dns: Option<Dns>,
668 dns_options: Option<DnsOptions>,
669 dns_search: Option<DnsSearch>,
670 expose: Option<Expose>,
671 security_options: Option<SecurityOptions>,
672 working_dir: Option<Located<String>>,
673 read_only: Option<Located<BooleanValue>>,
674 pids_limit: Option<PidsLimit>,
675 shm_size: Option<ShmSize>,
676 mem_limit: Option<MemLimit>,
677 tmpfs: Option<Tmpfs>,
678 sysctls: Option<Sysctls>,
679 pull_policy: Option<PullPolicy>,
680 restart: Option<RestartPolicy>,
681 stop_signal: Option<Located<String>>,
682 stop_grace_period: Option<Located<StopGracePeriod>>,
683 ulimits: Option<Ulimits>,
684 depends_on: Option<DependsOn>,
685 healthcheck: Option<Healthcheck>,
686 build: Option<Build>,
687 deploy: Option<DeployDefinition>,
688 ports: Vec<Port>,
689 volumes: Vec<VolumeMount>,
690 networks: Option<ServiceNetworks>,
691 profiles: Vec<Located<String>>,
692 configs: Vec<ConfigGrant>,
693 secrets: Vec<SecretGrant>,
694 extension_fields: Vec<FieldReference>,
695 unknown_fields: Vec<FieldReference>,
696}
697
698impl Service {
699 fn new(name: Located<String>, span: SourceSpan) -> Self {
700 Self {
701 name,
702 span,
703 hostname: None,
704 container_name: None,
705 image: None,
706 entrypoint: None,
707 command: None,
708 init: None,
709 environment: None,
710 environment_files: Vec::new(),
711 labels: None,
712 annotations: None,
713 extra_hosts: None,
714 user: None,
715 userns_mode: None,
716 group_add: Vec::new(),
717 cap_add: None,
718 cap_drop: None,
719 devices: None,
720 dns: None,
721 dns_options: None,
722 dns_search: None,
723 expose: None,
724 security_options: None,
725 working_dir: None,
726 read_only: None,
727 pids_limit: None,
728 shm_size: None,
729 mem_limit: None,
730 tmpfs: None,
731 sysctls: None,
732 pull_policy: None,
733 restart: None,
734 stop_signal: None,
735 stop_grace_period: None,
736 ulimits: None,
737 depends_on: None,
738 healthcheck: None,
739 build: None,
740 deploy: None,
741 ports: Vec::new(),
742 volumes: Vec::new(),
743 networks: None,
744 profiles: Vec::new(),
745 configs: Vec::new(),
746 secrets: Vec::new(),
747 extension_fields: Vec::new(),
748 unknown_fields: Vec::new(),
749 }
750 }
751
752 #[must_use]
754 pub const fn name(&self) -> &Located<String> {
755 &self.name
756 }
757
758 #[must_use]
760 pub const fn span(&self) -> SourceSpan {
761 self.span
762 }
763
764 #[must_use]
766 pub const fn hostname(&self) -> Option<&Hostname> {
767 self.hostname.as_ref()
768 }
769
770 #[must_use]
772 pub const fn container_name(&self) -> Option<&Located<String>> {
773 self.container_name.as_ref()
774 }
775
776 #[must_use]
778 pub const fn image(&self) -> Option<&Located<ImageReference>> {
779 self.image.as_ref()
780 }
781
782 #[must_use]
784 pub const fn entrypoint(&self) -> Option<&Entrypoint> {
785 self.entrypoint.as_ref()
786 }
787
788 #[must_use]
790 pub const fn command(&self) -> Option<&Command> {
791 self.command.as_ref()
792 }
793
794 #[must_use]
796 pub const fn init(&self) -> Option<&Located<BooleanValue>> {
797 self.init.as_ref()
798 }
799
800 #[must_use]
802 pub const fn environment(&self) -> Option<&Environment> {
803 self.environment.as_ref()
804 }
805
806 #[must_use]
808 pub fn environment_files(&self) -> &[EnvironmentFile] {
809 &self.environment_files
810 }
811
812 #[must_use]
814 pub const fn labels(&self) -> Option<&Labels> {
815 self.labels.as_ref()
816 }
817
818 #[must_use]
820 pub const fn annotations(&self) -> Option<&Annotations> {
821 self.annotations.as_ref()
822 }
823
824 #[must_use]
826 pub const fn extra_hosts(&self) -> Option<&ExtraHosts> {
827 self.extra_hosts.as_ref()
828 }
829
830 #[must_use]
832 pub const fn user(&self) -> Option<&UserSpec> {
833 self.user.as_ref()
834 }
835
836 #[must_use]
838 pub const fn userns_mode(&self) -> Option<&UserNamespaceMode> {
839 self.userns_mode.as_ref()
840 }
841
842 #[must_use]
844 pub fn group_add(&self) -> &[Located<String>] {
845 &self.group_add
846 }
847
848 #[must_use]
850 pub const fn cap_add(&self) -> Option<&CapabilityAdd> {
851 self.cap_add.as_ref()
852 }
853
854 #[must_use]
856 pub const fn cap_drop(&self) -> Option<&CapabilityDrop> {
857 self.cap_drop.as_ref()
858 }
859
860 #[must_use]
862 pub const fn devices(&self) -> Option<&Devices> {
863 self.devices.as_ref()
864 }
865
866 #[must_use]
868 pub const fn dns(&self) -> Option<&Dns> {
869 self.dns.as_ref()
870 }
871
872 #[must_use]
874 pub const fn dns_options(&self) -> Option<&DnsOptions> {
875 self.dns_options.as_ref()
876 }
877
878 #[must_use]
880 pub const fn dns_search(&self) -> Option<&DnsSearch> {
881 self.dns_search.as_ref()
882 }
883
884 #[must_use]
886 pub const fn expose(&self) -> Option<&Expose> {
887 self.expose.as_ref()
888 }
889
890 #[must_use]
892 pub const fn security_options(&self) -> Option<&SecurityOptions> {
893 self.security_options.as_ref()
894 }
895
896 #[must_use]
898 pub const fn working_dir(&self) -> Option<&Located<String>> {
899 self.working_dir.as_ref()
900 }
901
902 #[must_use]
904 pub const fn read_only(&self) -> Option<&Located<BooleanValue>> {
905 self.read_only.as_ref()
906 }
907
908 #[must_use]
910 pub const fn pids_limit(&self) -> Option<&PidsLimit> {
911 self.pids_limit.as_ref()
912 }
913
914 #[must_use]
916 pub const fn shm_size(&self) -> Option<&ShmSize> {
917 self.shm_size.as_ref()
918 }
919
920 #[must_use]
922 pub const fn mem_limit(&self) -> Option<&MemLimit> {
923 self.mem_limit.as_ref()
924 }
925
926 #[must_use]
928 pub const fn tmpfs(&self) -> Option<&Tmpfs> {
929 self.tmpfs.as_ref()
930 }
931
932 #[must_use]
934 pub const fn sysctls(&self) -> Option<&Sysctls> {
935 self.sysctls.as_ref()
936 }
937
938 #[must_use]
940 pub const fn pull_policy(&self) -> Option<&PullPolicy> {
941 self.pull_policy.as_ref()
942 }
943
944 #[must_use]
946 pub const fn restart(&self) -> Option<&RestartPolicy> {
947 self.restart.as_ref()
948 }
949
950 #[must_use]
952 pub const fn stop_signal(&self) -> Option<&Located<String>> {
953 self.stop_signal.as_ref()
954 }
955
956 #[must_use]
958 pub const fn stop_grace_period(&self) -> Option<&Located<StopGracePeriod>> {
959 self.stop_grace_period.as_ref()
960 }
961
962 #[must_use]
964 pub const fn ulimits(&self) -> Option<&Ulimits> {
965 self.ulimits.as_ref()
966 }
967
968 #[must_use]
970 pub const fn depends_on(&self) -> Option<&DependsOn> {
971 self.depends_on.as_ref()
972 }
973
974 #[must_use]
976 pub const fn healthcheck(&self) -> Option<&Healthcheck> {
977 self.healthcheck.as_ref()
978 }
979
980 #[must_use]
982 pub const fn build(&self) -> Option<&Build> {
983 self.build.as_ref()
984 }
985
986 #[must_use]
988 pub const fn deploy(&self) -> Option<&DeployDefinition> {
989 self.deploy.as_ref()
990 }
991
992 #[must_use]
994 pub fn ports(&self) -> &[Port] {
995 &self.ports
996 }
997
998 #[must_use]
1000 pub fn volumes(&self) -> &[VolumeMount] {
1001 &self.volumes
1002 }
1003
1004 #[must_use]
1006 pub const fn networks(&self) -> Option<&ServiceNetworks> {
1007 self.networks.as_ref()
1008 }
1009
1010 #[must_use]
1012 pub fn profiles(&self) -> &[Located<String>] {
1013 &self.profiles
1014 }
1015
1016 #[must_use]
1018 pub fn configs(&self) -> &[ConfigGrant] {
1019 &self.configs
1020 }
1021
1022 #[must_use]
1024 pub fn secrets(&self) -> &[SecretGrant] {
1025 &self.secrets
1026 }
1027
1028 #[must_use]
1030 pub fn extension_fields(&self) -> &[FieldReference] {
1031 &self.extension_fields
1032 }
1033
1034 #[must_use]
1036 pub fn unknown_fields(&self) -> &[FieldReference] {
1037 &self.unknown_fields
1038 }
1039}
1040
1041#[derive(Debug, Clone, PartialEq, Eq)]
1043pub struct ComposeDocument {
1044 source_id: SourceId,
1045 span: SourceSpan,
1046 name: Option<Located<String>>,
1047 services: Vec<Service>,
1048 networks: Vec<NetworkDefinition>,
1049 volumes: Vec<VolumeDefinition>,
1050 configs: Vec<ConfigDefinition>,
1051 secrets: Vec<SecretDefinition>,
1052 extension_fields: Vec<FieldReference>,
1053 unknown_fields: Vec<FieldReference>,
1054}
1055
1056impl ComposeDocument {
1057 #[must_use]
1063 pub fn parse(syntax: &SyntaxDocument) -> ModelParse {
1064 Parser::new(syntax).parse()
1065 }
1066
1067 #[must_use]
1069 pub const fn source_id(&self) -> SourceId {
1070 self.source_id
1071 }
1072
1073 #[must_use]
1075 pub const fn span(&self) -> SourceSpan {
1076 self.span
1077 }
1078
1079 #[must_use]
1081 pub const fn name(&self) -> Option<&Located<String>> {
1082 self.name.as_ref()
1083 }
1084
1085 #[must_use]
1087 pub fn services(&self) -> &[Service] {
1088 &self.services
1089 }
1090
1091 #[must_use]
1093 pub fn service(&self, name: &str) -> Option<&Service> {
1094 self.services.iter().find(|service| service.name.value == name)
1095 }
1096
1097 #[must_use]
1102 pub fn validate_dependencies(&self) -> Vec<Diagnostic> {
1103 let mut diagnostics = Vec::new();
1104 for service in &self.services {
1105 let Some(depends_on) = service.depends_on() else {
1106 continue;
1107 };
1108 match depends_on {
1109 DependsOn::Short { services, .. } => {
1110 for target in services {
1111 if self.service(target.value()).is_none() {
1112 diagnostics.push(missing_dependency_diagnostic(target.span(), false, true));
1113 }
1114 }
1115 }
1116 DependsOn::Long { services, .. } => {
1117 for dependency in services {
1118 let required = !matches!(
1119 dependency.required().map(Located::value),
1120 Some(BooleanValue::Literal(false))
1121 );
1122 let Some(target) = self.service(dependency.service().value()) else {
1123 diagnostics.push(missing_dependency_diagnostic(
1124 dependency.service().span(),
1125 false,
1126 required,
1127 ));
1128 continue;
1129 };
1130 let needs_healthcheck = matches!(
1131 dependency.condition().map(Located::value),
1132 Some(DependencyCondition::ServiceHealthy)
1133 );
1134 if needs_healthcheck && target.healthcheck().is_none() {
1135 let span = dependency
1136 .condition()
1137 .map_or_else(|| dependency.service().span(), Located::span);
1138 diagnostics.push(unverified_healthcheck_diagnostic(span));
1139 } else if needs_healthcheck && target.healthcheck().is_some_and(Healthcheck::is_disabled) {
1140 let span = dependency
1141 .condition()
1142 .map_or_else(|| dependency.service().span(), Located::span);
1143 diagnostics.push(missing_dependency_diagnostic(span, true, required));
1144 }
1145 }
1146 }
1147 }
1148 }
1149 diagnostics
1150 }
1151
1152 #[must_use]
1154 pub fn networks(&self) -> &[NetworkDefinition] {
1155 &self.networks
1156 }
1157
1158 #[must_use]
1160 pub fn volumes(&self) -> &[VolumeDefinition] {
1161 &self.volumes
1162 }
1163
1164 #[must_use]
1166 pub fn configs(&self) -> &[ConfigDefinition] {
1167 &self.configs
1168 }
1169
1170 #[must_use]
1172 pub fn secrets(&self) -> &[SecretDefinition] {
1173 &self.secrets
1174 }
1175
1176 #[must_use]
1178 pub fn extension_fields(&self) -> &[FieldReference] {
1179 &self.extension_fields
1180 }
1181
1182 #[must_use]
1184 pub fn unknown_fields(&self) -> &[FieldReference] {
1185 &self.unknown_fields
1186 }
1187}
1188
1189#[derive(Debug, Clone, PartialEq, Eq)]
1191pub struct ModelParse {
1192 document: Option<ComposeDocument>,
1193 diagnostics: Vec<Diagnostic>,
1194}
1195
1196impl ModelParse {
1197 #[must_use]
1199 pub const fn document(&self) -> Option<&ComposeDocument> {
1200 self.document.as_ref()
1201 }
1202
1203 #[must_use]
1205 pub fn diagnostics(&self) -> &[Diagnostic] {
1206 &self.diagnostics
1207 }
1208
1209 #[must_use]
1211 pub fn is_valid(&self) -> bool {
1212 !self
1213 .diagnostics
1214 .iter()
1215 .any(|diagnostic| diagnostic.severity() == Severity::Error)
1216 }
1217
1218 #[must_use]
1220 pub fn into_parts(self) -> (Option<ComposeDocument>, Vec<Diagnostic>) {
1221 (self.document, self.diagnostics)
1222 }
1223}
1224
1225fn missing_dependency_diagnostic(span: SourceSpan, healthcheck: bool, required: bool) -> Diagnostic {
1226 let severity = if required { Severity::Error } else { Severity::Warning };
1227 if healthcheck {
1228 Diagnostic::new(
1229 DEPENDENCY_MISSING_HEALTHCHECK,
1230 severity,
1231 if required {
1232 "service_healthy dependency requires an enabled health check"
1233 } else {
1234 "optional service_healthy dependency has no enabled health check"
1235 },
1236 )
1237 .with_label(DiagnosticLabel::primary(span, "dependency cannot become healthy"))
1238 } else {
1239 Diagnostic::new(
1240 DEPENDENCY_MISSING_SERVICE,
1241 severity,
1242 if required {
1243 "service dependency is not declared in this Compose document"
1244 } else {
1245 "optional service dependency is not declared in this Compose document"
1246 },
1247 )
1248 .with_label(DiagnosticLabel::primary(span, "missing dependency service"))
1249 }
1250}
1251
1252fn unverified_healthcheck_diagnostic(span: SourceSpan) -> Diagnostic {
1253 Diagnostic::new(
1254 DEPENDENCY_HEALTHCHECK_UNVERIFIED,
1255 Severity::Warning,
1256 "service_healthy dependency has no Compose healthcheck to validate",
1257 )
1258 .with_label(DiagnosticLabel::primary(span, "image health metadata is not available"))
1259 .with_note("the dependency image may still define a health check; verify it at build or runtime")
1260}
1261
1262fn annotation_diagnostic(
1263 code: DiagnosticCode,
1264 severity: Severity,
1265 span: SourceSpan,
1266 message: &'static str,
1267 label: &'static str,
1268) -> Diagnostic {
1269 Diagnostic::new(code, severity, message).with_label(DiagnosticLabel::primary(span, label))
1270}
1271
1272#[derive(Debug)]
1273struct Parser {
1274 source_id: SourceId,
1275 source_span: SourceSpan,
1276 source: String,
1277 tree: yaml_edit::YamlFile,
1278 anchors: AnchorRegistry,
1279 diagnostics: Vec<Diagnostic>,
1280}
1281
1282impl Parser {
1283 fn new(syntax: &SyntaxDocument) -> Self {
1284 let tree = syntax.yaml_file();
1285 let anchors = tree
1286 .document()
1287 .map_or_else(AnchorRegistry::new, |document| AnchorRegistry::from_document(&document));
1288 Self {
1289 source_id: syntax.source_id(),
1290 source_span: syntax.source_span(),
1291 source: syntax.source_text().to_owned(),
1292 tree,
1293 anchors,
1294 diagnostics: Vec::new(),
1295 }
1296 }
1297
1298 fn parse(mut self) -> ModelParse {
1299 if self.tree.documents().count() > 1 {
1300 self.diagnostics.push(
1301 Diagnostic::new(
1302 MULTIPLE_DOCUMENTS,
1303 Severity::Error,
1304 "Compose input must contain one YAML document",
1305 )
1306 .with_label(DiagnosticLabel::primary(self.source_span, "multiple YAML documents")),
1307 );
1308 }
1309
1310 let Some(root) = self.tree.document() else {
1311 self.diagnostics.push(
1312 Diagnostic::new(
1313 DOCUMENT_ROOT_TYPE,
1314 Severity::Error,
1315 "Compose document root must be a mapping",
1316 )
1317 .with_label(DiagnosticLabel::primary(self.source_span, "empty document")),
1318 );
1319 return ModelParse {
1320 document: None,
1321 diagnostics: self.diagnostics,
1322 };
1323 };
1324 let root_span = span_from_position(self.source_id, root.byte_range());
1325 let Some(mapping) = root.as_mapping() else {
1326 self.diagnostics.push(
1327 Diagnostic::new(
1328 DOCUMENT_ROOT_TYPE,
1329 Severity::Error,
1330 "Compose document root must be a mapping",
1331 )
1332 .with_label(DiagnosticLabel::primary(root_span, "not a mapping")),
1333 );
1334 return ModelParse {
1335 document: None,
1336 diagnostics: self.diagnostics,
1337 };
1338 };
1339
1340 let document = self.parse_root(&mapping, root_span);
1341 ModelParse {
1342 document: Some(document),
1343 diagnostics: self.diagnostics,
1344 }
1345 }
1346
1347 fn parse_root(&mut self, mapping: &Mapping, span: SourceSpan) -> ComposeDocument {
1348 let mut document = ComposeDocument {
1349 source_id: self.source_id,
1350 span,
1351 name: None,
1352 services: Vec::new(),
1353 networks: Vec::new(),
1354 volumes: Vec::new(),
1355 configs: Vec::new(),
1356 secrets: Vec::new(),
1357 extension_fields: Vec::new(),
1358 unknown_fields: Vec::new(),
1359 };
1360 let mut seen = BTreeMap::new();
1361
1362 for field in self.fields(mapping) {
1363 let duplicate = self.record_duplicate(&mut seen, &field);
1364 match field.name.value.as_str() {
1365 "name" if !duplicate => {
1366 document.name = self.parse_string(&field, "project name");
1367 }
1368 "services" if !duplicate => {
1369 document.services = self.parse_services(&field);
1370 }
1371 "networks" if !duplicate => {
1372 document.networks = self.parse_network_definitions(&field);
1373 }
1374 "volumes" if !duplicate => {
1375 document.volumes = self.parse_volume_definitions(&field);
1376 }
1377 "configs" if !duplicate => {
1378 document.configs = self.parse_config_definitions(&field);
1379 }
1380 "secrets" if !duplicate => {
1381 document.secrets = self.parse_secret_definitions(&field);
1382 }
1383 name if name.starts_with("x-") => {
1384 document.extension_fields.push(field.reference());
1385 }
1386 _ if duplicate => {}
1387 _ => document.unknown_fields.push(field.reference()),
1388 }
1389 }
1390 document
1391 }
1392
1393 fn parse_services(&mut self, field: &ParsedField) -> Vec<Service> {
1394 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1395 self.expected(EXPECTED_MAPPING, field, "services must be a mapping");
1396 return Vec::new();
1397 };
1398 let mut services = Vec::new();
1399 let mut seen = BTreeMap::new();
1400 for service_field in self.fields(mapping) {
1401 self.record_duplicate(&mut seen, &service_field);
1402 let Some(service_mapping) = service_field.value.as_ref().and_then(YamlNode::as_mapping) else {
1403 self.expected(EXPECTED_MAPPING, &service_field, "service definition must be a mapping");
1404 continue;
1405 };
1406 services.push(self.parse_service(&service_field, service_mapping));
1407 }
1408 services
1409 }
1410
1411 fn parse_service(&mut self, field: &ParsedField, mapping: &Mapping) -> Service {
1412 let mut service = Service::new(field.name.clone(), field.span);
1413 let mut seen = BTreeMap::new();
1414 for service_field in self.fields(mapping) {
1415 let duplicate = self.record_duplicate(&mut seen, &service_field);
1416 match service_field.name.value.as_str() {
1417 "hostname" if !duplicate => service.hostname = self.parse_hostname(&service_field),
1418 "container_name" if !duplicate => {
1419 service.container_name = self.parse_string(&service_field, "container name");
1420 }
1421 "image" if !duplicate => service.image = self.parse_image(&service_field),
1422 "entrypoint" if !duplicate => service.entrypoint = self.parse_entrypoint(&service_field),
1423 "command" if !duplicate => service.command = self.parse_command(&service_field),
1424 "init" if !duplicate => service.init = self.parse_boolean(&service_field, "service init"),
1425 "environment" if !duplicate => service.environment = self.parse_environment(&service_field),
1426 "env_file" if !duplicate => {
1427 service.environment_files = self.parse_environment_files(&service_field);
1428 }
1429 "labels" if !duplicate => service.labels = self.parse_labels(&service_field),
1430 "annotations" if !duplicate => service.annotations = self.parse_annotations(&service_field),
1431 "extra_hosts" if !duplicate => service.extra_hosts = self.parse_extra_hosts(&service_field),
1432 "user" if !duplicate => {
1433 service.user = self.parse_string(&service_field, "service user").map(UserSpec::parse);
1434 }
1435 "userns_mode" if !duplicate => {
1436 service.userns_mode = self
1437 .parse_string(&service_field, "service user namespace mode")
1438 .map(UserNamespaceMode::parse);
1439 }
1440 "group_add" if !duplicate => {
1441 service.group_add = self.parse_string_sequence(&service_field, "service supplementary groups");
1442 }
1443 "cap_add" if !duplicate => service.cap_add = self.parse_cap_add(&service_field),
1444 "cap_drop" if !duplicate => service.cap_drop = self.parse_cap_drop(&service_field),
1445 "devices" if !duplicate => service.devices = self.parse_devices(&service_field),
1446 "dns" if !duplicate => service.dns = self.parse_dns(&service_field),
1447 "dns_opt" if !duplicate => service.dns_options = self.parse_dns_options(&service_field),
1448 "dns_search" if !duplicate => service.dns_search = self.parse_dns_search(&service_field),
1449 "expose" if !duplicate => service.expose = self.parse_expose(&service_field),
1450 "security_opt" if !duplicate => service.security_options = self.parse_security_options(&service_field),
1451 "working_dir" if !duplicate => {
1452 service.working_dir = self.parse_string(&service_field, "service working directory");
1453 }
1454 "read_only" if !duplicate => {
1455 service.read_only = self.parse_boolean(&service_field, "service read_only");
1456 }
1457 "pids_limit" if !duplicate => service.pids_limit = self.parse_pids_limit(&service_field),
1458 "shm_size" if !duplicate => service.shm_size = self.parse_shm_size(&service_field),
1459 "mem_limit" if !duplicate => service.mem_limit = self.parse_mem_limit(&service_field),
1460 "tmpfs" if !duplicate => service.tmpfs = self.parse_tmpfs(&service_field),
1461 "sysctls" if !duplicate => service.sysctls = self.parse_sysctls(&service_field),
1462 "pull_policy" if !duplicate => service.pull_policy = self.parse_pull_policy(&service_field),
1463 "restart" if !duplicate => service.restart = self.parse_restart_policy(&service_field),
1464 "stop_signal" if !duplicate => {
1465 service.stop_signal = self.parse_string(&service_field, "service stop signal");
1466 }
1467 "stop_grace_period" if !duplicate => {
1468 service.stop_grace_period = self.parse_stop_grace_period(&service_field);
1469 }
1470 "ulimits" if !duplicate => {
1471 service.ulimits = self.parse_ulimits(&service_field);
1472 }
1473 "depends_on" if !duplicate => {
1474 service.depends_on = self.parse_depends_on(&service_field);
1475 }
1476 "healthcheck" if !duplicate => {
1477 service.healthcheck = self.parse_healthcheck(&service_field);
1478 }
1479 "build" if !duplicate => {
1480 service.build = self.parse_build(&service_field);
1481 }
1482 "deploy" if !duplicate => {
1483 service.deploy = self.parse_deploy(&service_field);
1484 }
1485 "ports" if !duplicate => {
1486 service.ports = self.parse_service_ports(&service_field);
1487 }
1488 "volumes" if !duplicate => {
1489 service.volumes = self.parse_service_volumes(&service_field);
1490 }
1491 "networks" if !duplicate => {
1492 service.networks = self.parse_service_networks(&service_field);
1493 }
1494 "profiles" if !duplicate => {
1495 service.profiles = self.parse_string_sequence(&service_field, "service profiles");
1496 }
1497 "configs" if !duplicate => {
1498 service.configs = self.parse_config_grants(&service_field);
1499 }
1500 "secrets" if !duplicate => {
1501 service.secrets = self.parse_secret_grants(&service_field);
1502 }
1503 name if name.starts_with("x-") => {
1504 service.extension_fields.push(service_field.reference());
1505 }
1506 _ if duplicate => {}
1507 _ => service.unknown_fields.push(service_field.reference()),
1508 }
1509 }
1510 service
1511 }
1512
1513 fn parse_hostname(&mut self, field: &ParsedField) -> Option<Hostname> {
1514 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
1515 self.expected(HOSTNAME_EXPECTED_STRING, field, "hostname must be a YAML string scalar");
1516 return None;
1517 };
1518 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
1519 self.expected(HOSTNAME_EXPECTED_STRING, field, "hostname must be a YAML string scalar");
1520 return None;
1521 }
1522 let span = span_from_position(self.source_id, scalar.byte_range());
1523 let hostname = Hostname::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
1524 if hostname.kind() == &HostnameKind::Invalid {
1525 self.diagnostics.push(
1526 Diagnostic::new(
1527 HOSTNAME_INVALID,
1528 Severity::Error,
1529 "hostname must be an ASCII RFC-1123 name of 1 to 253 characters with dot-separated labels of 1 to 63 alphanumeric or hyphen characters",
1530 )
1531 .with_label(DiagnosticLabel::primary(span, "invalid service hostname"))
1532 .with_note("each label must start and end with an ASCII letter or digit"),
1533 );
1534 }
1535 Some(hostname)
1536 }
1537
1538 fn parse_image(&mut self, field: &ParsedField) -> Option<Located<ImageReference>> {
1539 self.parse_string(field, "service image")
1540 .map(|value| Located::new(ImageReference::parse(value.value), value.span))
1541 }
1542
1543 fn parse_cap_drop(&mut self, field: &ParsedField) -> Option<CapabilityDrop> {
1544 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1545 self.expected(
1546 CAP_DROP_EXPECTED_SEQUENCE,
1547 field,
1548 "cap_drop must be a sequence of string scalars",
1549 );
1550 return None;
1551 };
1552 let span = span_from_position(self.source_id, sequence.byte_range());
1553 let mut items = Vec::new();
1554 let mut seen = BTreeMap::new();
1555 for node in sequence.values() {
1556 let YamlNode::Scalar(scalar) = node else {
1557 self.unsupported_sequence_item(
1558 CAP_DROP_EXPECTED_STRING,
1559 &node,
1560 field.span,
1561 "cap_drop entries must be string scalars",
1562 );
1563 continue;
1564 };
1565 let scalar_type = ScalarValue::from_scalar(&scalar).scalar_type();
1566 if !matches!(
1567 scalar_type,
1568 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1569 ) {
1570 self.unsupported_sequence_item(
1571 CAP_DROP_EXPECTED_STRING,
1572 &YamlNode::Scalar(scalar),
1573 field.span,
1574 "cap_drop entries must be string scalars",
1575 );
1576 continue;
1577 }
1578 let item_span = span_from_position(self.source_id, scalar.byte_range());
1579 let value = scalar_string_from_source(&self.source, &scalar);
1580 if let Some(first) = seen.get(&value) {
1581 self.diagnostics.push(
1582 Diagnostic::new(
1583 CAP_DROP_DUPLICATE_ITEM,
1584 Severity::Error,
1585 "cap_drop entries must be unique exact strings",
1586 )
1587 .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
1588 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
1589 );
1590 } else {
1591 seen.insert(value.clone(), item_span);
1592 }
1593 items.push(CapabilityDropItem::new(Located::new(value, item_span)));
1594 }
1595 Some(CapabilityDrop::new(span, items))
1596 }
1597
1598 fn parse_cap_add(&mut self, field: &ParsedField) -> Option<CapabilityAdd> {
1599 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1600 self.expected(
1601 CAP_ADD_EXPECTED_SEQUENCE,
1602 field,
1603 "cap_add must be a sequence of string scalars",
1604 );
1605 return None;
1606 };
1607 let span = span_from_position(self.source_id, sequence.byte_range());
1608 let mut items = Vec::new();
1609 let mut seen = BTreeMap::new();
1610 for node in sequence.values() {
1611 let YamlNode::Scalar(scalar) = node else {
1612 self.unsupported_sequence_item(
1613 CAP_ADD_EXPECTED_STRING,
1614 &node,
1615 field.span,
1616 "cap_add entries must be string scalars",
1617 );
1618 continue;
1619 };
1620 let scalar_type = ScalarValue::from_scalar(&scalar).scalar_type();
1621 if !matches!(
1622 scalar_type,
1623 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1624 ) {
1625 self.unsupported_sequence_item(
1626 CAP_ADD_EXPECTED_STRING,
1627 &YamlNode::Scalar(scalar),
1628 field.span,
1629 "cap_add entries must be string scalars",
1630 );
1631 continue;
1632 }
1633 let item_span = span_from_position(self.source_id, scalar.byte_range());
1634 let value = scalar_string_from_source(&self.source, &scalar);
1635 if let Some(first) = seen.get(&value) {
1636 self.diagnostics.push(
1637 Diagnostic::new(
1638 CAP_ADD_DUPLICATE_ITEM,
1639 Severity::Error,
1640 "cap_add entries must be unique exact strings",
1641 )
1642 .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
1643 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
1644 );
1645 } else {
1646 seen.insert(value.clone(), item_span);
1647 }
1648 items.push(CapabilityAddItem::new(Located::new(value, item_span)));
1649 }
1650 Some(CapabilityAdd::new(span, items))
1651 }
1652
1653 fn parse_devices(&mut self, field: &ParsedField) -> Option<Devices> {
1654 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1655 self.expected(
1656 DEVICES_EXPECTED_SEQUENCE,
1657 field,
1658 "service devices must be a sequence of string scalars or mappings",
1659 );
1660 return None;
1661 };
1662 let span = span_from_position(self.source_id, sequence.byte_range());
1663 let mut devices = Vec::new();
1664 for node in sequence.values() {
1665 match node {
1666 YamlNode::Scalar(scalar)
1667 if matches!(
1668 ScalarValue::from_scalar(&scalar).scalar_type(),
1669 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1670 ) =>
1671 {
1672 let item_span = span_from_position(self.source_id, scalar.byte_range());
1673 let raw = Located::new(scalar_string_from_source(&self.source, &scalar), item_span);
1674 devices.push(Device::Short(ShortDevice::new(raw)));
1675 }
1676 YamlNode::Mapping(mapping) => devices.push(Device::Long(self.parse_long_device(&mapping))),
1677 other => self.unsupported_sequence_item(
1678 DEVICE_EXPECTED_FORM,
1679 &other,
1680 field.span,
1681 "service device must use string short syntax or mapping long syntax",
1682 ),
1683 }
1684 }
1685 Some(Devices::new(span, devices))
1686 }
1687
1688 fn parse_long_device(&mut self, mapping: &Mapping) -> LongDevice {
1689 let span = span_from_position(self.source_id, mapping.byte_range());
1690 let mut device = LongDevice::new(span);
1691 let mut seen = BTreeMap::new();
1692 for field in self.fields(mapping) {
1693 let duplicate = self.record_duplicate(&mut seen, &field);
1694 match field.name.value.as_str() {
1695 "source" if !duplicate => self
1696 .parse_device_string(&field, "device source")
1697 .into_iter()
1698 .for_each(|value| device.set_source(value)),
1699 "target" if !duplicate => self
1700 .parse_device_string(&field, "device target")
1701 .into_iter()
1702 .for_each(|value| device.set_target(value)),
1703 "permissions" if !duplicate => self
1704 .parse_device_string(&field, "device permissions")
1705 .into_iter()
1706 .for_each(|value| device.set_permissions(value)),
1707 name if name.starts_with("x-") => device.push_extension(field.reference()),
1708 _ if duplicate => {}
1709 _ => device.push_unknown(field.reference()),
1710 }
1711 }
1712 if device.source().is_none() {
1713 self.missing(
1714 DEVICE_MISSING_SOURCE,
1715 span,
1716 "long service device is missing required string `source`",
1717 );
1718 }
1719 device
1720 }
1721
1722 fn parse_device_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
1723 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
1724 self.expected(
1725 DEVICE_EXPECTED_STRING,
1726 field,
1727 format!("{description} must be a string scalar"),
1728 );
1729 return None;
1730 };
1731 if !matches!(
1732 ScalarValue::from_scalar(scalar).scalar_type(),
1733 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1734 ) {
1735 self.expected(
1736 DEVICE_EXPECTED_STRING,
1737 field,
1738 format!("{description} must be a string scalar"),
1739 );
1740 return None;
1741 }
1742 Some(Located::new(
1743 scalar_string_from_source(&self.source, scalar),
1744 span_from_position(self.source_id, scalar.byte_range()),
1745 ))
1746 }
1747
1748 fn parse_dns(&mut self, field: &ParsedField) -> Option<Dns> {
1749 let value = field.value.as_ref()?;
1750 if let Some(scalar) = value.as_scalar() {
1751 if !matches!(
1752 ScalarValue::from_scalar(scalar).scalar_type(),
1753 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1754 ) {
1755 self.expected(
1756 DNS_EXPECTED_FORM,
1757 field,
1758 "dns must be a string scalar or a sequence of string scalars",
1759 );
1760 return None;
1761 }
1762 let span = span_from_position(self.source_id, scalar.byte_range());
1763 return Some(Dns::new(
1764 span,
1765 DnsForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
1766 ));
1767 }
1768
1769 let Some(sequence) = value.as_sequence() else {
1770 self.expected(
1771 DNS_EXPECTED_FORM,
1772 field,
1773 "dns must be a string scalar or a sequence of string scalars",
1774 );
1775 return None;
1776 };
1777 let span = span_from_position(self.source_id, sequence.byte_range());
1778 let mut items = Vec::new();
1779 for node in sequence.values() {
1780 let YamlNode::Scalar(scalar) = node else {
1781 self.unsupported_sequence_item(
1782 DNS_EXPECTED_STRING,
1783 &node,
1784 field.span,
1785 "dns entries must be string scalars",
1786 );
1787 continue;
1788 };
1789 if !matches!(
1790 ScalarValue::from_scalar(&scalar).scalar_type(),
1791 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1792 ) {
1793 self.unsupported_sequence_item(
1794 DNS_EXPECTED_STRING,
1795 &YamlNode::Scalar(scalar),
1796 field.span,
1797 "dns entries must be string scalars",
1798 );
1799 continue;
1800 }
1801 let item_span = span_from_position(self.source_id, scalar.byte_range());
1802 items.push(Located::new(
1803 scalar_string_from_source(&self.source, &scalar),
1804 item_span,
1805 ));
1806 }
1807 Some(Dns::new(span, DnsForm::List(items)))
1808 }
1809
1810 fn parse_dns_options(&mut self, field: &ParsedField) -> Option<DnsOptions> {
1811 let value = field.value.as_ref()?;
1812 let Some(sequence) = value.as_sequence() else {
1813 self.expected(
1814 DNS_OPT_EXPECTED_SEQUENCE,
1815 field,
1816 "dns_opt must be a sequence of string scalars",
1817 );
1818 return None;
1819 };
1820 let span = span_from_position(self.source_id, sequence.byte_range());
1821 let mut items = Vec::new();
1822 let mut seen = BTreeSet::new();
1823 for node in sequence.values() {
1824 let YamlNode::Scalar(scalar) = node else {
1825 self.unsupported_sequence_item(
1826 DNS_OPT_EXPECTED_STRING,
1827 &node,
1828 field.span,
1829 "dns_opt entries must be string scalars",
1830 );
1831 continue;
1832 };
1833 if !matches!(
1834 ScalarValue::from_scalar(&scalar).scalar_type(),
1835 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1836 ) {
1837 self.unsupported_sequence_item(
1838 DNS_OPT_EXPECTED_STRING,
1839 &YamlNode::Scalar(scalar),
1840 field.span,
1841 "dns_opt entries must be string scalars",
1842 );
1843 continue;
1844 }
1845 let item_span = span_from_position(self.source_id, scalar.byte_range());
1846 let option = scalar_string_from_source(&self.source, &scalar);
1847 if !seen.insert(option.clone()) {
1848 self.diagnostics.push(
1849 Diagnostic::new(
1850 DNS_OPT_DUPLICATE_ITEM,
1851 Severity::Warning,
1852 "dns_opt entries must be unique exact strings",
1853 )
1854 .with_label(DiagnosticLabel::primary(item_span, "duplicate DNS option retained")),
1855 );
1856 }
1857 items.push(Located::new(option, item_span));
1858 }
1859 Some(DnsOptions::new(span, items))
1860 }
1861
1862 fn parse_dns_search(&mut self, field: &ParsedField) -> Option<DnsSearch> {
1863 let value = field.value.as_ref()?;
1864 if let Some(scalar) = value.as_scalar() {
1865 if !matches!(
1866 ScalarValue::from_scalar(scalar).scalar_type(),
1867 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1868 ) {
1869 self.expected(
1870 DNS_SEARCH_EXPECTED_FORM,
1871 field,
1872 "dns_search must be a string scalar or a sequence of string scalars",
1873 );
1874 return None;
1875 }
1876 let span = span_from_position(self.source_id, scalar.byte_range());
1877 return Some(DnsSearch::new(
1878 span,
1879 DnsSearchForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
1880 ));
1881 }
1882
1883 let Some(sequence) = value.as_sequence() else {
1884 self.expected(
1885 DNS_SEARCH_EXPECTED_FORM,
1886 field,
1887 "dns_search must be a string scalar or a sequence of string scalars",
1888 );
1889 return None;
1890 };
1891 let span = span_from_position(self.source_id, sequence.byte_range());
1892 let mut items = Vec::new();
1893 let mut seen = BTreeSet::new();
1894 for node in sequence.values() {
1895 let YamlNode::Scalar(scalar) = node else {
1896 self.unsupported_sequence_item(
1897 DNS_SEARCH_EXPECTED_STRING,
1898 &node,
1899 field.span,
1900 "dns_search entries must be string scalars",
1901 );
1902 continue;
1903 };
1904 if !matches!(
1905 ScalarValue::from_scalar(&scalar).scalar_type(),
1906 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1907 ) {
1908 self.unsupported_sequence_item(
1909 DNS_SEARCH_EXPECTED_STRING,
1910 &YamlNode::Scalar(scalar),
1911 field.span,
1912 "dns_search entries must be string scalars",
1913 );
1914 continue;
1915 }
1916 let item_span = span_from_position(self.source_id, scalar.byte_range());
1917 let search = scalar_string_from_source(&self.source, &scalar);
1918 if !seen.insert(search.clone()) {
1919 self.diagnostics.push(
1920 Diagnostic::new(
1921 DNS_SEARCH_DUPLICATE_ITEM,
1922 Severity::Warning,
1923 "dns_search schema entries are unique, but duplicate merge behavior is ambiguous",
1924 )
1925 .with_label(DiagnosticLabel::primary(
1926 item_span,
1927 "duplicate DNS search domain retained",
1928 )),
1929 );
1930 }
1931 items.push(Located::new(search, item_span));
1932 }
1933 Some(DnsSearch::new(span, DnsSearchForm::List(items)))
1934 }
1935
1936 fn parse_expose(&mut self, field: &ParsedField) -> Option<Expose> {
1937 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1938 self.expected(
1939 EXPOSE_EXPECTED_SEQUENCE,
1940 field,
1941 "expose must be a sequence of string or number scalars",
1942 );
1943 return None;
1944 };
1945 let span = span_from_position(self.source_id, sequence.byte_range());
1946 let mut items = Vec::new();
1947 let mut seen = Vec::new();
1948 for node in sequence.values() {
1949 let YamlNode::Scalar(scalar) = node else {
1950 self.unsupported_sequence_item(
1951 EXPOSE_EXPECTED_SCALAR,
1952 &node,
1953 field.span,
1954 "expose entries must be string or number scalars",
1955 );
1956 continue;
1957 };
1958 let scalar_kind = match ScalarValue::from_scalar(&scalar).scalar_type() {
1959 ScalarType::Integer | ScalarType::Float => ExposeScalarKind::Number,
1960 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => ExposeScalarKind::String,
1961 ScalarType::Null | ScalarType::Boolean => {
1962 self.unsupported_sequence_item(
1963 EXPOSE_EXPECTED_SCALAR,
1964 &YamlNode::Scalar(scalar),
1965 field.span,
1966 "expose entries must be string or number scalars",
1967 );
1968 continue;
1969 }
1970 };
1971 let item_span = span_from_position(self.source_id, scalar.byte_range());
1972 let raw = scalar_string_from_source(&self.source, &scalar);
1973 if seen.contains(&(scalar_kind, raw.clone())) {
1974 self.diagnostics.push(
1975 Diagnostic::new(
1976 EXPOSE_DUPLICATE_ITEM,
1977 Severity::Warning,
1978 "expose entries must be unique by exact scalar identity",
1979 )
1980 .with_label(DiagnosticLabel::primary(
1981 item_span,
1982 "duplicate exposed-port item retained",
1983 )),
1984 );
1985 } else {
1986 seen.push((scalar_kind, raw.clone()));
1987 }
1988 let item = ExposeItem::parse(Located::new(raw, item_span), scalar_kind);
1989 self.diagnose_expose_item(&item);
1990 items.push(item);
1991 }
1992 Some(Expose::new(span, items))
1993 }
1994
1995 fn diagnose_expose_item(&mut self, item: &ExposeItem) {
1996 match item.kind() {
1997 ExposeItemKind::Documented { .. } | ExposeItemKind::Expression => {}
1998 ExposeItemKind::Sctp { .. } | ExposeItemKind::UnknownProtocol { .. } => {
1999 self.diagnostics.push(
2000 Diagnostic::new(
2001 EXPOSE_PROVIDER_DEPENDENT,
2002 Severity::Warning,
2003 "expose protocol is outside the documented portable `tcp` and `udp` set",
2004 )
2005 .with_label(DiagnosticLabel::primary(
2006 item.span(),
2007 "provider-dependent exposed-port protocol retained",
2008 ))
2009 .with_note("ComposeLens does not normalize or reject the raw protocol spelling"),
2010 );
2011 }
2012 ExposeItemKind::Malformed => {
2013 self.diagnostics.push(
2014 Diagnostic::new(
2015 EXPOSE_INVALID_ITEM,
2016 Severity::Error,
2017 "expose item must be a decimal port or range with an optional protocol",
2018 )
2019 .with_label(DiagnosticLabel::primary(
2020 item.span(),
2021 "malformed exposed-port item retained",
2022 ))
2023 .with_note("use `PORT`, `START-END`, `PORT/tcp`, or `PORT/udp` for documented portable syntax"),
2024 );
2025 }
2026 }
2027 }
2028
2029 fn parse_security_options(&mut self, field: &ParsedField) -> Option<SecurityOptions> {
2030 let value = field.value.as_ref()?;
2031 let Some(sequence) = value.as_sequence() else {
2032 self.expected(
2033 SECURITY_OPT_EXPECTED_SEQUENCE,
2034 field,
2035 "security_opt must be a sequence of string scalars",
2036 );
2037 return None;
2038 };
2039 let span = span_from_position(self.source_id, sequence.byte_range());
2040 let mut items = Vec::new();
2041 let mut candidates = SecurityOptionCandidateCounts::default();
2042 for node in sequence.values() {
2043 let YamlNode::Scalar(scalar) = node else {
2044 self.unsupported_sequence_item(
2045 SECURITY_OPT_EXPECTED_STRING,
2046 &node,
2047 field.span,
2048 "security_opt entries must be string scalars",
2049 );
2050 continue;
2051 };
2052 if !matches!(
2053 ScalarValue::from_scalar(&scalar).scalar_type(),
2054 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2055 ) {
2056 self.unsupported_sequence_item(
2057 SECURITY_OPT_EXPECTED_STRING,
2058 &YamlNode::Scalar(scalar),
2059 field.span,
2060 "security_opt entries must be string scalars",
2061 );
2062 continue;
2063 }
2064 let item_span = span_from_position(self.source_id, scalar.byte_range());
2065 let raw = scalar_string_from_source(&self.source, &scalar);
2066 let item = SecurityOptionItem::parse(Located::new(raw, item_span));
2067 self.diagnose_security_option_item(item.kind(), item_span, &mut candidates);
2068 items.push(item);
2069 }
2070 Some(SecurityOptions::new(span, items))
2071 }
2072
2073 fn diagnose_security_option_item(
2074 &mut self,
2075 kind: &SecurityOptionKind,
2076 span: SourceSpan,
2077 candidates: &mut SecurityOptionCandidateCounts,
2078 ) {
2079 let diagnostic = match kind {
2080 SecurityOptionKind::AppArmor { .. } => {
2081 candidates.apparmor += 1;
2082 (candidates.apparmor > 1).then(|| {
2083 Diagnostic::new(
2084 SECURITY_OPT_APPARMOR_CONFLICT,
2085 Severity::Warning,
2086 "multiple AppArmor candidates are retained; a consumer must resolve the conflict explicitly",
2087 )
2088 .with_label(DiagnosticLabel::primary(span, "additional AppArmor candidate retained"))
2089 })
2090 }
2091 SecurityOptionKind::AppArmorNearMiss => Some(
2092 Diagnostic::new(
2093 SECURITY_OPT_APPARMOR_NEAR_MISS,
2094 Severity::Warning,
2095 "AppArmor candidates require exact lowercase `apparmor=<profile>` spelling without whitespace",
2096 )
2097 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2098 ),
2099 SecurityOptionKind::Seccomp { .. } => {
2100 candidates.seccomp += 1;
2101 (candidates.seccomp > 1).then(|| {
2102 Diagnostic::new(
2103 SECURITY_OPT_SECCOMP_CONFLICT,
2104 Severity::Warning,
2105 "multiple seccomp candidates are retained; a consumer must resolve the conflict explicitly",
2106 )
2107 .with_label(DiagnosticLabel::primary(span, "additional seccomp candidate retained"))
2108 })
2109 }
2110 SecurityOptionKind::SeccompNearMiss => Some(
2111 Diagnostic::new(
2112 SECURITY_OPT_SECCOMP_NEAR_MISS,
2113 Severity::Warning,
2114 "seccomp candidates require exact lowercase `seccomp=<profile>` spelling without whitespace",
2115 )
2116 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2117 ),
2118 SecurityOptionKind::NoNewPrivileges { .. } => {
2119 candidates.no_new_privileges += 1;
2120 (candidates.no_new_privileges > 1).then(|| {
2121 Diagnostic::new(
2122 SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT,
2123 Severity::Warning,
2124 "multiple no-new-privileges candidates are retained; a consumer must resolve the conflict explicitly",
2125 )
2126 .with_label(DiagnosticLabel::primary(
2127 span,
2128 "additional no-new-privileges candidate retained",
2129 ))
2130 })
2131 }
2132 SecurityOptionKind::NoNewPrivilegesNearMiss => Some(
2133 Diagnostic::new(
2134 SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS,
2135 Severity::Warning,
2136 "no-new-privileges candidates require exact lowercase `no-new-privileges:true` or `no-new-privileges:false` spelling without whitespace",
2137 )
2138 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2139 ),
2140 SecurityOptionKind::Mask { .. }
2141 | SecurityOptionKind::MaskNearMiss
2142 | SecurityOptionKind::Unmask { .. }
2143 | SecurityOptionKind::UnmaskNearMiss => security_path_option_diagnostic(kind, span),
2144 SecurityOptionKind::SecurityLabelDisable { .. }
2145 | SecurityOptionKind::SecurityLabelDisableNearMiss
2146 | SecurityOptionKind::SecurityLabelFileType { .. }
2147 | SecurityOptionKind::SecurityLabelFileTypeNearMiss
2148 | SecurityOptionKind::SecurityLabelLevel { .. }
2149 | SecurityOptionKind::SecurityLabelLevelNearMiss
2150 | SecurityOptionKind::SecurityLabelNested { .. }
2151 | SecurityOptionKind::SecurityLabelNestedNearMiss
2152 | SecurityOptionKind::SecurityLabelType { .. }
2153 | SecurityOptionKind::SecurityLabelTypeNearMiss => {
2154 authored_security_label_diagnostic(kind, span, candidates)
2155 }
2156 SecurityOptionKind::Empty => Some(
2157 Diagnostic::new(
2158 SECURITY_OPT_EMPTY_ITEM,
2159 Severity::Error,
2160 "security_opt entries must not be empty strings",
2161 )
2162 .with_label(DiagnosticLabel::primary(span, "empty security option retained")),
2163 ),
2164 SecurityOptionKind::Expression | SecurityOptionKind::Other => None,
2165 };
2166 if let Some(diagnostic) = diagnostic {
2167 self.diagnostics.push(diagnostic);
2168 }
2169 }
2170
2171 fn parse_tmpfs(&mut self, field: &ParsedField) -> Option<Tmpfs> {
2172 let value = field.value.as_ref()?;
2173 if let Some(scalar) = value.as_scalar() {
2174 if !matches!(
2175 ScalarValue::from_scalar(scalar).scalar_type(),
2176 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2177 ) {
2178 self.expected(
2179 TMPFS_EXPECTED_FORM,
2180 field,
2181 "tmpfs must be a string scalar or a sequence of string scalars",
2182 );
2183 return None;
2184 }
2185 let span = span_from_position(self.source_id, scalar.byte_range());
2186 let item = TmpfsItem::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
2187 self.diagnose_tmpfs_item(&item);
2188 return Some(Tmpfs::new(span, TmpfsForm::Scalar(item)));
2189 }
2190
2191 let Some(sequence) = value.as_sequence() else {
2192 self.expected(
2193 TMPFS_EXPECTED_FORM,
2194 field,
2195 "tmpfs must be a string scalar or a sequence of string scalars",
2196 );
2197 return None;
2198 };
2199 let span = span_from_position(self.source_id, sequence.byte_range());
2200 let mut items = Vec::new();
2201 for node in sequence.values() {
2202 let YamlNode::Scalar(scalar) = node else {
2203 self.unsupported_sequence_item(
2204 TMPFS_EXPECTED_STRING,
2205 &node,
2206 field.span,
2207 "tmpfs entries must be string scalars",
2208 );
2209 continue;
2210 };
2211 if !matches!(
2212 ScalarValue::from_scalar(&scalar).scalar_type(),
2213 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2214 ) {
2215 self.unsupported_sequence_item(
2216 TMPFS_EXPECTED_STRING,
2217 &YamlNode::Scalar(scalar),
2218 field.span,
2219 "tmpfs entries must be string scalars",
2220 );
2221 continue;
2222 }
2223 let item_span = span_from_position(self.source_id, scalar.byte_range());
2224 let raw = scalar_string_from_source(&self.source, &scalar);
2225 let item = TmpfsItem::parse(Located::new(raw, item_span));
2226 self.diagnose_tmpfs_item(&item);
2227 items.push(item);
2228 }
2229 Some(Tmpfs::new(span, TmpfsForm::List(items)))
2230 }
2231
2232 fn diagnose_tmpfs_item(&mut self, item: &TmpfsItem) {
2233 if item.kind() != TmpfsItemKind::ProviderDependent {
2234 return;
2235 }
2236 self.diagnostics.push(
2237 Diagnostic::new(
2238 TMPFS_PROVIDER_DEPENDENT,
2239 Severity::Warning,
2240 "tmpfs item is malformed or uses provider- or target-specific options",
2241 )
2242 .with_label(DiagnosticLabel::primary(
2243 item.span(),
2244 "provider-dependent temporary-filesystem item",
2245 ))
2246 .with_note("use a non-empty path with only non-empty `mode`, `uid`, or `gid` assignments for documented portable syntax"),
2247 );
2248 }
2249
2250 fn parse_sysctls(&mut self, field: &ParsedField) -> Option<Sysctls> {
2251 match field.value.as_ref() {
2252 Some(YamlNode::Mapping(mapping)) => {
2253 let span = span_from_position(self.source_id, mapping.byte_range());
2254 let mut entries = Vec::new();
2255 let mut seen = BTreeMap::new();
2256 for entry in self.fields(mapping) {
2257 if self.record_duplicate(&mut seen, &entry) {
2258 continue;
2259 }
2260 if entry.name.value.is_empty() {
2261 self.diagnostics.push(
2262 Diagnostic::new(
2263 SYSCTLS_EMPTY_KEY,
2264 Severity::Error,
2265 "sysctls mapping keys must not be empty",
2266 )
2267 .with_label(DiagnosticLabel::primary(entry.name.span, "empty sysctl name")),
2268 );
2269 continue;
2270 }
2271 if entry.value.as_ref().is_some_and(|value| value.as_scalar().is_none()) {
2272 self.diagnostics.push(
2273 Diagnostic::new(
2274 SYSCTLS_EXPECTED_SCALAR,
2275 Severity::Error,
2276 "sysctls mapping values must be scalar strings, numbers, booleans, or null",
2277 )
2278 .with_label(DiagnosticLabel::primary(
2279 entry.value_span.unwrap_or(entry.span),
2280 "non-scalar sysctl value",
2281 )),
2282 );
2283 continue;
2284 }
2285 let Some(value) = self.parse_compose_scalar(&entry, "sysctls mapping values must be scalars")
2286 else {
2287 continue;
2288 };
2289 entries.push(KeyValueEntry::new(entry.name, value, entry.span));
2290 }
2291 Some(Sysctls::new(span, SysctlsForm::Map(entries)))
2292 }
2293 Some(YamlNode::Sequence(sequence)) => {
2294 let span = span_from_position(self.source_id, sequence.byte_range());
2295 let mut items = Vec::new();
2296 let mut seen = BTreeMap::new();
2297 for node in sequence.values() {
2298 let YamlNode::Scalar(scalar) = node else {
2299 self.unsupported_sequence_item(
2300 SYSCTLS_EXPECTED_STRING,
2301 &node,
2302 field.span,
2303 "sysctls list entries must be YAML string scalars",
2304 );
2305 continue;
2306 };
2307 if !matches!(
2308 ScalarValue::from_scalar(&scalar).scalar_type(),
2309 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2310 ) {
2311 self.unsupported_sequence_item(
2312 SYSCTLS_EXPECTED_STRING,
2313 &YamlNode::Scalar(scalar),
2314 field.span,
2315 "sysctls list entries must be YAML string scalars",
2316 );
2317 continue;
2318 }
2319 let item_span = span_from_position(self.source_id, scalar.byte_range());
2320 let value = scalar_string_from_source(&self.source, &scalar);
2321 if let Some(first) = seen.get(&value) {
2322 self.diagnostics.push(
2323 Diagnostic::new(
2324 SYSCTLS_DUPLICATE_ITEM,
2325 Severity::Error,
2326 "sysctls list entries must be unique exact strings",
2327 )
2328 .with_label(DiagnosticLabel::primary(item_span, "duplicate sysctl string"))
2329 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
2330 );
2331 } else {
2332 seen.insert(value.clone(), item_span);
2333 }
2334 items.push(Located::new(value, item_span));
2335 }
2336 Some(Sysctls::new(span, SysctlsForm::List(items)))
2337 }
2338 _ => {
2339 self.expected(
2340 SYSCTLS_EXPECTED_FORM,
2341 field,
2342 "sysctls must be a mapping or a sequence of string scalars",
2343 );
2344 None
2345 }
2346 }
2347 }
2348
2349 fn parse_restart_policy(&mut self, field: &ParsedField) -> Option<RestartPolicy> {
2350 let value = self.parse_string(field, "service restart policy")?;
2351 let policy = RestartPolicy::parse(value);
2352 if !policy.is_valid() {
2353 self.diagnostics.push(
2354 Diagnostic::new(
2355 RESTART_INVALID_POLICY,
2356 Severity::Error,
2357 "restart must be `no`, `always`, `on-failure[:max-retries]`, `unless-stopped`, or interpolation",
2358 )
2359 .with_label(DiagnosticLabel::primary(
2360 policy.raw().span(),
2361 "invalid service restart policy",
2362 )),
2363 );
2364 }
2365 Some(policy)
2366 }
2367
2368 fn parse_pids_limit(&mut self, field: &ParsedField) -> Option<PidsLimit> {
2369 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
2370 self.expected(
2371 PIDS_LIMIT_EXPECTED_VALUE,
2372 field,
2373 "pids_limit must be a number or string scalar",
2374 );
2375 return None;
2376 };
2377 if matches!(
2378 ScalarValue::from_scalar(scalar).scalar_type(),
2379 ScalarType::Boolean | ScalarType::Null
2380 ) {
2381 self.expected(
2382 PIDS_LIMIT_EXPECTED_VALUE,
2383 field,
2384 "pids_limit must be a number or string scalar",
2385 );
2386 return None;
2387 }
2388 let span = span_from_position(self.source_id, scalar.byte_range());
2389 let limit = PidsLimit::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
2390 match limit.kind() {
2391 PidsLimitKind::Zero => self.diagnostics.push(
2392 Diagnostic::new(
2393 PIDS_LIMIT_AMBIGUOUS_ZERO,
2394 Severity::Warning,
2395 "pids_limit zero is preserved as an ambiguous and unportable native state",
2396 )
2397 .with_label(DiagnosticLabel::primary(span, "ambiguous zero PID limit")),
2398 ),
2399 PidsLimitKind::Other => self.diagnostics.push(
2400 Diagnostic::new(
2401 PIDS_LIMIT_INVALID,
2402 Severity::Error,
2403 "pids_limit must be `-1`, a positive integral decimal, or interpolation",
2404 )
2405 .with_label(DiagnosticLabel::primary(span, "unsupported service PID limit")),
2406 ),
2407 _ => {}
2408 }
2409 Some(limit)
2410 }
2411
2412 fn parse_shm_size(&mut self, field: &ParsedField) -> Option<ShmSize> {
2413 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
2414 self.expected(
2415 SHM_SIZE_EXPECTED_VALUE,
2416 field,
2417 "shm_size must be a YAML number or string scalar",
2418 );
2419 return None;
2420 };
2421 let scalar_kind = match ScalarValue::from_scalar(scalar).scalar_type() {
2422 ScalarType::Integer | ScalarType::Float => ShmSizeScalarKind::Number,
2423 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => ShmSizeScalarKind::String,
2424 ScalarType::Boolean | ScalarType::Null => {
2425 self.expected(
2426 SHM_SIZE_EXPECTED_VALUE,
2427 field,
2428 "shm_size must be a YAML number or string scalar",
2429 );
2430 return None;
2431 }
2432 };
2433 let span = span_from_position(self.source_id, scalar.byte_range());
2434 let size = ShmSize::parse(
2435 Located::new(scalar_string_from_source(&self.source, scalar), span),
2436 scalar_kind,
2437 );
2438 self.diagnose_shm_size(&size);
2439 Some(size)
2440 }
2441
2442 fn diagnose_shm_size(&mut self, size: &ShmSize) {
2443 let (code, message, label, note) = match size.kind() {
2444 ShmSizeKind::Zero { .. } => (
2445 SHM_SIZE_AMBIGUOUS_ZERO,
2446 "shm_size zero is preserved because Compose does not define its semantics",
2447 "ambiguous zero shared-memory size",
2448 "choose a positive size with an explicit documented lowercase unit",
2449 ),
2450 ShmSizeKind::ProviderDependentNumber => (
2451 SHM_SIZE_PROVIDER_DEPENDENT_NUMBER,
2452 "numeric shm_size is schema-accepted but lacks a documented explicit unit",
2453 "provider-dependent numeric shared-memory size",
2454 "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for portable intent",
2455 ),
2456 ShmSizeKind::ProviderDependentString => (
2457 SHM_SIZE_PROVIDER_DEPENDENT_STRING,
2458 "string shm_size is schema-accepted but falls outside the documented lowercase suffix family",
2459 "provider-dependent string shared-memory size",
2460 "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
2461 ),
2462 ShmSizeKind::Documented { .. } | ShmSizeKind::Expression => return,
2463 };
2464 self.diagnostics.push(
2465 Diagnostic::new(code, Severity::Warning, message)
2466 .with_label(DiagnosticLabel::primary(size.raw().span(), label))
2467 .with_note(note),
2468 );
2469 }
2470
2471 fn parse_mem_limit(&mut self, field: &ParsedField) -> Option<MemLimit> {
2472 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
2473 self.expected(
2474 MEM_LIMIT_EXPECTED_VALUE,
2475 field,
2476 "mem_limit must be a YAML number or string scalar",
2477 );
2478 return None;
2479 };
2480 let scalar_kind = match ScalarValue::from_scalar(scalar).scalar_type() {
2481 ScalarType::Integer | ScalarType::Float => MemLimitScalarKind::Number,
2482 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MemLimitScalarKind::String,
2483 ScalarType::Boolean | ScalarType::Null => {
2484 self.expected(
2485 MEM_LIMIT_EXPECTED_VALUE,
2486 field,
2487 "mem_limit must be a YAML number or string scalar",
2488 );
2489 return None;
2490 }
2491 };
2492 let span = span_from_position(self.source_id, scalar.byte_range());
2493 let limit = MemLimit::parse(
2494 Located::new(scalar_string_from_source(&self.source, scalar), span),
2495 scalar_kind,
2496 );
2497 self.diagnose_mem_limit(&limit);
2498 Some(limit)
2499 }
2500
2501 fn diagnose_mem_limit(&mut self, limit: &MemLimit) {
2502 let (code, message, label, note) = match limit.kind() {
2503 MemLimitKind::Zero { .. } => (
2504 MEM_LIMIT_AMBIGUOUS_ZERO,
2505 "mem_limit zero is preserved without inferring portable runtime behavior",
2506 "ambiguous zero memory limit",
2507 "choose a positive size with an explicit documented lowercase unit",
2508 ),
2509 MemLimitKind::SchemaNumber => (
2510 MEM_LIMIT_SCHEMA_NUMBER,
2511 "numeric mem_limit is schema-accepted but lacks a documented explicit unit",
2512 "schema-only numeric memory limit",
2513 "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for explicit intent",
2514 ),
2515 MemLimitKind::ProviderDependentString => (
2516 MEM_LIMIT_PROVIDER_DEPENDENT_STRING,
2517 "string mem_limit is schema-accepted but falls outside the documented lowercase suffix family",
2518 "provider-dependent string memory limit",
2519 "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
2520 ),
2521 MemLimitKind::Documented { .. } | MemLimitKind::Expression => return,
2522 };
2523 self.diagnostics.push(
2524 Diagnostic::new(code, Severity::Warning, message)
2525 .with_label(DiagnosticLabel::primary(limit.raw().span(), label))
2526 .with_note(note),
2527 );
2528 }
2529
2530 fn parse_pull_policy(&mut self, field: &ParsedField) -> Option<PullPolicy> {
2531 let value = self.parse_string(field, "service pull policy")?;
2532 let policy = PullPolicy::parse(value);
2533 if !policy.is_recognized() {
2534 self.diagnostics.push(
2535 Diagnostic::new(
2536 PULL_POLICY_INVALID,
2537 Severity::Error,
2538 "pull_policy must be a documented Compose policy, the retained `if_not_present` alias, schema-only `refresh`, an `every_` interval matching integer `w`, `d`, `h`, `m`, and `s` components, or interpolation",
2539 )
2540 .with_label(DiagnosticLabel::primary(
2541 policy.raw().span(),
2542 "invalid or provider-specific service pull policy",
2543 )),
2544 );
2545 }
2546 Some(policy)
2547 }
2548
2549 fn parse_stop_grace_period(&mut self, field: &ParsedField) -> Option<Located<StopGracePeriod>> {
2550 let value = self.parse_string(field, "service stop grace period")?;
2551 let period = StopGracePeriod::parse(value.value);
2552 if !period.is_valid() {
2553 self.diagnostics.push(
2554 Diagnostic::new(
2555 STOP_GRACE_PERIOD_INVALID,
2556 Severity::Error,
2557 "stop_grace_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
2558 )
2559 .with_label(DiagnosticLabel::primary(
2560 value.span,
2561 "invalid service stop grace period",
2562 )),
2563 );
2564 }
2565 Some(Located::new(period, value.span))
2566 }
2567
2568 fn parse_command(&mut self, field: &ParsedField) -> Option<Command> {
2569 match field.value.as_ref() {
2570 Some(YamlNode::Scalar(scalar)) => {
2571 let span = span_from_position(self.source_id, scalar.byte_range());
2572 if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
2573 Some(Command::Null(span))
2574 } else {
2575 Some(Command::String(Located::new(
2576 scalar_string_from_source(&self.source, scalar),
2577 span,
2578 )))
2579 }
2580 }
2581 Some(YamlNode::Sequence(sequence)) => {
2582 let span = span_from_position(self.source_id, sequence.byte_range());
2583 let values =
2584 self.parse_scalar_nodes(sequence.values(), field.span, "command list items must be scalars");
2585 Some(Command::List { span, values })
2586 }
2587 _ => {
2588 self.expected(
2589 EXPECTED_FIELD_FORM,
2590 field,
2591 "command must be null, a scalar, or a sequence",
2592 );
2593 None
2594 }
2595 }
2596 }
2597
2598 fn parse_entrypoint(&mut self, field: &ParsedField) -> Option<Entrypoint> {
2599 match field.value.as_ref() {
2600 Some(YamlNode::Scalar(scalar)) => {
2601 let span = span_from_position(self.source_id, scalar.byte_range());
2602 if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
2603 Some(Entrypoint::Null(span))
2604 } else {
2605 Some(Entrypoint::String(Located::new(
2606 scalar_string_from_source(&self.source, scalar),
2607 span,
2608 )))
2609 }
2610 }
2611 Some(YamlNode::Sequence(sequence)) => {
2612 let span = span_from_position(self.source_id, sequence.byte_range());
2613 let values =
2614 self.parse_scalar_nodes(sequence.values(), field.span, "entrypoint list items must be scalars");
2615 Some(Entrypoint::List { span, values })
2616 }
2617 _ => {
2618 self.expected(
2619 EXPECTED_FIELD_FORM,
2620 field,
2621 "entrypoint must be null, a scalar, or a sequence",
2622 );
2623 None
2624 }
2625 }
2626 }
2627
2628 fn parse_environment(&mut self, field: &ParsedField) -> Option<Environment> {
2629 match field.value.as_ref() {
2630 Some(YamlNode::Sequence(sequence)) => {
2631 let span = span_from_position(self.source_id, sequence.byte_range());
2632 let entries = self
2633 .parse_scalar_nodes(sequence.values(), field.span, "environment list items must be scalars")
2634 .into_iter()
2635 .map(EnvironmentListEntry::parse)
2636 .collect();
2637 Some(Environment::List { span, entries })
2638 }
2639 Some(YamlNode::Mapping(mapping)) => {
2640 let span = span_from_position(self.source_id, mapping.byte_range());
2641 let entries = self.parse_environment_map(mapping);
2642 Some(Environment::Map { span, entries })
2643 }
2644 _ => {
2645 self.expected(EXPECTED_FIELD_FORM, field, "environment must be a sequence or mapping");
2646 None
2647 }
2648 }
2649 }
2650
2651 fn parse_environment_map(&mut self, mapping: &Mapping) -> Vec<EnvironmentMapEntry> {
2652 let mut entries = Vec::new();
2653 let mut seen = BTreeMap::new();
2654 for field in self.fields(mapping) {
2655 if self.record_duplicate(&mut seen, &field) {
2656 continue;
2657 }
2658 let value = self.parse_compose_scalar(&field, "environment values must be scalars");
2659 if let Some(value) = value {
2660 entries.push(EnvironmentMapEntry::new(field.name, value, field.span));
2661 }
2662 }
2663 entries
2664 }
2665
2666 fn parse_environment_files(&mut self, field: &ParsedField) -> Vec<EnvironmentFile> {
2667 match field.value.as_ref() {
2668 Some(YamlNode::Scalar(_)) => self
2669 .parse_string(field, "service environment-file path")
2670 .map(EnvironmentFile::Short)
2671 .into_iter()
2672 .collect(),
2673 Some(YamlNode::Sequence(sequence)) => sequence
2674 .values()
2675 .filter_map(|value| match value {
2676 YamlNode::Scalar(scalar) => {
2677 let span = span_from_position(self.source_id, scalar.byte_range());
2678 Some(EnvironmentFile::Short(Located::new(
2679 scalar_string_from_source(&self.source, &scalar),
2680 span,
2681 )))
2682 }
2683 YamlNode::Mapping(mapping) => Some(EnvironmentFile::Long(Box::new(
2684 self.parse_long_environment_file(&mapping),
2685 ))),
2686 _ => {
2687 self.diagnostics.push(
2688 Diagnostic::new(
2689 ENVIRONMENT_FILE_EXPECTED_FORM,
2690 Severity::Error,
2691 "env_file item must use scalar short syntax or mapping long syntax",
2692 )
2693 .with_label(DiagnosticLabel::primary(
2694 node_span(self.source_id, &value).unwrap_or(field.span),
2695 "invalid environment-file item",
2696 )),
2697 );
2698 None
2699 }
2700 })
2701 .collect(),
2702 _ => {
2703 self.expected(
2704 EXPECTED_FIELD_FORM,
2705 field,
2706 "env_file must be a scalar path or a sequence of short/long entries",
2707 );
2708 Vec::new()
2709 }
2710 }
2711 }
2712
2713 fn parse_long_environment_file(&mut self, mapping: &Mapping) -> LongEnvironmentFile {
2714 let span = span_from_position(self.source_id, mapping.byte_range());
2715 let mut environment_file = LongEnvironmentFile::new(span);
2716 let mut seen = BTreeMap::new();
2717 for field in self.fields(mapping) {
2718 let duplicate = self.record_duplicate(&mut seen, &field);
2719 match field.name.value.as_str() {
2720 "path" if !duplicate => self
2721 .parse_string(&field, "environment-file path")
2722 .into_iter()
2723 .for_each(|value| environment_file.set_path(value)),
2724 "required" if !duplicate => self
2725 .parse_boolean(&field, "environment-file required option")
2726 .into_iter()
2727 .for_each(|value| environment_file.set_required(value)),
2728 "format" if !duplicate => {
2729 if let Some(raw) = self.parse_string(&field, "environment-file format") {
2730 let format = EnvironmentFileFormat::parse(raw);
2731 if !format.is_valid() {
2732 self.diagnostics.push(
2733 Diagnostic::new(
2734 ENVIRONMENT_FILE_INVALID_FORMAT,
2735 Severity::Error,
2736 "environment-file format must be `raw` or interpolation",
2737 )
2738 .with_label(DiagnosticLabel::primary(format.raw().span(), "invalid format")),
2739 );
2740 }
2741 environment_file.set_format(format);
2742 }
2743 }
2744 name if name.starts_with("x-") => environment_file.push_extension(field.reference()),
2745 _ if duplicate => {}
2746 _ => environment_file.push_unknown(field.reference()),
2747 }
2748 }
2749 if environment_file.path().is_none() {
2750 self.missing(
2751 ENVIRONMENT_FILE_MISSING_PATH,
2752 span,
2753 "long environment-file entry is missing `path`",
2754 );
2755 }
2756 environment_file
2757 }
2758
2759 fn parse_extra_hosts(&mut self, field: &ParsedField) -> Option<ExtraHosts> {
2760 match field.value.as_ref() {
2761 Some(YamlNode::Sequence(sequence)) => {
2762 let span = span_from_position(self.source_id, sequence.byte_range());
2763 let entries = self
2764 .parse_scalar_nodes(sequence.values(), field.span, "extra_hosts entries must be scalars")
2765 .into_iter()
2766 .map(|raw| {
2767 let entry = ShortExtraHost::parse(raw);
2768 if !entry.is_complete() {
2769 self.diagnostics.push(
2770 Diagnostic::new(
2771 EXTRA_HOST_INVALID_ENTRY,
2772 Severity::Error,
2773 "short extra_hosts entry must contain a hostname and address",
2774 )
2775 .with_label(DiagnosticLabel::primary(
2776 entry.raw().span(),
2777 "missing separator or value",
2778 )),
2779 );
2780 }
2781 entry
2782 })
2783 .collect();
2784 Some(ExtraHosts::Short { span, entries })
2785 }
2786 Some(YamlNode::Mapping(mapping)) => {
2787 let span = span_from_position(self.source_id, mapping.byte_range());
2788 let mut entries = Vec::new();
2789 let mut seen = BTreeMap::new();
2790 for host in self.fields(mapping) {
2791 if self.record_duplicate(&mut seen, &host) {
2792 continue;
2793 }
2794 if let Some(address) = self.parse_string(&host, "extra host address") {
2795 let address = Located::new(HostAddress::parse(address.value), address.span);
2796 entries.push(LongExtraHost::new(host.name, address, host.span));
2797 }
2798 }
2799 Some(ExtraHosts::Long { span, entries })
2800 }
2801 _ => {
2802 self.expected(EXPECTED_FIELD_FORM, field, "extra_hosts must be a sequence or mapping");
2803 None
2804 }
2805 }
2806 }
2807
2808 fn parse_ulimits(&mut self, field: &ParsedField) -> Option<Ulimits> {
2809 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
2810 self.expected(EXPECTED_MAPPING, field, "ulimits must be a mapping");
2811 return None;
2812 };
2813 let span = span_from_position(self.source_id, mapping.byte_range());
2814 let mut entries = Vec::new();
2815 let mut seen = BTreeMap::new();
2816 for limit in self.fields(mapping) {
2817 if self.record_duplicate(&mut seen, &limit) {
2818 continue;
2819 }
2820 if !valid_ulimit_name(limit.name.value()) {
2821 self.diagnostics.push(
2822 Diagnostic::new(
2823 ULIMIT_INVALID_NAME,
2824 Severity::Error,
2825 "ulimit names must contain only lowercase ASCII letters",
2826 )
2827 .with_label(DiagnosticLabel::primary(limit.name.span, "invalid ulimit name")),
2828 );
2829 }
2830 let value = match limit.value.as_ref() {
2831 Some(YamlNode::Scalar(_)) => self.parse_limit_value(&limit, "ulimit value").map(UlimitValue::Single),
2832 Some(YamlNode::Mapping(range)) => Some(UlimitValue::Range(self.parse_ulimit_range(range))),
2833 _ => {
2834 self.expected(
2835 EXPECTED_FIELD_FORM,
2836 &limit,
2837 "ulimit must be a scalar or soft/hard mapping",
2838 );
2839 None
2840 }
2841 };
2842 if let Some(value) = value {
2843 entries.push(Ulimit::new(limit.name, limit.span, value));
2844 }
2845 }
2846 Some(Ulimits::new(span, entries))
2847 }
2848
2849 fn parse_ulimit_range(&mut self, mapping: &Mapping) -> UlimitRange {
2850 let span = span_from_position(self.source_id, mapping.byte_range());
2851 let mut range = UlimitRange::new(span);
2852 let mut seen = BTreeMap::new();
2853 for field in self.fields(mapping) {
2854 let duplicate = self.record_duplicate(&mut seen, &field);
2855 match field.name.value.as_str() {
2856 "soft" if !duplicate => self
2857 .parse_limit_value(&field, "ulimit soft value")
2858 .into_iter()
2859 .for_each(|value| range.set_soft(value)),
2860 "hard" if !duplicate => self
2861 .parse_limit_value(&field, "ulimit hard value")
2862 .into_iter()
2863 .for_each(|value| range.set_hard(value)),
2864 name if name.starts_with("x-") => range.push_extension(field.reference()),
2865 _ if duplicate => {}
2866 _ => range.push_unknown(field.reference()),
2867 }
2868 }
2869 if range.soft().is_none() {
2870 self.missing(
2871 ULIMIT_MISSING_RANGE_MEMBER,
2872 span,
2873 "ulimit range is missing required `soft`",
2874 );
2875 }
2876 if range.hard().is_none() {
2877 self.missing(
2878 ULIMIT_MISSING_RANGE_MEMBER,
2879 span,
2880 "ulimit range is missing required `hard`",
2881 );
2882 }
2883 range
2884 }
2885
2886 fn parse_limit_value(&mut self, field: &ParsedField, description: &str) -> Option<Located<LimitValue>> {
2887 let value = self.parse_string(field, description)?;
2888 let parsed = LimitValue::parse(value.value);
2889 if !parsed.is_valid() {
2890 self.diagnostics.push(
2891 Diagnostic::new(
2892 ULIMIT_INVALID_VALUE,
2893 Severity::Error,
2894 "ulimit must be -1, a non-negative integer, or an interpolation expression",
2895 )
2896 .with_label(DiagnosticLabel::primary(value.span, "invalid ulimit value")),
2897 );
2898 }
2899 Some(Located::new(parsed, value.span))
2900 }
2901
2902 fn parse_depends_on(&mut self, field: &ParsedField) -> Option<DependsOn> {
2903 match field.value.as_ref() {
2904 Some(YamlNode::Sequence(sequence)) => {
2905 let span = span_from_position(self.source_id, sequence.byte_range());
2906 let services = self.parse_scalar_nodes(
2907 sequence.values(),
2908 field.span,
2909 "dependency service names must be scalars",
2910 );
2911 Some(DependsOn::Short { span, services })
2912 }
2913 Some(YamlNode::Mapping(mapping)) => {
2914 let span = span_from_position(self.source_id, mapping.byte_range());
2915 let mut services = Vec::new();
2916 let mut seen = BTreeMap::new();
2917 for dependency in self.fields(mapping) {
2918 if self.record_duplicate(&mut seen, &dependency) {
2919 continue;
2920 }
2921 let mut parsed = ServiceDependency::new(dependency.name.clone(), dependency.span);
2922 if Self::field_is_null(&dependency) {
2923 services.push(parsed);
2924 continue;
2925 }
2926 let Some(options) = dependency.value.as_ref().and_then(YamlNode::as_mapping) else {
2927 self.expected(
2928 EXPECTED_MAPPING,
2929 &dependency,
2930 "long dependency options must be a mapping or null",
2931 );
2932 continue;
2933 };
2934 let mut option_seen = BTreeMap::new();
2935 for option in self.fields(options) {
2936 let duplicate = self.record_duplicate(&mut option_seen, &option);
2937 match option.name.value.as_str() {
2938 "condition" if !duplicate => {
2939 if let Some(value) = self.parse_string(&option, "dependency condition") {
2940 let condition = DependencyCondition::parse(value.value);
2941 if !condition.is_known() {
2942 self.diagnostics.push(
2943 Diagnostic::new(
2944 DEPENDENCY_INVALID_CONDITION,
2945 Severity::Error,
2946 "dependency condition is not defined by Compose",
2947 )
2948 .with_label(
2949 DiagnosticLabel::primary(value.span, "unknown dependency condition"),
2950 ),
2951 );
2952 }
2953 parsed.set_condition(Located::new(condition, value.span));
2954 }
2955 }
2956 "restart" if !duplicate => self
2957 .parse_boolean(&option, "dependency restart")
2958 .into_iter()
2959 .for_each(|value| parsed.set_restart(value)),
2960 "required" if !duplicate => self
2961 .parse_boolean(&option, "dependency required")
2962 .into_iter()
2963 .for_each(|value| parsed.set_required(value)),
2964 name if name.starts_with("x-") => parsed.push_extension(option.reference()),
2965 _ if duplicate => {}
2966 _ => parsed.push_unknown(option.reference()),
2967 }
2968 }
2969 services.push(parsed);
2970 }
2971 Some(DependsOn::Long { span, services })
2972 }
2973 _ => {
2974 self.expected(EXPECTED_FIELD_FORM, field, "depends_on must be a sequence or mapping");
2975 None
2976 }
2977 }
2978 }
2979
2980 fn parse_healthcheck(&mut self, field: &ParsedField) -> Option<Healthcheck> {
2981 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
2982 self.expected(EXPECTED_MAPPING, field, "healthcheck must be a mapping");
2983 return None;
2984 };
2985 let span = span_from_position(self.source_id, mapping.byte_range());
2986 let mut healthcheck = Healthcheck::new(span);
2987 let mut seen = BTreeMap::new();
2988 for option in self.fields(mapping) {
2989 let duplicate = self.record_duplicate(&mut seen, &option);
2990 match option.name.value.as_str() {
2991 "test" if !duplicate => self
2992 .parse_healthcheck_test(&option)
2993 .into_iter()
2994 .for_each(|value| healthcheck.set_test(value)),
2995 "interval" if !duplicate => self
2996 .parse_healthcheck_duration(&option, "healthcheck interval")
2997 .into_iter()
2998 .for_each(|value| healthcheck.set_interval(value)),
2999 "timeout" if !duplicate => self
3000 .parse_healthcheck_duration(&option, "healthcheck timeout")
3001 .into_iter()
3002 .for_each(|value| healthcheck.set_timeout(value)),
3003 "retries" if !duplicate => self
3004 .parse_healthcheck_retries(&option)
3005 .into_iter()
3006 .for_each(|value| healthcheck.set_retries(value)),
3007 "start_period" if !duplicate => self
3008 .parse_healthcheck_duration(&option, "healthcheck start period")
3009 .into_iter()
3010 .for_each(|value| healthcheck.set_start_period(value)),
3011 "start_interval" if !duplicate => self
3012 .parse_healthcheck_duration(&option, "healthcheck start interval")
3013 .into_iter()
3014 .for_each(|value| healthcheck.set_start_interval(value)),
3015 "disable" if !duplicate => self
3016 .parse_boolean(&option, "healthcheck disable")
3017 .into_iter()
3018 .for_each(|value| healthcheck.set_disable(value)),
3019 name if name.starts_with("x-") => healthcheck.push_extension(option.reference()),
3020 _ if duplicate => {}
3021 _ => healthcheck.push_unknown(option.reference()),
3022 }
3023 }
3024 Some(healthcheck)
3025 }
3026
3027 fn parse_healthcheck_duration(
3028 &mut self,
3029 field: &ParsedField,
3030 description: &str,
3031 ) -> Option<Located<HealthcheckDuration>> {
3032 let value = self.parse_string(field, description)?;
3033 let duration = HealthcheckDuration::parse(value.value);
3034 if !duration.is_valid() {
3035 self.diagnostics.push(
3036 Diagnostic::new(
3037 HEALTHCHECK_INVALID_DURATION,
3038 Severity::Error,
3039 "healthcheck duration must use Compose duration syntax or interpolation",
3040 )
3041 .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck duration")),
3042 );
3043 }
3044 Some(Located::new(duration, value.span))
3045 }
3046
3047 fn parse_healthcheck_retries(&mut self, field: &ParsedField) -> Option<Located<HealthcheckRetries>> {
3048 let value = self.parse_string(field, "healthcheck retries")?;
3049 let retries = HealthcheckRetries::parse(value.value);
3050 if !retries.is_valid() {
3051 self.diagnostics.push(
3052 Diagnostic::new(
3053 HEALTHCHECK_INVALID_RETRIES,
3054 Severity::Error,
3055 "healthcheck retries must be a non-negative integer or interpolation expression",
3056 )
3057 .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck retry count")),
3058 );
3059 }
3060 Some(Located::new(retries, value.span))
3061 }
3062
3063 fn parse_healthcheck_test(&mut self, field: &ParsedField) -> Option<HealthcheckTest> {
3064 match field.value.as_ref() {
3065 Some(YamlNode::Scalar(_)) => self
3066 .parse_string(field, "healthcheck test")
3067 .map(HealthcheckTest::String),
3068 Some(YamlNode::Sequence(sequence)) => {
3069 let span = span_from_position(self.source_id, sequence.byte_range());
3070 let values =
3071 self.parse_scalar_nodes(sequence.values(), field.span, "healthcheck test items must be scalars");
3072 let kind = values.first().map(|value| HealthcheckTestKind::parse(value.value()));
3073 if kind.is_none()
3074 || kind == Some(HealthcheckTestKind::Other)
3075 || (kind == Some(HealthcheckTestKind::None) && values.len() != 1)
3076 {
3077 self.diagnostics.push(
3078 Diagnostic::new(
3079 HEALTHCHECK_INVALID_TEST,
3080 Severity::Error,
3081 "healthcheck list must begin with NONE, CMD, or CMD-SHELL",
3082 )
3083 .with_label(DiagnosticLabel::primary(span, "invalid healthcheck command mode")),
3084 );
3085 }
3086 Some(HealthcheckTest::List { span, kind, values })
3087 }
3088 _ => {
3089 self.expected(
3090 EXPECTED_FIELD_FORM,
3091 field,
3092 "healthcheck test must be a scalar or sequence",
3093 );
3094 None
3095 }
3096 }
3097 }
3098
3099 fn parse_build(&mut self, field: &ParsedField) -> Option<Build> {
3100 match field.value.as_ref() {
3101 Some(YamlNode::Scalar(_)) => self.parse_string(field, "build context").map(Build::Context),
3102 Some(YamlNode::Mapping(mapping)) => {
3103 let span = span_from_position(self.source_id, mapping.byte_range());
3104 let mut definition = BuildDefinition::new(span);
3105 let mut seen = BTreeMap::new();
3106 for option in self.fields(mapping) {
3107 let duplicate = self.record_duplicate(&mut seen, &option);
3108 if duplicate {
3109 continue;
3110 }
3111 if let Some(kind) = BuildFieldKind::from_name(option.name.value()) {
3112 definition.push_field(BuildField::new(kind, option.reference()));
3113 } else if option.name.value().starts_with("x-") {
3114 definition.push_extension(option.reference());
3115 } else {
3116 definition.push_unknown(option.reference());
3117 }
3118 }
3119 Some(Build::Definition(definition))
3120 }
3121 _ => {
3122 self.expected(EXPECTED_FIELD_FORM, field, "build must be a scalar context or mapping");
3123 None
3124 }
3125 }
3126 }
3127
3128 fn parse_deploy(&mut self, field: &ParsedField) -> Option<DeployDefinition> {
3129 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3130 self.expected(EXPECTED_MAPPING, field, "deploy must be a mapping");
3131 return None;
3132 };
3133 let span = span_from_position(self.source_id, mapping.byte_range());
3134 let mut definition = DeployDefinition::new(span);
3135 let mut seen = BTreeMap::new();
3136 for option in self.fields(mapping) {
3137 let duplicate = self.record_duplicate(&mut seen, &option);
3138 if duplicate {
3139 continue;
3140 }
3141 if let Some(kind) = DeployFieldKind::from_name(option.name.value()) {
3142 definition.push_field(DeployField::new(kind, option.reference()));
3143 } else if option.name.value().starts_with("x-") {
3144 definition.push_extension(option.reference());
3145 } else {
3146 definition.push_unknown(option.reference());
3147 }
3148 }
3149 Some(definition)
3150 }
3151
3152 fn source_column(&self, offset: usize) -> usize {
3153 let prefix = self.source.get(..offset).unwrap_or_default();
3154 let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
3155 self.source[line_start..offset].chars().count()
3156 }
3157
3158 fn parse_service_ports(&mut self, field: &ParsedField) -> Vec<Port> {
3159 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3160 self.expected(EXPECTED_SEQUENCE, field, "service ports must be a sequence");
3161 return Vec::new();
3162 };
3163
3164 let mut ports = Vec::new();
3165 for value in sequence.values() {
3166 match value {
3167 YamlNode::Scalar(scalar) => {
3168 let span = span_from_position(self.source_id, scalar.byte_range());
3169 ports.push(Port::Short(ShortPort::parse(Located::new(
3170 scalar_string_from_source(&self.source, &scalar),
3171 span,
3172 ))));
3173 }
3174 YamlNode::Mapping(mapping) => {
3175 ports.push(Port::Long(Box::new(self.parse_long_port(&mapping))));
3176 }
3177 other => self.unsupported_sequence_item(
3178 PORT_EXPECTED_FORM,
3179 &other,
3180 field.span,
3181 "service port must use scalar short syntax or mapping long syntax",
3182 ),
3183 }
3184 }
3185 ports
3186 }
3187
3188 fn parse_long_port(&mut self, mapping: &Mapping) -> LongPort {
3189 let span = span_from_position(self.source_id, mapping.byte_range());
3190 let mut port = LongPort::new(span);
3191 let mut seen = BTreeMap::new();
3192 for field in self.fields(mapping) {
3193 let duplicate = self.record_duplicate(&mut seen, &field);
3194 match field.name.value.as_str() {
3195 "target" if !duplicate => self
3196 .parse_string(&field, "port target")
3197 .into_iter()
3198 .for_each(|value| port.set_target(value)),
3199 "published" if !duplicate => self
3200 .parse_string(&field, "published port")
3201 .into_iter()
3202 .for_each(|value| port.set_published(value)),
3203 "host_ip" if !duplicate => self
3204 .parse_string(&field, "port host IP")
3205 .into_iter()
3206 .for_each(|value| port.set_host_ip(value)),
3207 "protocol" if !duplicate => self
3208 .parse_string(&field, "port protocol")
3209 .into_iter()
3210 .for_each(|value| port.set_protocol(value)),
3211 "app_protocol" if !duplicate => self
3212 .parse_string(&field, "port application protocol")
3213 .into_iter()
3214 .for_each(|value| port.set_app_protocol(value)),
3215 "mode" if !duplicate => self
3216 .parse_string(&field, "port mode")
3217 .into_iter()
3218 .for_each(|value| port.set_mode(value)),
3219 "name" if !duplicate => self
3220 .parse_string(&field, "port name")
3221 .into_iter()
3222 .for_each(|value| port.set_name(value)),
3223 name if name.starts_with("x-") => port.push_extension(field.reference()),
3224 _ if duplicate => {}
3225 _ => port.push_unknown(field.reference()),
3226 }
3227 }
3228 if port.target().is_none() {
3229 self.missing(PORT_MISSING_TARGET, span, "long port is missing `target`");
3230 }
3231 port
3232 }
3233
3234 fn parse_service_networks(&mut self, field: &ParsedField) -> Option<ServiceNetworks> {
3235 match field.value.as_ref() {
3236 Some(YamlNode::Sequence(sequence)) => {
3237 let span = span_from_position(self.source_id, sequence.byte_range());
3238 let names =
3239 self.parse_scalar_nodes(sequence.values(), field.span, "service network names must be scalars");
3240 Some(ServiceNetworks::Short { span, names })
3241 }
3242 Some(YamlNode::Mapping(mapping)) => {
3243 let span = span_from_position(self.source_id, mapping.byte_range());
3244 let networks = self.parse_service_network_map(mapping);
3245 Some(ServiceNetworks::Long { span, networks })
3246 }
3247 _ => {
3248 self.expected(
3249 EXPECTED_FIELD_FORM,
3250 field,
3251 "service networks must be a sequence or mapping",
3252 );
3253 None
3254 }
3255 }
3256 }
3257
3258 fn parse_service_network_map(&mut self, mapping: &Mapping) -> Vec<ServiceNetwork> {
3259 let mut networks = Vec::new();
3260 let mut seen = BTreeMap::new();
3261 for field in self.fields(mapping) {
3262 if self.record_duplicate(&mut seen, &field) {
3263 continue;
3264 }
3265 if Self::field_is_null(&field) {
3266 networks.push(ServiceNetwork::new(field.name, field.span));
3267 continue;
3268 }
3269 let Some(options) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3270 self.expected(
3271 EXPECTED_MAPPING,
3272 &field,
3273 "service network options must be a mapping or null",
3274 );
3275 continue;
3276 };
3277 networks.push(self.parse_service_network(&field, options));
3278 }
3279 networks
3280 }
3281
3282 fn parse_service_network(&mut self, field: &ParsedField, mapping: &Mapping) -> ServiceNetwork {
3283 let mut network = ServiceNetwork::new(field.name.clone(), field.span);
3284 let mut seen = BTreeMap::new();
3285 for option in self.fields(mapping) {
3286 let duplicate = self.record_duplicate(&mut seen, &option);
3287 match option.name.value.as_str() {
3288 "aliases" if !duplicate => network.set_aliases(self.parse_string_sequence(&option, "network aliases")),
3289 "interface_name" if !duplicate => self
3290 .parse_string(&option, "network interface name")
3291 .into_iter()
3292 .for_each(|value| network.set_interface_name(value)),
3293 "ipv4_address" if !duplicate => self
3294 .parse_string(&option, "network IPv4 address")
3295 .into_iter()
3296 .for_each(|value| network.set_ipv4_address(value)),
3297 "ipv6_address" if !duplicate => self
3298 .parse_string(&option, "network IPv6 address")
3299 .into_iter()
3300 .for_each(|value| network.set_ipv6_address(value)),
3301 "link_local_ips" if !duplicate => {
3302 network.set_link_local_ips(self.parse_string_sequence(&option, "link-local IP addresses"));
3303 }
3304 "mac_address" if !duplicate => self
3305 .parse_string(&option, "network MAC address")
3306 .into_iter()
3307 .for_each(|value| network.set_mac_address(value)),
3308 "driver_opts" if !duplicate => {
3309 network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
3310 }
3311 "gw_priority" if !duplicate => self
3312 .parse_string(&option, "network gateway priority")
3313 .into_iter()
3314 .for_each(|value| network.set_gw_priority(value)),
3315 "priority" if !duplicate => self
3316 .parse_string(&option, "network priority")
3317 .into_iter()
3318 .for_each(|value| network.set_priority(value)),
3319 name if name.starts_with("x-") => network.push_extension(option.reference()),
3320 _ if duplicate => {}
3321 _ => network.push_unknown(option.reference()),
3322 }
3323 }
3324 network
3325 }
3326
3327 fn parse_config_grants(&mut self, field: &ParsedField) -> Vec<ConfigGrant> {
3328 self.parse_grants(field)
3329 .into_iter()
3330 .map(|grant| match grant {
3331 ParsedGrant::Short(value) => ConfigGrant::Short(value),
3332 ParsedGrant::Long(value) => ConfigGrant::Long(value),
3333 })
3334 .collect()
3335 }
3336
3337 fn parse_secret_grants(&mut self, field: &ParsedField) -> Vec<SecretGrant> {
3338 self.parse_grants(field)
3339 .into_iter()
3340 .map(|grant| match grant {
3341 ParsedGrant::Short(value) => SecretGrant::Short(value),
3342 ParsedGrant::Long(value) => SecretGrant::Long(value),
3343 })
3344 .collect()
3345 }
3346
3347 fn parse_grants(&mut self, field: &ParsedField) -> Vec<ParsedGrant> {
3348 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3349 self.expected(EXPECTED_SEQUENCE, field, "service grants must be a sequence");
3350 return Vec::new();
3351 };
3352 let mut grants = Vec::new();
3353 for value in sequence.values() {
3354 match value {
3355 YamlNode::Scalar(scalar) => {
3356 let span = span_from_position(self.source_id, scalar.byte_range());
3357 grants.push(ParsedGrant::Short(Located::new(
3358 scalar_string_from_source(&self.source, &scalar),
3359 span,
3360 )));
3361 }
3362 YamlNode::Mapping(mapping) => {
3363 grants.push(ParsedGrant::Long(Box::new(self.parse_long_grant(&mapping))));
3364 }
3365 other => self.unsupported_sequence_item(
3366 GRANT_EXPECTED_FORM,
3367 &other,
3368 field.span,
3369 "grant must use scalar short syntax or mapping long syntax",
3370 ),
3371 }
3372 }
3373 grants
3374 }
3375
3376 fn parse_long_grant(&mut self, mapping: &Mapping) -> LongGrant {
3377 let span = span_from_position(self.source_id, mapping.byte_range());
3378 let mut grant = LongGrant::new(span);
3379 let mut seen = BTreeMap::new();
3380 for field in self.fields(mapping) {
3381 let duplicate = self.record_duplicate(&mut seen, &field);
3382 match field.name.value.as_str() {
3383 "source" if !duplicate => self
3384 .parse_string(&field, "grant source")
3385 .into_iter()
3386 .for_each(|value| grant.set_source(value)),
3387 "target" if !duplicate => self
3388 .parse_string(&field, "grant target")
3389 .into_iter()
3390 .for_each(|value| grant.set_target(value)),
3391 "uid" if !duplicate => self
3392 .parse_string(&field, "grant user ID")
3393 .into_iter()
3394 .for_each(|value| grant.set_uid(value)),
3395 "gid" if !duplicate => self
3396 .parse_string(&field, "grant group ID")
3397 .into_iter()
3398 .for_each(|value| grant.set_gid(value)),
3399 "mode" if !duplicate => self
3400 .parse_string(&field, "grant mode")
3401 .into_iter()
3402 .for_each(|value| grant.set_mode(value)),
3403 name if name.starts_with("x-") => grant.push_extension(field.reference()),
3404 _ if duplicate => {}
3405 _ => grant.push_unknown(field.reference()),
3406 }
3407 }
3408 if grant.source().is_none() {
3409 self.missing(GRANT_MISSING_SOURCE, span, "long grant is missing `source`");
3410 }
3411 grant
3412 }
3413
3414 fn parse_service_volumes(&mut self, field: &ParsedField) -> Vec<VolumeMount> {
3415 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3416 self.expected(EXPECTED_SEQUENCE, field, "service volumes must be a sequence");
3417 return Vec::new();
3418 };
3419
3420 sequence
3421 .values()
3422 .filter_map(|value| match value {
3423 YamlNode::Scalar(scalar) => {
3424 let span = span_from_position(self.source_id, scalar.byte_range());
3425 let raw = Located::new(scalar_string_from_source(&self.source, &scalar), span);
3426 Some(VolumeMount::Short(ShortVolumeMount::new(raw)))
3427 }
3428 YamlNode::Mapping(mapping) => Some(VolumeMount::Long(Box::new(self.parse_long_volume(&mapping)))),
3429 other => {
3430 let span = node_span(self.source_id, &other).unwrap_or(field.span);
3431 self.diagnostics.push(
3432 Diagnostic::new(
3433 VOLUME_EXPECTED_FORM,
3434 Severity::Error,
3435 "service volume must use scalar short syntax or mapping long syntax",
3436 )
3437 .with_label(DiagnosticLabel::primary(span, "unsupported volume form")),
3438 );
3439 None
3440 }
3441 })
3442 .collect()
3443 }
3444
3445 fn parse_long_volume(&mut self, mapping: &Mapping) -> LongVolumeMount {
3446 let span = span_from_position(self.source_id, mapping.byte_range());
3447 let mut mount = LongVolumeMount::new(span);
3448 let mut seen = BTreeMap::new();
3449 for field in self.fields(mapping) {
3450 let duplicate = self.record_duplicate(&mut seen, &field);
3451 match field.name.value.as_str() {
3452 "type" if !duplicate => {
3453 if let Some(value) = self.parse_string(&field, "volume type") {
3454 mount.set_mount_type(Located::new(MountType::from_text(value.value), value.span));
3455 }
3456 }
3457 "source" if !duplicate => {
3458 if let Some(value) = self.parse_string(&field, "volume source") {
3459 mount.set_source(value);
3460 }
3461 }
3462 "target" if !duplicate => {
3463 if let Some(value) = self.parse_string(&field, "volume target") {
3464 mount.set_target(value);
3465 }
3466 }
3467 "read_only" if !duplicate => {
3468 if let Some(value) = self.parse_boolean(&field, "read_only") {
3469 mount.set_read_only(value);
3470 }
3471 }
3472 "bind" if !duplicate => {
3473 if let Some(value) = self.parse_bind_options(&field) {
3474 mount.set_bind(value);
3475 }
3476 }
3477 name if name.starts_with("x-") => mount.push_extension(field.reference()),
3478 _ if duplicate => {}
3479 _ => mount.push_unknown(field.reference()),
3480 }
3481 }
3482
3483 if mount.mount_type().is_none() {
3484 self.missing(VOLUME_MISSING_TYPE, span, "long volume is missing `type`");
3485 }
3486 if mount.target().is_none() {
3487 self.missing(VOLUME_MISSING_TARGET, span, "long volume is missing `target`");
3488 }
3489 mount
3490 }
3491
3492 fn parse_bind_options(&mut self, field: &ParsedField) -> Option<BindOptions> {
3493 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3494 self.expected(EXPECTED_MAPPING, field, "bind options must be a mapping");
3495 return None;
3496 };
3497 let span = span_from_position(self.source_id, mapping.byte_range());
3498 let mut bind = BindOptions::new(span);
3499 let mut seen = BTreeMap::new();
3500 for bind_field in self.fields(mapping) {
3501 let duplicate = self.record_duplicate(&mut seen, &bind_field);
3502 match bind_field.name.value.as_str() {
3503 "propagation" if !duplicate => {
3504 if let Some(value) = self.parse_string(&bind_field, "bind propagation") {
3505 bind.set_propagation(value);
3506 }
3507 }
3508 "create_host_path" if !duplicate => {
3509 if let Some(value) = self.parse_boolean(&bind_field, "create_host_path") {
3510 bind.set_create_host_path(value);
3511 }
3512 }
3513 "selinux" if !duplicate => {
3514 if let Some(value) = self.parse_string(&bind_field, "SELinux relabel mode") {
3515 let mode = match value.value.as_str() {
3516 "z" => Some(SelinuxRelabel::Shared),
3517 "Z" => Some(SelinuxRelabel::Private),
3518 _ => None,
3519 };
3520 if let Some(mode) = mode {
3521 bind.set_selinux(Located::new(mode, value.span));
3522 } else {
3523 self.diagnostics.push(
3524 Diagnostic::new(
3525 VOLUME_INVALID_SELINUX,
3526 Severity::Error,
3527 "SELinux relabel mode must be `z` or `Z`",
3528 )
3529 .with_label(DiagnosticLabel::primary(value.span, "invalid SELinux mode")),
3530 );
3531 }
3532 }
3533 }
3534 name if name.starts_with("x-") => bind.push_extension(bind_field.reference()),
3535 _ if duplicate => {}
3536 _ => bind.push_unknown(bind_field.reference()),
3537 }
3538 }
3539 Some(bind)
3540 }
3541
3542 fn parse_network_definitions(&mut self, field: &ParsedField) -> Vec<NetworkDefinition> {
3543 let Some(mapping) = self.resource_collection(field, "networks") else {
3544 return Vec::new();
3545 };
3546 let mut definitions = Vec::new();
3547 let mut seen = BTreeMap::new();
3548 for resource in self.fields(&mapping) {
3549 if self.record_duplicate(&mut seen, &resource) {
3550 continue;
3551 }
3552 if Self::field_is_null(&resource) {
3553 definitions.push(NetworkDefinition::new(resource.name, resource.span));
3554 continue;
3555 }
3556 let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
3557 self.expected(
3558 RESOURCE_EXPECTED_FORM,
3559 &resource,
3560 "network definition must be a mapping or null",
3561 );
3562 continue;
3563 };
3564 definitions.push(self.parse_network_definition(&resource, definition));
3565 }
3566 definitions
3567 }
3568
3569 fn parse_network_definition(&mut self, field: &ParsedField, mapping: &Mapping) -> NetworkDefinition {
3570 let mut network = NetworkDefinition::new(field.name.clone(), field.span);
3571 let mut seen = BTreeMap::new();
3572 for option in self.fields(mapping) {
3573 let duplicate = self.record_duplicate(&mut seen, &option);
3574 match option.name.value.as_str() {
3575 "driver" if !duplicate => self
3576 .parse_string(&option, "network driver")
3577 .into_iter()
3578 .for_each(|value| network.set_driver(value)),
3579 "driver_opts" if !duplicate => {
3580 network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
3581 }
3582 "attachable" if !duplicate => self
3583 .parse_boolean(&option, "network attachable")
3584 .into_iter()
3585 .for_each(|value| network.set_attachable(value)),
3586 "enable_ipv4" if !duplicate => self
3587 .parse_boolean(&option, "network enable_ipv4")
3588 .into_iter()
3589 .for_each(|value| network.set_enable_ipv4(value)),
3590 "enable_ipv6" if !duplicate => self
3591 .parse_boolean(&option, "network enable_ipv6")
3592 .into_iter()
3593 .for_each(|value| network.set_enable_ipv6(value)),
3594 "external" if !duplicate => self
3595 .parse_boolean(&option, "network external")
3596 .into_iter()
3597 .for_each(|value| network.set_external(value)),
3598 "internal" if !duplicate => self
3599 .parse_boolean(&option, "network internal")
3600 .into_iter()
3601 .for_each(|value| network.set_internal(value)),
3602 "ipam" if !duplicate => self
3603 .parse_ipam(&option)
3604 .into_iter()
3605 .for_each(|value| network.set_ipam(value)),
3606 "labels" if !duplicate => self
3607 .parse_labels(&option)
3608 .into_iter()
3609 .for_each(|value| network.set_labels(value)),
3610 "name" if !duplicate => self
3611 .parse_string(&option, "network custom name")
3612 .into_iter()
3613 .for_each(|value| network.set_custom_name(value)),
3614 name if name.starts_with("x-") => network.push_extension(option.reference()),
3615 _ if duplicate => {}
3616 _ => network.push_unknown(option.reference()),
3617 }
3618 }
3619 network
3620 }
3621
3622 fn parse_ipam(&mut self, field: &ParsedField) -> Option<Ipam> {
3623 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3624 self.expected(EXPECTED_MAPPING, field, "network IPAM must be a mapping");
3625 return None;
3626 };
3627 let span = span_from_position(self.source_id, mapping.byte_range());
3628 let mut ipam = Ipam::new(span);
3629 let mut seen = BTreeMap::new();
3630 for option in self.fields(mapping) {
3631 let duplicate = self.record_duplicate(&mut seen, &option);
3632 match option.name.value.as_str() {
3633 "driver" if !duplicate => self
3634 .parse_string(&option, "IPAM driver")
3635 .into_iter()
3636 .for_each(|value| ipam.set_driver(value)),
3637 "config" if !duplicate => ipam.set_config(self.parse_ipam_configs(&option)),
3638 "options" if !duplicate => {
3639 ipam.set_options(self.parse_scalar_mapping(&option, "IPAM options"));
3640 }
3641 name if name.starts_with("x-") => ipam.push_extension(option.reference()),
3642 _ if duplicate => {}
3643 _ => ipam.push_unknown(option.reference()),
3644 }
3645 }
3646 Some(ipam)
3647 }
3648
3649 fn parse_ipam_configs(&mut self, field: &ParsedField) -> Vec<IpamConfig> {
3650 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3651 self.expected(EXPECTED_SEQUENCE, field, "IPAM config must be a sequence");
3652 return Vec::new();
3653 };
3654 let mut configs = Vec::new();
3655 for value in sequence.values() {
3656 let YamlNode::Mapping(mapping) = value else {
3657 self.unsupported_sequence_item(
3658 EXPECTED_MAPPING,
3659 &value,
3660 field.span,
3661 "IPAM config entries must be mappings",
3662 );
3663 continue;
3664 };
3665 configs.push(self.parse_ipam_config(&mapping));
3666 }
3667 configs
3668 }
3669
3670 fn parse_ipam_config(&mut self, mapping: &Mapping) -> IpamConfig {
3671 let span = span_from_position(self.source_id, mapping.byte_range());
3672 let mut config = IpamConfig::new(span);
3673 let mut seen = BTreeMap::new();
3674 for field in self.fields(mapping) {
3675 let duplicate = self.record_duplicate(&mut seen, &field);
3676 match field.name.value.as_str() {
3677 "subnet" if !duplicate => self
3678 .parse_string(&field, "IPAM subnet")
3679 .into_iter()
3680 .for_each(|value| config.set_subnet(value)),
3681 "ip_range" if !duplicate => self
3682 .parse_string(&field, "IPAM allocation range")
3683 .into_iter()
3684 .for_each(|value| config.set_ip_range(value)),
3685 "gateway" if !duplicate => self
3686 .parse_string(&field, "IPAM gateway")
3687 .into_iter()
3688 .for_each(|value| config.set_gateway(value)),
3689 "aux_addresses" if !duplicate => {
3690 config.set_aux_addresses(self.parse_scalar_mapping(&field, "IPAM auxiliary addresses"));
3691 }
3692 name if name.starts_with("x-") => config.push_extension(field.reference()),
3693 _ if duplicate => {}
3694 _ => config.push_unknown(field.reference()),
3695 }
3696 }
3697 config
3698 }
3699
3700 fn parse_volume_definitions(&mut self, field: &ParsedField) -> Vec<VolumeDefinition> {
3701 let Some(mapping) = self.resource_collection(field, "volumes") else {
3702 return Vec::new();
3703 };
3704 let mut definitions = Vec::new();
3705 let mut seen = BTreeMap::new();
3706 for resource in self.fields(&mapping) {
3707 if self.record_duplicate(&mut seen, &resource) {
3708 continue;
3709 }
3710 let mut volume = VolumeDefinition::new(resource.name.clone(), resource.span);
3711 if Self::field_is_null(&resource) {
3712 definitions.push(volume);
3713 continue;
3714 }
3715 let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
3716 self.expected(
3717 RESOURCE_EXPECTED_FORM,
3718 &resource,
3719 "volume definition must be a mapping or null",
3720 );
3721 continue;
3722 };
3723 let mut nested_seen = BTreeMap::new();
3724 for option in self.fields(definition) {
3725 let duplicate = self.record_duplicate(&mut nested_seen, &option);
3726 match option.name.value.as_str() {
3727 "driver" if !duplicate => self
3728 .parse_string(&option, "volume driver")
3729 .into_iter()
3730 .for_each(|value| volume.set_driver(value)),
3731 "driver_opts" if !duplicate => {
3732 volume.set_driver_opts(self.parse_scalar_mapping(&option, "volume driver options"));
3733 }
3734 "external" if !duplicate => self
3735 .parse_boolean(&option, "volume external")
3736 .into_iter()
3737 .for_each(|value| volume.set_external(value)),
3738 "labels" if !duplicate => self
3739 .parse_labels(&option)
3740 .into_iter()
3741 .for_each(|value| volume.set_labels(value)),
3742 "name" if !duplicate => self
3743 .parse_string(&option, "volume custom name")
3744 .into_iter()
3745 .for_each(|value| volume.set_custom_name(value)),
3746 name if name.starts_with("x-") => volume.push_extension(option.reference()),
3747 _ if duplicate => {}
3748 _ => volume.push_unknown(option.reference()),
3749 }
3750 }
3751 definitions.push(volume);
3752 }
3753 definitions
3754 }
3755
3756 fn parse_config_definitions(&mut self, field: &ParsedField) -> Vec<ConfigDefinition> {
3757 let Some(mapping) = self.resource_collection(field, "configs") else {
3758 return Vec::new();
3759 };
3760 let mut definitions = Vec::new();
3761 let mut seen = BTreeMap::new();
3762 for resource in self.fields(&mapping) {
3763 if self.record_duplicate(&mut seen, &resource) {
3764 continue;
3765 }
3766 let mut config = ConfigDefinition::new(resource.name.clone(), resource.span);
3767 if Self::field_is_null(&resource) {
3768 definitions.push(config);
3769 continue;
3770 }
3771 let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
3772 self.expected(
3773 RESOURCE_EXPECTED_FORM,
3774 &resource,
3775 "config definition must be a mapping or null",
3776 );
3777 continue;
3778 };
3779 let mut nested_seen = BTreeMap::new();
3780 for option in self.fields(definition) {
3781 let duplicate = self.record_duplicate(&mut nested_seen, &option);
3782 match option.name.value.as_str() {
3783 "file" if !duplicate => self
3784 .parse_string(&option, "config file")
3785 .into_iter()
3786 .for_each(|value| config.set_file(value)),
3787 "environment" if !duplicate => self
3788 .parse_string(&option, "config environment source")
3789 .into_iter()
3790 .for_each(|value| config.set_environment(value)),
3791 "content" if !duplicate => self
3792 .parse_string(&option, "config content")
3793 .into_iter()
3794 .for_each(|value| config.set_content(value)),
3795 "external" if !duplicate => self
3796 .parse_boolean(&option, "config external")
3797 .into_iter()
3798 .for_each(|value| config.set_external(value)),
3799 "name" if !duplicate => self
3800 .parse_string(&option, "config custom name")
3801 .into_iter()
3802 .for_each(|value| config.set_custom_name(value)),
3803 name if name.starts_with("x-") => config.push_extension(option.reference()),
3804 _ if duplicate => {}
3805 _ => config.push_unknown(option.reference()),
3806 }
3807 }
3808 definitions.push(config);
3809 }
3810 definitions
3811 }
3812
3813 fn parse_secret_definitions(&mut self, field: &ParsedField) -> Vec<SecretDefinition> {
3814 let Some(mapping) = self.resource_collection(field, "secrets") else {
3815 return Vec::new();
3816 };
3817 let mut definitions = Vec::new();
3818 let mut seen = BTreeMap::new();
3819 for resource in self.fields(&mapping) {
3820 if self.record_duplicate(&mut seen, &resource) {
3821 continue;
3822 }
3823 let mut secret = SecretDefinition::new(resource.name.clone(), resource.span);
3824 if Self::field_is_null(&resource) {
3825 definitions.push(secret);
3826 continue;
3827 }
3828 let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
3829 self.expected(
3830 RESOURCE_EXPECTED_FORM,
3831 &resource,
3832 "secret definition must be a mapping or null",
3833 );
3834 continue;
3835 };
3836 let mut nested_seen = BTreeMap::new();
3837 for option in self.fields(definition) {
3838 let duplicate = self.record_duplicate(&mut nested_seen, &option);
3839 match option.name.value.as_str() {
3840 "file" if !duplicate => self
3841 .parse_string(&option, "secret file")
3842 .into_iter()
3843 .for_each(|value| secret.set_file(value)),
3844 "environment" if !duplicate => self
3845 .parse_string(&option, "secret environment source")
3846 .into_iter()
3847 .for_each(|value| secret.set_environment(value)),
3848 "external" if !duplicate => self
3849 .parse_boolean(&option, "secret external")
3850 .into_iter()
3851 .for_each(|value| secret.set_external(value)),
3852 "name" if !duplicate => self
3853 .parse_string(&option, "secret custom name")
3854 .into_iter()
3855 .for_each(|value| secret.set_custom_name(value)),
3856 name if name.starts_with("x-") => secret.push_extension(option.reference()),
3857 _ if duplicate => {}
3858 _ => secret.push_unknown(option.reference()),
3859 }
3860 }
3861 definitions.push(secret);
3862 }
3863 definitions
3864 }
3865
3866 fn resource_collection(&mut self, field: &ParsedField, kind: &str) -> Option<Mapping> {
3867 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3868 self.expected(EXPECTED_MAPPING, field, format!("top-level {kind} must be a mapping"));
3869 return None;
3870 };
3871 Some(mapping.clone())
3872 }
3873
3874 fn parse_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
3875 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3876 self.expected(EXPECTED_SCALAR, field, format!("{description} must be a scalar"));
3877 return None;
3878 };
3879 if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
3880 self.expected(
3881 EXPECTED_SCALAR,
3882 field,
3883 format!("{description} must be a non-null scalar"),
3884 );
3885 return None;
3886 }
3887 Some(Located::new(
3888 scalar_string_from_source(&self.source, scalar),
3889 span_from_position(self.source_id, scalar.byte_range()),
3890 ))
3891 }
3892
3893 fn parse_boolean(&mut self, field: &ParsedField, description: &str) -> Option<Located<BooleanValue>> {
3894 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3895 self.expected(EXPECTED_BOOLEAN, field, format!("{description} must be a boolean"));
3896 return None;
3897 };
3898 let span = span_from_position(self.source_id, scalar.byte_range());
3899 let scalar_value = ScalarValue::from_scalar(scalar);
3900 if let Some(value) = scalar_value.to_bool() {
3901 return Some(Located::new(BooleanValue::Literal(value), span));
3902 }
3903 let value = scalar_string_from_source(&self.source, scalar);
3904 if value.contains('$') {
3905 return Some(Located::new(BooleanValue::Expression(value), span));
3906 }
3907 self.diagnostics.push(
3908 Diagnostic::new(
3909 EXPECTED_BOOLEAN,
3910 Severity::Error,
3911 format!("{description} must be a boolean or interpolation expression"),
3912 )
3913 .with_label(DiagnosticLabel::primary(span, "not a boolean expression")),
3914 );
3915 None
3916 }
3917
3918 fn parse_string_sequence(&mut self, field: &ParsedField, description: &str) -> Vec<Located<String>> {
3919 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3920 self.expected(EXPECTED_SEQUENCE, field, format!("{description} must be a sequence"));
3921 return Vec::new();
3922 };
3923 self.parse_scalar_nodes(
3924 sequence.values(),
3925 field.span,
3926 format!("{description} entries must be scalars"),
3927 )
3928 }
3929
3930 fn parse_scalar_nodes(
3931 &mut self,
3932 nodes: impl Iterator<Item = YamlNode>,
3933 fallback_span: SourceSpan,
3934 message: impl Into<String>,
3935 ) -> Vec<Located<String>> {
3936 let message = message.into();
3937 let mut values = Vec::new();
3938 for node in nodes {
3939 let YamlNode::Scalar(scalar) = node else {
3940 self.unsupported_sequence_item(EXPECTED_SCALAR, &node, fallback_span, &message);
3941 continue;
3942 };
3943 let scalar_value = ScalarValue::from_scalar(&scalar);
3944 if scalar_value.scalar_type() == ScalarType::Null {
3945 self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar), fallback_span, &message);
3946 continue;
3947 }
3948 let span = span_from_position(self.source_id, scalar.byte_range());
3949 values.push(Located::new(scalar_string_from_source(&self.source, &scalar), span));
3950 }
3951 values
3952 }
3953
3954 fn parse_scalar_mapping(&mut self, field: &ParsedField, description: &str) -> Vec<KeyValueEntry> {
3955 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3956 self.expected(EXPECTED_MAPPING, field, format!("{description} must be a mapping"));
3957 return Vec::new();
3958 };
3959 let mut entries = Vec::new();
3960 let mut seen = BTreeMap::new();
3961 for entry in self.fields(mapping) {
3962 if self.record_duplicate(&mut seen, &entry) {
3963 continue;
3964 }
3965 if let Some(value) = self.parse_compose_scalar(&entry, format!("{description} values must be scalars")) {
3966 entries.push(KeyValueEntry::new(entry.name, value, entry.span));
3967 }
3968 }
3969 entries
3970 }
3971
3972 fn parse_compose_scalar(
3973 &mut self,
3974 field: &ParsedField,
3975 message: impl Into<String>,
3976 ) -> Option<Located<ComposeScalar>> {
3977 let Some(node) = field.value.as_ref() else {
3978 return Some(Located::new(ComposeScalar::Null, field.name.span));
3979 };
3980 let Some(scalar) = node.as_scalar() else {
3981 self.expected(EXPECTED_SCALAR, field, message);
3982 return None;
3983 };
3984 let span = span_from_position(self.source_id, scalar.byte_range());
3985 let value = ScalarValue::from_scalar(scalar);
3986 let typed = match value.scalar_type() {
3987 ScalarType::Null => ComposeScalar::Null,
3988 ScalarType::Boolean => ComposeScalar::Boolean(value.to_bool().unwrap_or(false)),
3989 ScalarType::Integer | ScalarType::Float => {
3990 ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
3991 }
3992 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
3993 ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
3994 }
3995 };
3996 Some(Located::new(typed, span))
3997 }
3998
3999 fn parse_labels(&mut self, field: &ParsedField) -> Option<Labels> {
4000 match field.value.as_ref() {
4001 Some(YamlNode::Sequence(sequence)) => {
4002 let span = span_from_position(self.source_id, sequence.byte_range());
4003 let values =
4004 self.parse_scalar_nodes(sequence.values(), field.span, "label list entries must be scalars");
4005 Some(Labels::List { span, values })
4006 }
4007 Some(YamlNode::Mapping(mapping)) => {
4008 let span = span_from_position(self.source_id, mapping.byte_range());
4009 let entries = self.parse_scalar_mapping(field, "labels");
4010 Some(Labels::Map { span, entries })
4011 }
4012 _ => {
4013 self.expected(EXPECTED_FIELD_FORM, field, "labels must be a sequence or mapping");
4014 None
4015 }
4016 }
4017 }
4018
4019 fn parse_annotations(&mut self, field: &ParsedField) -> Option<Annotations> {
4020 match field.value.as_ref() {
4021 Some(YamlNode::Sequence(sequence)) => Some(self.parse_annotation_list(sequence, field.span)),
4022 Some(YamlNode::Mapping(mapping)) => Some(self.parse_annotation_map(mapping)),
4023 _ => {
4024 self.expected(
4025 ANNOTATIONS_EXPECTED_FORM,
4026 field,
4027 "annotations must be a sequence or mapping",
4028 );
4029 None
4030 }
4031 }
4032 }
4033
4034 fn parse_annotation_list(&mut self, sequence: &yaml_edit::Sequence, fallback: SourceSpan) -> Annotations {
4035 let span = span_from_position(self.source_id, sequence.byte_range());
4036 let mut values = Vec::new();
4037 let mut seen = BTreeSet::new();
4038 for node in sequence.values() {
4039 let YamlNode::Scalar(scalar) = node else {
4040 self.unsupported_sequence_item(
4041 ANNOTATIONS_EXPECTED_STRING,
4042 &node,
4043 fallback,
4044 "annotation list entries must be string scalars",
4045 );
4046 continue;
4047 };
4048 let item_span = span_from_position(self.source_id, scalar.byte_range());
4049 let scalar_value = ScalarValue::from_scalar(&scalar);
4050 let value = match scalar_value.scalar_type() {
4051 ScalarType::Null => ComposeScalar::Null,
4052 ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
4053 ScalarType::Integer | ScalarType::Float => {
4054 ComposeScalar::Number(scalar_string_from_source(&self.source, &scalar))
4055 }
4056 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
4057 ComposeScalar::String(scalar_string_from_source(&self.source, &scalar))
4058 }
4059 };
4060 self.validate_annotation_list_scalar(&value, item_span, &mut seen);
4061 values.push(Located::new(value, item_span));
4062 }
4063 Annotations::new(span, AnnotationsForm::List(values))
4064 }
4065
4066 fn validate_annotation_list_scalar(
4067 &mut self,
4068 value: &ComposeScalar,
4069 span: SourceSpan,
4070 seen: &mut BTreeSet<String>,
4071 ) {
4072 let ComposeScalar::String(raw) = value else {
4073 self.diagnostics.push(annotation_diagnostic(
4074 ANNOTATIONS_EXPECTED_STRING,
4075 Severity::Error,
4076 span,
4077 "annotation list entries must be string scalars",
4078 "non-string annotation item retained",
4079 ));
4080 return;
4081 };
4082 let name = raw.split_once('=').map_or(raw.as_str(), |(name, _)| name);
4083 if name.is_empty() {
4084 self.diagnostics.push(annotation_diagnostic(
4085 ANNOTATIONS_EMPTY_NAME,
4086 Severity::Error,
4087 span,
4088 "service annotation name must not be empty",
4089 "empty annotation name",
4090 ));
4091 } else if !seen.insert(name.to_owned()) {
4092 self.diagnostics.push(annotation_diagnostic(
4093 ANNOTATIONS_DUPLICATE_NAME,
4094 Severity::Error,
4095 span,
4096 "service annotation names must be unique",
4097 "duplicate annotation name",
4098 ));
4099 }
4100 if !raw.contains('=') {
4101 self.diagnostics.push(annotation_diagnostic(
4102 ANNOTATIONS_KEY_ONLY,
4103 Severity::Warning,
4104 span,
4105 "key-only service annotation has no explicit value",
4106 "ambiguous key-only annotation",
4107 ));
4108 }
4109 }
4110
4111 fn parse_annotation_map(&mut self, mapping: &Mapping) -> Annotations {
4112 let span = span_from_position(self.source_id, mapping.byte_range());
4113 let mut entries = Vec::new();
4114 let mut seen = BTreeMap::new();
4115 for entry in self.fields(mapping) {
4116 let _duplicate = self.record_duplicate(&mut seen, &entry);
4117 if entry.name.value.is_empty() {
4118 self.diagnostics.push(annotation_diagnostic(
4119 ANNOTATIONS_EMPTY_NAME,
4120 Severity::Error,
4121 entry.name.span,
4122 "service annotation name must not be empty",
4123 "empty annotation name",
4124 ));
4125 }
4126 if let Some(value) = self.parse_compose_scalar(
4127 &entry,
4128 "annotation mapping values must be scalar strings, numbers, booleans, or null",
4129 ) {
4130 entries.push(KeyValueEntry::new(entry.name, value, entry.span));
4131 }
4132 }
4133 Annotations::new(span, AnnotationsForm::Map(entries))
4134 }
4135
4136 fn field_is_null(field: &ParsedField) -> bool {
4137 field.value.as_ref().is_none_or(|node| {
4138 node.as_scalar()
4139 .is_some_and(|scalar| ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null)
4140 })
4141 }
4142
4143 fn unsupported_sequence_item(
4144 &mut self,
4145 code: DiagnosticCode,
4146 node: &YamlNode,
4147 fallback_span: SourceSpan,
4148 message: impl Into<String>,
4149 ) {
4150 let span = node_span(self.source_id, node).unwrap_or(fallback_span);
4151 self.diagnostics.push(
4152 Diagnostic::new(code, Severity::Error, message)
4153 .with_label(DiagnosticLabel::primary(span, "unsupported value form")),
4154 );
4155 }
4156
4157 fn fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
4158 let fields = self.raw_fields(mapping);
4159 let mut fields = self.flatten_empty_value_continuations(fields);
4160 for field in &mut fields {
4161 field.value = field.value.take().map(|value| self.resolve_alias(value));
4162 }
4163 fields
4164 }
4165
4166 fn raw_fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
4167 mapping
4168 .entries()
4169 .filter_map(|entry| {
4170 let key = entry.key_node()?;
4171 let Some(scalar) = key.as_scalar() else {
4172 let span = node_span(self.source_id, &key)
4173 .unwrap_or_else(|| span_from_position(self.source_id, mapping.byte_range()));
4174 self.diagnostics.push(
4175 Diagnostic::new(EXPECTED_SCALAR, Severity::Error, "Compose mapping keys must be scalars")
4176 .with_label(DiagnosticLabel::primary(span, "non-scalar key")),
4177 );
4178 return None;
4179 };
4180 let name_span = span_from_position(self.source_id, scalar.byte_range());
4181 let authored_value = entry.value_node();
4182 let value_span = authored_value
4183 .as_ref()
4184 .and_then(|value| node_span(self.source_id, value));
4185 let value = authored_value.map(unwrap_processing_tag);
4186 let span = value_span.map_or(name_span, |value_span| union(name_span, value_span));
4187 Some(ParsedField {
4188 name: Located::new(scalar_string_from_source(&self.source, scalar), name_span),
4189 value,
4190 value_span,
4191 span,
4192 })
4193 })
4194 .collect()
4195 }
4196
4197 fn resolve_alias(&self, node: YamlNode) -> YamlNode {
4198 let mut node = node;
4199 let mut visited = BTreeSet::new();
4200 for _ in 0..64 {
4201 let YamlNode::Alias(alias) = &node else {
4202 return node;
4203 };
4204 if !visited.insert(alias.name()) {
4205 return node;
4206 }
4207 let Some(target) = self.anchors.resolve(&alias.name()).and_then(|target| {
4208 YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
4209 }) else {
4210 return node;
4211 };
4212 node = target;
4213 }
4214 node
4215 }
4216
4217 fn flatten_empty_value_continuations(&mut self, fields: Vec<ParsedField>) -> Vec<ParsedField> {
4218 let Some(target_column) = fields.first().map(|field| self.source_column(field.name.span.start())) else {
4219 return fields;
4220 };
4221 self.recover_fields(fields, target_column)
4222 }
4223
4224 fn recover_fields(&mut self, fields: Vec<ParsedField>, target_column: usize) -> Vec<ParsedField> {
4225 let mut flattened = Vec::new();
4226 for mut field in fields {
4227 let field_column = self.source_column(field.name.span.start());
4228 let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
4229 let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
4230 !self.is_flow_mapping(mapping)
4231 && mapping
4232 .entries()
4233 .find_map(|entry| {
4234 let key = entry.key_node()?;
4235 let scalar = key.as_scalar()?;
4236 Some(scalar.byte_range().start as usize)
4237 })
4238 .is_some_and(|key_start| self.source_column(key_start) <= field_column)
4239 });
4240
4241 if continuation {
4242 field.value = None;
4243 field.value_span = None;
4244 field.span = field.name.span;
4245 }
4246 if field_column == target_column {
4247 flattened.push(field);
4248 }
4249 if let Some(mapping) = nested_mapping.filter(|mapping| !self.is_flow_mapping(mapping)) {
4250 let nested = self.raw_fields(&mapping);
4251 flattened.extend(self.recover_fields(nested, target_column));
4252 }
4253 }
4254 flattened
4255 }
4256
4257 fn is_flow_mapping(&self, mapping: &Mapping) -> bool {
4258 let position = mapping.byte_range();
4259 self.source
4260 .get(position.start as usize..position.end as usize)
4261 .is_some_and(|text| text.trim_start().starts_with('{'))
4262 }
4263
4264 fn record_duplicate(&mut self, seen: &mut BTreeMap<String, SourceSpan>, field: &ParsedField) -> bool {
4265 if let Some(first) = seen.get(field.name.value()) {
4266 self.diagnostics.push(
4267 Diagnostic::new(
4268 DUPLICATE_FIELD,
4269 Severity::Error,
4270 "Compose mapping fields must be unique",
4271 )
4272 .with_label(DiagnosticLabel::primary(field.name.span, "duplicate field"))
4273 .with_label(DiagnosticLabel::secondary(*first, "first field")),
4274 );
4275 true
4276 } else {
4277 seen.insert(field.name.value.clone(), field.name.span);
4278 false
4279 }
4280 }
4281
4282 fn expected(&mut self, code: DiagnosticCode, field: &ParsedField, message: impl Into<String>) {
4283 self.diagnostics.push(
4284 Diagnostic::new(code, Severity::Error, message)
4285 .with_label(DiagnosticLabel::primary(field.span, "unexpected value form")),
4286 );
4287 }
4288
4289 fn missing(&mut self, code: DiagnosticCode, span: SourceSpan, message: &'static str) {
4290 self.diagnostics.push(
4291 Diagnostic::new(code, Severity::Error, message)
4292 .with_label(DiagnosticLabel::primary(span, "incomplete long syntax")),
4293 );
4294 }
4295}
4296
4297fn unwrap_processing_tag(node: YamlNode) -> YamlNode {
4298 let YamlNode::TaggedNode(tagged) = &node else {
4299 return node;
4300 };
4301 if !matches!(tagged.tag().as_deref(), Some("!reset" | "!override")) {
4302 return node;
4303 }
4304 tagged
4305 .as_node()
4306 .and_then(|syntax| syntax.children().find_map(YamlNode::from_syntax))
4307 .unwrap_or(node)
4308}
4309
4310#[derive(Debug, Clone)]
4311enum ParsedGrant {
4312 Short(Located<String>),
4313 Long(Box<LongGrant>),
4314}
4315
4316#[derive(Debug, Clone)]
4317struct ParsedField {
4318 name: Located<String>,
4319 value: Option<YamlNode>,
4320 value_span: Option<SourceSpan>,
4321 span: SourceSpan,
4322}
4323
4324impl ParsedField {
4325 fn reference(&self) -> FieldReference {
4326 FieldReference {
4327 name: self.name.clone(),
4328 span: self.span,
4329 value_span: self.value_span,
4330 }
4331 }
4332}
4333
4334fn node_span(source_id: SourceId, node: &YamlNode) -> Option<SourceSpan> {
4335 let position = match node {
4336 YamlNode::Scalar(value) => value.byte_range(),
4337 YamlNode::Mapping(value) => value.byte_range(),
4338 YamlNode::Sequence(value) => value.byte_range(),
4339 YamlNode::Alias(_) | YamlNode::TaggedNode(_) => {
4340 let range = node.as_node()?.text_range();
4341 return Some(SourceSpan::from_valid_offsets(
4342 source_id,
4343 u32::from(range.start()) as usize,
4344 u32::from(range.end()) as usize,
4345 ));
4346 }
4347 };
4348 Some(span_from_position(source_id, position))
4349}
4350
4351fn span_from_position(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
4352 SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
4353}
4354
4355fn union(left: SourceSpan, right: SourceSpan) -> SourceSpan {
4356 SourceSpan::from_valid_offsets(
4357 left.source_id(),
4358 left.start().min(right.start()),
4359 left.end().max(right.end()),
4360 )
4361}