1mod annotation;
4mod blkio;
5mod build_extra_host;
6mod capability;
7mod cgroup;
8mod command;
9mod cpu_count;
10mod cpu_percent;
11mod cpu_period;
12mod cpu_quota;
13mod cpu_rt_period;
14mod credential_spec;
15mod dependency;
16mod device;
17mod dns;
18mod dns_option;
19mod dns_search;
20mod entrypoint;
21mod environment;
22mod expose;
23mod extends;
24mod host;
25mod hostname;
26mod identity;
27mod image;
28mod lifecycle;
29mod lifecycle_hook;
30mod logging;
31mod memory;
32mod network;
33mod pids;
34mod port;
35mod provider;
36mod pull;
37mod resource;
38mod restart;
39mod sections;
40mod security_option;
41mod shm;
42mod sysctl;
43mod tmpfs;
44mod ulimit;
45mod value;
46mod volume;
47
48pub use annotation::{Annotations, AnnotationsForm};
49pub use blkio::{
50 BlkioConfig, BlkioDeviceRate, BlkioDeviceRateForm, BlkioScalar, BlkioWeightDevice, BlkioWeightDeviceForm,
51};
52pub use build_extra_host::{BuildExtraHostAddresses, BuildExtraHostEntry, BuildExtraHosts};
53pub use capability::{CapabilityAdd, CapabilityAddItem, CapabilityDrop, CapabilityDropItem};
54pub use cgroup::{CgroupNamespace, CgroupNamespaceKind};
55pub use command::Command;
56pub use cpu_count::CpuCount;
57pub use cpu_percent::CpuPercent;
58pub use cpu_period::CpuPeriod;
59pub use cpu_quota::CpuQuota;
60pub use cpu_rt_period::CpuRtPeriod;
61pub use credential_spec::CredentialSpec;
62pub use dependency::{
63 DependencyCondition, DependsOn, Healthcheck, HealthcheckDuration, HealthcheckRetries, HealthcheckTest,
64 HealthcheckTestKind, ServiceDependency,
65};
66pub(crate) use device::valid_generated_device_string;
67pub use device::{Device, Devices, LongDevice, ShortDevice, ShortDeviceKind};
68pub use dns::{Dns, DnsForm};
69pub use dns_option::DnsOptions;
70pub use dns_search::{DnsSearch, DnsSearchForm};
71pub use entrypoint::Entrypoint;
72pub use environment::{
73 Environment, EnvironmentFile, EnvironmentFileFormat, EnvironmentFileFormatKind, EnvironmentListEntry,
74 EnvironmentMapEntry, LongEnvironmentFile,
75};
76pub use expose::{Expose, ExposeItem, ExposeItemKind, ExposePort, ExposeProtocol, ExposeScalarKind};
77pub(crate) use expose::{classify_expose_item, valid_generated_expose_item};
78pub use extends::{Extends, ExtendsReference};
79pub use host::{ExtraHostSeparator, ExtraHosts, HostAddress, HostAddressKind, LongExtraHost, ShortExtraHost};
80pub(crate) use hostname::valid_hostname;
81pub use hostname::{Hostname, HostnameKind};
82pub use identity::{IdentityComponent, UserNamespaceMode, UserNamespaceModeKind, UserSpec};
83pub use image::{ImageDigest, ImageReference};
84pub use lifecycle::StopGracePeriod;
85pub use lifecycle_hook::{
86 PostStartHook, PostStartHooks, PreStartHook, PreStartHooks, PreStartServiceHook, PreStopHook, PreStopHooks,
87 ServiceHook,
88};
89pub use logging::{Logging, LoggingOption, LoggingOptionValue, LoggingOptions};
90pub(crate) use memory::valid_generated_mem_amount;
91pub use memory::{MemLimit, MemLimitKind, MemLimitScalarKind, MemLimitUnit};
92pub use network::{Ipam, IpamConfig, NetworkDefinition, ServiceNetwork, ServiceNetworks};
93pub(crate) use pids::valid_positive_pids_decimal;
94pub use pids::{PidsLimit, PidsLimitKind};
95pub use port::{LongPort, Port, ShortPort};
96pub use provider::{Provider, ProviderOption, ProviderOptionItem, ProviderOptionValue, ProviderOptions};
97pub(crate) use pull::valid_pull_policy_duration;
98pub use pull::{PullPolicy, PullPolicyKind};
99pub use resource::{ConfigDefinition, ConfigGrant, LongGrant, SecretDefinition, SecretGrant, VolumeDefinition};
100pub use restart::{RestartPolicy, RestartPolicyKind};
101pub use sections::{
102 Build, BuildAdditionalContexts, BuildArgs, BuildDefinition, BuildField, BuildFieldKind, BuildNoCacheFilter,
103 BuildSsh, BuildSshForm, DeployDefinition, DeployDiscreteResourceSpec, DeployDiscreteResourceValue,
104 DeployEndpointMode, DeployField, DeployFieldKind, DeployGenericResource, DeployGenericResourceForm,
105 DeployGenericResources, DeployMode, DeployPlacement, DeployPlacementMaxReplicasPerNode, DeployPlacementPreference,
106 DeployReplicas, DeployReservationDevice, DeployReservationDeviceCapabilities, DeployReservationDeviceCapability,
107 DeployReservationDeviceCapabilityForm, DeployReservationDeviceCount, DeployReservationDeviceForm,
108 DeployReservationDeviceId, DeployReservationDeviceIdForm, DeployReservationDeviceIds,
109 DeployReservationDeviceOptionItem, DeployReservationDeviceOptionItemForm, DeployReservationDeviceOptions,
110 DeployReservationDevices, DeployResourceCpus, DeployResourceLimits, DeployResourceMemory, DeployResourceMemoryKind,
111 DeployResourceMemoryUnit, DeployResourcePids, DeployResourceReservations, DeployResources, DeployRestartCondition,
112 DeployRestartDuration, DeployRestartMaxAttempts, DeployRestartPolicy, DeployRollbackConfig,
113 DeployRollbackMaxFailureRatio, DeployRollbackOrder, DeployRollbackParallelism, DeployUpdateConfig,
114 DeployUpdateMaxFailureRatio, DeployUpdateOrder, DeployUpdateParallelism,
115};
116pub(crate) use security_option::{SecurityOptionCandidateCounts, classify_security_option};
117pub use security_option::{SecurityOptionItem, SecurityOptionKind, SecurityOptions};
118pub(crate) use shm::valid_generated_shm_amount;
119pub use shm::{ShmSize, ShmSizeKind, ShmSizeScalarKind, ShmSizeUnit};
120pub use sysctl::{Sysctls, SysctlsForm};
121pub(crate) use tmpfs::valid_generated_tmpfs_item;
122pub use tmpfs::{Tmpfs, TmpfsForm, TmpfsItem, TmpfsItemKind};
123pub(crate) use ulimit::valid_ulimit_name;
124pub use ulimit::{LimitValue, Ulimit, UlimitRange, UlimitValue, Ulimits};
125pub use value::{BooleanValue, BuildNoCache, BuildProvenance, BuildSbom, ComposeScalar, KeyValueEntry, Labels};
126pub use volume::{
127 BindOptions, ContainerPath, ContainerPathKind, LongVolumeMount, MountType, SelinuxRelabel, ShortVolumeMount,
128 VolumeMount, VolumeSyntax,
129};
130
131use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
132use crate::source::{SourceId, SourceSpan};
133use crate::syntax::{SyntaxDocument, scalar_string_from_source};
134use std::collections::{BTreeMap, BTreeSet};
135use yaml_edit::{AnchorRegistry, AsYaml, Mapping, Scalar, ScalarStyle, ScalarType, ScalarValue, YamlNode};
136
137pub const DOCUMENT_ROOT_TYPE: DiagnosticCode = DiagnosticCode::new("compose.document.expected-mapping");
139
140pub const MULTIPLE_DOCUMENTS: DiagnosticCode = DiagnosticCode::new("compose.document.multiple-documents");
142
143pub const DUPLICATE_FIELD: DiagnosticCode = DiagnosticCode::new("compose.model.duplicate-field");
145pub const CGROUP_NAMESPACE_INVALID: DiagnosticCode = DiagnosticCode::new("compose.cgroup.invalid-namespace");
147pub const DEPLOY_ENDPOINT_MODE_PORTABILITY: DiagnosticCode =
149 DiagnosticCode::new("compose.deploy.endpoint-mode.portability");
150pub const DEPLOY_MODE_PORTABILITY: DiagnosticCode = DiagnosticCode::new("compose.deploy.mode.portability");
152pub const DEPLOY_UPDATE_CONFIG_ORDER_PORTABILITY: DiagnosticCode =
154 DiagnosticCode::new("compose.deploy.update-config.order.portability");
155pub const DEPLOY_ROLLBACK_CONFIG_ORDER_PORTABILITY: DiagnosticCode =
157 DiagnosticCode::new("compose.deploy.rollback-config.order.portability");
158pub const EXTENDS_MISSING_SERVICE: DiagnosticCode = DiagnosticCode::new("compose.extends.missing-service");
160pub const PROVIDER_MISSING_TYPE: DiagnosticCode = DiagnosticCode::new("compose.provider.missing-type");
162pub const POST_START_MISSING_COMMAND: DiagnosticCode = DiagnosticCode::new("compose.post-start.missing-command");
164pub const PRE_STOP_MISSING_COMMAND: DiagnosticCode = DiagnosticCode::new("compose.pre-stop.missing-command");
166pub const DEPLOY_RESERVATION_DEVICE_CAPABILITY_DUPLICATE_ITEM: DiagnosticCode =
168 DiagnosticCode::new("compose.deploy.reservations.devices.capabilities.duplicate-item");
169pub const DEPLOY_RESERVATION_DEVICE_MISSING_CAPABILITIES: DiagnosticCode =
171 DiagnosticCode::new("compose.deploy.reservations.devices.missing-capabilities");
172pub const DEPLOY_RESERVATION_DEVICE_ALLOCATION_SELECTOR_CONFLICT: DiagnosticCode =
174 DiagnosticCode::new("compose.deploy.reservations.devices.allocation-selector-conflict");
175pub const DEPLOY_RESERVATION_DEVICE_OPTIONS_EXPECTED_FORM: DiagnosticCode =
177 DiagnosticCode::new("compose.deploy.reservations.devices.options.expected-form");
178pub const DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY: DiagnosticCode =
180 DiagnosticCode::new("compose.deploy.reservations.devices.options.invalid-key");
181pub const DEPLOY_RESERVATION_DEVICE_OPTIONS_DUPLICATE_ITEM: DiagnosticCode =
183 DiagnosticCode::new("compose.deploy.reservations.devices.options.duplicate-item");
184pub const BUILD_NO_CACHE_FILTER_DUPLICATE_ITEM: DiagnosticCode =
186 DiagnosticCode::new("compose.build.no-cache-filter.duplicate-item");
187
188pub const EXPECTED_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.model.expected-mapping");
190
191pub const EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.model.expected-sequence");
193
194pub const EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.model.expected-scalar");
196
197pub const EXPECTED_BOOLEAN: DiagnosticCode = DiagnosticCode::new("compose.model.expected-boolean");
199
200pub const EXPECTED_FIELD_FORM: DiagnosticCode = DiagnosticCode::new("compose.model.expected-field-form");
202
203pub const BUILD_DOCKERFILE_EXPECTED_NON_EMPTY: DiagnosticCode =
205 DiagnosticCode::new("compose.build.dockerfile.expected-non-empty-scalar");
206
207pub const BUILD_DOCKERFILE_INLINE_CONFLICT: DiagnosticCode =
209 DiagnosticCode::new("compose.build.dockerfile-inline-conflict");
210
211pub const BUILD_NO_CACHE_EXPECTED_BOOLEAN_OR_STRING: DiagnosticCode =
213 DiagnosticCode::new("compose.build.no-cache.expected-boolean-or-string");
214
215pub const BUILD_SBOM_EXPECTED_BOOLEAN_OR_STRING: DiagnosticCode =
217 DiagnosticCode::new("compose.build.sbom.expected-boolean-or-string");
218
219pub const BUILD_ISOLATION_EXPECTED_STRING: DiagnosticCode =
221 DiagnosticCode::new("compose.build.isolation.expected-string");
222
223pub const BUILD_EXTRA_HOSTS_EXPECTED_FORM: DiagnosticCode =
225 DiagnosticCode::new("compose.build.extra-hosts.expected-form");
226
227pub const BUILD_EXTRA_HOSTS_EXPECTED_STRING: DiagnosticCode =
229 DiagnosticCode::new("compose.build.extra-hosts.expected-string");
230
231pub const BUILD_EXTRA_HOSTS_DUPLICATE_ITEM: DiagnosticCode =
233 DiagnosticCode::new("compose.build.extra-hosts.duplicate-item");
234
235pub const PORT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.port.expected-short-or-long");
237
238pub const PORT_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.port.long.missing-target");
240
241pub const GRANT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.grant.expected-short-or-long");
243
244pub const GRANT_MISSING_SOURCE: DiagnosticCode = DiagnosticCode::new("compose.grant.long.missing-source");
246
247pub const RESOURCE_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.resource.expected-mapping-or-null");
249
250pub const VOLUME_EXTERNAL_DRIVER_CONFIGURATION: DiagnosticCode =
254 DiagnosticCode::new("compose.volume.external-driver-configuration");
255
256pub const VOLUME_EXTERNAL_LABELS_CONFIGURATION: DiagnosticCode =
261 DiagnosticCode::new("compose.volume.external-labels-configuration");
262
263pub const VOLUME_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.volume.expected-short-or-long");
265
266pub const VOLUME_MISSING_TYPE: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-type");
268
269pub const VOLUME_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-target");
271
272pub const VOLUME_INVALID_SELINUX: DiagnosticCode = DiagnosticCode::new("compose.volume.bind.invalid-selinux");
274
275pub const EXTRA_HOST_INVALID_ENTRY: DiagnosticCode = DiagnosticCode::new("compose.extra-hosts.invalid-entry");
277
278pub const ULIMIT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.ulimits.invalid-value");
280
281pub const ULIMIT_INVALID_NAME: DiagnosticCode = DiagnosticCode::new("compose.ulimits.invalid-name");
283
284pub const ULIMIT_MISSING_RANGE_MEMBER: DiagnosticCode = DiagnosticCode::new("compose.ulimits.missing-range-member");
286
287pub const HEALTHCHECK_INVALID_TEST: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-test");
289
290pub const HEALTHCHECK_INVALID_DURATION: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-duration");
292
293pub const HEALTHCHECK_INVALID_RETRIES: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-retries");
295
296pub const RESTART_INVALID_POLICY: DiagnosticCode = DiagnosticCode::new("compose.restart.invalid-policy");
298
299pub const HOSTNAME_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.hostname.expected-string");
301
302pub const HOSTNAME_INVALID: DiagnosticCode = DiagnosticCode::new("compose.hostname.invalid-value");
304
305pub const PIDS_LIMIT_EXPECTED_VALUE: DiagnosticCode =
307 DiagnosticCode::new("compose.pids-limit.expected-number-or-string");
308
309pub const PIDS_LIMIT_INVALID: DiagnosticCode = DiagnosticCode::new("compose.pids-limit.invalid-value");
311
312pub const PIDS_LIMIT_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.pids-limit.ambiguous-zero");
314
315pub const CPU_COUNT_EXPECTED_VALUE: DiagnosticCode =
317 DiagnosticCode::new("compose.cpu-count.expected-integer-or-string");
318
319pub const CPU_COUNT_NEGATIVE: DiagnosticCode = DiagnosticCode::new("compose.cpu-count.negative-value");
321
322pub const CPU_PERCENT_OUT_OF_RANGE: DiagnosticCode = DiagnosticCode::new("compose.cpu-percent.out-of-range");
324
325pub const CPU_PERCENT_EXPECTED_VALUE: DiagnosticCode =
327 DiagnosticCode::new("compose.cpu-percent.expected-integer-or-string");
328
329pub const CPU_PERIOD_EXPECTED_VALUE: DiagnosticCode =
331 DiagnosticCode::new("compose.cpu-period.expected-number-or-string");
332
333pub const CPU_QUOTA_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.cpu-quota.expected-number-or-string");
335
336pub const CPU_RT_PERIOD_EXPECTED_VALUE: DiagnosticCode =
338 DiagnosticCode::new("compose.cpu-rt-period.expected-number-or-string");
339
340pub const CPU_RT_PERIOD_INVALID: DiagnosticCode = DiagnosticCode::new("compose.cpu-rt-period.invalid-duration");
342
343pub const SHM_SIZE_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.shm-size.expected-number-or-string");
345
346pub const SHM_SIZE_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.shm-size.ambiguous-zero");
348
349pub const SHM_SIZE_PROVIDER_DEPENDENT_NUMBER: DiagnosticCode =
351 DiagnosticCode::new("compose.shm-size.provider-dependent-number");
352
353pub const SHM_SIZE_PROVIDER_DEPENDENT_STRING: DiagnosticCode =
355 DiagnosticCode::new("compose.shm-size.provider-dependent-string");
356
357pub const MEM_LIMIT_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.expected-number-or-string");
359
360pub const MEM_LIMIT_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.ambiguous-zero");
362
363pub const MEM_LIMIT_SCHEMA_NUMBER: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.schema-number");
365
366pub const MEM_LIMIT_PROVIDER_DEPENDENT_STRING: DiagnosticCode =
368 DiagnosticCode::new("compose.mem-limit.provider-dependent-string");
369
370pub const PULL_POLICY_INVALID: DiagnosticCode = DiagnosticCode::new("compose.pull-policy.invalid-policy");
372
373pub const STOP_GRACE_PERIOD_INVALID: DiagnosticCode =
375 DiagnosticCode::new("compose.lifecycle.invalid-stop-grace-period");
376
377pub const CAP_DROP_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.expected-sequence");
379
380pub const CAP_DROP_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.expected-string");
382
383pub const CAP_DROP_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.duplicate-item");
385
386pub const CAP_ADD_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.cap-add.expected-sequence");
388
389pub const CAP_ADD_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.cap-add.expected-string");
391
392pub const CAP_ADD_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.cap-add.duplicate-item");
394
395pub const DEVICES_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-sequence");
397
398pub const DEVICE_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-short-or-long");
400
401pub const DEVICE_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-string");
403
404pub const DEVICE_MISSING_SOURCE: DiagnosticCode = DiagnosticCode::new("compose.devices.long.missing-source");
406
407pub const DNS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.dns.expected-string-or-list");
409
410pub const DNS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns.expected-string");
412
413pub const DNS_OPT_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.expected-sequence");
415
416pub const DNS_OPT_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.expected-string");
418
419pub const DNS_OPT_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.duplicate-item");
421
422pub const DNS_SEARCH_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.dns-search.expected-string-or-list");
424
425pub const DNS_SEARCH_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns-search.expected-string");
427
428pub const DNS_SEARCH_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.dns-search.duplicate-item");
430
431pub const EXPOSE_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.expose.expected-sequence");
433
434pub const EXPOSE_EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.expose.expected-string-or-number");
436
437pub const EXPOSE_INVALID_ITEM: DiagnosticCode = DiagnosticCode::new("compose.expose.invalid-item");
439
440pub const EXPOSE_PROVIDER_DEPENDENT: DiagnosticCode = DiagnosticCode::new("compose.expose.provider-dependent-protocol");
442
443pub const EXPOSE_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.expose.duplicate-item");
445
446pub const SECURITY_OPT_EXPECTED_SEQUENCE: DiagnosticCode =
448 DiagnosticCode::new("compose.security-opt.expected-sequence");
449
450pub const SECURITY_OPT_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.security-opt.expected-string");
452
453pub const SECURITY_OPT_EMPTY_ITEM: DiagnosticCode = DiagnosticCode::new("compose.security-opt.empty-item");
455
456pub const SECURITY_OPT_APPARMOR_NEAR_MISS: DiagnosticCode =
458 DiagnosticCode::new("compose.security-opt.apparmor-near-miss");
459
460pub const SECURITY_OPT_APPARMOR_CONFLICT: DiagnosticCode =
462 DiagnosticCode::new("compose.security-opt.apparmor-conflict");
463
464pub const SECURITY_OPT_SECCOMP_NEAR_MISS: DiagnosticCode =
466 DiagnosticCode::new("compose.security-opt.seccomp-near-miss");
467
468pub const SECURITY_OPT_SECCOMP_CONFLICT: DiagnosticCode = DiagnosticCode::new("compose.security-opt.seccomp-conflict");
470
471pub const SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS: DiagnosticCode =
473 DiagnosticCode::new("compose.security-opt.no-new-privileges-near-miss");
474
475pub const SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT: DiagnosticCode =
477 DiagnosticCode::new("compose.security-opt.no-new-privileges-conflict");
478
479pub const SECURITY_OPT_MASK_NEAR_MISS: DiagnosticCode = DiagnosticCode::new("compose.security-opt.mask-near-miss");
481
482pub const SECURITY_OPT_UNMASK_NEAR_MISS: DiagnosticCode = DiagnosticCode::new("compose.security-opt.unmask-near-miss");
484
485pub(crate) fn security_path_option_diagnostic(kind: &SecurityOptionKind, span: SourceSpan) -> Option<Diagnostic> {
486 let (code, message) = match kind {
487 SecurityOptionKind::MaskNearMiss => (
488 SECURITY_OPT_MASK_NEAR_MISS,
489 "mask candidates require exact lowercase `mask=<paths>` spelling with a non-empty whitespace-free payload",
490 ),
491 SecurityOptionKind::UnmaskNearMiss => (
492 SECURITY_OPT_UNMASK_NEAR_MISS,
493 "unmask candidates require exact lowercase `unmask=ALL` or colon-separated slash-prefixed paths without whitespace",
494 ),
495 _ => return None,
496 };
497 Some(
498 Diagnostic::new(code, Severity::Warning, message)
499 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
500 )
501}
502
503pub const SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS: DiagnosticCode =
505 DiagnosticCode::new("compose.security-opt.security-label-disable-near-miss");
506
507pub const SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT: DiagnosticCode =
509 DiagnosticCode::new("compose.security-opt.security-label-disable-conflict");
510
511pub const SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS: DiagnosticCode =
513 DiagnosticCode::new("compose.security-opt.security-label-filetype-near-miss");
514
515pub const SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT: DiagnosticCode =
517 DiagnosticCode::new("compose.security-opt.security-label-filetype-conflict");
518
519pub const SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS: DiagnosticCode =
521 DiagnosticCode::new("compose.security-opt.security-label-level-near-miss");
522
523pub const SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT: DiagnosticCode =
525 DiagnosticCode::new("compose.security-opt.security-label-level-conflict");
526
527pub const SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS: DiagnosticCode =
529 DiagnosticCode::new("compose.security-opt.security-label-nested-near-miss");
530
531pub const SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT: DiagnosticCode =
533 DiagnosticCode::new("compose.security-opt.security-label-nested-conflict");
534
535pub const SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS: DiagnosticCode =
537 DiagnosticCode::new("compose.security-opt.security-label-type-near-miss");
538
539pub const SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT: DiagnosticCode =
541 DiagnosticCode::new("compose.security-opt.security-label-type-conflict");
542
543fn authored_security_label_diagnostic(
544 kind: &SecurityOptionKind,
545 span: SourceSpan,
546 candidates: &mut SecurityOptionCandidateCounts,
547) -> Option<Diagnostic> {
548 match kind {
549 SecurityOptionKind::SecurityLabelDisable { .. } => {
550 candidates.security_label_disable += 1;
551 (candidates.security_label_disable > 1).then(|| {
552 Diagnostic::new(
553 SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT,
554 Severity::Warning,
555 "multiple SELinux label-disable candidates are retained; a consumer must resolve the conflict explicitly",
556 )
557 .with_label(DiagnosticLabel::primary(
558 span,
559 "additional SELinux label-disable candidate retained",
560 ))
561 })
562 }
563 SecurityOptionKind::SecurityLabelDisableNearMiss => Some(
564 Diagnostic::new(
565 SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS,
566 Severity::Warning,
567 "SELinux label-disable candidates require exact lowercase `label:disable` spelling without whitespace",
568 )
569 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
570 ),
571 SecurityOptionKind::SecurityLabelFileType { .. } => {
572 candidates.security_label_filetype += 1;
573 (candidates.security_label_filetype > 1).then(|| {
574 Diagnostic::new(
575 SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT,
576 Severity::Warning,
577 "multiple SELinux label-filetype candidates are retained; a consumer must resolve the conflict explicitly",
578 )
579 .with_label(DiagnosticLabel::primary(
580 span,
581 "additional SELinux label-filetype candidate retained",
582 ))
583 })
584 }
585 SecurityOptionKind::SecurityLabelFileTypeNearMiss => Some(
586 Diagnostic::new(
587 SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS,
588 Severity::Warning,
589 "SELinux label-filetype candidates require exact lowercase `label:filetype:<type>` spelling without whitespace",
590 )
591 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
592 ),
593 SecurityOptionKind::SecurityLabelLevel { .. } => {
594 candidates.security_label_level += 1;
595 (candidates.security_label_level > 1).then(|| {
596 Diagnostic::new(
597 SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT,
598 Severity::Warning,
599 "multiple SELinux label-level candidates are retained; a consumer must resolve the conflict explicitly",
600 )
601 .with_label(DiagnosticLabel::primary(
602 span,
603 "additional SELinux label-level candidate retained",
604 ))
605 })
606 }
607 SecurityOptionKind::SecurityLabelLevelNearMiss => Some(
608 Diagnostic::new(
609 SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS,
610 Severity::Warning,
611 "SELinux label-level candidates require exact lowercase `label:level:<level>` spelling without whitespace",
612 )
613 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
614 ),
615 SecurityOptionKind::SecurityLabelNested { .. } => {
616 candidates.security_label_nested += 1;
617 (candidates.security_label_nested > 1).then(|| {
618 Diagnostic::new(
619 SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT,
620 Severity::Warning,
621 "multiple SELinux label-nested candidates are retained; a consumer must resolve the conflict explicitly",
622 )
623 .with_label(DiagnosticLabel::primary(
624 span,
625 "additional SELinux label-nested candidate retained",
626 ))
627 })
628 }
629 SecurityOptionKind::SecurityLabelNestedNearMiss => Some(
630 Diagnostic::new(
631 SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS,
632 Severity::Warning,
633 "SELinux label-nested candidates require exact lowercase `label:nested` spelling without whitespace",
634 )
635 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
636 ),
637 SecurityOptionKind::SecurityLabelType { .. } | SecurityOptionKind::SecurityLabelTypeNearMiss => {
638 authored_security_label_type_diagnostic(kind, span, &mut candidates.security_label_type)
639 }
640 _ => None,
641 }
642}
643
644fn authored_security_label_type_diagnostic(
645 kind: &SecurityOptionKind,
646 span: SourceSpan,
647 candidates: &mut usize,
648) -> Option<Diagnostic> {
649 match kind {
650 SecurityOptionKind::SecurityLabelType { .. } => {
651 *candidates += 1;
652 (*candidates > 1).then(|| {
653 Diagnostic::new(
654 SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT,
655 Severity::Warning,
656 "multiple SELinux label-type candidates are retained; a consumer must resolve the conflict explicitly",
657 )
658 .with_label(DiagnosticLabel::primary(
659 span,
660 "additional SELinux label-type candidate retained",
661 ))
662 })
663 }
664 SecurityOptionKind::SecurityLabelTypeNearMiss => Some(
665 Diagnostic::new(
666 SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS,
667 Severity::Warning,
668 "SELinux label-type candidates require exact lowercase `label:type:<type>` spelling with one non-empty whitespace-free type",
669 )
670 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
671 ),
672 _ => None,
673 }
674}
675
676pub const ANNOTATIONS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.annotations.expected-map-or-list");
678
679pub const ANNOTATIONS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.annotations.expected-string");
681
682pub const ANNOTATIONS_EMPTY_NAME: DiagnosticCode = DiagnosticCode::new("compose.annotations.empty-name");
684
685pub const ANNOTATIONS_KEY_ONLY: DiagnosticCode = DiagnosticCode::new("compose.annotations.key-only");
687
688pub const ANNOTATIONS_DUPLICATE_NAME: DiagnosticCode = DiagnosticCode::new("compose.annotations.duplicate-name");
690
691pub const TMPFS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.expected-string-or-list");
693
694pub const TMPFS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.expected-string");
696
697pub const TMPFS_PROVIDER_DEPENDENT: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.provider-dependent-item");
699
700pub const SYSCTLS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-map-or-list");
702
703pub const SYSCTLS_EMPTY_KEY: DiagnosticCode = DiagnosticCode::new("compose.sysctls.empty-key");
705
706pub const SYSCTLS_EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-scalar");
708
709pub const SYSCTLS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-string");
711
712pub const SYSCTLS_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.sysctls.duplicate-item");
714
715pub const LOGGING_EXPECTED_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.logging.expected-mapping");
717
718pub const LOGGING_DRIVER_EXPECTED_STRING: DiagnosticCode =
720 DiagnosticCode::new("compose.logging.driver.expected-string");
721
722pub const LOGGING_OPTIONS_EXPECTED_MAPPING: DiagnosticCode =
724 DiagnosticCode::new("compose.logging.options.expected-mapping");
725
726pub const LOGGING_OPTION_EMPTY_KEY: DiagnosticCode = DiagnosticCode::new("compose.logging.option.empty-key");
728
729pub const LOGGING_OPTION_EXPECTED_SCALAR: DiagnosticCode =
731 DiagnosticCode::new("compose.logging.option.expected-scalar");
732
733pub const ENVIRONMENT_FILE_EXPECTED_FORM: DiagnosticCode =
735 DiagnosticCode::new("compose.environment-file.expected-short-or-long");
736
737pub const ENVIRONMENT_FILE_MISSING_PATH: DiagnosticCode =
739 DiagnosticCode::new("compose.environment-file.long.missing-path");
740
741pub const ENVIRONMENT_FILE_INVALID_FORMAT: DiagnosticCode =
743 DiagnosticCode::new("compose.environment-file.invalid-format");
744
745pub const DEPENDENCY_INVALID_CONDITION: DiagnosticCode = DiagnosticCode::new("compose.dependencies.invalid-condition");
747
748pub const DEPENDENCY_MISSING_SERVICE: DiagnosticCode = DiagnosticCode::new("compose.dependencies.missing-service");
750
751pub const DEPENDENCY_MISSING_HEALTHCHECK: DiagnosticCode =
753 DiagnosticCode::new("compose.dependencies.missing-healthcheck");
754
755pub const DEPENDENCY_HEALTHCHECK_UNVERIFIED: DiagnosticCode =
757 DiagnosticCode::new("compose.dependencies.healthcheck-unverified");
758
759pub const BUILD_SSH_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.build.ssh-expected-form");
761
762pub const BUILD_SSH_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.build.ssh-duplicate-item");
764
765#[derive(Debug, Clone, PartialEq, Eq)]
767pub struct Located<T> {
768 value: T,
769 span: SourceSpan,
770}
771
772impl<T> Located<T> {
773 pub(crate) const fn new(value: T, span: SourceSpan) -> Self {
774 Self { value, span }
775 }
776
777 #[must_use]
779 pub const fn value(&self) -> &T {
780 &self.value
781 }
782
783 #[must_use]
785 pub const fn span(&self) -> SourceSpan {
786 self.span
787 }
788
789 #[must_use]
791 pub fn into_value(self) -> T {
792 self.value
793 }
794}
795
796#[derive(Debug, Clone, PartialEq, Eq)]
801pub struct FieldReference {
802 name: Located<String>,
803 span: SourceSpan,
804 value_span: Option<SourceSpan>,
805}
806
807impl FieldReference {
808 #[must_use]
810 pub const fn name(&self) -> &Located<String> {
811 &self.name
812 }
813
814 #[must_use]
816 pub const fn span(&self) -> SourceSpan {
817 self.span
818 }
819
820 #[must_use]
822 pub const fn value_span(&self) -> Option<SourceSpan> {
823 self.value_span
824 }
825}
826
827#[derive(Debug, Clone, PartialEq, Eq)]
829pub struct Service {
830 name: Located<String>,
831 span: SourceSpan,
832 hostname: Option<Hostname>,
833 container_name: Option<Located<String>>,
834 image: Option<Located<ImageReference>>,
835 platform: Option<Located<String>>,
836 entrypoint: Option<Entrypoint>,
837 command: Option<Command>,
838 credential_spec: Option<CredentialSpec>,
839 extends: Option<Extends>,
840 provider: Option<Provider>,
841 post_start: Option<PostStartHooks>,
842 pre_stop: Option<PreStopHooks>,
843 pre_start: Option<PreStartHooks>,
844 blkio_config: Option<BlkioConfig>,
845 cgroup: Option<CgroupNamespace>,
846 cgroup_parent: Option<Located<String>>,
847 attach: Option<Located<BooleanValue>>,
848 init: Option<Located<BooleanValue>>,
849 stdin_open: Option<Located<BooleanValue>>,
850 tty: Option<Located<BooleanValue>>,
851 privileged: Option<Located<BooleanValue>>,
852 environment: Option<Environment>,
853 environment_files: Vec<EnvironmentFile>,
854 labels: Option<Labels>,
855 annotations: Option<Annotations>,
856 extra_hosts: Option<ExtraHosts>,
857 user: Option<UserSpec>,
858 userns_mode: Option<UserNamespaceMode>,
859 group_add: Vec<Located<String>>,
860 cap_add: Option<CapabilityAdd>,
861 cap_drop: Option<CapabilityDrop>,
862 devices: Option<Devices>,
863 dns: Option<Dns>,
864 dns_options: Option<DnsOptions>,
865 dns_search: Option<DnsSearch>,
866 expose: Option<Expose>,
867 security_options: Option<SecurityOptions>,
868 working_dir: Option<Located<String>>,
869 read_only: Option<Located<BooleanValue>>,
870 pids_limit: Option<PidsLimit>,
871 cpu_count: Option<Located<CpuCount>>,
872 cpu_percent: Option<Located<CpuPercent>>,
873 cpu_period: Option<Located<CpuPeriod>>,
874 cpu_quota: Option<Located<CpuQuota>>,
875 cpu_rt_period: Option<Located<CpuRtPeriod>>,
876 shm_size: Option<ShmSize>,
877 mem_limit: Option<MemLimit>,
878 tmpfs: Option<Tmpfs>,
879 sysctls: Option<Sysctls>,
880 logging: Option<Logging>,
881 pull_policy: Option<PullPolicy>,
882 pull_refresh_after: Option<Located<String>>,
883 restart: Option<RestartPolicy>,
884 runtime: Option<Located<String>>,
885 stop_signal: Option<Located<String>>,
886 stop_grace_period: Option<Located<StopGracePeriod>>,
887 ulimits: Option<Ulimits>,
888 depends_on: Option<DependsOn>,
889 healthcheck: Option<Healthcheck>,
890 build: Option<Build>,
891 deploy: Option<DeployDefinition>,
892 ports: Vec<Port>,
893 volumes: Vec<VolumeMount>,
894 networks: Option<ServiceNetworks>,
895 profiles: Vec<Located<String>>,
896 configs: Vec<ConfigGrant>,
897 secrets: Vec<SecretGrant>,
898 extension_fields: Vec<FieldReference>,
899 unknown_fields: Vec<FieldReference>,
900}
901
902impl Service {
903 fn new(name: Located<String>, span: SourceSpan) -> Self {
904 Self {
905 name,
906 span,
907 hostname: None,
908 container_name: None,
909 image: None,
910 platform: None,
911 entrypoint: None,
912 command: None,
913 credential_spec: None,
914 extends: None,
915 provider: None,
916 post_start: None,
917 pre_stop: None,
918 pre_start: None,
919 blkio_config: None,
920 cgroup: None,
921 cgroup_parent: None,
922 attach: None,
923 init: None,
924 stdin_open: None,
925 tty: None,
926 privileged: None,
927 environment: None,
928 environment_files: Vec::new(),
929 labels: None,
930 annotations: None,
931 extra_hosts: None,
932 user: None,
933 userns_mode: None,
934 group_add: Vec::new(),
935 cap_add: None,
936 cap_drop: None,
937 devices: None,
938 dns: None,
939 dns_options: None,
940 dns_search: None,
941 expose: None,
942 security_options: None,
943 working_dir: None,
944 read_only: None,
945 pids_limit: None,
946 cpu_count: None,
947 cpu_percent: None,
948 cpu_period: None,
949 cpu_quota: None,
950 cpu_rt_period: None,
951 shm_size: None,
952 mem_limit: None,
953 tmpfs: None,
954 sysctls: None,
955 logging: None,
956 pull_policy: None,
957 pull_refresh_after: None,
958 restart: None,
959 runtime: None,
960 stop_signal: None,
961 stop_grace_period: None,
962 ulimits: None,
963 depends_on: None,
964 healthcheck: None,
965 build: None,
966 deploy: None,
967 ports: Vec::new(),
968 volumes: Vec::new(),
969 networks: None,
970 profiles: Vec::new(),
971 configs: Vec::new(),
972 secrets: Vec::new(),
973 extension_fields: Vec::new(),
974 unknown_fields: Vec::new(),
975 }
976 }
977
978 #[must_use]
980 pub const fn name(&self) -> &Located<String> {
981 &self.name
982 }
983
984 #[must_use]
986 pub const fn span(&self) -> SourceSpan {
987 self.span
988 }
989
990 #[must_use]
992 pub const fn hostname(&self) -> Option<&Hostname> {
993 self.hostname.as_ref()
994 }
995
996 #[must_use]
998 pub const fn container_name(&self) -> Option<&Located<String>> {
999 self.container_name.as_ref()
1000 }
1001
1002 #[must_use]
1004 pub const fn image(&self) -> Option<&Located<ImageReference>> {
1005 self.image.as_ref()
1006 }
1007
1008 #[must_use]
1010 pub const fn platform(&self) -> Option<&Located<String>> {
1011 self.platform.as_ref()
1012 }
1013
1014 #[must_use]
1016 pub const fn entrypoint(&self) -> Option<&Entrypoint> {
1017 self.entrypoint.as_ref()
1018 }
1019
1020 #[must_use]
1022 pub const fn command(&self) -> Option<&Command> {
1023 self.command.as_ref()
1024 }
1025
1026 #[must_use]
1028 pub const fn credential_spec(&self) -> Option<&CredentialSpec> {
1029 self.credential_spec.as_ref()
1030 }
1031
1032 #[must_use]
1034 pub const fn extends(&self) -> Option<&Extends> {
1035 self.extends.as_ref()
1036 }
1037
1038 #[must_use]
1040 pub const fn provider(&self) -> Option<&Provider> {
1041 self.provider.as_ref()
1042 }
1043
1044 #[must_use]
1046 pub const fn post_start(&self) -> Option<&PostStartHooks> {
1047 self.post_start.as_ref()
1048 }
1049
1050 #[must_use]
1052 pub const fn pre_stop(&self) -> Option<&PreStopHooks> {
1053 self.pre_stop.as_ref()
1054 }
1055
1056 #[must_use]
1058 pub const fn pre_start(&self) -> Option<&PreStartHooks> {
1059 self.pre_start.as_ref()
1060 }
1061
1062 #[must_use]
1064 pub const fn blkio_config(&self) -> Option<&BlkioConfig> {
1065 self.blkio_config.as_ref()
1066 }
1067
1068 #[must_use]
1070 pub const fn cgroup(&self) -> Option<&CgroupNamespace> {
1071 self.cgroup.as_ref()
1072 }
1073
1074 #[must_use]
1076 pub const fn cgroup_parent(&self) -> Option<&Located<String>> {
1077 self.cgroup_parent.as_ref()
1078 }
1079
1080 #[must_use]
1082 pub const fn runtime(&self) -> Option<&Located<String>> {
1083 self.runtime.as_ref()
1084 }
1085
1086 #[must_use]
1088 pub const fn pull_refresh_after(&self) -> Option<&Located<String>> {
1089 self.pull_refresh_after.as_ref()
1090 }
1091
1092 #[must_use]
1094 pub const fn attach(&self) -> Option<&Located<BooleanValue>> {
1095 self.attach.as_ref()
1096 }
1097
1098 #[must_use]
1100 pub const fn init(&self) -> Option<&Located<BooleanValue>> {
1101 self.init.as_ref()
1102 }
1103
1104 #[must_use]
1106 pub const fn stdin_open(&self) -> Option<&Located<BooleanValue>> {
1107 self.stdin_open.as_ref()
1108 }
1109
1110 #[must_use]
1112 pub const fn tty(&self) -> Option<&Located<BooleanValue>> {
1113 self.tty.as_ref()
1114 }
1115
1116 #[must_use]
1118 pub const fn privileged(&self) -> Option<&Located<BooleanValue>> {
1119 self.privileged.as_ref()
1120 }
1121
1122 #[must_use]
1124 pub const fn environment(&self) -> Option<&Environment> {
1125 self.environment.as_ref()
1126 }
1127
1128 #[must_use]
1130 pub fn environment_files(&self) -> &[EnvironmentFile] {
1131 &self.environment_files
1132 }
1133
1134 #[must_use]
1136 pub const fn labels(&self) -> Option<&Labels> {
1137 self.labels.as_ref()
1138 }
1139
1140 #[must_use]
1142 pub const fn annotations(&self) -> Option<&Annotations> {
1143 self.annotations.as_ref()
1144 }
1145
1146 #[must_use]
1148 pub const fn extra_hosts(&self) -> Option<&ExtraHosts> {
1149 self.extra_hosts.as_ref()
1150 }
1151
1152 #[must_use]
1154 pub const fn user(&self) -> Option<&UserSpec> {
1155 self.user.as_ref()
1156 }
1157
1158 #[must_use]
1160 pub const fn userns_mode(&self) -> Option<&UserNamespaceMode> {
1161 self.userns_mode.as_ref()
1162 }
1163
1164 #[must_use]
1166 pub fn group_add(&self) -> &[Located<String>] {
1167 &self.group_add
1168 }
1169
1170 #[must_use]
1172 pub const fn cap_add(&self) -> Option<&CapabilityAdd> {
1173 self.cap_add.as_ref()
1174 }
1175
1176 #[must_use]
1178 pub const fn cap_drop(&self) -> Option<&CapabilityDrop> {
1179 self.cap_drop.as_ref()
1180 }
1181
1182 #[must_use]
1184 pub const fn devices(&self) -> Option<&Devices> {
1185 self.devices.as_ref()
1186 }
1187
1188 #[must_use]
1190 pub const fn dns(&self) -> Option<&Dns> {
1191 self.dns.as_ref()
1192 }
1193
1194 #[must_use]
1196 pub const fn dns_options(&self) -> Option<&DnsOptions> {
1197 self.dns_options.as_ref()
1198 }
1199
1200 #[must_use]
1202 pub const fn dns_search(&self) -> Option<&DnsSearch> {
1203 self.dns_search.as_ref()
1204 }
1205
1206 #[must_use]
1208 pub const fn expose(&self) -> Option<&Expose> {
1209 self.expose.as_ref()
1210 }
1211
1212 #[must_use]
1214 pub const fn security_options(&self) -> Option<&SecurityOptions> {
1215 self.security_options.as_ref()
1216 }
1217
1218 #[must_use]
1220 pub const fn working_dir(&self) -> Option<&Located<String>> {
1221 self.working_dir.as_ref()
1222 }
1223
1224 #[must_use]
1226 pub const fn read_only(&self) -> Option<&Located<BooleanValue>> {
1227 self.read_only.as_ref()
1228 }
1229
1230 #[must_use]
1232 pub const fn pids_limit(&self) -> Option<&PidsLimit> {
1233 self.pids_limit.as_ref()
1234 }
1235
1236 #[must_use]
1238 pub const fn cpu_count(&self) -> Option<&Located<CpuCount>> {
1239 self.cpu_count.as_ref()
1240 }
1241
1242 #[must_use]
1244 pub const fn cpu_percent(&self) -> Option<&Located<CpuPercent>> {
1245 self.cpu_percent.as_ref()
1246 }
1247
1248 #[must_use]
1250 pub const fn cpu_period(&self) -> Option<&Located<CpuPeriod>> {
1251 self.cpu_period.as_ref()
1252 }
1253
1254 #[must_use]
1256 pub const fn cpu_quota(&self) -> Option<&Located<CpuQuota>> {
1257 self.cpu_quota.as_ref()
1258 }
1259
1260 #[must_use]
1262 pub const fn cpu_rt_period(&self) -> Option<&Located<CpuRtPeriod>> {
1263 self.cpu_rt_period.as_ref()
1264 }
1265
1266 #[must_use]
1268 pub const fn shm_size(&self) -> Option<&ShmSize> {
1269 self.shm_size.as_ref()
1270 }
1271
1272 #[must_use]
1274 pub const fn mem_limit(&self) -> Option<&MemLimit> {
1275 self.mem_limit.as_ref()
1276 }
1277
1278 #[must_use]
1280 pub const fn tmpfs(&self) -> Option<&Tmpfs> {
1281 self.tmpfs.as_ref()
1282 }
1283
1284 #[must_use]
1286 pub const fn sysctls(&self) -> Option<&Sysctls> {
1287 self.sysctls.as_ref()
1288 }
1289
1290 #[must_use]
1292 pub const fn logging(&self) -> Option<&Logging> {
1293 self.logging.as_ref()
1294 }
1295
1296 #[must_use]
1298 pub const fn pull_policy(&self) -> Option<&PullPolicy> {
1299 self.pull_policy.as_ref()
1300 }
1301
1302 #[must_use]
1304 pub const fn restart(&self) -> Option<&RestartPolicy> {
1305 self.restart.as_ref()
1306 }
1307
1308 #[must_use]
1310 pub const fn stop_signal(&self) -> Option<&Located<String>> {
1311 self.stop_signal.as_ref()
1312 }
1313
1314 #[must_use]
1316 pub const fn stop_grace_period(&self) -> Option<&Located<StopGracePeriod>> {
1317 self.stop_grace_period.as_ref()
1318 }
1319
1320 #[must_use]
1322 pub const fn ulimits(&self) -> Option<&Ulimits> {
1323 self.ulimits.as_ref()
1324 }
1325
1326 #[must_use]
1328 pub const fn depends_on(&self) -> Option<&DependsOn> {
1329 self.depends_on.as_ref()
1330 }
1331
1332 #[must_use]
1334 pub const fn healthcheck(&self) -> Option<&Healthcheck> {
1335 self.healthcheck.as_ref()
1336 }
1337
1338 #[must_use]
1340 pub const fn build(&self) -> Option<&Build> {
1341 self.build.as_ref()
1342 }
1343
1344 #[must_use]
1346 pub const fn deploy(&self) -> Option<&DeployDefinition> {
1347 self.deploy.as_ref()
1348 }
1349
1350 #[must_use]
1352 pub fn ports(&self) -> &[Port] {
1353 &self.ports
1354 }
1355
1356 #[must_use]
1358 pub fn volumes(&self) -> &[VolumeMount] {
1359 &self.volumes
1360 }
1361
1362 #[must_use]
1364 pub const fn networks(&self) -> Option<&ServiceNetworks> {
1365 self.networks.as_ref()
1366 }
1367
1368 #[must_use]
1370 pub fn profiles(&self) -> &[Located<String>] {
1371 &self.profiles
1372 }
1373
1374 #[must_use]
1376 pub fn configs(&self) -> &[ConfigGrant] {
1377 &self.configs
1378 }
1379
1380 #[must_use]
1382 pub fn secrets(&self) -> &[SecretGrant] {
1383 &self.secrets
1384 }
1385
1386 #[must_use]
1388 pub fn extension_fields(&self) -> &[FieldReference] {
1389 &self.extension_fields
1390 }
1391
1392 #[must_use]
1394 pub fn unknown_fields(&self) -> &[FieldReference] {
1395 &self.unknown_fields
1396 }
1397}
1398
1399#[derive(Debug, Clone, PartialEq, Eq)]
1401pub struct ComposeDocument {
1402 source_id: SourceId,
1403 span: SourceSpan,
1404 name: Option<Located<String>>,
1405 services: Vec<Service>,
1406 networks: Vec<NetworkDefinition>,
1407 volumes: Vec<VolumeDefinition>,
1408 configs: Vec<ConfigDefinition>,
1409 secrets: Vec<SecretDefinition>,
1410 extension_fields: Vec<FieldReference>,
1411 unknown_fields: Vec<FieldReference>,
1412}
1413
1414impl ComposeDocument {
1415 #[must_use]
1421 pub fn parse(syntax: &SyntaxDocument) -> ModelParse {
1422 Parser::new(syntax).parse()
1423 }
1424
1425 #[must_use]
1427 pub const fn source_id(&self) -> SourceId {
1428 self.source_id
1429 }
1430
1431 #[must_use]
1433 pub const fn span(&self) -> SourceSpan {
1434 self.span
1435 }
1436
1437 #[must_use]
1439 pub const fn name(&self) -> Option<&Located<String>> {
1440 self.name.as_ref()
1441 }
1442
1443 #[must_use]
1445 pub fn services(&self) -> &[Service] {
1446 &self.services
1447 }
1448
1449 #[must_use]
1451 pub fn service(&self, name: &str) -> Option<&Service> {
1452 self.services.iter().find(|service| service.name.value == name)
1453 }
1454
1455 #[must_use]
1460 pub fn validate_dependencies(&self) -> Vec<Diagnostic> {
1461 let mut diagnostics = Vec::new();
1462 for service in &self.services {
1463 let Some(depends_on) = service.depends_on() else {
1464 continue;
1465 };
1466 match depends_on {
1467 DependsOn::Short { services, .. } => {
1468 for target in services {
1469 if self.service(target.value()).is_none() {
1470 diagnostics.push(missing_dependency_diagnostic(target.span(), false, true));
1471 }
1472 }
1473 }
1474 DependsOn::Long { services, .. } => {
1475 for dependency in services {
1476 let required = !matches!(
1477 dependency.required().map(Located::value),
1478 Some(BooleanValue::Literal(false))
1479 );
1480 let Some(target) = self.service(dependency.service().value()) else {
1481 diagnostics.push(missing_dependency_diagnostic(
1482 dependency.service().span(),
1483 false,
1484 required,
1485 ));
1486 continue;
1487 };
1488 let needs_healthcheck = matches!(
1489 dependency.condition().map(Located::value),
1490 Some(DependencyCondition::ServiceHealthy)
1491 );
1492 if needs_healthcheck && target.healthcheck().is_none() {
1493 let span = dependency
1494 .condition()
1495 .map_or_else(|| dependency.service().span(), Located::span);
1496 diagnostics.push(unverified_healthcheck_diagnostic(span));
1497 } else if needs_healthcheck && target.healthcheck().is_some_and(Healthcheck::is_disabled) {
1498 let span = dependency
1499 .condition()
1500 .map_or_else(|| dependency.service().span(), Located::span);
1501 diagnostics.push(missing_dependency_diagnostic(span, true, required));
1502 }
1503 }
1504 }
1505 }
1506 }
1507 diagnostics
1508 }
1509
1510 #[must_use]
1512 pub fn networks(&self) -> &[NetworkDefinition] {
1513 &self.networks
1514 }
1515
1516 #[must_use]
1518 pub fn volumes(&self) -> &[VolumeDefinition] {
1519 &self.volumes
1520 }
1521
1522 #[must_use]
1524 pub fn configs(&self) -> &[ConfigDefinition] {
1525 &self.configs
1526 }
1527
1528 #[must_use]
1530 pub fn secrets(&self) -> &[SecretDefinition] {
1531 &self.secrets
1532 }
1533
1534 #[must_use]
1536 pub fn extension_fields(&self) -> &[FieldReference] {
1537 &self.extension_fields
1538 }
1539
1540 #[must_use]
1542 pub fn unknown_fields(&self) -> &[FieldReference] {
1543 &self.unknown_fields
1544 }
1545}
1546
1547#[derive(Debug, Clone, PartialEq, Eq)]
1549pub struct ModelParse {
1550 document: Option<ComposeDocument>,
1551 diagnostics: Vec<Diagnostic>,
1552}
1553
1554impl ModelParse {
1555 #[must_use]
1557 pub const fn document(&self) -> Option<&ComposeDocument> {
1558 self.document.as_ref()
1559 }
1560
1561 #[must_use]
1563 pub fn diagnostics(&self) -> &[Diagnostic] {
1564 &self.diagnostics
1565 }
1566
1567 #[must_use]
1569 pub fn is_valid(&self) -> bool {
1570 !self
1571 .diagnostics
1572 .iter()
1573 .any(|diagnostic| diagnostic.severity() == Severity::Error)
1574 }
1575
1576 #[must_use]
1578 pub fn into_parts(self) -> (Option<ComposeDocument>, Vec<Diagnostic>) {
1579 (self.document, self.diagnostics)
1580 }
1581}
1582
1583fn missing_dependency_diagnostic(span: SourceSpan, healthcheck: bool, required: bool) -> Diagnostic {
1584 let severity = if required { Severity::Error } else { Severity::Warning };
1585 if healthcheck {
1586 Diagnostic::new(
1587 DEPENDENCY_MISSING_HEALTHCHECK,
1588 severity,
1589 if required {
1590 "service_healthy dependency requires an enabled health check"
1591 } else {
1592 "optional service_healthy dependency has no enabled health check"
1593 },
1594 )
1595 .with_label(DiagnosticLabel::primary(span, "dependency cannot become healthy"))
1596 } else {
1597 Diagnostic::new(
1598 DEPENDENCY_MISSING_SERVICE,
1599 severity,
1600 if required {
1601 "service dependency is not declared in this Compose document"
1602 } else {
1603 "optional service dependency is not declared in this Compose document"
1604 },
1605 )
1606 .with_label(DiagnosticLabel::primary(span, "missing dependency service"))
1607 }
1608}
1609
1610fn unverified_healthcheck_diagnostic(span: SourceSpan) -> Diagnostic {
1611 Diagnostic::new(
1612 DEPENDENCY_HEALTHCHECK_UNVERIFIED,
1613 Severity::Warning,
1614 "service_healthy dependency has no Compose healthcheck to validate",
1615 )
1616 .with_label(DiagnosticLabel::primary(span, "image health metadata is not available"))
1617 .with_note("the dependency image may still define a health check; verify it at build or runtime")
1618}
1619
1620fn annotation_diagnostic(
1621 code: DiagnosticCode,
1622 severity: Severity,
1623 span: SourceSpan,
1624 message: &'static str,
1625 label: &'static str,
1626) -> Diagnostic {
1627 Diagnostic::new(code, severity, message).with_label(DiagnosticLabel::primary(span, label))
1628}
1629
1630#[derive(Debug)]
1631struct Parser {
1632 source_id: SourceId,
1633 source_span: SourceSpan,
1634 source: String,
1635 tree: yaml_edit::YamlFile,
1636 anchors: AnchorRegistry,
1637 diagnostics: Vec<Diagnostic>,
1638}
1639
1640impl Parser {
1641 fn new(syntax: &SyntaxDocument) -> Self {
1642 let tree = syntax.yaml_file();
1643 let anchors = tree
1644 .document()
1645 .map_or_else(AnchorRegistry::new, |document| AnchorRegistry::from_document(&document));
1646 Self {
1647 source_id: syntax.source_id(),
1648 source_span: syntax.source_span(),
1649 source: syntax.source_text().to_owned(),
1650 tree,
1651 anchors,
1652 diagnostics: Vec::new(),
1653 }
1654 }
1655
1656 fn parse(mut self) -> ModelParse {
1657 if self.tree.documents().count() > 1 {
1658 self.diagnostics.push(
1659 Diagnostic::new(
1660 MULTIPLE_DOCUMENTS,
1661 Severity::Error,
1662 "Compose input must contain one YAML document",
1663 )
1664 .with_label(DiagnosticLabel::primary(self.source_span, "multiple YAML documents")),
1665 );
1666 }
1667
1668 let Some(root) = self.tree.document() else {
1669 self.diagnostics.push(
1670 Diagnostic::new(
1671 DOCUMENT_ROOT_TYPE,
1672 Severity::Error,
1673 "Compose document root must be a mapping",
1674 )
1675 .with_label(DiagnosticLabel::primary(self.source_span, "empty document")),
1676 );
1677 return ModelParse {
1678 document: None,
1679 diagnostics: self.diagnostics,
1680 };
1681 };
1682 let root_span = span_from_position(self.source_id, root.byte_range());
1683 let Some(mapping) = root.as_mapping() else {
1684 self.diagnostics.push(
1685 Diagnostic::new(
1686 DOCUMENT_ROOT_TYPE,
1687 Severity::Error,
1688 "Compose document root must be a mapping",
1689 )
1690 .with_label(DiagnosticLabel::primary(root_span, "not a mapping")),
1691 );
1692 return ModelParse {
1693 document: None,
1694 diagnostics: self.diagnostics,
1695 };
1696 };
1697
1698 let document = self.parse_root(&mapping, root_span);
1699 ModelParse {
1700 document: Some(document),
1701 diagnostics: self.diagnostics,
1702 }
1703 }
1704
1705 fn parse_root(&mut self, mapping: &Mapping, span: SourceSpan) -> ComposeDocument {
1706 let mut document = ComposeDocument {
1707 source_id: self.source_id,
1708 span,
1709 name: None,
1710 services: Vec::new(),
1711 networks: Vec::new(),
1712 volumes: Vec::new(),
1713 configs: Vec::new(),
1714 secrets: Vec::new(),
1715 extension_fields: Vec::new(),
1716 unknown_fields: Vec::new(),
1717 };
1718 let mut seen = BTreeMap::new();
1719
1720 for field in self.fields(mapping) {
1721 let duplicate = self.record_duplicate(&mut seen, &field);
1722 match field.name.value.as_str() {
1723 "name" if !duplicate => {
1724 document.name = self.parse_string(&field, "project name");
1725 }
1726 "services" if !duplicate => {
1727 document.services = self.parse_services(&field);
1728 }
1729 "networks" if !duplicate => {
1730 document.networks = self.parse_network_definitions(&field);
1731 }
1732 "volumes" if !duplicate => {
1733 document.volumes = self.parse_volume_definitions(&field);
1734 }
1735 "configs" if !duplicate => {
1736 document.configs = self.parse_config_definitions(&field);
1737 }
1738 "secrets" if !duplicate => {
1739 document.secrets = self.parse_secret_definitions(&field);
1740 }
1741 name if name.starts_with("x-") => {
1742 document.extension_fields.push(field.reference());
1743 }
1744 _ if duplicate => {}
1745 _ => document.unknown_fields.push(field.reference()),
1746 }
1747 }
1748 document
1749 }
1750
1751 fn parse_services(&mut self, field: &ParsedField) -> Vec<Service> {
1752 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1753 self.expected(EXPECTED_MAPPING, field, "services must be a mapping");
1754 return Vec::new();
1755 };
1756 let mut services = Vec::new();
1757 let mut seen = BTreeMap::new();
1758 for service_field in self.fields(mapping) {
1759 self.record_duplicate(&mut seen, &service_field);
1760 let Some(service_mapping) = service_field.value.as_ref().and_then(YamlNode::as_mapping) else {
1761 self.expected(EXPECTED_MAPPING, &service_field, "service definition must be a mapping");
1762 continue;
1763 };
1764 services.push(self.parse_service(&service_field, service_mapping));
1765 }
1766 services
1767 }
1768
1769 fn parse_service(&mut self, field: &ParsedField, mapping: &Mapping) -> Service {
1770 let (mut service, mut seen) = (Service::new(field.name.clone(), field.span), BTreeMap::new());
1771 for service_field in self.fields(mapping) {
1772 let duplicate = self.record_duplicate(&mut seen, &service_field);
1773 match service_field.name.value.as_str() {
1774 "hostname" if !duplicate => service.hostname = self.parse_hostname(&service_field),
1775 "container_name" if !duplicate => {
1776 service.container_name = self.parse_string(&service_field, "container name");
1777 }
1778 "image" if !duplicate => service.image = self.parse_image(&service_field),
1779 "platform" if !duplicate => self.set_service_platform(&mut service, &service_field),
1780 "entrypoint" if !duplicate => service.entrypoint = self.parse_entrypoint(&service_field),
1781 "command" if !duplicate => service.command = self.parse_command(&service_field),
1782 "credential_spec" if !duplicate => service.credential_spec = self.parse_credential_spec(&service_field),
1783 "extends" if !duplicate => service.extends = self.parse_extends(&service_field),
1784 "provider" if !duplicate => service.provider = self.parse_provider(&service_field),
1785 "post_start" | "pre_stop" | "pre_start" if !duplicate => self.hooks(&mut service, &service_field),
1786 "blkio_config" if !duplicate => self.set_service_blkio_config(&mut service, &service_field),
1787 "cgroup" | "cgroup_parent" if !duplicate => self.set_service_cgroup(&mut service, &service_field),
1788 "attach" | "init" | "stdin_open" | "tty" | "privileged" if !duplicate => {
1789 self.set_service_boolean(&mut service, &service_field);
1790 }
1791 "environment" if !duplicate => service.environment = self.parse_environment(&service_field),
1792 "env_file" if !duplicate => service.environment_files = self.parse_environment_files(&service_field),
1793 "labels" if !duplicate => service.labels = self.parse_labels(&service_field),
1794 "annotations" if !duplicate => service.annotations = self.parse_annotations(&service_field),
1795 "extra_hosts" if !duplicate => service.extra_hosts = self.parse_extra_hosts(&service_field),
1796 "user" if !duplicate => service.user = self.parse_service_user(&service_field),
1797 "userns_mode" if !duplicate => {
1798 service.userns_mode = self
1799 .parse_string(&service_field, "service user namespace mode")
1800 .map(UserNamespaceMode::parse);
1801 }
1802 "group_add" if !duplicate => service.group_add = self.parse_service_group_add(&service_field),
1803 "cap_add" if !duplicate => service.cap_add = self.parse_cap_add(&service_field),
1804 "cap_drop" if !duplicate => service.cap_drop = self.parse_cap_drop(&service_field),
1805 "devices" if !duplicate => service.devices = self.parse_devices(&service_field),
1806 "dns" if !duplicate => service.dns = self.parse_dns(&service_field),
1807 "dns_opt" if !duplicate => service.dns_options = self.parse_dns_options(&service_field),
1808 "dns_search" if !duplicate => service.dns_search = self.parse_dns_search(&service_field),
1809 "expose" if !duplicate => service.expose = self.parse_expose(&service_field),
1810 "security_opt" if !duplicate => service.security_options = self.parse_security_options(&service_field),
1811 "working_dir" if !duplicate => {
1812 service.working_dir = self.parse_string(&service_field, "service working directory");
1813 }
1814 "read_only" if !duplicate => service.read_only = self.parse_service_read_only(&service_field),
1815 "cpu_count" | "cpu_percent" | "cpu_period" | "cpu_quota" | "cpu_rt_period" | "pids_limit"
1816 if !duplicate =>
1817 {
1818 self.set_service_count(&mut service, &service_field);
1819 }
1820 "shm_size" if !duplicate => service.shm_size = self.parse_shm_size(&service_field),
1821 "mem_limit" if !duplicate => service.mem_limit = self.parse_mem_limit(&service_field),
1822 "tmpfs" if !duplicate => service.tmpfs = self.parse_tmpfs(&service_field),
1823 "sysctls" if !duplicate => service.sysctls = self.parse_sysctls(&service_field),
1824 "logging" if !duplicate => service.logging = self.parse_logging(&service_field),
1825 "pull_policy" if !duplicate => service.pull_policy = self.parse_pull_policy(&service_field),
1826 "pull_refresh_after" if !duplicate => self.set_service_pull_refresh_after(&mut service, &service_field),
1827 "restart" if !duplicate => service.restart = self.parse_restart_policy(&service_field),
1828 "runtime" if !duplicate => self.set_service_runtime(&mut service, &service_field),
1829 "stop_signal" if !duplicate => {
1830 service.stop_signal = self.parse_string(&service_field, "service stop signal");
1831 }
1832 "stop_grace_period" if !duplicate => {
1833 service.stop_grace_period = self.parse_stop_grace_period(&service_field);
1834 }
1835 "ulimits" if !duplicate => service.ulimits = self.parse_ulimits(&service_field),
1836 "depends_on" if !duplicate => {
1837 service.depends_on = self.parse_depends_on(&service_field);
1838 }
1839 "healthcheck" if !duplicate => {
1840 service.healthcheck = self.parse_healthcheck(&service_field);
1841 }
1842 "build" if !duplicate => service.build = self.parse_build(&service_field),
1843 "deploy" if !duplicate => {
1844 service.deploy = self.parse_deploy(&service_field);
1845 }
1846 "ports" if !duplicate => {
1847 service.ports = self.parse_service_ports(&service_field);
1848 }
1849 "volumes" if !duplicate => {
1850 service.volumes = self.parse_service_volumes(&service_field);
1851 }
1852 "networks" if !duplicate => {
1853 service.networks = self.parse_service_networks(&service_field);
1854 }
1855 "profiles" if !duplicate => {
1856 service.profiles = self.parse_string_sequence(&service_field, "service profiles");
1857 }
1858 "configs" if !duplicate => {
1859 service.configs = self.parse_config_grants(&service_field);
1860 }
1861 "secrets" if !duplicate => {
1862 service.secrets = self.parse_secret_grants(&service_field).unwrap_or_default();
1863 }
1864 name if name.starts_with("x-") => service.extension_fields.push(service_field.reference()),
1865 _ if duplicate => {}
1866 _ => service.unknown_fields.push(service_field.reference()),
1867 }
1868 }
1869 service
1870 }
1871
1872 fn hooks(&mut self, service: &mut Service, field: &ParsedField) {
1873 match field.name.value().as_str() {
1874 "post_start" => service.post_start = self.parse_post_start(field),
1875 "pre_stop" => service.pre_stop = self.parse_pre_stop(field),
1876 "pre_start" => service.pre_start = self.parse_pre_start(field),
1877 _ => {}
1878 }
1879 }
1880
1881 fn parse_service_read_only(&mut self, field: &ParsedField) -> Option<Located<BooleanValue>> {
1882 self.parse_boolean(field, "service read_only")
1883 }
1884
1885 fn parse_service_group_add(&mut self, field: &ParsedField) -> Vec<Located<String>> {
1886 self.parse_string_sequence(field, "service supplementary groups")
1887 }
1888
1889 fn set_service_count(&mut self, service: &mut Service, field: &ParsedField) {
1890 match field.name.value().as_str() {
1891 "cpu_count" => {
1892 service.cpu_count = self.parse_cpu_count(field);
1893 if service.cpu_count.is_none() {
1894 service.unknown_fields.push(field.reference());
1895 }
1896 }
1897 "cpu_percent" => {
1898 service.cpu_percent = self.parse_cpu_percent(field);
1899 if service.cpu_percent.is_none() {
1900 service.unknown_fields.push(field.reference());
1901 }
1902 }
1903 "cpu_period" => {
1904 service.cpu_period = self.parse_cpu_period(field);
1905 if service.cpu_period.is_none() {
1906 service.unknown_fields.push(field.reference());
1907 }
1908 }
1909 "cpu_quota" => {
1910 service.cpu_quota = self.parse_cpu_quota(field);
1911 if service.cpu_quota.is_none() {
1912 service.unknown_fields.push(field.reference());
1913 }
1914 }
1915 "cpu_rt_period" => {
1916 service.cpu_rt_period = self.parse_cpu_rt_period(field);
1917 if service.cpu_rt_period.is_none() {
1918 service.unknown_fields.push(field.reference());
1919 }
1920 }
1921 "pids_limit" => service.pids_limit = self.parse_pids_limit(field),
1922 _ => {}
1923 }
1924 }
1925
1926 fn set_service_runtime(&mut self, service: &mut Service, field: &ParsedField) {
1927 service.runtime = self.parse_extends_string(field, "service runtime must be a YAML string scalar");
1928 if service.runtime.is_none() {
1929 service.unknown_fields.push(field.reference());
1930 }
1931 }
1932
1933 fn set_service_pull_refresh_after(&mut self, service: &mut Service, field: &ParsedField) {
1934 service.pull_refresh_after =
1935 self.parse_extends_string(field, "service pull_refresh_after must be a YAML string scalar");
1936 if service.pull_refresh_after.is_none() {
1937 service.unknown_fields.push(field.reference());
1938 }
1939 }
1940
1941 fn set_service_platform(&mut self, service: &mut Service, field: &ParsedField) {
1942 service.platform = self.parse_extends_string(field, "service platform must be a YAML string scalar");
1943 if service.platform.is_none() {
1944 service.unknown_fields.push(field.reference());
1945 }
1946 }
1947
1948 fn set_service_attach(&mut self, service: &mut Service, field: &ParsedField) {
1949 service.attach = self.parse_boolean(field, "service attach");
1950 if service.attach.is_none() {
1951 service.unknown_fields.push(field.reference());
1952 }
1953 }
1954
1955 fn set_service_boolean(&mut self, service: &mut Service, field: &ParsedField) {
1956 match field.name.value().as_str() {
1957 "attach" => self.set_service_attach(service, field),
1958 "init" => service.init = self.parse_boolean(field, "service init"),
1959 "stdin_open" => service.stdin_open = self.parse_boolean(field, "stdin_open"),
1960 "tty" => service.tty = self.parse_boolean(field, "tty"),
1961 "privileged" => service.privileged = self.parse_boolean(field, "privileged"),
1962 _ => {}
1963 }
1964 }
1965
1966 fn set_service_blkio_config(&mut self, service: &mut Service, field: &ParsedField) {
1967 service.blkio_config = self.parse_blkio_config(field);
1968 if service.blkio_config.is_none() {
1969 service.unknown_fields.push(field.reference());
1970 }
1971 }
1972
1973 fn set_service_cgroup(&mut self, service: &mut Service, field: &ParsedField) {
1974 match field.name.value().as_str() {
1975 "cgroup" => {
1976 service.cgroup = self.parse_cgroup_namespace(field);
1977 if service.cgroup.is_none() {
1978 service.unknown_fields.push(field.reference());
1979 }
1980 }
1981 "cgroup_parent" => {
1982 service.cgroup_parent =
1983 self.parse_extends_string(field, "service cgroup_parent must be a YAML string scalar");
1984 if service.cgroup_parent.is_none() {
1985 service.unknown_fields.push(field.reference());
1986 }
1987 }
1988 _ => {}
1989 }
1990 }
1991
1992 fn parse_cgroup_namespace(&mut self, field: &ParsedField) -> Option<CgroupNamespace> {
1993 let raw = self.parse_extends_string(field, "service cgroup must be a YAML string scalar")?;
1994 let cgroup = CgroupNamespace::parse(raw);
1995 if !cgroup.is_valid() {
1996 self.diagnostics.push(
1997 Diagnostic::new(
1998 CGROUP_NAMESPACE_INVALID,
1999 Severity::Warning,
2000 "service cgroup must be `host`, `private`, or a deferred expression",
2001 )
2002 .with_label(DiagnosticLabel::primary(
2003 cgroup.raw().span(),
2004 "retained unsupported cgroup namespace",
2005 )),
2006 );
2007 }
2008 Some(cgroup)
2009 }
2010
2011 fn parse_blkio_config(&mut self, field: &ParsedField) -> Option<BlkioConfig> {
2012 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
2013 self.expected(EXPECTED_MAPPING, field, "blkio_config must be a mapping");
2014 return None;
2015 };
2016 let mut config = BlkioConfig::new(span_from_position(self.source_id, mapping.byte_range()));
2017 let mut seen = BTreeMap::new();
2018 for option in self.fields(mapping) {
2019 if self.record_duplicate(&mut seen, &option) {
2020 continue;
2021 }
2022 match option.name.value().as_str() {
2023 name if name.starts_with("x-") => config.push_extension(option.reference()),
2024 "weight" => match self.parse_blkio_scalar(&option) {
2025 Some(value) => config.set_weight(value),
2026 None => config.push_unknown(option.reference()),
2027 },
2028 "device_read_bps" | "device_read_iops" | "device_write_bps" | "device_write_iops" => {
2029 if let Some(values) = self.parse_blkio_device_rates(&option) {
2030 if let Some(items) = config.device_rates_mut(option.name.value()) {
2031 items.extend(values);
2032 }
2033 } else {
2034 config.push_unknown(option.reference());
2035 }
2036 }
2037 "weight_device" => {
2038 if let Some(values) = self.parse_blkio_weight_devices(&option) {
2039 config.weight_devices_mut().extend(values);
2040 } else {
2041 config.push_unknown(option.reference());
2042 }
2043 }
2044 _ => config.push_unknown(option.reference()),
2045 }
2046 }
2047 Some(config)
2048 }
2049
2050 fn parse_blkio_scalar(&mut self, field: &ParsedField) -> Option<Located<BlkioScalar>> {
2051 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
2052 self.expected(
2053 EXPECTED_SCALAR,
2054 field,
2055 "blkio value must be a YAML integer or string scalar",
2056 );
2057 return None;
2058 };
2059 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
2060 ScalarType::Integer => BlkioScalar::YamlInteger(scalar_string_from_source(&self.source, scalar)),
2061 ScalarType::String => BlkioScalar::String(scalar_string_from_source(&self.source, scalar)),
2062 _ => {
2063 self.expected(
2064 EXPECTED_SCALAR,
2065 field,
2066 "blkio value must be a YAML integer or string scalar",
2067 );
2068 return None;
2069 }
2070 };
2071 Some(Located::new(
2072 value,
2073 span_from_position(self.source_id, scalar.byte_range()),
2074 ))
2075 }
2076
2077 fn parse_blkio_device_rates(&mut self, field: &ParsedField) -> Option<Vec<BlkioDeviceRate>> {
2078 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
2079 self.expected(EXPECTED_SEQUENCE, field, "blkio device rates must be sequences");
2080 return None;
2081 };
2082 let mut items = Vec::new();
2083 for node in sequence.values() {
2084 let Some(mapping) = node.as_mapping() else {
2085 self.unsupported_sequence_item(
2086 EXPECTED_MAPPING,
2087 &node,
2088 field.span,
2089 "blkio device rate entries must be mappings",
2090 );
2091 items.push(BlkioDeviceRate::unmodeled(
2092 node_span(self.source_id, &node).unwrap_or(field.span),
2093 ));
2094 continue;
2095 };
2096 let mut item = BlkioDeviceRate::new(span_from_position(self.source_id, mapping.byte_range()));
2097 let mut seen = BTreeMap::new();
2098 for option in self.fields(mapping) {
2099 if self.record_duplicate(&mut seen, &option) {
2100 continue;
2101 }
2102 match option.name.value().as_str() {
2103 name if name.starts_with("x-") => item.push_extension(option.reference()),
2104 "path" => match self.parse_string(&option, "blkio device path") {
2105 Some(value) => item.set_path(value),
2106 None => item.push_unknown(option.reference()),
2107 },
2108 "rate" => match self.parse_blkio_scalar(&option) {
2109 Some(value) => item.set_rate(value),
2110 None => item.push_unknown(option.reference()),
2111 },
2112 _ => item.push_unknown(option.reference()),
2113 }
2114 }
2115 items.push(item);
2116 }
2117 Some(items)
2118 }
2119
2120 fn parse_blkio_weight_devices(&mut self, field: &ParsedField) -> Option<Vec<BlkioWeightDevice>> {
2121 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
2122 self.expected(EXPECTED_SEQUENCE, field, "blkio weight devices must be sequences");
2123 return None;
2124 };
2125 let mut items = Vec::new();
2126 for node in sequence.values() {
2127 let Some(mapping) = node.as_mapping() else {
2128 self.unsupported_sequence_item(
2129 EXPECTED_MAPPING,
2130 &node,
2131 field.span,
2132 "blkio weight device entries must be mappings",
2133 );
2134 items.push(BlkioWeightDevice::unmodeled(
2135 node_span(self.source_id, &node).unwrap_or(field.span),
2136 ));
2137 continue;
2138 };
2139 let mut item = BlkioWeightDevice::new(span_from_position(self.source_id, mapping.byte_range()));
2140 let mut seen = BTreeMap::new();
2141 for option in self.fields(mapping) {
2142 if self.record_duplicate(&mut seen, &option) {
2143 continue;
2144 }
2145 match option.name.value().as_str() {
2146 name if name.starts_with("x-") => item.push_extension(option.reference()),
2147 "path" => match self.parse_string(&option, "blkio weight device path") {
2148 Some(value) => item.set_path(value),
2149 None => item.push_unknown(option.reference()),
2150 },
2151 "weight" => match self.parse_blkio_scalar(&option) {
2152 Some(value) => item.set_weight(value),
2153 None => item.push_unknown(option.reference()),
2154 },
2155 _ => item.push_unknown(option.reference()),
2156 }
2157 }
2158 items.push(item);
2159 }
2160 Some(items)
2161 }
2162
2163 fn parse_hostname(&mut self, field: &ParsedField) -> Option<Hostname> {
2164 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
2165 self.expected(HOSTNAME_EXPECTED_STRING, field, "hostname must be a YAML string scalar");
2166 return None;
2167 };
2168 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
2169 self.expected(HOSTNAME_EXPECTED_STRING, field, "hostname must be a YAML string scalar");
2170 return None;
2171 }
2172 let span = span_from_position(self.source_id, scalar.byte_range());
2173 let hostname = Hostname::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
2174 if hostname.kind() == &HostnameKind::Invalid {
2175 self.diagnostics.push(
2176 Diagnostic::new(
2177 HOSTNAME_INVALID,
2178 Severity::Error,
2179 "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",
2180 )
2181 .with_label(DiagnosticLabel::primary(span, "invalid service hostname"))
2182 .with_note("each label must start and end with an ASCII letter or digit"),
2183 );
2184 }
2185 Some(hostname)
2186 }
2187
2188 fn parse_image(&mut self, field: &ParsedField) -> Option<Located<ImageReference>> {
2189 self.parse_string(field, "service image")
2190 .map(|value| Located::new(ImageReference::parse(value.value), value.span))
2191 }
2192
2193 fn parse_cap_drop(&mut self, field: &ParsedField) -> Option<CapabilityDrop> {
2194 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
2195 self.expected(
2196 CAP_DROP_EXPECTED_SEQUENCE,
2197 field,
2198 "cap_drop must be a sequence of string scalars",
2199 );
2200 return None;
2201 };
2202 let span = span_from_position(self.source_id, sequence.byte_range());
2203 let mut items = Vec::new();
2204 let mut seen = BTreeMap::new();
2205 for node in sequence.values() {
2206 let YamlNode::Scalar(scalar) = node else {
2207 self.unsupported_sequence_item(
2208 CAP_DROP_EXPECTED_STRING,
2209 &node,
2210 field.span,
2211 "cap_drop entries must be string scalars",
2212 );
2213 continue;
2214 };
2215 let scalar_type = ScalarValue::from_scalar(&scalar).scalar_type();
2216 if !matches!(
2217 scalar_type,
2218 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2219 ) {
2220 self.unsupported_sequence_item(
2221 CAP_DROP_EXPECTED_STRING,
2222 &YamlNode::Scalar(scalar),
2223 field.span,
2224 "cap_drop entries must be string scalars",
2225 );
2226 continue;
2227 }
2228 let item_span = span_from_position(self.source_id, scalar.byte_range());
2229 let value = scalar_string_from_source(&self.source, &scalar);
2230 if let Some(first) = seen.get(&value) {
2231 self.diagnostics.push(
2232 Diagnostic::new(
2233 CAP_DROP_DUPLICATE_ITEM,
2234 Severity::Error,
2235 "cap_drop entries must be unique exact strings",
2236 )
2237 .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
2238 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
2239 );
2240 } else {
2241 seen.insert(value.clone(), item_span);
2242 }
2243 items.push(CapabilityDropItem::new(Located::new(value, item_span)));
2244 }
2245 Some(CapabilityDrop::new(span, items))
2246 }
2247
2248 fn parse_cap_add(&mut self, field: &ParsedField) -> Option<CapabilityAdd> {
2249 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
2250 self.expected(
2251 CAP_ADD_EXPECTED_SEQUENCE,
2252 field,
2253 "cap_add must be a sequence of string scalars",
2254 );
2255 return None;
2256 };
2257 let span = span_from_position(self.source_id, sequence.byte_range());
2258 let mut items = Vec::new();
2259 let mut seen = BTreeMap::new();
2260 for node in sequence.values() {
2261 let YamlNode::Scalar(scalar) = node else {
2262 self.unsupported_sequence_item(
2263 CAP_ADD_EXPECTED_STRING,
2264 &node,
2265 field.span,
2266 "cap_add entries must be string scalars",
2267 );
2268 continue;
2269 };
2270 let scalar_type = ScalarValue::from_scalar(&scalar).scalar_type();
2271 if !matches!(
2272 scalar_type,
2273 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2274 ) {
2275 self.unsupported_sequence_item(
2276 CAP_ADD_EXPECTED_STRING,
2277 &YamlNode::Scalar(scalar),
2278 field.span,
2279 "cap_add entries must be string scalars",
2280 );
2281 continue;
2282 }
2283 let item_span = span_from_position(self.source_id, scalar.byte_range());
2284 let value = scalar_string_from_source(&self.source, &scalar);
2285 if let Some(first) = seen.get(&value) {
2286 self.diagnostics.push(
2287 Diagnostic::new(
2288 CAP_ADD_DUPLICATE_ITEM,
2289 Severity::Error,
2290 "cap_add entries must be unique exact strings",
2291 )
2292 .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
2293 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
2294 );
2295 } else {
2296 seen.insert(value.clone(), item_span);
2297 }
2298 items.push(CapabilityAddItem::new(Located::new(value, item_span)));
2299 }
2300 Some(CapabilityAdd::new(span, items))
2301 }
2302
2303 fn parse_devices(&mut self, field: &ParsedField) -> Option<Devices> {
2304 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
2305 self.expected(
2306 DEVICES_EXPECTED_SEQUENCE,
2307 field,
2308 "service devices must be a sequence of string scalars or mappings",
2309 );
2310 return None;
2311 };
2312 let span = span_from_position(self.source_id, sequence.byte_range());
2313 let mut devices = Vec::new();
2314 for node in sequence.values() {
2315 match node {
2316 YamlNode::Scalar(scalar)
2317 if matches!(
2318 ScalarValue::from_scalar(&scalar).scalar_type(),
2319 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2320 ) =>
2321 {
2322 let item_span = span_from_position(self.source_id, scalar.byte_range());
2323 let raw = Located::new(scalar_string_from_source(&self.source, &scalar), item_span);
2324 devices.push(Device::Short(ShortDevice::new(raw)));
2325 }
2326 YamlNode::Mapping(mapping) => devices.push(Device::Long(self.parse_long_device(&mapping))),
2327 other => self.unsupported_sequence_item(
2328 DEVICE_EXPECTED_FORM,
2329 &other,
2330 field.span,
2331 "service device must use string short syntax or mapping long syntax",
2332 ),
2333 }
2334 }
2335 Some(Devices::new(span, devices))
2336 }
2337
2338 fn parse_long_device(&mut self, mapping: &Mapping) -> LongDevice {
2339 let span = span_from_position(self.source_id, mapping.byte_range());
2340 let mut device = LongDevice::new(span);
2341 let mut seen = BTreeMap::new();
2342 for field in self.fields(mapping) {
2343 let duplicate = self.record_duplicate(&mut seen, &field);
2344 match field.name.value.as_str() {
2345 "source" if !duplicate => self
2346 .parse_device_string(&field, "device source")
2347 .into_iter()
2348 .for_each(|value| device.set_source(value)),
2349 "target" if !duplicate => self
2350 .parse_device_string(&field, "device target")
2351 .into_iter()
2352 .for_each(|value| device.set_target(value)),
2353 "permissions" if !duplicate => self
2354 .parse_device_string(&field, "device permissions")
2355 .into_iter()
2356 .for_each(|value| device.set_permissions(value)),
2357 name if name.starts_with("x-") => device.push_extension(field.reference()),
2358 _ if duplicate => {}
2359 _ => device.push_unknown(field.reference()),
2360 }
2361 }
2362 if device.source().is_none() {
2363 self.missing(
2364 DEVICE_MISSING_SOURCE,
2365 span,
2366 "long service device is missing required string `source`",
2367 );
2368 }
2369 device
2370 }
2371
2372 fn parse_device_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
2373 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
2374 self.expected(
2375 DEVICE_EXPECTED_STRING,
2376 field,
2377 format!("{description} must be a string scalar"),
2378 );
2379 return None;
2380 };
2381 if !matches!(
2382 ScalarValue::from_scalar(scalar).scalar_type(),
2383 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2384 ) {
2385 self.expected(
2386 DEVICE_EXPECTED_STRING,
2387 field,
2388 format!("{description} must be a string scalar"),
2389 );
2390 return None;
2391 }
2392 Some(Located::new(
2393 scalar_string_from_source(&self.source, scalar),
2394 span_from_position(self.source_id, scalar.byte_range()),
2395 ))
2396 }
2397
2398 fn parse_dns(&mut self, field: &ParsedField) -> Option<Dns> {
2399 let value = field.value.as_ref()?;
2400 if let Some(scalar) = value.as_scalar() {
2401 if !matches!(
2402 ScalarValue::from_scalar(scalar).scalar_type(),
2403 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2404 ) {
2405 self.expected(
2406 DNS_EXPECTED_FORM,
2407 field,
2408 "dns must be a string scalar or a sequence of string scalars",
2409 );
2410 return None;
2411 }
2412 let span = span_from_position(self.source_id, scalar.byte_range());
2413 return Some(Dns::new(
2414 span,
2415 DnsForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
2416 ));
2417 }
2418
2419 let Some(sequence) = value.as_sequence() else {
2420 self.expected(
2421 DNS_EXPECTED_FORM,
2422 field,
2423 "dns must be a string scalar or a sequence of string scalars",
2424 );
2425 return None;
2426 };
2427 let span = span_from_position(self.source_id, sequence.byte_range());
2428 let mut items = Vec::new();
2429 for node in sequence.values() {
2430 let YamlNode::Scalar(scalar) = node else {
2431 self.unsupported_sequence_item(
2432 DNS_EXPECTED_STRING,
2433 &node,
2434 field.span,
2435 "dns entries must be string scalars",
2436 );
2437 continue;
2438 };
2439 if !matches!(
2440 ScalarValue::from_scalar(&scalar).scalar_type(),
2441 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2442 ) {
2443 self.unsupported_sequence_item(
2444 DNS_EXPECTED_STRING,
2445 &YamlNode::Scalar(scalar),
2446 field.span,
2447 "dns entries must be string scalars",
2448 );
2449 continue;
2450 }
2451 let item_span = span_from_position(self.source_id, scalar.byte_range());
2452 items.push(Located::new(
2453 scalar_string_from_source(&self.source, &scalar),
2454 item_span,
2455 ));
2456 }
2457 Some(Dns::new(span, DnsForm::List(items)))
2458 }
2459
2460 fn parse_dns_options(&mut self, field: &ParsedField) -> Option<DnsOptions> {
2461 let value = field.value.as_ref()?;
2462 let Some(sequence) = value.as_sequence() else {
2463 self.expected(
2464 DNS_OPT_EXPECTED_SEQUENCE,
2465 field,
2466 "dns_opt must be a sequence of string scalars",
2467 );
2468 return None;
2469 };
2470 let span = span_from_position(self.source_id, sequence.byte_range());
2471 let mut items = Vec::new();
2472 let mut seen = BTreeSet::new();
2473 for node in sequence.values() {
2474 let YamlNode::Scalar(scalar) = node else {
2475 self.unsupported_sequence_item(
2476 DNS_OPT_EXPECTED_STRING,
2477 &node,
2478 field.span,
2479 "dns_opt entries must be string scalars",
2480 );
2481 continue;
2482 };
2483 if !matches!(
2484 ScalarValue::from_scalar(&scalar).scalar_type(),
2485 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2486 ) {
2487 self.unsupported_sequence_item(
2488 DNS_OPT_EXPECTED_STRING,
2489 &YamlNode::Scalar(scalar),
2490 field.span,
2491 "dns_opt entries must be string scalars",
2492 );
2493 continue;
2494 }
2495 let item_span = span_from_position(self.source_id, scalar.byte_range());
2496 let option = scalar_string_from_source(&self.source, &scalar);
2497 if !seen.insert(option.clone()) {
2498 self.diagnostics.push(
2499 Diagnostic::new(
2500 DNS_OPT_DUPLICATE_ITEM,
2501 Severity::Warning,
2502 "dns_opt entries must be unique exact strings",
2503 )
2504 .with_label(DiagnosticLabel::primary(item_span, "duplicate DNS option retained")),
2505 );
2506 }
2507 items.push(Located::new(option, item_span));
2508 }
2509 Some(DnsOptions::new(span, items))
2510 }
2511
2512 fn parse_dns_search(&mut self, field: &ParsedField) -> Option<DnsSearch> {
2513 let value = field.value.as_ref()?;
2514 if let Some(scalar) = value.as_scalar() {
2515 if !matches!(
2516 ScalarValue::from_scalar(scalar).scalar_type(),
2517 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2518 ) {
2519 self.expected(
2520 DNS_SEARCH_EXPECTED_FORM,
2521 field,
2522 "dns_search must be a string scalar or a sequence of string scalars",
2523 );
2524 return None;
2525 }
2526 let span = span_from_position(self.source_id, scalar.byte_range());
2527 return Some(DnsSearch::new(
2528 span,
2529 DnsSearchForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
2530 ));
2531 }
2532
2533 let Some(sequence) = value.as_sequence() else {
2534 self.expected(
2535 DNS_SEARCH_EXPECTED_FORM,
2536 field,
2537 "dns_search must be a string scalar or a sequence of string scalars",
2538 );
2539 return None;
2540 };
2541 let span = span_from_position(self.source_id, sequence.byte_range());
2542 let mut items = Vec::new();
2543 let mut seen = BTreeSet::new();
2544 for node in sequence.values() {
2545 let YamlNode::Scalar(scalar) = node else {
2546 self.unsupported_sequence_item(
2547 DNS_SEARCH_EXPECTED_STRING,
2548 &node,
2549 field.span,
2550 "dns_search entries must be string scalars",
2551 );
2552 continue;
2553 };
2554 if !matches!(
2555 ScalarValue::from_scalar(&scalar).scalar_type(),
2556 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2557 ) {
2558 self.unsupported_sequence_item(
2559 DNS_SEARCH_EXPECTED_STRING,
2560 &YamlNode::Scalar(scalar),
2561 field.span,
2562 "dns_search entries must be string scalars",
2563 );
2564 continue;
2565 }
2566 let item_span = span_from_position(self.source_id, scalar.byte_range());
2567 let search = scalar_string_from_source(&self.source, &scalar);
2568 if !seen.insert(search.clone()) {
2569 self.diagnostics.push(
2570 Diagnostic::new(
2571 DNS_SEARCH_DUPLICATE_ITEM,
2572 Severity::Warning,
2573 "dns_search schema entries are unique, but duplicate merge behavior is ambiguous",
2574 )
2575 .with_label(DiagnosticLabel::primary(
2576 item_span,
2577 "duplicate DNS search domain retained",
2578 )),
2579 );
2580 }
2581 items.push(Located::new(search, item_span));
2582 }
2583 Some(DnsSearch::new(span, DnsSearchForm::List(items)))
2584 }
2585
2586 fn parse_expose(&mut self, field: &ParsedField) -> Option<Expose> {
2587 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
2588 self.expected(
2589 EXPOSE_EXPECTED_SEQUENCE,
2590 field,
2591 "expose must be a sequence of string or number scalars",
2592 );
2593 return None;
2594 };
2595 let span = span_from_position(self.source_id, sequence.byte_range());
2596 let mut items = Vec::new();
2597 let mut seen = Vec::new();
2598 for node in sequence.values() {
2599 let YamlNode::Scalar(scalar) = node else {
2600 self.unsupported_sequence_item(
2601 EXPOSE_EXPECTED_SCALAR,
2602 &node,
2603 field.span,
2604 "expose entries must be string or number scalars",
2605 );
2606 continue;
2607 };
2608 let scalar_kind = match ScalarValue::from_scalar(&scalar).scalar_type() {
2609 ScalarType::Integer | ScalarType::Float => ExposeScalarKind::Number,
2610 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => ExposeScalarKind::String,
2611 ScalarType::Null | ScalarType::Boolean => {
2612 self.unsupported_sequence_item(
2613 EXPOSE_EXPECTED_SCALAR,
2614 &YamlNode::Scalar(scalar),
2615 field.span,
2616 "expose entries must be string or number scalars",
2617 );
2618 continue;
2619 }
2620 };
2621 let item_span = span_from_position(self.source_id, scalar.byte_range());
2622 let raw = scalar_string_from_source(&self.source, &scalar);
2623 if seen.contains(&(scalar_kind, raw.clone())) {
2624 self.diagnostics.push(
2625 Diagnostic::new(
2626 EXPOSE_DUPLICATE_ITEM,
2627 Severity::Warning,
2628 "expose entries must be unique by exact scalar identity",
2629 )
2630 .with_label(DiagnosticLabel::primary(
2631 item_span,
2632 "duplicate exposed-port item retained",
2633 )),
2634 );
2635 } else {
2636 seen.push((scalar_kind, raw.clone()));
2637 }
2638 let item = ExposeItem::parse(Located::new(raw, item_span), scalar_kind);
2639 self.diagnose_expose_item(&item);
2640 items.push(item);
2641 }
2642 Some(Expose::new(span, items))
2643 }
2644
2645 fn diagnose_expose_item(&mut self, item: &ExposeItem) {
2646 match item.kind() {
2647 ExposeItemKind::Documented { .. } | ExposeItemKind::Expression => {}
2648 ExposeItemKind::Sctp { .. } | ExposeItemKind::UnknownProtocol { .. } => {
2649 self.diagnostics.push(
2650 Diagnostic::new(
2651 EXPOSE_PROVIDER_DEPENDENT,
2652 Severity::Warning,
2653 "expose protocol is outside the documented portable `tcp` and `udp` set",
2654 )
2655 .with_label(DiagnosticLabel::primary(
2656 item.span(),
2657 "provider-dependent exposed-port protocol retained",
2658 ))
2659 .with_note("ComposeLens does not normalize or reject the raw protocol spelling"),
2660 );
2661 }
2662 ExposeItemKind::Malformed => {
2663 self.diagnostics.push(
2664 Diagnostic::new(
2665 EXPOSE_INVALID_ITEM,
2666 Severity::Error,
2667 "expose item must be a decimal port or range with an optional protocol",
2668 )
2669 .with_label(DiagnosticLabel::primary(
2670 item.span(),
2671 "malformed exposed-port item retained",
2672 ))
2673 .with_note("use `PORT`, `START-END`, `PORT/tcp`, or `PORT/udp` for documented portable syntax"),
2674 );
2675 }
2676 }
2677 }
2678
2679 fn parse_security_options(&mut self, field: &ParsedField) -> Option<SecurityOptions> {
2680 let value = field.value.as_ref()?;
2681 let Some(sequence) = value.as_sequence() else {
2682 self.expected(
2683 SECURITY_OPT_EXPECTED_SEQUENCE,
2684 field,
2685 "security_opt must be a sequence of string scalars",
2686 );
2687 return None;
2688 };
2689 let span = span_from_position(self.source_id, sequence.byte_range());
2690 let mut items = Vec::new();
2691 let mut candidates = SecurityOptionCandidateCounts::default();
2692 for node in sequence.values() {
2693 let YamlNode::Scalar(scalar) = node else {
2694 self.unsupported_sequence_item(
2695 SECURITY_OPT_EXPECTED_STRING,
2696 &node,
2697 field.span,
2698 "security_opt entries must be string scalars",
2699 );
2700 continue;
2701 };
2702 if !matches!(
2703 ScalarValue::from_scalar(&scalar).scalar_type(),
2704 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2705 ) {
2706 self.unsupported_sequence_item(
2707 SECURITY_OPT_EXPECTED_STRING,
2708 &YamlNode::Scalar(scalar),
2709 field.span,
2710 "security_opt entries must be string scalars",
2711 );
2712 continue;
2713 }
2714 let item_span = span_from_position(self.source_id, scalar.byte_range());
2715 let raw = scalar_string_from_source(&self.source, &scalar);
2716 let item = SecurityOptionItem::parse(Located::new(raw, item_span));
2717 self.diagnose_security_option_item(item.kind(), item_span, &mut candidates);
2718 items.push(item);
2719 }
2720 Some(SecurityOptions::new(span, items))
2721 }
2722
2723 fn diagnose_security_option_item(
2724 &mut self,
2725 kind: &SecurityOptionKind,
2726 span: SourceSpan,
2727 candidates: &mut SecurityOptionCandidateCounts,
2728 ) {
2729 let diagnostic = match kind {
2730 SecurityOptionKind::AppArmor { .. } => {
2731 candidates.apparmor += 1;
2732 (candidates.apparmor > 1).then(|| {
2733 Diagnostic::new(
2734 SECURITY_OPT_APPARMOR_CONFLICT,
2735 Severity::Warning,
2736 "multiple AppArmor candidates are retained; a consumer must resolve the conflict explicitly",
2737 )
2738 .with_label(DiagnosticLabel::primary(span, "additional AppArmor candidate retained"))
2739 })
2740 }
2741 SecurityOptionKind::AppArmorNearMiss => Some(
2742 Diagnostic::new(
2743 SECURITY_OPT_APPARMOR_NEAR_MISS,
2744 Severity::Warning,
2745 "AppArmor candidates require exact lowercase `apparmor=<profile>` spelling without whitespace",
2746 )
2747 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2748 ),
2749 SecurityOptionKind::Seccomp { .. } => {
2750 candidates.seccomp += 1;
2751 (candidates.seccomp > 1).then(|| {
2752 Diagnostic::new(
2753 SECURITY_OPT_SECCOMP_CONFLICT,
2754 Severity::Warning,
2755 "multiple seccomp candidates are retained; a consumer must resolve the conflict explicitly",
2756 )
2757 .with_label(DiagnosticLabel::primary(span, "additional seccomp candidate retained"))
2758 })
2759 }
2760 SecurityOptionKind::SeccompNearMiss => Some(
2761 Diagnostic::new(
2762 SECURITY_OPT_SECCOMP_NEAR_MISS,
2763 Severity::Warning,
2764 "seccomp candidates require exact lowercase `seccomp=<profile>` spelling without whitespace",
2765 )
2766 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2767 ),
2768 SecurityOptionKind::NoNewPrivileges { .. } => {
2769 candidates.no_new_privileges += 1;
2770 (candidates.no_new_privileges > 1).then(|| {
2771 Diagnostic::new(
2772 SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT,
2773 Severity::Warning,
2774 "multiple no-new-privileges candidates are retained; a consumer must resolve the conflict explicitly",
2775 )
2776 .with_label(DiagnosticLabel::primary(
2777 span,
2778 "additional no-new-privileges candidate retained",
2779 ))
2780 })
2781 }
2782 SecurityOptionKind::NoNewPrivilegesNearMiss => Some(
2783 Diagnostic::new(
2784 SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS,
2785 Severity::Warning,
2786 "no-new-privileges candidates require exact lowercase `no-new-privileges:true` or `no-new-privileges:false` spelling without whitespace",
2787 )
2788 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2789 ),
2790 SecurityOptionKind::Mask { .. }
2791 | SecurityOptionKind::MaskNearMiss
2792 | SecurityOptionKind::Unmask { .. }
2793 | SecurityOptionKind::UnmaskNearMiss => security_path_option_diagnostic(kind, span),
2794 SecurityOptionKind::SecurityLabelDisable { .. }
2795 | SecurityOptionKind::SecurityLabelDisableNearMiss
2796 | SecurityOptionKind::SecurityLabelFileType { .. }
2797 | SecurityOptionKind::SecurityLabelFileTypeNearMiss
2798 | SecurityOptionKind::SecurityLabelLevel { .. }
2799 | SecurityOptionKind::SecurityLabelLevelNearMiss
2800 | SecurityOptionKind::SecurityLabelNested { .. }
2801 | SecurityOptionKind::SecurityLabelNestedNearMiss
2802 | SecurityOptionKind::SecurityLabelType { .. }
2803 | SecurityOptionKind::SecurityLabelTypeNearMiss => {
2804 authored_security_label_diagnostic(kind, span, candidates)
2805 }
2806 SecurityOptionKind::Empty => Some(
2807 Diagnostic::new(
2808 SECURITY_OPT_EMPTY_ITEM,
2809 Severity::Error,
2810 "security_opt entries must not be empty strings",
2811 )
2812 .with_label(DiagnosticLabel::primary(span, "empty security option retained")),
2813 ),
2814 SecurityOptionKind::Expression | SecurityOptionKind::Other => None,
2815 };
2816 if let Some(diagnostic) = diagnostic {
2817 self.diagnostics.push(diagnostic);
2818 }
2819 }
2820
2821 fn parse_tmpfs(&mut self, field: &ParsedField) -> Option<Tmpfs> {
2822 let value = field.value.as_ref()?;
2823 if let Some(scalar) = value.as_scalar() {
2824 if !matches!(
2825 ScalarValue::from_scalar(scalar).scalar_type(),
2826 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2827 ) {
2828 self.expected(
2829 TMPFS_EXPECTED_FORM,
2830 field,
2831 "tmpfs must be a string scalar or a sequence of string scalars",
2832 );
2833 return None;
2834 }
2835 let span = span_from_position(self.source_id, scalar.byte_range());
2836 let item = TmpfsItem::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
2837 self.diagnose_tmpfs_item(&item);
2838 return Some(Tmpfs::new(span, TmpfsForm::Scalar(item)));
2839 }
2840
2841 let Some(sequence) = value.as_sequence() else {
2842 self.expected(
2843 TMPFS_EXPECTED_FORM,
2844 field,
2845 "tmpfs must be a string scalar or a sequence of string scalars",
2846 );
2847 return None;
2848 };
2849 let span = span_from_position(self.source_id, sequence.byte_range());
2850 let mut items = Vec::new();
2851 for node in sequence.values() {
2852 let YamlNode::Scalar(scalar) = node else {
2853 self.unsupported_sequence_item(
2854 TMPFS_EXPECTED_STRING,
2855 &node,
2856 field.span,
2857 "tmpfs entries must be string scalars",
2858 );
2859 continue;
2860 };
2861 if !matches!(
2862 ScalarValue::from_scalar(&scalar).scalar_type(),
2863 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2864 ) {
2865 self.unsupported_sequence_item(
2866 TMPFS_EXPECTED_STRING,
2867 &YamlNode::Scalar(scalar),
2868 field.span,
2869 "tmpfs entries must be string scalars",
2870 );
2871 continue;
2872 }
2873 let item_span = span_from_position(self.source_id, scalar.byte_range());
2874 let raw = scalar_string_from_source(&self.source, &scalar);
2875 let item = TmpfsItem::parse(Located::new(raw, item_span));
2876 self.diagnose_tmpfs_item(&item);
2877 items.push(item);
2878 }
2879 Some(Tmpfs::new(span, TmpfsForm::List(items)))
2880 }
2881
2882 fn diagnose_tmpfs_item(&mut self, item: &TmpfsItem) {
2883 if item.kind() != TmpfsItemKind::ProviderDependent {
2884 return;
2885 }
2886 self.diagnostics.push(
2887 Diagnostic::new(
2888 TMPFS_PROVIDER_DEPENDENT,
2889 Severity::Warning,
2890 "tmpfs item is malformed or uses provider- or target-specific options",
2891 )
2892 .with_label(DiagnosticLabel::primary(
2893 item.span(),
2894 "provider-dependent temporary-filesystem item",
2895 ))
2896 .with_note("use a non-empty path with only non-empty `mode`, `uid`, or `gid` assignments for documented portable syntax"),
2897 );
2898 }
2899
2900 fn parse_sysctls(&mut self, field: &ParsedField) -> Option<Sysctls> {
2901 match field.value.as_ref() {
2902 Some(YamlNode::Mapping(mapping)) => {
2903 let span = span_from_position(self.source_id, mapping.byte_range());
2904 let mut entries = Vec::new();
2905 let mut seen = BTreeMap::new();
2906 for entry in self.fields(mapping) {
2907 if self.record_duplicate(&mut seen, &entry) {
2908 continue;
2909 }
2910 if entry.name.value.is_empty() {
2911 self.diagnostics.push(
2912 Diagnostic::new(
2913 SYSCTLS_EMPTY_KEY,
2914 Severity::Error,
2915 "sysctls mapping keys must not be empty",
2916 )
2917 .with_label(DiagnosticLabel::primary(entry.name.span, "empty sysctl name")),
2918 );
2919 continue;
2920 }
2921 if entry.value.as_ref().is_some_and(|value| value.as_scalar().is_none()) {
2922 self.diagnostics.push(
2923 Diagnostic::new(
2924 SYSCTLS_EXPECTED_SCALAR,
2925 Severity::Error,
2926 "sysctls mapping values must be scalar strings, numbers, booleans, or null",
2927 )
2928 .with_label(DiagnosticLabel::primary(
2929 entry.value_span.unwrap_or(entry.span),
2930 "non-scalar sysctl value",
2931 )),
2932 );
2933 continue;
2934 }
2935 let Some(value) = self.parse_compose_scalar(&entry, "sysctls mapping values must be scalars")
2936 else {
2937 continue;
2938 };
2939 entries.push(KeyValueEntry::new(entry.name, value, entry.span));
2940 }
2941 Some(Sysctls::new(span, SysctlsForm::Map(entries)))
2942 }
2943 Some(YamlNode::Sequence(sequence)) => {
2944 let span = span_from_position(self.source_id, sequence.byte_range());
2945 let mut items = Vec::new();
2946 let mut seen = BTreeMap::new();
2947 for node in sequence.values() {
2948 let YamlNode::Scalar(scalar) = node else {
2949 self.unsupported_sequence_item(
2950 SYSCTLS_EXPECTED_STRING,
2951 &node,
2952 field.span,
2953 "sysctls list entries must be YAML string scalars",
2954 );
2955 continue;
2956 };
2957 if !matches!(
2958 ScalarValue::from_scalar(&scalar).scalar_type(),
2959 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2960 ) {
2961 self.unsupported_sequence_item(
2962 SYSCTLS_EXPECTED_STRING,
2963 &YamlNode::Scalar(scalar),
2964 field.span,
2965 "sysctls list entries must be YAML string scalars",
2966 );
2967 continue;
2968 }
2969 let item_span = span_from_position(self.source_id, scalar.byte_range());
2970 let value = scalar_string_from_source(&self.source, &scalar);
2971 if let Some(first) = seen.get(&value) {
2972 self.diagnostics.push(
2973 Diagnostic::new(
2974 SYSCTLS_DUPLICATE_ITEM,
2975 Severity::Error,
2976 "sysctls list entries must be unique exact strings",
2977 )
2978 .with_label(DiagnosticLabel::primary(item_span, "duplicate sysctl string"))
2979 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
2980 );
2981 } else {
2982 seen.insert(value.clone(), item_span);
2983 }
2984 items.push(Located::new(value, item_span));
2985 }
2986 Some(Sysctls::new(span, SysctlsForm::List(items)))
2987 }
2988 _ => {
2989 self.expected(
2990 SYSCTLS_EXPECTED_FORM,
2991 field,
2992 "sysctls must be a mapping or a sequence of string scalars",
2993 );
2994 None
2995 }
2996 }
2997 }
2998
2999 fn parse_logging(&mut self, field: &ParsedField) -> Option<Logging> {
3000 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3001 self.expected(
3002 LOGGING_EXPECTED_MAPPING,
3003 field,
3004 "logging must be a mapping with optional driver and options fields",
3005 );
3006 return None;
3007 };
3008 let span = span_from_position(self.source_id, mapping.byte_range());
3009 let mut logging = Logging::new(span);
3010 let mut seen = BTreeMap::new();
3011 for member in self.fields(mapping) {
3012 let duplicate = self.record_duplicate(&mut seen, &member);
3013 match member.name.value.as_str() {
3014 "driver" if !duplicate => {
3015 if let Some(driver) = self.parse_logging_driver(&member) {
3016 logging.set_driver(driver);
3017 }
3018 }
3019 "options" if !duplicate => {
3020 if let Some(options) = self.parse_logging_options(&member) {
3021 logging.set_options(options);
3022 }
3023 }
3024 name if name.starts_with("x-") => logging.push_extension(member.reference()),
3025 _ if duplicate => {}
3026 _ => logging.push_unknown(member.reference()),
3027 }
3028 }
3029 Some(logging)
3030 }
3031
3032 fn parse_credential_spec(&mut self, field: &ParsedField) -> Option<CredentialSpec> {
3033 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3034 self.expected(EXPECTED_MAPPING, field, "credential_spec must be a mapping");
3035 return None;
3036 };
3037 let mut credential_spec = CredentialSpec::new(span_from_position(self.source_id, mapping.byte_range()));
3038 let mut seen = BTreeMap::new();
3039 for member in self.fields(mapping) {
3040 let duplicate = self.record_duplicate(&mut seen, &member);
3041 match member.name.value().as_str() {
3042 "config" if !duplicate => {
3043 if let Some(value) = self
3044 .parse_credential_spec_string(&member, "credential_spec config must be a YAML string scalar")
3045 {
3046 credential_spec.set_config(value);
3047 } else {
3048 credential_spec.push_unknown(member.reference());
3049 }
3050 }
3051 "file" if !duplicate => {
3052 if let Some(value) =
3053 self.parse_credential_spec_string(&member, "credential_spec file must be a YAML string scalar")
3054 {
3055 credential_spec.set_file(value);
3056 } else {
3057 credential_spec.push_unknown(member.reference());
3058 }
3059 }
3060 "registry" if !duplicate => {
3061 if let Some(value) = self
3062 .parse_credential_spec_string(&member, "credential_spec registry must be a YAML string scalar")
3063 {
3064 credential_spec.set_registry(value);
3065 } else {
3066 credential_spec.push_unknown(member.reference());
3067 }
3068 }
3069 name if name.starts_with("x-") => credential_spec.push_extension(member.reference()),
3070 _ if duplicate => {}
3071 _ => credential_spec.push_unknown(member.reference()),
3072 }
3073 }
3074 Some(credential_spec)
3075 }
3076
3077 fn parse_credential_spec_string(&mut self, field: &ParsedField, message: &'static str) -> Option<Located<String>> {
3078 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3079 self.expected(EXPECTED_SCALAR, field, message);
3080 return None;
3081 };
3082 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
3083 self.expected(EXPECTED_SCALAR, field, message);
3084 return None;
3085 }
3086 Some(Located::new(
3087 scalar_string_from_source(&self.source, scalar),
3088 span_from_position(self.source_id, scalar.byte_range()),
3089 ))
3090 }
3091
3092 fn parse_extends(&mut self, field: &ParsedField) -> Option<Extends> {
3093 match field.value.as_ref() {
3094 Some(YamlNode::Scalar(scalar)) if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::String => {
3095 Some(Extends::Short(Located::new(
3096 scalar_string_from_source(&self.source, scalar),
3097 span_from_position(self.source_id, scalar.byte_range()),
3098 )))
3099 }
3100 Some(YamlNode::Mapping(mapping)) => {
3101 let span = span_from_position(self.source_id, mapping.byte_range());
3102 let mut reference = ExtendsReference::new(span);
3103 let mut seen = BTreeMap::new();
3104 for member in self.fields(mapping) {
3105 let duplicate = self.record_duplicate(&mut seen, &member);
3106 match member.name.value().as_str() {
3107 "service" if !duplicate => {
3108 if let Some(value) =
3109 self.parse_extends_string(&member, "extends service must be a YAML string scalar")
3110 {
3111 reference.set_service(value);
3112 } else {
3113 reference.push_unknown(member.reference());
3114 }
3115 }
3116 "file" if !duplicate => {
3117 if let Some(value) =
3118 self.parse_extends_string(&member, "extends file must be a YAML string scalar")
3119 {
3120 reference.set_file(value);
3121 } else {
3122 reference.push_unknown(member.reference());
3123 }
3124 }
3125 name if name.starts_with("x-") => reference.push_extension(member.reference()),
3126 _ if duplicate => {}
3127 _ => reference.push_unknown(member.reference()),
3128 }
3129 }
3130 if reference.service().is_none() {
3131 self.missing(
3132 EXTENDS_MISSING_SERVICE,
3133 span,
3134 "long extends is missing required `service`",
3135 );
3136 }
3137 Some(Extends::Long(reference))
3138 }
3139 _ => {
3140 self.expected(
3141 EXPECTED_FIELD_FORM,
3142 field,
3143 "extends must be a YAML string scalar or mapping",
3144 );
3145 None
3146 }
3147 }
3148 }
3149
3150 fn parse_extends_string(&mut self, field: &ParsedField, message: impl Into<String>) -> Option<Located<String>> {
3151 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3152 self.expected(EXPECTED_SCALAR, field, message);
3153 return None;
3154 };
3155 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
3156 self.expected(EXPECTED_SCALAR, field, message);
3157 return None;
3158 }
3159 Some(Located::new(
3160 scalar_string_from_source(&self.source, scalar),
3161 span_from_position(self.source_id, scalar.byte_range()),
3162 ))
3163 }
3164
3165 fn parse_provider(&mut self, field: &ParsedField) -> Option<Provider> {
3166 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3167 self.expected(EXPECTED_MAPPING, field, "provider must be a mapping");
3168 return None;
3169 };
3170 let span = span_from_position(self.source_id, mapping.byte_range());
3171 let mut provider = Provider::new(span);
3172 let mut seen = BTreeMap::new();
3173 for member in self.fields(mapping) {
3174 let duplicate = self.record_duplicate(&mut seen, &member);
3175 match member.name.value().as_str() {
3176 "type" if !duplicate => {
3177 if let Some(value) = self.parse_provider_type(&member) {
3178 provider.set_type(value);
3179 } else {
3180 provider.push_unknown(member.reference());
3181 }
3182 }
3183 "options" if !duplicate => {
3184 if let Some(options) = self.parse_provider_options(&member) {
3185 provider.set_options(options);
3186 } else {
3187 provider.push_unknown(member.reference());
3188 }
3189 }
3190 name if name.starts_with("x-") => provider.push_extension(member.reference()),
3191 _ if duplicate => {}
3192 _ => provider.push_unknown(member.reference()),
3193 }
3194 }
3195 if provider.type_().is_none() {
3196 self.missing(PROVIDER_MISSING_TYPE, span, "provider is missing required `type`");
3197 }
3198 Some(provider)
3199 }
3200
3201 fn parse_provider_type(&mut self, field: &ParsedField) -> Option<Located<String>> {
3202 self.parse_extends_string(field, "provider type must be a YAML string scalar")
3203 }
3204
3205 fn parse_post_start(&mut self, field: &ParsedField) -> Option<PostStartHooks> {
3206 let (span, entries) = self.parse_lifecycle_hooks(
3207 field,
3208 "post_start",
3209 POST_START_MISSING_COMMAND,
3210 PostStartHook::Hook,
3211 |span| PostStartHook::Unmodeled { span },
3212 )?;
3213 Some(PostStartHooks::new(span, entries))
3214 }
3215
3216 fn parse_pre_stop(&mut self, field: &ParsedField) -> Option<PreStopHooks> {
3217 let (span, entries) =
3218 self.parse_lifecycle_hooks(field, "pre_stop", PRE_STOP_MISSING_COMMAND, PreStopHook::Hook, |span| {
3219 PreStopHook::Unmodeled { span }
3220 })?;
3221 Some(PreStopHooks::new(span, entries))
3222 }
3223
3224 fn parse_pre_start(&mut self, field: &ParsedField) -> Option<PreStartHooks> {
3225 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3226 self.expected(
3227 EXPECTED_SEQUENCE,
3228 field,
3229 "pre_start must be a sequence of hook mappings",
3230 );
3231 return None;
3232 };
3233 let span = span_from_position(self.source_id, sequence.byte_range());
3234 let mut entries = Vec::new();
3235 for item in sequence.values() {
3236 let Some(mapping) = item.as_mapping() else {
3237 self.unsupported_sequence_item(
3238 EXPECTED_MAPPING,
3239 &item,
3240 field.span,
3241 "pre_start items must be hook mappings",
3242 );
3243 entries.push(PreStartHook::Unmodeled {
3244 span: node_span(self.source_id, &item).unwrap_or(field.span),
3245 });
3246 continue;
3247 };
3248 entries.push(PreStartHook::Hook(Box::new(self.parse_pre_start_service_hook(mapping))));
3249 }
3250 Some(PreStartHooks::new(span, entries))
3251 }
3252
3253 fn parse_lifecycle_hooks<T>(
3254 &mut self,
3255 field: &ParsedField,
3256 name: &str,
3257 missing_code: DiagnosticCode,
3258 hook_entry: fn(Box<ServiceHook>) -> T,
3259 unmodeled_entry: fn(SourceSpan) -> T,
3260 ) -> Option<(SourceSpan, Vec<T>)> {
3261 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3262 self.expected(
3263 EXPECTED_SEQUENCE,
3264 field,
3265 format!("{name} must be a sequence of hook mappings"),
3266 );
3267 return None;
3268 };
3269 let span = span_from_position(self.source_id, sequence.byte_range());
3270 let mut entries = Vec::new();
3271 for item in sequence.values() {
3272 let Some(mapping) = item.as_mapping() else {
3273 self.unsupported_sequence_item(
3274 EXPECTED_MAPPING,
3275 &item,
3276 field.span,
3277 format!("{name} items must be hook mappings"),
3278 );
3279 entries.push(unmodeled_entry(node_span(self.source_id, &item).unwrap_or(field.span)));
3280 continue;
3281 };
3282 entries.push(hook_entry(Box::new(self.parse_service_hook(
3283 mapping,
3284 name,
3285 missing_code,
3286 ))));
3287 }
3288 Some((span, entries))
3289 }
3290
3291 fn parse_service_hook(&mut self, mapping: &Mapping, name: &str, missing_code: DiagnosticCode) -> ServiceHook {
3292 let span = span_from_position(self.source_id, mapping.byte_range());
3293 let mut hook = ServiceHook::new(span);
3294 let mut seen = BTreeMap::new();
3295 for member in self.fields(mapping) {
3296 let duplicate = self.record_duplicate(&mut seen, &member);
3297 if duplicate {
3298 hook.push_unknown(member.reference());
3299 continue;
3300 }
3301 match member.name.value().as_str() {
3302 "command" => {
3303 if let Some(value) = self.parse_command(&member) {
3304 hook.set_command(value);
3305 } else {
3306 hook.push_unknown(member.reference());
3307 }
3308 }
3309 "environment" => {
3310 if let Some(value) = self.parse_environment(&member) {
3311 hook.set_environment(value);
3312 } else {
3313 hook.push_unknown(member.reference());
3314 }
3315 }
3316 "privileged" => {
3317 if let Some(value) = self.parse_boolean(&member, &format!("{name} privileged")) {
3318 hook.set_privileged(value);
3319 } else {
3320 hook.push_unknown(member.reference());
3321 }
3322 }
3323 "user" => {
3324 if let Some(value) =
3325 self.parse_extends_string(&member, format!("{name} user must be a YAML string scalar"))
3326 {
3327 hook.set_user(value);
3328 } else {
3329 hook.push_unknown(member.reference());
3330 }
3331 }
3332 "working_dir" => {
3333 if let Some(value) =
3334 self.parse_extends_string(&member, format!("{name} working_dir must be a YAML string scalar"))
3335 {
3336 hook.set_working_dir(value);
3337 } else {
3338 hook.push_unknown(member.reference());
3339 }
3340 }
3341 name if name.starts_with("x-") => hook.push_extension(member.reference()),
3342 _ => hook.push_unknown(member.reference()),
3343 }
3344 }
3345 if hook.command().is_none() {
3346 self.missing(missing_code, span, format!("{name} hook is missing required `command`"));
3347 }
3348 hook
3349 }
3350
3351 fn parse_pre_start_service_hook(&mut self, mapping: &Mapping) -> PreStartServiceHook {
3352 let span = span_from_position(self.source_id, mapping.byte_range());
3353 let mut hook = PreStartServiceHook::new(span);
3354 let mut seen = BTreeMap::new();
3355 for member in self.fields(mapping) {
3356 let duplicate = self.record_duplicate(&mut seen, &member);
3357 if duplicate {
3358 hook.push_unknown(member.reference());
3359 continue;
3360 }
3361 match member.name.value().as_str() {
3362 "command" => {
3363 if let Some(value) = self.parse_command(&member) {
3364 hook.set_command(value);
3365 } else {
3366 hook.push_unknown(member.reference());
3367 }
3368 }
3369 "image" => {
3370 if let Some(value) =
3371 self.parse_extends_string(&member, "pre_start image must be a YAML string scalar")
3372 {
3373 hook.set_image(value);
3374 } else {
3375 hook.push_unknown(member.reference());
3376 }
3377 }
3378 "environment" => {
3379 if let Some(value) = self.parse_environment(&member) {
3380 hook.set_environment(value);
3381 } else {
3382 hook.push_unknown(member.reference());
3383 }
3384 }
3385 "privileged" => {
3386 if let Some(value) = self.parse_boolean(&member, "pre_start privileged") {
3387 hook.set_privileged(value);
3388 } else {
3389 hook.push_unknown(member.reference());
3390 }
3391 }
3392 "per_replica" => {
3393 if let Some(value) = self.parse_boolean(&member, "pre_start per_replica") {
3394 hook.set_per_replica(value);
3395 } else {
3396 hook.push_unknown(member.reference());
3397 }
3398 }
3399 "user" => {
3400 if let Some(value) =
3401 self.parse_extends_string(&member, "pre_start user must be a YAML string scalar")
3402 {
3403 hook.set_user(value);
3404 } else {
3405 hook.push_unknown(member.reference());
3406 }
3407 }
3408 "working_dir" => {
3409 if let Some(value) =
3410 self.parse_extends_string(&member, "pre_start working_dir must be a YAML string scalar")
3411 {
3412 hook.set_working_dir(value);
3413 } else {
3414 hook.push_unknown(member.reference());
3415 }
3416 }
3417 name if name.starts_with("x-") => hook.push_extension(member.reference()),
3418 _ => hook.push_unknown(member.reference()),
3419 }
3420 }
3421 hook
3422 }
3423
3424 fn parse_provider_options(&mut self, field: &ParsedField) -> Option<ProviderOptions> {
3425 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3426 self.expected(EXPECTED_MAPPING, field, "provider options must be a mapping");
3427 return None;
3428 };
3429 let span = span_from_position(self.source_id, mapping.byte_range());
3430 let mut entries = Vec::new();
3431 let mut unmodeled_entries = Vec::new();
3432 let mut seen = BTreeMap::new();
3433 for option in self.fields(mapping) {
3434 if self.record_duplicate(&mut seen, &option) {
3435 unmodeled_entries.push(option.reference());
3436 continue;
3437 }
3438 if option.name.value().is_empty() {
3439 self.expected(EXPECTED_FIELD_FORM, &option, "provider option keys must not be empty");
3440 unmodeled_entries.push(option.reference());
3441 continue;
3442 }
3443 let Some(value) = self.parse_provider_option_value(&option) else {
3444 unmodeled_entries.push(option.reference());
3445 continue;
3446 };
3447 entries.push(ProviderOption::new(option.name, value, option.span));
3448 }
3449 Some(ProviderOptions::new(span, entries, unmodeled_entries))
3450 }
3451
3452 fn parse_provider_option_value(&mut self, field: &ParsedField) -> Option<ProviderOptionValue> {
3453 let Some(value) = field.value.as_ref() else {
3454 self.expected(
3455 EXPECTED_FIELD_FORM,
3456 field,
3457 "provider option values must be YAML string, number, or boolean scalars or sequences of them",
3458 );
3459 return None;
3460 };
3461 if let Some(scalar) = value.as_scalar() {
3462 return self
3463 .provider_option_scalar(scalar)
3464 .map(ProviderOptionValue::Scalar)
3465 .or_else(|| {
3466 self.expected(
3467 EXPECTED_FIELD_FORM,
3468 field,
3469 "provider option values must be YAML string, number, or boolean scalars or sequences of them",
3470 );
3471 None
3472 });
3473 }
3474 let Some(sequence) = value.as_sequence() else {
3475 self.expected(
3476 EXPECTED_FIELD_FORM,
3477 field,
3478 "provider option values must be YAML string, number, or boolean scalars or sequences of them",
3479 );
3480 return None;
3481 };
3482 let span = span_from_position(self.source_id, sequence.byte_range());
3483 let mut items = Vec::new();
3484 for item in sequence.values() {
3485 let Some(scalar) = item.as_scalar() else {
3486 self.unsupported_sequence_item(
3487 EXPECTED_SCALAR,
3488 &item,
3489 field.span,
3490 "provider option sequence items must be YAML string, number, or boolean scalars",
3491 );
3492 items.push(ProviderOptionItem::Unmodeled {
3493 span: node_span(self.source_id, &item).unwrap_or(field.span),
3494 });
3495 continue;
3496 };
3497 if let Some(value) = self.provider_option_scalar(scalar) {
3498 items.push(ProviderOptionItem::Scalar(value));
3499 } else {
3500 self.unsupported_sequence_item(
3501 EXPECTED_SCALAR,
3502 &item,
3503 field.span,
3504 "provider option sequence items must be YAML string, number, or boolean scalars",
3505 );
3506 items.push(ProviderOptionItem::Unmodeled {
3507 span: node_span(self.source_id, &item).unwrap_or(field.span),
3508 });
3509 }
3510 }
3511 Some(ProviderOptionValue::Sequence { span, items })
3512 }
3513
3514 fn provider_option_scalar(&self, scalar: &Scalar) -> Option<Located<ComposeScalar>> {
3515 let scalar_value = ScalarValue::from_scalar(scalar);
3516 let value = match scalar_value.scalar_type() {
3517 ScalarType::String => ComposeScalar::String(scalar_string_from_source(&self.source, scalar)),
3518 ScalarType::Integer | ScalarType::Float => {
3519 ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
3520 }
3521 ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
3522 ScalarType::Null | ScalarType::Timestamp | ScalarType::Regex => return None,
3523 };
3524 Some(Located::new(
3525 value,
3526 span_from_position(self.source_id, scalar.byte_range()),
3527 ))
3528 }
3529
3530 fn parse_logging_driver(&mut self, field: &ParsedField) -> Option<Located<String>> {
3531 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3532 self.expected(
3533 LOGGING_DRIVER_EXPECTED_STRING,
3534 field,
3535 "logging driver must be a YAML string scalar",
3536 );
3537 return None;
3538 };
3539 if !matches!(
3540 ScalarValue::from_scalar(scalar).scalar_type(),
3541 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3542 ) {
3543 self.expected(
3544 LOGGING_DRIVER_EXPECTED_STRING,
3545 field,
3546 "logging driver must be a YAML string scalar",
3547 );
3548 return None;
3549 }
3550 let span = span_from_position(self.source_id, scalar.byte_range());
3551 Some(Located::new(scalar_string_from_source(&self.source, scalar), span))
3552 }
3553
3554 fn parse_logging_options(&mut self, field: &ParsedField) -> Option<LoggingOptions> {
3555 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3556 self.expected(
3557 LOGGING_OPTIONS_EXPECTED_MAPPING,
3558 field,
3559 "logging options must be a mapping",
3560 );
3561 return None;
3562 };
3563 let span = span_from_position(self.source_id, mapping.byte_range());
3564 let mut entries = Vec::new();
3565 let mut unmodeled_entries = Vec::new();
3566 let mut seen = BTreeMap::new();
3567 for option in self.fields(mapping) {
3568 if self.record_duplicate(&mut seen, &option) {
3569 continue;
3570 }
3571 if option.name.value.is_empty() {
3572 self.diagnostics.push(
3573 Diagnostic::new(
3574 LOGGING_OPTION_EMPTY_KEY,
3575 Severity::Error,
3576 "logging option keys must not be empty",
3577 )
3578 .with_label(DiagnosticLabel::primary(option.name.span, "empty logging option key")),
3579 );
3580 unmodeled_entries.push(option.reference());
3581 continue;
3582 }
3583 let Some(value) = self.parse_logging_option_scalar(&option) else {
3584 unmodeled_entries.push(option.reference());
3585 continue;
3586 };
3587 entries.push(LoggingOption::new(option.name, value, option.span));
3588 }
3589 Some(LoggingOptions::new(span, entries, unmodeled_entries))
3590 }
3591
3592 fn parse_logging_option_scalar(&mut self, field: &ParsedField) -> Option<Located<LoggingOptionValue>> {
3593 let Some(node) = field.value.as_ref() else {
3594 return Some(Located::new(LoggingOptionValue::Null, field.name.span));
3595 };
3596 let Some(scalar) = node.as_scalar() else {
3597 self.expected(
3598 LOGGING_OPTION_EXPECTED_SCALAR,
3599 field,
3600 "logging option values must be YAML string, number, or null scalars",
3601 );
3602 return None;
3603 };
3604 let span = span_from_position(self.source_id, scalar.byte_range());
3605 let scalar_value = ScalarValue::from_scalar(scalar);
3606 let value = match scalar_value.scalar_type() {
3607 ScalarType::Null => LoggingOptionValue::Null,
3608 ScalarType::Integer | ScalarType::Float => {
3609 LoggingOptionValue::Number(scalar_string_from_source(&self.source, scalar))
3610 }
3611 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
3612 LoggingOptionValue::String(scalar_string_from_source(&self.source, scalar))
3613 }
3614 ScalarType::Boolean => {
3615 self.diagnostics.push(
3616 Diagnostic::new(
3617 LOGGING_OPTION_EXPECTED_SCALAR,
3618 Severity::Error,
3619 "logging option values must be YAML string, number, or null scalars",
3620 )
3621 .with_label(DiagnosticLabel::primary(
3622 span,
3623 "boolean logging option retained as malformed",
3624 )),
3625 );
3626 return None;
3627 }
3628 };
3629 Some(Located::new(value, span))
3630 }
3631
3632 fn parse_restart_policy(&mut self, field: &ParsedField) -> Option<RestartPolicy> {
3633 let value = self.parse_string(field, "service restart policy")?;
3634 let policy = RestartPolicy::parse(value);
3635 if !policy.is_valid() {
3636 self.diagnostics.push(
3637 Diagnostic::new(
3638 RESTART_INVALID_POLICY,
3639 Severity::Error,
3640 "restart must be `no`, `always`, `on-failure[:max-retries]`, `unless-stopped`, or interpolation",
3641 )
3642 .with_label(DiagnosticLabel::primary(
3643 policy.raw().span(),
3644 "invalid service restart policy",
3645 )),
3646 );
3647 }
3648 Some(policy)
3649 }
3650
3651 fn parse_pids_limit(&mut self, field: &ParsedField) -> Option<PidsLimit> {
3652 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3653 self.expected(
3654 PIDS_LIMIT_EXPECTED_VALUE,
3655 field,
3656 "pids_limit must be a number or string scalar",
3657 );
3658 return None;
3659 };
3660 if matches!(
3661 ScalarValue::from_scalar(scalar).scalar_type(),
3662 ScalarType::Boolean | ScalarType::Null
3663 ) {
3664 self.expected(
3665 PIDS_LIMIT_EXPECTED_VALUE,
3666 field,
3667 "pids_limit must be a number or string scalar",
3668 );
3669 return None;
3670 }
3671 let span = span_from_position(self.source_id, scalar.byte_range());
3672 let limit = PidsLimit::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
3673 match limit.kind() {
3674 PidsLimitKind::Zero => self.diagnostics.push(
3675 Diagnostic::new(
3676 PIDS_LIMIT_AMBIGUOUS_ZERO,
3677 Severity::Warning,
3678 "pids_limit zero is preserved as an ambiguous and unportable native state",
3679 )
3680 .with_label(DiagnosticLabel::primary(span, "ambiguous zero PID limit")),
3681 ),
3682 PidsLimitKind::Other => self.diagnostics.push(
3683 Diagnostic::new(
3684 PIDS_LIMIT_INVALID,
3685 Severity::Error,
3686 "pids_limit must be `-1`, a positive integral decimal, or interpolation",
3687 )
3688 .with_label(DiagnosticLabel::primary(span, "unsupported service PID limit")),
3689 ),
3690 _ => {}
3691 }
3692 Some(limit)
3693 }
3694
3695 fn parse_cpu_count(&mut self, field: &ParsedField) -> Option<Located<CpuCount>> {
3696 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3697 self.expected(
3698 CPU_COUNT_EXPECTED_VALUE,
3699 field,
3700 "cpu_count must be a YAML integer or string scalar",
3701 );
3702 return None;
3703 };
3704 let span = span_from_position(self.source_id, scalar.byte_range());
3705 let scalar_value = ScalarValue::from_scalar(scalar);
3706 let raw = scalar_string_from_source(&self.source, scalar);
3707 let count = match scalar_value.scalar_type() {
3708 ScalarType::Integer | ScalarType::Float if CpuCount::yaml_integer_spelling(&raw) => {
3709 CpuCount::yaml_integer(raw)
3710 }
3711 ScalarType::String
3712 if scalar_value.style() == ScalarStyle::Plain
3713 && !scalar_uses_block_style(&self.source, scalar)
3714 && CpuCount::yaml_integer_spelling(&raw) =>
3715 {
3716 CpuCount::yaml_integer(raw)
3717 }
3718 ScalarType::String => CpuCount::String(scalar_string_from_source(&self.source, scalar)),
3719 _ => {
3720 self.expected(
3721 CPU_COUNT_EXPECTED_VALUE,
3722 field,
3723 "cpu_count must be a YAML integer or string scalar",
3724 );
3725 return None;
3726 }
3727 };
3728 if !count.is_valid() {
3729 self.diagnostics.push(
3730 Diagnostic::new(
3731 CPU_COUNT_NEGATIVE,
3732 Severity::Error,
3733 "cpu_count YAML integers must be nonnegative",
3734 )
3735 .with_label(DiagnosticLabel::primary(
3736 span,
3737 "negative CPU count retained as invalid evidence",
3738 )),
3739 );
3740 }
3741 Some(Located::new(count, span))
3742 }
3743
3744 fn parse_cpu_percent(&mut self, field: &ParsedField) -> Option<Located<CpuPercent>> {
3745 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3746 self.expected(
3747 CPU_PERCENT_EXPECTED_VALUE,
3748 field,
3749 "cpu_percent must be a YAML integer or string scalar",
3750 );
3751 return None;
3752 };
3753 let span = span_from_position(self.source_id, scalar.byte_range());
3754 let scalar_value = ScalarValue::from_scalar(scalar);
3755 let raw = scalar_string_from_source(&self.source, scalar);
3756 let percent = match scalar_value.scalar_type() {
3757 ScalarType::Integer | ScalarType::Float if CpuPercent::yaml_integer_spelling(&raw) => {
3758 CpuPercent::yaml_integer(raw)
3759 }
3760 ScalarType::String
3761 if scalar_value.style() == ScalarStyle::Plain
3762 && !scalar_uses_block_style(&self.source, scalar)
3763 && CpuPercent::yaml_integer_spelling(&raw) =>
3764 {
3765 CpuPercent::yaml_integer(raw)
3766 }
3767 ScalarType::String => CpuPercent::String(scalar_string_from_source(&self.source, scalar)),
3768 _ => {
3769 self.expected(
3770 CPU_PERCENT_EXPECTED_VALUE,
3771 field,
3772 "cpu_percent must be a YAML integer or string scalar",
3773 );
3774 return None;
3775 }
3776 };
3777 if !percent.is_valid() {
3778 self.diagnostics.push(
3779 Diagnostic::new(
3780 CPU_PERCENT_OUT_OF_RANGE,
3781 Severity::Error,
3782 "cpu_percent YAML integers must be between 0 and 100 inclusive",
3783 )
3784 .with_label(DiagnosticLabel::primary(
3785 span,
3786 "out-of-range CPU percentage retained as invalid evidence",
3787 )),
3788 );
3789 }
3790 Some(Located::new(percent, span))
3791 }
3792
3793 fn parse_cpu_period(&mut self, field: &ParsedField) -> Option<Located<CpuPeriod>> {
3794 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3795 self.expected(
3796 CPU_PERIOD_EXPECTED_VALUE,
3797 field,
3798 "cpu_period must be a YAML number or string scalar",
3799 );
3800 return None;
3801 };
3802 let span = span_from_position(self.source_id, scalar.byte_range());
3803 let scalar_value = ScalarValue::from_scalar(scalar);
3804 let raw = scalar_string_from_source(&self.source, scalar);
3805 let period = match scalar_value.scalar_type() {
3806 ScalarType::Integer | ScalarType::Float => CpuPeriod::YamlNumber(raw),
3807 ScalarType::String
3808 if scalar_value.style() == ScalarStyle::Plain
3809 && !scalar_uses_block_style(&self.source, scalar)
3810 && CpuPeriod::yaml_number_spelling(&raw) =>
3811 {
3812 CpuPeriod::YamlNumber(raw)
3813 }
3814 ScalarType::String => CpuPeriod::String(raw),
3815 _ => {
3816 self.expected(
3817 CPU_PERIOD_EXPECTED_VALUE,
3818 field,
3819 "cpu_period must be a YAML number or string scalar",
3820 );
3821 return None;
3822 }
3823 };
3824 Some(Located::new(period, span))
3825 }
3826
3827 fn parse_cpu_quota(&mut self, field: &ParsedField) -> Option<Located<CpuQuota>> {
3828 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3829 self.expected(
3830 CPU_QUOTA_EXPECTED_VALUE,
3831 field,
3832 "cpu_quota must be a YAML number or string scalar",
3833 );
3834 return None;
3835 };
3836 let span = span_from_position(self.source_id, scalar.byte_range());
3837 let scalar_value = ScalarValue::from_scalar(scalar);
3838 let raw = scalar_string_from_source(&self.source, scalar);
3839 let quota = match scalar_value.scalar_type() {
3840 ScalarType::Integer | ScalarType::Float => CpuQuota::YamlNumber(raw),
3841 ScalarType::String
3842 if scalar_value.style() == ScalarStyle::Plain
3843 && !scalar_uses_block_style(&self.source, scalar)
3844 && CpuPeriod::yaml_number_spelling(&raw) =>
3845 {
3846 CpuQuota::YamlNumber(raw)
3847 }
3848 ScalarType::String => CpuQuota::String(raw),
3849 _ => {
3850 self.expected(
3851 CPU_QUOTA_EXPECTED_VALUE,
3852 field,
3853 "cpu_quota must be a YAML number or string scalar",
3854 );
3855 return None;
3856 }
3857 };
3858 Some(Located::new(quota, span))
3859 }
3860
3861 fn parse_cpu_rt_period(&mut self, field: &ParsedField) -> Option<Located<CpuRtPeriod>> {
3862 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3863 self.expected(
3864 CPU_RT_PERIOD_EXPECTED_VALUE,
3865 field,
3866 "cpu_rt_period must be a YAML number or string scalar",
3867 );
3868 return None;
3869 };
3870 let span = span_from_position(self.source_id, scalar.byte_range());
3871 let scalar_value = ScalarValue::from_scalar(scalar);
3872 let raw = scalar_string_from_source(&self.source, scalar);
3873 let period = match scalar_value.scalar_type() {
3874 ScalarType::Integer | ScalarType::Float => CpuRtPeriod::YamlNumber(raw),
3875 ScalarType::String
3876 if scalar_value.style() == ScalarStyle::Plain
3877 && !scalar_uses_block_style(&self.source, scalar)
3878 && CpuPeriod::yaml_number_spelling(&raw) =>
3879 {
3880 CpuRtPeriod::YamlNumber(raw)
3881 }
3882 ScalarType::String => CpuRtPeriod::parse_string(raw),
3883 _ => {
3884 self.expected(
3885 CPU_RT_PERIOD_EXPECTED_VALUE,
3886 field,
3887 "cpu_rt_period must be a YAML number or string scalar",
3888 );
3889 return None;
3890 }
3891 };
3892 if !period.is_valid() {
3893 self.diagnostics.push(
3894 Diagnostic::new(
3895 CPU_RT_PERIOD_INVALID,
3896 Severity::Error,
3897 "cpu_rt_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
3898 )
3899 .with_label(DiagnosticLabel::primary(span, "invalid service real-time CPU period")),
3900 );
3901 }
3902 Some(Located::new(period, span))
3903 }
3904
3905 fn parse_shm_size(&mut self, field: &ParsedField) -> Option<ShmSize> {
3906 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3907 self.expected(
3908 SHM_SIZE_EXPECTED_VALUE,
3909 field,
3910 "shm_size must be a YAML number or string scalar",
3911 );
3912 return None;
3913 };
3914 let scalar_kind = match ScalarValue::from_scalar(scalar).scalar_type() {
3915 ScalarType::Integer | ScalarType::Float => ShmSizeScalarKind::Number,
3916 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => ShmSizeScalarKind::String,
3917 ScalarType::Boolean | ScalarType::Null => {
3918 self.expected(
3919 SHM_SIZE_EXPECTED_VALUE,
3920 field,
3921 "shm_size must be a YAML number or string scalar",
3922 );
3923 return None;
3924 }
3925 };
3926 let span = span_from_position(self.source_id, scalar.byte_range());
3927 let size = ShmSize::parse(
3928 Located::new(scalar_string_from_source(&self.source, scalar), span),
3929 scalar_kind,
3930 );
3931 self.diagnose_shm_size(&size);
3932 Some(size)
3933 }
3934
3935 fn diagnose_shm_size(&mut self, size: &ShmSize) {
3936 let (code, message, label, note) = match size.kind() {
3937 ShmSizeKind::Zero { .. } => (
3938 SHM_SIZE_AMBIGUOUS_ZERO,
3939 "shm_size zero is preserved because Compose does not define its semantics",
3940 "ambiguous zero shared-memory size",
3941 "choose a positive size with an explicit documented lowercase unit",
3942 ),
3943 ShmSizeKind::ProviderDependentNumber => (
3944 SHM_SIZE_PROVIDER_DEPENDENT_NUMBER,
3945 "numeric shm_size is schema-accepted but lacks a documented explicit unit",
3946 "provider-dependent numeric shared-memory size",
3947 "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for portable intent",
3948 ),
3949 ShmSizeKind::ProviderDependentString => (
3950 SHM_SIZE_PROVIDER_DEPENDENT_STRING,
3951 "string shm_size is schema-accepted but falls outside the documented lowercase suffix family",
3952 "provider-dependent string shared-memory size",
3953 "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
3954 ),
3955 ShmSizeKind::Documented { .. } | ShmSizeKind::Expression => return,
3956 };
3957 self.diagnostics.push(
3958 Diagnostic::new(code, Severity::Warning, message)
3959 .with_label(DiagnosticLabel::primary(size.raw().span(), label))
3960 .with_note(note),
3961 );
3962 }
3963
3964 fn parse_mem_limit(&mut self, field: &ParsedField) -> Option<MemLimit> {
3965 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3966 self.expected(
3967 MEM_LIMIT_EXPECTED_VALUE,
3968 field,
3969 "mem_limit must be a YAML number or string scalar",
3970 );
3971 return None;
3972 };
3973 let scalar_kind = match ScalarValue::from_scalar(scalar).scalar_type() {
3974 ScalarType::Integer | ScalarType::Float => MemLimitScalarKind::Number,
3975 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MemLimitScalarKind::String,
3976 ScalarType::Boolean | ScalarType::Null => {
3977 self.expected(
3978 MEM_LIMIT_EXPECTED_VALUE,
3979 field,
3980 "mem_limit must be a YAML number or string scalar",
3981 );
3982 return None;
3983 }
3984 };
3985 let span = span_from_position(self.source_id, scalar.byte_range());
3986 let limit = MemLimit::parse(
3987 Located::new(scalar_string_from_source(&self.source, scalar), span),
3988 scalar_kind,
3989 );
3990 self.diagnose_mem_limit(&limit);
3991 Some(limit)
3992 }
3993
3994 fn diagnose_mem_limit(&mut self, limit: &MemLimit) {
3995 let (code, message, label, note) = match limit.kind() {
3996 MemLimitKind::Zero { .. } => (
3997 MEM_LIMIT_AMBIGUOUS_ZERO,
3998 "mem_limit zero is preserved without inferring portable runtime behavior",
3999 "ambiguous zero memory limit",
4000 "choose a positive size with an explicit documented lowercase unit",
4001 ),
4002 MemLimitKind::SchemaNumber => (
4003 MEM_LIMIT_SCHEMA_NUMBER,
4004 "numeric mem_limit is schema-accepted but lacks a documented explicit unit",
4005 "schema-only numeric memory limit",
4006 "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for explicit intent",
4007 ),
4008 MemLimitKind::ProviderDependentString => (
4009 MEM_LIMIT_PROVIDER_DEPENDENT_STRING,
4010 "string mem_limit is schema-accepted but falls outside the documented lowercase suffix family",
4011 "provider-dependent string memory limit",
4012 "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
4013 ),
4014 MemLimitKind::Documented { .. } | MemLimitKind::Expression => return,
4015 };
4016 self.diagnostics.push(
4017 Diagnostic::new(code, Severity::Warning, message)
4018 .with_label(DiagnosticLabel::primary(limit.raw().span(), label))
4019 .with_note(note),
4020 );
4021 }
4022
4023 fn parse_pull_policy(&mut self, field: &ParsedField) -> Option<PullPolicy> {
4024 let value = self.parse_string(field, "service pull policy")?;
4025 let policy = PullPolicy::parse(value);
4026 if !policy.is_recognized() {
4027 self.diagnostics.push(
4028 Diagnostic::new(
4029 PULL_POLICY_INVALID,
4030 Severity::Error,
4031 "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",
4032 )
4033 .with_label(DiagnosticLabel::primary(
4034 policy.raw().span(),
4035 "invalid or provider-specific service pull policy",
4036 )),
4037 );
4038 }
4039 Some(policy)
4040 }
4041
4042 fn parse_stop_grace_period(&mut self, field: &ParsedField) -> Option<Located<StopGracePeriod>> {
4043 let value = self.parse_string(field, "service stop grace period")?;
4044 let period = StopGracePeriod::parse(value.value);
4045 if !period.is_valid() {
4046 self.diagnostics.push(
4047 Diagnostic::new(
4048 STOP_GRACE_PERIOD_INVALID,
4049 Severity::Error,
4050 "stop_grace_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
4051 )
4052 .with_label(DiagnosticLabel::primary(
4053 value.span,
4054 "invalid service stop grace period",
4055 )),
4056 );
4057 }
4058 Some(Located::new(period, value.span))
4059 }
4060
4061 fn parse_command(&mut self, field: &ParsedField) -> Option<Command> {
4062 match field.value.as_ref() {
4063 Some(YamlNode::Scalar(scalar)) => {
4064 let span = span_from_position(self.source_id, scalar.byte_range());
4065 if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
4066 Some(Command::Null(span))
4067 } else {
4068 Some(Command::String(Located::new(
4069 scalar_string_from_source(&self.source, scalar),
4070 span,
4071 )))
4072 }
4073 }
4074 Some(YamlNode::Sequence(sequence)) => {
4075 let span = span_from_position(self.source_id, sequence.byte_range());
4076 let values =
4077 self.parse_scalar_nodes(sequence.values(), field.span, "command list items must be scalars");
4078 Some(Command::List { span, values })
4079 }
4080 _ => {
4081 self.expected(
4082 EXPECTED_FIELD_FORM,
4083 field,
4084 "command must be null, a scalar, or a sequence",
4085 );
4086 None
4087 }
4088 }
4089 }
4090
4091 fn parse_service_user(&mut self, field: &ParsedField) -> Option<UserSpec> {
4092 self.parse_string(field, "service user").map(UserSpec::parse)
4093 }
4094
4095 fn parse_entrypoint(&mut self, field: &ParsedField) -> Option<Entrypoint> {
4096 match field.value.as_ref() {
4097 Some(YamlNode::Scalar(scalar)) => {
4098 let span = span_from_position(self.source_id, scalar.byte_range());
4099 if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
4100 Some(Entrypoint::Null(span))
4101 } else {
4102 Some(Entrypoint::String(Located::new(
4103 scalar_string_from_source(&self.source, scalar),
4104 span,
4105 )))
4106 }
4107 }
4108 Some(YamlNode::Sequence(sequence)) => {
4109 let span = span_from_position(self.source_id, sequence.byte_range());
4110 let values =
4111 self.parse_scalar_nodes(sequence.values(), field.span, "entrypoint list items must be scalars");
4112 Some(Entrypoint::List { span, values })
4113 }
4114 _ => {
4115 self.expected(
4116 EXPECTED_FIELD_FORM,
4117 field,
4118 "entrypoint must be null, a scalar, or a sequence",
4119 );
4120 None
4121 }
4122 }
4123 }
4124
4125 fn parse_environment(&mut self, field: &ParsedField) -> Option<Environment> {
4126 match field.value.as_ref() {
4127 Some(YamlNode::Sequence(sequence)) => {
4128 let span = span_from_position(self.source_id, sequence.byte_range());
4129 let entries = self
4130 .parse_scalar_nodes(sequence.values(), field.span, "environment list items must be scalars")
4131 .into_iter()
4132 .map(EnvironmentListEntry::parse)
4133 .collect();
4134 Some(Environment::List { span, entries })
4135 }
4136 Some(YamlNode::Mapping(mapping)) => {
4137 let span = span_from_position(self.source_id, mapping.byte_range());
4138 let entries = self.parse_environment_map(mapping);
4139 Some(Environment::Map { span, entries })
4140 }
4141 _ => {
4142 self.expected(EXPECTED_FIELD_FORM, field, "environment must be a sequence or mapping");
4143 None
4144 }
4145 }
4146 }
4147
4148 fn parse_environment_map(&mut self, mapping: &Mapping) -> Vec<EnvironmentMapEntry> {
4149 let mut entries = Vec::new();
4150 let mut seen = BTreeMap::new();
4151 for field in self.fields(mapping) {
4152 if self.record_duplicate(&mut seen, &field) {
4153 continue;
4154 }
4155 let value = self.parse_compose_scalar(&field, "environment values must be scalars");
4156 if let Some(value) = value {
4157 entries.push(EnvironmentMapEntry::new(field.name, value, field.span));
4158 }
4159 }
4160 entries
4161 }
4162
4163 fn parse_environment_files(&mut self, field: &ParsedField) -> Vec<EnvironmentFile> {
4164 match field.value.as_ref() {
4165 Some(YamlNode::Scalar(_)) => self
4166 .parse_string(field, "service environment-file path")
4167 .map(EnvironmentFile::Short)
4168 .into_iter()
4169 .collect(),
4170 Some(YamlNode::Sequence(sequence)) => sequence
4171 .values()
4172 .filter_map(|value| match value {
4173 YamlNode::Scalar(scalar) => {
4174 let span = span_from_position(self.source_id, scalar.byte_range());
4175 Some(EnvironmentFile::Short(Located::new(
4176 scalar_string_from_source(&self.source, &scalar),
4177 span,
4178 )))
4179 }
4180 YamlNode::Mapping(mapping) => Some(EnvironmentFile::Long(Box::new(
4181 self.parse_long_environment_file(&mapping),
4182 ))),
4183 _ => {
4184 self.diagnostics.push(
4185 Diagnostic::new(
4186 ENVIRONMENT_FILE_EXPECTED_FORM,
4187 Severity::Error,
4188 "env_file item must use scalar short syntax or mapping long syntax",
4189 )
4190 .with_label(DiagnosticLabel::primary(
4191 node_span(self.source_id, &value).unwrap_or(field.span),
4192 "invalid environment-file item",
4193 )),
4194 );
4195 None
4196 }
4197 })
4198 .collect(),
4199 _ => {
4200 self.expected(
4201 EXPECTED_FIELD_FORM,
4202 field,
4203 "env_file must be a scalar path or a sequence of short/long entries",
4204 );
4205 Vec::new()
4206 }
4207 }
4208 }
4209
4210 fn parse_long_environment_file(&mut self, mapping: &Mapping) -> LongEnvironmentFile {
4211 let span = span_from_position(self.source_id, mapping.byte_range());
4212 let mut environment_file = LongEnvironmentFile::new(span);
4213 let mut seen = BTreeMap::new();
4214 for field in self.fields(mapping) {
4215 let duplicate = self.record_duplicate(&mut seen, &field);
4216 match field.name.value.as_str() {
4217 "path" if !duplicate => self
4218 .parse_string(&field, "environment-file path")
4219 .into_iter()
4220 .for_each(|value| environment_file.set_path(value)),
4221 "required" if !duplicate => self
4222 .parse_boolean(&field, "environment-file required option")
4223 .into_iter()
4224 .for_each(|value| environment_file.set_required(value)),
4225 "format" if !duplicate => {
4226 if let Some(raw) = self.parse_string(&field, "environment-file format") {
4227 let format = EnvironmentFileFormat::parse(raw);
4228 if !format.is_valid() {
4229 self.diagnostics.push(
4230 Diagnostic::new(
4231 ENVIRONMENT_FILE_INVALID_FORMAT,
4232 Severity::Error,
4233 "environment-file format must be `raw` or interpolation",
4234 )
4235 .with_label(DiagnosticLabel::primary(format.raw().span(), "invalid format")),
4236 );
4237 }
4238 environment_file.set_format(format);
4239 }
4240 }
4241 name if name.starts_with("x-") => environment_file.push_extension(field.reference()),
4242 _ if duplicate => {}
4243 _ => environment_file.push_unknown(field.reference()),
4244 }
4245 }
4246 if environment_file.path().is_none() {
4247 self.missing(
4248 ENVIRONMENT_FILE_MISSING_PATH,
4249 span,
4250 "long environment-file entry is missing `path`",
4251 );
4252 }
4253 environment_file
4254 }
4255
4256 fn parse_extra_hosts(&mut self, field: &ParsedField) -> Option<ExtraHosts> {
4257 match field.value.as_ref() {
4258 Some(YamlNode::Sequence(sequence)) => {
4259 let span = span_from_position(self.source_id, sequence.byte_range());
4260 let entries = self
4261 .parse_scalar_nodes(sequence.values(), field.span, "extra_hosts entries must be scalars")
4262 .into_iter()
4263 .map(|raw| {
4264 let entry = ShortExtraHost::parse(raw);
4265 if !entry.is_complete() {
4266 self.diagnostics.push(
4267 Diagnostic::new(
4268 EXTRA_HOST_INVALID_ENTRY,
4269 Severity::Error,
4270 "short extra_hosts entry must contain a hostname and address",
4271 )
4272 .with_label(DiagnosticLabel::primary(
4273 entry.raw().span(),
4274 "missing separator or value",
4275 )),
4276 );
4277 }
4278 entry
4279 })
4280 .collect();
4281 Some(ExtraHosts::Short { span, entries })
4282 }
4283 Some(YamlNode::Mapping(mapping)) => {
4284 let span = span_from_position(self.source_id, mapping.byte_range());
4285 let mut entries = Vec::new();
4286 let mut seen = BTreeMap::new();
4287 for host in self.fields(mapping) {
4288 if self.record_duplicate(&mut seen, &host) {
4289 continue;
4290 }
4291 if let Some(address) = self.parse_string(&host, "extra host address") {
4292 let address = Located::new(HostAddress::parse(address.value), address.span);
4293 entries.push(LongExtraHost::new(host.name, address, host.span));
4294 }
4295 }
4296 Some(ExtraHosts::Long { span, entries })
4297 }
4298 _ => {
4299 self.expected(EXPECTED_FIELD_FORM, field, "extra_hosts must be a sequence or mapping");
4300 None
4301 }
4302 }
4303 }
4304
4305 fn parse_ulimits(&mut self, field: &ParsedField) -> Option<Ulimits> {
4306 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4307 self.expected(EXPECTED_MAPPING, field, "ulimits must be a mapping");
4308 return None;
4309 };
4310 let span = span_from_position(self.source_id, mapping.byte_range());
4311 let mut entries = Vec::new();
4312 let mut seen = BTreeMap::new();
4313 for limit in self.fields(mapping) {
4314 if self.record_duplicate(&mut seen, &limit) {
4315 continue;
4316 }
4317 if !valid_ulimit_name(limit.name.value()) {
4318 self.diagnostics.push(
4319 Diagnostic::new(
4320 ULIMIT_INVALID_NAME,
4321 Severity::Error,
4322 "ulimit names must contain only lowercase ASCII letters",
4323 )
4324 .with_label(DiagnosticLabel::primary(limit.name.span, "invalid ulimit name")),
4325 );
4326 }
4327 let value = match limit.value.as_ref() {
4328 Some(YamlNode::Scalar(_)) => self.parse_limit_value(&limit, "ulimit value").map(UlimitValue::Single),
4329 Some(YamlNode::Mapping(range)) => Some(UlimitValue::Range(self.parse_ulimit_range(range))),
4330 _ => {
4331 self.expected(
4332 EXPECTED_FIELD_FORM,
4333 &limit,
4334 "ulimit must be a scalar or soft/hard mapping",
4335 );
4336 None
4337 }
4338 };
4339 if let Some(value) = value {
4340 entries.push(Ulimit::new(limit.name, limit.span, value));
4341 }
4342 }
4343 Some(Ulimits::new(span, entries))
4344 }
4345
4346 fn parse_ulimit_range(&mut self, mapping: &Mapping) -> UlimitRange {
4347 let span = span_from_position(self.source_id, mapping.byte_range());
4348 let mut range = UlimitRange::new(span);
4349 let mut seen = BTreeMap::new();
4350 for field in self.fields(mapping) {
4351 let duplicate = self.record_duplicate(&mut seen, &field);
4352 match field.name.value.as_str() {
4353 "soft" if !duplicate => self
4354 .parse_limit_value(&field, "ulimit soft value")
4355 .into_iter()
4356 .for_each(|value| range.set_soft(value)),
4357 "hard" if !duplicate => self
4358 .parse_limit_value(&field, "ulimit hard value")
4359 .into_iter()
4360 .for_each(|value| range.set_hard(value)),
4361 name if name.starts_with("x-") => range.push_extension(field.reference()),
4362 _ if duplicate => {}
4363 _ => range.push_unknown(field.reference()),
4364 }
4365 }
4366 if range.soft().is_none() {
4367 self.missing(
4368 ULIMIT_MISSING_RANGE_MEMBER,
4369 span,
4370 "ulimit range is missing required `soft`",
4371 );
4372 }
4373 if range.hard().is_none() {
4374 self.missing(
4375 ULIMIT_MISSING_RANGE_MEMBER,
4376 span,
4377 "ulimit range is missing required `hard`",
4378 );
4379 }
4380 range
4381 }
4382
4383 fn parse_limit_value(&mut self, field: &ParsedField, description: &str) -> Option<Located<LimitValue>> {
4384 let value = self.parse_string(field, description)?;
4385 let parsed = LimitValue::parse(value.value);
4386 if !parsed.is_valid() {
4387 self.diagnostics.push(
4388 Diagnostic::new(
4389 ULIMIT_INVALID_VALUE,
4390 Severity::Error,
4391 "ulimit must be -1, a non-negative integer, or an interpolation expression",
4392 )
4393 .with_label(DiagnosticLabel::primary(value.span, "invalid ulimit value")),
4394 );
4395 }
4396 Some(Located::new(parsed, value.span))
4397 }
4398
4399 fn parse_depends_on(&mut self, field: &ParsedField) -> Option<DependsOn> {
4400 match field.value.as_ref() {
4401 Some(YamlNode::Sequence(sequence)) => {
4402 let span = span_from_position(self.source_id, sequence.byte_range());
4403 let services = self.parse_scalar_nodes(
4404 sequence.values(),
4405 field.span,
4406 "dependency service names must be scalars",
4407 );
4408 Some(DependsOn::Short { span, services })
4409 }
4410 Some(YamlNode::Mapping(mapping)) => {
4411 let span = span_from_position(self.source_id, mapping.byte_range());
4412 let mut services = Vec::new();
4413 let mut seen = BTreeMap::new();
4414 for dependency in self.fields(mapping) {
4415 if self.record_duplicate(&mut seen, &dependency) {
4416 continue;
4417 }
4418 let mut parsed = ServiceDependency::new(dependency.name.clone(), dependency.span);
4419 if Self::field_is_null(&dependency) {
4420 services.push(parsed);
4421 continue;
4422 }
4423 let Some(options) = dependency.value.as_ref().and_then(YamlNode::as_mapping) else {
4424 self.expected(
4425 EXPECTED_MAPPING,
4426 &dependency,
4427 "long dependency options must be a mapping or null",
4428 );
4429 continue;
4430 };
4431 let mut option_seen = BTreeMap::new();
4432 for option in self.fields(options) {
4433 let duplicate = self.record_duplicate(&mut option_seen, &option);
4434 match option.name.value.as_str() {
4435 "condition" if !duplicate => {
4436 if let Some(value) = self.parse_string(&option, "dependency condition") {
4437 let condition = DependencyCondition::parse(value.value);
4438 if !condition.is_known() {
4439 self.diagnostics.push(
4440 Diagnostic::new(
4441 DEPENDENCY_INVALID_CONDITION,
4442 Severity::Error,
4443 "dependency condition is not defined by Compose",
4444 )
4445 .with_label(
4446 DiagnosticLabel::primary(value.span, "unknown dependency condition"),
4447 ),
4448 );
4449 }
4450 parsed.set_condition(Located::new(condition, value.span));
4451 }
4452 }
4453 "restart" if !duplicate => self
4454 .parse_boolean(&option, "dependency restart")
4455 .into_iter()
4456 .for_each(|value| parsed.set_restart(value)),
4457 "required" if !duplicate => self
4458 .parse_boolean(&option, "dependency required")
4459 .into_iter()
4460 .for_each(|value| parsed.set_required(value)),
4461 name if name.starts_with("x-") => parsed.push_extension(option.reference()),
4462 _ if duplicate => {}
4463 _ => parsed.push_unknown(option.reference()),
4464 }
4465 }
4466 services.push(parsed);
4467 }
4468 Some(DependsOn::Long { span, services })
4469 }
4470 _ => {
4471 self.expected(EXPECTED_FIELD_FORM, field, "depends_on must be a sequence or mapping");
4472 None
4473 }
4474 }
4475 }
4476
4477 fn parse_healthcheck(&mut self, field: &ParsedField) -> Option<Healthcheck> {
4478 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4479 self.expected(EXPECTED_MAPPING, field, "healthcheck must be a mapping");
4480 return None;
4481 };
4482 let span = span_from_position(self.source_id, mapping.byte_range());
4483 let mut healthcheck = Healthcheck::new(span);
4484 let mut seen = BTreeMap::new();
4485 for option in self.fields(mapping) {
4486 let duplicate = self.record_duplicate(&mut seen, &option);
4487 match option.name.value.as_str() {
4488 "test" if !duplicate => self
4489 .parse_healthcheck_test(&option)
4490 .into_iter()
4491 .for_each(|value| healthcheck.set_test(value)),
4492 "interval" if !duplicate => self
4493 .parse_healthcheck_duration(&option, "healthcheck interval")
4494 .into_iter()
4495 .for_each(|value| healthcheck.set_interval(value)),
4496 "timeout" if !duplicate => self
4497 .parse_healthcheck_duration(&option, "healthcheck timeout")
4498 .into_iter()
4499 .for_each(|value| healthcheck.set_timeout(value)),
4500 "retries" if !duplicate => self
4501 .parse_healthcheck_retries(&option)
4502 .into_iter()
4503 .for_each(|value| healthcheck.set_retries(value)),
4504 "start_period" if !duplicate => self
4505 .parse_healthcheck_duration(&option, "healthcheck start period")
4506 .into_iter()
4507 .for_each(|value| healthcheck.set_start_period(value)),
4508 "start_interval" if !duplicate => self
4509 .parse_healthcheck_duration(&option, "healthcheck start interval")
4510 .into_iter()
4511 .for_each(|value| healthcheck.set_start_interval(value)),
4512 "disable" if !duplicate => self
4513 .parse_boolean(&option, "healthcheck disable")
4514 .into_iter()
4515 .for_each(|value| healthcheck.set_disable(value)),
4516 name if name.starts_with("x-") => healthcheck.push_extension(option.reference()),
4517 _ if duplicate => {}
4518 _ => healthcheck.push_unknown(option.reference()),
4519 }
4520 }
4521 Some(healthcheck)
4522 }
4523
4524 fn parse_healthcheck_duration(
4525 &mut self,
4526 field: &ParsedField,
4527 description: &str,
4528 ) -> Option<Located<HealthcheckDuration>> {
4529 let value = self.parse_string(field, description)?;
4530 let duration = HealthcheckDuration::parse(value.value);
4531 if !duration.is_valid() {
4532 self.diagnostics.push(
4533 Diagnostic::new(
4534 HEALTHCHECK_INVALID_DURATION,
4535 Severity::Error,
4536 "healthcheck duration must use Compose duration syntax or interpolation",
4537 )
4538 .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck duration")),
4539 );
4540 }
4541 Some(Located::new(duration, value.span))
4542 }
4543
4544 fn parse_healthcheck_retries(&mut self, field: &ParsedField) -> Option<Located<HealthcheckRetries>> {
4545 let value = self.parse_string(field, "healthcheck retries")?;
4546 let retries = HealthcheckRetries::parse(value.value);
4547 if !retries.is_valid() {
4548 self.diagnostics.push(
4549 Diagnostic::new(
4550 HEALTHCHECK_INVALID_RETRIES,
4551 Severity::Error,
4552 "healthcheck retries must be a non-negative integer or interpolation expression",
4553 )
4554 .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck retry count")),
4555 );
4556 }
4557 Some(Located::new(retries, value.span))
4558 }
4559
4560 fn parse_healthcheck_test(&mut self, field: &ParsedField) -> Option<HealthcheckTest> {
4561 match field.value.as_ref() {
4562 Some(YamlNode::Scalar(_)) => self
4563 .parse_string(field, "healthcheck test")
4564 .map(HealthcheckTest::String),
4565 Some(YamlNode::Sequence(sequence)) => {
4566 let span = span_from_position(self.source_id, sequence.byte_range());
4567 let values =
4568 self.parse_scalar_nodes(sequence.values(), field.span, "healthcheck test items must be scalars");
4569 let kind = values.first().map(|value| HealthcheckTestKind::parse(value.value()));
4570 if kind.is_none()
4571 || kind == Some(HealthcheckTestKind::Other)
4572 || (kind == Some(HealthcheckTestKind::None) && values.len() != 1)
4573 {
4574 self.diagnostics.push(
4575 Diagnostic::new(
4576 HEALTHCHECK_INVALID_TEST,
4577 Severity::Error,
4578 "healthcheck list must begin with NONE, CMD, or CMD-SHELL",
4579 )
4580 .with_label(DiagnosticLabel::primary(span, "invalid healthcheck command mode")),
4581 );
4582 }
4583 Some(HealthcheckTest::List { span, kind, values })
4584 }
4585 _ => {
4586 self.expected(
4587 EXPECTED_FIELD_FORM,
4588 field,
4589 "healthcheck test must be a scalar or sequence",
4590 );
4591 None
4592 }
4593 }
4594 }
4595
4596 fn parse_build(&mut self, field: &ParsedField) -> Option<Build> {
4597 match field.value.as_ref() {
4598 Some(YamlNode::Scalar(_)) => self.parse_string(field, "build context").map(Build::Context),
4599 Some(YamlNode::Mapping(mapping)) => {
4600 let mut definition = BuildDefinition::new(span_from_position(self.source_id, mapping.byte_range()));
4601 let mut seen = BTreeMap::new();
4602 let (mut dockerfile, mut dockerfile_inline) = (None, None);
4603 for option in self.fields(mapping) {
4604 if self.record_duplicate(&mut seen, &option) {
4605 continue;
4606 }
4607 if let Some(kind) = BuildFieldKind::from_name(option.name.value()) {
4608 definition.push_field(BuildField::new(kind, option.reference()));
4609 match kind {
4610 BuildFieldKind::AdditionalContexts => {
4611 definition.set_additional_contexts(self.parse_build_additional_contexts(&option));
4612 }
4613 BuildFieldKind::Args => {
4614 if let Some(args) = self.parse_build_args(&option) {
4615 definition.set_args(args);
4616 }
4617 }
4618 BuildFieldKind::CacheFrom | BuildFieldKind::CacheTo => {
4619 self.set_build_cache_locations(&mut definition, &option, kind);
4620 }
4621 BuildFieldKind::Entitlements => {
4622 if let Some(entitlements) = self.parse_build_entitlements(&option) {
4623 definition.set_entitlements(entitlements);
4624 }
4625 }
4626 BuildFieldKind::ExtraHosts => {
4627 if let Some(extra_hosts) = self.parse_build_extra_hosts(&option) {
4628 definition.set_extra_hosts(extra_hosts);
4629 }
4630 }
4631 BuildFieldKind::Context => {
4632 if let Some(context) = self.parse_string(&option, "build context") {
4633 definition.set_context(context);
4634 }
4635 }
4636 BuildFieldKind::Dockerfile => {
4637 dockerfile = Some(self.parse_build_dockerfile(&mut definition, &option));
4638 }
4639 BuildFieldKind::DockerfileInline => {
4640 self.set_build_dockerfile_inline(&mut definition, &option, &mut dockerfile_inline);
4641 }
4642 BuildFieldKind::Target => {
4643 if let Some(target) = self.parse_string(&option, "build target") {
4644 definition.set_target(target);
4645 }
4646 }
4647 BuildFieldKind::Network => {
4648 if let Some(network) = self.parse_string(&option, "build network") {
4649 definition.set_network(network);
4650 }
4651 }
4652 BuildFieldKind::Isolation => self.set_build_isolation(&mut definition, &option),
4653 BuildFieldKind::Platforms => {
4654 if let Some(platforms) = self.parse_build_platforms(&option) {
4655 definition.set_platforms(platforms);
4656 }
4657 }
4658 BuildFieldKind::NoCache => self.set_build_no_cache(&mut definition, &option),
4659 BuildFieldKind::NoCacheFilter => self.set_build_no_cache_filter(&mut definition, &option),
4660 BuildFieldKind::Privileged => self.set_build_privileged(&mut definition, &option),
4661 BuildFieldKind::Sbom => self.set_build_sbom(&mut definition, &option),
4662 BuildFieldKind::Provenance => self.set_build_provenance(&mut definition, &option),
4663 BuildFieldKind::Pull => {
4664 if let Some(pull) = self.parse_boolean(&option, "build pull") {
4665 definition.set_pull(pull);
4666 }
4667 }
4668 BuildFieldKind::ShmSize => self.set_build_shm_size(&mut definition, &option),
4669 BuildFieldKind::Tags => {
4670 if let Some(tags) = self.parse_build_tags(&option) {
4671 definition.set_tags(tags);
4672 }
4673 }
4674 BuildFieldKind::Labels => {
4675 if let Some(labels) = self.parse_labels(&option) {
4676 definition.set_labels(labels);
4677 }
4678 }
4679 BuildFieldKind::Secrets => self
4680 .parse_secret_grants(&option)
4681 .into_iter()
4682 .for_each(|secrets| definition.set_secrets(secrets)),
4683 BuildFieldKind::Ssh => self.set_build_ssh(&mut definition, &option),
4684 BuildFieldKind::Ulimits => self.set_build_ulimits(&mut definition, &option),
4685 }
4686 } else if option.name.value().starts_with("x-") {
4687 definition.push_extension(option.reference());
4688 } else {
4689 definition.push_unknown(option.reference());
4690 }
4691 }
4692 self.report_build_dockerfile_conflict(dockerfile, dockerfile_inline);
4693 Some(Build::Definition(definition))
4694 }
4695 _ => self.invalid_build_form(field),
4696 }
4697 }
4698
4699 fn invalid_build_form(&mut self, field: &ParsedField) -> Option<Build> {
4700 self.expected(EXPECTED_FIELD_FORM, field, "build must be a scalar context or mapping");
4701 None
4702 }
4703
4704 fn report_build_dockerfile_conflict(
4705 &mut self,
4706 dockerfile: Option<FieldReference>,
4707 dockerfile_inline: Option<FieldReference>,
4708 ) {
4709 let (Some(dockerfile), Some(dockerfile_inline)) = (dockerfile, dockerfile_inline) else {
4710 return;
4711 };
4712 self.diagnostics.push(
4713 Diagnostic::new(
4714 BUILD_DOCKERFILE_INLINE_CONFLICT,
4715 Severity::Error,
4716 "build `dockerfile` and `dockerfile_inline` are mutually exclusive",
4717 )
4718 .with_label(DiagnosticLabel::primary(dockerfile.span(), "dockerfile retained"))
4719 .with_label(DiagnosticLabel::secondary(
4720 dockerfile_inline.span(),
4721 "dockerfile_inline retained",
4722 )),
4723 );
4724 }
4725
4726 fn set_build_shm_size(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
4727 if let Some(shm_size) = self.parse_shm_size(field) {
4728 definition.set_shm_size(shm_size);
4729 }
4730 }
4731
4732 fn set_build_ulimits(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
4733 if let Some(ulimits) = self.parse_ulimits(field) {
4734 definition.set_ulimits(ulimits);
4735 }
4736 }
4737
4738 fn parse_build_tags(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
4739 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
4740 self.expected(EXPECTED_SEQUENCE, field, "build tags must be a sequence of scalars");
4741 return None;
4742 };
4743 Some(self.parse_scalar_nodes(
4744 sequence.values(),
4745 field.span,
4746 "build tag entries must be non-null scalars",
4747 ))
4748 }
4749
4750 fn parse_build_entitlements(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
4751 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
4752 self.expected(
4753 EXPECTED_SEQUENCE,
4754 field,
4755 "build entitlements must be a sequence of string scalars",
4756 );
4757 return None;
4758 };
4759 Some(self.parse_string_scalar_nodes(
4760 sequence.values(),
4761 field.span,
4762 "build entitlement entries must be string scalars",
4763 ))
4764 }
4765
4766 fn parse_build_cache_locations(&mut self, field: &ParsedField, name: &str) -> Option<Vec<Located<String>>> {
4767 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
4768 self.expected(
4769 EXPECTED_SEQUENCE,
4770 field,
4771 format!("build {name} must be a sequence of string scalars"),
4772 );
4773 return None;
4774 };
4775 Some(self.parse_string_scalar_nodes(
4776 sequence.values(),
4777 field.span,
4778 format!("build {name} entries must be string scalars"),
4779 ))
4780 }
4781
4782 fn set_build_cache_locations(
4783 &mut self,
4784 definition: &mut BuildDefinition,
4785 field: &ParsedField,
4786 kind: BuildFieldKind,
4787 ) {
4788 let name = if kind == BuildFieldKind::CacheFrom {
4789 "cache_from"
4790 } else {
4791 "cache_to"
4792 };
4793 if let Some(locations) = self.parse_build_cache_locations(field, name) {
4794 if kind == BuildFieldKind::CacheFrom {
4795 definition.set_cache_from(locations);
4796 } else {
4797 definition.set_cache_to(locations);
4798 }
4799 }
4800 }
4801
4802 fn parse_build_extra_hosts(&mut self, field: &ParsedField) -> Option<BuildExtraHosts> {
4803 match field.value.as_ref() {
4804 Some(YamlNode::Sequence(sequence)) => {
4805 let span = span_from_position(self.source_id, sequence.byte_range());
4806 let mut values = Vec::new();
4807 let mut seen = BTreeSet::new();
4808 for node in sequence.values() {
4809 let YamlNode::Scalar(scalar) = node else {
4810 self.unsupported_sequence_item(
4811 BUILD_EXTRA_HOSTS_EXPECTED_STRING,
4812 &node,
4813 field.span,
4814 "build extra_hosts list entries must be string scalars",
4815 );
4816 continue;
4817 };
4818 if !matches!(
4819 ScalarValue::from_scalar(&scalar).scalar_type(),
4820 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4821 ) {
4822 self.unsupported_sequence_item(
4823 BUILD_EXTRA_HOSTS_EXPECTED_STRING,
4824 &YamlNode::Scalar(scalar),
4825 field.span,
4826 "build extra_hosts list entries must be string scalars",
4827 );
4828 continue;
4829 }
4830 let raw = scalar_string_from_source(&self.source, &scalar);
4831 let item_span = span_from_position(self.source_id, scalar.byte_range());
4832 if !seen.insert(raw.clone()) {
4833 self.diagnostics.push(
4834 Diagnostic::new(
4835 BUILD_EXTRA_HOSTS_DUPLICATE_ITEM,
4836 Severity::Error,
4837 "build extra_hosts list entries must be unique raw strings",
4838 )
4839 .with_label(DiagnosticLabel::primary(item_span, "duplicate entry retained")),
4840 );
4841 }
4842 values.push(Located::new(raw, item_span));
4843 }
4844 Some(BuildExtraHosts::List { span, values })
4845 }
4846 Some(YamlNode::Mapping(mapping)) => {
4847 let span = span_from_position(self.source_id, mapping.byte_range());
4848 let mut entries = Vec::new();
4849 let mut seen = BTreeMap::new();
4850 for entry in self.fields(mapping) {
4851 if self.record_duplicate(&mut seen, &entry) {
4852 continue;
4853 }
4854 let Some(addresses) = self.parse_build_extra_host_addresses(&entry) else {
4855 continue;
4856 };
4857 entries.push(BuildExtraHostEntry::new(entry.name, addresses, entry.span));
4858 }
4859 Some(BuildExtraHosts::Map { span, entries })
4860 }
4861 _ => {
4862 self.expected(
4863 BUILD_EXTRA_HOSTS_EXPECTED_FORM,
4864 field,
4865 "build extra_hosts must be a sequence or mapping",
4866 );
4867 None
4868 }
4869 }
4870 }
4871
4872 fn parse_build_extra_host_addresses(&mut self, field: &ParsedField) -> Option<BuildExtraHostAddresses> {
4873 match field.value.as_ref() {
4874 Some(YamlNode::Scalar(scalar)) => {
4875 if !matches!(
4876 ScalarValue::from_scalar(scalar).scalar_type(),
4877 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4878 ) {
4879 self.expected(
4880 BUILD_EXTRA_HOSTS_EXPECTED_STRING,
4881 field,
4882 "build extra_hosts mapping addresses must be string scalars or sequences of string scalars",
4883 );
4884 return None;
4885 }
4886 let span = span_from_position(self.source_id, scalar.byte_range());
4887 Some(BuildExtraHostAddresses::Scalar(Located::new(
4888 scalar_string_from_source(&self.source, scalar),
4889 span,
4890 )))
4891 }
4892 Some(YamlNode::Sequence(sequence)) => {
4893 let span = span_from_position(self.source_id, sequence.byte_range());
4894 let values = self.parse_string_scalar_nodes(
4895 sequence.values(),
4896 field.span,
4897 "build extra_hosts mapping address lists must contain string scalars",
4898 );
4899 Some(BuildExtraHostAddresses::List { span, values })
4900 }
4901 _ => {
4902 self.expected(
4903 BUILD_EXTRA_HOSTS_EXPECTED_STRING,
4904 field,
4905 "build extra_hosts mapping addresses must be string scalars or sequences of string scalars",
4906 );
4907 None
4908 }
4909 }
4910 }
4911
4912 fn parse_build_additional_contexts(&mut self, field: &ParsedField) -> Option<BuildAdditionalContexts> {
4913 match field.value.as_ref() {
4914 Some(YamlNode::Sequence(sequence)) => {
4915 let span = span_from_position(self.source_id, sequence.byte_range());
4916 let values = self.parse_string_scalar_nodes(
4917 sequence.values(),
4918 field.span,
4919 "build additional context list entries must be string scalars",
4920 );
4921 Some(BuildAdditionalContexts::List { span, values })
4922 }
4923 Some(YamlNode::Mapping(mapping)) => {
4924 let span = span_from_position(self.source_id, mapping.byte_range());
4925 let entries = self.parse_scalar_mapping(field, "build additional contexts");
4926 Some(BuildAdditionalContexts::Map { span, entries })
4927 }
4928 _ => {
4929 self.expected(
4930 EXPECTED_FIELD_FORM,
4931 field,
4932 "build additional_contexts must be a sequence or mapping",
4933 );
4934 None
4935 }
4936 }
4937 }
4938
4939 fn parse_build_platforms(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
4940 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
4941 self.expected(
4942 EXPECTED_SEQUENCE,
4943 field,
4944 "build platforms must be a sequence of scalars",
4945 );
4946 return None;
4947 };
4948 Some(self.parse_scalar_nodes(
4949 sequence.values(),
4950 field.span,
4951 "build platform entries must be non-null scalars",
4952 ))
4953 }
4954
4955 fn parse_build_args(&mut self, field: &ParsedField) -> Option<BuildArgs> {
4956 match field.value.as_ref() {
4957 Some(YamlNode::Sequence(sequence)) => {
4958 let span = span_from_position(self.source_id, sequence.byte_range());
4959 let values = self.parse_string_scalar_nodes(
4960 sequence.values(),
4961 field.span,
4962 "build argument list entries must be string scalars",
4963 );
4964 Some(BuildArgs::List { span, values })
4965 }
4966 Some(YamlNode::Mapping(mapping)) => {
4967 let span = span_from_position(self.source_id, mapping.byte_range());
4968 let entries = self.parse_scalar_mapping(field, "build arguments");
4969 Some(BuildArgs::Map { span, entries })
4970 }
4971 _ => {
4972 self.expected(EXPECTED_FIELD_FORM, field, "build args must be a sequence or mapping");
4973 None
4974 }
4975 }
4976 }
4977
4978 fn parse_build_ssh(&mut self, field: &ParsedField) -> Option<BuildSsh> {
4979 match field.value.as_ref() {
4980 Some(YamlNode::Sequence(sequence)) => {
4981 let span = span_from_position(self.source_id, sequence.byte_range());
4982 let mut values = Vec::new();
4983 let mut seen = BTreeSet::new();
4984 for node in sequence.values() {
4985 let YamlNode::Scalar(scalar) = node else {
4986 self.unsupported_sequence_item(
4987 BUILD_SSH_EXPECTED_FORM,
4988 &node,
4989 field.span,
4990 "build ssh list entries must be string scalars",
4991 );
4992 continue;
4993 };
4994 if !matches!(
4995 ScalarValue::from_scalar(&scalar).scalar_type(),
4996 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4997 ) {
4998 self.unsupported_sequence_item(
4999 BUILD_SSH_EXPECTED_FORM,
5000 &YamlNode::Scalar(scalar),
5001 field.span,
5002 "build ssh list entries must be string scalars",
5003 );
5004 continue;
5005 }
5006 let value = scalar_string_from_source(&self.source, &scalar);
5007 let span = span_from_position(self.source_id, scalar.byte_range());
5008 if !seen.insert(value.clone()) {
5009 self.diagnostics.push(
5010 Diagnostic::new(
5011 BUILD_SSH_DUPLICATE_ITEM,
5012 Severity::Error,
5013 "build ssh list entries must be unique",
5014 )
5015 .with_label(DiagnosticLabel::primary(span, "duplicate SSH entry retained")),
5016 );
5017 }
5018 values.push(Located::new(value, span));
5019 }
5020 Some(BuildSsh::list(span, values))
5021 }
5022 Some(YamlNode::Mapping(mapping)) => {
5023 let span = span_from_position(self.source_id, mapping.byte_range());
5024 let mut entries = Vec::new();
5025 let mut seen = BTreeMap::new();
5026 for entry in self.fields(mapping) {
5027 if self.record_duplicate(&mut seen, &entry) {
5028 continue;
5029 }
5030 let key_span = entry.name.span;
5031 let Some(value) = entry.value.as_ref() else {
5032 entries.push(KeyValueEntry::new(
5033 entry.name,
5034 Located::new(ComposeScalar::Null, key_span),
5035 entry.span,
5036 ));
5037 continue;
5038 };
5039 let Some(scalar) = value.as_scalar() else {
5040 self.expected(
5041 BUILD_SSH_EXPECTED_FORM,
5042 &entry,
5043 "build ssh mapping values must be scalars or null",
5044 );
5045 continue;
5046 };
5047 let scalar_span = span_from_position(self.source_id, scalar.byte_range());
5048 let scalar_value = ScalarValue::from_scalar(scalar);
5049 let value = match scalar_value.scalar_type() {
5050 ScalarType::Null => ComposeScalar::Null,
5051 ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
5052 ScalarType::Integer | ScalarType::Float => {
5053 ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
5054 }
5055 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
5056 ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
5057 }
5058 };
5059 entries.push(KeyValueEntry::new(
5060 entry.name,
5061 Located::new(value, scalar_span),
5062 entry.span,
5063 ));
5064 }
5065 Some(BuildSsh::map(span, entries))
5066 }
5067 _ => {
5068 self.expected(
5069 BUILD_SSH_EXPECTED_FORM,
5070 field,
5071 "build ssh must be a sequence or mapping",
5072 );
5073 None
5074 }
5075 }
5076 }
5077
5078 fn set_build_ssh(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
5079 if let Some(ssh) = self.parse_build_ssh(field) {
5080 definition.set_ssh(ssh);
5081 }
5082 }
5083
5084 fn parse_deploy(&mut self, field: &ParsedField) -> Option<DeployDefinition> {
5085 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5086 self.expected(EXPECTED_MAPPING, field, "deploy must be a mapping");
5087 return None;
5088 };
5089 let span = span_from_position(self.source_id, mapping.byte_range());
5090 let mut definition = DeployDefinition::new(span);
5091 let mut seen = BTreeMap::new();
5092 for option in self.fields(mapping) {
5093 let duplicate = self.record_duplicate(&mut seen, &option);
5094 if duplicate {
5095 continue;
5096 }
5097 if let Some(kind) = DeployFieldKind::from_name(option.name.value()) {
5098 definition.push_field(DeployField::new(kind, option.reference()));
5099 match kind {
5100 DeployFieldKind::EndpointMode => self.set_deploy_endpoint_mode(&mut definition, &option),
5101 DeployFieldKind::Labels => self
5102 .parse_labels(&option)
5103 .into_iter()
5104 .for_each(|labels| definition.set_labels(labels)),
5105 DeployFieldKind::Mode => self.set_deploy_mode(&mut definition, &option),
5106 DeployFieldKind::Placement => self
5107 .parse_deploy_placement(&option)
5108 .into_iter()
5109 .for_each(|value| definition.set_placement(value)),
5110 DeployFieldKind::Replicas => self.set_deploy_replicas(&mut definition, &option),
5111 DeployFieldKind::Resources => self
5112 .parse_deploy_resources(&option)
5113 .into_iter()
5114 .for_each(|value| definition.set_resources(value)),
5115 DeployFieldKind::RestartPolicy => self
5116 .parse_deploy_restart_policy(&option)
5117 .into_iter()
5118 .for_each(|value| definition.set_restart_policy(value)),
5119 DeployFieldKind::RollbackConfig => self
5120 .parse_deploy_rollback_config(&option)
5121 .into_iter()
5122 .for_each(|value| definition.set_rollback_config(value)),
5123 DeployFieldKind::UpdateConfig => self
5124 .parse_deploy_update_config(&option)
5125 .into_iter()
5126 .for_each(|value| definition.set_update_config(value)),
5127 }
5128 } else if option.name.value().starts_with("x-") {
5129 definition.push_extension(option.reference());
5130 } else {
5131 definition.push_unknown(option.reference());
5132 }
5133 }
5134 Some(definition)
5135 }
5136
5137 fn set_deploy_endpoint_mode(&mut self, definition: &mut DeployDefinition, field: &ParsedField) {
5138 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5139 self.expected(
5140 EXPECTED_SCALAR,
5141 field,
5142 "deploy endpoint_mode must be a YAML string scalar",
5143 );
5144 return;
5145 };
5146 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
5147 self.expected(
5148 EXPECTED_SCALAR,
5149 field,
5150 "deploy endpoint_mode must be a YAML string scalar",
5151 );
5152 return;
5153 }
5154 let endpoint_mode = Located::new(
5155 DeployEndpointMode::parse(scalar_string_from_source(&self.source, scalar)),
5156 span_from_position(self.source_id, scalar.byte_range()),
5157 );
5158 if !endpoint_mode.value().is_documented() {
5159 self.diagnostics.push(
5160 Diagnostic::new(
5161 DEPLOY_ENDPOINT_MODE_PORTABILITY,
5162 Severity::Warning,
5163 "deploy endpoint_mode is outside Compose's documented portable values",
5164 )
5165 .with_label(DiagnosticLabel::primary(
5166 endpoint_mode.span(),
5167 "retained provider-specific endpoint mode",
5168 )),
5169 );
5170 }
5171 definition.set_endpoint_mode(endpoint_mode);
5172 }
5173
5174 fn set_deploy_mode(&mut self, definition: &mut DeployDefinition, field: &ParsedField) {
5175 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5176 self.expected(EXPECTED_SCALAR, field, "deploy mode must be a YAML string scalar");
5177 return;
5178 };
5179 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
5180 self.expected(EXPECTED_SCALAR, field, "deploy mode must be a YAML string scalar");
5181 return;
5182 }
5183 let mode = Located::new(
5184 DeployMode::parse(scalar_string_from_source(&self.source, scalar)),
5185 span_from_position(self.source_id, scalar.byte_range()),
5186 );
5187 if !mode.value().is_documented() {
5188 self.diagnostics.push(
5189 Diagnostic::new(
5190 DEPLOY_MODE_PORTABILITY,
5191 Severity::Warning,
5192 "deploy mode is outside Compose's documented portable values",
5193 )
5194 .with_label(DiagnosticLabel::primary(
5195 mode.span(),
5196 "retained provider-specific deploy mode",
5197 )),
5198 );
5199 }
5200 definition.set_mode(mode);
5201 }
5202
5203 fn set_deploy_replicas(&mut self, definition: &mut DeployDefinition, field: &ParsedField) {
5204 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5205 self.expected(
5206 EXPECTED_SCALAR,
5207 field,
5208 "deploy replicas must be a YAML number or string scalar",
5209 );
5210 return;
5211 };
5212 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
5213 ScalarType::Integer | ScalarType::Float => {
5214 DeployReplicas::YamlNumber(scalar_string_from_source(&self.source, scalar))
5215 }
5216 ScalarType::String => DeployReplicas::String(scalar_string_from_source(&self.source, scalar)),
5217 ScalarType::Boolean | ScalarType::Null | ScalarType::Timestamp | ScalarType::Regex => {
5218 self.expected(
5219 EXPECTED_SCALAR,
5220 field,
5221 "deploy replicas must be a YAML number or string scalar",
5222 );
5223 return;
5224 }
5225 };
5226 definition.set_replicas(Located::new(
5227 value,
5228 span_from_position(self.source_id, scalar.byte_range()),
5229 ));
5230 }
5231
5232 fn parse_deploy_restart_policy(&mut self, field: &ParsedField) -> Option<DeployRestartPolicy> {
5233 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5234 self.expected(EXPECTED_MAPPING, field, "deploy restart_policy must be a mapping");
5235 return None;
5236 };
5237 let mut policy = DeployRestartPolicy::new(span_from_position(self.source_id, mapping.byte_range()));
5238 let mut seen = BTreeMap::new();
5239 for option in self.fields(mapping) {
5240 if self.record_duplicate(&mut seen, &option) {
5241 continue;
5242 }
5243 match option.name.value().as_str() {
5244 name if name.starts_with("x-") => {
5245 policy.push_extension(option.reference());
5246 continue;
5247 }
5248 "condition" | "delay" | "max_attempts" | "window" => {}
5249 _ => {
5250 policy.push_unknown(option.reference());
5251 continue;
5252 }
5253 }
5254 let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) else {
5255 self.expected(EXPECTED_SCALAR, &option, "deploy restart-policy members must be scalar");
5256 continue;
5257 };
5258 let span = span_from_position(self.source_id, scalar.byte_range());
5259 match option.name.value().as_str() {
5260 "condition" if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::String => policy
5261 .set_condition(Located::new(
5262 DeployRestartCondition::parse(scalar_string_from_source(&self.source, scalar)),
5263 span,
5264 )),
5265 "delay" | "window" if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::String => {
5266 let value = Located::new(
5267 DeployRestartDuration::new(scalar_string_from_source(&self.source, scalar)),
5268 span,
5269 );
5270 if option.name.value() == "delay" {
5271 policy.set_delay(value);
5272 } else {
5273 policy.set_window(value);
5274 }
5275 }
5276 "max_attempts" => match ScalarValue::from_scalar(scalar).scalar_type() {
5277 ScalarType::Integer => policy.set_max_attempts(Located::new(
5278 DeployRestartMaxAttempts::YamlNumber(scalar_string_from_source(&self.source, scalar)),
5279 span,
5280 )),
5281 ScalarType::String => policy.set_max_attempts(Located::new(
5282 DeployRestartMaxAttempts::String(scalar_string_from_source(&self.source, scalar)),
5283 span,
5284 )),
5285 _ => self.expected(
5286 EXPECTED_SCALAR,
5287 &option,
5288 "deploy restart-policy max_attempts must be a YAML integer or string scalar",
5289 ),
5290 },
5291 "condition" | "delay" | "window" => self.expected(
5292 EXPECTED_SCALAR,
5293 &option,
5294 "deploy restart-policy condition, delay, and window must be YAML string scalars",
5295 ),
5296 _ => unreachable!("recognized deploy restart-policy field already matched"),
5297 }
5298 }
5299 Some(policy)
5300 }
5301
5302 fn parse_deploy_update_config(&mut self, field: &ParsedField) -> Option<DeployUpdateConfig> {
5303 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5304 self.expected(EXPECTED_MAPPING, field, "deploy update_config must be a mapping");
5305 return None;
5306 };
5307 let mut config = DeployUpdateConfig::new(span_from_position(self.source_id, mapping.byte_range()));
5308 let mut seen = BTreeMap::new();
5309 for member in self.fields(mapping) {
5310 if self.record_duplicate(&mut seen, &member) {
5311 continue;
5312 }
5313 let parsed = match member.name.value().as_str() {
5314 name if name.starts_with("x-") => {
5315 config.push_extension(member.reference());
5316 true
5317 }
5318 "parallelism" => self
5319 .parse_deploy_update_parallelism(&member)
5320 .map(|value| {
5321 config.set_parallelism(value);
5322 })
5323 .is_some(),
5324 "delay" => self
5325 .parse_deploy_update_string(&member, "deploy update_config delay must be a YAML string scalar")
5326 .map(|value| {
5327 config.set_delay(value);
5328 })
5329 .is_some(),
5330 "monitor" => self
5331 .parse_deploy_update_string(&member, "deploy update_config monitor must be a YAML string scalar")
5332 .map(|value| {
5333 config.set_monitor(value);
5334 })
5335 .is_some(),
5336 "failure_action" => self
5337 .parse_deploy_update_string(
5338 &member,
5339 "deploy update_config failure_action must be a YAML string scalar",
5340 )
5341 .map(|value| {
5342 config.set_failure_action(value);
5343 })
5344 .is_some(),
5345 "max_failure_ratio" => self
5346 .parse_deploy_update_max_failure_ratio(&member)
5347 .map(|value| {
5348 config.set_max_failure_ratio(value);
5349 })
5350 .is_some(),
5351 "order" => self
5352 .parse_deploy_update_order(&member)
5353 .map(|value| {
5354 config.set_order(value);
5355 })
5356 .is_some(),
5357 _ => {
5358 config.push_unknown(member.reference());
5359 true
5360 }
5361 };
5362 if !parsed {
5363 config.push_unknown(member.reference());
5364 }
5365 }
5366 Some(config)
5367 }
5368
5369 fn parse_deploy_rollback_config(&mut self, field: &ParsedField) -> Option<DeployRollbackConfig> {
5370 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5371 self.expected(EXPECTED_MAPPING, field, "deploy rollback_config must be a mapping");
5372 return None;
5373 };
5374 let mut config = DeployRollbackConfig::new(span_from_position(self.source_id, mapping.byte_range()));
5375 let mut seen = BTreeMap::new();
5376 for member in self.fields(mapping) {
5377 if self.record_duplicate(&mut seen, &member) {
5378 continue;
5379 }
5380 let parsed = match member.name.value().as_str() {
5381 name if name.starts_with("x-") => {
5382 config.push_extension(member.reference());
5383 true
5384 }
5385 "parallelism" => self
5386 .parse_deploy_rollback_parallelism(&member)
5387 .map(|value| config.set_parallelism(value))
5388 .is_some(),
5389 "delay" => self
5390 .parse_deploy_rollback_string(&member, "deploy rollback_config delay must be a YAML string scalar")
5391 .map(|value| config.set_delay(value))
5392 .is_some(),
5393 "monitor" => self
5394 .parse_deploy_rollback_string(
5395 &member,
5396 "deploy rollback_config monitor must be a YAML string scalar",
5397 )
5398 .map(|value| config.set_monitor(value))
5399 .is_some(),
5400 "failure_action" => self
5401 .parse_deploy_rollback_string(
5402 &member,
5403 "deploy rollback_config failure_action must be a YAML string scalar",
5404 )
5405 .map(|value| config.set_failure_action(value))
5406 .is_some(),
5407 "max_failure_ratio" => self
5408 .parse_deploy_rollback_max_failure_ratio(&member)
5409 .map(|value| config.set_max_failure_ratio(value))
5410 .is_some(),
5411 "order" => self
5412 .parse_deploy_rollback_order(&member)
5413 .map(|value| config.set_order(value))
5414 .is_some(),
5415 _ => {
5416 config.push_unknown(member.reference());
5417 true
5418 }
5419 };
5420 if !parsed {
5421 config.push_unknown(member.reference());
5422 }
5423 }
5424 Some(config)
5425 }
5426
5427 fn parse_deploy_rollback_string(&mut self, field: &ParsedField, message: &'static str) -> Option<Located<String>> {
5428 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5429 self.expected(EXPECTED_SCALAR, field, message);
5430 return None;
5431 };
5432 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
5433 self.expected(EXPECTED_SCALAR, field, message);
5434 return None;
5435 }
5436 Some(Located::new(
5437 scalar_string_from_source(&self.source, scalar),
5438 span_from_position(self.source_id, scalar.byte_range()),
5439 ))
5440 }
5441
5442 fn parse_deploy_rollback_parallelism(&mut self, field: &ParsedField) -> Option<Located<DeployRollbackParallelism>> {
5443 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5444 self.expected(
5445 EXPECTED_SCALAR,
5446 field,
5447 "deploy rollback_config parallelism must be a YAML integer or string scalar",
5448 );
5449 return None;
5450 };
5451 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
5452 ScalarType::Integer => {
5453 DeployRollbackParallelism::YamlInteger(scalar_string_from_source(&self.source, scalar))
5454 }
5455 ScalarType::String => DeployRollbackParallelism::String(scalar_string_from_source(&self.source, scalar)),
5456 _ => {
5457 self.expected(
5458 EXPECTED_SCALAR,
5459 field,
5460 "deploy rollback_config parallelism must be a YAML integer or string scalar",
5461 );
5462 return None;
5463 }
5464 };
5465 Some(Located::new(
5466 value,
5467 span_from_position(self.source_id, scalar.byte_range()),
5468 ))
5469 }
5470
5471 fn parse_deploy_rollback_max_failure_ratio(
5472 &mut self,
5473 field: &ParsedField,
5474 ) -> Option<Located<DeployRollbackMaxFailureRatio>> {
5475 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5476 self.expected(
5477 EXPECTED_SCALAR,
5478 field,
5479 "deploy rollback_config max_failure_ratio must be a YAML number or string scalar",
5480 );
5481 return None;
5482 };
5483 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
5484 ScalarType::Integer | ScalarType::Float => {
5485 DeployRollbackMaxFailureRatio::YamlNumber(scalar_string_from_source(&self.source, scalar))
5486 }
5487 ScalarType::String => {
5488 DeployRollbackMaxFailureRatio::String(scalar_string_from_source(&self.source, scalar))
5489 }
5490 _ => {
5491 self.expected(
5492 EXPECTED_SCALAR,
5493 field,
5494 "deploy rollback_config max_failure_ratio must be a YAML number or string scalar",
5495 );
5496 return None;
5497 }
5498 };
5499 Some(Located::new(
5500 value,
5501 span_from_position(self.source_id, scalar.byte_range()),
5502 ))
5503 }
5504
5505 fn parse_deploy_rollback_order(&mut self, field: &ParsedField) -> Option<Located<DeployRollbackOrder>> {
5506 let raw =
5507 self.parse_deploy_rollback_string(field, "deploy rollback_config order must be a YAML string scalar")?;
5508 let order = DeployRollbackOrder::parse(raw.value().clone());
5509 if !order.is_documented() {
5510 self.diagnostics.push(
5511 Diagnostic::new(
5512 DEPLOY_ROLLBACK_CONFIG_ORDER_PORTABILITY,
5513 Severity::Warning,
5514 "deploy rollback_config order is outside Compose's documented portable values",
5515 )
5516 .with_label(DiagnosticLabel::primary(
5517 raw.span(),
5518 "retained provider-specific rollback order",
5519 )),
5520 );
5521 }
5522 Some(Located::new(order, raw.span()))
5523 }
5524
5525 fn parse_deploy_update_string(&mut self, field: &ParsedField, message: &'static str) -> Option<Located<String>> {
5526 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5527 self.expected(EXPECTED_SCALAR, field, message);
5528 return None;
5529 };
5530 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
5531 self.expected(EXPECTED_SCALAR, field, message);
5532 return None;
5533 }
5534 Some(Located::new(
5535 scalar_string_from_source(&self.source, scalar),
5536 span_from_position(self.source_id, scalar.byte_range()),
5537 ))
5538 }
5539
5540 fn parse_deploy_update_parallelism(&mut self, field: &ParsedField) -> Option<Located<DeployUpdateParallelism>> {
5541 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5542 self.expected(
5543 EXPECTED_SCALAR,
5544 field,
5545 "deploy update_config parallelism must be a YAML integer or string scalar",
5546 );
5547 return None;
5548 };
5549 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
5550 ScalarType::Integer => {
5551 DeployUpdateParallelism::YamlInteger(scalar_string_from_source(&self.source, scalar))
5552 }
5553 ScalarType::String => DeployUpdateParallelism::String(scalar_string_from_source(&self.source, scalar)),
5554 _ => {
5555 self.expected(
5556 EXPECTED_SCALAR,
5557 field,
5558 "deploy update_config parallelism must be a YAML integer or string scalar",
5559 );
5560 return None;
5561 }
5562 };
5563 Some(Located::new(
5564 value,
5565 span_from_position(self.source_id, scalar.byte_range()),
5566 ))
5567 }
5568 fn parse_deploy_update_max_failure_ratio(
5569 &mut self,
5570 field: &ParsedField,
5571 ) -> Option<Located<DeployUpdateMaxFailureRatio>> {
5572 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5573 self.expected(
5574 EXPECTED_SCALAR,
5575 field,
5576 "deploy update_config max_failure_ratio must be a YAML number or string scalar",
5577 );
5578 return None;
5579 };
5580 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
5581 ScalarType::Integer | ScalarType::Float => {
5582 DeployUpdateMaxFailureRatio::YamlNumber(scalar_string_from_source(&self.source, scalar))
5583 }
5584 ScalarType::String => DeployUpdateMaxFailureRatio::String(scalar_string_from_source(&self.source, scalar)),
5585 _ => {
5586 self.expected(
5587 EXPECTED_SCALAR,
5588 field,
5589 "deploy update_config max_failure_ratio must be a YAML number or string scalar",
5590 );
5591 return None;
5592 }
5593 };
5594 Some(Located::new(
5595 value,
5596 span_from_position(self.source_id, scalar.byte_range()),
5597 ))
5598 }
5599 fn parse_deploy_update_order(&mut self, field: &ParsedField) -> Option<Located<DeployUpdateOrder>> {
5600 let raw = self.parse_deploy_update_string(field, "deploy update_config order must be a YAML string scalar")?;
5601 let order = DeployUpdateOrder::parse(raw.value().clone());
5602 if !order.is_documented() {
5603 self.diagnostics.push(
5604 Diagnostic::new(
5605 DEPLOY_UPDATE_CONFIG_ORDER_PORTABILITY,
5606 Severity::Warning,
5607 "deploy update_config order is outside Compose's documented portable values",
5608 )
5609 .with_label(DiagnosticLabel::primary(
5610 raw.span(),
5611 "retained provider-specific update order",
5612 )),
5613 );
5614 }
5615 Some(Located::new(order, raw.span()))
5616 }
5617
5618 fn parse_deploy_placement(&mut self, field: &ParsedField) -> Option<DeployPlacement> {
5619 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5620 self.expected(EXPECTED_MAPPING, field, "deploy placement must be a mapping");
5621 return None;
5622 };
5623 let mut placement = DeployPlacement::new(span_from_position(self.source_id, mapping.byte_range()));
5624 let mut seen = BTreeMap::new();
5625 for option in self.fields(mapping) {
5626 if self.record_duplicate(&mut seen, &option) {
5627 continue;
5628 }
5629 match option.name.value().as_str() {
5630 name if name.starts_with("x-") => placement.push_extension(option.reference()),
5631 "constraints" => self
5632 .parse_deploy_placement_constraints(&option)
5633 .into_iter()
5634 .for_each(|value| placement.set_constraints(value)),
5635 "preferences" => self
5636 .parse_deploy_placement_preferences(&option)
5637 .into_iter()
5638 .for_each(|value| placement.set_preferences(value)),
5639 "max_replicas_per_node" => self
5640 .parse_deploy_placement_max_replicas_per_node(&option)
5641 .into_iter()
5642 .for_each(|value| placement.set_max_replicas_per_node(value)),
5643 _ => placement.push_unknown(option.reference()),
5644 }
5645 }
5646 Some(placement)
5647 }
5648
5649 fn parse_deploy_placement_constraints(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
5650 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
5651 self.expected(
5652 EXPECTED_SEQUENCE,
5653 field,
5654 "deploy placement constraints must be a sequence",
5655 );
5656 return None;
5657 };
5658 let mut constraints = Vec::new();
5659 for value in sequence.values() {
5660 let YamlNode::Scalar(scalar) = value else {
5661 self.unsupported_sequence_item(
5662 EXPECTED_SCALAR,
5663 &value,
5664 field.span,
5665 "deploy placement constraints must contain YAML string scalars",
5666 );
5667 continue;
5668 };
5669 if ScalarValue::from_scalar(&scalar).scalar_type() != ScalarType::String {
5670 self.unsupported_sequence_item(
5671 EXPECTED_SCALAR,
5672 &YamlNode::Scalar(scalar),
5673 field.span,
5674 "deploy placement constraints must contain YAML string scalars",
5675 );
5676 continue;
5677 }
5678 constraints.push(Located::new(
5679 scalar_string_from_source(&self.source, &scalar),
5680 span_from_position(self.source_id, scalar.byte_range()),
5681 ));
5682 }
5683 Some(constraints)
5684 }
5685
5686 fn parse_deploy_placement_preferences(&mut self, field: &ParsedField) -> Option<Vec<DeployPlacementPreference>> {
5687 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
5688 self.expected(
5689 EXPECTED_SEQUENCE,
5690 field,
5691 "deploy placement preferences must be a sequence",
5692 );
5693 return None;
5694 };
5695 let mut preferences = Vec::new();
5696 for value in sequence.values() {
5697 let YamlNode::Mapping(mapping) = value else {
5698 self.unsupported_sequence_item(
5699 EXPECTED_MAPPING,
5700 &value,
5701 field.span,
5702 "deploy placement preferences must contain mappings",
5703 );
5704 continue;
5705 };
5706 let mut preference =
5707 DeployPlacementPreference::new(span_from_position(self.source_id, mapping.byte_range()));
5708 let mut seen = BTreeMap::new();
5709 for member in self.fields(&mapping) {
5710 if self.record_duplicate(&mut seen, &member) {
5711 continue;
5712 }
5713 match member.name.value().as_str() {
5714 name if name.starts_with("x-") => preference.push_extension(member.reference()),
5715 "spread" => self
5716 .parse_deploy_placement_string(&member, "deploy placement preference spread")
5717 .into_iter()
5718 .for_each(|value| preference.set_spread(value)),
5719 _ => preference.push_unknown(member.reference()),
5720 }
5721 }
5722 preferences.push(preference);
5723 }
5724 Some(preferences)
5725 }
5726
5727 fn parse_deploy_placement_max_replicas_per_node(
5728 &mut self,
5729 field: &ParsedField,
5730 ) -> Option<Located<DeployPlacementMaxReplicasPerNode>> {
5731 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5732 self.expected(
5733 EXPECTED_SCALAR,
5734 field,
5735 "deploy placement max_replicas_per_node must be a YAML integer or string scalar",
5736 );
5737 return None;
5738 };
5739 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
5740 ScalarType::Integer => {
5741 DeployPlacementMaxReplicasPerNode::YamlInteger(scalar_string_from_source(&self.source, scalar))
5742 }
5743 ScalarType::String => {
5744 DeployPlacementMaxReplicasPerNode::String(scalar_string_from_source(&self.source, scalar))
5745 }
5746 _ => {
5747 self.expected(
5748 EXPECTED_SCALAR,
5749 field,
5750 "deploy placement max_replicas_per_node must be a YAML integer or string scalar",
5751 );
5752 return None;
5753 }
5754 };
5755 Some(Located::new(
5756 value,
5757 span_from_position(self.source_id, scalar.byte_range()),
5758 ))
5759 }
5760
5761 fn parse_deploy_placement_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
5762 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5763 self.expected(
5764 EXPECTED_SCALAR,
5765 field,
5766 format!("{description} must be a YAML string scalar"),
5767 );
5768 return None;
5769 };
5770 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
5771 self.expected(
5772 EXPECTED_SCALAR,
5773 field,
5774 format!("{description} must be a YAML string scalar"),
5775 );
5776 return None;
5777 }
5778 Some(Located::new(
5779 scalar_string_from_source(&self.source, scalar),
5780 span_from_position(self.source_id, scalar.byte_range()),
5781 ))
5782 }
5783
5784 fn parse_deploy_resources(&mut self, field: &ParsedField) -> Option<DeployResources> {
5785 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5786 self.expected(EXPECTED_MAPPING, field, "deploy resources must be a mapping");
5787 return None;
5788 };
5789 let mut resources = DeployResources::new(span_from_position(self.source_id, mapping.byte_range()));
5790 let mut seen = BTreeMap::new();
5791 for option in self.fields(mapping) {
5792 if self.record_duplicate(&mut seen, &option) {
5793 continue;
5794 }
5795 match option.name.value().as_str() {
5796 name if name.starts_with("x-") => resources.push_extension(option.reference()),
5797 "limits" => self
5798 .parse_deploy_resource_limits(&option)
5799 .into_iter()
5800 .for_each(|value| resources.set_limits(value)),
5801 "reservations" => self
5802 .parse_deploy_resource_reservations(&option)
5803 .into_iter()
5804 .for_each(|value| resources.set_reservations(value)),
5805 _ => resources.push_unknown(option.reference()),
5806 }
5807 }
5808 Some(resources)
5809 }
5810
5811 fn parse_deploy_resource_limits(&mut self, field: &ParsedField) -> Option<DeployResourceLimits> {
5812 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5813 self.expected(EXPECTED_MAPPING, field, "deploy resource limits must be a mapping");
5814 return None;
5815 };
5816 let mut limits = DeployResourceLimits::new(span_from_position(self.source_id, mapping.byte_range()));
5817 let mut seen = BTreeMap::new();
5818 for option in self.fields(mapping) {
5819 if self.record_duplicate(&mut seen, &option) {
5820 continue;
5821 }
5822 match option.name.value().as_str() {
5823 name if name.starts_with("x-") => limits.push_extension(option.reference()),
5824 "cpus" => self
5825 .parse_deploy_resource_cpus(&option, "deploy resource limits cpus")
5826 .into_iter()
5827 .for_each(|value| limits.set_cpus(value)),
5828 "memory" => self
5829 .parse_deploy_resource_memory(&option, "deploy resource limits memory must be a YAML string scalar")
5830 .into_iter()
5831 .for_each(|value| limits.set_memory(value)),
5832 "pids" => self
5833 .parse_deploy_resource_pids(&option)
5834 .into_iter()
5835 .for_each(|value| limits.set_pids(value)),
5836 _ => limits.push_unknown(option.reference()),
5837 }
5838 }
5839 Some(limits)
5840 }
5841
5842 fn parse_deploy_resource_reservations(&mut self, field: &ParsedField) -> Option<DeployResourceReservations> {
5843 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5844 self.expected(
5845 EXPECTED_MAPPING,
5846 field,
5847 "deploy resource reservations must be a mapping",
5848 );
5849 return None;
5850 };
5851 let mut reservations =
5852 DeployResourceReservations::new(span_from_position(self.source_id, mapping.byte_range()));
5853 let mut seen = BTreeMap::new();
5854 for option in self.fields(mapping) {
5855 if self.record_duplicate(&mut seen, &option) {
5856 continue;
5857 }
5858 match option.name.value().as_str() {
5859 name if name.starts_with("x-") => reservations.push_extension(option.reference()),
5860 "cpus" => self
5861 .parse_deploy_resource_cpus(&option, "deploy resource reservations cpus")
5862 .into_iter()
5863 .for_each(|value| reservations.set_cpus(value)),
5864 "memory" => self
5865 .parse_deploy_resource_memory(
5866 &option,
5867 "deploy resource reservations memory must be a YAML string scalar",
5868 )
5869 .into_iter()
5870 .for_each(|value| reservations.set_memory(value)),
5871 "generic_resources" => {
5872 if let Some(value) = self.parse_deploy_generic_resources(&option) {
5873 reservations.set_generic_resources(value);
5874 } else {
5875 reservations.push_unknown(option.reference());
5876 }
5877 }
5878 "devices" => {
5879 if let Some(value) = self.parse_deploy_reservation_devices(&option) {
5880 reservations.set_devices(value);
5881 } else {
5882 reservations.push_unknown(option.reference());
5883 }
5884 }
5885 _ => reservations.push_unknown(option.reference()),
5886 }
5887 }
5888 Some(reservations)
5889 }
5890
5891 fn parse_deploy_resource_pids(&mut self, field: &ParsedField) -> Option<Located<DeployResourcePids>> {
5892 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5893 self.expected(
5894 EXPECTED_SCALAR,
5895 field,
5896 "deploy resource limits pids must be a YAML integer or string scalar",
5897 );
5898 return None;
5899 };
5900 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
5901 ScalarType::Integer => DeployResourcePids::YamlInteger(scalar_string_from_source(&self.source, scalar)),
5902 ScalarType::String => DeployResourcePids::String(scalar_string_from_source(&self.source, scalar)),
5903 _ => {
5904 self.expected(
5905 EXPECTED_SCALAR,
5906 field,
5907 "deploy resource limits pids must be a YAML integer or string scalar",
5908 );
5909 return None;
5910 }
5911 };
5912 Some(Located::new(
5913 value,
5914 span_from_position(self.source_id, scalar.byte_range()),
5915 ))
5916 }
5917
5918 fn parse_deploy_resource_cpus(
5919 &mut self,
5920 field: &ParsedField,
5921 context: &'static str,
5922 ) -> Option<Located<DeployResourceCpus>> {
5923 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5924 self.expected(
5925 EXPECTED_SCALAR,
5926 field,
5927 format!("{context} must be a YAML number or string scalar"),
5928 );
5929 return None;
5930 };
5931 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
5932 ScalarType::Integer | ScalarType::Float => {
5933 DeployResourceCpus::YamlNumber(scalar_string_from_source(&self.source, scalar))
5934 }
5935 ScalarType::String => DeployResourceCpus::String(scalar_string_from_source(&self.source, scalar)),
5936 _ => {
5937 self.expected(
5938 EXPECTED_SCALAR,
5939 field,
5940 format!("{context} must be a YAML number or string scalar"),
5941 );
5942 return None;
5943 }
5944 };
5945 Some(Located::new(
5946 value,
5947 span_from_position(self.source_id, scalar.byte_range()),
5948 ))
5949 }
5950
5951 fn parse_deploy_resource_memory(
5952 &mut self,
5953 field: &ParsedField,
5954 message: &'static str,
5955 ) -> Option<Located<DeployResourceMemory>> {
5956 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5957 self.expected(EXPECTED_SCALAR, field, message);
5958 return None;
5959 };
5960 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
5961 self.expected(EXPECTED_SCALAR, field, message);
5962 return None;
5963 }
5964 Some(Located::new(
5965 DeployResourceMemory::parse(scalar_string_from_source(&self.source, scalar)),
5966 span_from_position(self.source_id, scalar.byte_range()),
5967 ))
5968 }
5969
5970 fn parse_deploy_generic_resources(&mut self, field: &ParsedField) -> Option<DeployGenericResources> {
5971 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
5972 self.expected(
5973 EXPECTED_SEQUENCE,
5974 field,
5975 "deploy resource reservations generic_resources must be a sequence",
5976 );
5977 return None;
5978 };
5979 let mut items = Vec::new();
5980 for node in sequence.values() {
5981 let Some(mapping) = node.as_mapping() else {
5982 self.unsupported_sequence_item(
5983 EXPECTED_MAPPING,
5984 &node,
5985 field.span,
5986 "deploy resource generic-resource entries must be mappings",
5987 );
5988 items.push(DeployGenericResource::unmodeled(
5989 node_span(self.source_id, &node).unwrap_or(field.span),
5990 ));
5991 continue;
5992 };
5993 let mut item = DeployGenericResource::new(span_from_position(self.source_id, mapping.byte_range()));
5994 let mut seen = BTreeMap::new();
5995 for option in self.fields(mapping) {
5996 if self.record_duplicate(&mut seen, &option) {
5997 continue;
5998 }
5999 match option.name.value().as_str() {
6000 name if name.starts_with("x-") => item.push_extension(option.reference()),
6001 "discrete_resource_spec" => {
6002 if let Some(value) = self.parse_deploy_discrete_resource_spec(&option) {
6003 item.set_discrete_resource_spec(value);
6004 } else {
6005 item.push_unknown(option.reference());
6006 }
6007 }
6008 _ => item.push_unknown(option.reference()),
6009 }
6010 }
6011 items.push(item);
6012 }
6013 Some(DeployGenericResources::new(
6014 span_from_position(self.source_id, sequence.byte_range()),
6015 items,
6016 ))
6017 }
6018
6019 fn parse_deploy_reservation_devices(&mut self, field: &ParsedField) -> Option<DeployReservationDevices> {
6020 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6021 self.expected(
6022 EXPECTED_SEQUENCE,
6023 field,
6024 "deploy resource reservation devices must be a sequence",
6025 );
6026 return None;
6027 };
6028 let mut items = Vec::new();
6029 for node in sequence.values() {
6030 let Some(mapping) = node.as_mapping() else {
6031 self.unsupported_sequence_item(
6032 EXPECTED_MAPPING,
6033 &node,
6034 field.span,
6035 "deploy resource reservation device entries must be mappings",
6036 );
6037 items.push(DeployReservationDevice::unmodeled(
6038 node_span(self.source_id, &node).unwrap_or(field.span),
6039 ));
6040 continue;
6041 };
6042 let item_span = span_from_position(self.source_id, mapping.byte_range());
6043 let mut item = DeployReservationDevice::new(item_span);
6044 let mut seen = BTreeMap::new();
6045 let mut has_capabilities = false;
6046 let mut count_field = None;
6047 let mut device_ids_field = None;
6048 for option in self.fields(mapping) {
6049 if self.record_duplicate(&mut seen, &option) {
6050 continue;
6051 }
6052 match option.name.value().as_str() {
6053 name if name.starts_with("x-") => item.push_extension(option.reference()),
6054 "capabilities" => {
6055 has_capabilities = true;
6056 if let Some(value) = self.parse_deploy_reservation_device_capabilities(&option) {
6057 item.set_capabilities(value);
6058 } else {
6059 item.push_unknown(option.reference());
6060 }
6061 }
6062 "driver" => {
6063 let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) else {
6064 self.expected(
6065 EXPECTED_SCALAR,
6066 &option,
6067 "deploy resource reservation device driver must be a YAML string scalar",
6068 );
6069 item.push_unknown(option.reference());
6070 continue;
6071 };
6072 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
6073 self.expected(
6074 EXPECTED_SCALAR,
6075 &option,
6076 "deploy resource reservation device driver must be a YAML string scalar",
6077 );
6078 item.push_unknown(option.reference());
6079 continue;
6080 }
6081 item.set_driver(Located::new(
6082 scalar_string_from_source(&self.source, scalar),
6083 span_from_position(self.source_id, scalar.byte_range()),
6084 ));
6085 }
6086 "count" => {
6087 count_field.get_or_insert(option.span);
6088 if let Some(value) = self.parse_deploy_reservation_device_count(&option) {
6089 item.set_count(value);
6090 } else {
6091 item.push_unknown(option.reference());
6092 }
6093 }
6094 "device_ids" => {
6095 device_ids_field.get_or_insert(option.span);
6096 if let Some(value) = self.parse_deploy_reservation_device_ids(&option) {
6097 item.set_device_ids(value);
6098 } else {
6099 item.push_unknown(option.reference());
6100 }
6101 }
6102 "options" => self.set_deploy_reservation_device_options(&mut item, &option),
6103 _ => item.push_unknown(option.reference()),
6104 }
6105 }
6106 self.reservation_device_allocation_selector_conflict(count_field, device_ids_field);
6107 if !has_capabilities {
6108 self.missing(
6109 DEPLOY_RESERVATION_DEVICE_MISSING_CAPABILITIES,
6110 item_span,
6111 "deploy resource reservation device is missing required `capabilities`",
6112 );
6113 }
6114 items.push(item);
6115 }
6116 Some(DeployReservationDevices::new(
6117 span_from_position(self.source_id, sequence.byte_range()),
6118 items,
6119 ))
6120 }
6121
6122 fn reservation_device_allocation_selector_conflict(
6123 &mut self,
6124 count: Option<SourceSpan>,
6125 device_ids: Option<SourceSpan>,
6126 ) {
6127 let (Some(count), Some(device_ids)) = (count, device_ids) else {
6128 return;
6129 };
6130 self.diagnostics.push(
6131 Diagnostic::new(
6132 DEPLOY_RESERVATION_DEVICE_ALLOCATION_SELECTOR_CONFLICT,
6133 Severity::Error,
6134 "deploy resource reservation device count and device_ids are mutually exclusive",
6135 )
6136 .with_label(DiagnosticLabel::primary(count, "count retained"))
6137 .with_label(DiagnosticLabel::secondary(device_ids, "device_ids retained")),
6138 );
6139 }
6140
6141 fn parse_deploy_reservation_device_count(
6142 &mut self,
6143 field: &ParsedField,
6144 ) -> Option<Located<DeployReservationDeviceCount>> {
6145 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6146 self.expected(
6147 EXPECTED_SCALAR,
6148 field,
6149 "deploy resource reservation device count must be a YAML integer or string scalar",
6150 );
6151 return None;
6152 };
6153 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
6154 ScalarType::Integer => {
6155 DeployReservationDeviceCount::YamlInteger(scalar_string_from_source(&self.source, scalar))
6156 }
6157 ScalarType::String => DeployReservationDeviceCount::String(scalar_string_from_source(&self.source, scalar)),
6158 _ => {
6159 self.expected(
6160 EXPECTED_SCALAR,
6161 field,
6162 "deploy resource reservation device count must be a YAML integer or string scalar",
6163 );
6164 return None;
6165 }
6166 };
6167 Some(Located::new(
6168 value,
6169 span_from_position(self.source_id, scalar.byte_range()),
6170 ))
6171 }
6172
6173 fn parse_deploy_reservation_device_ids(&mut self, field: &ParsedField) -> Option<DeployReservationDeviceIds> {
6174 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6175 self.expected(
6176 EXPECTED_SEQUENCE,
6177 field,
6178 "deploy resource reservation device device_ids must be a sequence",
6179 );
6180 return None;
6181 };
6182 let mut items = Vec::new();
6183 for node in sequence.values() {
6184 let Some(scalar) = node.as_scalar() else {
6185 self.unsupported_sequence_item(
6186 EXPECTED_SCALAR,
6187 &node,
6188 field.span,
6189 "deploy resource reservation device device_ids must be string scalars",
6190 );
6191 items.push(DeployReservationDeviceId::unmodeled(
6192 node_span(self.source_id, &node).unwrap_or(field.span),
6193 ));
6194 continue;
6195 };
6196 let item_span = span_from_position(self.source_id, scalar.byte_range());
6197 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
6198 self.unsupported_sequence_item(
6199 EXPECTED_SCALAR,
6200 &YamlNode::Scalar(scalar.clone()),
6201 field.span,
6202 "deploy resource reservation device device_ids must be string scalars",
6203 );
6204 items.push(DeployReservationDeviceId::unmodeled(item_span));
6205 continue;
6206 }
6207 items.push(DeployReservationDeviceId::string(Located::new(
6208 scalar_string_from_source(&self.source, scalar),
6209 item_span,
6210 )));
6211 }
6212 Some(DeployReservationDeviceIds::new(
6213 span_from_position(self.source_id, sequence.byte_range()),
6214 items,
6215 ))
6216 }
6217
6218 fn parse_deploy_reservation_device_options(
6219 &mut self,
6220 field: &ParsedField,
6221 ) -> Option<DeployReservationDeviceOptions> {
6222 match field.value.as_ref() {
6223 Some(YamlNode::Mapping(mapping)) => Some(self.parse_deploy_reservation_device_options_map(mapping)),
6224 Some(YamlNode::Sequence(sequence)) => {
6225 let mut items = Vec::new();
6226 let mut seen = BTreeMap::new();
6227 for node in sequence.values() {
6228 let Some(scalar) = node.as_scalar() else {
6229 self.unsupported_sequence_item(EXPECTED_SCALAR, &node, field.span,
6230 "deploy resource reservation device options list entries must be strict YAML string scalars");
6231 items.push(DeployReservationDeviceOptionItem::unmodeled(
6232 node_span(self.source_id, &node).unwrap_or(field.span),
6233 ));
6234 continue;
6235 };
6236 let span = span_from_position(self.source_id, scalar.byte_range());
6237 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
6238 self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar.clone()), field.span,
6239 "deploy resource reservation device options list entries must be strict YAML string scalars");
6240 items.push(DeployReservationDeviceOptionItem::unmodeled(span));
6241 continue;
6242 }
6243 let value = scalar_string_from_source(&self.source, scalar);
6244 if let Some(first) = seen.get(&value) {
6245 self.diagnostics.push(
6246 Diagnostic::new(
6247 DEPLOY_RESERVATION_DEVICE_OPTIONS_DUPLICATE_ITEM,
6248 Severity::Error,
6249 "deploy resource reservation device options list entries must be unique exact strings",
6250 )
6251 .with_label(DiagnosticLabel::primary(span, "duplicate option string"))
6252 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
6253 );
6254 } else {
6255 seen.insert(value.clone(), span);
6256 }
6257 items.push(DeployReservationDeviceOptionItem::string(Located::new(value, span)));
6258 }
6259 Some(DeployReservationDeviceOptions::List {
6260 span: span_from_position(self.source_id, sequence.byte_range()),
6261 items,
6262 })
6263 }
6264 _ => {
6265 self.expected(
6266 DEPLOY_RESERVATION_DEVICE_OPTIONS_EXPECTED_FORM,
6267 field,
6268 "deploy resource reservation device options must be a mapping or sequence",
6269 );
6270 None
6271 }
6272 }
6273 }
6274
6275 fn set_deploy_reservation_device_options(&mut self, device: &mut DeployReservationDevice, field: &ParsedField) {
6276 if let Some(options) = self.parse_deploy_reservation_device_options(field) {
6277 device.set_options(options);
6278 } else {
6279 device.push_unknown(field.reference());
6280 }
6281 }
6282
6283 fn parse_deploy_reservation_device_options_map(&mut self, mapping: &Mapping) -> DeployReservationDeviceOptions {
6284 let mut entries = Vec::new();
6285 let mut unmodeled_entries = Vec::new();
6286 let mut seen = BTreeMap::new();
6287 for entry in mapping.entries() {
6288 let Some(key) = entry.key_node() else { continue };
6289 let key_span = node_span(self.source_id, &key)
6290 .unwrap_or_else(|| span_from_position(self.source_id, mapping.byte_range()));
6291 let authored_value = entry.value_node();
6292 let value_span = authored_value
6293 .as_ref()
6294 .and_then(|value| node_span(self.source_id, value));
6295 let field_span = value_span.map_or(key_span, |value| union(key_span, value));
6296 let Some(scalar) = key.as_scalar() else {
6297 self.diagnostics.push(
6298 Diagnostic::new(
6299 DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY,
6300 Severity::Error,
6301 "deploy resource reservation device options mapping keys must be non-empty strict YAML strings",
6302 )
6303 .with_label(DiagnosticLabel::primary(key_span, "invalid option key")),
6304 );
6305 unmodeled_entries.push(FieldReference {
6306 name: Located::new("<unmodeled-key>".to_owned(), key_span),
6307 span: field_span,
6308 value_span,
6309 });
6310 continue;
6311 };
6312 let name = Located::new(
6313 scalar_string_from_source(&self.source, scalar),
6314 span_from_position(self.source_id, scalar.byte_range()),
6315 );
6316 let field_span = value_span.map_or(name.span(), |value| union(name.span(), value));
6317 let parsed = ParsedField {
6318 name,
6319 value: authored_value
6320 .map(unwrap_processing_tag)
6321 .map(|value| self.resolve_alias(value)),
6322 value_span,
6323 span: field_span,
6324 };
6325 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String || parsed.name.value().is_empty() {
6326 self.diagnostics.push(
6327 Diagnostic::new(
6328 DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY,
6329 Severity::Error,
6330 "deploy resource reservation device options mapping keys must be non-empty strict YAML strings",
6331 )
6332 .with_label(DiagnosticLabel::primary(parsed.name.span(), "invalid option key")),
6333 );
6334 unmodeled_entries.push(parsed.reference());
6335 continue;
6336 }
6337 if self.record_duplicate(&mut seen, &parsed) {
6338 unmodeled_entries.push(parsed.reference());
6339 continue;
6340 }
6341 let Some(value) = self.compose_scalar_from_field(&parsed,
6342 "deploy resource reservation device options mapping values must be scalar strings, numbers, booleans, or null") else {
6343 unmodeled_entries.push(parsed.reference()); continue;
6344 };
6345 entries.push(KeyValueEntry::new(parsed.name, value, parsed.span));
6346 }
6347 DeployReservationDeviceOptions::Map {
6348 span: span_from_position(self.source_id, mapping.byte_range()),
6349 entries,
6350 unmodeled_entries,
6351 }
6352 }
6353
6354 fn compose_scalar_from_field(
6355 &mut self,
6356 field: &ParsedField,
6357 message: &'static str,
6358 ) -> Option<Located<ComposeScalar>> {
6359 let Some(node) = field.value.as_ref() else {
6360 return Some(Located::new(ComposeScalar::Null, field.name.span()));
6361 };
6362 let Some(scalar) = node.as_scalar() else {
6363 self.expected(EXPECTED_SCALAR, field, message);
6364 return None;
6365 };
6366 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
6367 ScalarType::Null => ComposeScalar::Null,
6368 ScalarType::Boolean => ComposeScalar::Boolean(ScalarValue::from_scalar(scalar).to_bool().unwrap_or(false)),
6369 ScalarType::Integer | ScalarType::Float => {
6370 ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
6371 }
6372 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
6373 ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
6374 }
6375 };
6376 Some(Located::new(
6377 value,
6378 span_from_position(self.source_id, scalar.byte_range()),
6379 ))
6380 }
6381
6382 fn parse_deploy_reservation_device_capabilities(
6383 &mut self,
6384 field: &ParsedField,
6385 ) -> Option<DeployReservationDeviceCapabilities> {
6386 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6387 self.expected(
6388 EXPECTED_SEQUENCE,
6389 field,
6390 "deploy resource reservation device capabilities must be a sequence of strings",
6391 );
6392 return None;
6393 };
6394 let mut items = Vec::new();
6395 let mut seen = BTreeMap::new();
6396 for node in sequence.values() {
6397 let Some(scalar) = node.as_scalar() else {
6398 self.unsupported_sequence_item(
6399 EXPECTED_SCALAR,
6400 &node,
6401 field.span,
6402 "deploy resource reservation device capabilities must be string scalars",
6403 );
6404 items.push(DeployReservationDeviceCapability::unmodeled(
6405 node_span(self.source_id, &node).unwrap_or(field.span),
6406 ));
6407 continue;
6408 };
6409 let item_span = span_from_position(self.source_id, scalar.byte_range());
6410 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
6411 self.unsupported_sequence_item(
6412 EXPECTED_SCALAR,
6413 &YamlNode::Scalar(scalar.clone()),
6414 field.span,
6415 "deploy resource reservation device capabilities must be string scalars",
6416 );
6417 items.push(DeployReservationDeviceCapability::unmodeled(item_span));
6418 continue;
6419 }
6420 let value = scalar_string_from_source(&self.source, scalar);
6421 if let Some(first) = seen.get(&value) {
6422 self.diagnostics.push(
6423 Diagnostic::new(
6424 DEPLOY_RESERVATION_DEVICE_CAPABILITY_DUPLICATE_ITEM,
6425 Severity::Error,
6426 "deploy resource reservation device capabilities must be unique exact strings",
6427 )
6428 .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
6429 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
6430 );
6431 } else {
6432 seen.insert(value.clone(), item_span);
6433 }
6434 items.push(DeployReservationDeviceCapability::string(Located::new(
6435 value, item_span,
6436 )));
6437 }
6438 Some(DeployReservationDeviceCapabilities::new(
6439 span_from_position(self.source_id, sequence.byte_range()),
6440 items,
6441 ))
6442 }
6443
6444 fn parse_deploy_discrete_resource_spec(&mut self, field: &ParsedField) -> Option<DeployDiscreteResourceSpec> {
6445 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6446 self.expected(
6447 EXPECTED_MAPPING,
6448 field,
6449 "deploy discrete_resource_spec must be a mapping",
6450 );
6451 return None;
6452 };
6453 let mut spec = DeployDiscreteResourceSpec::new(span_from_position(self.source_id, mapping.byte_range()));
6454 let mut seen = BTreeMap::new();
6455 for option in self.fields(mapping) {
6456 if self.record_duplicate(&mut seen, &option) {
6457 continue;
6458 }
6459 match option.name.value().as_str() {
6460 name if name.starts_with("x-") => spec.push_extension(option.reference()),
6461 "kind" => {
6462 if let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) {
6463 if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::String {
6464 spec.set_kind(Located::new(
6465 scalar_string_from_source(&self.source, scalar),
6466 span_from_position(self.source_id, scalar.byte_range()),
6467 ));
6468 } else {
6469 self.expected(
6470 EXPECTED_SCALAR,
6471 &option,
6472 "deploy discrete_resource_spec kind must be a YAML string scalar",
6473 );
6474 spec.push_unknown(option.reference());
6475 }
6476 } else {
6477 self.expected(
6478 EXPECTED_SCALAR,
6479 &option,
6480 "deploy discrete_resource_spec kind must be a YAML string scalar",
6481 );
6482 spec.push_unknown(option.reference());
6483 }
6484 }
6485 "value" => {
6486 if let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) {
6487 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
6488 ScalarType::Integer | ScalarType::Float => Some(DeployDiscreteResourceValue::YamlNumber(
6489 scalar_string_from_source(&self.source, scalar),
6490 )),
6491 ScalarType::String => Some(DeployDiscreteResourceValue::String(scalar_string_from_source(
6492 &self.source,
6493 scalar,
6494 ))),
6495 _ => None,
6496 };
6497 if let Some(value) = value {
6498 spec.set_value(Located::new(
6499 value,
6500 span_from_position(self.source_id, scalar.byte_range()),
6501 ));
6502 } else {
6503 self.expected(
6504 EXPECTED_SCALAR,
6505 &option,
6506 "deploy discrete_resource_spec value must be a YAML number or string scalar",
6507 );
6508 spec.push_unknown(option.reference());
6509 }
6510 } else {
6511 self.expected(
6512 EXPECTED_SCALAR,
6513 &option,
6514 "deploy discrete_resource_spec value must be a YAML number or string scalar",
6515 );
6516 spec.push_unknown(option.reference());
6517 }
6518 }
6519 _ => spec.push_unknown(option.reference()),
6520 }
6521 }
6522 Some(spec)
6523 }
6524
6525 fn source_column(&self, offset: usize) -> usize {
6526 let prefix = self.source.get(..offset).unwrap_or_default();
6527 let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
6528 self.source[line_start..offset].chars().count()
6529 }
6530
6531 fn parse_service_ports(&mut self, field: &ParsedField) -> Vec<Port> {
6532 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6533 self.expected(EXPECTED_SEQUENCE, field, "service ports must be a sequence");
6534 return Vec::new();
6535 };
6536
6537 let mut ports = Vec::new();
6538 for value in sequence.values() {
6539 match value {
6540 YamlNode::Scalar(scalar) => {
6541 let span = span_from_position(self.source_id, scalar.byte_range());
6542 ports.push(Port::Short(ShortPort::parse(Located::new(
6543 scalar_string_from_source(&self.source, &scalar),
6544 span,
6545 ))));
6546 }
6547 YamlNode::Mapping(mapping) => {
6548 ports.push(Port::Long(Box::new(self.parse_long_port(&mapping))));
6549 }
6550 other => self.unsupported_sequence_item(
6551 PORT_EXPECTED_FORM,
6552 &other,
6553 field.span,
6554 "service port must use scalar short syntax or mapping long syntax",
6555 ),
6556 }
6557 }
6558 ports
6559 }
6560
6561 fn parse_long_port(&mut self, mapping: &Mapping) -> LongPort {
6562 let span = span_from_position(self.source_id, mapping.byte_range());
6563 let mut port = LongPort::new(span);
6564 let mut seen = BTreeMap::new();
6565 for field in self.fields(mapping) {
6566 let duplicate = self.record_duplicate(&mut seen, &field);
6567 match field.name.value.as_str() {
6568 "target" if !duplicate => self
6569 .parse_string(&field, "port target")
6570 .into_iter()
6571 .for_each(|value| port.set_target(value)),
6572 "published" if !duplicate => self
6573 .parse_string(&field, "published port")
6574 .into_iter()
6575 .for_each(|value| port.set_published(value)),
6576 "host_ip" if !duplicate => self
6577 .parse_string(&field, "port host IP")
6578 .into_iter()
6579 .for_each(|value| port.set_host_ip(value)),
6580 "protocol" if !duplicate => self
6581 .parse_string(&field, "port protocol")
6582 .into_iter()
6583 .for_each(|value| port.set_protocol(value)),
6584 "app_protocol" if !duplicate => self
6585 .parse_string(&field, "port application protocol")
6586 .into_iter()
6587 .for_each(|value| port.set_app_protocol(value)),
6588 "mode" if !duplicate => self
6589 .parse_string(&field, "port mode")
6590 .into_iter()
6591 .for_each(|value| port.set_mode(value)),
6592 "name" if !duplicate => self
6593 .parse_string(&field, "port name")
6594 .into_iter()
6595 .for_each(|value| port.set_name(value)),
6596 name if name.starts_with("x-") => port.push_extension(field.reference()),
6597 _ if duplicate => {}
6598 _ => port.push_unknown(field.reference()),
6599 }
6600 }
6601 if port.target().is_none() {
6602 self.missing(PORT_MISSING_TARGET, span, "long port is missing `target`");
6603 }
6604 port
6605 }
6606
6607 fn parse_service_networks(&mut self, field: &ParsedField) -> Option<ServiceNetworks> {
6608 match field.value.as_ref() {
6609 Some(YamlNode::Sequence(sequence)) => {
6610 let span = span_from_position(self.source_id, sequence.byte_range());
6611 let names =
6612 self.parse_scalar_nodes(sequence.values(), field.span, "service network names must be scalars");
6613 Some(ServiceNetworks::Short { span, names })
6614 }
6615 Some(YamlNode::Mapping(mapping)) => {
6616 let span = span_from_position(self.source_id, mapping.byte_range());
6617 let networks = self.parse_service_network_map(mapping);
6618 Some(ServiceNetworks::Long { span, networks })
6619 }
6620 _ => {
6621 self.expected(
6622 EXPECTED_FIELD_FORM,
6623 field,
6624 "service networks must be a sequence or mapping",
6625 );
6626 None
6627 }
6628 }
6629 }
6630
6631 fn parse_service_network_map(&mut self, mapping: &Mapping) -> Vec<ServiceNetwork> {
6632 let mut networks = Vec::new();
6633 let mut seen = BTreeMap::new();
6634 for field in self.fields(mapping) {
6635 if self.record_duplicate(&mut seen, &field) {
6636 continue;
6637 }
6638 if Self::field_is_null(&field) {
6639 networks.push(ServiceNetwork::new(field.name, field.span));
6640 continue;
6641 }
6642 let Some(options) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6643 self.expected(
6644 EXPECTED_MAPPING,
6645 &field,
6646 "service network options must be a mapping or null",
6647 );
6648 continue;
6649 };
6650 networks.push(self.parse_service_network(&field, options));
6651 }
6652 networks
6653 }
6654
6655 fn parse_service_network(&mut self, field: &ParsedField, mapping: &Mapping) -> ServiceNetwork {
6656 let mut network = ServiceNetwork::new(field.name.clone(), field.span);
6657 let mut seen = BTreeMap::new();
6658 for option in self.fields(mapping) {
6659 let duplicate = self.record_duplicate(&mut seen, &option);
6660 match option.name.value.as_str() {
6661 "aliases" if !duplicate => network.set_aliases(self.parse_string_sequence(&option, "network aliases")),
6662 "interface_name" if !duplicate => self
6663 .parse_string(&option, "network interface name")
6664 .into_iter()
6665 .for_each(|value| network.set_interface_name(value)),
6666 "ipv4_address" if !duplicate => self
6667 .parse_string(&option, "network IPv4 address")
6668 .into_iter()
6669 .for_each(|value| network.set_ipv4_address(value)),
6670 "ipv6_address" if !duplicate => self
6671 .parse_string(&option, "network IPv6 address")
6672 .into_iter()
6673 .for_each(|value| network.set_ipv6_address(value)),
6674 "link_local_ips" if !duplicate => {
6675 network.set_link_local_ips(self.parse_string_sequence(&option, "link-local IP addresses"));
6676 }
6677 "mac_address" if !duplicate => self
6678 .parse_string(&option, "network MAC address")
6679 .into_iter()
6680 .for_each(|value| network.set_mac_address(value)),
6681 "driver_opts" if !duplicate => {
6682 network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
6683 }
6684 "gw_priority" if !duplicate => self
6685 .parse_string(&option, "network gateway priority")
6686 .into_iter()
6687 .for_each(|value| network.set_gw_priority(value)),
6688 "priority" if !duplicate => self
6689 .parse_string(&option, "network priority")
6690 .into_iter()
6691 .for_each(|value| network.set_priority(value)),
6692 name if name.starts_with("x-") => network.push_extension(option.reference()),
6693 _ if duplicate => {}
6694 _ => network.push_unknown(option.reference()),
6695 }
6696 }
6697 network
6698 }
6699
6700 fn parse_config_grants(&mut self, field: &ParsedField) -> Vec<ConfigGrant> {
6701 self.parse_grants(field)
6702 .unwrap_or_default()
6703 .into_iter()
6704 .map(|grant| match grant {
6705 ParsedGrant::Short(value) => ConfigGrant::Short(value),
6706 ParsedGrant::Long(value) => ConfigGrant::Long(value),
6707 })
6708 .collect()
6709 }
6710
6711 fn parse_secret_grants(&mut self, field: &ParsedField) -> Option<Vec<SecretGrant>> {
6712 Some(
6713 self.parse_grants(field)?
6714 .into_iter()
6715 .map(|grant| match grant {
6716 ParsedGrant::Short(value) => SecretGrant::Short(value),
6717 ParsedGrant::Long(value) => SecretGrant::Long(value),
6718 })
6719 .collect(),
6720 )
6721 }
6722
6723 fn parse_grants(&mut self, field: &ParsedField) -> Option<Vec<ParsedGrant>> {
6724 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6725 self.expected(EXPECTED_SEQUENCE, field, "service grants must be a sequence");
6726 return None;
6727 };
6728 let mut grants = Vec::new();
6729 for value in sequence.values() {
6730 match value {
6731 YamlNode::Scalar(scalar) => {
6732 let span = span_from_position(self.source_id, scalar.byte_range());
6733 grants.push(ParsedGrant::Short(Located::new(
6734 scalar_string_from_source(&self.source, &scalar),
6735 span,
6736 )));
6737 }
6738 YamlNode::Mapping(mapping) => {
6739 grants.push(ParsedGrant::Long(Box::new(self.parse_long_grant(&mapping))));
6740 }
6741 other => self.unsupported_sequence_item(
6742 GRANT_EXPECTED_FORM,
6743 &other,
6744 field.span,
6745 "grant must use scalar short syntax or mapping long syntax",
6746 ),
6747 }
6748 }
6749 Some(grants)
6750 }
6751
6752 fn parse_long_grant(&mut self, mapping: &Mapping) -> LongGrant {
6753 let span = span_from_position(self.source_id, mapping.byte_range());
6754 let mut grant = LongGrant::new(span);
6755 let mut seen = BTreeMap::new();
6756 for field in self.fields(mapping) {
6757 let duplicate = self.record_duplicate(&mut seen, &field);
6758 match field.name.value.as_str() {
6759 "source" if !duplicate => self
6760 .parse_string(&field, "grant source")
6761 .into_iter()
6762 .for_each(|value| grant.set_source(value)),
6763 "target" if !duplicate => self
6764 .parse_string(&field, "grant target")
6765 .into_iter()
6766 .for_each(|value| grant.set_target(value)),
6767 "uid" if !duplicate => self
6768 .parse_string(&field, "grant user ID")
6769 .into_iter()
6770 .for_each(|value| grant.set_uid(value)),
6771 "gid" if !duplicate => self
6772 .parse_string(&field, "grant group ID")
6773 .into_iter()
6774 .for_each(|value| grant.set_gid(value)),
6775 "mode" if !duplicate => self
6776 .parse_string(&field, "grant mode")
6777 .into_iter()
6778 .for_each(|value| grant.set_mode(value)),
6779 name if name.starts_with("x-") => grant.push_extension(field.reference()),
6780 _ if duplicate => {}
6781 _ => grant.push_unknown(field.reference()),
6782 }
6783 }
6784 if grant.source().is_none() {
6785 self.missing(GRANT_MISSING_SOURCE, span, "long grant is missing `source`");
6786 }
6787 grant
6788 }
6789
6790 fn parse_service_volumes(&mut self, field: &ParsedField) -> Vec<VolumeMount> {
6791 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6792 self.expected(EXPECTED_SEQUENCE, field, "service volumes must be a sequence");
6793 return Vec::new();
6794 };
6795
6796 sequence
6797 .values()
6798 .filter_map(|value| match value {
6799 YamlNode::Scalar(scalar) => {
6800 let span = span_from_position(self.source_id, scalar.byte_range());
6801 let raw = Located::new(scalar_string_from_source(&self.source, &scalar), span);
6802 Some(VolumeMount::Short(ShortVolumeMount::new(raw)))
6803 }
6804 YamlNode::Mapping(mapping) => Some(VolumeMount::Long(Box::new(self.parse_long_volume(&mapping)))),
6805 other => {
6806 let span = node_span(self.source_id, &other).unwrap_or(field.span);
6807 self.diagnostics.push(
6808 Diagnostic::new(
6809 VOLUME_EXPECTED_FORM,
6810 Severity::Error,
6811 "service volume must use scalar short syntax or mapping long syntax",
6812 )
6813 .with_label(DiagnosticLabel::primary(span, "unsupported volume form")),
6814 );
6815 None
6816 }
6817 })
6818 .collect()
6819 }
6820
6821 fn parse_long_volume(&mut self, mapping: &Mapping) -> LongVolumeMount {
6822 let span = span_from_position(self.source_id, mapping.byte_range());
6823 let mut mount = LongVolumeMount::new(span);
6824 let mut seen = BTreeMap::new();
6825 for field in self.fields(mapping) {
6826 let duplicate = self.record_duplicate(&mut seen, &field);
6827 match field.name.value.as_str() {
6828 "type" if !duplicate => {
6829 if let Some(value) = self.parse_string(&field, "volume type") {
6830 mount.set_mount_type(Located::new(MountType::from_text(value.value), value.span));
6831 }
6832 }
6833 "source" if !duplicate => {
6834 if let Some(value) = self.parse_string(&field, "volume source") {
6835 mount.set_source(value);
6836 }
6837 }
6838 "target" if !duplicate => {
6839 if let Some(value) = self.parse_string(&field, "volume target") {
6840 mount.set_target(value);
6841 }
6842 }
6843 "read_only" if !duplicate => {
6844 if let Some(value) = self.parse_boolean(&field, "read_only") {
6845 mount.set_read_only(value);
6846 }
6847 }
6848 "bind" if !duplicate => {
6849 if let Some(value) = self.parse_bind_options(&field) {
6850 mount.set_bind(value);
6851 }
6852 }
6853 name if name.starts_with("x-") => mount.push_extension(field.reference()),
6854 _ if duplicate => {}
6855 _ => mount.push_unknown(field.reference()),
6856 }
6857 }
6858
6859 if mount.mount_type().is_none() {
6860 self.missing(VOLUME_MISSING_TYPE, span, "long volume is missing `type`");
6861 }
6862 if mount.target().is_none() {
6863 self.missing(VOLUME_MISSING_TARGET, span, "long volume is missing `target`");
6864 }
6865 mount
6866 }
6867
6868 fn parse_bind_options(&mut self, field: &ParsedField) -> Option<BindOptions> {
6869 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6870 self.expected(EXPECTED_MAPPING, field, "bind options must be a mapping");
6871 return None;
6872 };
6873 let span = span_from_position(self.source_id, mapping.byte_range());
6874 let mut bind = BindOptions::new(span);
6875 let mut seen = BTreeMap::new();
6876 for bind_field in self.fields(mapping) {
6877 let duplicate = self.record_duplicate(&mut seen, &bind_field);
6878 match bind_field.name.value.as_str() {
6879 "propagation" if !duplicate => {
6880 if let Some(value) = self.parse_string(&bind_field, "bind propagation") {
6881 bind.set_propagation(value);
6882 }
6883 }
6884 "create_host_path" if !duplicate => {
6885 if let Some(value) = self.parse_boolean(&bind_field, "create_host_path") {
6886 bind.set_create_host_path(value);
6887 }
6888 }
6889 "selinux" if !duplicate => {
6890 if let Some(value) = self.parse_string(&bind_field, "SELinux relabel mode") {
6891 let mode = match value.value.as_str() {
6892 "z" => Some(SelinuxRelabel::Shared),
6893 "Z" => Some(SelinuxRelabel::Private),
6894 _ => None,
6895 };
6896 if let Some(mode) = mode {
6897 bind.set_selinux(Located::new(mode, value.span));
6898 } else {
6899 self.diagnostics.push(
6900 Diagnostic::new(
6901 VOLUME_INVALID_SELINUX,
6902 Severity::Error,
6903 "SELinux relabel mode must be `z` or `Z`",
6904 )
6905 .with_label(DiagnosticLabel::primary(value.span, "invalid SELinux mode")),
6906 );
6907 }
6908 }
6909 }
6910 name if name.starts_with("x-") => bind.push_extension(bind_field.reference()),
6911 _ if duplicate => {}
6912 _ => bind.push_unknown(bind_field.reference()),
6913 }
6914 }
6915 Some(bind)
6916 }
6917
6918 fn parse_network_definitions(&mut self, field: &ParsedField) -> Vec<NetworkDefinition> {
6919 let Some(mapping) = self.resource_collection(field, "networks") else {
6920 return Vec::new();
6921 };
6922 let mut definitions = Vec::new();
6923 let mut seen = BTreeMap::new();
6924 for resource in self.fields(&mapping) {
6925 if self.record_duplicate(&mut seen, &resource) {
6926 continue;
6927 }
6928 if Self::field_is_null(&resource) {
6929 definitions.push(NetworkDefinition::new(resource.name, resource.span));
6930 continue;
6931 }
6932 let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
6933 self.expected(
6934 RESOURCE_EXPECTED_FORM,
6935 &resource,
6936 "network definition must be a mapping or null",
6937 );
6938 continue;
6939 };
6940 definitions.push(self.parse_network_definition(&resource, definition));
6941 }
6942 definitions
6943 }
6944
6945 fn parse_network_definition(&mut self, field: &ParsedField, mapping: &Mapping) -> NetworkDefinition {
6946 let mut network = NetworkDefinition::new(field.name.clone(), field.span);
6947 let mut seen = BTreeMap::new();
6948 for option in self.fields(mapping) {
6949 let duplicate = self.record_duplicate(&mut seen, &option);
6950 match option.name.value.as_str() {
6951 "driver" if !duplicate => self
6952 .parse_string(&option, "network driver")
6953 .into_iter()
6954 .for_each(|value| network.set_driver(value)),
6955 "driver_opts" if !duplicate => {
6956 network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
6957 }
6958 "attachable" if !duplicate => self
6959 .parse_boolean(&option, "network attachable")
6960 .into_iter()
6961 .for_each(|value| network.set_attachable(value)),
6962 "enable_ipv4" if !duplicate => self
6963 .parse_boolean(&option, "network enable_ipv4")
6964 .into_iter()
6965 .for_each(|value| network.set_enable_ipv4(value)),
6966 "enable_ipv6" if !duplicate => self
6967 .parse_boolean(&option, "network enable_ipv6")
6968 .into_iter()
6969 .for_each(|value| network.set_enable_ipv6(value)),
6970 "external" if !duplicate => self
6971 .parse_boolean(&option, "network external")
6972 .into_iter()
6973 .for_each(|value| network.set_external(value)),
6974 "internal" if !duplicate => self
6975 .parse_boolean(&option, "network internal")
6976 .into_iter()
6977 .for_each(|value| network.set_internal(value)),
6978 "ipam" if !duplicate => self
6979 .parse_ipam(&option)
6980 .into_iter()
6981 .for_each(|value| network.set_ipam(value)),
6982 "labels" if !duplicate => self
6983 .parse_labels(&option)
6984 .into_iter()
6985 .for_each(|value| network.set_labels(value)),
6986 "name" if !duplicate => self
6987 .parse_string(&option, "network custom name")
6988 .into_iter()
6989 .for_each(|value| network.set_custom_name(value)),
6990 name if name.starts_with("x-") => network.push_extension(option.reference()),
6991 _ if duplicate => {}
6992 _ => network.push_unknown(option.reference()),
6993 }
6994 }
6995 network
6996 }
6997
6998 fn parse_ipam(&mut self, field: &ParsedField) -> Option<Ipam> {
6999 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7000 self.expected(EXPECTED_MAPPING, field, "network IPAM must be a mapping");
7001 return None;
7002 };
7003 let span = span_from_position(self.source_id, mapping.byte_range());
7004 let mut ipam = Ipam::new(span);
7005 let mut seen = BTreeMap::new();
7006 for option in self.fields(mapping) {
7007 let duplicate = self.record_duplicate(&mut seen, &option);
7008 match option.name.value.as_str() {
7009 "driver" if !duplicate => self
7010 .parse_string(&option, "IPAM driver")
7011 .into_iter()
7012 .for_each(|value| ipam.set_driver(value)),
7013 "config" if !duplicate => ipam.set_config(self.parse_ipam_configs(&option)),
7014 "options" if !duplicate => {
7015 ipam.set_options(self.parse_scalar_mapping(&option, "IPAM options"));
7016 }
7017 name if name.starts_with("x-") => ipam.push_extension(option.reference()),
7018 _ if duplicate => {}
7019 _ => ipam.push_unknown(option.reference()),
7020 }
7021 }
7022 Some(ipam)
7023 }
7024
7025 fn parse_ipam_configs(&mut self, field: &ParsedField) -> Vec<IpamConfig> {
7026 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7027 self.expected(EXPECTED_SEQUENCE, field, "IPAM config must be a sequence");
7028 return Vec::new();
7029 };
7030 let mut configs = Vec::new();
7031 for value in sequence.values() {
7032 let YamlNode::Mapping(mapping) = value else {
7033 self.unsupported_sequence_item(
7034 EXPECTED_MAPPING,
7035 &value,
7036 field.span,
7037 "IPAM config entries must be mappings",
7038 );
7039 continue;
7040 };
7041 configs.push(self.parse_ipam_config(&mapping));
7042 }
7043 configs
7044 }
7045
7046 fn parse_ipam_config(&mut self, mapping: &Mapping) -> IpamConfig {
7047 let span = span_from_position(self.source_id, mapping.byte_range());
7048 let mut config = IpamConfig::new(span);
7049 let mut seen = BTreeMap::new();
7050 for field in self.fields(mapping) {
7051 let duplicate = self.record_duplicate(&mut seen, &field);
7052 match field.name.value.as_str() {
7053 "subnet" if !duplicate => self
7054 .parse_string(&field, "IPAM subnet")
7055 .into_iter()
7056 .for_each(|value| config.set_subnet(value)),
7057 "ip_range" if !duplicate => self
7058 .parse_string(&field, "IPAM allocation range")
7059 .into_iter()
7060 .for_each(|value| config.set_ip_range(value)),
7061 "gateway" if !duplicate => self
7062 .parse_string(&field, "IPAM gateway")
7063 .into_iter()
7064 .for_each(|value| config.set_gateway(value)),
7065 "aux_addresses" if !duplicate => {
7066 config.set_aux_addresses(self.parse_scalar_mapping(&field, "IPAM auxiliary addresses"));
7067 }
7068 name if name.starts_with("x-") => config.push_extension(field.reference()),
7069 _ if duplicate => {}
7070 _ => config.push_unknown(field.reference()),
7071 }
7072 }
7073 config
7074 }
7075
7076 fn parse_volume_definitions(&mut self, field: &ParsedField) -> Vec<VolumeDefinition> {
7077 let Some(mapping) = self.resource_collection(field, "volumes") else {
7078 return Vec::new();
7079 };
7080 let mut definitions = Vec::new();
7081 let mut seen = BTreeMap::new();
7082 for resource in self.fields(&mapping) {
7083 if self.record_duplicate(&mut seen, &resource) {
7084 continue;
7085 }
7086 let mut volume = VolumeDefinition::new(resource.name.clone(), resource.span);
7087 if Self::field_is_null(&resource) {
7088 definitions.push(volume);
7089 continue;
7090 }
7091 let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
7092 self.expected(
7093 RESOURCE_EXPECTED_FORM,
7094 &resource,
7095 "volume definition must be a mapping or null",
7096 );
7097 continue;
7098 };
7099 let mut nested_seen = BTreeMap::new();
7100 for option in self.fields(definition) {
7101 let duplicate = self.record_duplicate(&mut nested_seen, &option);
7102 match option.name.value.as_str() {
7103 "driver" if !duplicate => self
7104 .parse_string(&option, "volume driver")
7105 .into_iter()
7106 .for_each(|value| volume.set_driver(value)),
7107 "driver_opts" if !duplicate => {
7108 volume.set_driver_opts(self.parse_scalar_mapping(&option, "volume driver options"));
7109 }
7110 "external" if !duplicate => self
7111 .parse_boolean(&option, "volume external")
7112 .into_iter()
7113 .for_each(|value| volume.set_external(value)),
7114 "labels" if !duplicate => self
7115 .parse_labels(&option)
7116 .into_iter()
7117 .for_each(|value| volume.set_labels(value)),
7118 "name" if !duplicate => self
7119 .parse_string(&option, "volume custom name")
7120 .into_iter()
7121 .for_each(|value| volume.set_custom_name(value)),
7122 name if name.starts_with("x-") => volume.push_extension(option.reference()),
7123 _ if duplicate => {}
7124 _ => volume.push_unknown(option.reference()),
7125 }
7126 }
7127 self.validate_external_volume_driver_configuration(&volume);
7128 self.validate_external_volume_labels_configuration(&volume);
7129 definitions.push(volume);
7130 }
7131 definitions
7132 }
7133
7134 fn validate_external_volume_driver_configuration(&mut self, volume: &VolumeDefinition) {
7135 if !matches!(volume.external().map(Located::value), Some(BooleanValue::Literal(true)))
7136 || (volume.driver().is_none() && volume.driver_opts().is_empty())
7137 {
7138 return;
7139 }
7140 let span = volume
7141 .driver()
7142 .map(Located::span)
7143 .or_else(|| volume.driver_opts().first().map(KeyValueEntry::span))
7144 .unwrap_or_else(|| volume.span());
7145 self.diagnostics.push(
7146 Diagnostic::new(
7147 VOLUME_EXTERNAL_DRIVER_CONFIGURATION,
7148 Severity::Error,
7149 "external volume cannot also configure `driver` or `driver_opts`",
7150 )
7151 .with_label(DiagnosticLabel::primary(
7152 span,
7153 "driver configuration remains retained for review",
7154 )),
7155 );
7156 }
7157
7158 fn validate_external_volume_labels_configuration(&mut self, volume: &VolumeDefinition) {
7159 if !matches!(volume.external().map(Located::value), Some(BooleanValue::Literal(true)))
7160 || volume.labels().is_none()
7161 {
7162 return;
7163 }
7164 let span = volume.labels().map_or_else(|| volume.span(), Labels::span);
7165 self.diagnostics.push(
7166 Diagnostic::new(
7167 VOLUME_EXTERNAL_LABELS_CONFIGURATION,
7168 Severity::Error,
7169 "external volume cannot also configure `labels`",
7170 )
7171 .with_label(DiagnosticLabel::primary(span, "labels remain retained for review")),
7172 );
7173 }
7174
7175 fn parse_config_definitions(&mut self, field: &ParsedField) -> Vec<ConfigDefinition> {
7176 let Some(mapping) = self.resource_collection(field, "configs") else {
7177 return Vec::new();
7178 };
7179 let mut definitions = Vec::new();
7180 let mut seen = BTreeMap::new();
7181 for resource in self.fields(&mapping) {
7182 if self.record_duplicate(&mut seen, &resource) {
7183 continue;
7184 }
7185 let mut config = ConfigDefinition::new(resource.name.clone(), resource.span);
7186 if Self::field_is_null(&resource) {
7187 definitions.push(config);
7188 continue;
7189 }
7190 let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
7191 self.expected(
7192 RESOURCE_EXPECTED_FORM,
7193 &resource,
7194 "config definition must be a mapping or null",
7195 );
7196 continue;
7197 };
7198 let mut nested_seen = BTreeMap::new();
7199 for option in self.fields(definition) {
7200 let duplicate = self.record_duplicate(&mut nested_seen, &option);
7201 match option.name.value.as_str() {
7202 "file" if !duplicate => self
7203 .parse_string(&option, "config file")
7204 .into_iter()
7205 .for_each(|value| config.set_file(value)),
7206 "environment" if !duplicate => self
7207 .parse_string(&option, "config environment source")
7208 .into_iter()
7209 .for_each(|value| config.set_environment(value)),
7210 "content" if !duplicate => self
7211 .parse_string(&option, "config content")
7212 .into_iter()
7213 .for_each(|value| config.set_content(value)),
7214 "external" if !duplicate => self
7215 .parse_boolean(&option, "config external")
7216 .into_iter()
7217 .for_each(|value| config.set_external(value)),
7218 "name" if !duplicate => self
7219 .parse_string(&option, "config custom name")
7220 .into_iter()
7221 .for_each(|value| config.set_custom_name(value)),
7222 name if name.starts_with("x-") => config.push_extension(option.reference()),
7223 _ if duplicate => {}
7224 _ => config.push_unknown(option.reference()),
7225 }
7226 }
7227 definitions.push(config);
7228 }
7229 definitions
7230 }
7231
7232 fn parse_secret_definitions(&mut self, field: &ParsedField) -> Vec<SecretDefinition> {
7233 let Some(mapping) = self.resource_collection(field, "secrets") else {
7234 return Vec::new();
7235 };
7236 let mut definitions = Vec::new();
7237 let mut seen = BTreeMap::new();
7238 for resource in self.fields(&mapping) {
7239 if self.record_duplicate(&mut seen, &resource) {
7240 continue;
7241 }
7242 let mut secret = SecretDefinition::new(resource.name.clone(), resource.span);
7243 if Self::field_is_null(&resource) {
7244 definitions.push(secret);
7245 continue;
7246 }
7247 let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
7248 self.expected(
7249 RESOURCE_EXPECTED_FORM,
7250 &resource,
7251 "secret definition must be a mapping or null",
7252 );
7253 continue;
7254 };
7255 let mut nested_seen = BTreeMap::new();
7256 for option in self.fields(definition) {
7257 let duplicate = self.record_duplicate(&mut nested_seen, &option);
7258 match option.name.value.as_str() {
7259 "file" if !duplicate => self
7260 .parse_string(&option, "secret file")
7261 .into_iter()
7262 .for_each(|value| secret.set_file(value)),
7263 "environment" if !duplicate => self
7264 .parse_string(&option, "secret environment source")
7265 .into_iter()
7266 .for_each(|value| secret.set_environment(value)),
7267 "external" if !duplicate => self
7268 .parse_boolean(&option, "secret external")
7269 .into_iter()
7270 .for_each(|value| secret.set_external(value)),
7271 "name" if !duplicate => self
7272 .parse_string(&option, "secret custom name")
7273 .into_iter()
7274 .for_each(|value| secret.set_custom_name(value)),
7275 name if name.starts_with("x-") => secret.push_extension(option.reference()),
7276 _ if duplicate => {}
7277 _ => secret.push_unknown(option.reference()),
7278 }
7279 }
7280 definitions.push(secret);
7281 }
7282 definitions
7283 }
7284
7285 fn resource_collection(&mut self, field: &ParsedField, kind: &str) -> Option<Mapping> {
7286 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7287 self.expected(EXPECTED_MAPPING, field, format!("top-level {kind} must be a mapping"));
7288 return None;
7289 };
7290 Some(mapping.clone())
7291 }
7292
7293 fn parse_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
7294 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7295 self.expected(EXPECTED_SCALAR, field, format!("{description} must be a scalar"));
7296 return None;
7297 };
7298 if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
7299 self.expected(
7300 EXPECTED_SCALAR,
7301 field,
7302 format!("{description} must be a non-null scalar"),
7303 );
7304 return None;
7305 }
7306 Some(Located::new(
7307 scalar_string_from_source(&self.source, scalar),
7308 span_from_position(self.source_id, scalar.byte_range()),
7309 ))
7310 }
7311
7312 fn parse_non_empty_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
7313 let value = self.parse_string(field, description)?;
7314 if value.value().is_empty() {
7315 self.diagnostics.push(
7316 Diagnostic::new(
7317 BUILD_DOCKERFILE_EXPECTED_NON_EMPTY,
7318 Severity::Error,
7319 format!("{description} must be a non-empty scalar"),
7320 )
7321 .with_label(DiagnosticLabel::primary(value.span(), "empty scalar retained")),
7322 );
7323 return None;
7324 }
7325 Some(value)
7326 }
7327
7328 fn parse_build_dockerfile(&mut self, definition: &mut BuildDefinition, field: &ParsedField) -> FieldReference {
7329 if let Some(dockerfile) = self.parse_non_empty_string(field, "build dockerfile") {
7330 definition.set_dockerfile(dockerfile);
7331 }
7332 field.reference()
7333 }
7334
7335 fn set_build_dockerfile_inline(
7336 &mut self,
7337 definition: &mut BuildDefinition,
7338 field: &ParsedField,
7339 dockerfile_inline: &mut Option<FieldReference>,
7340 ) {
7341 *dockerfile_inline = Some(field.reference());
7342 if let Some(value) = self.parse_build_dockerfile_inline(field) {
7343 definition.set_dockerfile_inline(value);
7344 }
7345 }
7346
7347 fn parse_build_dockerfile_inline(&mut self, field: &ParsedField) -> Option<Located<String>> {
7348 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7349 self.expected(
7350 EXPECTED_SCALAR,
7351 field,
7352 "build dockerfile_inline must be a YAML string scalar",
7353 );
7354 return None;
7355 };
7356 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
7357 self.expected(
7358 EXPECTED_SCALAR,
7359 field,
7360 "build dockerfile_inline must be a YAML string scalar",
7361 );
7362 return None;
7363 }
7364 Some(Located::new(
7365 scalar_string_from_source(&self.source, scalar),
7366 span_from_position(self.source_id, scalar.byte_range()),
7367 ))
7368 }
7369
7370 fn set_build_no_cache(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
7371 if let Some(no_cache) = self.parse_build_no_cache(field) {
7372 definition.set_no_cache(no_cache);
7373 }
7374 }
7375
7376 fn set_build_sbom(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
7377 if let Some(sbom) = self.parse_build_sbom(field) {
7378 definition.set_sbom(sbom);
7379 }
7380 }
7381
7382 fn set_build_provenance(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
7383 if let Some(value) = self.parse_build_provenance(field) {
7384 definition.set_provenance(value);
7385 }
7386 }
7387
7388 fn set_build_isolation(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
7389 if let Some(isolation) = self.parse_build_isolation(field) {
7390 definition.set_isolation(isolation);
7391 }
7392 }
7393
7394 fn parse_build_isolation(&mut self, field: &ParsedField) -> Option<Located<String>> {
7395 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7396 self.expected(
7397 BUILD_ISOLATION_EXPECTED_STRING,
7398 field,
7399 "build isolation must be a YAML string scalar",
7400 );
7401 return None;
7402 };
7403 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
7404 self.expected(
7405 BUILD_ISOLATION_EXPECTED_STRING,
7406 field,
7407 "build isolation must be a YAML string scalar",
7408 );
7409 return None;
7410 }
7411 Some(Located::new(
7412 scalar_string_from_source(&self.source, scalar),
7413 span_from_position(self.source_id, scalar.byte_range()),
7414 ))
7415 }
7416
7417 fn parse_boolean(&mut self, field: &ParsedField, description: &str) -> Option<Located<BooleanValue>> {
7418 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7419 self.expected(EXPECTED_BOOLEAN, field, format!("{description} must be a boolean"));
7420 return None;
7421 };
7422 let span = span_from_position(self.source_id, scalar.byte_range());
7423 let scalar_value = ScalarValue::from_scalar(scalar);
7424 if let Some(value) = scalar_value.to_bool() {
7425 return Some(Located::new(BooleanValue::Literal(value), span));
7426 }
7427 let value = scalar_string_from_source(&self.source, scalar);
7428 if value.contains('$') {
7429 return Some(Located::new(BooleanValue::Expression(value), span));
7430 }
7431 self.diagnostics.push(
7432 Diagnostic::new(
7433 EXPECTED_BOOLEAN,
7434 Severity::Error,
7435 format!("{description} must be a boolean or interpolation expression"),
7436 )
7437 .with_label(DiagnosticLabel::primary(span, "not a boolean expression")),
7438 );
7439 None
7440 }
7441
7442 fn parse_build_no_cache(&mut self, field: &ParsedField) -> Option<Located<BuildNoCache>> {
7443 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7444 self.expected(
7445 BUILD_NO_CACHE_EXPECTED_BOOLEAN_OR_STRING,
7446 field,
7447 "build no_cache must be a YAML boolean or string scalar",
7448 );
7449 return None;
7450 };
7451 let span = span_from_position(self.source_id, scalar.byte_range());
7452 let scalar_value = ScalarValue::from_scalar(scalar);
7453 let value = match scalar_value.scalar_type() {
7454 ScalarType::Boolean => BuildNoCache::Boolean(scalar_value.to_bool().unwrap_or(false)),
7455 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
7456 BuildNoCache::String(scalar_string_from_source(&self.source, scalar))
7457 }
7458 ScalarType::Null | ScalarType::Integer | ScalarType::Float => {
7459 self.expected(
7460 BUILD_NO_CACHE_EXPECTED_BOOLEAN_OR_STRING,
7461 field,
7462 "build no_cache must be a YAML boolean or string scalar",
7463 );
7464 return None;
7465 }
7466 };
7467 Some(Located::new(value, span))
7468 }
7469 fn parse_build_no_cache_filter(&mut self, field: &ParsedField) -> Option<BuildNoCacheFilter> {
7470 match field.value.as_ref() {
7471 Some(YamlNode::Scalar(s)) if ScalarValue::from_scalar(s).scalar_type() == ScalarType::String => {
7472 Some(BuildNoCacheFilter::Scalar(Located::new(
7473 scalar_string_from_source(&self.source, s),
7474 span_from_position(self.source_id, s.byte_range()),
7475 )))
7476 }
7477 Some(YamlNode::Sequence(seq)) => {
7478 let values = self.parse_string_scalar_nodes(
7479 seq.values(),
7480 field.span,
7481 "build no_cache_filter entries must be string scalars",
7482 );
7483 let mut seen = BTreeSet::new();
7484 for value in &values {
7485 if !seen.insert(value.value().clone()) {
7486 self.diagnostics.push(
7487 Diagnostic::new(
7488 BUILD_NO_CACHE_FILTER_DUPLICATE_ITEM,
7489 Severity::Warning,
7490 "build no_cache_filter retains duplicate stage",
7491 )
7492 .with_label(DiagnosticLabel::primary(value.span(), "duplicate retained")),
7493 );
7494 }
7495 }
7496 Some(BuildNoCacheFilter::List(values))
7497 }
7498 _ => {
7499 self.expected(
7500 EXPECTED_FIELD_FORM,
7501 field,
7502 "build no_cache_filter must be a string scalar or sequence",
7503 );
7504 None
7505 }
7506 }
7507 }
7508 fn set_build_no_cache_filter(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
7509 if let Some(value) = self.parse_build_no_cache_filter(field) {
7510 definition.set_no_cache_filter(value);
7511 }
7512 }
7513 fn set_build_privileged(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
7514 if let Some(value) = self.parse_boolean(field, "build privileged") {
7515 definition.set_privileged(value);
7516 }
7517 }
7518
7519 fn parse_build_sbom(&mut self, field: &ParsedField) -> Option<Located<BuildSbom>> {
7520 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7521 self.expected(
7522 BUILD_SBOM_EXPECTED_BOOLEAN_OR_STRING,
7523 field,
7524 "build sbom must be a YAML boolean or string scalar",
7525 );
7526 return None;
7527 };
7528 let span = span_from_position(self.source_id, scalar.byte_range());
7529 let scalar_value = ScalarValue::from_scalar(scalar);
7530 let value = match scalar_value.scalar_type() {
7531 ScalarType::Boolean => BuildSbom::Boolean(scalar_value.to_bool().unwrap_or(false)),
7532 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
7533 BuildSbom::String(scalar_string_from_source(&self.source, scalar))
7534 }
7535 ScalarType::Null | ScalarType::Integer | ScalarType::Float => {
7536 self.expected(
7537 BUILD_SBOM_EXPECTED_BOOLEAN_OR_STRING,
7538 field,
7539 "build sbom must be a YAML boolean or string scalar",
7540 );
7541 return None;
7542 }
7543 };
7544 Some(Located::new(value, span))
7545 }
7546
7547 fn parse_build_provenance(&mut self, field: &ParsedField) -> Option<Located<BuildProvenance>> {
7548 let scalar = field.value.as_ref().and_then(YamlNode::as_scalar)?;
7549 let span = span_from_position(self.source_id, scalar.byte_range());
7550 let value = match ScalarValue::from_scalar(scalar).scalar_type() {
7551 ScalarType::Boolean => {
7552 BuildProvenance::Boolean(ScalarValue::from_scalar(scalar).to_bool().unwrap_or(false))
7553 }
7554 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
7555 BuildProvenance::String(scalar_string_from_source(&self.source, scalar))
7556 }
7557 _ => {
7558 self.expected(
7559 EXPECTED_SCALAR,
7560 field,
7561 "build provenance must be a YAML boolean or string scalar",
7562 );
7563 return None;
7564 }
7565 };
7566 Some(Located::new(value, span))
7567 }
7568
7569 fn parse_string_sequence(&mut self, field: &ParsedField, description: &str) -> Vec<Located<String>> {
7570 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7571 self.expected(EXPECTED_SEQUENCE, field, format!("{description} must be a sequence"));
7572 return Vec::new();
7573 };
7574 self.parse_scalar_nodes(
7575 sequence.values(),
7576 field.span,
7577 format!("{description} entries must be scalars"),
7578 )
7579 }
7580
7581 fn parse_scalar_nodes(
7582 &mut self,
7583 nodes: impl Iterator<Item = YamlNode>,
7584 fallback_span: SourceSpan,
7585 message: impl Into<String>,
7586 ) -> Vec<Located<String>> {
7587 let message = message.into();
7588 let mut values = Vec::new();
7589 for node in nodes {
7590 let YamlNode::Scalar(scalar) = node else {
7591 self.unsupported_sequence_item(EXPECTED_SCALAR, &node, fallback_span, &message);
7592 continue;
7593 };
7594 let scalar_value = ScalarValue::from_scalar(&scalar);
7595 if scalar_value.scalar_type() == ScalarType::Null {
7596 self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar), fallback_span, &message);
7597 continue;
7598 }
7599 let span = span_from_position(self.source_id, scalar.byte_range());
7600 values.push(Located::new(scalar_string_from_source(&self.source, &scalar), span));
7601 }
7602 values
7603 }
7604
7605 fn parse_scalar_mapping(&mut self, field: &ParsedField, description: &str) -> Vec<KeyValueEntry> {
7606 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7607 self.expected(EXPECTED_MAPPING, field, format!("{description} must be a mapping"));
7608 return Vec::new();
7609 };
7610 let mut entries = Vec::new();
7611 let mut seen = BTreeMap::new();
7612 for entry in self.fields(mapping) {
7613 if self.record_duplicate(&mut seen, &entry) {
7614 continue;
7615 }
7616 if let Some(value) = self.parse_compose_scalar(&entry, format!("{description} values must be scalars")) {
7617 entries.push(KeyValueEntry::new(entry.name, value, entry.span));
7618 }
7619 }
7620 entries
7621 }
7622
7623 fn parse_compose_scalar(
7624 &mut self,
7625 field: &ParsedField,
7626 message: impl Into<String>,
7627 ) -> Option<Located<ComposeScalar>> {
7628 let Some(node) = field.value.as_ref() else {
7629 return Some(Located::new(ComposeScalar::Null, field.name.span));
7630 };
7631 let Some(scalar) = node.as_scalar() else {
7632 self.expected(EXPECTED_SCALAR, field, message);
7633 return None;
7634 };
7635 let span = span_from_position(self.source_id, scalar.byte_range());
7636 let value = ScalarValue::from_scalar(scalar);
7637 let typed = match value.scalar_type() {
7638 ScalarType::Null => ComposeScalar::Null,
7639 ScalarType::Boolean => ComposeScalar::Boolean(value.to_bool().unwrap_or(false)),
7640 ScalarType::Integer | ScalarType::Float => {
7641 ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
7642 }
7643 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
7644 ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
7645 }
7646 };
7647 Some(Located::new(typed, span))
7648 }
7649
7650 fn parse_labels(&mut self, field: &ParsedField) -> Option<Labels> {
7651 match field.value.as_ref() {
7652 Some(YamlNode::Sequence(sequence)) => {
7653 let span = span_from_position(self.source_id, sequence.byte_range());
7654 let values = self.parse_string_scalar_nodes(
7655 sequence.values(),
7656 field.span,
7657 "label list entries must be string scalars",
7658 );
7659 Some(Labels::List { span, values })
7660 }
7661 Some(YamlNode::Mapping(mapping)) => {
7662 let span = span_from_position(self.source_id, mapping.byte_range());
7663 let entries = self.parse_scalar_mapping(field, "labels");
7664 Some(Labels::Map { span, entries })
7665 }
7666 _ => {
7667 self.expected(EXPECTED_FIELD_FORM, field, "labels must be a sequence or mapping");
7668 None
7669 }
7670 }
7671 }
7672
7673 fn parse_string_scalar_nodes(
7674 &mut self,
7675 nodes: impl Iterator<Item = YamlNode>,
7676 fallback_span: SourceSpan,
7677 message: impl Into<String>,
7678 ) -> Vec<Located<String>> {
7679 let message = message.into();
7680 let mut values = Vec::new();
7681 for node in nodes {
7682 let YamlNode::Scalar(scalar) = node else {
7683 self.unsupported_sequence_item(EXPECTED_SCALAR, &node, fallback_span, &message);
7684 continue;
7685 };
7686 if !matches!(
7687 ScalarValue::from_scalar(&scalar).scalar_type(),
7688 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
7689 ) {
7690 self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar), fallback_span, &message);
7691 continue;
7692 }
7693 let span = span_from_position(self.source_id, scalar.byte_range());
7694 values.push(Located::new(scalar_string_from_source(&self.source, &scalar), span));
7695 }
7696 values
7697 }
7698
7699 fn parse_annotations(&mut self, field: &ParsedField) -> Option<Annotations> {
7700 match field.value.as_ref() {
7701 Some(YamlNode::Sequence(sequence)) => Some(self.parse_annotation_list(sequence, field.span)),
7702 Some(YamlNode::Mapping(mapping)) => Some(self.parse_annotation_map(mapping)),
7703 _ => {
7704 self.expected(
7705 ANNOTATIONS_EXPECTED_FORM,
7706 field,
7707 "annotations must be a sequence or mapping",
7708 );
7709 None
7710 }
7711 }
7712 }
7713
7714 fn parse_annotation_list(&mut self, sequence: &yaml_edit::Sequence, fallback: SourceSpan) -> Annotations {
7715 let span = span_from_position(self.source_id, sequence.byte_range());
7716 let mut values = Vec::new();
7717 let mut seen = BTreeSet::new();
7718 for node in sequence.values() {
7719 let YamlNode::Scalar(scalar) = node else {
7720 self.unsupported_sequence_item(
7721 ANNOTATIONS_EXPECTED_STRING,
7722 &node,
7723 fallback,
7724 "annotation list entries must be string scalars",
7725 );
7726 continue;
7727 };
7728 let item_span = span_from_position(self.source_id, scalar.byte_range());
7729 let scalar_value = ScalarValue::from_scalar(&scalar);
7730 let value = match scalar_value.scalar_type() {
7731 ScalarType::Null => ComposeScalar::Null,
7732 ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
7733 ScalarType::Integer | ScalarType::Float => {
7734 ComposeScalar::Number(scalar_string_from_source(&self.source, &scalar))
7735 }
7736 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
7737 ComposeScalar::String(scalar_string_from_source(&self.source, &scalar))
7738 }
7739 };
7740 self.validate_annotation_list_scalar(&value, item_span, &mut seen);
7741 values.push(Located::new(value, item_span));
7742 }
7743 Annotations::new(span, AnnotationsForm::List(values))
7744 }
7745
7746 fn validate_annotation_list_scalar(
7747 &mut self,
7748 value: &ComposeScalar,
7749 span: SourceSpan,
7750 seen: &mut BTreeSet<String>,
7751 ) {
7752 let ComposeScalar::String(raw) = value else {
7753 self.diagnostics.push(annotation_diagnostic(
7754 ANNOTATIONS_EXPECTED_STRING,
7755 Severity::Error,
7756 span,
7757 "annotation list entries must be string scalars",
7758 "non-string annotation item retained",
7759 ));
7760 return;
7761 };
7762 let name = raw.split_once('=').map_or(raw.as_str(), |(name, _)| name);
7763 if name.is_empty() {
7764 self.diagnostics.push(annotation_diagnostic(
7765 ANNOTATIONS_EMPTY_NAME,
7766 Severity::Error,
7767 span,
7768 "service annotation name must not be empty",
7769 "empty annotation name",
7770 ));
7771 } else if !seen.insert(name.to_owned()) {
7772 self.diagnostics.push(annotation_diagnostic(
7773 ANNOTATIONS_DUPLICATE_NAME,
7774 Severity::Error,
7775 span,
7776 "service annotation names must be unique",
7777 "duplicate annotation name",
7778 ));
7779 }
7780 if !raw.contains('=') {
7781 self.diagnostics.push(annotation_diagnostic(
7782 ANNOTATIONS_KEY_ONLY,
7783 Severity::Warning,
7784 span,
7785 "key-only service annotation has no explicit value",
7786 "ambiguous key-only annotation",
7787 ));
7788 }
7789 }
7790
7791 fn parse_annotation_map(&mut self, mapping: &Mapping) -> Annotations {
7792 let span = span_from_position(self.source_id, mapping.byte_range());
7793 let mut entries = Vec::new();
7794 let mut seen = BTreeMap::new();
7795 for entry in self.fields(mapping) {
7796 let _duplicate = self.record_duplicate(&mut seen, &entry);
7797 if entry.name.value.is_empty() {
7798 self.diagnostics.push(annotation_diagnostic(
7799 ANNOTATIONS_EMPTY_NAME,
7800 Severity::Error,
7801 entry.name.span,
7802 "service annotation name must not be empty",
7803 "empty annotation name",
7804 ));
7805 }
7806 if let Some(value) = self.parse_compose_scalar(
7807 &entry,
7808 "annotation mapping values must be scalar strings, numbers, booleans, or null",
7809 ) {
7810 entries.push(KeyValueEntry::new(entry.name, value, entry.span));
7811 }
7812 }
7813 Annotations::new(span, AnnotationsForm::Map(entries))
7814 }
7815
7816 fn field_is_null(field: &ParsedField) -> bool {
7817 field.value.as_ref().is_none_or(|node| {
7818 node.as_scalar()
7819 .is_some_and(|scalar| ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null)
7820 })
7821 }
7822
7823 fn unsupported_sequence_item(
7824 &mut self,
7825 code: DiagnosticCode,
7826 node: &YamlNode,
7827 fallback_span: SourceSpan,
7828 message: impl Into<String>,
7829 ) {
7830 let span = node_span(self.source_id, node).unwrap_or(fallback_span);
7831 self.diagnostics.push(
7832 Diagnostic::new(code, Severity::Error, message)
7833 .with_label(DiagnosticLabel::primary(span, "unsupported value form")),
7834 );
7835 }
7836
7837 fn fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
7838 let fields = self.raw_fields(mapping);
7839 let mut fields = self.flatten_empty_value_continuations(fields);
7840 for field in &mut fields {
7841 field.value = field.value.take().map(|value| self.resolve_alias(value));
7842 }
7843 fields
7844 }
7845
7846 fn raw_fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
7847 mapping
7848 .entries()
7849 .filter_map(|entry| {
7850 let key = entry.key_node()?;
7851 let Some(scalar) = key.as_scalar() else {
7852 let span = node_span(self.source_id, &key)
7853 .unwrap_or_else(|| span_from_position(self.source_id, mapping.byte_range()));
7854 self.diagnostics.push(
7855 Diagnostic::new(EXPECTED_SCALAR, Severity::Error, "Compose mapping keys must be scalars")
7856 .with_label(DiagnosticLabel::primary(span, "non-scalar key")),
7857 );
7858 return None;
7859 };
7860 let name_span = span_from_position(self.source_id, scalar.byte_range());
7861 let authored_value = entry.value_node();
7862 let value_span = authored_value
7863 .as_ref()
7864 .and_then(|value| node_span(self.source_id, value));
7865 let value = authored_value.map(unwrap_processing_tag);
7866 let span = value_span.map_or(name_span, |value_span| union(name_span, value_span));
7867 Some(ParsedField {
7868 name: Located::new(scalar_string_from_source(&self.source, scalar), name_span),
7869 value,
7870 value_span,
7871 span,
7872 })
7873 })
7874 .collect()
7875 }
7876
7877 fn resolve_alias(&self, node: YamlNode) -> YamlNode {
7878 let mut node = node;
7879 let mut visited = BTreeSet::new();
7880 for _ in 0..64 {
7881 let YamlNode::Alias(alias) = &node else {
7882 return node;
7883 };
7884 if !visited.insert(alias.name()) {
7885 return node;
7886 }
7887 let Some(target) = self.anchors.resolve(&alias.name()).and_then(|target| {
7888 YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
7889 }) else {
7890 return node;
7891 };
7892 node = target;
7893 }
7894 node
7895 }
7896
7897 fn flatten_empty_value_continuations(&mut self, fields: Vec<ParsedField>) -> Vec<ParsedField> {
7898 let Some(target_column) = fields.first().map(|field| self.source_column(field.name.span.start())) else {
7899 return fields;
7900 };
7901 self.recover_fields(fields, target_column)
7902 }
7903
7904 fn recover_fields(&mut self, fields: Vec<ParsedField>, target_column: usize) -> Vec<ParsedField> {
7905 let mut flattened = Vec::new();
7906 for mut field in fields {
7907 let field_column = self.source_column(field.name.span.start());
7908 let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
7909 let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
7910 !self.is_flow_mapping(mapping)
7911 && mapping
7912 .entries()
7913 .find_map(|entry| {
7914 let key = entry.key_node()?;
7915 let scalar = key.as_scalar()?;
7916 Some(scalar.byte_range().start as usize)
7917 })
7918 .is_some_and(|key_start| self.source_column(key_start) <= field_column)
7919 });
7920
7921 if continuation {
7922 field.value = None;
7923 field.value_span = None;
7924 field.span = field.name.span;
7925 }
7926 if field_column == target_column {
7927 flattened.push(field);
7928 }
7929 if let Some(mapping) = nested_mapping.filter(|mapping| !self.is_flow_mapping(mapping)) {
7930 let nested = self.raw_fields(&mapping);
7931 flattened.extend(self.recover_fields(nested, target_column));
7932 }
7933 }
7934 flattened
7935 }
7936
7937 fn is_flow_mapping(&self, mapping: &Mapping) -> bool {
7938 let position = mapping.byte_range();
7939 self.source
7940 .get(position.start as usize..position.end as usize)
7941 .is_some_and(|text| text.trim_start().starts_with('{'))
7942 }
7943
7944 fn record_duplicate(&mut self, seen: &mut BTreeMap<String, SourceSpan>, field: &ParsedField) -> bool {
7945 if let Some(first) = seen.get(field.name.value()) {
7946 self.diagnostics.push(
7947 Diagnostic::new(
7948 DUPLICATE_FIELD,
7949 Severity::Error,
7950 "Compose mapping fields must be unique",
7951 )
7952 .with_label(DiagnosticLabel::primary(field.name.span, "duplicate field"))
7953 .with_label(DiagnosticLabel::secondary(*first, "first field")),
7954 );
7955 true
7956 } else {
7957 seen.insert(field.name.value.clone(), field.name.span);
7958 false
7959 }
7960 }
7961
7962 fn expected(&mut self, code: DiagnosticCode, field: &ParsedField, message: impl Into<String>) {
7963 self.diagnostics.push(
7964 Diagnostic::new(code, Severity::Error, message)
7965 .with_label(DiagnosticLabel::primary(field.span, "unexpected value form")),
7966 );
7967 }
7968
7969 fn missing(&mut self, code: DiagnosticCode, span: SourceSpan, message: impl Into<String>) {
7970 self.diagnostics.push(
7971 Diagnostic::new(code, Severity::Error, message)
7972 .with_label(DiagnosticLabel::primary(span, "incomplete long syntax")),
7973 );
7974 }
7975}
7976
7977fn unwrap_processing_tag(node: YamlNode) -> YamlNode {
7978 let YamlNode::TaggedNode(tagged) = &node else {
7979 return node;
7980 };
7981 if !matches!(tagged.tag().as_deref(), Some("!reset" | "!override")) {
7982 return node;
7983 }
7984 tagged
7985 .as_node()
7986 .and_then(|syntax| syntax.children().find_map(YamlNode::from_syntax))
7987 .unwrap_or(node)
7988}
7989
7990#[derive(Debug, Clone)]
7991enum ParsedGrant {
7992 Short(Located<String>),
7993 Long(Box<LongGrant>),
7994}
7995
7996#[derive(Debug, Clone)]
7997struct ParsedField {
7998 name: Located<String>,
7999 value: Option<YamlNode>,
8000 value_span: Option<SourceSpan>,
8001 span: SourceSpan,
8002}
8003
8004impl ParsedField {
8005 fn reference(&self) -> FieldReference {
8006 FieldReference {
8007 name: self.name.clone(),
8008 span: self.span,
8009 value_span: self.value_span,
8010 }
8011 }
8012}
8013
8014fn scalar_uses_block_style(source: &str, scalar: &Scalar) -> bool {
8015 let start = scalar.byte_range().start as usize;
8016 source[start..].trim_start().starts_with(['|', '>'])
8017 || source[..start]
8018 .lines()
8019 .rev()
8020 .find(|line| !line.trim().is_empty())
8021 .is_some_and(|header| header.contains(": |") || header.contains(": >"))
8022}
8023
8024fn node_span(source_id: SourceId, node: &YamlNode) -> Option<SourceSpan> {
8025 let position = match node {
8026 YamlNode::Scalar(value) => value.byte_range(),
8027 YamlNode::Mapping(value) => value.byte_range(),
8028 YamlNode::Sequence(value) => value.byte_range(),
8029 YamlNode::Alias(_) | YamlNode::TaggedNode(_) => {
8030 let range = node.as_node()?.text_range();
8031 return Some(SourceSpan::from_valid_offsets(
8032 source_id,
8033 u32::from(range.start()) as usize,
8034 u32::from(range.end()) as usize,
8035 ));
8036 }
8037 };
8038 Some(span_from_position(source_id, position))
8039}
8040
8041fn span_from_position(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
8042 SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
8043}
8044
8045fn union(left: SourceSpan, right: SourceSpan) -> SourceSpan {
8046 SourceSpan::from_valid_offsets(
8047 left.source_id(),
8048 left.start().min(right.start()),
8049 left.end().max(right.end()),
8050 )
8051}