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 cpu_rt_runtime;
15mod credential_spec;
16mod dependency;
17mod device;
18mod dns;
19mod dns_option;
20mod dns_search;
21mod entrypoint;
22mod environment;
23mod expose;
24mod extends;
25mod host;
26mod hostname;
27mod identity;
28mod image;
29mod lifecycle;
30mod lifecycle_hook;
31mod logging;
32mod memory;
33mod network;
34mod pids;
35mod port;
36mod provider;
37mod pull;
38mod remaining;
39mod resource;
40mod restart;
41mod sections;
42mod security_option;
43mod service_runtime;
44mod shm;
45mod sysctl;
46mod tmpfs;
47mod ulimit;
48mod value;
49mod volume;
50
51pub use annotation::{Annotations, AnnotationsForm};
52pub use blkio::{
53 BlkioConfig, BlkioDeviceRate, BlkioDeviceRateForm, BlkioScalar, BlkioWeightDevice, BlkioWeightDeviceForm,
54};
55pub use build_extra_host::{BuildExtraHostAddresses, BuildExtraHostEntry, BuildExtraHosts};
56pub use capability::{CapabilityAdd, CapabilityAddItem, CapabilityDrop, CapabilityDropItem};
57pub use cgroup::{CgroupNamespace, CgroupNamespaceKind};
58pub use command::Command;
59pub use cpu_count::CpuCount;
60pub use cpu_percent::CpuPercent;
61pub use cpu_period::CpuPeriod;
62pub use cpu_quota::CpuQuota;
63pub use cpu_rt_period::CpuRtPeriod;
64pub use cpu_rt_runtime::CpuRtRuntime;
65pub use credential_spec::CredentialSpec;
66pub use dependency::{
67 DependencyCondition, DependsOn, Healthcheck, HealthcheckDuration, HealthcheckRetries, HealthcheckTest,
68 HealthcheckTestKind, ServiceDependency,
69};
70pub(crate) use device::valid_generated_device_string;
71pub use device::{Device, Devices, LongDevice, ShortDevice, ShortDeviceKind};
72pub use dns::{Dns, DnsForm};
73pub use dns_option::DnsOptions;
74pub use dns_search::{DnsSearch, DnsSearchForm};
75pub use entrypoint::Entrypoint;
76pub use environment::{
77 Environment, EnvironmentFile, EnvironmentFileFormat, EnvironmentFileFormatKind, EnvironmentListEntry,
78 EnvironmentMapEntry, LongEnvironmentFile,
79};
80pub use expose::{Expose, ExposeItem, ExposeItemKind, ExposePort, ExposeProtocol, ExposeScalarKind};
81pub(crate) use expose::{classify_expose_item, valid_generated_expose_item};
82pub use extends::{Extends, ExtendsReference};
83pub use host::{ExtraHostSeparator, ExtraHosts, HostAddress, HostAddressKind, LongExtraHost, ShortExtraHost};
84pub(crate) use hostname::valid_hostname;
85pub use hostname::{Hostname, HostnameKind};
86pub use identity::{IdentityComponent, UserNamespaceMode, UserNamespaceModeKind, UserSpec};
87pub use image::{ImageDigest, ImageReference};
88pub use lifecycle::StopGracePeriod;
89pub use lifecycle_hook::{
90 PostStartHook, PostStartHooks, PreStartHook, PreStartHooks, PreStartServiceHook, PreStopHook, PreStopHooks,
91 ServiceHook,
92};
93pub use logging::{Logging, LoggingOption, LoggingOptionValue, LoggingOptions};
94pub(crate) use memory::valid_generated_mem_amount;
95pub use memory::{MemLimit, MemLimitKind, MemLimitScalarKind, MemLimitUnit};
96pub use network::{Ipam, IpamConfig, NetworkDefinition, ServiceNetwork, ServiceNetworks};
97pub(crate) use pids::valid_positive_pids_decimal;
98pub use pids::{PidsLimit, PidsLimitKind};
99pub use port::{LongPort, Port, ShortPort};
100pub use provider::{Provider, ProviderOption, ProviderOptionItem, ProviderOptionValue, ProviderOptions};
101pub(crate) use pull::valid_pull_policy_duration;
102pub use pull::{PullPolicy, PullPolicyKind};
103pub use remaining::{
104 Develop, DevelopWatch, DevelopWatchExec, GpuDevice, GpuOptions, Gpus, IncludeItem, IncludeLong, Includes,
105 LabelFiles, LabelFilesForm, ModelDefinition, ModelDefinitions, ServiceModelBinding, ServiceModels,
106};
107pub use resource::{
108 ConfigDefinition, ConfigGrant, ExternalNameMapping, LongGrant, ResourceExternal, SecretDefinition, SecretGrant,
109 VolumeDefinition,
110};
111pub use restart::{RestartPolicy, RestartPolicyKind};
112pub use sections::{
113 Build, BuildAdditionalContexts, BuildArgs, BuildDefinition, BuildField, BuildFieldKind, BuildNoCacheFilter,
114 BuildSsh, BuildSshForm, DeployDefinition, DeployDiscreteResourceSpec, DeployDiscreteResourceValue,
115 DeployEndpointMode, DeployField, DeployFieldKind, DeployGenericResource, DeployGenericResourceForm,
116 DeployGenericResources, DeployMode, DeployPlacement, DeployPlacementMaxReplicasPerNode, DeployPlacementPreference,
117 DeployReplicas, DeployReservationDevice, DeployReservationDeviceCapabilities, DeployReservationDeviceCapability,
118 DeployReservationDeviceCapabilityForm, DeployReservationDeviceCount, DeployReservationDeviceForm,
119 DeployReservationDeviceId, DeployReservationDeviceIdForm, DeployReservationDeviceIds,
120 DeployReservationDeviceOptionItem, DeployReservationDeviceOptionItemForm, DeployReservationDeviceOptions,
121 DeployReservationDevices, DeployResourceCpus, DeployResourceLimits, DeployResourceMemory, DeployResourceMemoryKind,
122 DeployResourceMemoryUnit, DeployResourcePids, DeployResourceReservations, DeployResources, DeployRestartCondition,
123 DeployRestartDuration, DeployRestartMaxAttempts, DeployRestartPolicy, DeployRollbackConfig,
124 DeployRollbackMaxFailureRatio, DeployRollbackOrder, DeployRollbackParallelism, DeployUpdateConfig,
125 DeployUpdateMaxFailureRatio, DeployUpdateOrder, DeployUpdateParallelism,
126};
127pub(crate) use security_option::{SecurityOptionCandidateCounts, classify_security_option};
128pub use security_option::{SecurityOptionItem, SecurityOptionKind, SecurityOptions};
129pub use service_runtime::{
130 Cpus, InvalidServiceStringItem, IpcMode, MemswapLimit, MemswapLimitKind, MemswapLimitScalarKind, NetworkMode,
131 PidMode, ServiceInteger, VolumesFrom,
132};
133pub(crate) use shm::valid_generated_shm_amount;
134pub use shm::{ShmSize, ShmSizeKind, ShmSizeScalarKind, ShmSizeUnit};
135pub use sysctl::{Sysctls, SysctlsForm};
136pub(crate) use tmpfs::valid_generated_tmpfs_item;
137pub use tmpfs::{Tmpfs, TmpfsForm, TmpfsItem, TmpfsItemKind};
138pub(crate) use ulimit::valid_ulimit_name;
139pub use ulimit::{LimitValue, Ulimit, UlimitRange, UlimitValue, Ulimits};
140pub use value::{BooleanValue, BuildNoCache, BuildProvenance, BuildSbom, ComposeScalar, KeyValueEntry, Labels};
141pub use volume::{
142 BindOptions, ContainerPath, ContainerPathKind, ImageMountOptions, LongVolumeMount, MountType, SelinuxRelabel,
143 ShortVolumeMount, TmpfsMountOptions, VolumeMount, VolumeMountOptions, VolumeSyntax,
144};
145
146use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
147use crate::source::{SourceId, SourceSpan};
148use crate::syntax::{SyntaxDocument, scalar_string_from_source};
149use std::collections::{BTreeMap, BTreeSet};
150use yaml_edit::{AnchorRegistry, AsYaml, Mapping, Scalar, ScalarStyle, ScalarType, ScalarValue, YamlNode};
151
152pub const DOCUMENT_ROOT_TYPE: DiagnosticCode = DiagnosticCode::new("compose.document.expected-mapping");
154
155pub const MULTIPLE_DOCUMENTS: DiagnosticCode = DiagnosticCode::new("compose.document.multiple-documents");
157
158pub const DUPLICATE_FIELD: DiagnosticCode = DiagnosticCode::new("compose.model.duplicate-field");
160pub const VERSION_OBSOLETE: DiagnosticCode = DiagnosticCode::new("compose.version.obsolete");
162pub const DEVELOP_MISSING_WATCH: DiagnosticCode = DiagnosticCode::new("compose.develop.missing-watch");
164pub const DEVELOP_WATCH_MISSING_ACTION: DiagnosticCode = DiagnosticCode::new("compose.develop.watch.missing-action");
166pub const DEVELOP_WATCH_MISSING_PATH: DiagnosticCode = DiagnosticCode::new("compose.develop.watch.missing-path");
168pub const DEVELOP_WATCH_INVALID_ACTION: DiagnosticCode = DiagnosticCode::new("compose.develop.watch.invalid-action");
170pub const DEVELOP_WATCH_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.develop.watch.missing-target");
172pub const DEVELOP_WATCH_MISSING_EXEC: DiagnosticCode = DiagnosticCode::new("compose.develop.watch.missing-exec");
174pub const DEVELOP_WATCH_EXEC_MISSING_COMMAND: DiagnosticCode =
176 DiagnosticCode::new("compose.develop.watch.exec.missing-command");
177pub const GPU_COUNT_DEVICE_IDS_CONFLICT: DiagnosticCode = DiagnosticCode::new("compose.gpus.count-device-ids-conflict");
179pub const GPU_MISSING_CAPABILITIES: DiagnosticCode = DiagnosticCode::new("compose.gpus.missing-capabilities");
181pub const CGROUP_NAMESPACE_INVALID: DiagnosticCode = DiagnosticCode::new("compose.cgroup.invalid-namespace");
183pub const DEPLOY_ENDPOINT_MODE_PORTABILITY: DiagnosticCode =
185 DiagnosticCode::new("compose.deploy.endpoint-mode.portability");
186pub const DEPLOY_MODE_PORTABILITY: DiagnosticCode = DiagnosticCode::new("compose.deploy.mode.portability");
188pub const DEPLOY_UPDATE_CONFIG_ORDER_PORTABILITY: DiagnosticCode =
190 DiagnosticCode::new("compose.deploy.update-config.order.portability");
191pub const DEPLOY_ROLLBACK_CONFIG_ORDER_PORTABILITY: DiagnosticCode =
193 DiagnosticCode::new("compose.deploy.rollback-config.order.portability");
194pub const EXTENDS_MISSING_SERVICE: DiagnosticCode = DiagnosticCode::new("compose.extends.missing-service");
196pub const PROVIDER_MISSING_TYPE: DiagnosticCode = DiagnosticCode::new("compose.provider.missing-type");
198pub const POST_START_MISSING_COMMAND: DiagnosticCode = DiagnosticCode::new("compose.post-start.missing-command");
200pub const PRE_STOP_MISSING_COMMAND: DiagnosticCode = DiagnosticCode::new("compose.pre-stop.missing-command");
202pub const DEPLOY_RESERVATION_DEVICE_CAPABILITY_DUPLICATE_ITEM: DiagnosticCode =
204 DiagnosticCode::new("compose.deploy.reservations.devices.capabilities.duplicate-item");
205pub const DEPLOY_RESERVATION_DEVICE_MISSING_CAPABILITIES: DiagnosticCode =
207 DiagnosticCode::new("compose.deploy.reservations.devices.missing-capabilities");
208pub const DEPLOY_RESERVATION_DEVICE_ALLOCATION_SELECTOR_CONFLICT: DiagnosticCode =
210 DiagnosticCode::new("compose.deploy.reservations.devices.allocation-selector-conflict");
211pub const DEPLOY_RESERVATION_DEVICE_OPTIONS_EXPECTED_FORM: DiagnosticCode =
213 DiagnosticCode::new("compose.deploy.reservations.devices.options.expected-form");
214pub const DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY: DiagnosticCode =
216 DiagnosticCode::new("compose.deploy.reservations.devices.options.invalid-key");
217pub const DEPLOY_RESERVATION_DEVICE_OPTIONS_DUPLICATE_ITEM: DiagnosticCode =
219 DiagnosticCode::new("compose.deploy.reservations.devices.options.duplicate-item");
220pub const BUILD_NO_CACHE_FILTER_DUPLICATE_ITEM: DiagnosticCode =
222 DiagnosticCode::new("compose.build.no-cache-filter.duplicate-item");
223
224pub const EXPECTED_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.model.expected-mapping");
226
227pub const EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.model.expected-sequence");
229
230pub const EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.model.expected-scalar");
232
233pub const EXPECTED_BOOLEAN: DiagnosticCode = DiagnosticCode::new("compose.model.expected-boolean");
235
236pub const EXPECTED_FIELD_FORM: DiagnosticCode = DiagnosticCode::new("compose.model.expected-field-form");
238
239pub const BUILD_DOCKERFILE_EXPECTED_NON_EMPTY: DiagnosticCode =
241 DiagnosticCode::new("compose.build.dockerfile.expected-non-empty-scalar");
242
243pub const BUILD_DOCKERFILE_INLINE_CONFLICT: DiagnosticCode =
245 DiagnosticCode::new("compose.build.dockerfile-inline-conflict");
246
247pub const BUILD_NO_CACHE_EXPECTED_BOOLEAN_OR_STRING: DiagnosticCode =
249 DiagnosticCode::new("compose.build.no-cache.expected-boolean-or-string");
250
251pub const BUILD_SBOM_EXPECTED_BOOLEAN_OR_STRING: DiagnosticCode =
253 DiagnosticCode::new("compose.build.sbom.expected-boolean-or-string");
254
255pub const BUILD_ISOLATION_EXPECTED_STRING: DiagnosticCode =
257 DiagnosticCode::new("compose.build.isolation.expected-string");
258
259pub const BUILD_EXTRA_HOSTS_EXPECTED_FORM: DiagnosticCode =
261 DiagnosticCode::new("compose.build.extra-hosts.expected-form");
262
263pub const BUILD_EXTRA_HOSTS_EXPECTED_STRING: DiagnosticCode =
265 DiagnosticCode::new("compose.build.extra-hosts.expected-string");
266
267pub const BUILD_EXTRA_HOSTS_DUPLICATE_ITEM: DiagnosticCode =
269 DiagnosticCode::new("compose.build.extra-hosts.duplicate-item");
270
271pub const PORT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.port.expected-short-or-long");
273
274pub const PORT_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.port.long.missing-target");
276
277pub const GRANT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.grant.expected-short-or-long");
279
280pub const GRANT_MISSING_SOURCE: DiagnosticCode = DiagnosticCode::new("compose.grant.long.missing-source");
282
283pub const RESOURCE_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.resource.expected-mapping-or-null");
285
286pub const VOLUME_EXTERNAL_DRIVER_CONFIGURATION: DiagnosticCode =
290 DiagnosticCode::new("compose.volume.external-driver-configuration");
291
292pub const VOLUME_EXTERNAL_LABELS_CONFIGURATION: DiagnosticCode =
297 DiagnosticCode::new("compose.volume.external-labels-configuration");
298
299pub const RESOURCE_EXTERNAL_NAME_MAPPING_DEPRECATED: DiagnosticCode =
301 DiagnosticCode::new("compose.resource.external-name-mapping-deprecated");
302pub const RESOURCE_EXTERNAL_NAME_CONFLICT: DiagnosticCode =
304 DiagnosticCode::new("compose.resource.external-name-conflict");
305pub const RESOURCE_EXTERNAL_CREATION_CONFIGURATION: DiagnosticCode =
307 DiagnosticCode::new("compose.resource.external-creation-configuration");
308
309pub const VOLUME_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.volume.expected-short-or-long");
311
312pub const VOLUME_MISSING_TYPE: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-type");
314
315pub const VOLUME_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-target");
317
318pub const VOLUME_INVALID_SELINUX: DiagnosticCode = DiagnosticCode::new("compose.volume.bind.invalid-selinux");
320
321pub const VOLUME_OPTION_EXPECTED_MAPPING: DiagnosticCode =
323 DiagnosticCode::new("compose.volume.long.option.expected-mapping");
324
325pub const EXTRA_HOST_INVALID_ENTRY: DiagnosticCode = DiagnosticCode::new("compose.extra-hosts.invalid-entry");
327
328pub const ULIMIT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.ulimits.invalid-value");
330
331pub const ULIMIT_INVALID_NAME: DiagnosticCode = DiagnosticCode::new("compose.ulimits.invalid-name");
333
334pub const ULIMIT_MISSING_RANGE_MEMBER: DiagnosticCode = DiagnosticCode::new("compose.ulimits.missing-range-member");
336
337pub const HEALTHCHECK_INVALID_TEST: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-test");
339
340pub const HEALTHCHECK_INVALID_DURATION: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-duration");
342
343pub const HEALTHCHECK_INVALID_RETRIES: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-retries");
345
346pub const RESTART_INVALID_POLICY: DiagnosticCode = DiagnosticCode::new("compose.restart.invalid-policy");
348
349pub const HOSTNAME_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.hostname.expected-string");
351
352pub const HOSTNAME_INVALID: DiagnosticCode = DiagnosticCode::new("compose.hostname.invalid-value");
354
355pub const PIDS_LIMIT_EXPECTED_VALUE: DiagnosticCode =
357 DiagnosticCode::new("compose.pids-limit.expected-number-or-string");
358
359pub const PIDS_LIMIT_INVALID: DiagnosticCode = DiagnosticCode::new("compose.pids-limit.invalid-value");
361
362pub const PIDS_LIMIT_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.pids-limit.ambiguous-zero");
364
365pub const CPU_COUNT_EXPECTED_VALUE: DiagnosticCode =
367 DiagnosticCode::new("compose.cpu-count.expected-integer-or-string");
368
369pub const CPU_COUNT_NEGATIVE: DiagnosticCode = DiagnosticCode::new("compose.cpu-count.negative-value");
371
372pub const CPU_PERCENT_OUT_OF_RANGE: DiagnosticCode = DiagnosticCode::new("compose.cpu-percent.out-of-range");
374
375pub const CPU_PERCENT_EXPECTED_VALUE: DiagnosticCode =
377 DiagnosticCode::new("compose.cpu-percent.expected-integer-or-string");
378
379pub const CPU_PERIOD_EXPECTED_VALUE: DiagnosticCode =
381 DiagnosticCode::new("compose.cpu-period.expected-number-or-string");
382
383pub const CPU_QUOTA_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.cpu-quota.expected-number-or-string");
385
386pub const CPU_RT_PERIOD_EXPECTED_VALUE: DiagnosticCode =
388 DiagnosticCode::new("compose.cpu-rt-period.expected-number-or-string");
389
390pub const CPU_RT_PERIOD_INVALID: DiagnosticCode = DiagnosticCode::new("compose.cpu-rt-period.invalid-duration");
392pub const CPU_RT_RUNTIME_INVALID: DiagnosticCode = DiagnosticCode::new("compose.cpu-rt-runtime.invalid-value");
394
395pub const SHM_SIZE_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.shm-size.expected-number-or-string");
397
398pub const SHM_SIZE_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.shm-size.ambiguous-zero");
400
401pub const SHM_SIZE_PROVIDER_DEPENDENT_NUMBER: DiagnosticCode =
403 DiagnosticCode::new("compose.shm-size.provider-dependent-number");
404
405pub const SHM_SIZE_PROVIDER_DEPENDENT_STRING: DiagnosticCode =
407 DiagnosticCode::new("compose.shm-size.provider-dependent-string");
408
409pub const MEM_LIMIT_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.expected-number-or-string");
411
412pub const MEM_LIMIT_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.ambiguous-zero");
414
415pub const MEM_LIMIT_SCHEMA_NUMBER: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.schema-number");
417
418pub const MEM_LIMIT_PROVIDER_DEPENDENT_STRING: DiagnosticCode =
420 DiagnosticCode::new("compose.mem-limit.provider-dependent-string");
421
422pub const MEMSWAP_LIMIT_EXPECTED_VALUE: DiagnosticCode =
424 DiagnosticCode::new("compose.memswap-limit.expected-number-or-string");
425
426pub const MEMSWAP_LIMIT_INVALID: DiagnosticCode = DiagnosticCode::new("compose.memswap-limit.invalid-value");
428
429pub const PULL_POLICY_INVALID: DiagnosticCode = DiagnosticCode::new("compose.pull-policy.invalid-policy");
431
432pub const STOP_GRACE_PERIOD_INVALID: DiagnosticCode =
434 DiagnosticCode::new("compose.lifecycle.invalid-stop-grace-period");
435
436pub const CAP_DROP_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.expected-sequence");
438
439pub const CAP_DROP_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.expected-string");
441
442pub const CAP_DROP_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.duplicate-item");
444
445pub const CAP_ADD_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.cap-add.expected-sequence");
447
448pub const CAP_ADD_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.cap-add.expected-string");
450
451pub const CAP_ADD_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.cap-add.duplicate-item");
453
454pub const DEVICES_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-sequence");
456
457pub const DEVICE_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-short-or-long");
459
460pub const DEVICE_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-string");
462
463pub const DEVICE_MISSING_SOURCE: DiagnosticCode = DiagnosticCode::new("compose.devices.long.missing-source");
465
466pub const DNS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.dns.expected-string-or-list");
468
469pub const DNS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns.expected-string");
471
472pub const DNS_OPT_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.expected-sequence");
474
475pub const DNS_OPT_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.expected-string");
477
478pub const DNS_OPT_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.duplicate-item");
480
481pub const DNS_SEARCH_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.dns-search.expected-string-or-list");
483
484pub const DNS_SEARCH_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns-search.expected-string");
486
487pub const DNS_SEARCH_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.dns-search.duplicate-item");
489
490pub const EXPOSE_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.expose.expected-sequence");
492
493pub const EXPOSE_EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.expose.expected-string-or-number");
495
496pub const EXPOSE_INVALID_ITEM: DiagnosticCode = DiagnosticCode::new("compose.expose.invalid-item");
498
499pub const EXPOSE_PROVIDER_DEPENDENT: DiagnosticCode = DiagnosticCode::new("compose.expose.provider-dependent-protocol");
501
502pub const EXPOSE_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.expose.duplicate-item");
504
505pub const SECURITY_OPT_EXPECTED_SEQUENCE: DiagnosticCode =
507 DiagnosticCode::new("compose.security-opt.expected-sequence");
508
509pub const SECURITY_OPT_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.security-opt.expected-string");
511
512pub const SECURITY_OPT_EMPTY_ITEM: DiagnosticCode = DiagnosticCode::new("compose.security-opt.empty-item");
514
515pub const SECURITY_OPT_APPARMOR_NEAR_MISS: DiagnosticCode =
517 DiagnosticCode::new("compose.security-opt.apparmor-near-miss");
518
519pub const SECURITY_OPT_APPARMOR_CONFLICT: DiagnosticCode =
521 DiagnosticCode::new("compose.security-opt.apparmor-conflict");
522
523pub const SECURITY_OPT_SECCOMP_NEAR_MISS: DiagnosticCode =
525 DiagnosticCode::new("compose.security-opt.seccomp-near-miss");
526
527pub const SECURITY_OPT_SECCOMP_CONFLICT: DiagnosticCode = DiagnosticCode::new("compose.security-opt.seccomp-conflict");
529
530pub const SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS: DiagnosticCode =
532 DiagnosticCode::new("compose.security-opt.no-new-privileges-near-miss");
533
534pub const SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT: DiagnosticCode =
536 DiagnosticCode::new("compose.security-opt.no-new-privileges-conflict");
537
538pub const SECURITY_OPT_MASK_NEAR_MISS: DiagnosticCode = DiagnosticCode::new("compose.security-opt.mask-near-miss");
540
541pub const SECURITY_OPT_UNMASK_NEAR_MISS: DiagnosticCode = DiagnosticCode::new("compose.security-opt.unmask-near-miss");
543
544pub(crate) fn security_path_option_diagnostic(kind: &SecurityOptionKind, span: SourceSpan) -> Option<Diagnostic> {
545 let (code, message) = match kind {
546 SecurityOptionKind::MaskNearMiss => (
547 SECURITY_OPT_MASK_NEAR_MISS,
548 "mask candidates require exact lowercase `mask=<paths>` spelling with a non-empty whitespace-free payload",
549 ),
550 SecurityOptionKind::UnmaskNearMiss => (
551 SECURITY_OPT_UNMASK_NEAR_MISS,
552 "unmask candidates require exact lowercase `unmask=ALL` or colon-separated slash-prefixed paths without whitespace",
553 ),
554 _ => return None,
555 };
556 Some(
557 Diagnostic::new(code, Severity::Warning, message)
558 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
559 )
560}
561
562pub const SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS: DiagnosticCode =
564 DiagnosticCode::new("compose.security-opt.security-label-disable-near-miss");
565
566pub const SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT: DiagnosticCode =
568 DiagnosticCode::new("compose.security-opt.security-label-disable-conflict");
569
570pub const SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS: DiagnosticCode =
572 DiagnosticCode::new("compose.security-opt.security-label-filetype-near-miss");
573
574pub const SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT: DiagnosticCode =
576 DiagnosticCode::new("compose.security-opt.security-label-filetype-conflict");
577
578pub const SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS: DiagnosticCode =
580 DiagnosticCode::new("compose.security-opt.security-label-level-near-miss");
581
582pub const SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT: DiagnosticCode =
584 DiagnosticCode::new("compose.security-opt.security-label-level-conflict");
585
586pub const SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS: DiagnosticCode =
588 DiagnosticCode::new("compose.security-opt.security-label-nested-near-miss");
589
590pub const SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT: DiagnosticCode =
592 DiagnosticCode::new("compose.security-opt.security-label-nested-conflict");
593
594pub const SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS: DiagnosticCode =
596 DiagnosticCode::new("compose.security-opt.security-label-type-near-miss");
597
598pub const SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT: DiagnosticCode =
600 DiagnosticCode::new("compose.security-opt.security-label-type-conflict");
601
602fn authored_security_label_diagnostic(
603 kind: &SecurityOptionKind,
604 span: SourceSpan,
605 candidates: &mut SecurityOptionCandidateCounts,
606) -> Option<Diagnostic> {
607 match kind {
608 SecurityOptionKind::SecurityLabelDisable { .. } => {
609 candidates.security_label_disable += 1;
610 (candidates.security_label_disable > 1).then(|| {
611 Diagnostic::new(
612 SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT,
613 Severity::Warning,
614 "multiple SELinux label-disable candidates are retained; a consumer must resolve the conflict explicitly",
615 )
616 .with_label(DiagnosticLabel::primary(
617 span,
618 "additional SELinux label-disable candidate retained",
619 ))
620 })
621 }
622 SecurityOptionKind::SecurityLabelDisableNearMiss => Some(
623 Diagnostic::new(
624 SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS,
625 Severity::Warning,
626 "SELinux label-disable candidates require exact lowercase `label:disable` spelling without whitespace",
627 )
628 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
629 ),
630 SecurityOptionKind::SecurityLabelFileType { .. } => {
631 candidates.security_label_filetype += 1;
632 (candidates.security_label_filetype > 1).then(|| {
633 Diagnostic::new(
634 SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT,
635 Severity::Warning,
636 "multiple SELinux label-filetype candidates are retained; a consumer must resolve the conflict explicitly",
637 )
638 .with_label(DiagnosticLabel::primary(
639 span,
640 "additional SELinux label-filetype candidate retained",
641 ))
642 })
643 }
644 SecurityOptionKind::SecurityLabelFileTypeNearMiss => Some(
645 Diagnostic::new(
646 SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS,
647 Severity::Warning,
648 "SELinux label-filetype candidates require exact lowercase `label:filetype:<type>` spelling without whitespace",
649 )
650 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
651 ),
652 SecurityOptionKind::SecurityLabelLevel { .. } => {
653 candidates.security_label_level += 1;
654 (candidates.security_label_level > 1).then(|| {
655 Diagnostic::new(
656 SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT,
657 Severity::Warning,
658 "multiple SELinux label-level candidates are retained; a consumer must resolve the conflict explicitly",
659 )
660 .with_label(DiagnosticLabel::primary(
661 span,
662 "additional SELinux label-level candidate retained",
663 ))
664 })
665 }
666 SecurityOptionKind::SecurityLabelLevelNearMiss => Some(
667 Diagnostic::new(
668 SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS,
669 Severity::Warning,
670 "SELinux label-level candidates require exact lowercase `label:level:<level>` spelling without whitespace",
671 )
672 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
673 ),
674 SecurityOptionKind::SecurityLabelNested { .. } => {
675 candidates.security_label_nested += 1;
676 (candidates.security_label_nested > 1).then(|| {
677 Diagnostic::new(
678 SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT,
679 Severity::Warning,
680 "multiple SELinux label-nested candidates are retained; a consumer must resolve the conflict explicitly",
681 )
682 .with_label(DiagnosticLabel::primary(
683 span,
684 "additional SELinux label-nested candidate retained",
685 ))
686 })
687 }
688 SecurityOptionKind::SecurityLabelNestedNearMiss => Some(
689 Diagnostic::new(
690 SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS,
691 Severity::Warning,
692 "SELinux label-nested candidates require exact lowercase `label:nested` spelling without whitespace",
693 )
694 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
695 ),
696 SecurityOptionKind::SecurityLabelType { .. } | SecurityOptionKind::SecurityLabelTypeNearMiss => {
697 authored_security_label_type_diagnostic(kind, span, &mut candidates.security_label_type)
698 }
699 _ => None,
700 }
701}
702
703fn authored_security_label_type_diagnostic(
704 kind: &SecurityOptionKind,
705 span: SourceSpan,
706 candidates: &mut usize,
707) -> Option<Diagnostic> {
708 match kind {
709 SecurityOptionKind::SecurityLabelType { .. } => {
710 *candidates += 1;
711 (*candidates > 1).then(|| {
712 Diagnostic::new(
713 SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT,
714 Severity::Warning,
715 "multiple SELinux label-type candidates are retained; a consumer must resolve the conflict explicitly",
716 )
717 .with_label(DiagnosticLabel::primary(
718 span,
719 "additional SELinux label-type candidate retained",
720 ))
721 })
722 }
723 SecurityOptionKind::SecurityLabelTypeNearMiss => Some(
724 Diagnostic::new(
725 SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS,
726 Severity::Warning,
727 "SELinux label-type candidates require exact lowercase `label:type:<type>` spelling with one non-empty whitespace-free type",
728 )
729 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
730 ),
731 _ => None,
732 }
733}
734
735pub const ANNOTATIONS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.annotations.expected-map-or-list");
737
738pub const ANNOTATIONS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.annotations.expected-string");
740
741pub const ANNOTATIONS_EMPTY_NAME: DiagnosticCode = DiagnosticCode::new("compose.annotations.empty-name");
743
744pub const ANNOTATIONS_KEY_ONLY: DiagnosticCode = DiagnosticCode::new("compose.annotations.key-only");
746
747pub const ANNOTATIONS_DUPLICATE_NAME: DiagnosticCode = DiagnosticCode::new("compose.annotations.duplicate-name");
749
750pub const TMPFS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.expected-string-or-list");
752
753pub const TMPFS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.expected-string");
755
756pub const TMPFS_PROVIDER_DEPENDENT: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.provider-dependent-item");
758
759pub const SYSCTLS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-map-or-list");
761
762pub const SYSCTLS_EMPTY_KEY: DiagnosticCode = DiagnosticCode::new("compose.sysctls.empty-key");
764
765pub const SYSCTLS_EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-scalar");
767
768pub const SYSCTLS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-string");
770
771pub const SYSCTLS_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.sysctls.duplicate-item");
773
774pub const LOGGING_EXPECTED_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.logging.expected-mapping");
776
777pub const LOGGING_DRIVER_EXPECTED_STRING: DiagnosticCode =
779 DiagnosticCode::new("compose.logging.driver.expected-string");
780
781pub const LOGGING_OPTIONS_EXPECTED_MAPPING: DiagnosticCode =
783 DiagnosticCode::new("compose.logging.options.expected-mapping");
784
785pub const LOGGING_OPTION_EMPTY_KEY: DiagnosticCode = DiagnosticCode::new("compose.logging.option.empty-key");
787
788pub const LOGGING_OPTION_EXPECTED_SCALAR: DiagnosticCode =
790 DiagnosticCode::new("compose.logging.option.expected-scalar");
791
792pub const ENVIRONMENT_FILE_EXPECTED_FORM: DiagnosticCode =
794 DiagnosticCode::new("compose.environment-file.expected-short-or-long");
795
796pub const ENVIRONMENT_FILE_MISSING_PATH: DiagnosticCode =
798 DiagnosticCode::new("compose.environment-file.long.missing-path");
799
800pub const ENVIRONMENT_FILE_INVALID_FORMAT: DiagnosticCode =
802 DiagnosticCode::new("compose.environment-file.invalid-format");
803
804pub const DEPENDENCY_INVALID_CONDITION: DiagnosticCode = DiagnosticCode::new("compose.dependencies.invalid-condition");
806
807pub const DEPENDENCY_MISSING_SERVICE: DiagnosticCode = DiagnosticCode::new("compose.dependencies.missing-service");
809
810pub const DEPENDENCY_MISSING_HEALTHCHECK: DiagnosticCode =
812 DiagnosticCode::new("compose.dependencies.missing-healthcheck");
813
814pub const DEPENDENCY_HEALTHCHECK_UNVERIFIED: DiagnosticCode =
816 DiagnosticCode::new("compose.dependencies.healthcheck-unverified");
817
818pub const BUILD_SSH_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.build.ssh-expected-form");
820
821pub const BUILD_SSH_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.build.ssh-duplicate-item");
823
824#[derive(Debug, Clone, PartialEq, Eq)]
826pub struct Located<T> {
827 value: T,
828 span: SourceSpan,
829}
830
831impl<T> Located<T> {
832 pub(crate) const fn new(value: T, span: SourceSpan) -> Self {
833 Self { value, span }
834 }
835
836 #[must_use]
838 pub const fn value(&self) -> &T {
839 &self.value
840 }
841
842 #[must_use]
844 pub const fn span(&self) -> SourceSpan {
845 self.span
846 }
847
848 #[must_use]
850 pub fn into_value(self) -> T {
851 self.value
852 }
853}
854
855#[derive(Debug, Clone, PartialEq, Eq)]
860pub struct FieldReference {
861 name: Located<String>,
862 span: SourceSpan,
863 value_span: Option<SourceSpan>,
864}
865
866impl FieldReference {
867 pub(crate) const fn generated(name: Located<String>, span: SourceSpan, value_span: Option<SourceSpan>) -> Self {
868 Self { name, span, value_span }
869 }
870
871 #[must_use]
873 pub const fn name(&self) -> &Located<String> {
874 &self.name
875 }
876
877 #[must_use]
879 pub const fn span(&self) -> SourceSpan {
880 self.span
881 }
882
883 #[must_use]
885 pub const fn value_span(&self) -> Option<SourceSpan> {
886 self.value_span
887 }
888}
889
890#[derive(Debug, Clone, PartialEq, Eq)]
892pub struct Service {
893 name: Located<String>,
894 span: SourceSpan,
895 hostname: Option<Hostname>,
896 domainname: Option<Located<String>>,
897 container_name: Option<Located<String>>,
898 image: Option<Located<ImageReference>>,
899 platform: Option<Located<String>>,
900 isolation: Option<Located<String>>,
901 mac_address: Option<Located<String>>,
902 uts: Option<Located<String>>,
903 entrypoint: Option<Entrypoint>,
904 command: Option<Command>,
905 credential_spec: Option<CredentialSpec>,
906 extends: Option<Extends>,
907 provider: Option<Provider>,
908 post_start: Option<PostStartHooks>,
909 pre_stop: Option<PreStopHooks>,
910 pre_start: Option<PreStartHooks>,
911 blkio_config: Option<BlkioConfig>,
912 cgroup: Option<CgroupNamespace>,
913 cgroup_parent: Option<Located<String>>,
914 attach: Option<Located<BooleanValue>>,
915 init: Option<Located<BooleanValue>>,
916 stdin_open: Option<Located<BooleanValue>>,
917 tty: Option<Located<BooleanValue>>,
918 privileged: Option<Located<BooleanValue>>,
919 use_api_socket: Option<Located<BooleanValue>>,
920 environment: Option<Environment>,
921 environment_files: Vec<EnvironmentFile>,
922 label_files: Option<LabelFiles>,
923 labels: Option<Labels>,
924 annotations: Option<Annotations>,
925 extra_hosts: Option<ExtraHosts>,
926 external_links: Vec<Located<String>>,
927 links: Vec<Located<String>>,
928 storage_opt: Option<Labels>,
929 models: Option<ServiceModels>,
930 gpus: Option<Gpus>,
931 develop: Option<Develop>,
932 user: Option<UserSpec>,
933 userns_mode: Option<UserNamespaceMode>,
934 group_add: Vec<Located<String>>,
935 cap_add: Option<CapabilityAdd>,
936 cap_drop: Option<CapabilityDrop>,
937 devices: Option<Devices>,
938 dns: Option<Dns>,
939 dns_options: Option<DnsOptions>,
940 dns_search: Option<DnsSearch>,
941 expose: Option<Expose>,
942 security_options: Option<SecurityOptions>,
943 working_dir: Option<Located<String>>,
944 read_only: Option<Located<BooleanValue>>,
945 pids_limit: Option<PidsLimit>,
946 cpu_count: Option<Located<CpuCount>>,
947 cpu_percent: Option<Located<CpuPercent>>,
948 cpu_period: Option<Located<CpuPeriod>>,
949 cpu_quota: Option<Located<CpuQuota>>,
950 cpu_rt_period: Option<Located<CpuRtPeriod>>,
951 cpu_rt_runtime: Option<Located<CpuRtRuntime>>,
952 cpu_shares: Option<Located<ServiceInteger>>,
953 cpus: Option<Located<Cpus>>,
954 cpuset: Option<Located<String>>,
955 device_cgroup_rules: Vec<Located<String>>,
956 invalid_device_cgroup_rules: Vec<InvalidServiceStringItem>,
957 ipc: Option<Located<IpcMode>>,
958 mem_reservation: Option<MemLimit>,
959 mem_swappiness: Option<Located<ServiceInteger>>,
960 memswap_limit: Option<MemswapLimit>,
961 network_mode: Option<Located<NetworkMode>>,
962 oom_kill_disable: Option<Located<BooleanValue>>,
963 oom_score_adj: Option<Located<ServiceInteger>>,
964 pid: Option<Located<PidMode>>,
965 scale: Option<Located<ServiceInteger>>,
966 volumes_from: Vec<VolumesFrom>,
967 invalid_volumes_from: Vec<InvalidServiceStringItem>,
968 shm_size: Option<ShmSize>,
969 mem_limit: Option<MemLimit>,
970 tmpfs: Option<Tmpfs>,
971 sysctls: Option<Sysctls>,
972 logging: Option<Logging>,
973 pull_policy: Option<PullPolicy>,
974 pull_refresh_after: Option<Located<String>>,
975 restart: Option<RestartPolicy>,
976 runtime: Option<Located<String>>,
977 stop_signal: Option<Located<String>>,
978 stop_grace_period: Option<Located<StopGracePeriod>>,
979 ulimits: Option<Ulimits>,
980 depends_on: Option<DependsOn>,
981 healthcheck: Option<Healthcheck>,
982 build: Option<Build>,
983 deploy: Option<DeployDefinition>,
984 ports: Vec<Port>,
985 volumes: Vec<VolumeMount>,
986 networks: Option<ServiceNetworks>,
987 profiles: Vec<Located<String>>,
988 configs: Vec<ConfigGrant>,
989 secrets: Vec<SecretGrant>,
990 extension_fields: Vec<FieldReference>,
991 unknown_fields: Vec<FieldReference>,
992}
993
994impl Service {
995 #[expect(
996 clippy::too_many_lines,
997 reason = "each source-aware field has an explicit omission state; grouping would obscure that contract"
998 )]
999 fn new(name: Located<String>, span: SourceSpan) -> Self {
1000 Self {
1001 name,
1002 span,
1003 hostname: None,
1004 domainname: None,
1005 container_name: None,
1006 image: None,
1007 platform: None,
1008 isolation: None,
1009 mac_address: None,
1010 uts: None,
1011 entrypoint: None,
1012 command: None,
1013 credential_spec: None,
1014 extends: None,
1015 provider: None,
1016 post_start: None,
1017 pre_stop: None,
1018 pre_start: None,
1019 blkio_config: None,
1020 cgroup: None,
1021 cgroup_parent: None,
1022 attach: None,
1023 init: None,
1024 stdin_open: None,
1025 tty: None,
1026 privileged: None,
1027 use_api_socket: None,
1028 environment: None,
1029 environment_files: Vec::new(),
1030 label_files: None,
1031 labels: None,
1032 annotations: None,
1033 extra_hosts: None,
1034 external_links: Vec::new(),
1035 links: Vec::new(),
1036 storage_opt: None,
1037 models: None,
1038 gpus: None,
1039 develop: None,
1040 user: None,
1041 userns_mode: None,
1042 group_add: Vec::new(),
1043 cap_add: None,
1044 cap_drop: None,
1045 devices: None,
1046 dns: None,
1047 dns_options: None,
1048 dns_search: None,
1049 expose: None,
1050 security_options: None,
1051 working_dir: None,
1052 read_only: None,
1053 pids_limit: None,
1054 cpu_count: None,
1055 cpu_percent: None,
1056 cpu_period: None,
1057 cpu_quota: None,
1058 cpu_rt_period: None,
1059 cpu_rt_runtime: None,
1060 cpu_shares: None,
1061 cpus: None,
1062 cpuset: None,
1063 device_cgroup_rules: Vec::new(),
1064 invalid_device_cgroup_rules: Vec::new(),
1065 ipc: None,
1066 mem_reservation: None,
1067 mem_swappiness: None,
1068 memswap_limit: None,
1069 network_mode: None,
1070 oom_kill_disable: None,
1071 oom_score_adj: None,
1072 pid: None,
1073 scale: None,
1074 volumes_from: Vec::new(),
1075 invalid_volumes_from: Vec::new(),
1076 shm_size: None,
1077 mem_limit: None,
1078 tmpfs: None,
1079 sysctls: None,
1080 logging: None,
1081 pull_policy: None,
1082 pull_refresh_after: None,
1083 restart: None,
1084 runtime: None,
1085 stop_signal: None,
1086 stop_grace_period: None,
1087 ulimits: None,
1088 depends_on: None,
1089 healthcheck: None,
1090 build: None,
1091 deploy: None,
1092 ports: Vec::new(),
1093 volumes: Vec::new(),
1094 networks: None,
1095 profiles: Vec::new(),
1096 configs: Vec::new(),
1097 secrets: Vec::new(),
1098 extension_fields: Vec::new(),
1099 unknown_fields: Vec::new(),
1100 }
1101 }
1102
1103 #[must_use]
1105 pub const fn name(&self) -> &Located<String> {
1106 &self.name
1107 }
1108
1109 #[must_use]
1111 pub const fn span(&self) -> SourceSpan {
1112 self.span
1113 }
1114
1115 #[must_use]
1117 pub const fn hostname(&self) -> Option<&Hostname> {
1118 self.hostname.as_ref()
1119 }
1120
1121 #[must_use]
1123 pub const fn domainname(&self) -> Option<&Located<String>> {
1124 self.domainname.as_ref()
1125 }
1126
1127 #[must_use]
1129 pub const fn container_name(&self) -> Option<&Located<String>> {
1130 self.container_name.as_ref()
1131 }
1132
1133 #[must_use]
1135 pub const fn image(&self) -> Option<&Located<ImageReference>> {
1136 self.image.as_ref()
1137 }
1138
1139 #[must_use]
1141 pub const fn platform(&self) -> Option<&Located<String>> {
1142 self.platform.as_ref()
1143 }
1144
1145 #[must_use]
1147 pub const fn isolation(&self) -> Option<&Located<String>> {
1148 self.isolation.as_ref()
1149 }
1150
1151 #[must_use]
1153 pub const fn mac_address(&self) -> Option<&Located<String>> {
1154 self.mac_address.as_ref()
1155 }
1156
1157 #[must_use]
1159 pub const fn uts(&self) -> Option<&Located<String>> {
1160 self.uts.as_ref()
1161 }
1162
1163 #[must_use]
1165 pub const fn entrypoint(&self) -> Option<&Entrypoint> {
1166 self.entrypoint.as_ref()
1167 }
1168
1169 #[must_use]
1171 pub const fn command(&self) -> Option<&Command> {
1172 self.command.as_ref()
1173 }
1174
1175 #[must_use]
1177 pub const fn credential_spec(&self) -> Option<&CredentialSpec> {
1178 self.credential_spec.as_ref()
1179 }
1180
1181 #[must_use]
1183 pub const fn extends(&self) -> Option<&Extends> {
1184 self.extends.as_ref()
1185 }
1186
1187 #[must_use]
1189 pub const fn provider(&self) -> Option<&Provider> {
1190 self.provider.as_ref()
1191 }
1192
1193 #[must_use]
1195 pub const fn post_start(&self) -> Option<&PostStartHooks> {
1196 self.post_start.as_ref()
1197 }
1198
1199 #[must_use]
1201 pub const fn pre_stop(&self) -> Option<&PreStopHooks> {
1202 self.pre_stop.as_ref()
1203 }
1204
1205 #[must_use]
1207 pub const fn pre_start(&self) -> Option<&PreStartHooks> {
1208 self.pre_start.as_ref()
1209 }
1210
1211 #[must_use]
1213 pub const fn blkio_config(&self) -> Option<&BlkioConfig> {
1214 self.blkio_config.as_ref()
1215 }
1216
1217 #[must_use]
1219 pub const fn cgroup(&self) -> Option<&CgroupNamespace> {
1220 self.cgroup.as_ref()
1221 }
1222
1223 #[must_use]
1225 pub const fn cgroup_parent(&self) -> Option<&Located<String>> {
1226 self.cgroup_parent.as_ref()
1227 }
1228
1229 #[must_use]
1231 pub const fn runtime(&self) -> Option<&Located<String>> {
1232 self.runtime.as_ref()
1233 }
1234
1235 #[must_use]
1237 pub const fn pull_refresh_after(&self) -> Option<&Located<String>> {
1238 self.pull_refresh_after.as_ref()
1239 }
1240
1241 #[must_use]
1243 pub const fn attach(&self) -> Option<&Located<BooleanValue>> {
1244 self.attach.as_ref()
1245 }
1246
1247 #[must_use]
1249 pub const fn init(&self) -> Option<&Located<BooleanValue>> {
1250 self.init.as_ref()
1251 }
1252
1253 #[must_use]
1255 pub const fn stdin_open(&self) -> Option<&Located<BooleanValue>> {
1256 self.stdin_open.as_ref()
1257 }
1258
1259 #[must_use]
1261 pub const fn tty(&self) -> Option<&Located<BooleanValue>> {
1262 self.tty.as_ref()
1263 }
1264
1265 #[must_use]
1267 pub const fn privileged(&self) -> Option<&Located<BooleanValue>> {
1268 self.privileged.as_ref()
1269 }
1270
1271 #[must_use]
1273 pub const fn use_api_socket(&self) -> Option<&Located<BooleanValue>> {
1274 self.use_api_socket.as_ref()
1275 }
1276
1277 #[must_use]
1279 pub const fn environment(&self) -> Option<&Environment> {
1280 self.environment.as_ref()
1281 }
1282
1283 #[must_use]
1285 pub fn environment_files(&self) -> &[EnvironmentFile] {
1286 &self.environment_files
1287 }
1288
1289 #[must_use]
1291 pub const fn label_files(&self) -> Option<&LabelFiles> {
1292 self.label_files.as_ref()
1293 }
1294
1295 #[must_use]
1297 pub const fn labels(&self) -> Option<&Labels> {
1298 self.labels.as_ref()
1299 }
1300
1301 #[must_use]
1303 pub const fn annotations(&self) -> Option<&Annotations> {
1304 self.annotations.as_ref()
1305 }
1306
1307 #[must_use]
1309 pub const fn extra_hosts(&self) -> Option<&ExtraHosts> {
1310 self.extra_hosts.as_ref()
1311 }
1312
1313 #[must_use]
1315 pub fn external_links(&self) -> &[Located<String>] {
1316 &self.external_links
1317 }
1318
1319 #[must_use]
1321 pub fn links(&self) -> &[Located<String>] {
1322 &self.links
1323 }
1324
1325 #[must_use]
1327 pub const fn storage_opt(&self) -> Option<&Labels> {
1328 self.storage_opt.as_ref()
1329 }
1330
1331 #[must_use]
1333 pub const fn models(&self) -> Option<&ServiceModels> {
1334 self.models.as_ref()
1335 }
1336
1337 #[must_use]
1339 pub const fn gpus(&self) -> Option<&Gpus> {
1340 self.gpus.as_ref()
1341 }
1342
1343 #[must_use]
1345 pub const fn develop(&self) -> Option<&Develop> {
1346 self.develop.as_ref()
1347 }
1348
1349 #[must_use]
1351 pub const fn user(&self) -> Option<&UserSpec> {
1352 self.user.as_ref()
1353 }
1354
1355 #[must_use]
1357 pub const fn userns_mode(&self) -> Option<&UserNamespaceMode> {
1358 self.userns_mode.as_ref()
1359 }
1360
1361 #[must_use]
1363 pub fn group_add(&self) -> &[Located<String>] {
1364 &self.group_add
1365 }
1366
1367 #[must_use]
1369 pub const fn cap_add(&self) -> Option<&CapabilityAdd> {
1370 self.cap_add.as_ref()
1371 }
1372
1373 #[must_use]
1375 pub const fn cap_drop(&self) -> Option<&CapabilityDrop> {
1376 self.cap_drop.as_ref()
1377 }
1378
1379 #[must_use]
1381 pub const fn devices(&self) -> Option<&Devices> {
1382 self.devices.as_ref()
1383 }
1384
1385 #[must_use]
1387 pub const fn dns(&self) -> Option<&Dns> {
1388 self.dns.as_ref()
1389 }
1390
1391 #[must_use]
1393 pub const fn dns_options(&self) -> Option<&DnsOptions> {
1394 self.dns_options.as_ref()
1395 }
1396
1397 #[must_use]
1399 pub const fn dns_search(&self) -> Option<&DnsSearch> {
1400 self.dns_search.as_ref()
1401 }
1402
1403 #[must_use]
1405 pub const fn expose(&self) -> Option<&Expose> {
1406 self.expose.as_ref()
1407 }
1408
1409 #[must_use]
1411 pub const fn security_options(&self) -> Option<&SecurityOptions> {
1412 self.security_options.as_ref()
1413 }
1414
1415 #[must_use]
1417 pub const fn working_dir(&self) -> Option<&Located<String>> {
1418 self.working_dir.as_ref()
1419 }
1420
1421 #[must_use]
1423 pub const fn read_only(&self) -> Option<&Located<BooleanValue>> {
1424 self.read_only.as_ref()
1425 }
1426
1427 #[must_use]
1429 pub const fn pids_limit(&self) -> Option<&PidsLimit> {
1430 self.pids_limit.as_ref()
1431 }
1432
1433 #[must_use]
1435 pub const fn cpu_count(&self) -> Option<&Located<CpuCount>> {
1436 self.cpu_count.as_ref()
1437 }
1438
1439 #[must_use]
1441 pub const fn cpu_percent(&self) -> Option<&Located<CpuPercent>> {
1442 self.cpu_percent.as_ref()
1443 }
1444
1445 #[must_use]
1447 pub const fn cpu_period(&self) -> Option<&Located<CpuPeriod>> {
1448 self.cpu_period.as_ref()
1449 }
1450
1451 #[must_use]
1453 pub const fn cpu_quota(&self) -> Option<&Located<CpuQuota>> {
1454 self.cpu_quota.as_ref()
1455 }
1456
1457 #[must_use]
1459 pub const fn cpu_rt_period(&self) -> Option<&Located<CpuRtPeriod>> {
1460 self.cpu_rt_period.as_ref()
1461 }
1462
1463 #[must_use]
1465 pub const fn cpu_rt_runtime(&self) -> Option<&Located<CpuRtRuntime>> {
1466 self.cpu_rt_runtime.as_ref()
1467 }
1468 #[must_use]
1470 pub const fn cpu_shares(&self) -> Option<&Located<ServiceInteger>> {
1471 self.cpu_shares.as_ref()
1472 }
1473 #[must_use]
1475 pub const fn cpus(&self) -> Option<&Located<Cpus>> {
1476 self.cpus.as_ref()
1477 }
1478 #[must_use]
1480 pub const fn cpuset(&self) -> Option<&Located<String>> {
1481 self.cpuset.as_ref()
1482 }
1483 #[must_use]
1485 pub fn device_cgroup_rules(&self) -> &[Located<String>] {
1486 &self.device_cgroup_rules
1487 }
1488 #[must_use]
1490 pub fn invalid_device_cgroup_rules(&self) -> &[InvalidServiceStringItem] {
1491 &self.invalid_device_cgroup_rules
1492 }
1493 #[must_use]
1495 pub const fn ipc(&self) -> Option<&Located<IpcMode>> {
1496 self.ipc.as_ref()
1497 }
1498 #[must_use]
1500 pub const fn mem_reservation(&self) -> Option<&MemLimit> {
1501 self.mem_reservation.as_ref()
1502 }
1503 #[must_use]
1505 pub const fn mem_swappiness(&self) -> Option<&Located<ServiceInteger>> {
1506 self.mem_swappiness.as_ref()
1507 }
1508 #[must_use]
1510 pub const fn memswap_limit(&self) -> Option<&MemswapLimit> {
1511 self.memswap_limit.as_ref()
1512 }
1513 #[must_use]
1515 pub const fn network_mode(&self) -> Option<&Located<NetworkMode>> {
1516 self.network_mode.as_ref()
1517 }
1518 #[must_use]
1520 pub const fn oom_kill_disable(&self) -> Option<&Located<BooleanValue>> {
1521 self.oom_kill_disable.as_ref()
1522 }
1523 #[must_use]
1525 pub const fn oom_score_adj(&self) -> Option<&Located<ServiceInteger>> {
1526 self.oom_score_adj.as_ref()
1527 }
1528 #[must_use]
1530 pub const fn pid(&self) -> Option<&Located<PidMode>> {
1531 self.pid.as_ref()
1532 }
1533 #[must_use]
1535 pub const fn scale(&self) -> Option<&Located<ServiceInteger>> {
1536 self.scale.as_ref()
1537 }
1538 #[must_use]
1540 pub fn volumes_from(&self) -> &[VolumesFrom] {
1541 &self.volumes_from
1542 }
1543 #[must_use]
1545 pub fn invalid_volumes_from(&self) -> &[InvalidServiceStringItem] {
1546 &self.invalid_volumes_from
1547 }
1548
1549 #[must_use]
1551 pub const fn shm_size(&self) -> Option<&ShmSize> {
1552 self.shm_size.as_ref()
1553 }
1554
1555 #[must_use]
1557 pub const fn mem_limit(&self) -> Option<&MemLimit> {
1558 self.mem_limit.as_ref()
1559 }
1560
1561 #[must_use]
1563 pub const fn tmpfs(&self) -> Option<&Tmpfs> {
1564 self.tmpfs.as_ref()
1565 }
1566
1567 #[must_use]
1569 pub const fn sysctls(&self) -> Option<&Sysctls> {
1570 self.sysctls.as_ref()
1571 }
1572
1573 #[must_use]
1575 pub const fn logging(&self) -> Option<&Logging> {
1576 self.logging.as_ref()
1577 }
1578
1579 #[must_use]
1581 pub const fn pull_policy(&self) -> Option<&PullPolicy> {
1582 self.pull_policy.as_ref()
1583 }
1584
1585 #[must_use]
1587 pub const fn restart(&self) -> Option<&RestartPolicy> {
1588 self.restart.as_ref()
1589 }
1590
1591 #[must_use]
1593 pub const fn stop_signal(&self) -> Option<&Located<String>> {
1594 self.stop_signal.as_ref()
1595 }
1596
1597 #[must_use]
1599 pub const fn stop_grace_period(&self) -> Option<&Located<StopGracePeriod>> {
1600 self.stop_grace_period.as_ref()
1601 }
1602
1603 #[must_use]
1605 pub const fn ulimits(&self) -> Option<&Ulimits> {
1606 self.ulimits.as_ref()
1607 }
1608
1609 #[must_use]
1611 pub const fn depends_on(&self) -> Option<&DependsOn> {
1612 self.depends_on.as_ref()
1613 }
1614
1615 #[must_use]
1617 pub const fn healthcheck(&self) -> Option<&Healthcheck> {
1618 self.healthcheck.as_ref()
1619 }
1620
1621 #[must_use]
1623 pub const fn build(&self) -> Option<&Build> {
1624 self.build.as_ref()
1625 }
1626
1627 #[must_use]
1629 pub const fn deploy(&self) -> Option<&DeployDefinition> {
1630 self.deploy.as_ref()
1631 }
1632
1633 #[must_use]
1635 pub fn ports(&self) -> &[Port] {
1636 &self.ports
1637 }
1638
1639 #[must_use]
1641 pub fn volumes(&self) -> &[VolumeMount] {
1642 &self.volumes
1643 }
1644
1645 #[must_use]
1647 pub const fn networks(&self) -> Option<&ServiceNetworks> {
1648 self.networks.as_ref()
1649 }
1650
1651 #[must_use]
1653 pub fn profiles(&self) -> &[Located<String>] {
1654 &self.profiles
1655 }
1656
1657 #[must_use]
1659 pub fn configs(&self) -> &[ConfigGrant] {
1660 &self.configs
1661 }
1662
1663 #[must_use]
1665 pub fn secrets(&self) -> &[SecretGrant] {
1666 &self.secrets
1667 }
1668
1669 #[must_use]
1671 pub fn extension_fields(&self) -> &[FieldReference] {
1672 &self.extension_fields
1673 }
1674
1675 #[must_use]
1677 pub fn unknown_fields(&self) -> &[FieldReference] {
1678 &self.unknown_fields
1679 }
1680}
1681
1682#[derive(Debug, Clone, PartialEq, Eq)]
1684pub struct ComposeDocument {
1685 source_id: SourceId,
1686 span: SourceSpan,
1687 name: Option<Located<String>>,
1688 version: Option<Located<String>>,
1689 include: Option<Includes>,
1690 models: Option<ModelDefinitions>,
1691 services: Vec<Service>,
1692 networks: Vec<NetworkDefinition>,
1693 volumes: Vec<VolumeDefinition>,
1694 configs: Vec<ConfigDefinition>,
1695 secrets: Vec<SecretDefinition>,
1696 extension_fields: Vec<FieldReference>,
1697 unknown_fields: Vec<FieldReference>,
1698}
1699
1700impl ComposeDocument {
1701 #[must_use]
1707 pub fn parse(syntax: &SyntaxDocument) -> ModelParse {
1708 Parser::new(syntax).parse()
1709 }
1710
1711 #[must_use]
1713 pub const fn source_id(&self) -> SourceId {
1714 self.source_id
1715 }
1716
1717 #[must_use]
1719 pub const fn span(&self) -> SourceSpan {
1720 self.span
1721 }
1722
1723 #[must_use]
1725 pub const fn name(&self) -> Option<&Located<String>> {
1726 self.name.as_ref()
1727 }
1728
1729 #[must_use]
1732 pub const fn version(&self) -> Option<&Located<String>> {
1733 self.version.as_ref()
1734 }
1735
1736 #[must_use]
1738 pub const fn include(&self) -> Option<&Includes> {
1739 self.include.as_ref()
1740 }
1741
1742 #[must_use]
1744 pub const fn models(&self) -> Option<&ModelDefinitions> {
1745 self.models.as_ref()
1746 }
1747
1748 #[must_use]
1750 pub fn services(&self) -> &[Service] {
1751 &self.services
1752 }
1753
1754 #[must_use]
1756 pub fn service(&self, name: &str) -> Option<&Service> {
1757 self.services.iter().find(|service| service.name.value == name)
1758 }
1759
1760 #[must_use]
1765 pub fn validate_dependencies(&self) -> Vec<Diagnostic> {
1766 let mut diagnostics = Vec::new();
1767 for service in &self.services {
1768 let Some(depends_on) = service.depends_on() else {
1769 continue;
1770 };
1771 match depends_on {
1772 DependsOn::Short { services, .. } => {
1773 for target in services {
1774 if self.service(target.value()).is_none() {
1775 diagnostics.push(missing_dependency_diagnostic(target.span(), false, true));
1776 }
1777 }
1778 }
1779 DependsOn::Long { services, .. } => {
1780 for dependency in services {
1781 let required = !matches!(
1782 dependency.required().map(Located::value),
1783 Some(BooleanValue::Literal(false))
1784 );
1785 let Some(target) = self.service(dependency.service().value()) else {
1786 diagnostics.push(missing_dependency_diagnostic(
1787 dependency.service().span(),
1788 false,
1789 required,
1790 ));
1791 continue;
1792 };
1793 let needs_healthcheck = matches!(
1794 dependency.condition().map(Located::value),
1795 Some(DependencyCondition::ServiceHealthy)
1796 );
1797 if needs_healthcheck && target.healthcheck().is_none() {
1798 let span = dependency
1799 .condition()
1800 .map_or_else(|| dependency.service().span(), Located::span);
1801 diagnostics.push(unverified_healthcheck_diagnostic(span));
1802 } else if needs_healthcheck && target.healthcheck().is_some_and(Healthcheck::is_disabled) {
1803 let span = dependency
1804 .condition()
1805 .map_or_else(|| dependency.service().span(), Located::span);
1806 diagnostics.push(missing_dependency_diagnostic(span, true, required));
1807 }
1808 }
1809 }
1810 }
1811 }
1812 diagnostics
1813 }
1814
1815 #[must_use]
1817 pub fn networks(&self) -> &[NetworkDefinition] {
1818 &self.networks
1819 }
1820
1821 #[must_use]
1823 pub fn volumes(&self) -> &[VolumeDefinition] {
1824 &self.volumes
1825 }
1826
1827 #[must_use]
1829 pub fn configs(&self) -> &[ConfigDefinition] {
1830 &self.configs
1831 }
1832
1833 #[must_use]
1835 pub fn secrets(&self) -> &[SecretDefinition] {
1836 &self.secrets
1837 }
1838
1839 #[must_use]
1841 pub fn extension_fields(&self) -> &[FieldReference] {
1842 &self.extension_fields
1843 }
1844
1845 #[must_use]
1847 pub fn unknown_fields(&self) -> &[FieldReference] {
1848 &self.unknown_fields
1849 }
1850}
1851
1852#[derive(Debug, Clone, PartialEq, Eq)]
1854pub struct ModelParse {
1855 document: Option<ComposeDocument>,
1856 diagnostics: Vec<Diagnostic>,
1857}
1858
1859impl ModelParse {
1860 #[must_use]
1862 pub const fn document(&self) -> Option<&ComposeDocument> {
1863 self.document.as_ref()
1864 }
1865
1866 #[must_use]
1868 pub fn diagnostics(&self) -> &[Diagnostic] {
1869 &self.diagnostics
1870 }
1871
1872 #[must_use]
1874 pub fn is_valid(&self) -> bool {
1875 !self
1876 .diagnostics
1877 .iter()
1878 .any(|diagnostic| diagnostic.severity() == Severity::Error)
1879 }
1880
1881 #[must_use]
1883 pub fn into_parts(self) -> (Option<ComposeDocument>, Vec<Diagnostic>) {
1884 (self.document, self.diagnostics)
1885 }
1886}
1887
1888fn missing_dependency_diagnostic(span: SourceSpan, healthcheck: bool, required: bool) -> Diagnostic {
1889 let severity = if required { Severity::Error } else { Severity::Warning };
1890 if healthcheck {
1891 Diagnostic::new(
1892 DEPENDENCY_MISSING_HEALTHCHECK,
1893 severity,
1894 if required {
1895 "service_healthy dependency requires an enabled health check"
1896 } else {
1897 "optional service_healthy dependency has no enabled health check"
1898 },
1899 )
1900 .with_label(DiagnosticLabel::primary(span, "dependency cannot become healthy"))
1901 } else {
1902 Diagnostic::new(
1903 DEPENDENCY_MISSING_SERVICE,
1904 severity,
1905 if required {
1906 "service dependency is not declared in this Compose document"
1907 } else {
1908 "optional service dependency is not declared in this Compose document"
1909 },
1910 )
1911 .with_label(DiagnosticLabel::primary(span, "missing dependency service"))
1912 }
1913}
1914
1915fn unverified_healthcheck_diagnostic(span: SourceSpan) -> Diagnostic {
1916 Diagnostic::new(
1917 DEPENDENCY_HEALTHCHECK_UNVERIFIED,
1918 Severity::Warning,
1919 "service_healthy dependency has no Compose healthcheck to validate",
1920 )
1921 .with_label(DiagnosticLabel::primary(span, "image health metadata is not available"))
1922 .with_note("the dependency image may still define a health check; verify it at build or runtime")
1923}
1924
1925fn annotation_diagnostic(
1926 code: DiagnosticCode,
1927 severity: Severity,
1928 span: SourceSpan,
1929 message: &'static str,
1930 label: &'static str,
1931) -> Diagnostic {
1932 Diagnostic::new(code, severity, message).with_label(DiagnosticLabel::primary(span, label))
1933}
1934
1935#[derive(Debug)]
1936struct Parser {
1937 source_id: SourceId,
1938 source_span: SourceSpan,
1939 source: String,
1940 tree: yaml_edit::YamlFile,
1941 anchors: AnchorRegistry,
1942 diagnostics: Vec<Diagnostic>,
1943}
1944
1945impl Parser {
1946 fn new(syntax: &SyntaxDocument) -> Self {
1947 let tree = syntax.yaml_file();
1948 let anchors = tree
1949 .document()
1950 .map_or_else(AnchorRegistry::new, |document| AnchorRegistry::from_document(&document));
1951 Self {
1952 source_id: syntax.source_id(),
1953 source_span: syntax.source_span(),
1954 source: syntax.source_text().to_owned(),
1955 tree,
1956 anchors,
1957 diagnostics: Vec::new(),
1958 }
1959 }
1960
1961 fn parse(mut self) -> ModelParse {
1962 if self.tree.documents().count() > 1 {
1963 self.diagnostics.push(
1964 Diagnostic::new(
1965 MULTIPLE_DOCUMENTS,
1966 Severity::Error,
1967 "Compose input must contain one YAML document",
1968 )
1969 .with_label(DiagnosticLabel::primary(self.source_span, "multiple YAML documents")),
1970 );
1971 }
1972
1973 let Some(root) = self.tree.document() else {
1974 self.diagnostics.push(
1975 Diagnostic::new(
1976 DOCUMENT_ROOT_TYPE,
1977 Severity::Error,
1978 "Compose document root must be a mapping",
1979 )
1980 .with_label(DiagnosticLabel::primary(self.source_span, "empty document")),
1981 );
1982 return ModelParse {
1983 document: None,
1984 diagnostics: self.diagnostics,
1985 };
1986 };
1987 let root_span = span_from_position(self.source_id, root.byte_range());
1988 let Some(mapping) = root.as_mapping() else {
1989 self.diagnostics.push(
1990 Diagnostic::new(
1991 DOCUMENT_ROOT_TYPE,
1992 Severity::Error,
1993 "Compose document root must be a mapping",
1994 )
1995 .with_label(DiagnosticLabel::primary(root_span, "not a mapping")),
1996 );
1997 return ModelParse {
1998 document: None,
1999 diagnostics: self.diagnostics,
2000 };
2001 };
2002
2003 let document = self.parse_root(&mapping, root_span);
2004 ModelParse {
2005 document: Some(document),
2006 diagnostics: self.diagnostics,
2007 }
2008 }
2009
2010 fn parse_root(&mut self, mapping: &Mapping, span: SourceSpan) -> ComposeDocument {
2011 let mut document = ComposeDocument {
2012 source_id: self.source_id,
2013 span,
2014 name: None,
2015 version: None,
2016 include: None,
2017 models: None,
2018 services: Vec::new(),
2019 networks: Vec::new(),
2020 volumes: Vec::new(),
2021 configs: Vec::new(),
2022 secrets: Vec::new(),
2023 extension_fields: Vec::new(),
2024 unknown_fields: Vec::new(),
2025 };
2026 let mut seen = BTreeMap::new();
2027
2028 for field in self.fields(mapping) {
2029 let duplicate = self.record_duplicate(&mut seen, &field);
2030 match field.name.value.as_str() {
2031 "name" if !duplicate => {
2032 document.name = self.parse_string(&field, "project name");
2033 }
2034 "version" if !duplicate => {
2035 document.version = self.parse_string(&field, "Compose version");
2036 self.diagnostics.push(
2037 Diagnostic::new(
2038 VERSION_OBSOLETE,
2039 Severity::Warning,
2040 "top-level Compose version is obsolete and does not select provider behavior",
2041 )
2042 .with_label(DiagnosticLabel::primary(field.span, "obsolete version retained")),
2043 );
2044 }
2045 "include" if !duplicate => {
2046 document.include = self.parse_includes(&field);
2047 if document.include.is_none() {
2048 document.unknown_fields.push(field.reference());
2049 }
2050 }
2051 "models" if !duplicate => {
2052 document.models = self.parse_model_definitions(&field);
2053 if document.models.is_none() {
2054 document.unknown_fields.push(field.reference());
2055 }
2056 }
2057 "services" if !duplicate => {
2058 document.services = self.parse_services(&field);
2059 }
2060 "networks" if !duplicate => {
2061 document.networks = self.parse_network_definitions(&field);
2062 }
2063 "volumes" if !duplicate => {
2064 document.volumes = self.parse_volume_definitions(&field);
2065 }
2066 "configs" if !duplicate => {
2067 document.configs = self.parse_config_definitions(&field);
2068 }
2069 "secrets" if !duplicate => {
2070 document.secrets = self.parse_secret_definitions(&field);
2071 }
2072 name if name.starts_with("x-") => {
2073 document.extension_fields.push(field.reference());
2074 }
2075 _ if duplicate => {}
2076 _ => document.unknown_fields.push(field.reference()),
2077 }
2078 }
2079 document
2080 }
2081
2082 fn parse_services(&mut self, field: &ParsedField) -> Vec<Service> {
2083 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
2084 self.expected(EXPECTED_MAPPING, field, "services must be a mapping");
2085 return Vec::new();
2086 };
2087 let mut services = Vec::new();
2088 let mut seen = BTreeMap::new();
2089 for service_field in self.fields(mapping) {
2090 self.record_duplicate(&mut seen, &service_field);
2091 let Some(service_mapping) = service_field.value.as_ref().and_then(YamlNode::as_mapping) else {
2092 self.expected(EXPECTED_MAPPING, &service_field, "service definition must be a mapping");
2093 continue;
2094 };
2095 services.push(self.parse_service(&service_field, service_mapping));
2096 }
2097 services
2098 }
2099
2100 fn parse_service(&mut self, field: &ParsedField, mapping: &Mapping) -> Service {
2101 let (mut service, mut seen) = (Service::new(field.name.clone(), field.span), BTreeMap::new());
2102 for service_field in self.fields(mapping) {
2103 let duplicate = self.record_duplicate(&mut seen, &service_field);
2104 if !duplicate && self.parse_extra_service_field(&mut service, &service_field) {
2105 continue;
2106 }
2107 match service_field.name.value.as_str() {
2108 "hostname" if !duplicate => service.hostname = self.parse_hostname(&service_field),
2109 "container_name" if !duplicate => {
2110 service.container_name = self.parse_string(&service_field, "container name");
2111 }
2112 "image" if !duplicate => service.image = self.parse_image(&service_field),
2113 "platform" if !duplicate => self.set_service_platform(&mut service, &service_field),
2114 "entrypoint" if !duplicate => service.entrypoint = self.parse_entrypoint(&service_field),
2115 "command" if !duplicate => service.command = self.parse_command(&service_field),
2116 "credential_spec" if !duplicate => service.credential_spec = self.parse_credential_spec(&service_field),
2117 "extends" if !duplicate => service.extends = self.parse_extends(&service_field),
2118 "provider" if !duplicate => service.provider = self.parse_provider(&service_field),
2119 "post_start" | "pre_stop" | "pre_start" if !duplicate => self.hooks(&mut service, &service_field),
2120 "blkio_config" if !duplicate => self.set_service_blkio_config(&mut service, &service_field),
2121 "cgroup" | "cgroup_parent" if !duplicate => self.set_service_cgroup(&mut service, &service_field),
2122 "attach" | "init" | "stdin_open" | "tty" | "privileged" if !duplicate => {
2123 self.set_service_boolean(&mut service, &service_field);
2124 }
2125 "environment" if !duplicate => service.environment = self.parse_environment(&service_field),
2126 "env_file" if !duplicate => service.environment_files = self.parse_environment_files(&service_field),
2127 "labels" if !duplicate => service.labels = self.parse_labels(&service_field),
2128 "annotations" if !duplicate => service.annotations = self.parse_annotations(&service_field),
2129 "extra_hosts" if !duplicate => service.extra_hosts = self.parse_extra_hosts(&service_field),
2130 "user" if !duplicate => service.user = self.parse_service_user(&service_field),
2131 "userns_mode" if !duplicate => {
2132 service.userns_mode = self
2133 .parse_string(&service_field, "service user namespace mode")
2134 .map(UserNamespaceMode::parse);
2135 }
2136 "group_add" if !duplicate => service.group_add = self.parse_service_group_add(&service_field),
2137 "cap_add" if !duplicate => service.cap_add = self.parse_cap_add(&service_field),
2138 "cap_drop" if !duplicate => service.cap_drop = self.parse_cap_drop(&service_field),
2139 "devices" if !duplicate => service.devices = self.parse_devices(&service_field),
2140 "dns" if !duplicate => service.dns = self.parse_dns(&service_field),
2141 "dns_opt" if !duplicate => service.dns_options = self.parse_dns_options(&service_field),
2142 "dns_search" if !duplicate => service.dns_search = self.parse_dns_search(&service_field),
2143 "expose" if !duplicate => service.expose = self.parse_expose(&service_field),
2144 "security_opt" if !duplicate => service.security_options = self.parse_security_options(&service_field),
2145 "working_dir" if !duplicate => {
2146 service.working_dir = self.parse_string(&service_field, "service working directory");
2147 }
2148 "read_only" if !duplicate => service.read_only = self.parse_service_read_only(&service_field),
2149 "shm_size" if !duplicate => service.shm_size = self.parse_shm_size(&service_field),
2150 "mem_limit" if !duplicate => service.mem_limit = self.parse_mem_limit(&service_field),
2151 "tmpfs" if !duplicate => service.tmpfs = self.parse_tmpfs(&service_field),
2152 "sysctls" if !duplicate => service.sysctls = self.parse_sysctls(&service_field),
2153 "logging" if !duplicate => service.logging = self.parse_logging(&service_field),
2154 "pull_policy" if !duplicate => service.pull_policy = self.parse_pull_policy(&service_field),
2155 "pull_refresh_after" if !duplicate => self.set_service_pull_refresh_after(&mut service, &service_field),
2156 "restart" if !duplicate => service.restart = self.parse_restart_policy(&service_field),
2157 "runtime" if !duplicate => self.set_service_runtime(&mut service, &service_field),
2158 "stop_signal" if !duplicate => {
2159 service.stop_signal = self.parse_string(&service_field, "service stop signal");
2160 }
2161 "stop_grace_period" if !duplicate => {
2162 service.stop_grace_period = self.parse_stop_grace_period(&service_field);
2163 }
2164 "ulimits" if !duplicate => service.ulimits = self.parse_ulimits(&service_field),
2165 "depends_on" if !duplicate => {
2166 service.depends_on = self.parse_depends_on(&service_field);
2167 }
2168 "healthcheck" if !duplicate => {
2169 service.healthcheck = self.parse_healthcheck(&service_field);
2170 }
2171 "build" if !duplicate => service.build = self.parse_build(&service_field),
2172 "deploy" if !duplicate => {
2173 service.deploy = self.parse_deploy(&service_field);
2174 }
2175 "ports" if !duplicate => {
2176 service.ports = self.parse_service_ports(&service_field);
2177 }
2178 "volumes" if !duplicate => {
2179 service.volumes = self.parse_service_volumes(&service_field);
2180 }
2181 "networks" if !duplicate => {
2182 service.networks = self.parse_service_networks(&service_field);
2183 }
2184 "profiles" if !duplicate => {
2185 service.profiles = self.parse_string_sequence(&service_field, "service profiles");
2186 }
2187 "configs" if !duplicate => {
2188 service.configs = self.parse_config_grants(&service_field);
2189 }
2190 "secrets" if !duplicate => {
2191 service.secrets = self.parse_secret_grants(&service_field).unwrap_or_default();
2192 }
2193 name if name.starts_with("x-") => service.extension_fields.push(service_field.reference()),
2194 _ if duplicate => {}
2195 _ => service.unknown_fields.push(service_field.reference()),
2196 }
2197 }
2198 service
2199 }
2200
2201 fn parse_extra_service_field(&mut self, service: &mut Service, field: &ParsedField) -> bool {
2202 self.parse_service_runtime_field(service, field) || self.parse_service_remaining_field(service, field)
2203 }
2204
2205 fn parse_service_remaining_field(&mut self, service: &mut Service, field: &ParsedField) -> bool {
2206 let scalar = |parser: &mut Self, description| parser.parse_extends_string(field, description);
2207 match field.name.value().as_str() {
2208 "domainname" => service.domainname = scalar(self, "service domainname must be a YAML string scalar"),
2209 "isolation" => service.isolation = scalar(self, "service isolation must be a YAML string scalar"),
2210 "mac_address" => service.mac_address = scalar(self, "service mac_address must be a YAML string scalar"),
2211 "uts" => service.uts = scalar(self, "service uts must be a YAML string scalar"),
2212 "gpus" => {
2213 service.gpus = self.parse_gpus(field);
2214 if service.gpus.as_ref().is_some_and(|gpus| {
2215 !gpus.unmodeled_items().is_empty()
2216 || matches!(gpus, Gpus::Devices { devices, .. } if devices.iter().any(|device| !device.unmodeled_fields().is_empty()))
2217 }) {
2218 service.unknown_fields.push(field.reference());
2219 }
2220 }
2221 "use_api_socket" => service.use_api_socket = self.parse_boolean(field, "service use_api_socket"),
2222 "label_file" => service.label_files = self.parse_label_files(field),
2223 "external_links" => service.external_links = self.parse_string_sequence(field, "service external_links"),
2224 "links" => service.links = self.parse_string_sequence(field, "service links"),
2225 "storage_opt" => service.storage_opt = self.parse_labels(field),
2226 "models" => service.models = self.parse_service_models(field),
2227 "develop" => {
2228 service.develop = self.parse_develop(field);
2229 }
2230 _ => return false,
2231 }
2232 let is_scalar_sequence = matches!(field.name.value().as_str(), "external_links" | "links");
2233 let parsed = match field.name.value().as_str() {
2234 "domainname" => service.domainname.is_some(),
2235 "isolation" => service.isolation.is_some(),
2236 "mac_address" => service.mac_address.is_some(),
2237 "uts" => service.uts.is_some(),
2238 "gpus" => service.gpus.is_some(),
2239 "use_api_socket" => service.use_api_socket.is_some(),
2240 "storage_opt" => service.storage_opt.is_some(),
2241 "models" => service.models.is_some(),
2242 "develop" => service.develop.is_some(),
2243 "label_file" => service.label_files.is_some(),
2244 "external_links" | "links" => field.value.as_ref().is_some_and(|value| value.as_sequence().is_some()),
2245 _ => unreachable!("all supported remaining service fields are listed above"),
2246 };
2247 if !parsed || (is_scalar_sequence && field.value.as_ref().is_some_and(|value| value.as_sequence().is_none())) {
2248 service.unknown_fields.push(field.reference());
2249 }
2250 true
2251 }
2252
2253 fn parse_service_runtime_field(&mut self, service: &mut Service, field: &ParsedField) -> bool {
2254 match field.name.value().as_str() {
2255 "cpu_count" | "cpu_percent" | "cpu_period" | "cpu_quota" | "cpu_rt_period" | "cpu_rt_runtime"
2256 | "cpu_shares" | "cpus" | "mem_swappiness" | "oom_score_adj" | "scale" | "pids_limit" => {
2257 self.set_service_count(service, field);
2258 }
2259 "cpuset" => {
2260 service.cpuset = self.parse_string(field, "service cpuset");
2261 if service.cpuset.is_none() {
2262 service.unknown_fields.push(field.reference());
2263 }
2264 }
2265 "device_cgroup_rules" => {
2266 if field.value.as_ref().and_then(YamlNode::as_sequence).is_none() {
2267 service.unknown_fields.push(field.reference());
2268 }
2269 let (items, invalid) = self.parse_strict_string_sequence(field, "device_cgroup_rules");
2270 service.device_cgroup_rules = items;
2271 service.invalid_device_cgroup_rules = invalid;
2272 }
2273 "ipc" => {
2274 service.ipc = self
2275 .parse_string(field, "service ipc mode")
2276 .map(|value| Located::new(IpcMode::parse(value.value), value.span));
2277 if service.ipc.is_none() {
2278 service.unknown_fields.push(field.reference());
2279 }
2280 }
2281 "mem_reservation" => {
2282 service.mem_reservation = self.parse_mem_limit(field);
2283 if service.mem_reservation.is_none() {
2284 service.unknown_fields.push(field.reference());
2285 }
2286 }
2287 "memswap_limit" => {
2288 service.memswap_limit = self.parse_memswap_limit(field);
2289 if service.memswap_limit.is_none() {
2290 service.unknown_fields.push(field.reference());
2291 }
2292 }
2293 "network_mode" => {
2294 service.network_mode = self
2295 .parse_string(field, "service network mode")
2296 .map(|value| Located::new(NetworkMode::parse(value.value), value.span));
2297 if service.network_mode.is_none() {
2298 service.unknown_fields.push(field.reference());
2299 }
2300 }
2301 "oom_kill_disable" => {
2302 service.oom_kill_disable = self.parse_boolean(field, "service oom_kill_disable");
2303 if service.oom_kill_disable.is_none() {
2304 service.unknown_fields.push(field.reference());
2305 }
2306 }
2307 "pid" => {
2308 service.pid = self
2309 .parse_string(field, "service pid mode")
2310 .map(|value| Located::new(PidMode::parse(value.value), value.span));
2311 if service.pid.is_none() {
2312 service.unknown_fields.push(field.reference());
2313 }
2314 }
2315 "volumes_from" => {
2316 if field.value.as_ref().and_then(YamlNode::as_sequence).is_none() {
2317 service.unknown_fields.push(field.reference());
2318 }
2319 let (items, invalid) = self.parse_strict_string_sequence(field, "volumes_from");
2320 service.volumes_from = items.into_iter().map(VolumesFrom::parse).collect();
2321 service.invalid_volumes_from = invalid;
2322 }
2323 _ => return false,
2324 }
2325 true
2326 }
2327
2328 fn hooks(&mut self, service: &mut Service, field: &ParsedField) {
2329 match field.name.value().as_str() {
2330 "post_start" => service.post_start = self.parse_post_start(field),
2331 "pre_stop" => service.pre_stop = self.parse_pre_stop(field),
2332 "pre_start" => service.pre_start = self.parse_pre_start(field),
2333 _ => {}
2334 }
2335 }
2336
2337 fn parse_includes(&mut self, field: &ParsedField) -> Option<Includes> {
2338 let sequence = field.value.as_ref().and_then(YamlNode::as_sequence)?;
2339 let span = span_from_position(self.source_id, sequence.byte_range());
2340 let mut items = Vec::new();
2341 let mut unmodeled_fields = Vec::new();
2342 for node in sequence.values() {
2343 match node {
2344 YamlNode::Scalar(scalar) if scalar_type_for_value(&scalar) == ScalarType::String => {
2345 items.push(IncludeItem::Short(Located::new(
2346 scalar_string_from_source(&self.source, &scalar),
2347 span_from_position(self.source_id, scalar.byte_range()),
2348 )));
2349 }
2350 YamlNode::Scalar(_) => {
2351 self.unsupported_sequence_item(
2352 EXPECTED_SCALAR,
2353 &node,
2354 field.span,
2355 "include items must be YAML string paths or mappings",
2356 );
2357 unmodeled_fields.push(field.reference());
2358 items.push(IncludeItem::Unmodeled);
2359 }
2360 YamlNode::Mapping(mapping) => items.push(IncludeItem::Long(self.parse_include_long(&mapping))),
2361 other => {
2362 self.unsupported_sequence_item(
2363 EXPECTED_FIELD_FORM,
2364 &other,
2365 field.span,
2366 "include items must be paths or mappings",
2367 );
2368 unmodeled_fields.push(field.reference());
2369 items.push(IncludeItem::Unmodeled);
2370 }
2371 }
2372 }
2373 Some(Includes::new(span, items, unmodeled_fields))
2374 }
2375
2376 fn parse_include_long(&mut self, mapping: &Mapping) -> IncludeLong {
2377 let span = span_from_position(self.source_id, mapping.byte_range());
2378 let mut paths = Vec::new();
2379 let mut env_files = Vec::new();
2380 let mut project_directory = None;
2381 let mut unmodeled_fields = Vec::new();
2382 let mut seen = BTreeMap::new();
2383 for field in self.fields(mapping) {
2384 if self.record_duplicate(&mut seen, &field) {
2385 unmodeled_fields.push(field.reference());
2386 continue;
2387 }
2388 match field.name.value().as_str() {
2389 "path" => {
2390 paths = self.string_or_sequence(&field, "include path must be a YAML string or sequence");
2391 if !Self::is_strict_string_or_sequence(&field) {
2392 unmodeled_fields.push(field.reference());
2393 }
2394 }
2395 "env_file" => {
2396 env_files = self.string_or_sequence(&field, "include env_file must be a YAML string or sequence");
2397 if !Self::is_strict_string_or_sequence(&field) {
2398 unmodeled_fields.push(field.reference());
2399 }
2400 }
2401 "project_directory" => {
2402 project_directory =
2403 self.parse_extends_string(&field, "include project_directory must be a YAML string scalar");
2404 if project_directory.is_none() {
2405 unmodeled_fields.push(field.reference());
2406 }
2407 }
2408 _ => unmodeled_fields.push(field.reference()),
2409 }
2410 }
2411 if paths.is_empty() {
2412 self.diagnostics.push(
2413 Diagnostic::new(EXPECTED_FIELD_FORM, Severity::Error, "include mapping requires path")
2414 .with_label(DiagnosticLabel::primary(span, "path is missing")),
2415 );
2416 }
2417 IncludeLong::new(span, paths, env_files, project_directory, unmodeled_fields)
2418 }
2419
2420 fn string_or_sequence(&mut self, field: &ParsedField, message: &str) -> Vec<Located<String>> {
2421 if field.value.as_ref().and_then(YamlNode::as_scalar).is_some() {
2422 self.parse_extends_string(field, message).into_iter().collect()
2423 } else {
2424 self.parse_strict_string_sequence(field, message).0
2425 }
2426 }
2427
2428 fn is_strict_string_or_sequence(field: &ParsedField) -> bool {
2429 match field.value.as_ref() {
2430 Some(YamlNode::Scalar(scalar)) => scalar_type_for_value(scalar) == ScalarType::String,
2431 Some(YamlNode::Sequence(sequence)) => sequence.values().all(|item| {
2432 item.as_scalar()
2433 .is_some_and(|scalar| scalar_type_for_value(scalar) == ScalarType::String)
2434 }),
2435 _ => false,
2436 }
2437 }
2438
2439 fn parse_label_files(&mut self, field: &ParsedField) -> Option<LabelFiles> {
2440 let value = field.value.as_ref()?;
2441 if let Some(scalar) = value.as_scalar() {
2442 if scalar_type_for_value(scalar) != ScalarType::String {
2443 self.expected(
2444 EXPECTED_SCALAR,
2445 field,
2446 "service label_file must be a YAML string scalar or sequence",
2447 );
2448 return None;
2449 }
2450 let span = span_from_position(self.source_id, scalar.byte_range());
2451 return Some(LabelFiles::new(
2452 span,
2453 LabelFilesForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
2454 Vec::new(),
2455 ));
2456 }
2457 let Some(sequence) = value.as_sequence() else {
2458 self.expected(
2459 EXPECTED_FIELD_FORM,
2460 field,
2461 "service label_file must be a YAML string scalar or sequence",
2462 );
2463 return None;
2464 };
2465 let span = span_from_position(self.source_id, sequence.byte_range());
2466 let mut paths = Vec::new();
2467 let mut unmodeled_items = Vec::new();
2468 for item in sequence.values() {
2469 let item_span = node_span(self.source_id, &item).unwrap_or(field.span);
2470 let Some(scalar) = item.as_scalar() else {
2471 self.unsupported_sequence_item(
2472 EXPECTED_SCALAR,
2473 &item,
2474 field.span,
2475 "service label_file entries must be YAML string scalars",
2476 );
2477 unmodeled_items.push(item_span);
2478 continue;
2479 };
2480 if scalar_type_for_value(scalar) != ScalarType::String {
2481 self.unsupported_sequence_item(
2482 EXPECTED_SCALAR,
2483 &item,
2484 field.span,
2485 "service label_file entries must be YAML string scalars",
2486 );
2487 unmodeled_items.push(item_span);
2488 continue;
2489 }
2490 paths.push(Located::new(scalar_string_from_source(&self.source, scalar), item_span));
2491 }
2492 Some(LabelFiles::new(span, LabelFilesForm::List(paths), unmodeled_items))
2493 }
2494
2495 fn parse_model_definitions(&mut self, field: &ParsedField) -> Option<ModelDefinitions> {
2496 let mapping = field.value.as_ref().and_then(YamlNode::as_mapping)?;
2497 let span = span_from_position(self.source_id, mapping.byte_range());
2498 let mut definitions = Vec::new();
2499 let mut unmodeled_fields = Vec::new();
2500 let mut seen = BTreeMap::new();
2501 for definition_field in self.fields(mapping) {
2502 if self.record_duplicate(&mut seen, &definition_field) {
2503 unmodeled_fields.push(definition_field.reference());
2504 continue;
2505 }
2506 let Some(definition_mapping) = definition_field.value.as_ref().and_then(YamlNode::as_mapping) else {
2507 self.expected(
2508 EXPECTED_MAPPING,
2509 &definition_field,
2510 "model definition must be a mapping",
2511 );
2512 unmodeled_fields.push(definition_field.reference());
2513 continue;
2514 };
2515 let mut definition = ModelDefinition::new(definition_field.name.clone(), definition_field.span);
2516 let mut member_seen = BTreeMap::new();
2517 for member in self.fields(definition_mapping) {
2518 if self.record_duplicate(&mut member_seen, &member) {
2519 definition.push_unmodeled(member.reference());
2520 continue;
2521 }
2522 match member.name.value().as_str() {
2523 "name" => {
2524 let parsed = self.parse_extends_string(&member, "model name must be a YAML string scalar");
2525 if let Some(value) = parsed {
2526 definition.set_name(value);
2527 } else {
2528 definition.push_unmodeled(member.reference());
2529 }
2530 }
2531 "model" => {
2532 let parsed = self.parse_extends_string(&member, "model reference must be a YAML string scalar");
2533 if let Some(value) = parsed {
2534 definition.set_model(value);
2535 } else {
2536 definition.push_unmodeled(member.reference());
2537 }
2538 }
2539 "context_size" => {
2540 let parsed = self.parse_integer_scalar(&member, "model context_size must be a YAML integer");
2541 if let Some(value) = parsed {
2542 definition.set_context_size(value);
2543 } else {
2544 definition.push_unmodeled(member.reference());
2545 }
2546 }
2547 "runtime_flags" => {
2548 let flags =
2549 self.string_or_sequence(&member, "model runtime_flags must be a YAML string or sequence");
2550 if !Self::is_strict_string_or_sequence(&member) {
2551 definition.push_unmodeled(member.reference());
2552 }
2553 definition.set_runtime_flags(flags);
2554 }
2555 _ => definition.push_unmodeled(member.reference()),
2556 }
2557 }
2558 if definition.model().is_none() {
2559 self.diagnostics.push(
2560 Diagnostic::new(EXPECTED_FIELD_FORM, Severity::Error, "model definition requires model")
2561 .with_label(DiagnosticLabel::primary(definition.span(), "model is missing")),
2562 );
2563 }
2564 definitions.push(definition);
2565 }
2566 Some(ModelDefinitions::new(span, definitions, unmodeled_fields))
2567 }
2568
2569 fn parse_service_models(&mut self, field: &ParsedField) -> Option<ServiceModels> {
2570 let span = field
2571 .value
2572 .as_ref()
2573 .and_then(|value| node_span(self.source_id, value))
2574 .unwrap_or(field.span);
2575 let mut bindings = Vec::new();
2576 let mut unmodeled_fields = Vec::new();
2577 match field.value.as_ref()? {
2578 YamlNode::Sequence(sequence) => {
2579 for node in sequence.values() {
2580 let Some(scalar) = node.as_scalar() else {
2581 self.unsupported_sequence_item(
2582 EXPECTED_SCALAR,
2583 &node,
2584 field.span,
2585 "service model bindings must be strings",
2586 );
2587 unmodeled_fields.push(field.reference());
2588 continue;
2589 };
2590 if scalar_type_for_value(scalar) != ScalarType::String {
2591 self.unsupported_sequence_item(
2592 EXPECTED_SCALAR,
2593 &node,
2594 field.span,
2595 "service model bindings must be YAML strings",
2596 );
2597 unmodeled_fields.push(field.reference());
2598 continue;
2599 }
2600 bindings.push(ServiceModelBinding::new(
2601 Located::new(
2602 scalar_string_from_source(&self.source, scalar),
2603 span_from_position(self.source_id, scalar.byte_range()),
2604 ),
2605 span_from_position(self.source_id, scalar.byte_range()),
2606 ));
2607 }
2608 }
2609 YamlNode::Mapping(mapping) => {
2610 for binding_field in self.fields(mapping) {
2611 let mut binding = ServiceModelBinding::new(binding_field.name.clone(), binding_field.span);
2612 match binding_field.value.as_ref() {
2613 None => {}
2614 Some(YamlNode::Scalar(scalar)) if scalar_type_for_value(scalar) == ScalarType::Null => {}
2615 Some(YamlNode::Mapping(value)) => {
2616 let mut seen = BTreeMap::new();
2617 for member in self.fields(value) {
2618 if self.record_duplicate(&mut seen, &member) {
2619 continue;
2620 }
2621 match member.name.value().as_str() {
2622 "endpoint_var" => self
2623 .parse_extends_string(
2624 &member,
2625 "model endpoint_var must be a YAML string scalar",
2626 )
2627 .into_iter()
2628 .for_each(|value| binding.set_endpoint_var(value)),
2629 "model_var" => self
2630 .parse_extends_string(&member, "model model_var must be a YAML string scalar")
2631 .into_iter()
2632 .for_each(|value| binding.set_model_var(value)),
2633 _ => binding.push_unmodeled(member.reference()),
2634 }
2635 }
2636 }
2637 Some(_) => {
2638 self.expected(
2639 EXPECTED_FIELD_FORM,
2640 &binding_field,
2641 "service model binding must be null or a mapping",
2642 );
2643 binding.push_unmodeled(binding_field.reference());
2644 }
2645 }
2646 bindings.push(binding);
2647 }
2648 }
2649 _ => return None,
2650 }
2651 Some(ServiceModels::new(span, bindings, unmodeled_fields))
2652 }
2653
2654 #[expect(
2655 clippy::too_many_lines,
2656 reason = "GPU selectors retain supported members and every rejected source member"
2657 )]
2658 fn parse_gpus(&mut self, field: &ParsedField) -> Option<Gpus> {
2659 match field.value.as_ref()? {
2660 YamlNode::Scalar(_) => self
2661 .parse_extends_string(field, "service gpus must be a YAML string scalar or sequence")
2662 .and_then(|value| {
2663 if value.value() == "all" {
2664 Some(Gpus::All(value))
2665 } else {
2666 self.expected(EXPECTED_FIELD_FORM, field, "service gpus scalar must be exactly `all`");
2667 None
2668 }
2669 }),
2670 YamlNode::Sequence(sequence) => {
2671 let span = span_from_position(self.source_id, sequence.byte_range());
2672 let mut devices = Vec::new();
2673 let mut unmodeled_items = Vec::new();
2674 for node in sequence.values() {
2675 let Some(mapping) = node.as_mapping() else {
2676 self.unsupported_sequence_item(
2677 EXPECTED_MAPPING,
2678 &node,
2679 field.span,
2680 "GPU selector must be a mapping",
2681 );
2682 unmodeled_items.push(node_span(self.source_id, &node).unwrap_or(field.span));
2683 continue;
2684 };
2685 let mut device = GpuDevice::new(node_span(self.source_id, &node).unwrap_or(field.span));
2686 let mut seen = BTreeMap::new();
2687 for member in self.fields(mapping) {
2688 if self.record_duplicate(&mut seen, &member) {
2689 continue;
2690 }
2691 match member.name.value().as_str() {
2692 "capabilities" => {
2693 let (values, invalid_items) =
2694 self.parse_strict_string_sequence(&member, "GPU capabilities");
2695 if !matches!(member.value.as_ref(), Some(YamlNode::Sequence(_)))
2696 || !invalid_items.is_empty()
2697 {
2698 device.push_unmodeled(member.reference());
2699 }
2700 device.set_capabilities(values);
2701 }
2702 "count" => {
2703 let parsed = self.parse_integer_or_string_scalar(
2704 &member,
2705 "GPU count must be a YAML integer or string",
2706 );
2707 if let Some(value) = parsed {
2708 device.set_count(value);
2709 } else {
2710 device.push_unmodeled(member.reference());
2711 }
2712 }
2713 "device_ids" => {
2714 let values = self
2715 .string_or_sequence(&member, "GPU device_ids must be a YAML string or sequence");
2716 if !Self::is_strict_string_or_sequence(&member) {
2717 device.push_unmodeled(member.reference());
2718 }
2719 device.set_device_ids(values);
2720 }
2721 "driver" => {
2722 let parsed =
2723 self.parse_extends_string(&member, "GPU driver must be a YAML string scalar");
2724 if let Some(value) = parsed {
2725 device.set_driver(value);
2726 } else {
2727 device.push_unmodeled(member.reference());
2728 }
2729 }
2730 "options" => match member.value.as_ref() {
2731 Some(YamlNode::Mapping(_)) => {
2732 device.set_options(GpuOptions::Mapping(
2733 self.parse_scalar_mapping(&member, "GPU options"),
2734 ));
2735 }
2736 Some(YamlNode::Sequence(_)) => {
2737 let options = self.parse_string_sequence(&member, "GPU options");
2738 if !Self::is_strict_string_or_sequence(&member) {
2739 device.push_unmodeled(member.reference());
2740 }
2741 device.set_options(GpuOptions::List(options));
2742 }
2743 _ => {
2744 self.expected(
2745 EXPECTED_FIELD_FORM,
2746 &member,
2747 "GPU options must be a mapping or sequence",
2748 );
2749 device.push_unmodeled(member.reference());
2750 }
2751 },
2752 _ => device.push_unmodeled(member.reference()),
2753 }
2754 }
2755 if device.count().is_some() && !device.device_ids().is_empty() {
2756 self.diagnostics.push(
2757 Diagnostic::new(
2758 GPU_COUNT_DEVICE_IDS_CONFLICT,
2759 Severity::Warning,
2760 "GPU selector cannot use count and device_ids together",
2761 )
2762 .with_label(DiagnosticLabel::primary(
2763 device.span(),
2764 "conflicting GPU allocation selectors",
2765 )),
2766 );
2767 }
2768 if !gpu_has_capabilities(&device) {
2769 self.diagnostics.push(
2770 Diagnostic::new(
2771 GPU_MISSING_CAPABILITIES,
2772 Severity::Error,
2773 "GPU selector requires a non-empty capabilities declaration",
2774 )
2775 .with_label(DiagnosticLabel::primary(
2776 device.span(),
2777 "capabilities are missing, empty, or malformed",
2778 )),
2779 );
2780 }
2781 devices.push(device);
2782 }
2783 Some(Gpus::Devices {
2784 span,
2785 devices,
2786 unmodeled_items,
2787 })
2788 }
2789 _ => None,
2790 }
2791 }
2792
2793 #[expect(
2794 clippy::too_many_lines,
2795 reason = "develop watch retains supported members and every rejected source member"
2796 )]
2797 fn parse_develop(&mut self, field: &ParsedField) -> Option<Develop> {
2798 let mapping = field.value.as_ref().and_then(YamlNode::as_mapping)?;
2799 let span = span_from_position(self.source_id, mapping.byte_range());
2800 let mut watch = Vec::new();
2801 let mut unmodeled = Vec::new();
2802 let mut unmodeled_items = Vec::new();
2803 let mut seen = BTreeMap::new();
2804 for member in self.fields(mapping) {
2805 if self.record_duplicate(&mut seen, &member) {
2806 unmodeled.push(member.reference());
2807 continue;
2808 }
2809 if member.name.value() != "watch" {
2810 unmodeled.push(member.reference());
2811 continue;
2812 }
2813 let Some(sequence) = member.value.as_ref().and_then(YamlNode::as_sequence) else {
2814 self.expected(EXPECTED_SEQUENCE, &member, "develop watch must be a sequence");
2815 unmodeled.push(member.reference());
2816 continue;
2817 };
2818 for node in sequence.values() {
2819 let Some(watch_mapping) = node.as_mapping() else {
2820 self.unsupported_sequence_item(
2821 EXPECTED_MAPPING,
2822 &node,
2823 member.span,
2824 "develop watch items must be mappings",
2825 );
2826 unmodeled_items.push(node_span(self.source_id, &node).unwrap_or(member.span));
2827 continue;
2828 };
2829 let mut item = DevelopWatch::new(node_span(self.source_id, &node).unwrap_or(member.span));
2830 let mut item_seen = BTreeMap::new();
2831 for watch_member in self.fields(watch_mapping) {
2832 if self.record_duplicate(&mut item_seen, &watch_member) {
2833 item.push_unmodeled(watch_member.reference());
2834 continue;
2835 }
2836 match watch_member.name.value().as_str() {
2837 "action" => {
2838 let parsed = self.parse_extends_string(
2839 &watch_member,
2840 "develop watch action must be a YAML string scalar",
2841 );
2842 if let Some(value) = parsed {
2843 item.set_action(value);
2844 } else {
2845 item.push_unmodeled(watch_member.reference());
2846 }
2847 }
2848 "path" => {
2849 let parsed = self
2850 .parse_extends_string(&watch_member, "develop watch path must be a YAML string scalar");
2851 if let Some(value) = parsed {
2852 item.set_path(value);
2853 } else {
2854 item.push_unmodeled(watch_member.reference());
2855 }
2856 }
2857 "target" => {
2858 let parsed = self.parse_extends_string(
2859 &watch_member,
2860 "develop watch target must be a YAML string scalar",
2861 );
2862 if let Some(value) = parsed {
2863 item.set_target(value);
2864 } else {
2865 item.push_unmodeled(watch_member.reference());
2866 }
2867 }
2868 "ignore" => {
2869 let values = self.string_or_sequence(
2870 &watch_member,
2871 "develop watch ignore must be a YAML string or sequence",
2872 );
2873 if !Self::is_strict_string_or_sequence(&watch_member) {
2874 item.push_unmodeled(watch_member.reference());
2875 }
2876 item.set_ignore(values);
2877 }
2878 "include" => {
2879 let values = self.string_or_sequence(
2880 &watch_member,
2881 "develop watch include must be a YAML string or sequence",
2882 );
2883 if !Self::is_strict_string_or_sequence(&watch_member) {
2884 item.push_unmodeled(watch_member.reference());
2885 }
2886 item.set_include(values);
2887 }
2888 "initial_sync" => {
2889 let parsed =
2890 self.parse_boolean(&watch_member, "develop watch initial_sync must be a boolean");
2891 if let Some(value) = parsed {
2892 item.set_initial_sync(value);
2893 } else {
2894 item.push_unmodeled(watch_member.reference());
2895 }
2896 }
2897 "exec" if matches!(watch_member.value.as_ref(), Some(YamlNode::Mapping(_))) => {
2898 let exec = self.parse_develop_exec(&watch_member);
2899 if !exec.command().is_some_and(command_is_non_empty) {
2900 self.diagnostics.push(
2901 Diagnostic::new(
2902 DEVELOP_WATCH_EXEC_MISSING_COMMAND,
2903 Severity::Error,
2904 "develop watch exec mapping requires a non-empty command member",
2905 )
2906 .with_label(DiagnosticLabel::primary(watch_member.span, "exec command is missing")),
2907 );
2908 }
2909 item.set_exec(exec);
2910 }
2911 "exec" => {
2912 self.expected(EXPECTED_MAPPING, &watch_member, "develop watch exec must be a mapping");
2913 item.push_unmodeled(watch_member.reference());
2914 }
2915 _ => item.push_unmodeled(watch_member.reference()),
2916 }
2917 }
2918 self.validate_develop_watch(&item);
2919 watch.push(item);
2920 }
2921 }
2922 if watch.is_empty() {
2923 self.diagnostics.push(
2924 Diagnostic::new(
2925 DEVELOP_MISSING_WATCH,
2926 Severity::Error,
2927 "develop requires a watch sequence",
2928 )
2929 .with_label(DiagnosticLabel::primary(span, "watch is missing")),
2930 );
2931 }
2932 Some(Develop::new(span, watch, unmodeled, unmodeled_items))
2933 }
2934
2935 fn validate_develop_watch(&mut self, item: &DevelopWatch) {
2936 if item.action().is_none() {
2937 self.diagnostics.push(
2938 Diagnostic::new(
2939 DEVELOP_WATCH_MISSING_ACTION,
2940 Severity::Error,
2941 "develop watch item requires an action",
2942 )
2943 .with_label(DiagnosticLabel::primary(item.span(), "action is missing")),
2944 );
2945 }
2946 if item.path().is_none() {
2947 self.diagnostics.push(
2948 Diagnostic::new(
2949 DEVELOP_WATCH_MISSING_PATH,
2950 Severity::Error,
2951 "develop watch item requires a path",
2952 )
2953 .with_label(DiagnosticLabel::primary(item.span(), "path is missing")),
2954 );
2955 }
2956 let Some(action) = item.action() else {
2957 return;
2958 };
2959 let action_value = action.value();
2960 let synchronizes = matches!(action_value.as_str(), "sync" | "sync+restart" | "sync+exec");
2961 if !matches!(
2962 action_value.as_str(),
2963 "rebuild" | "sync" | "restart" | "sync+restart" | "sync+exec"
2964 ) {
2965 self.diagnostics.push(
2966 Diagnostic::new(
2967 DEVELOP_WATCH_INVALID_ACTION,
2968 Severity::Error,
2969 "develop watch action is not one of rebuild, sync, restart, sync+restart, or sync+exec",
2970 )
2971 .with_label(DiagnosticLabel::primary(action.span(), "unknown watch action")),
2972 );
2973 }
2974 if synchronizes && item.target().is_none() {
2975 self.diagnostics.push(
2976 Diagnostic::new(
2977 DEVELOP_WATCH_MISSING_TARGET,
2978 Severity::Error,
2979 "synchronizing develop watch action requires a target",
2980 )
2981 .with_label(DiagnosticLabel::primary(item.span(), "target is missing")),
2982 );
2983 }
2984 if action_value == "sync+exec" && item.exec().is_none() {
2985 self.diagnostics.push(
2986 Diagnostic::new(
2987 DEVELOP_WATCH_MISSING_EXEC,
2988 Severity::Error,
2989 "sync+exec develop watch action requires an exec command mapping",
2990 )
2991 .with_label(DiagnosticLabel::primary(item.span(), "exec is missing")),
2992 );
2993 }
2994 }
2995
2996 fn parse_develop_exec(&mut self, field: &ParsedField) -> DevelopWatchExec {
2997 let Some(YamlNode::Mapping(mapping)) = field.value.as_ref() else {
2998 unreachable!("caller checks the mapping form");
2999 };
3000 let mut exec = DevelopWatchExec::new(field.span);
3001 let mut seen = BTreeMap::new();
3002 for member in self.fields(mapping) {
3003 if self.record_duplicate(&mut seen, &member) {
3004 exec.push_unmodeled(member.reference());
3005 continue;
3006 }
3007 match member.name.value().as_str() {
3008 "command" => match self.parse_command(&member) {
3009 Some(value) => exec.set_command(value),
3010 None => exec.push_unmodeled(member.reference()),
3011 },
3012 "user" => match self.parse_extends_string(&member, "develop exec user must be a YAML string scalar") {
3013 Some(value) => exec.set_user(value),
3014 None => exec.push_unmodeled(member.reference()),
3015 },
3016 "privileged" => match self.parse_boolean(&member, "develop exec privileged must be a boolean") {
3017 Some(value) => exec.set_privileged(value),
3018 None => exec.push_unmodeled(member.reference()),
3019 },
3020 "working_dir" => {
3021 match self.parse_extends_string(&member, "develop exec working_dir must be a YAML string scalar") {
3022 Some(value) => exec.set_working_dir(value),
3023 None => exec.push_unmodeled(member.reference()),
3024 }
3025 }
3026 "environment" => match self.parse_environment(&member) {
3027 Some(value) => exec.set_environment(value),
3028 None => exec.push_unmodeled(member.reference()),
3029 },
3030 _ => exec.push_unmodeled(member.reference()),
3031 }
3032 }
3033 exec
3034 }
3035
3036 fn parse_service_read_only(&mut self, field: &ParsedField) -> Option<Located<BooleanValue>> {
3037 self.parse_boolean(field, "service read_only")
3038 }
3039
3040 fn parse_service_group_add(&mut self, field: &ParsedField) -> Vec<Located<String>> {
3041 self.parse_string_sequence(field, "service supplementary groups")
3042 }
3043
3044 fn set_service_count(&mut self, service: &mut Service, field: &ParsedField) {
3045 match field.name.value().as_str() {
3046 "cpu_count" => {
3047 service.cpu_count = self.parse_cpu_count(field);
3048 if service.cpu_count.is_none() {
3049 service.unknown_fields.push(field.reference());
3050 }
3051 }
3052 "cpu_percent" => {
3053 service.cpu_percent = self.parse_cpu_percent(field);
3054 if service.cpu_percent.is_none() {
3055 service.unknown_fields.push(field.reference());
3056 }
3057 }
3058 "cpu_period" => {
3059 service.cpu_period = self.parse_cpu_period(field);
3060 if service.cpu_period.is_none() {
3061 service.unknown_fields.push(field.reference());
3062 }
3063 }
3064 "cpu_quota" => {
3065 service.cpu_quota = self.parse_cpu_quota(field);
3066 if service.cpu_quota.is_none() {
3067 service.unknown_fields.push(field.reference());
3068 }
3069 }
3070 "cpu_rt_period" => {
3071 service.cpu_rt_period = self.parse_cpu_rt_period(field);
3072 if service.cpu_rt_period.is_none() {
3073 service.unknown_fields.push(field.reference());
3074 }
3075 }
3076 "cpu_rt_runtime" => {
3077 service.cpu_rt_runtime = self.parse_cpu_rt_runtime(field);
3078 if service.cpu_rt_runtime.is_none() {
3079 service.unknown_fields.push(field.reference());
3080 }
3081 }
3082 "cpu_shares" => {
3083 service.cpu_shares = self.parse_service_integer(field, 0, i128::MAX, "cpu_shares");
3084 if service.cpu_shares.is_none() {
3085 service.unknown_fields.push(field.reference());
3086 }
3087 }
3088 "cpus" => {
3089 service.cpus = self.parse_cpus(field);
3090 if service.cpus.is_none() {
3091 service.unknown_fields.push(field.reference());
3092 }
3093 }
3094 "mem_swappiness" => {
3095 service.mem_swappiness = self.parse_service_integer(field, 0, 100, "mem_swappiness");
3096 if service.mem_swappiness.is_none() {
3097 service.unknown_fields.push(field.reference());
3098 }
3099 }
3100 "oom_score_adj" => {
3101 service.oom_score_adj = self.parse_service_integer(field, -1000, 1000, "oom_score_adj");
3102 if service.oom_score_adj.is_none() {
3103 service.unknown_fields.push(field.reference());
3104 }
3105 }
3106 "scale" => {
3107 service.scale = self.parse_service_integer(field, 0, i128::MAX, "scale");
3108 if service.scale.is_none() {
3109 service.unknown_fields.push(field.reference());
3110 }
3111 }
3112 "pids_limit" => service.pids_limit = self.parse_pids_limit(field),
3113 _ => {}
3114 }
3115 }
3116
3117 fn set_service_runtime(&mut self, service: &mut Service, field: &ParsedField) {
3118 service.runtime = self.parse_extends_string(field, "service runtime must be a YAML string scalar");
3119 if service.runtime.is_none() {
3120 service.unknown_fields.push(field.reference());
3121 }
3122 }
3123
3124 fn set_service_pull_refresh_after(&mut self, service: &mut Service, field: &ParsedField) {
3125 service.pull_refresh_after =
3126 self.parse_extends_string(field, "service pull_refresh_after must be a YAML string scalar");
3127 if service.pull_refresh_after.is_none() {
3128 service.unknown_fields.push(field.reference());
3129 }
3130 }
3131
3132 fn set_service_platform(&mut self, service: &mut Service, field: &ParsedField) {
3133 service.platform = self.parse_extends_string(field, "service platform must be a YAML string scalar");
3134 if service.platform.is_none() {
3135 service.unknown_fields.push(field.reference());
3136 }
3137 }
3138
3139 fn set_service_attach(&mut self, service: &mut Service, field: &ParsedField) {
3140 service.attach = self.parse_boolean(field, "service attach");
3141 if service.attach.is_none() {
3142 service.unknown_fields.push(field.reference());
3143 }
3144 }
3145
3146 fn set_service_boolean(&mut self, service: &mut Service, field: &ParsedField) {
3147 match field.name.value().as_str() {
3148 "attach" => self.set_service_attach(service, field),
3149 "init" => service.init = self.parse_boolean(field, "service init"),
3150 "stdin_open" => service.stdin_open = self.parse_boolean(field, "stdin_open"),
3151 "tty" => service.tty = self.parse_boolean(field, "tty"),
3152 "privileged" => service.privileged = self.parse_boolean(field, "privileged"),
3153 "use_api_socket" => service.use_api_socket = self.parse_boolean(field, "use_api_socket"),
3154 _ => {}
3155 }
3156 }
3157
3158 fn set_service_blkio_config(&mut self, service: &mut Service, field: &ParsedField) {
3159 service.blkio_config = self.parse_blkio_config(field);
3160 if service.blkio_config.is_none() {
3161 service.unknown_fields.push(field.reference());
3162 }
3163 }
3164
3165 fn set_service_cgroup(&mut self, service: &mut Service, field: &ParsedField) {
3166 match field.name.value().as_str() {
3167 "cgroup" => {
3168 service.cgroup = self.parse_cgroup_namespace(field);
3169 if service.cgroup.is_none() {
3170 service.unknown_fields.push(field.reference());
3171 }
3172 }
3173 "cgroup_parent" => {
3174 service.cgroup_parent =
3175 self.parse_extends_string(field, "service cgroup_parent must be a YAML string scalar");
3176 if service.cgroup_parent.is_none() {
3177 service.unknown_fields.push(field.reference());
3178 }
3179 }
3180 _ => {}
3181 }
3182 }
3183
3184 fn parse_cgroup_namespace(&mut self, field: &ParsedField) -> Option<CgroupNamespace> {
3185 let raw = self.parse_extends_string(field, "service cgroup must be a YAML string scalar")?;
3186 let cgroup = CgroupNamespace::parse(raw);
3187 if !cgroup.is_valid() {
3188 self.diagnostics.push(
3189 Diagnostic::new(
3190 CGROUP_NAMESPACE_INVALID,
3191 Severity::Warning,
3192 "service cgroup must be `host`, `private`, or a deferred expression",
3193 )
3194 .with_label(DiagnosticLabel::primary(
3195 cgroup.raw().span(),
3196 "retained unsupported cgroup namespace",
3197 )),
3198 );
3199 }
3200 Some(cgroup)
3201 }
3202
3203 fn parse_blkio_config(&mut self, field: &ParsedField) -> Option<BlkioConfig> {
3204 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3205 self.expected(EXPECTED_MAPPING, field, "blkio_config must be a mapping");
3206 return None;
3207 };
3208 let mut config = BlkioConfig::new(span_from_position(self.source_id, mapping.byte_range()));
3209 let mut seen = BTreeMap::new();
3210 for option in self.fields(mapping) {
3211 if self.record_duplicate(&mut seen, &option) {
3212 continue;
3213 }
3214 match option.name.value().as_str() {
3215 name if name.starts_with("x-") => config.push_extension(option.reference()),
3216 "weight" => match self.parse_blkio_scalar(&option) {
3217 Some(value) => config.set_weight(value),
3218 None => config.push_unknown(option.reference()),
3219 },
3220 "device_read_bps" | "device_read_iops" | "device_write_bps" | "device_write_iops" => {
3221 if let Some(values) = self.parse_blkio_device_rates(&option) {
3222 if let Some(items) = config.device_rates_mut(option.name.value()) {
3223 items.extend(values);
3224 }
3225 } else {
3226 config.push_unknown(option.reference());
3227 }
3228 }
3229 "weight_device" => {
3230 if let Some(values) = self.parse_blkio_weight_devices(&option) {
3231 config.weight_devices_mut().extend(values);
3232 } else {
3233 config.push_unknown(option.reference());
3234 }
3235 }
3236 _ => config.push_unknown(option.reference()),
3237 }
3238 }
3239 Some(config)
3240 }
3241
3242 fn parse_blkio_scalar(&mut self, field: &ParsedField) -> Option<Located<BlkioScalar>> {
3243 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3244 self.expected(
3245 EXPECTED_SCALAR,
3246 field,
3247 "blkio value must be a YAML integer or string scalar",
3248 );
3249 return None;
3250 };
3251 let value = match scalar_type_for_value(scalar) {
3252 ScalarType::Integer => BlkioScalar::YamlInteger(scalar_string_from_source(&self.source, scalar)),
3253 ScalarType::String => BlkioScalar::String(scalar_string_from_source(&self.source, scalar)),
3254 _ => {
3255 self.expected(
3256 EXPECTED_SCALAR,
3257 field,
3258 "blkio value must be a YAML integer or string scalar",
3259 );
3260 return None;
3261 }
3262 };
3263 Some(Located::new(
3264 value,
3265 span_from_position(self.source_id, scalar.byte_range()),
3266 ))
3267 }
3268
3269 fn parse_blkio_device_rates(&mut self, field: &ParsedField) -> Option<Vec<BlkioDeviceRate>> {
3270 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3271 self.expected(EXPECTED_SEQUENCE, field, "blkio device rates must be sequences");
3272 return None;
3273 };
3274 let mut items = Vec::new();
3275 for node in sequence.values() {
3276 let Some(mapping) = node.as_mapping() else {
3277 self.unsupported_sequence_item(
3278 EXPECTED_MAPPING,
3279 &node,
3280 field.span,
3281 "blkio device rate entries must be mappings",
3282 );
3283 items.push(BlkioDeviceRate::unmodeled(
3284 node_span(self.source_id, &node).unwrap_or(field.span),
3285 ));
3286 continue;
3287 };
3288 let mut item = BlkioDeviceRate::new(span_from_position(self.source_id, mapping.byte_range()));
3289 let mut seen = BTreeMap::new();
3290 for option in self.fields(mapping) {
3291 if self.record_duplicate(&mut seen, &option) {
3292 continue;
3293 }
3294 match option.name.value().as_str() {
3295 name if name.starts_with("x-") => item.push_extension(option.reference()),
3296 "path" => match self.parse_string(&option, "blkio device path") {
3297 Some(value) => item.set_path(value),
3298 None => item.push_unknown(option.reference()),
3299 },
3300 "rate" => match self.parse_blkio_scalar(&option) {
3301 Some(value) => item.set_rate(value),
3302 None => item.push_unknown(option.reference()),
3303 },
3304 _ => item.push_unknown(option.reference()),
3305 }
3306 }
3307 items.push(item);
3308 }
3309 Some(items)
3310 }
3311
3312 fn parse_blkio_weight_devices(&mut self, field: &ParsedField) -> Option<Vec<BlkioWeightDevice>> {
3313 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3314 self.expected(EXPECTED_SEQUENCE, field, "blkio weight devices must be sequences");
3315 return None;
3316 };
3317 let mut items = Vec::new();
3318 for node in sequence.values() {
3319 let Some(mapping) = node.as_mapping() else {
3320 self.unsupported_sequence_item(
3321 EXPECTED_MAPPING,
3322 &node,
3323 field.span,
3324 "blkio weight device entries must be mappings",
3325 );
3326 items.push(BlkioWeightDevice::unmodeled(
3327 node_span(self.source_id, &node).unwrap_or(field.span),
3328 ));
3329 continue;
3330 };
3331 let mut item = BlkioWeightDevice::new(span_from_position(self.source_id, mapping.byte_range()));
3332 let mut seen = BTreeMap::new();
3333 for option in self.fields(mapping) {
3334 if self.record_duplicate(&mut seen, &option) {
3335 continue;
3336 }
3337 match option.name.value().as_str() {
3338 name if name.starts_with("x-") => item.push_extension(option.reference()),
3339 "path" => match self.parse_string(&option, "blkio weight device path") {
3340 Some(value) => item.set_path(value),
3341 None => item.push_unknown(option.reference()),
3342 },
3343 "weight" => match self.parse_blkio_scalar(&option) {
3344 Some(value) => item.set_weight(value),
3345 None => item.push_unknown(option.reference()),
3346 },
3347 _ => item.push_unknown(option.reference()),
3348 }
3349 }
3350 items.push(item);
3351 }
3352 Some(items)
3353 }
3354
3355 fn parse_hostname(&mut self, field: &ParsedField) -> Option<Hostname> {
3356 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3357 self.expected(HOSTNAME_EXPECTED_STRING, field, "hostname must be a YAML string scalar");
3358 return None;
3359 };
3360 if scalar_type_for_value(scalar) != ScalarType::String {
3361 self.expected(HOSTNAME_EXPECTED_STRING, field, "hostname must be a YAML string scalar");
3362 return None;
3363 }
3364 let span = span_from_position(self.source_id, scalar.byte_range());
3365 let hostname = Hostname::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
3366 if hostname.kind() == &HostnameKind::Invalid {
3367 self.diagnostics.push(
3368 Diagnostic::new(
3369 HOSTNAME_INVALID,
3370 Severity::Error,
3371 "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",
3372 )
3373 .with_label(DiagnosticLabel::primary(span, "invalid service hostname"))
3374 .with_note("each label must start and end with an ASCII letter or digit"),
3375 );
3376 }
3377 Some(hostname)
3378 }
3379
3380 fn parse_image(&mut self, field: &ParsedField) -> Option<Located<ImageReference>> {
3381 self.parse_string(field, "service image")
3382 .map(|value| Located::new(ImageReference::parse(value.value), value.span))
3383 }
3384
3385 fn parse_cap_drop(&mut self, field: &ParsedField) -> Option<CapabilityDrop> {
3386 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3387 self.expected(
3388 CAP_DROP_EXPECTED_SEQUENCE,
3389 field,
3390 "cap_drop must be a sequence of string scalars",
3391 );
3392 return None;
3393 };
3394 let span = span_from_position(self.source_id, sequence.byte_range());
3395 let mut items = Vec::new();
3396 let mut seen = BTreeMap::new();
3397 for node in sequence.values() {
3398 let YamlNode::Scalar(scalar) = node else {
3399 self.unsupported_sequence_item(
3400 CAP_DROP_EXPECTED_STRING,
3401 &node,
3402 field.span,
3403 "cap_drop entries must be string scalars",
3404 );
3405 continue;
3406 };
3407 let scalar_type = scalar_type_for_value(&scalar);
3408 if !matches!(
3409 scalar_type,
3410 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3411 ) {
3412 self.unsupported_sequence_item(
3413 CAP_DROP_EXPECTED_STRING,
3414 &YamlNode::Scalar(scalar),
3415 field.span,
3416 "cap_drop entries must be string scalars",
3417 );
3418 continue;
3419 }
3420 let item_span = span_from_position(self.source_id, scalar.byte_range());
3421 let value = scalar_string_from_source(&self.source, &scalar);
3422 if let Some(first) = seen.get(&value) {
3423 self.diagnostics.push(
3424 Diagnostic::new(
3425 CAP_DROP_DUPLICATE_ITEM,
3426 Severity::Error,
3427 "cap_drop entries must be unique exact strings",
3428 )
3429 .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
3430 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
3431 );
3432 } else {
3433 seen.insert(value.clone(), item_span);
3434 }
3435 items.push(CapabilityDropItem::new(Located::new(value, item_span)));
3436 }
3437 Some(CapabilityDrop::new(span, items))
3438 }
3439
3440 fn parse_cap_add(&mut self, field: &ParsedField) -> Option<CapabilityAdd> {
3441 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3442 self.expected(
3443 CAP_ADD_EXPECTED_SEQUENCE,
3444 field,
3445 "cap_add must be a sequence of string scalars",
3446 );
3447 return None;
3448 };
3449 let span = span_from_position(self.source_id, sequence.byte_range());
3450 let mut items = Vec::new();
3451 let mut seen = BTreeMap::new();
3452 for node in sequence.values() {
3453 let YamlNode::Scalar(scalar) = node else {
3454 self.unsupported_sequence_item(
3455 CAP_ADD_EXPECTED_STRING,
3456 &node,
3457 field.span,
3458 "cap_add entries must be string scalars",
3459 );
3460 continue;
3461 };
3462 let scalar_type = scalar_type_for_value(&scalar);
3463 if !matches!(
3464 scalar_type,
3465 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3466 ) {
3467 self.unsupported_sequence_item(
3468 CAP_ADD_EXPECTED_STRING,
3469 &YamlNode::Scalar(scalar),
3470 field.span,
3471 "cap_add entries must be string scalars",
3472 );
3473 continue;
3474 }
3475 let item_span = span_from_position(self.source_id, scalar.byte_range());
3476 let value = scalar_string_from_source(&self.source, &scalar);
3477 if let Some(first) = seen.get(&value) {
3478 self.diagnostics.push(
3479 Diagnostic::new(
3480 CAP_ADD_DUPLICATE_ITEM,
3481 Severity::Error,
3482 "cap_add entries must be unique exact strings",
3483 )
3484 .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
3485 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
3486 );
3487 } else {
3488 seen.insert(value.clone(), item_span);
3489 }
3490 items.push(CapabilityAddItem::new(Located::new(value, item_span)));
3491 }
3492 Some(CapabilityAdd::new(span, items))
3493 }
3494
3495 fn parse_devices(&mut self, field: &ParsedField) -> Option<Devices> {
3496 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3497 self.expected(
3498 DEVICES_EXPECTED_SEQUENCE,
3499 field,
3500 "service devices must be a sequence of string scalars or mappings",
3501 );
3502 return None;
3503 };
3504 let span = span_from_position(self.source_id, sequence.byte_range());
3505 let mut devices = Vec::new();
3506 for node in sequence.values() {
3507 match node {
3508 YamlNode::Scalar(scalar)
3509 if matches!(
3510 scalar_type_for_value(&scalar),
3511 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3512 ) =>
3513 {
3514 let item_span = span_from_position(self.source_id, scalar.byte_range());
3515 let raw = Located::new(scalar_string_from_source(&self.source, &scalar), item_span);
3516 devices.push(Device::Short(ShortDevice::new(raw)));
3517 }
3518 YamlNode::Mapping(mapping) => devices.push(Device::Long(self.parse_long_device(&mapping))),
3519 other => self.unsupported_sequence_item(
3520 DEVICE_EXPECTED_FORM,
3521 &other,
3522 field.span,
3523 "service device must use string short syntax or mapping long syntax",
3524 ),
3525 }
3526 }
3527 Some(Devices::new(span, devices))
3528 }
3529
3530 fn parse_long_device(&mut self, mapping: &Mapping) -> LongDevice {
3531 let span = span_from_position(self.source_id, mapping.byte_range());
3532 let mut device = LongDevice::new(span);
3533 let mut seen = BTreeMap::new();
3534 for field in self.fields(mapping) {
3535 let duplicate = self.record_duplicate(&mut seen, &field);
3536 match field.name.value.as_str() {
3537 "source" if !duplicate => self
3538 .parse_device_string(&field, "device source")
3539 .into_iter()
3540 .for_each(|value| device.set_source(value)),
3541 "target" if !duplicate => self
3542 .parse_device_string(&field, "device target")
3543 .into_iter()
3544 .for_each(|value| device.set_target(value)),
3545 "permissions" if !duplicate => self
3546 .parse_device_string(&field, "device permissions")
3547 .into_iter()
3548 .for_each(|value| device.set_permissions(value)),
3549 name if name.starts_with("x-") => device.push_extension(field.reference()),
3550 _ if duplicate => {}
3551 _ => device.push_unknown(field.reference()),
3552 }
3553 }
3554 if device.source().is_none() {
3555 self.missing(
3556 DEVICE_MISSING_SOURCE,
3557 span,
3558 "long service device is missing required string `source`",
3559 );
3560 }
3561 device
3562 }
3563
3564 fn parse_device_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
3565 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3566 self.expected(
3567 DEVICE_EXPECTED_STRING,
3568 field,
3569 format!("{description} must be a string scalar"),
3570 );
3571 return None;
3572 };
3573 if !matches!(
3574 scalar_type_for_value(scalar),
3575 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3576 ) {
3577 self.expected(
3578 DEVICE_EXPECTED_STRING,
3579 field,
3580 format!("{description} must be a string scalar"),
3581 );
3582 return None;
3583 }
3584 Some(Located::new(
3585 scalar_string_from_source(&self.source, scalar),
3586 span_from_position(self.source_id, scalar.byte_range()),
3587 ))
3588 }
3589
3590 fn parse_dns(&mut self, field: &ParsedField) -> Option<Dns> {
3591 let value = field.value.as_ref()?;
3592 if let Some(scalar) = value.as_scalar() {
3593 if !matches!(
3594 scalar_type_for_value(scalar),
3595 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3596 ) {
3597 self.expected(
3598 DNS_EXPECTED_FORM,
3599 field,
3600 "dns must be a string scalar or a sequence of string scalars",
3601 );
3602 return None;
3603 }
3604 let span = span_from_position(self.source_id, scalar.byte_range());
3605 return Some(Dns::new(
3606 span,
3607 DnsForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
3608 ));
3609 }
3610
3611 let Some(sequence) = value.as_sequence() else {
3612 self.expected(
3613 DNS_EXPECTED_FORM,
3614 field,
3615 "dns must be a string scalar or a sequence of string scalars",
3616 );
3617 return None;
3618 };
3619 let span = span_from_position(self.source_id, sequence.byte_range());
3620 let mut items = Vec::new();
3621 for node in sequence.values() {
3622 let YamlNode::Scalar(scalar) = node else {
3623 self.unsupported_sequence_item(
3624 DNS_EXPECTED_STRING,
3625 &node,
3626 field.span,
3627 "dns entries must be string scalars",
3628 );
3629 continue;
3630 };
3631 if !matches!(
3632 scalar_type_for_value(&scalar),
3633 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3634 ) {
3635 self.unsupported_sequence_item(
3636 DNS_EXPECTED_STRING,
3637 &YamlNode::Scalar(scalar),
3638 field.span,
3639 "dns entries must be string scalars",
3640 );
3641 continue;
3642 }
3643 let item_span = span_from_position(self.source_id, scalar.byte_range());
3644 items.push(Located::new(
3645 scalar_string_from_source(&self.source, &scalar),
3646 item_span,
3647 ));
3648 }
3649 Some(Dns::new(span, DnsForm::List(items)))
3650 }
3651
3652 fn parse_dns_options(&mut self, field: &ParsedField) -> Option<DnsOptions> {
3653 let value = field.value.as_ref()?;
3654 let Some(sequence) = value.as_sequence() else {
3655 self.expected(
3656 DNS_OPT_EXPECTED_SEQUENCE,
3657 field,
3658 "dns_opt must be a sequence of string scalars",
3659 );
3660 return None;
3661 };
3662 let span = span_from_position(self.source_id, sequence.byte_range());
3663 let mut items = Vec::new();
3664 let mut seen = BTreeSet::new();
3665 for node in sequence.values() {
3666 let YamlNode::Scalar(scalar) = node else {
3667 self.unsupported_sequence_item(
3668 DNS_OPT_EXPECTED_STRING,
3669 &node,
3670 field.span,
3671 "dns_opt entries must be string scalars",
3672 );
3673 continue;
3674 };
3675 if !matches!(
3676 scalar_type_for_value(&scalar),
3677 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3678 ) {
3679 self.unsupported_sequence_item(
3680 DNS_OPT_EXPECTED_STRING,
3681 &YamlNode::Scalar(scalar),
3682 field.span,
3683 "dns_opt entries must be string scalars",
3684 );
3685 continue;
3686 }
3687 let item_span = span_from_position(self.source_id, scalar.byte_range());
3688 let option = scalar_string_from_source(&self.source, &scalar);
3689 if !seen.insert(option.clone()) {
3690 self.diagnostics.push(
3691 Diagnostic::new(
3692 DNS_OPT_DUPLICATE_ITEM,
3693 Severity::Warning,
3694 "dns_opt entries must be unique exact strings",
3695 )
3696 .with_label(DiagnosticLabel::primary(item_span, "duplicate DNS option retained")),
3697 );
3698 }
3699 items.push(Located::new(option, item_span));
3700 }
3701 Some(DnsOptions::new(span, items))
3702 }
3703
3704 fn parse_dns_search(&mut self, field: &ParsedField) -> Option<DnsSearch> {
3705 let value = field.value.as_ref()?;
3706 if let Some(scalar) = value.as_scalar() {
3707 if !matches!(
3708 scalar_type_for_value(scalar),
3709 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3710 ) {
3711 self.expected(
3712 DNS_SEARCH_EXPECTED_FORM,
3713 field,
3714 "dns_search must be a string scalar or a sequence of string scalars",
3715 );
3716 return None;
3717 }
3718 let span = span_from_position(self.source_id, scalar.byte_range());
3719 return Some(DnsSearch::new(
3720 span,
3721 DnsSearchForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
3722 ));
3723 }
3724
3725 let Some(sequence) = value.as_sequence() else {
3726 self.expected(
3727 DNS_SEARCH_EXPECTED_FORM,
3728 field,
3729 "dns_search must be a string scalar or a sequence of string scalars",
3730 );
3731 return None;
3732 };
3733 let span = span_from_position(self.source_id, sequence.byte_range());
3734 let mut items = Vec::new();
3735 let mut seen = BTreeSet::new();
3736 for node in sequence.values() {
3737 let YamlNode::Scalar(scalar) = node else {
3738 self.unsupported_sequence_item(
3739 DNS_SEARCH_EXPECTED_STRING,
3740 &node,
3741 field.span,
3742 "dns_search entries must be string scalars",
3743 );
3744 continue;
3745 };
3746 if !matches!(
3747 scalar_type_for_value(&scalar),
3748 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3749 ) {
3750 self.unsupported_sequence_item(
3751 DNS_SEARCH_EXPECTED_STRING,
3752 &YamlNode::Scalar(scalar),
3753 field.span,
3754 "dns_search entries must be string scalars",
3755 );
3756 continue;
3757 }
3758 let item_span = span_from_position(self.source_id, scalar.byte_range());
3759 let search = scalar_string_from_source(&self.source, &scalar);
3760 if !seen.insert(search.clone()) {
3761 self.diagnostics.push(
3762 Diagnostic::new(
3763 DNS_SEARCH_DUPLICATE_ITEM,
3764 Severity::Warning,
3765 "dns_search schema entries are unique, but duplicate merge behavior is ambiguous",
3766 )
3767 .with_label(DiagnosticLabel::primary(
3768 item_span,
3769 "duplicate DNS search domain retained",
3770 )),
3771 );
3772 }
3773 items.push(Located::new(search, item_span));
3774 }
3775 Some(DnsSearch::new(span, DnsSearchForm::List(items)))
3776 }
3777
3778 fn parse_expose(&mut self, field: &ParsedField) -> Option<Expose> {
3779 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3780 self.expected(
3781 EXPOSE_EXPECTED_SEQUENCE,
3782 field,
3783 "expose must be a sequence of string or number scalars",
3784 );
3785 return None;
3786 };
3787 let span = span_from_position(self.source_id, sequence.byte_range());
3788 let mut items = Vec::new();
3789 let mut seen = Vec::new();
3790 for node in sequence.values() {
3791 let YamlNode::Scalar(scalar) = node else {
3792 self.unsupported_sequence_item(
3793 EXPOSE_EXPECTED_SCALAR,
3794 &node,
3795 field.span,
3796 "expose entries must be string or number scalars",
3797 );
3798 continue;
3799 };
3800 let scalar_kind = match scalar_type_for_value(&scalar) {
3801 ScalarType::Integer | ScalarType::Float => ExposeScalarKind::Number,
3802 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => ExposeScalarKind::String,
3803 ScalarType::Null | ScalarType::Boolean => {
3804 self.unsupported_sequence_item(
3805 EXPOSE_EXPECTED_SCALAR,
3806 &YamlNode::Scalar(scalar),
3807 field.span,
3808 "expose entries must be string or number scalars",
3809 );
3810 continue;
3811 }
3812 };
3813 let item_span = span_from_position(self.source_id, scalar.byte_range());
3814 let raw = scalar_string_from_source(&self.source, &scalar);
3815 if seen.contains(&(scalar_kind, raw.clone())) {
3816 self.diagnostics.push(
3817 Diagnostic::new(
3818 EXPOSE_DUPLICATE_ITEM,
3819 Severity::Warning,
3820 "expose entries must be unique by exact scalar identity",
3821 )
3822 .with_label(DiagnosticLabel::primary(
3823 item_span,
3824 "duplicate exposed-port item retained",
3825 )),
3826 );
3827 } else {
3828 seen.push((scalar_kind, raw.clone()));
3829 }
3830 let item = ExposeItem::parse(Located::new(raw, item_span), scalar_kind);
3831 self.diagnose_expose_item(&item);
3832 items.push(item);
3833 }
3834 Some(Expose::new(span, items))
3835 }
3836
3837 fn diagnose_expose_item(&mut self, item: &ExposeItem) {
3838 match item.kind() {
3839 ExposeItemKind::Documented { .. } | ExposeItemKind::Expression => {}
3840 ExposeItemKind::Sctp { .. } | ExposeItemKind::UnknownProtocol { .. } => {
3841 self.diagnostics.push(
3842 Diagnostic::new(
3843 EXPOSE_PROVIDER_DEPENDENT,
3844 Severity::Warning,
3845 "expose protocol is outside the documented portable `tcp` and `udp` set",
3846 )
3847 .with_label(DiagnosticLabel::primary(
3848 item.span(),
3849 "provider-dependent exposed-port protocol retained",
3850 ))
3851 .with_note("ComposeLens does not normalize or reject the raw protocol spelling"),
3852 );
3853 }
3854 ExposeItemKind::Malformed => {
3855 self.diagnostics.push(
3856 Diagnostic::new(
3857 EXPOSE_INVALID_ITEM,
3858 Severity::Error,
3859 "expose item must be a decimal port or range with an optional protocol",
3860 )
3861 .with_label(DiagnosticLabel::primary(
3862 item.span(),
3863 "malformed exposed-port item retained",
3864 ))
3865 .with_note("use `PORT`, `START-END`, `PORT/tcp`, or `PORT/udp` for documented portable syntax"),
3866 );
3867 }
3868 }
3869 }
3870
3871 fn parse_security_options(&mut self, field: &ParsedField) -> Option<SecurityOptions> {
3872 let value = field.value.as_ref()?;
3873 let Some(sequence) = value.as_sequence() else {
3874 self.expected(
3875 SECURITY_OPT_EXPECTED_SEQUENCE,
3876 field,
3877 "security_opt must be a sequence of string scalars",
3878 );
3879 return None;
3880 };
3881 let span = span_from_position(self.source_id, sequence.byte_range());
3882 let mut items = Vec::new();
3883 let mut candidates = SecurityOptionCandidateCounts::default();
3884 for node in sequence.values() {
3885 let YamlNode::Scalar(scalar) = node else {
3886 self.unsupported_sequence_item(
3887 SECURITY_OPT_EXPECTED_STRING,
3888 &node,
3889 field.span,
3890 "security_opt entries must be string scalars",
3891 );
3892 continue;
3893 };
3894 if !matches!(
3895 scalar_type_for_value(&scalar),
3896 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
3897 ) {
3898 self.unsupported_sequence_item(
3899 SECURITY_OPT_EXPECTED_STRING,
3900 &YamlNode::Scalar(scalar),
3901 field.span,
3902 "security_opt entries must be string scalars",
3903 );
3904 continue;
3905 }
3906 let item_span = span_from_position(self.source_id, scalar.byte_range());
3907 let raw = scalar_string_from_source(&self.source, &scalar);
3908 let item = SecurityOptionItem::parse(Located::new(raw, item_span));
3909 self.diagnose_security_option_item(item.kind(), item_span, &mut candidates);
3910 items.push(item);
3911 }
3912 Some(SecurityOptions::new(span, items))
3913 }
3914
3915 fn diagnose_security_option_item(
3916 &mut self,
3917 kind: &SecurityOptionKind,
3918 span: SourceSpan,
3919 candidates: &mut SecurityOptionCandidateCounts,
3920 ) {
3921 let diagnostic = match kind {
3922 SecurityOptionKind::AppArmor { .. } => {
3923 candidates.apparmor += 1;
3924 (candidates.apparmor > 1).then(|| {
3925 Diagnostic::new(
3926 SECURITY_OPT_APPARMOR_CONFLICT,
3927 Severity::Warning,
3928 "multiple AppArmor candidates are retained; a consumer must resolve the conflict explicitly",
3929 )
3930 .with_label(DiagnosticLabel::primary(span, "additional AppArmor candidate retained"))
3931 })
3932 }
3933 SecurityOptionKind::AppArmorNearMiss => Some(
3934 Diagnostic::new(
3935 SECURITY_OPT_APPARMOR_NEAR_MISS,
3936 Severity::Warning,
3937 "AppArmor candidates require exact lowercase `apparmor=<profile>` spelling without whitespace",
3938 )
3939 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3940 ),
3941 SecurityOptionKind::Seccomp { .. } => {
3942 candidates.seccomp += 1;
3943 (candidates.seccomp > 1).then(|| {
3944 Diagnostic::new(
3945 SECURITY_OPT_SECCOMP_CONFLICT,
3946 Severity::Warning,
3947 "multiple seccomp candidates are retained; a consumer must resolve the conflict explicitly",
3948 )
3949 .with_label(DiagnosticLabel::primary(span, "additional seccomp candidate retained"))
3950 })
3951 }
3952 SecurityOptionKind::SeccompNearMiss => Some(
3953 Diagnostic::new(
3954 SECURITY_OPT_SECCOMP_NEAR_MISS,
3955 Severity::Warning,
3956 "seccomp candidates require exact lowercase `seccomp=<profile>` spelling without whitespace",
3957 )
3958 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3959 ),
3960 SecurityOptionKind::NoNewPrivileges { .. } => {
3961 candidates.no_new_privileges += 1;
3962 (candidates.no_new_privileges > 1).then(|| {
3963 Diagnostic::new(
3964 SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT,
3965 Severity::Warning,
3966 "multiple no-new-privileges candidates are retained; a consumer must resolve the conflict explicitly",
3967 )
3968 .with_label(DiagnosticLabel::primary(
3969 span,
3970 "additional no-new-privileges candidate retained",
3971 ))
3972 })
3973 }
3974 SecurityOptionKind::NoNewPrivilegesNearMiss => Some(
3975 Diagnostic::new(
3976 SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS,
3977 Severity::Warning,
3978 "no-new-privileges candidates require exact lowercase `no-new-privileges:true` or `no-new-privileges:false` spelling without whitespace",
3979 )
3980 .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
3981 ),
3982 SecurityOptionKind::Mask { .. }
3983 | SecurityOptionKind::MaskNearMiss
3984 | SecurityOptionKind::Unmask { .. }
3985 | SecurityOptionKind::UnmaskNearMiss => security_path_option_diagnostic(kind, span),
3986 SecurityOptionKind::SecurityLabelDisable { .. }
3987 | SecurityOptionKind::SecurityLabelDisableNearMiss
3988 | SecurityOptionKind::SecurityLabelFileType { .. }
3989 | SecurityOptionKind::SecurityLabelFileTypeNearMiss
3990 | SecurityOptionKind::SecurityLabelLevel { .. }
3991 | SecurityOptionKind::SecurityLabelLevelNearMiss
3992 | SecurityOptionKind::SecurityLabelNested { .. }
3993 | SecurityOptionKind::SecurityLabelNestedNearMiss
3994 | SecurityOptionKind::SecurityLabelType { .. }
3995 | SecurityOptionKind::SecurityLabelTypeNearMiss => {
3996 authored_security_label_diagnostic(kind, span, candidates)
3997 }
3998 SecurityOptionKind::Empty => Some(
3999 Diagnostic::new(
4000 SECURITY_OPT_EMPTY_ITEM,
4001 Severity::Error,
4002 "security_opt entries must not be empty strings",
4003 )
4004 .with_label(DiagnosticLabel::primary(span, "empty security option retained")),
4005 ),
4006 SecurityOptionKind::Expression | SecurityOptionKind::Other => None,
4007 };
4008 if let Some(diagnostic) = diagnostic {
4009 self.diagnostics.push(diagnostic);
4010 }
4011 }
4012
4013 fn parse_tmpfs(&mut self, field: &ParsedField) -> Option<Tmpfs> {
4014 let value = field.value.as_ref()?;
4015 if let Some(scalar) = value.as_scalar() {
4016 if !matches!(
4017 scalar_type_for_value(scalar),
4018 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4019 ) {
4020 self.expected(
4021 TMPFS_EXPECTED_FORM,
4022 field,
4023 "tmpfs must be a string scalar or a sequence of string scalars",
4024 );
4025 return None;
4026 }
4027 let span = span_from_position(self.source_id, scalar.byte_range());
4028 let item = TmpfsItem::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
4029 self.diagnose_tmpfs_item(&item);
4030 return Some(Tmpfs::new(span, TmpfsForm::Scalar(item)));
4031 }
4032
4033 let Some(sequence) = value.as_sequence() else {
4034 self.expected(
4035 TMPFS_EXPECTED_FORM,
4036 field,
4037 "tmpfs must be a string scalar or a sequence of string scalars",
4038 );
4039 return None;
4040 };
4041 let span = span_from_position(self.source_id, sequence.byte_range());
4042 let mut items = Vec::new();
4043 for node in sequence.values() {
4044 let YamlNode::Scalar(scalar) = node else {
4045 self.unsupported_sequence_item(
4046 TMPFS_EXPECTED_STRING,
4047 &node,
4048 field.span,
4049 "tmpfs entries must be string scalars",
4050 );
4051 continue;
4052 };
4053 if !matches!(
4054 scalar_type_for_value(&scalar),
4055 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4056 ) {
4057 self.unsupported_sequence_item(
4058 TMPFS_EXPECTED_STRING,
4059 &YamlNode::Scalar(scalar),
4060 field.span,
4061 "tmpfs entries must be string scalars",
4062 );
4063 continue;
4064 }
4065 let item_span = span_from_position(self.source_id, scalar.byte_range());
4066 let raw = scalar_string_from_source(&self.source, &scalar);
4067 let item = TmpfsItem::parse(Located::new(raw, item_span));
4068 self.diagnose_tmpfs_item(&item);
4069 items.push(item);
4070 }
4071 Some(Tmpfs::new(span, TmpfsForm::List(items)))
4072 }
4073
4074 fn diagnose_tmpfs_item(&mut self, item: &TmpfsItem) {
4075 if item.kind() != TmpfsItemKind::ProviderDependent {
4076 return;
4077 }
4078 self.diagnostics.push(
4079 Diagnostic::new(
4080 TMPFS_PROVIDER_DEPENDENT,
4081 Severity::Warning,
4082 "tmpfs item is malformed or uses provider- or target-specific options",
4083 )
4084 .with_label(DiagnosticLabel::primary(
4085 item.span(),
4086 "provider-dependent temporary-filesystem item",
4087 ))
4088 .with_note("use a non-empty path with only non-empty `mode`, `uid`, or `gid` assignments for documented portable syntax"),
4089 );
4090 }
4091
4092 fn parse_sysctls(&mut self, field: &ParsedField) -> Option<Sysctls> {
4093 match field.value.as_ref() {
4094 Some(YamlNode::Mapping(mapping)) => {
4095 let span = span_from_position(self.source_id, mapping.byte_range());
4096 let mut entries = Vec::new();
4097 let mut seen = BTreeMap::new();
4098 for entry in self.fields(mapping) {
4099 if self.record_duplicate(&mut seen, &entry) {
4100 continue;
4101 }
4102 if entry.name.value.is_empty() {
4103 self.diagnostics.push(
4104 Diagnostic::new(
4105 SYSCTLS_EMPTY_KEY,
4106 Severity::Error,
4107 "sysctls mapping keys must not be empty",
4108 )
4109 .with_label(DiagnosticLabel::primary(entry.name.span, "empty sysctl name")),
4110 );
4111 continue;
4112 }
4113 if entry.value.as_ref().is_some_and(|value| value.as_scalar().is_none()) {
4114 self.diagnostics.push(
4115 Diagnostic::new(
4116 SYSCTLS_EXPECTED_SCALAR,
4117 Severity::Error,
4118 "sysctls mapping values must be scalar strings, numbers, booleans, or null",
4119 )
4120 .with_label(DiagnosticLabel::primary(
4121 entry.value_span.unwrap_or(entry.span),
4122 "non-scalar sysctl value",
4123 )),
4124 );
4125 continue;
4126 }
4127 let Some(value) = self.parse_compose_scalar(&entry, "sysctls mapping values must be scalars")
4128 else {
4129 continue;
4130 };
4131 entries.push(KeyValueEntry::new(entry.name, value, entry.span));
4132 }
4133 Some(Sysctls::new(span, SysctlsForm::Map(entries)))
4134 }
4135 Some(YamlNode::Sequence(sequence)) => {
4136 let span = span_from_position(self.source_id, sequence.byte_range());
4137 let mut items = Vec::new();
4138 let mut seen = BTreeMap::new();
4139 for node in sequence.values() {
4140 let YamlNode::Scalar(scalar) = node else {
4141 self.unsupported_sequence_item(
4142 SYSCTLS_EXPECTED_STRING,
4143 &node,
4144 field.span,
4145 "sysctls list entries must be YAML string scalars",
4146 );
4147 continue;
4148 };
4149 if !matches!(
4150 scalar_type_for_value(&scalar),
4151 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4152 ) {
4153 self.unsupported_sequence_item(
4154 SYSCTLS_EXPECTED_STRING,
4155 &YamlNode::Scalar(scalar),
4156 field.span,
4157 "sysctls list entries must be YAML string scalars",
4158 );
4159 continue;
4160 }
4161 let item_span = span_from_position(self.source_id, scalar.byte_range());
4162 let value = scalar_string_from_source(&self.source, &scalar);
4163 if let Some(first) = seen.get(&value) {
4164 self.diagnostics.push(
4165 Diagnostic::new(
4166 SYSCTLS_DUPLICATE_ITEM,
4167 Severity::Error,
4168 "sysctls list entries must be unique exact strings",
4169 )
4170 .with_label(DiagnosticLabel::primary(item_span, "duplicate sysctl string"))
4171 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
4172 );
4173 } else {
4174 seen.insert(value.clone(), item_span);
4175 }
4176 items.push(Located::new(value, item_span));
4177 }
4178 Some(Sysctls::new(span, SysctlsForm::List(items)))
4179 }
4180 _ => {
4181 self.expected(
4182 SYSCTLS_EXPECTED_FORM,
4183 field,
4184 "sysctls must be a mapping or a sequence of string scalars",
4185 );
4186 None
4187 }
4188 }
4189 }
4190
4191 fn parse_logging(&mut self, field: &ParsedField) -> Option<Logging> {
4192 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4193 self.expected(
4194 LOGGING_EXPECTED_MAPPING,
4195 field,
4196 "logging must be a mapping with optional driver and options fields",
4197 );
4198 return None;
4199 };
4200 let span = span_from_position(self.source_id, mapping.byte_range());
4201 let mut logging = Logging::new(span);
4202 let mut seen = BTreeMap::new();
4203 for member in self.fields(mapping) {
4204 let duplicate = self.record_duplicate(&mut seen, &member);
4205 match member.name.value.as_str() {
4206 "driver" if !duplicate => {
4207 if let Some(driver) = self.parse_logging_driver(&member) {
4208 logging.set_driver(driver);
4209 }
4210 }
4211 "options" if !duplicate => {
4212 if let Some(options) = self.parse_logging_options(&member) {
4213 logging.set_options(options);
4214 }
4215 }
4216 name if name.starts_with("x-") => logging.push_extension(member.reference()),
4217 _ if duplicate => {}
4218 _ => logging.push_unknown(member.reference()),
4219 }
4220 }
4221 Some(logging)
4222 }
4223
4224 fn parse_credential_spec(&mut self, field: &ParsedField) -> Option<CredentialSpec> {
4225 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4226 self.expected(EXPECTED_MAPPING, field, "credential_spec must be a mapping");
4227 return None;
4228 };
4229 let mut credential_spec = CredentialSpec::new(span_from_position(self.source_id, mapping.byte_range()));
4230 let mut seen = BTreeMap::new();
4231 for member in self.fields(mapping) {
4232 let duplicate = self.record_duplicate(&mut seen, &member);
4233 match member.name.value().as_str() {
4234 "config" if !duplicate => {
4235 if let Some(value) = self
4236 .parse_credential_spec_string(&member, "credential_spec config must be a YAML string scalar")
4237 {
4238 credential_spec.set_config(value);
4239 } else {
4240 credential_spec.push_unknown(member.reference());
4241 }
4242 }
4243 "file" if !duplicate => {
4244 if let Some(value) =
4245 self.parse_credential_spec_string(&member, "credential_spec file must be a YAML string scalar")
4246 {
4247 credential_spec.set_file(value);
4248 } else {
4249 credential_spec.push_unknown(member.reference());
4250 }
4251 }
4252 "registry" if !duplicate => {
4253 if let Some(value) = self
4254 .parse_credential_spec_string(&member, "credential_spec registry must be a YAML string scalar")
4255 {
4256 credential_spec.set_registry(value);
4257 } else {
4258 credential_spec.push_unknown(member.reference());
4259 }
4260 }
4261 name if name.starts_with("x-") => credential_spec.push_extension(member.reference()),
4262 _ if duplicate => {}
4263 _ => credential_spec.push_unknown(member.reference()),
4264 }
4265 }
4266 Some(credential_spec)
4267 }
4268
4269 fn parse_credential_spec_string(&mut self, field: &ParsedField, message: &'static str) -> Option<Located<String>> {
4270 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4271 self.expected(EXPECTED_SCALAR, field, message);
4272 return None;
4273 };
4274 if scalar_type_for_value(scalar) != ScalarType::String {
4275 self.expected(EXPECTED_SCALAR, field, message);
4276 return None;
4277 }
4278 Some(Located::new(
4279 scalar_string_from_source(&self.source, scalar),
4280 span_from_position(self.source_id, scalar.byte_range()),
4281 ))
4282 }
4283
4284 fn parse_extends(&mut self, field: &ParsedField) -> Option<Extends> {
4285 match field.value.as_ref() {
4286 Some(YamlNode::Scalar(scalar)) if scalar_type_for_value(scalar) == ScalarType::String => {
4287 Some(Extends::Short(Located::new(
4288 scalar_string_from_source(&self.source, scalar),
4289 span_from_position(self.source_id, scalar.byte_range()),
4290 )))
4291 }
4292 Some(YamlNode::Mapping(mapping)) => {
4293 let span = span_from_position(self.source_id, mapping.byte_range());
4294 let mut reference = ExtendsReference::new(span);
4295 let mut seen = BTreeMap::new();
4296 for member in self.fields(mapping) {
4297 let duplicate = self.record_duplicate(&mut seen, &member);
4298 match member.name.value().as_str() {
4299 "service" if !duplicate => {
4300 if let Some(value) =
4301 self.parse_extends_string(&member, "extends service must be a YAML string scalar")
4302 {
4303 reference.set_service(value);
4304 } else {
4305 reference.push_unknown(member.reference());
4306 }
4307 }
4308 "file" if !duplicate => {
4309 if let Some(value) =
4310 self.parse_extends_string(&member, "extends file must be a YAML string scalar")
4311 {
4312 reference.set_file(value);
4313 } else {
4314 reference.push_unknown(member.reference());
4315 }
4316 }
4317 name if name.starts_with("x-") => reference.push_extension(member.reference()),
4318 _ if duplicate => {}
4319 _ => reference.push_unknown(member.reference()),
4320 }
4321 }
4322 if reference.service().is_none() {
4323 self.missing(
4324 EXTENDS_MISSING_SERVICE,
4325 span,
4326 "long extends is missing required `service`",
4327 );
4328 }
4329 Some(Extends::Long(reference))
4330 }
4331 _ => {
4332 self.expected(
4333 EXPECTED_FIELD_FORM,
4334 field,
4335 "extends must be a YAML string scalar or mapping",
4336 );
4337 None
4338 }
4339 }
4340 }
4341
4342 fn parse_extends_string(&mut self, field: &ParsedField, message: impl Into<String>) -> Option<Located<String>> {
4343 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4344 self.expected(EXPECTED_SCALAR, field, message);
4345 return None;
4346 };
4347 if scalar_type_for_value(scalar) != ScalarType::String {
4348 self.expected(EXPECTED_SCALAR, field, message);
4349 return None;
4350 }
4351 Some(Located::new(
4352 scalar_string_from_source(&self.source, scalar),
4353 span_from_position(self.source_id, scalar.byte_range()),
4354 ))
4355 }
4356
4357 fn parse_provider(&mut self, field: &ParsedField) -> Option<Provider> {
4358 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4359 self.expected(EXPECTED_MAPPING, field, "provider must be a mapping");
4360 return None;
4361 };
4362 let span = span_from_position(self.source_id, mapping.byte_range());
4363 let mut provider = Provider::new(span);
4364 let mut seen = BTreeMap::new();
4365 for member in self.fields(mapping) {
4366 let duplicate = self.record_duplicate(&mut seen, &member);
4367 match member.name.value().as_str() {
4368 "type" if !duplicate => {
4369 if let Some(value) = self.parse_provider_type(&member) {
4370 provider.set_type(value);
4371 } else {
4372 provider.push_unknown(member.reference());
4373 }
4374 }
4375 "options" if !duplicate => {
4376 if let Some(options) = self.parse_provider_options(&member) {
4377 provider.set_options(options);
4378 } else {
4379 provider.push_unknown(member.reference());
4380 }
4381 }
4382 name if name.starts_with("x-") => provider.push_extension(member.reference()),
4383 _ if duplicate => {}
4384 _ => provider.push_unknown(member.reference()),
4385 }
4386 }
4387 if provider.type_().is_none() {
4388 self.missing(PROVIDER_MISSING_TYPE, span, "provider is missing required `type`");
4389 }
4390 Some(provider)
4391 }
4392
4393 fn parse_provider_type(&mut self, field: &ParsedField) -> Option<Located<String>> {
4394 self.parse_extends_string(field, "provider type must be a YAML string scalar")
4395 }
4396
4397 fn parse_post_start(&mut self, field: &ParsedField) -> Option<PostStartHooks> {
4398 let (span, entries) = self.parse_lifecycle_hooks(
4399 field,
4400 "post_start",
4401 POST_START_MISSING_COMMAND,
4402 PostStartHook::Hook,
4403 |span| PostStartHook::Unmodeled { span },
4404 )?;
4405 Some(PostStartHooks::new(span, entries))
4406 }
4407
4408 fn parse_pre_stop(&mut self, field: &ParsedField) -> Option<PreStopHooks> {
4409 let (span, entries) =
4410 self.parse_lifecycle_hooks(field, "pre_stop", PRE_STOP_MISSING_COMMAND, PreStopHook::Hook, |span| {
4411 PreStopHook::Unmodeled { span }
4412 })?;
4413 Some(PreStopHooks::new(span, entries))
4414 }
4415
4416 fn parse_pre_start(&mut self, field: &ParsedField) -> Option<PreStartHooks> {
4417 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
4418 self.expected(
4419 EXPECTED_SEQUENCE,
4420 field,
4421 "pre_start must be a sequence of hook mappings",
4422 );
4423 return None;
4424 };
4425 let span = span_from_position(self.source_id, sequence.byte_range());
4426 let mut entries = Vec::new();
4427 for item in sequence.values() {
4428 let Some(mapping) = item.as_mapping() else {
4429 self.unsupported_sequence_item(
4430 EXPECTED_MAPPING,
4431 &item,
4432 field.span,
4433 "pre_start items must be hook mappings",
4434 );
4435 entries.push(PreStartHook::Unmodeled {
4436 span: node_span(self.source_id, &item).unwrap_or(field.span),
4437 });
4438 continue;
4439 };
4440 entries.push(PreStartHook::Hook(Box::new(self.parse_pre_start_service_hook(mapping))));
4441 }
4442 Some(PreStartHooks::new(span, entries))
4443 }
4444
4445 fn parse_lifecycle_hooks<T>(
4446 &mut self,
4447 field: &ParsedField,
4448 name: &str,
4449 missing_code: DiagnosticCode,
4450 hook_entry: fn(Box<ServiceHook>) -> T,
4451 unmodeled_entry: fn(SourceSpan) -> T,
4452 ) -> Option<(SourceSpan, Vec<T>)> {
4453 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
4454 self.expected(
4455 EXPECTED_SEQUENCE,
4456 field,
4457 format!("{name} must be a sequence of hook mappings"),
4458 );
4459 return None;
4460 };
4461 let span = span_from_position(self.source_id, sequence.byte_range());
4462 let mut entries = Vec::new();
4463 for item in sequence.values() {
4464 let Some(mapping) = item.as_mapping() else {
4465 self.unsupported_sequence_item(
4466 EXPECTED_MAPPING,
4467 &item,
4468 field.span,
4469 format!("{name} items must be hook mappings"),
4470 );
4471 entries.push(unmodeled_entry(node_span(self.source_id, &item).unwrap_or(field.span)));
4472 continue;
4473 };
4474 entries.push(hook_entry(Box::new(self.parse_service_hook(
4475 mapping,
4476 name,
4477 missing_code,
4478 ))));
4479 }
4480 Some((span, entries))
4481 }
4482
4483 fn parse_service_hook(&mut self, mapping: &Mapping, name: &str, missing_code: DiagnosticCode) -> ServiceHook {
4484 let span = span_from_position(self.source_id, mapping.byte_range());
4485 let mut hook = ServiceHook::new(span);
4486 let mut seen = BTreeMap::new();
4487 for member in self.fields(mapping) {
4488 let duplicate = self.record_duplicate(&mut seen, &member);
4489 if duplicate {
4490 hook.push_unknown(member.reference());
4491 continue;
4492 }
4493 match member.name.value().as_str() {
4494 "command" => {
4495 if let Some(value) = self.parse_command(&member) {
4496 hook.set_command(value);
4497 } else {
4498 hook.push_unknown(member.reference());
4499 }
4500 }
4501 "environment" => {
4502 if let Some(value) = self.parse_environment(&member) {
4503 hook.set_environment(value);
4504 } else {
4505 hook.push_unknown(member.reference());
4506 }
4507 }
4508 "privileged" => {
4509 if let Some(value) = self.parse_boolean(&member, &format!("{name} privileged")) {
4510 hook.set_privileged(value);
4511 } else {
4512 hook.push_unknown(member.reference());
4513 }
4514 }
4515 "user" => {
4516 if let Some(value) =
4517 self.parse_extends_string(&member, format!("{name} user must be a YAML string scalar"))
4518 {
4519 hook.set_user(value);
4520 } else {
4521 hook.push_unknown(member.reference());
4522 }
4523 }
4524 "working_dir" => {
4525 if let Some(value) =
4526 self.parse_extends_string(&member, format!("{name} working_dir must be a YAML string scalar"))
4527 {
4528 hook.set_working_dir(value);
4529 } else {
4530 hook.push_unknown(member.reference());
4531 }
4532 }
4533 name if name.starts_with("x-") => hook.push_extension(member.reference()),
4534 _ => hook.push_unknown(member.reference()),
4535 }
4536 }
4537 if hook.command().is_none() {
4538 self.missing(missing_code, span, format!("{name} hook is missing required `command`"));
4539 }
4540 hook
4541 }
4542
4543 fn parse_pre_start_service_hook(&mut self, mapping: &Mapping) -> PreStartServiceHook {
4544 let span = span_from_position(self.source_id, mapping.byte_range());
4545 let mut hook = PreStartServiceHook::new(span);
4546 let mut seen = BTreeMap::new();
4547 for member in self.fields(mapping) {
4548 let duplicate = self.record_duplicate(&mut seen, &member);
4549 if duplicate {
4550 hook.push_unknown(member.reference());
4551 continue;
4552 }
4553 match member.name.value().as_str() {
4554 "command" => {
4555 if let Some(value) = self.parse_command(&member) {
4556 hook.set_command(value);
4557 } else {
4558 hook.push_unknown(member.reference());
4559 }
4560 }
4561 "image" => {
4562 if let Some(value) =
4563 self.parse_extends_string(&member, "pre_start image must be a YAML string scalar")
4564 {
4565 hook.set_image(value);
4566 } else {
4567 hook.push_unknown(member.reference());
4568 }
4569 }
4570 "environment" => {
4571 if let Some(value) = self.parse_environment(&member) {
4572 hook.set_environment(value);
4573 } else {
4574 hook.push_unknown(member.reference());
4575 }
4576 }
4577 "privileged" => {
4578 if let Some(value) = self.parse_boolean(&member, "pre_start privileged") {
4579 hook.set_privileged(value);
4580 } else {
4581 hook.push_unknown(member.reference());
4582 }
4583 }
4584 "per_replica" => {
4585 if let Some(value) = self.parse_boolean(&member, "pre_start per_replica") {
4586 hook.set_per_replica(value);
4587 } else {
4588 hook.push_unknown(member.reference());
4589 }
4590 }
4591 "user" => {
4592 if let Some(value) =
4593 self.parse_extends_string(&member, "pre_start user must be a YAML string scalar")
4594 {
4595 hook.set_user(value);
4596 } else {
4597 hook.push_unknown(member.reference());
4598 }
4599 }
4600 "working_dir" => {
4601 if let Some(value) =
4602 self.parse_extends_string(&member, "pre_start working_dir must be a YAML string scalar")
4603 {
4604 hook.set_working_dir(value);
4605 } else {
4606 hook.push_unknown(member.reference());
4607 }
4608 }
4609 name if name.starts_with("x-") => hook.push_extension(member.reference()),
4610 _ => hook.push_unknown(member.reference()),
4611 }
4612 }
4613 hook
4614 }
4615
4616 fn parse_provider_options(&mut self, field: &ParsedField) -> Option<ProviderOptions> {
4617 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4618 self.expected(EXPECTED_MAPPING, field, "provider options must be a mapping");
4619 return None;
4620 };
4621 let span = span_from_position(self.source_id, mapping.byte_range());
4622 let mut entries = Vec::new();
4623 let mut unmodeled_entries = Vec::new();
4624 let mut seen = BTreeMap::new();
4625 for option in self.fields(mapping) {
4626 if self.record_duplicate(&mut seen, &option) {
4627 unmodeled_entries.push(option.reference());
4628 continue;
4629 }
4630 if option.name.value().is_empty() {
4631 self.expected(EXPECTED_FIELD_FORM, &option, "provider option keys must not be empty");
4632 unmodeled_entries.push(option.reference());
4633 continue;
4634 }
4635 let Some(value) = self.parse_provider_option_value(&option) else {
4636 unmodeled_entries.push(option.reference());
4637 continue;
4638 };
4639 entries.push(ProviderOption::new(option.name, value, option.span));
4640 }
4641 Some(ProviderOptions::new(span, entries, unmodeled_entries))
4642 }
4643
4644 fn parse_provider_option_value(&mut self, field: &ParsedField) -> Option<ProviderOptionValue> {
4645 let Some(value) = field.value.as_ref() else {
4646 self.expected(
4647 EXPECTED_FIELD_FORM,
4648 field,
4649 "provider option values must be YAML string, number, or boolean scalars or sequences of them",
4650 );
4651 return None;
4652 };
4653 if let Some(scalar) = value.as_scalar() {
4654 return self
4655 .provider_option_scalar(scalar)
4656 .map(ProviderOptionValue::Scalar)
4657 .or_else(|| {
4658 self.expected(
4659 EXPECTED_FIELD_FORM,
4660 field,
4661 "provider option values must be YAML string, number, or boolean scalars or sequences of them",
4662 );
4663 None
4664 });
4665 }
4666 let Some(sequence) = value.as_sequence() else {
4667 self.expected(
4668 EXPECTED_FIELD_FORM,
4669 field,
4670 "provider option values must be YAML string, number, or boolean scalars or sequences of them",
4671 );
4672 return None;
4673 };
4674 let span = span_from_position(self.source_id, sequence.byte_range());
4675 let mut items = Vec::new();
4676 for item in sequence.values() {
4677 let Some(scalar) = item.as_scalar() else {
4678 self.unsupported_sequence_item(
4679 EXPECTED_SCALAR,
4680 &item,
4681 field.span,
4682 "provider option sequence items must be YAML string, number, or boolean scalars",
4683 );
4684 items.push(ProviderOptionItem::Unmodeled {
4685 span: node_span(self.source_id, &item).unwrap_or(field.span),
4686 });
4687 continue;
4688 };
4689 if let Some(value) = self.provider_option_scalar(scalar) {
4690 items.push(ProviderOptionItem::Scalar(value));
4691 } else {
4692 self.unsupported_sequence_item(
4693 EXPECTED_SCALAR,
4694 &item,
4695 field.span,
4696 "provider option sequence items must be YAML string, number, or boolean scalars",
4697 );
4698 items.push(ProviderOptionItem::Unmodeled {
4699 span: node_span(self.source_id, &item).unwrap_or(field.span),
4700 });
4701 }
4702 }
4703 Some(ProviderOptionValue::Sequence { span, items })
4704 }
4705
4706 fn provider_option_scalar(&self, scalar: &Scalar) -> Option<Located<ComposeScalar>> {
4707 let scalar_value = ScalarValue::from_scalar(scalar);
4708 let value = match scalar_type_for_value(scalar) {
4709 ScalarType::String => ComposeScalar::String(scalar_string_from_source(&self.source, scalar)),
4710 ScalarType::Integer | ScalarType::Float => {
4711 ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
4712 }
4713 ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
4714 ScalarType::Null | ScalarType::Timestamp | ScalarType::Regex => return None,
4715 };
4716 Some(Located::new(
4717 value,
4718 span_from_position(self.source_id, scalar.byte_range()),
4719 ))
4720 }
4721
4722 fn parse_logging_driver(&mut self, field: &ParsedField) -> Option<Located<String>> {
4723 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4724 self.expected(
4725 LOGGING_DRIVER_EXPECTED_STRING,
4726 field,
4727 "logging driver must be a YAML string scalar",
4728 );
4729 return None;
4730 };
4731 if !matches!(
4732 scalar_type_for_value(scalar),
4733 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
4734 ) {
4735 self.expected(
4736 LOGGING_DRIVER_EXPECTED_STRING,
4737 field,
4738 "logging driver must be a YAML string scalar",
4739 );
4740 return None;
4741 }
4742 let span = span_from_position(self.source_id, scalar.byte_range());
4743 Some(Located::new(scalar_string_from_source(&self.source, scalar), span))
4744 }
4745
4746 fn parse_logging_options(&mut self, field: &ParsedField) -> Option<LoggingOptions> {
4747 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
4748 self.expected(
4749 LOGGING_OPTIONS_EXPECTED_MAPPING,
4750 field,
4751 "logging options must be a mapping",
4752 );
4753 return None;
4754 };
4755 let span = span_from_position(self.source_id, mapping.byte_range());
4756 let mut entries = Vec::new();
4757 let mut unmodeled_entries = Vec::new();
4758 let mut seen = BTreeMap::new();
4759 for option in self.fields(mapping) {
4760 if self.record_duplicate(&mut seen, &option) {
4761 continue;
4762 }
4763 if option.name.value.is_empty() {
4764 self.diagnostics.push(
4765 Diagnostic::new(
4766 LOGGING_OPTION_EMPTY_KEY,
4767 Severity::Error,
4768 "logging option keys must not be empty",
4769 )
4770 .with_label(DiagnosticLabel::primary(option.name.span, "empty logging option key")),
4771 );
4772 unmodeled_entries.push(option.reference());
4773 continue;
4774 }
4775 let Some(value) = self.parse_logging_option_scalar(&option) else {
4776 unmodeled_entries.push(option.reference());
4777 continue;
4778 };
4779 entries.push(LoggingOption::new(option.name, value, option.span));
4780 }
4781 Some(LoggingOptions::new(span, entries, unmodeled_entries))
4782 }
4783
4784 fn parse_logging_option_scalar(&mut self, field: &ParsedField) -> Option<Located<LoggingOptionValue>> {
4785 let Some(node) = field.value.as_ref() else {
4786 return Some(Located::new(LoggingOptionValue::Null, field.name.span));
4787 };
4788 let Some(scalar) = node.as_scalar() else {
4789 self.expected(
4790 LOGGING_OPTION_EXPECTED_SCALAR,
4791 field,
4792 "logging option values must be YAML string, number, or null scalars",
4793 );
4794 return None;
4795 };
4796 let span = span_from_position(self.source_id, scalar.byte_range());
4797 let value = match scalar_type_for_value(scalar) {
4798 ScalarType::Null => LoggingOptionValue::Null,
4799 ScalarType::Integer | ScalarType::Float => {
4800 LoggingOptionValue::Number(scalar_string_from_source(&self.source, scalar))
4801 }
4802 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
4803 LoggingOptionValue::String(scalar_string_from_source(&self.source, scalar))
4804 }
4805 ScalarType::Boolean => {
4806 self.diagnostics.push(
4807 Diagnostic::new(
4808 LOGGING_OPTION_EXPECTED_SCALAR,
4809 Severity::Error,
4810 "logging option values must be YAML string, number, or null scalars",
4811 )
4812 .with_label(DiagnosticLabel::primary(
4813 span,
4814 "boolean logging option retained as malformed",
4815 )),
4816 );
4817 return None;
4818 }
4819 };
4820 Some(Located::new(value, span))
4821 }
4822
4823 fn parse_restart_policy(&mut self, field: &ParsedField) -> Option<RestartPolicy> {
4824 let value = self.parse_string(field, "service restart policy")?;
4825 let policy = RestartPolicy::parse(value);
4826 if !policy.is_valid() {
4827 self.diagnostics.push(
4828 Diagnostic::new(
4829 RESTART_INVALID_POLICY,
4830 Severity::Error,
4831 "restart must be `no`, `always`, `on-failure[:max-retries]`, `unless-stopped`, or interpolation",
4832 )
4833 .with_label(DiagnosticLabel::primary(
4834 policy.raw().span(),
4835 "invalid service restart policy",
4836 )),
4837 );
4838 }
4839 Some(policy)
4840 }
4841
4842 fn parse_pids_limit(&mut self, field: &ParsedField) -> Option<PidsLimit> {
4843 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4844 self.expected(
4845 PIDS_LIMIT_EXPECTED_VALUE,
4846 field,
4847 "pids_limit must be a number or string scalar",
4848 );
4849 return None;
4850 };
4851 if matches!(scalar_type_for_value(scalar), ScalarType::Boolean | ScalarType::Null) {
4852 self.expected(
4853 PIDS_LIMIT_EXPECTED_VALUE,
4854 field,
4855 "pids_limit must be a number or string scalar",
4856 );
4857 return None;
4858 }
4859 let span = span_from_position(self.source_id, scalar.byte_range());
4860 let limit = PidsLimit::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
4861 match limit.kind() {
4862 PidsLimitKind::Zero => self.diagnostics.push(
4863 Diagnostic::new(
4864 PIDS_LIMIT_AMBIGUOUS_ZERO,
4865 Severity::Warning,
4866 "pids_limit zero is preserved as an ambiguous and unportable native state",
4867 )
4868 .with_label(DiagnosticLabel::primary(span, "ambiguous zero PID limit")),
4869 ),
4870 PidsLimitKind::Other => self.diagnostics.push(
4871 Diagnostic::new(
4872 PIDS_LIMIT_INVALID,
4873 Severity::Error,
4874 "pids_limit must be `-1`, a positive integral decimal, or interpolation",
4875 )
4876 .with_label(DiagnosticLabel::primary(span, "unsupported service PID limit")),
4877 ),
4878 _ => {}
4879 }
4880 Some(limit)
4881 }
4882
4883 fn parse_cpu_count(&mut self, field: &ParsedField) -> Option<Located<CpuCount>> {
4884 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4885 self.expected(
4886 CPU_COUNT_EXPECTED_VALUE,
4887 field,
4888 "cpu_count must be a YAML integer or string scalar",
4889 );
4890 return None;
4891 };
4892 let span = span_from_position(self.source_id, scalar.byte_range());
4893 let scalar_value = ScalarValue::from_scalar(scalar);
4894 let raw = scalar_string_from_source(&self.source, scalar);
4895 let count = match scalar_type_for_value(scalar) {
4896 ScalarType::Integer | ScalarType::Float if CpuCount::yaml_integer_spelling(&raw) => {
4897 CpuCount::yaml_integer(raw)
4898 }
4899 ScalarType::String
4900 if scalar_value.style() == ScalarStyle::Plain
4901 && !scalar_uses_block_style(&self.source, scalar)
4902 && CpuCount::yaml_integer_spelling(&raw) =>
4903 {
4904 CpuCount::yaml_integer(raw)
4905 }
4906 ScalarType::String => CpuCount::String(scalar_string_from_source(&self.source, scalar)),
4907 _ => {
4908 self.expected(
4909 CPU_COUNT_EXPECTED_VALUE,
4910 field,
4911 "cpu_count must be a YAML integer or string scalar",
4912 );
4913 return None;
4914 }
4915 };
4916 if !count.is_valid() {
4917 self.diagnostics.push(
4918 Diagnostic::new(
4919 CPU_COUNT_NEGATIVE,
4920 Severity::Error,
4921 "cpu_count YAML integers must be nonnegative",
4922 )
4923 .with_label(DiagnosticLabel::primary(
4924 span,
4925 "negative CPU count retained as invalid evidence",
4926 )),
4927 );
4928 }
4929 Some(Located::new(count, span))
4930 }
4931
4932 fn parse_cpu_percent(&mut self, field: &ParsedField) -> Option<Located<CpuPercent>> {
4933 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4934 self.expected(
4935 CPU_PERCENT_EXPECTED_VALUE,
4936 field,
4937 "cpu_percent must be a YAML integer or string scalar",
4938 );
4939 return None;
4940 };
4941 let span = span_from_position(self.source_id, scalar.byte_range());
4942 let scalar_value = ScalarValue::from_scalar(scalar);
4943 let raw = scalar_string_from_source(&self.source, scalar);
4944 let percent = match scalar_type_for_value(scalar) {
4945 ScalarType::Integer | ScalarType::Float if CpuPercent::yaml_integer_spelling(&raw) => {
4946 CpuPercent::yaml_integer(raw)
4947 }
4948 ScalarType::String
4949 if scalar_value.style() == ScalarStyle::Plain
4950 && !scalar_uses_block_style(&self.source, scalar)
4951 && CpuPercent::yaml_integer_spelling(&raw) =>
4952 {
4953 CpuPercent::yaml_integer(raw)
4954 }
4955 ScalarType::String => CpuPercent::String(scalar_string_from_source(&self.source, scalar)),
4956 _ => {
4957 self.expected(
4958 CPU_PERCENT_EXPECTED_VALUE,
4959 field,
4960 "cpu_percent must be a YAML integer or string scalar",
4961 );
4962 return None;
4963 }
4964 };
4965 if !percent.is_valid() {
4966 self.diagnostics.push(
4967 Diagnostic::new(
4968 CPU_PERCENT_OUT_OF_RANGE,
4969 Severity::Error,
4970 "cpu_percent YAML integers must be between 0 and 100 inclusive",
4971 )
4972 .with_label(DiagnosticLabel::primary(
4973 span,
4974 "out-of-range CPU percentage retained as invalid evidence",
4975 )),
4976 );
4977 }
4978 Some(Located::new(percent, span))
4979 }
4980
4981 fn parse_cpu_period(&mut self, field: &ParsedField) -> Option<Located<CpuPeriod>> {
4982 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
4983 self.expected(
4984 CPU_PERIOD_EXPECTED_VALUE,
4985 field,
4986 "cpu_period must be a YAML number or string scalar",
4987 );
4988 return None;
4989 };
4990 let span = span_from_position(self.source_id, scalar.byte_range());
4991 let scalar_value = ScalarValue::from_scalar(scalar);
4992 let raw = scalar_string_from_source(&self.source, scalar);
4993 let period = match scalar_type_for_value(scalar) {
4994 ScalarType::Integer | ScalarType::Float => CpuPeriod::YamlNumber(raw),
4995 ScalarType::String
4996 if scalar_value.style() == ScalarStyle::Plain
4997 && !scalar_uses_block_style(&self.source, scalar)
4998 && CpuPeriod::yaml_number_spelling(&raw) =>
4999 {
5000 CpuPeriod::YamlNumber(raw)
5001 }
5002 ScalarType::String => CpuPeriod::String(raw),
5003 _ => {
5004 self.expected(
5005 CPU_PERIOD_EXPECTED_VALUE,
5006 field,
5007 "cpu_period must be a YAML number or string scalar",
5008 );
5009 return None;
5010 }
5011 };
5012 Some(Located::new(period, span))
5013 }
5014
5015 fn parse_cpu_quota(&mut self, field: &ParsedField) -> Option<Located<CpuQuota>> {
5016 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5017 self.expected(
5018 CPU_QUOTA_EXPECTED_VALUE,
5019 field,
5020 "cpu_quota must be a YAML number or string scalar",
5021 );
5022 return None;
5023 };
5024 let span = span_from_position(self.source_id, scalar.byte_range());
5025 let scalar_value = ScalarValue::from_scalar(scalar);
5026 let raw = scalar_string_from_source(&self.source, scalar);
5027 let quota = match scalar_type_for_value(scalar) {
5028 ScalarType::Integer | ScalarType::Float => CpuQuota::YamlNumber(raw),
5029 ScalarType::String
5030 if scalar_value.style() == ScalarStyle::Plain
5031 && !scalar_uses_block_style(&self.source, scalar)
5032 && CpuPeriod::yaml_number_spelling(&raw) =>
5033 {
5034 CpuQuota::YamlNumber(raw)
5035 }
5036 ScalarType::String => CpuQuota::String(raw),
5037 _ => {
5038 self.expected(
5039 CPU_QUOTA_EXPECTED_VALUE,
5040 field,
5041 "cpu_quota must be a YAML number or string scalar",
5042 );
5043 return None;
5044 }
5045 };
5046 Some(Located::new(quota, span))
5047 }
5048
5049 fn parse_cpu_rt_period(&mut self, field: &ParsedField) -> Option<Located<CpuRtPeriod>> {
5050 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5051 self.expected(
5052 CPU_RT_PERIOD_EXPECTED_VALUE,
5053 field,
5054 "cpu_rt_period must be a YAML number or string scalar",
5055 );
5056 return None;
5057 };
5058 let span = span_from_position(self.source_id, scalar.byte_range());
5059 let scalar_value = ScalarValue::from_scalar(scalar);
5060 let raw = scalar_string_from_source(&self.source, scalar);
5061 let period = match scalar_type_for_value(scalar) {
5062 ScalarType::Integer | ScalarType::Float => CpuRtPeriod::YamlNumber(raw),
5063 ScalarType::String
5064 if scalar_value.style() == ScalarStyle::Plain
5065 && !scalar_uses_block_style(&self.source, scalar)
5066 && CpuPeriod::yaml_number_spelling(&raw) =>
5067 {
5068 CpuRtPeriod::YamlNumber(raw)
5069 }
5070 ScalarType::String => CpuRtPeriod::parse_string(raw),
5071 _ => {
5072 self.expected(
5073 CPU_RT_PERIOD_EXPECTED_VALUE,
5074 field,
5075 "cpu_rt_period must be a YAML number or string scalar",
5076 );
5077 return None;
5078 }
5079 };
5080 if !period.is_valid() {
5081 self.diagnostics.push(
5082 Diagnostic::new(
5083 CPU_RT_PERIOD_INVALID,
5084 Severity::Error,
5085 "cpu_rt_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
5086 )
5087 .with_label(DiagnosticLabel::primary(span, "invalid service real-time CPU period")),
5088 );
5089 }
5090 Some(Located::new(period, span))
5091 }
5092
5093 fn parse_cpu_rt_runtime(&mut self, field: &ParsedField) -> Option<Located<CpuRtRuntime>> {
5094 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5095 self.expected(
5096 EXPECTED_SCALAR,
5097 field,
5098 "cpu_rt_runtime must be an integer microsecond or duration string scalar",
5099 );
5100 return None;
5101 };
5102 let span = span_from_position(self.source_id, scalar.byte_range());
5103 let raw = scalar_string_from_source(&self.source, scalar);
5104 let runtime = match scalar_type_for_value(scalar) {
5105 ScalarType::Integer => CpuRtRuntime::parse_number(raw, true),
5106 ScalarType::Float => CpuRtRuntime::parse_number(raw, false),
5107 ScalarType::String => CpuRtRuntime::parse_string(raw),
5108 _ => {
5109 self.expected(
5110 EXPECTED_SCALAR,
5111 field,
5112 "cpu_rt_runtime must be an integer microsecond or duration string scalar",
5113 );
5114 return None;
5115 }
5116 };
5117 if !runtime.is_valid() {
5118 self.diagnostics.push(Diagnostic::new(CPU_RT_RUNTIME_INVALID, Severity::Error,
5119 "cpu_rt_runtime must be an integer microsecond value, a Compose duration, or an interpolation expression")
5120 .with_label(DiagnosticLabel::primary(span, "invalid service real-time CPU runtime")));
5121 }
5122 Some(Located::new(runtime, span))
5123 }
5124
5125 fn parse_service_integer(
5126 &mut self,
5127 field: &ParsedField,
5128 min: i128,
5129 max: i128,
5130 description: &str,
5131 ) -> Option<Located<ServiceInteger>> {
5132 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5133 self.expected(
5134 EXPECTED_SCALAR,
5135 field,
5136 format!("{description} must be an integer scalar"),
5137 );
5138 return None;
5139 };
5140 let span = span_from_position(self.source_id, scalar.byte_range());
5141 if matches!(
5142 scalar_type_for_value(scalar),
5143 ScalarType::Boolean | ScalarType::Timestamp | ScalarType::Regex
5144 ) {
5145 self.expected(
5146 EXPECTED_SCALAR,
5147 field,
5148 format!("{description} must be an integer scalar"),
5149 );
5150 return None;
5151 }
5152 let parsed = ServiceInteger::parse(scalar_string_from_source(&self.source, scalar), min, max);
5153 if !parsed.is_valid() {
5154 self.diagnostics.push(
5155 Diagnostic::new(
5156 EXPECTED_FIELD_FORM,
5157 Severity::Error,
5158 format!("{description} must be an integer in its documented range"),
5159 )
5160 .with_label(DiagnosticLabel::primary(span, "invalid integer spelling retained")),
5161 );
5162 }
5163 Some(Located::new(parsed, span))
5164 }
5165
5166 fn parse_cpus(&mut self, field: &ParsedField) -> Option<Located<Cpus>> {
5167 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5168 self.expected(EXPECTED_SCALAR, field, "cpus must be a decimal scalar");
5169 return None;
5170 };
5171 let span = span_from_position(self.source_id, scalar.byte_range());
5172 let kind = scalar_type_for_value(scalar);
5173 if matches!(kind, ScalarType::Boolean | ScalarType::Timestamp | ScalarType::Regex) {
5174 self.expected(EXPECTED_SCALAR, field, "cpus must be a decimal scalar");
5175 return None;
5176 }
5177 let value = Cpus::parse(scalar_string_from_source(&self.source, scalar));
5178 if !value.is_valid() {
5179 self.diagnostics.push(
5180 Diagnostic::new(
5181 EXPECTED_FIELD_FORM,
5182 Severity::Error,
5183 "cpus must be a decimal allocation or interpolation expression",
5184 )
5185 .with_label(DiagnosticLabel::primary(span, "invalid CPU allocation retained")),
5186 );
5187 }
5188 Some(Located::new(value, span))
5189 }
5190
5191 fn parse_shm_size(&mut self, field: &ParsedField) -> Option<ShmSize> {
5192 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5193 self.expected(
5194 SHM_SIZE_EXPECTED_VALUE,
5195 field,
5196 "shm_size must be a YAML number or string scalar",
5197 );
5198 return None;
5199 };
5200 let scalar_kind = match scalar_type_for_value(scalar) {
5201 ScalarType::Integer | ScalarType::Float => ShmSizeScalarKind::Number,
5202 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => ShmSizeScalarKind::String,
5203 ScalarType::Boolean | ScalarType::Null => {
5204 self.expected(
5205 SHM_SIZE_EXPECTED_VALUE,
5206 field,
5207 "shm_size must be a YAML number or string scalar",
5208 );
5209 return None;
5210 }
5211 };
5212 let span = span_from_position(self.source_id, scalar.byte_range());
5213 let size = ShmSize::parse(
5214 Located::new(scalar_string_from_source(&self.source, scalar), span),
5215 scalar_kind,
5216 );
5217 self.diagnose_shm_size(&size);
5218 Some(size)
5219 }
5220
5221 fn diagnose_shm_size(&mut self, size: &ShmSize) {
5222 let (code, message, label, note) = match size.kind() {
5223 ShmSizeKind::Zero { .. } => (
5224 SHM_SIZE_AMBIGUOUS_ZERO,
5225 "shm_size zero is preserved because Compose does not define its semantics",
5226 "ambiguous zero shared-memory size",
5227 "choose a positive size with an explicit documented lowercase unit",
5228 ),
5229 ShmSizeKind::ProviderDependentNumber => (
5230 SHM_SIZE_PROVIDER_DEPENDENT_NUMBER,
5231 "numeric shm_size is schema-accepted but lacks a documented explicit unit",
5232 "provider-dependent numeric shared-memory size",
5233 "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for portable intent",
5234 ),
5235 ShmSizeKind::ProviderDependentString => (
5236 SHM_SIZE_PROVIDER_DEPENDENT_STRING,
5237 "string shm_size is schema-accepted but falls outside the documented lowercase suffix family",
5238 "provider-dependent string shared-memory size",
5239 "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
5240 ),
5241 ShmSizeKind::Documented { .. } | ShmSizeKind::Expression => return,
5242 };
5243 self.diagnostics.push(
5244 Diagnostic::new(code, Severity::Warning, message)
5245 .with_label(DiagnosticLabel::primary(size.raw().span(), label))
5246 .with_note(note),
5247 );
5248 }
5249
5250 fn parse_mem_limit(&mut self, field: &ParsedField) -> Option<MemLimit> {
5251 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5252 self.expected(
5253 MEM_LIMIT_EXPECTED_VALUE,
5254 field,
5255 "mem_limit must be a YAML number or string scalar",
5256 );
5257 return None;
5258 };
5259 let scalar_kind = match scalar_type_for_value(scalar) {
5260 ScalarType::Integer | ScalarType::Float => MemLimitScalarKind::Number,
5261 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MemLimitScalarKind::String,
5262 ScalarType::Boolean | ScalarType::Null => {
5263 self.expected(
5264 MEM_LIMIT_EXPECTED_VALUE,
5265 field,
5266 "mem_limit must be a YAML number or string scalar",
5267 );
5268 return None;
5269 }
5270 };
5271 let span = span_from_position(self.source_id, scalar.byte_range());
5272 let limit = MemLimit::parse(
5273 Located::new(scalar_string_from_source(&self.source, scalar), span),
5274 scalar_kind,
5275 );
5276 self.diagnose_mem_limit(&limit);
5277 Some(limit)
5278 }
5279
5280 fn parse_memswap_limit(&mut self, field: &ParsedField) -> Option<MemswapLimit> {
5281 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
5282 self.expected(
5283 MEMSWAP_LIMIT_EXPECTED_VALUE,
5284 field,
5285 "memswap_limit must be a YAML number or string scalar",
5286 );
5287 return None;
5288 };
5289 let scalar_kind = match scalar_type_for_value(scalar) {
5290 ScalarType::Integer | ScalarType::Float => MemswapLimitScalarKind::Number,
5291 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MemswapLimitScalarKind::String,
5292 ScalarType::Boolean | ScalarType::Null => {
5293 self.expected(
5294 MEMSWAP_LIMIT_EXPECTED_VALUE,
5295 field,
5296 "memswap_limit must be a YAML number or string scalar",
5297 );
5298 return None;
5299 }
5300 };
5301 let span = span_from_position(self.source_id, scalar.byte_range());
5302 let limit = MemswapLimit::parse(
5303 Located::new(scalar_string_from_source(&self.source, scalar), span),
5304 scalar_kind,
5305 );
5306 if matches!(limit.kind(), MemswapLimitKind::Other(_)) {
5307 self.diagnostics.push(
5308 Diagnostic::new(
5309 MEMSWAP_LIMIT_INVALID,
5310 Severity::Error,
5311 "memswap_limit must be `-1`, a decimal byte quantity, or an interpolation expression",
5312 )
5313 .with_label(DiagnosticLabel::primary(
5314 span,
5315 "invalid memory-plus-swap limit retained",
5316 )),
5317 );
5318 }
5319 Some(limit)
5320 }
5321
5322 fn diagnose_mem_limit(&mut self, limit: &MemLimit) {
5323 let (code, message, label, note) = match limit.kind() {
5324 MemLimitKind::Zero { .. } => (
5325 MEM_LIMIT_AMBIGUOUS_ZERO,
5326 "mem_limit zero is preserved without inferring portable runtime behavior",
5327 "ambiguous zero memory limit",
5328 "choose a positive size with an explicit documented lowercase unit",
5329 ),
5330 MemLimitKind::SchemaNumber => (
5331 MEM_LIMIT_SCHEMA_NUMBER,
5332 "numeric mem_limit is schema-accepted but lacks a documented explicit unit",
5333 "schema-only numeric memory limit",
5334 "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for explicit intent",
5335 ),
5336 MemLimitKind::ProviderDependentString => (
5337 MEM_LIMIT_PROVIDER_DEPENDENT_STRING,
5338 "string mem_limit is schema-accepted but falls outside the documented lowercase suffix family",
5339 "provider-dependent string memory limit",
5340 "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
5341 ),
5342 MemLimitKind::Documented { .. } | MemLimitKind::Expression => return,
5343 };
5344 self.diagnostics.push(
5345 Diagnostic::new(code, Severity::Warning, message)
5346 .with_label(DiagnosticLabel::primary(limit.raw().span(), label))
5347 .with_note(note),
5348 );
5349 }
5350
5351 fn parse_pull_policy(&mut self, field: &ParsedField) -> Option<PullPolicy> {
5352 let value = self.parse_string(field, "service pull policy")?;
5353 let policy = PullPolicy::parse(value);
5354 if !policy.is_recognized() {
5355 self.diagnostics.push(
5356 Diagnostic::new(
5357 PULL_POLICY_INVALID,
5358 Severity::Error,
5359 "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",
5360 )
5361 .with_label(DiagnosticLabel::primary(
5362 policy.raw().span(),
5363 "invalid or provider-specific service pull policy",
5364 )),
5365 );
5366 }
5367 Some(policy)
5368 }
5369
5370 fn parse_stop_grace_period(&mut self, field: &ParsedField) -> Option<Located<StopGracePeriod>> {
5371 let value = self.parse_string(field, "service stop grace period")?;
5372 let period = StopGracePeriod::parse(value.value);
5373 if !period.is_valid() {
5374 self.diagnostics.push(
5375 Diagnostic::new(
5376 STOP_GRACE_PERIOD_INVALID,
5377 Severity::Error,
5378 "stop_grace_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
5379 )
5380 .with_label(DiagnosticLabel::primary(
5381 value.span,
5382 "invalid service stop grace period",
5383 )),
5384 );
5385 }
5386 Some(Located::new(period, value.span))
5387 }
5388
5389 fn parse_command(&mut self, field: &ParsedField) -> Option<Command> {
5390 match field.value.as_ref() {
5391 None => Some(Command::Null(field.name.span)),
5392 Some(YamlNode::Scalar(scalar)) => {
5393 let span = span_from_position(self.source_id, scalar.byte_range());
5394 if scalar_is_null(scalar) {
5395 Some(Command::Null(span))
5396 } else {
5397 Some(Command::String(Located::new(
5398 scalar_string_from_source(&self.source, scalar),
5399 span,
5400 )))
5401 }
5402 }
5403 Some(YamlNode::Sequence(sequence)) => {
5404 let span = span_from_position(self.source_id, sequence.byte_range());
5405 let values =
5406 self.parse_scalar_nodes(sequence.values(), field.span, "command list items must be scalars");
5407 Some(Command::List { span, values })
5408 }
5409 _ => {
5410 self.expected(
5411 EXPECTED_FIELD_FORM,
5412 field,
5413 "command must be null, a scalar, or a sequence",
5414 );
5415 None
5416 }
5417 }
5418 }
5419
5420 fn parse_service_user(&mut self, field: &ParsedField) -> Option<UserSpec> {
5421 self.parse_string(field, "service user").map(UserSpec::parse)
5422 }
5423
5424 fn parse_entrypoint(&mut self, field: &ParsedField) -> Option<Entrypoint> {
5425 match field.value.as_ref() {
5426 None => Some(Entrypoint::Null(field.name.span)),
5427 Some(YamlNode::Scalar(scalar)) => {
5428 let span = span_from_position(self.source_id, scalar.byte_range());
5429 if scalar_is_null(scalar) {
5430 Some(Entrypoint::Null(span))
5431 } else {
5432 Some(Entrypoint::String(Located::new(
5433 scalar_string_from_source(&self.source, scalar),
5434 span,
5435 )))
5436 }
5437 }
5438 Some(YamlNode::Sequence(sequence)) => {
5439 let span = span_from_position(self.source_id, sequence.byte_range());
5440 let values =
5441 self.parse_scalar_nodes(sequence.values(), field.span, "entrypoint list items must be scalars");
5442 Some(Entrypoint::List { span, values })
5443 }
5444 _ => {
5445 self.expected(
5446 EXPECTED_FIELD_FORM,
5447 field,
5448 "entrypoint must be null, a scalar, or a sequence",
5449 );
5450 None
5451 }
5452 }
5453 }
5454
5455 fn parse_environment(&mut self, field: &ParsedField) -> Option<Environment> {
5456 match field.value.as_ref() {
5457 Some(YamlNode::Sequence(sequence)) => {
5458 let span = span_from_position(self.source_id, sequence.byte_range());
5459 let entries = self
5460 .parse_scalar_nodes(sequence.values(), field.span, "environment list items must be scalars")
5461 .into_iter()
5462 .map(EnvironmentListEntry::parse)
5463 .collect();
5464 Some(Environment::List { span, entries })
5465 }
5466 Some(YamlNode::Mapping(mapping)) => {
5467 let span = span_from_position(self.source_id, mapping.byte_range());
5468 let entries = self.parse_environment_map(mapping);
5469 Some(Environment::Map { span, entries })
5470 }
5471 _ => {
5472 self.expected(EXPECTED_FIELD_FORM, field, "environment must be a sequence or mapping");
5473 None
5474 }
5475 }
5476 }
5477
5478 fn parse_environment_map(&mut self, mapping: &Mapping) -> Vec<EnvironmentMapEntry> {
5479 let mut entries = Vec::new();
5480 let mut seen = BTreeMap::new();
5481 for field in self.fields(mapping) {
5482 if self.record_duplicate(&mut seen, &field) {
5483 continue;
5484 }
5485 let value = self.parse_compose_scalar(&field, "environment values must be scalars");
5486 if let Some(value) = value {
5487 entries.push(EnvironmentMapEntry::new(field.name, value, field.span));
5488 }
5489 }
5490 entries
5491 }
5492
5493 fn parse_environment_files(&mut self, field: &ParsedField) -> Vec<EnvironmentFile> {
5494 match field.value.as_ref() {
5495 Some(YamlNode::Scalar(_)) => self
5496 .parse_string(field, "service environment-file path")
5497 .map(EnvironmentFile::Short)
5498 .into_iter()
5499 .collect(),
5500 Some(YamlNode::Sequence(sequence)) => sequence
5501 .values()
5502 .filter_map(|value| match value {
5503 YamlNode::Scalar(scalar) => {
5504 let span = span_from_position(self.source_id, scalar.byte_range());
5505 Some(EnvironmentFile::Short(Located::new(
5506 scalar_string_from_source(&self.source, &scalar),
5507 span,
5508 )))
5509 }
5510 YamlNode::Mapping(mapping) => Some(EnvironmentFile::Long(Box::new(
5511 self.parse_long_environment_file(&mapping),
5512 ))),
5513 _ => {
5514 self.diagnostics.push(
5515 Diagnostic::new(
5516 ENVIRONMENT_FILE_EXPECTED_FORM,
5517 Severity::Error,
5518 "env_file item must use scalar short syntax or mapping long syntax",
5519 )
5520 .with_label(DiagnosticLabel::primary(
5521 node_span(self.source_id, &value).unwrap_or(field.span),
5522 "invalid environment-file item",
5523 )),
5524 );
5525 None
5526 }
5527 })
5528 .collect(),
5529 _ => {
5530 self.expected(
5531 EXPECTED_FIELD_FORM,
5532 field,
5533 "env_file must be a scalar path or a sequence of short/long entries",
5534 );
5535 Vec::new()
5536 }
5537 }
5538 }
5539
5540 fn parse_long_environment_file(&mut self, mapping: &Mapping) -> LongEnvironmentFile {
5541 let span = span_from_position(self.source_id, mapping.byte_range());
5542 let mut environment_file = LongEnvironmentFile::new(span);
5543 let mut seen = BTreeMap::new();
5544 for field in self.fields(mapping) {
5545 let duplicate = self.record_duplicate(&mut seen, &field);
5546 match field.name.value.as_str() {
5547 "path" if !duplicate => self
5548 .parse_string(&field, "environment-file path")
5549 .into_iter()
5550 .for_each(|value| environment_file.set_path(value)),
5551 "required" if !duplicate => self
5552 .parse_boolean(&field, "environment-file required option")
5553 .into_iter()
5554 .for_each(|value| environment_file.set_required(value)),
5555 "format" if !duplicate => {
5556 if let Some(raw) = self.parse_string(&field, "environment-file format") {
5557 let format = EnvironmentFileFormat::parse(raw);
5558 if !format.is_valid() {
5559 self.diagnostics.push(
5560 Diagnostic::new(
5561 ENVIRONMENT_FILE_INVALID_FORMAT,
5562 Severity::Error,
5563 "environment-file format must be `raw` or interpolation",
5564 )
5565 .with_label(DiagnosticLabel::primary(format.raw().span(), "invalid format")),
5566 );
5567 }
5568 environment_file.set_format(format);
5569 }
5570 }
5571 name if name.starts_with("x-") => environment_file.push_extension(field.reference()),
5572 _ if duplicate => {}
5573 _ => environment_file.push_unknown(field.reference()),
5574 }
5575 }
5576 if environment_file.path().is_none() {
5577 self.missing(
5578 ENVIRONMENT_FILE_MISSING_PATH,
5579 span,
5580 "long environment-file entry is missing `path`",
5581 );
5582 }
5583 environment_file
5584 }
5585
5586 fn parse_extra_hosts(&mut self, field: &ParsedField) -> Option<ExtraHosts> {
5587 match field.value.as_ref() {
5588 Some(YamlNode::Sequence(sequence)) => {
5589 let span = span_from_position(self.source_id, sequence.byte_range());
5590 let entries = self
5591 .parse_scalar_nodes(sequence.values(), field.span, "extra_hosts entries must be scalars")
5592 .into_iter()
5593 .map(|raw| {
5594 let entry = ShortExtraHost::parse(raw);
5595 if !entry.is_complete() {
5596 self.diagnostics.push(
5597 Diagnostic::new(
5598 EXTRA_HOST_INVALID_ENTRY,
5599 Severity::Error,
5600 "short extra_hosts entry must contain a hostname and address",
5601 )
5602 .with_label(DiagnosticLabel::primary(
5603 entry.raw().span(),
5604 "missing separator or value",
5605 )),
5606 );
5607 }
5608 entry
5609 })
5610 .collect();
5611 Some(ExtraHosts::Short { span, entries })
5612 }
5613 Some(YamlNode::Mapping(mapping)) => {
5614 let span = span_from_position(self.source_id, mapping.byte_range());
5615 let mut entries = Vec::new();
5616 let mut seen = BTreeMap::new();
5617 for host in self.fields(mapping) {
5618 if self.record_duplicate(&mut seen, &host) {
5619 continue;
5620 }
5621 if let Some(address) = self.parse_string(&host, "extra host address") {
5622 let address = Located::new(HostAddress::parse(address.value), address.span);
5623 entries.push(LongExtraHost::new(host.name, address, host.span));
5624 }
5625 }
5626 Some(ExtraHosts::Long { span, entries })
5627 }
5628 _ => {
5629 self.expected(EXPECTED_FIELD_FORM, field, "extra_hosts must be a sequence or mapping");
5630 None
5631 }
5632 }
5633 }
5634
5635 fn parse_ulimits(&mut self, field: &ParsedField) -> Option<Ulimits> {
5636 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5637 self.expected(EXPECTED_MAPPING, field, "ulimits must be a mapping");
5638 return None;
5639 };
5640 let span = span_from_position(self.source_id, mapping.byte_range());
5641 let mut entries = Vec::new();
5642 let mut seen = BTreeMap::new();
5643 for limit in self.fields(mapping) {
5644 if self.record_duplicate(&mut seen, &limit) {
5645 continue;
5646 }
5647 if !valid_ulimit_name(limit.name.value()) {
5648 self.diagnostics.push(
5649 Diagnostic::new(
5650 ULIMIT_INVALID_NAME,
5651 Severity::Error,
5652 "ulimit names must contain only lowercase ASCII letters",
5653 )
5654 .with_label(DiagnosticLabel::primary(limit.name.span, "invalid ulimit name")),
5655 );
5656 }
5657 let value = match limit.value.as_ref() {
5658 Some(YamlNode::Scalar(_)) => self.parse_limit_value(&limit, "ulimit value").map(UlimitValue::Single),
5659 Some(YamlNode::Mapping(range)) => Some(UlimitValue::Range(self.parse_ulimit_range(range))),
5660 _ => {
5661 self.expected(
5662 EXPECTED_FIELD_FORM,
5663 &limit,
5664 "ulimit must be a scalar or soft/hard mapping",
5665 );
5666 None
5667 }
5668 };
5669 if let Some(value) = value {
5670 entries.push(Ulimit::new(limit.name, limit.span, value));
5671 }
5672 }
5673 Some(Ulimits::new(span, entries))
5674 }
5675
5676 fn parse_ulimit_range(&mut self, mapping: &Mapping) -> UlimitRange {
5677 let span = span_from_position(self.source_id, mapping.byte_range());
5678 let mut range = UlimitRange::new(span);
5679 let mut seen = BTreeMap::new();
5680 for field in self.fields(mapping) {
5681 let duplicate = self.record_duplicate(&mut seen, &field);
5682 match field.name.value.as_str() {
5683 "soft" if !duplicate => self
5684 .parse_limit_value(&field, "ulimit soft value")
5685 .into_iter()
5686 .for_each(|value| range.set_soft(value)),
5687 "hard" if !duplicate => self
5688 .parse_limit_value(&field, "ulimit hard value")
5689 .into_iter()
5690 .for_each(|value| range.set_hard(value)),
5691 name if name.starts_with("x-") => range.push_extension(field.reference()),
5692 _ if duplicate => {}
5693 _ => range.push_unknown(field.reference()),
5694 }
5695 }
5696 if range.soft().is_none() {
5697 self.missing(
5698 ULIMIT_MISSING_RANGE_MEMBER,
5699 span,
5700 "ulimit range is missing required `soft`",
5701 );
5702 }
5703 if range.hard().is_none() {
5704 self.missing(
5705 ULIMIT_MISSING_RANGE_MEMBER,
5706 span,
5707 "ulimit range is missing required `hard`",
5708 );
5709 }
5710 range
5711 }
5712
5713 fn parse_limit_value(&mut self, field: &ParsedField, description: &str) -> Option<Located<LimitValue>> {
5714 let value = self.parse_string(field, description)?;
5715 let parsed = LimitValue::parse(value.value);
5716 if !parsed.is_valid() {
5717 self.diagnostics.push(
5718 Diagnostic::new(
5719 ULIMIT_INVALID_VALUE,
5720 Severity::Error,
5721 "ulimit must be -1, a non-negative integer, or an interpolation expression",
5722 )
5723 .with_label(DiagnosticLabel::primary(value.span, "invalid ulimit value")),
5724 );
5725 }
5726 Some(Located::new(parsed, value.span))
5727 }
5728
5729 fn parse_depends_on(&mut self, field: &ParsedField) -> Option<DependsOn> {
5730 match field.value.as_ref() {
5731 Some(YamlNode::Sequence(sequence)) => {
5732 let span = span_from_position(self.source_id, sequence.byte_range());
5733 let services = self.parse_scalar_nodes(
5734 sequence.values(),
5735 field.span,
5736 "dependency service names must be scalars",
5737 );
5738 Some(DependsOn::Short { span, services })
5739 }
5740 Some(YamlNode::Mapping(mapping)) => {
5741 let span = span_from_position(self.source_id, mapping.byte_range());
5742 let mut services = Vec::new();
5743 let mut seen = BTreeMap::new();
5744 for dependency in self.fields(mapping) {
5745 if self.record_duplicate(&mut seen, &dependency) {
5746 continue;
5747 }
5748 let mut parsed = ServiceDependency::new(dependency.name.clone(), dependency.span);
5749 if Self::field_is_null(&dependency) {
5750 services.push(parsed);
5751 continue;
5752 }
5753 let Some(options) = dependency.value.as_ref().and_then(YamlNode::as_mapping) else {
5754 self.expected(
5755 EXPECTED_MAPPING,
5756 &dependency,
5757 "long dependency options must be a mapping or null",
5758 );
5759 continue;
5760 };
5761 let mut option_seen = BTreeMap::new();
5762 for option in self.fields(options) {
5763 let duplicate = self.record_duplicate(&mut option_seen, &option);
5764 match option.name.value.as_str() {
5765 "condition" if !duplicate => {
5766 if let Some(value) = self.parse_string(&option, "dependency condition") {
5767 let condition = DependencyCondition::parse(value.value);
5768 if !condition.is_known() {
5769 self.diagnostics.push(
5770 Diagnostic::new(
5771 DEPENDENCY_INVALID_CONDITION,
5772 Severity::Error,
5773 "dependency condition is not defined by Compose",
5774 )
5775 .with_label(
5776 DiagnosticLabel::primary(value.span, "unknown dependency condition"),
5777 ),
5778 );
5779 }
5780 parsed.set_condition(Located::new(condition, value.span));
5781 }
5782 }
5783 "restart" if !duplicate => self
5784 .parse_boolean(&option, "dependency restart")
5785 .into_iter()
5786 .for_each(|value| parsed.set_restart(value)),
5787 "required" if !duplicate => self
5788 .parse_boolean(&option, "dependency required")
5789 .into_iter()
5790 .for_each(|value| parsed.set_required(value)),
5791 name if name.starts_with("x-") => parsed.push_extension(option.reference()),
5792 _ if duplicate => {}
5793 _ => parsed.push_unknown(option.reference()),
5794 }
5795 }
5796 services.push(parsed);
5797 }
5798 Some(DependsOn::Long { span, services })
5799 }
5800 _ => {
5801 self.expected(EXPECTED_FIELD_FORM, field, "depends_on must be a sequence or mapping");
5802 None
5803 }
5804 }
5805 }
5806
5807 fn parse_healthcheck(&mut self, field: &ParsedField) -> Option<Healthcheck> {
5808 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
5809 self.expected(EXPECTED_MAPPING, field, "healthcheck must be a mapping");
5810 return None;
5811 };
5812 let span = span_from_position(self.source_id, mapping.byte_range());
5813 let mut healthcheck = Healthcheck::new(span);
5814 let mut seen = BTreeMap::new();
5815 for option in self.fields(mapping) {
5816 let duplicate = self.record_duplicate(&mut seen, &option);
5817 match option.name.value.as_str() {
5818 "test" if !duplicate => self
5819 .parse_healthcheck_test(&option)
5820 .into_iter()
5821 .for_each(|value| healthcheck.set_test(value)),
5822 "interval" if !duplicate => self
5823 .parse_healthcheck_duration(&option, "healthcheck interval")
5824 .into_iter()
5825 .for_each(|value| healthcheck.set_interval(value)),
5826 "timeout" if !duplicate => self
5827 .parse_healthcheck_duration(&option, "healthcheck timeout")
5828 .into_iter()
5829 .for_each(|value| healthcheck.set_timeout(value)),
5830 "retries" if !duplicate => self
5831 .parse_healthcheck_retries(&option)
5832 .into_iter()
5833 .for_each(|value| healthcheck.set_retries(value)),
5834 "start_period" if !duplicate => self
5835 .parse_healthcheck_duration(&option, "healthcheck start period")
5836 .into_iter()
5837 .for_each(|value| healthcheck.set_start_period(value)),
5838 "start_interval" if !duplicate => self
5839 .parse_healthcheck_duration(&option, "healthcheck start interval")
5840 .into_iter()
5841 .for_each(|value| healthcheck.set_start_interval(value)),
5842 "disable" if !duplicate => self
5843 .parse_boolean(&option, "healthcheck disable")
5844 .into_iter()
5845 .for_each(|value| healthcheck.set_disable(value)),
5846 name if name.starts_with("x-") => healthcheck.push_extension(option.reference()),
5847 _ if duplicate => {}
5848 _ => healthcheck.push_unknown(option.reference()),
5849 }
5850 }
5851 Some(healthcheck)
5852 }
5853
5854 fn parse_healthcheck_duration(
5855 &mut self,
5856 field: &ParsedField,
5857 description: &str,
5858 ) -> Option<Located<HealthcheckDuration>> {
5859 let value = self.parse_string(field, description)?;
5860 let duration = HealthcheckDuration::parse(value.value);
5861 if !duration.is_valid() {
5862 self.diagnostics.push(
5863 Diagnostic::new(
5864 HEALTHCHECK_INVALID_DURATION,
5865 Severity::Error,
5866 "healthcheck duration must use Compose duration syntax or interpolation",
5867 )
5868 .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck duration")),
5869 );
5870 }
5871 Some(Located::new(duration, value.span))
5872 }
5873
5874 fn parse_healthcheck_retries(&mut self, field: &ParsedField) -> Option<Located<HealthcheckRetries>> {
5875 let value = self.parse_string(field, "healthcheck retries")?;
5876 let retries = HealthcheckRetries::parse(value.value);
5877 if !retries.is_valid() {
5878 self.diagnostics.push(
5879 Diagnostic::new(
5880 HEALTHCHECK_INVALID_RETRIES,
5881 Severity::Error,
5882 "healthcheck retries must be a non-negative integer or interpolation expression",
5883 )
5884 .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck retry count")),
5885 );
5886 }
5887 Some(Located::new(retries, value.span))
5888 }
5889
5890 fn parse_healthcheck_test(&mut self, field: &ParsedField) -> Option<HealthcheckTest> {
5891 match field.value.as_ref() {
5892 Some(YamlNode::Scalar(_)) => self
5893 .parse_string(field, "healthcheck test")
5894 .map(HealthcheckTest::String),
5895 Some(YamlNode::Sequence(sequence)) => {
5896 let span = span_from_position(self.source_id, sequence.byte_range());
5897 let values =
5898 self.parse_scalar_nodes(sequence.values(), field.span, "healthcheck test items must be scalars");
5899 let kind = values.first().map(|value| HealthcheckTestKind::parse(value.value()));
5900 if kind.is_none()
5901 || kind == Some(HealthcheckTestKind::Other)
5902 || (kind == Some(HealthcheckTestKind::None) && values.len() != 1)
5903 {
5904 self.diagnostics.push(
5905 Diagnostic::new(
5906 HEALTHCHECK_INVALID_TEST,
5907 Severity::Error,
5908 "healthcheck list must begin with NONE, CMD, or CMD-SHELL",
5909 )
5910 .with_label(DiagnosticLabel::primary(span, "invalid healthcheck command mode")),
5911 );
5912 }
5913 Some(HealthcheckTest::List { span, kind, values })
5914 }
5915 _ => {
5916 self.expected(
5917 EXPECTED_FIELD_FORM,
5918 field,
5919 "healthcheck test must be a scalar or sequence",
5920 );
5921 None
5922 }
5923 }
5924 }
5925
5926 fn parse_build(&mut self, field: &ParsedField) -> Option<Build> {
5927 match field.value.as_ref() {
5928 Some(YamlNode::Scalar(_)) => self.parse_string(field, "build context").map(Build::Context),
5929 Some(YamlNode::Mapping(mapping)) => {
5930 let mut definition = BuildDefinition::new(span_from_position(self.source_id, mapping.byte_range()));
5931 let mut seen = BTreeMap::new();
5932 let (mut dockerfile, mut dockerfile_inline) = (None, None);
5933 for option in self.fields(mapping) {
5934 if self.record_duplicate(&mut seen, &option) {
5935 continue;
5936 }
5937 if let Some(kind) = BuildFieldKind::from_name(option.name.value()) {
5938 definition.push_field(BuildField::new(kind, option.reference()));
5939 match kind {
5940 BuildFieldKind::AdditionalContexts => {
5941 definition.set_additional_contexts(self.parse_build_additional_contexts(&option));
5942 }
5943 BuildFieldKind::Args => {
5944 if let Some(args) = self.parse_build_args(&option) {
5945 definition.set_args(args);
5946 }
5947 }
5948 BuildFieldKind::CacheFrom | BuildFieldKind::CacheTo => {
5949 self.set_build_cache_locations(&mut definition, &option, kind);
5950 }
5951 BuildFieldKind::Entitlements => {
5952 if let Some(entitlements) = self.parse_build_entitlements(&option) {
5953 definition.set_entitlements(entitlements);
5954 }
5955 }
5956 BuildFieldKind::ExtraHosts => {
5957 if let Some(extra_hosts) = self.parse_build_extra_hosts(&option) {
5958 definition.set_extra_hosts(extra_hosts);
5959 }
5960 }
5961 BuildFieldKind::Context => {
5962 if let Some(context) = self.parse_string(&option, "build context") {
5963 definition.set_context(context);
5964 }
5965 }
5966 BuildFieldKind::Dockerfile => {
5967 dockerfile = Some(self.parse_build_dockerfile(&mut definition, &option));
5968 }
5969 BuildFieldKind::DockerfileInline => {
5970 self.set_build_dockerfile_inline(&mut definition, &option, &mut dockerfile_inline);
5971 }
5972 BuildFieldKind::Target => {
5973 if let Some(target) = self.parse_string(&option, "build target") {
5974 definition.set_target(target);
5975 }
5976 }
5977 BuildFieldKind::Network => {
5978 if let Some(network) = self.parse_string(&option, "build network") {
5979 definition.set_network(network);
5980 }
5981 }
5982 BuildFieldKind::Isolation => self.set_build_isolation(&mut definition, &option),
5983 BuildFieldKind::Platforms => {
5984 if let Some(platforms) = self.parse_build_platforms(&option) {
5985 definition.set_platforms(platforms);
5986 }
5987 }
5988 BuildFieldKind::NoCache => self.set_build_no_cache(&mut definition, &option),
5989 BuildFieldKind::NoCacheFilter => self.set_build_no_cache_filter(&mut definition, &option),
5990 BuildFieldKind::Privileged => self.set_build_privileged(&mut definition, &option),
5991 BuildFieldKind::Sbom => self.set_build_sbom(&mut definition, &option),
5992 BuildFieldKind::Provenance => self.set_build_provenance(&mut definition, &option),
5993 BuildFieldKind::Pull => {
5994 if let Some(pull) = self.parse_boolean(&option, "build pull") {
5995 definition.set_pull(pull);
5996 }
5997 }
5998 BuildFieldKind::ShmSize => self.set_build_shm_size(&mut definition, &option),
5999 BuildFieldKind::Tags => {
6000 if let Some(tags) = self.parse_build_tags(&option) {
6001 definition.set_tags(tags);
6002 }
6003 }
6004 BuildFieldKind::Labels => {
6005 if let Some(labels) = self.parse_labels(&option) {
6006 definition.set_labels(labels);
6007 }
6008 }
6009 BuildFieldKind::Secrets => self
6010 .parse_secret_grants(&option)
6011 .into_iter()
6012 .for_each(|secrets| definition.set_secrets(secrets)),
6013 BuildFieldKind::Ssh => self.set_build_ssh(&mut definition, &option),
6014 BuildFieldKind::Ulimits => self.set_build_ulimits(&mut definition, &option),
6015 }
6016 } else if option.name.value().starts_with("x-") {
6017 definition.push_extension(option.reference());
6018 } else {
6019 definition.push_unknown(option.reference());
6020 }
6021 }
6022 self.report_build_dockerfile_conflict(dockerfile, dockerfile_inline);
6023 Some(Build::Definition(definition))
6024 }
6025 _ => self.invalid_build_form(field),
6026 }
6027 }
6028
6029 fn invalid_build_form(&mut self, field: &ParsedField) -> Option<Build> {
6030 self.expected(EXPECTED_FIELD_FORM, field, "build must be a scalar context or mapping");
6031 None
6032 }
6033
6034 fn report_build_dockerfile_conflict(
6035 &mut self,
6036 dockerfile: Option<FieldReference>,
6037 dockerfile_inline: Option<FieldReference>,
6038 ) {
6039 let (Some(dockerfile), Some(dockerfile_inline)) = (dockerfile, dockerfile_inline) else {
6040 return;
6041 };
6042 self.diagnostics.push(
6043 Diagnostic::new(
6044 BUILD_DOCKERFILE_INLINE_CONFLICT,
6045 Severity::Error,
6046 "build `dockerfile` and `dockerfile_inline` are mutually exclusive",
6047 )
6048 .with_label(DiagnosticLabel::primary(dockerfile.span(), "dockerfile retained"))
6049 .with_label(DiagnosticLabel::secondary(
6050 dockerfile_inline.span(),
6051 "dockerfile_inline retained",
6052 )),
6053 );
6054 }
6055
6056 fn set_build_shm_size(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
6057 if let Some(shm_size) = self.parse_shm_size(field) {
6058 definition.set_shm_size(shm_size);
6059 }
6060 }
6061
6062 fn set_build_ulimits(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
6063 if let Some(ulimits) = self.parse_ulimits(field) {
6064 definition.set_ulimits(ulimits);
6065 }
6066 }
6067
6068 fn parse_build_tags(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
6069 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6070 self.expected(EXPECTED_SEQUENCE, field, "build tags must be a sequence of scalars");
6071 return None;
6072 };
6073 Some(self.parse_scalar_nodes(
6074 sequence.values(),
6075 field.span,
6076 "build tag entries must be non-null scalars",
6077 ))
6078 }
6079
6080 fn parse_build_entitlements(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
6081 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6082 self.expected(
6083 EXPECTED_SEQUENCE,
6084 field,
6085 "build entitlements must be a sequence of string scalars",
6086 );
6087 return None;
6088 };
6089 Some(self.parse_string_scalar_nodes(
6090 sequence.values(),
6091 field.span,
6092 "build entitlement entries must be string scalars",
6093 ))
6094 }
6095
6096 fn parse_build_cache_locations(&mut self, field: &ParsedField, name: &str) -> Option<Vec<Located<String>>> {
6097 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6098 self.expected(
6099 EXPECTED_SEQUENCE,
6100 field,
6101 format!("build {name} must be a sequence of string scalars"),
6102 );
6103 return None;
6104 };
6105 Some(self.parse_string_scalar_nodes(
6106 sequence.values(),
6107 field.span,
6108 format!("build {name} entries must be string scalars"),
6109 ))
6110 }
6111
6112 fn set_build_cache_locations(
6113 &mut self,
6114 definition: &mut BuildDefinition,
6115 field: &ParsedField,
6116 kind: BuildFieldKind,
6117 ) {
6118 let name = if kind == BuildFieldKind::CacheFrom {
6119 "cache_from"
6120 } else {
6121 "cache_to"
6122 };
6123 if let Some(locations) = self.parse_build_cache_locations(field, name) {
6124 if kind == BuildFieldKind::CacheFrom {
6125 definition.set_cache_from(locations);
6126 } else {
6127 definition.set_cache_to(locations);
6128 }
6129 }
6130 }
6131
6132 fn parse_build_extra_hosts(&mut self, field: &ParsedField) -> Option<BuildExtraHosts> {
6133 match field.value.as_ref() {
6134 Some(YamlNode::Sequence(sequence)) => {
6135 let span = span_from_position(self.source_id, sequence.byte_range());
6136 let mut values = Vec::new();
6137 let mut seen = BTreeSet::new();
6138 for node in sequence.values() {
6139 let YamlNode::Scalar(scalar) = node else {
6140 self.unsupported_sequence_item(
6141 BUILD_EXTRA_HOSTS_EXPECTED_STRING,
6142 &node,
6143 field.span,
6144 "build extra_hosts list entries must be string scalars",
6145 );
6146 continue;
6147 };
6148 if !matches!(
6149 scalar_type_for_value(&scalar),
6150 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
6151 ) {
6152 self.unsupported_sequence_item(
6153 BUILD_EXTRA_HOSTS_EXPECTED_STRING,
6154 &YamlNode::Scalar(scalar),
6155 field.span,
6156 "build extra_hosts list entries must be string scalars",
6157 );
6158 continue;
6159 }
6160 let raw = scalar_string_from_source(&self.source, &scalar);
6161 let item_span = span_from_position(self.source_id, scalar.byte_range());
6162 if !seen.insert(raw.clone()) {
6163 self.diagnostics.push(
6164 Diagnostic::new(
6165 BUILD_EXTRA_HOSTS_DUPLICATE_ITEM,
6166 Severity::Error,
6167 "build extra_hosts list entries must be unique raw strings",
6168 )
6169 .with_label(DiagnosticLabel::primary(item_span, "duplicate entry retained")),
6170 );
6171 }
6172 values.push(Located::new(raw, item_span));
6173 }
6174 Some(BuildExtraHosts::List { span, values })
6175 }
6176 Some(YamlNode::Mapping(mapping)) => {
6177 let span = span_from_position(self.source_id, mapping.byte_range());
6178 let mut entries = Vec::new();
6179 let mut seen = BTreeMap::new();
6180 for entry in self.fields(mapping) {
6181 if self.record_duplicate(&mut seen, &entry) {
6182 continue;
6183 }
6184 let Some(addresses) = self.parse_build_extra_host_addresses(&entry) else {
6185 continue;
6186 };
6187 entries.push(BuildExtraHostEntry::new(entry.name, addresses, entry.span));
6188 }
6189 Some(BuildExtraHosts::Map { span, entries })
6190 }
6191 _ => {
6192 self.expected(
6193 BUILD_EXTRA_HOSTS_EXPECTED_FORM,
6194 field,
6195 "build extra_hosts must be a sequence or mapping",
6196 );
6197 None
6198 }
6199 }
6200 }
6201
6202 fn parse_build_extra_host_addresses(&mut self, field: &ParsedField) -> Option<BuildExtraHostAddresses> {
6203 match field.value.as_ref() {
6204 Some(YamlNode::Scalar(scalar)) => {
6205 if !matches!(
6206 scalar_type_for_value(scalar),
6207 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
6208 ) {
6209 self.expected(
6210 BUILD_EXTRA_HOSTS_EXPECTED_STRING,
6211 field,
6212 "build extra_hosts mapping addresses must be string scalars or sequences of string scalars",
6213 );
6214 return None;
6215 }
6216 let span = span_from_position(self.source_id, scalar.byte_range());
6217 Some(BuildExtraHostAddresses::Scalar(Located::new(
6218 scalar_string_from_source(&self.source, scalar),
6219 span,
6220 )))
6221 }
6222 Some(YamlNode::Sequence(sequence)) => {
6223 let span = span_from_position(self.source_id, sequence.byte_range());
6224 let values = self.parse_string_scalar_nodes(
6225 sequence.values(),
6226 field.span,
6227 "build extra_hosts mapping address lists must contain string scalars",
6228 );
6229 Some(BuildExtraHostAddresses::List { span, values })
6230 }
6231 _ => {
6232 self.expected(
6233 BUILD_EXTRA_HOSTS_EXPECTED_STRING,
6234 field,
6235 "build extra_hosts mapping addresses must be string scalars or sequences of string scalars",
6236 );
6237 None
6238 }
6239 }
6240 }
6241
6242 fn parse_build_additional_contexts(&mut self, field: &ParsedField) -> Option<BuildAdditionalContexts> {
6243 match field.value.as_ref() {
6244 Some(YamlNode::Sequence(sequence)) => {
6245 let span = span_from_position(self.source_id, sequence.byte_range());
6246 let values = self.parse_string_scalar_nodes(
6247 sequence.values(),
6248 field.span,
6249 "build additional context list entries must be string scalars",
6250 );
6251 Some(BuildAdditionalContexts::List { span, values })
6252 }
6253 Some(YamlNode::Mapping(mapping)) => {
6254 let span = span_from_position(self.source_id, mapping.byte_range());
6255 let entries = self.parse_scalar_mapping(field, "build additional contexts");
6256 Some(BuildAdditionalContexts::Map { span, entries })
6257 }
6258 _ => {
6259 self.expected(
6260 EXPECTED_FIELD_FORM,
6261 field,
6262 "build additional_contexts must be a sequence or mapping",
6263 );
6264 None
6265 }
6266 }
6267 }
6268
6269 fn parse_build_platforms(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
6270 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6271 self.expected(
6272 EXPECTED_SEQUENCE,
6273 field,
6274 "build platforms must be a sequence of scalars",
6275 );
6276 return None;
6277 };
6278 Some(self.parse_scalar_nodes(
6279 sequence.values(),
6280 field.span,
6281 "build platform entries must be non-null scalars",
6282 ))
6283 }
6284
6285 fn parse_build_args(&mut self, field: &ParsedField) -> Option<BuildArgs> {
6286 match field.value.as_ref() {
6287 Some(YamlNode::Sequence(sequence)) => {
6288 let span = span_from_position(self.source_id, sequence.byte_range());
6289 let values = self.parse_string_scalar_nodes(
6290 sequence.values(),
6291 field.span,
6292 "build argument list entries must be string scalars",
6293 );
6294 Some(BuildArgs::List { span, values })
6295 }
6296 Some(YamlNode::Mapping(mapping)) => {
6297 let span = span_from_position(self.source_id, mapping.byte_range());
6298 let entries = self.parse_scalar_mapping(field, "build arguments");
6299 Some(BuildArgs::Map { span, entries })
6300 }
6301 _ => {
6302 self.expected(EXPECTED_FIELD_FORM, field, "build args must be a sequence or mapping");
6303 None
6304 }
6305 }
6306 }
6307
6308 fn parse_build_ssh(&mut self, field: &ParsedField) -> Option<BuildSsh> {
6309 match field.value.as_ref() {
6310 Some(YamlNode::Sequence(sequence)) => {
6311 let span = span_from_position(self.source_id, sequence.byte_range());
6312 let mut values = Vec::new();
6313 let mut seen = BTreeSet::new();
6314 for node in sequence.values() {
6315 let YamlNode::Scalar(scalar) = node else {
6316 self.unsupported_sequence_item(
6317 BUILD_SSH_EXPECTED_FORM,
6318 &node,
6319 field.span,
6320 "build ssh list entries must be string scalars",
6321 );
6322 continue;
6323 };
6324 if !matches!(
6325 scalar_type_for_value(&scalar),
6326 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
6327 ) {
6328 self.unsupported_sequence_item(
6329 BUILD_SSH_EXPECTED_FORM,
6330 &YamlNode::Scalar(scalar),
6331 field.span,
6332 "build ssh list entries must be string scalars",
6333 );
6334 continue;
6335 }
6336 let value = scalar_string_from_source(&self.source, &scalar);
6337 let span = span_from_position(self.source_id, scalar.byte_range());
6338 if !seen.insert(value.clone()) {
6339 self.diagnostics.push(
6340 Diagnostic::new(
6341 BUILD_SSH_DUPLICATE_ITEM,
6342 Severity::Error,
6343 "build ssh list entries must be unique",
6344 )
6345 .with_label(DiagnosticLabel::primary(span, "duplicate SSH entry retained")),
6346 );
6347 }
6348 values.push(Located::new(value, span));
6349 }
6350 Some(BuildSsh::list(span, values))
6351 }
6352 Some(YamlNode::Mapping(mapping)) => {
6353 let span = span_from_position(self.source_id, mapping.byte_range());
6354 let mut entries = Vec::new();
6355 let mut seen = BTreeMap::new();
6356 for entry in self.fields(mapping) {
6357 if self.record_duplicate(&mut seen, &entry) {
6358 continue;
6359 }
6360 let key_span = entry.name.span;
6361 let Some(value) = entry.value.as_ref() else {
6362 entries.push(KeyValueEntry::new(
6363 entry.name,
6364 Located::new(ComposeScalar::Null, key_span),
6365 entry.span,
6366 ));
6367 continue;
6368 };
6369 let Some(scalar) = value.as_scalar() else {
6370 self.expected(
6371 BUILD_SSH_EXPECTED_FORM,
6372 &entry,
6373 "build ssh mapping values must be scalars or null",
6374 );
6375 continue;
6376 };
6377 let scalar_span = span_from_position(self.source_id, scalar.byte_range());
6378 let scalar_value = ScalarValue::from_scalar(scalar);
6379 let value = match scalar_type_for_value(scalar) {
6380 ScalarType::Null => ComposeScalar::Null,
6381 ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
6382 ScalarType::Integer | ScalarType::Float => {
6383 ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
6384 }
6385 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
6386 ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
6387 }
6388 };
6389 entries.push(KeyValueEntry::new(
6390 entry.name,
6391 Located::new(value, scalar_span),
6392 entry.span,
6393 ));
6394 }
6395 Some(BuildSsh::map(span, entries))
6396 }
6397 _ => {
6398 self.expected(
6399 BUILD_SSH_EXPECTED_FORM,
6400 field,
6401 "build ssh must be a sequence or mapping",
6402 );
6403 None
6404 }
6405 }
6406 }
6407
6408 fn set_build_ssh(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
6409 if let Some(ssh) = self.parse_build_ssh(field) {
6410 definition.set_ssh(ssh);
6411 }
6412 }
6413
6414 fn parse_deploy(&mut self, field: &ParsedField) -> Option<DeployDefinition> {
6415 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6416 self.expected(EXPECTED_MAPPING, field, "deploy must be a mapping");
6417 return None;
6418 };
6419 let span = span_from_position(self.source_id, mapping.byte_range());
6420 let mut definition = DeployDefinition::new(span);
6421 let mut seen = BTreeMap::new();
6422 for option in self.fields(mapping) {
6423 let duplicate = self.record_duplicate(&mut seen, &option);
6424 if duplicate {
6425 continue;
6426 }
6427 if let Some(kind) = DeployFieldKind::from_name(option.name.value()) {
6428 definition.push_field(DeployField::new(kind, option.reference()));
6429 match kind {
6430 DeployFieldKind::EndpointMode => self.set_deploy_endpoint_mode(&mut definition, &option),
6431 DeployFieldKind::Labels => self
6432 .parse_labels(&option)
6433 .into_iter()
6434 .for_each(|labels| definition.set_labels(labels)),
6435 DeployFieldKind::Mode => self.set_deploy_mode(&mut definition, &option),
6436 DeployFieldKind::Placement => self
6437 .parse_deploy_placement(&option)
6438 .into_iter()
6439 .for_each(|value| definition.set_placement(value)),
6440 DeployFieldKind::Replicas => self.set_deploy_replicas(&mut definition, &option),
6441 DeployFieldKind::Resources => self
6442 .parse_deploy_resources(&option)
6443 .into_iter()
6444 .for_each(|value| definition.set_resources(value)),
6445 DeployFieldKind::RestartPolicy => self
6446 .parse_deploy_restart_policy(&option)
6447 .into_iter()
6448 .for_each(|value| definition.set_restart_policy(value)),
6449 DeployFieldKind::RollbackConfig => self
6450 .parse_deploy_rollback_config(&option)
6451 .into_iter()
6452 .for_each(|value| definition.set_rollback_config(value)),
6453 DeployFieldKind::UpdateConfig => self
6454 .parse_deploy_update_config(&option)
6455 .into_iter()
6456 .for_each(|value| definition.set_update_config(value)),
6457 }
6458 } else if option.name.value().starts_with("x-") {
6459 definition.push_extension(option.reference());
6460 } else {
6461 definition.push_unknown(option.reference());
6462 }
6463 }
6464 Some(definition)
6465 }
6466
6467 fn set_deploy_endpoint_mode(&mut self, definition: &mut DeployDefinition, field: &ParsedField) {
6468 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6469 self.expected(
6470 EXPECTED_SCALAR,
6471 field,
6472 "deploy endpoint_mode must be a YAML string scalar",
6473 );
6474 return;
6475 };
6476 if scalar_type_for_value(scalar) != ScalarType::String {
6477 self.expected(
6478 EXPECTED_SCALAR,
6479 field,
6480 "deploy endpoint_mode must be a YAML string scalar",
6481 );
6482 return;
6483 }
6484 let endpoint_mode = Located::new(
6485 DeployEndpointMode::parse(scalar_string_from_source(&self.source, scalar)),
6486 span_from_position(self.source_id, scalar.byte_range()),
6487 );
6488 if !endpoint_mode.value().is_documented() {
6489 self.diagnostics.push(
6490 Diagnostic::new(
6491 DEPLOY_ENDPOINT_MODE_PORTABILITY,
6492 Severity::Warning,
6493 "deploy endpoint_mode is outside Compose's documented portable values",
6494 )
6495 .with_label(DiagnosticLabel::primary(
6496 endpoint_mode.span(),
6497 "retained provider-specific endpoint mode",
6498 )),
6499 );
6500 }
6501 definition.set_endpoint_mode(endpoint_mode);
6502 }
6503
6504 fn set_deploy_mode(&mut self, definition: &mut DeployDefinition, field: &ParsedField) {
6505 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6506 self.expected(EXPECTED_SCALAR, field, "deploy mode must be a YAML string scalar");
6507 return;
6508 };
6509 if scalar_type_for_value(scalar) != ScalarType::String {
6510 self.expected(EXPECTED_SCALAR, field, "deploy mode must be a YAML string scalar");
6511 return;
6512 }
6513 let mode = Located::new(
6514 DeployMode::parse(scalar_string_from_source(&self.source, scalar)),
6515 span_from_position(self.source_id, scalar.byte_range()),
6516 );
6517 if !mode.value().is_documented() {
6518 self.diagnostics.push(
6519 Diagnostic::new(
6520 DEPLOY_MODE_PORTABILITY,
6521 Severity::Warning,
6522 "deploy mode is outside Compose's documented portable values",
6523 )
6524 .with_label(DiagnosticLabel::primary(
6525 mode.span(),
6526 "retained provider-specific deploy mode",
6527 )),
6528 );
6529 }
6530 definition.set_mode(mode);
6531 }
6532
6533 fn set_deploy_replicas(&mut self, definition: &mut DeployDefinition, field: &ParsedField) {
6534 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6535 self.expected(
6536 EXPECTED_SCALAR,
6537 field,
6538 "deploy replicas must be a YAML number or string scalar",
6539 );
6540 return;
6541 };
6542 let value = match scalar_type_for_value(scalar) {
6543 ScalarType::Integer | ScalarType::Float => {
6544 DeployReplicas::YamlNumber(scalar_string_from_source(&self.source, scalar))
6545 }
6546 ScalarType::String => DeployReplicas::String(scalar_string_from_source(&self.source, scalar)),
6547 ScalarType::Boolean | ScalarType::Null | ScalarType::Timestamp | ScalarType::Regex => {
6548 self.expected(
6549 EXPECTED_SCALAR,
6550 field,
6551 "deploy replicas must be a YAML number or string scalar",
6552 );
6553 return;
6554 }
6555 };
6556 definition.set_replicas(Located::new(
6557 value,
6558 span_from_position(self.source_id, scalar.byte_range()),
6559 ));
6560 }
6561
6562 fn parse_deploy_restart_policy(&mut self, field: &ParsedField) -> Option<DeployRestartPolicy> {
6563 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6564 self.expected(EXPECTED_MAPPING, field, "deploy restart_policy must be a mapping");
6565 return None;
6566 };
6567 let mut policy = DeployRestartPolicy::new(span_from_position(self.source_id, mapping.byte_range()));
6568 let mut seen = BTreeMap::new();
6569 for option in self.fields(mapping) {
6570 if self.record_duplicate(&mut seen, &option) {
6571 continue;
6572 }
6573 match option.name.value().as_str() {
6574 name if name.starts_with("x-") => {
6575 policy.push_extension(option.reference());
6576 continue;
6577 }
6578 "condition" | "delay" | "max_attempts" | "window" => {}
6579 _ => {
6580 policy.push_unknown(option.reference());
6581 continue;
6582 }
6583 }
6584 let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) else {
6585 self.expected(EXPECTED_SCALAR, &option, "deploy restart-policy members must be scalar");
6586 continue;
6587 };
6588 let span = span_from_position(self.source_id, scalar.byte_range());
6589 match option.name.value().as_str() {
6590 "condition" if scalar_type_for_value(scalar) == ScalarType::String => {
6591 policy.set_condition(Located::new(
6592 DeployRestartCondition::parse(scalar_string_from_source(&self.source, scalar)),
6593 span,
6594 ));
6595 }
6596 "delay" | "window" if scalar_type_for_value(scalar) == ScalarType::String => {
6597 let value = Located::new(
6598 DeployRestartDuration::new(scalar_string_from_source(&self.source, scalar)),
6599 span,
6600 );
6601 if option.name.value() == "delay" {
6602 policy.set_delay(value);
6603 } else {
6604 policy.set_window(value);
6605 }
6606 }
6607 "max_attempts" => match scalar_type_for_value(scalar) {
6608 ScalarType::Integer => policy.set_max_attempts(Located::new(
6609 DeployRestartMaxAttempts::YamlNumber(scalar_string_from_source(&self.source, scalar)),
6610 span,
6611 )),
6612 ScalarType::String => policy.set_max_attempts(Located::new(
6613 DeployRestartMaxAttempts::String(scalar_string_from_source(&self.source, scalar)),
6614 span,
6615 )),
6616 _ => self.expected(
6617 EXPECTED_SCALAR,
6618 &option,
6619 "deploy restart-policy max_attempts must be a YAML integer or string scalar",
6620 ),
6621 },
6622 "condition" | "delay" | "window" => self.expected(
6623 EXPECTED_SCALAR,
6624 &option,
6625 "deploy restart-policy condition, delay, and window must be YAML string scalars",
6626 ),
6627 _ => unreachable!("recognized deploy restart-policy field already matched"),
6628 }
6629 }
6630 Some(policy)
6631 }
6632
6633 fn parse_deploy_update_config(&mut self, field: &ParsedField) -> Option<DeployUpdateConfig> {
6634 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6635 self.expected(EXPECTED_MAPPING, field, "deploy update_config must be a mapping");
6636 return None;
6637 };
6638 let mut config = DeployUpdateConfig::new(span_from_position(self.source_id, mapping.byte_range()));
6639 let mut seen = BTreeMap::new();
6640 for member in self.fields(mapping) {
6641 if self.record_duplicate(&mut seen, &member) {
6642 continue;
6643 }
6644 let parsed = match member.name.value().as_str() {
6645 name if name.starts_with("x-") => {
6646 config.push_extension(member.reference());
6647 true
6648 }
6649 "parallelism" => self
6650 .parse_deploy_update_parallelism(&member)
6651 .map(|value| {
6652 config.set_parallelism(value);
6653 })
6654 .is_some(),
6655 "delay" => self
6656 .parse_deploy_update_string(&member, "deploy update_config delay must be a YAML string scalar")
6657 .map(|value| {
6658 config.set_delay(value);
6659 })
6660 .is_some(),
6661 "monitor" => self
6662 .parse_deploy_update_string(&member, "deploy update_config monitor must be a YAML string scalar")
6663 .map(|value| {
6664 config.set_monitor(value);
6665 })
6666 .is_some(),
6667 "failure_action" => self
6668 .parse_deploy_update_string(
6669 &member,
6670 "deploy update_config failure_action must be a YAML string scalar",
6671 )
6672 .map(|value| {
6673 config.set_failure_action(value);
6674 })
6675 .is_some(),
6676 "max_failure_ratio" => self
6677 .parse_deploy_update_max_failure_ratio(&member)
6678 .map(|value| {
6679 config.set_max_failure_ratio(value);
6680 })
6681 .is_some(),
6682 "order" => self
6683 .parse_deploy_update_order(&member)
6684 .map(|value| {
6685 config.set_order(value);
6686 })
6687 .is_some(),
6688 _ => {
6689 config.push_unknown(member.reference());
6690 true
6691 }
6692 };
6693 if !parsed {
6694 config.push_unknown(member.reference());
6695 }
6696 }
6697 Some(config)
6698 }
6699
6700 fn parse_deploy_rollback_config(&mut self, field: &ParsedField) -> Option<DeployRollbackConfig> {
6701 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6702 self.expected(EXPECTED_MAPPING, field, "deploy rollback_config must be a mapping");
6703 return None;
6704 };
6705 let mut config = DeployRollbackConfig::new(span_from_position(self.source_id, mapping.byte_range()));
6706 let mut seen = BTreeMap::new();
6707 for member in self.fields(mapping) {
6708 if self.record_duplicate(&mut seen, &member) {
6709 continue;
6710 }
6711 let parsed = match member.name.value().as_str() {
6712 name if name.starts_with("x-") => {
6713 config.push_extension(member.reference());
6714 true
6715 }
6716 "parallelism" => self
6717 .parse_deploy_rollback_parallelism(&member)
6718 .map(|value| config.set_parallelism(value))
6719 .is_some(),
6720 "delay" => self
6721 .parse_deploy_rollback_string(&member, "deploy rollback_config delay must be a YAML string scalar")
6722 .map(|value| config.set_delay(value))
6723 .is_some(),
6724 "monitor" => self
6725 .parse_deploy_rollback_string(
6726 &member,
6727 "deploy rollback_config monitor must be a YAML string scalar",
6728 )
6729 .map(|value| config.set_monitor(value))
6730 .is_some(),
6731 "failure_action" => self
6732 .parse_deploy_rollback_string(
6733 &member,
6734 "deploy rollback_config failure_action must be a YAML string scalar",
6735 )
6736 .map(|value| config.set_failure_action(value))
6737 .is_some(),
6738 "max_failure_ratio" => self
6739 .parse_deploy_rollback_max_failure_ratio(&member)
6740 .map(|value| config.set_max_failure_ratio(value))
6741 .is_some(),
6742 "order" => self
6743 .parse_deploy_rollback_order(&member)
6744 .map(|value| config.set_order(value))
6745 .is_some(),
6746 _ => {
6747 config.push_unknown(member.reference());
6748 true
6749 }
6750 };
6751 if !parsed {
6752 config.push_unknown(member.reference());
6753 }
6754 }
6755 Some(config)
6756 }
6757
6758 fn parse_deploy_rollback_string(&mut self, field: &ParsedField, message: &'static str) -> Option<Located<String>> {
6759 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6760 self.expected(EXPECTED_SCALAR, field, message);
6761 return None;
6762 };
6763 if scalar_type_for_value(scalar) != ScalarType::String {
6764 self.expected(EXPECTED_SCALAR, field, message);
6765 return None;
6766 }
6767 Some(Located::new(
6768 scalar_string_from_source(&self.source, scalar),
6769 span_from_position(self.source_id, scalar.byte_range()),
6770 ))
6771 }
6772
6773 fn parse_deploy_rollback_parallelism(&mut self, field: &ParsedField) -> Option<Located<DeployRollbackParallelism>> {
6774 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6775 self.expected(
6776 EXPECTED_SCALAR,
6777 field,
6778 "deploy rollback_config parallelism must be a YAML integer or string scalar",
6779 );
6780 return None;
6781 };
6782 let value = match scalar_type_for_value(scalar) {
6783 ScalarType::Integer => {
6784 DeployRollbackParallelism::YamlInteger(scalar_string_from_source(&self.source, scalar))
6785 }
6786 ScalarType::String => DeployRollbackParallelism::String(scalar_string_from_source(&self.source, scalar)),
6787 _ => {
6788 self.expected(
6789 EXPECTED_SCALAR,
6790 field,
6791 "deploy rollback_config parallelism must be a YAML integer or string scalar",
6792 );
6793 return None;
6794 }
6795 };
6796 Some(Located::new(
6797 value,
6798 span_from_position(self.source_id, scalar.byte_range()),
6799 ))
6800 }
6801
6802 fn parse_deploy_rollback_max_failure_ratio(
6803 &mut self,
6804 field: &ParsedField,
6805 ) -> Option<Located<DeployRollbackMaxFailureRatio>> {
6806 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6807 self.expected(
6808 EXPECTED_SCALAR,
6809 field,
6810 "deploy rollback_config max_failure_ratio must be a YAML number or string scalar",
6811 );
6812 return None;
6813 };
6814 let value = match scalar_type_for_value(scalar) {
6815 ScalarType::Integer | ScalarType::Float => {
6816 DeployRollbackMaxFailureRatio::YamlNumber(scalar_string_from_source(&self.source, scalar))
6817 }
6818 ScalarType::String => {
6819 DeployRollbackMaxFailureRatio::String(scalar_string_from_source(&self.source, scalar))
6820 }
6821 _ => {
6822 self.expected(
6823 EXPECTED_SCALAR,
6824 field,
6825 "deploy rollback_config max_failure_ratio must be a YAML number or string scalar",
6826 );
6827 return None;
6828 }
6829 };
6830 Some(Located::new(
6831 value,
6832 span_from_position(self.source_id, scalar.byte_range()),
6833 ))
6834 }
6835
6836 fn parse_deploy_rollback_order(&mut self, field: &ParsedField) -> Option<Located<DeployRollbackOrder>> {
6837 let raw =
6838 self.parse_deploy_rollback_string(field, "deploy rollback_config order must be a YAML string scalar")?;
6839 let order = DeployRollbackOrder::parse(raw.value().clone());
6840 if !order.is_documented() {
6841 self.diagnostics.push(
6842 Diagnostic::new(
6843 DEPLOY_ROLLBACK_CONFIG_ORDER_PORTABILITY,
6844 Severity::Warning,
6845 "deploy rollback_config order is outside Compose's documented portable values",
6846 )
6847 .with_label(DiagnosticLabel::primary(
6848 raw.span(),
6849 "retained provider-specific rollback order",
6850 )),
6851 );
6852 }
6853 Some(Located::new(order, raw.span()))
6854 }
6855
6856 fn parse_deploy_update_string(&mut self, field: &ParsedField, message: &'static str) -> Option<Located<String>> {
6857 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6858 self.expected(EXPECTED_SCALAR, field, message);
6859 return None;
6860 };
6861 if scalar_type_for_value(scalar) != ScalarType::String {
6862 self.expected(EXPECTED_SCALAR, field, message);
6863 return None;
6864 }
6865 Some(Located::new(
6866 scalar_string_from_source(&self.source, scalar),
6867 span_from_position(self.source_id, scalar.byte_range()),
6868 ))
6869 }
6870
6871 fn parse_deploy_update_parallelism(&mut self, field: &ParsedField) -> Option<Located<DeployUpdateParallelism>> {
6872 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6873 self.expected(
6874 EXPECTED_SCALAR,
6875 field,
6876 "deploy update_config parallelism must be a YAML integer or string scalar",
6877 );
6878 return None;
6879 };
6880 let value = match scalar_type_for_value(scalar) {
6881 ScalarType::Integer => {
6882 DeployUpdateParallelism::YamlInteger(scalar_string_from_source(&self.source, scalar))
6883 }
6884 ScalarType::String => DeployUpdateParallelism::String(scalar_string_from_source(&self.source, scalar)),
6885 _ => {
6886 self.expected(
6887 EXPECTED_SCALAR,
6888 field,
6889 "deploy update_config parallelism must be a YAML integer or string scalar",
6890 );
6891 return None;
6892 }
6893 };
6894 Some(Located::new(
6895 value,
6896 span_from_position(self.source_id, scalar.byte_range()),
6897 ))
6898 }
6899 fn parse_deploy_update_max_failure_ratio(
6900 &mut self,
6901 field: &ParsedField,
6902 ) -> Option<Located<DeployUpdateMaxFailureRatio>> {
6903 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
6904 self.expected(
6905 EXPECTED_SCALAR,
6906 field,
6907 "deploy update_config max_failure_ratio must be a YAML number or string scalar",
6908 );
6909 return None;
6910 };
6911 let value = match scalar_type_for_value(scalar) {
6912 ScalarType::Integer | ScalarType::Float => {
6913 DeployUpdateMaxFailureRatio::YamlNumber(scalar_string_from_source(&self.source, scalar))
6914 }
6915 ScalarType::String => DeployUpdateMaxFailureRatio::String(scalar_string_from_source(&self.source, scalar)),
6916 _ => {
6917 self.expected(
6918 EXPECTED_SCALAR,
6919 field,
6920 "deploy update_config max_failure_ratio must be a YAML number or string scalar",
6921 );
6922 return None;
6923 }
6924 };
6925 Some(Located::new(
6926 value,
6927 span_from_position(self.source_id, scalar.byte_range()),
6928 ))
6929 }
6930 fn parse_deploy_update_order(&mut self, field: &ParsedField) -> Option<Located<DeployUpdateOrder>> {
6931 let raw = self.parse_deploy_update_string(field, "deploy update_config order must be a YAML string scalar")?;
6932 let order = DeployUpdateOrder::parse(raw.value().clone());
6933 if !order.is_documented() {
6934 self.diagnostics.push(
6935 Diagnostic::new(
6936 DEPLOY_UPDATE_CONFIG_ORDER_PORTABILITY,
6937 Severity::Warning,
6938 "deploy update_config order is outside Compose's documented portable values",
6939 )
6940 .with_label(DiagnosticLabel::primary(
6941 raw.span(),
6942 "retained provider-specific update order",
6943 )),
6944 );
6945 }
6946 Some(Located::new(order, raw.span()))
6947 }
6948
6949 fn parse_deploy_placement(&mut self, field: &ParsedField) -> Option<DeployPlacement> {
6950 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
6951 self.expected(EXPECTED_MAPPING, field, "deploy placement must be a mapping");
6952 return None;
6953 };
6954 let mut placement = DeployPlacement::new(span_from_position(self.source_id, mapping.byte_range()));
6955 let mut seen = BTreeMap::new();
6956 for option in self.fields(mapping) {
6957 if self.record_duplicate(&mut seen, &option) {
6958 continue;
6959 }
6960 match option.name.value().as_str() {
6961 name if name.starts_with("x-") => placement.push_extension(option.reference()),
6962 "constraints" => self
6963 .parse_deploy_placement_constraints(&option)
6964 .into_iter()
6965 .for_each(|value| placement.set_constraints(value)),
6966 "preferences" => self
6967 .parse_deploy_placement_preferences(&option)
6968 .into_iter()
6969 .for_each(|value| placement.set_preferences(value)),
6970 "max_replicas_per_node" => self
6971 .parse_deploy_placement_max_replicas_per_node(&option)
6972 .into_iter()
6973 .for_each(|value| placement.set_max_replicas_per_node(value)),
6974 _ => placement.push_unknown(option.reference()),
6975 }
6976 }
6977 Some(placement)
6978 }
6979
6980 fn parse_deploy_placement_constraints(&mut self, field: &ParsedField) -> Option<Vec<Located<String>>> {
6981 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
6982 self.expected(
6983 EXPECTED_SEQUENCE,
6984 field,
6985 "deploy placement constraints must be a sequence",
6986 );
6987 return None;
6988 };
6989 let mut constraints = Vec::new();
6990 for value in sequence.values() {
6991 let YamlNode::Scalar(scalar) = value else {
6992 self.unsupported_sequence_item(
6993 EXPECTED_SCALAR,
6994 &value,
6995 field.span,
6996 "deploy placement constraints must contain YAML string scalars",
6997 );
6998 continue;
6999 };
7000 if scalar_type_for_value(&scalar) != ScalarType::String {
7001 self.unsupported_sequence_item(
7002 EXPECTED_SCALAR,
7003 &YamlNode::Scalar(scalar),
7004 field.span,
7005 "deploy placement constraints must contain YAML string scalars",
7006 );
7007 continue;
7008 }
7009 constraints.push(Located::new(
7010 scalar_string_from_source(&self.source, &scalar),
7011 span_from_position(self.source_id, scalar.byte_range()),
7012 ));
7013 }
7014 Some(constraints)
7015 }
7016
7017 fn parse_deploy_placement_preferences(&mut self, field: &ParsedField) -> Option<Vec<DeployPlacementPreference>> {
7018 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7019 self.expected(
7020 EXPECTED_SEQUENCE,
7021 field,
7022 "deploy placement preferences must be a sequence",
7023 );
7024 return None;
7025 };
7026 let mut preferences = Vec::new();
7027 for value in sequence.values() {
7028 let YamlNode::Mapping(mapping) = value else {
7029 self.unsupported_sequence_item(
7030 EXPECTED_MAPPING,
7031 &value,
7032 field.span,
7033 "deploy placement preferences must contain mappings",
7034 );
7035 continue;
7036 };
7037 let mut preference =
7038 DeployPlacementPreference::new(span_from_position(self.source_id, mapping.byte_range()));
7039 let mut seen = BTreeMap::new();
7040 for member in self.fields(&mapping) {
7041 if self.record_duplicate(&mut seen, &member) {
7042 continue;
7043 }
7044 match member.name.value().as_str() {
7045 name if name.starts_with("x-") => preference.push_extension(member.reference()),
7046 "spread" => self
7047 .parse_deploy_placement_string(&member, "deploy placement preference spread")
7048 .into_iter()
7049 .for_each(|value| preference.set_spread(value)),
7050 _ => preference.push_unknown(member.reference()),
7051 }
7052 }
7053 preferences.push(preference);
7054 }
7055 Some(preferences)
7056 }
7057
7058 fn parse_deploy_placement_max_replicas_per_node(
7059 &mut self,
7060 field: &ParsedField,
7061 ) -> Option<Located<DeployPlacementMaxReplicasPerNode>> {
7062 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7063 self.expected(
7064 EXPECTED_SCALAR,
7065 field,
7066 "deploy placement max_replicas_per_node must be a YAML integer or string scalar",
7067 );
7068 return None;
7069 };
7070 let value = match scalar_type_for_value(scalar) {
7071 ScalarType::Integer => {
7072 DeployPlacementMaxReplicasPerNode::YamlInteger(scalar_string_from_source(&self.source, scalar))
7073 }
7074 ScalarType::String => {
7075 DeployPlacementMaxReplicasPerNode::String(scalar_string_from_source(&self.source, scalar))
7076 }
7077 _ => {
7078 self.expected(
7079 EXPECTED_SCALAR,
7080 field,
7081 "deploy placement max_replicas_per_node must be a YAML integer or string scalar",
7082 );
7083 return None;
7084 }
7085 };
7086 Some(Located::new(
7087 value,
7088 span_from_position(self.source_id, scalar.byte_range()),
7089 ))
7090 }
7091
7092 fn parse_deploy_placement_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
7093 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7094 self.expected(
7095 EXPECTED_SCALAR,
7096 field,
7097 format!("{description} must be a YAML string scalar"),
7098 );
7099 return None;
7100 };
7101 if scalar_type_for_value(scalar) != ScalarType::String {
7102 self.expected(
7103 EXPECTED_SCALAR,
7104 field,
7105 format!("{description} must be a YAML string scalar"),
7106 );
7107 return None;
7108 }
7109 Some(Located::new(
7110 scalar_string_from_source(&self.source, scalar),
7111 span_from_position(self.source_id, scalar.byte_range()),
7112 ))
7113 }
7114
7115 fn parse_deploy_resources(&mut self, field: &ParsedField) -> Option<DeployResources> {
7116 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7117 self.expected(EXPECTED_MAPPING, field, "deploy resources must be a mapping");
7118 return None;
7119 };
7120 let mut resources = DeployResources::new(span_from_position(self.source_id, mapping.byte_range()));
7121 let mut seen = BTreeMap::new();
7122 for option in self.fields(mapping) {
7123 if self.record_duplicate(&mut seen, &option) {
7124 continue;
7125 }
7126 match option.name.value().as_str() {
7127 name if name.starts_with("x-") => resources.push_extension(option.reference()),
7128 "limits" => self
7129 .parse_deploy_resource_limits(&option)
7130 .into_iter()
7131 .for_each(|value| resources.set_limits(value)),
7132 "reservations" => self
7133 .parse_deploy_resource_reservations(&option)
7134 .into_iter()
7135 .for_each(|value| resources.set_reservations(value)),
7136 _ => resources.push_unknown(option.reference()),
7137 }
7138 }
7139 Some(resources)
7140 }
7141
7142 fn parse_deploy_resource_limits(&mut self, field: &ParsedField) -> Option<DeployResourceLimits> {
7143 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7144 self.expected(EXPECTED_MAPPING, field, "deploy resource limits must be a mapping");
7145 return None;
7146 };
7147 let mut limits = DeployResourceLimits::new(span_from_position(self.source_id, mapping.byte_range()));
7148 let mut seen = BTreeMap::new();
7149 for option in self.fields(mapping) {
7150 if self.record_duplicate(&mut seen, &option) {
7151 continue;
7152 }
7153 match option.name.value().as_str() {
7154 name if name.starts_with("x-") => limits.push_extension(option.reference()),
7155 "cpus" => self
7156 .parse_deploy_resource_cpus(&option, "deploy resource limits cpus")
7157 .into_iter()
7158 .for_each(|value| limits.set_cpus(value)),
7159 "memory" => self
7160 .parse_deploy_resource_memory(&option, "deploy resource limits memory must be a YAML string scalar")
7161 .into_iter()
7162 .for_each(|value| limits.set_memory(value)),
7163 "pids" => self
7164 .parse_deploy_resource_pids(&option)
7165 .into_iter()
7166 .for_each(|value| limits.set_pids(value)),
7167 _ => limits.push_unknown(option.reference()),
7168 }
7169 }
7170 Some(limits)
7171 }
7172
7173 fn parse_deploy_resource_reservations(&mut self, field: &ParsedField) -> Option<DeployResourceReservations> {
7174 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7175 self.expected(
7176 EXPECTED_MAPPING,
7177 field,
7178 "deploy resource reservations must be a mapping",
7179 );
7180 return None;
7181 };
7182 let mut reservations =
7183 DeployResourceReservations::new(span_from_position(self.source_id, mapping.byte_range()));
7184 let mut seen = BTreeMap::new();
7185 for option in self.fields(mapping) {
7186 if self.record_duplicate(&mut seen, &option) {
7187 continue;
7188 }
7189 match option.name.value().as_str() {
7190 name if name.starts_with("x-") => reservations.push_extension(option.reference()),
7191 "cpus" => self
7192 .parse_deploy_resource_cpus(&option, "deploy resource reservations cpus")
7193 .into_iter()
7194 .for_each(|value| reservations.set_cpus(value)),
7195 "memory" => self
7196 .parse_deploy_resource_memory(
7197 &option,
7198 "deploy resource reservations memory must be a YAML string scalar",
7199 )
7200 .into_iter()
7201 .for_each(|value| reservations.set_memory(value)),
7202 "generic_resources" => {
7203 if let Some(value) = self.parse_deploy_generic_resources(&option) {
7204 reservations.set_generic_resources(value);
7205 } else {
7206 reservations.push_unknown(option.reference());
7207 }
7208 }
7209 "devices" => {
7210 if let Some(value) = self.parse_deploy_reservation_devices(&option) {
7211 reservations.set_devices(value);
7212 } else {
7213 reservations.push_unknown(option.reference());
7214 }
7215 }
7216 _ => reservations.push_unknown(option.reference()),
7217 }
7218 }
7219 Some(reservations)
7220 }
7221
7222 fn parse_deploy_resource_pids(&mut self, field: &ParsedField) -> Option<Located<DeployResourcePids>> {
7223 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7224 self.expected(
7225 EXPECTED_SCALAR,
7226 field,
7227 "deploy resource limits pids must be a YAML integer or string scalar",
7228 );
7229 return None;
7230 };
7231 let value = match scalar_type_for_value(scalar) {
7232 ScalarType::Integer => DeployResourcePids::YamlInteger(scalar_string_from_source(&self.source, scalar)),
7233 ScalarType::String => DeployResourcePids::String(scalar_string_from_source(&self.source, scalar)),
7234 _ => {
7235 self.expected(
7236 EXPECTED_SCALAR,
7237 field,
7238 "deploy resource limits pids must be a YAML integer or string scalar",
7239 );
7240 return None;
7241 }
7242 };
7243 Some(Located::new(
7244 value,
7245 span_from_position(self.source_id, scalar.byte_range()),
7246 ))
7247 }
7248
7249 fn parse_deploy_resource_cpus(
7250 &mut self,
7251 field: &ParsedField,
7252 context: &'static str,
7253 ) -> Option<Located<DeployResourceCpus>> {
7254 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7255 self.expected(
7256 EXPECTED_SCALAR,
7257 field,
7258 format!("{context} must be a YAML number or string scalar"),
7259 );
7260 return None;
7261 };
7262 let value = match scalar_type_for_value(scalar) {
7263 ScalarType::Integer | ScalarType::Float => {
7264 DeployResourceCpus::YamlNumber(scalar_string_from_source(&self.source, scalar))
7265 }
7266 ScalarType::String => DeployResourceCpus::String(scalar_string_from_source(&self.source, scalar)),
7267 _ => {
7268 self.expected(
7269 EXPECTED_SCALAR,
7270 field,
7271 format!("{context} must be a YAML number or string scalar"),
7272 );
7273 return None;
7274 }
7275 };
7276 Some(Located::new(
7277 value,
7278 span_from_position(self.source_id, scalar.byte_range()),
7279 ))
7280 }
7281
7282 fn parse_deploy_resource_memory(
7283 &mut self,
7284 field: &ParsedField,
7285 message: &'static str,
7286 ) -> Option<Located<DeployResourceMemory>> {
7287 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7288 self.expected(EXPECTED_SCALAR, field, message);
7289 return None;
7290 };
7291 if scalar_type_for_value(scalar) != ScalarType::String {
7292 self.expected(EXPECTED_SCALAR, field, message);
7293 return None;
7294 }
7295 Some(Located::new(
7296 DeployResourceMemory::parse(scalar_string_from_source(&self.source, scalar)),
7297 span_from_position(self.source_id, scalar.byte_range()),
7298 ))
7299 }
7300
7301 fn parse_deploy_generic_resources(&mut self, field: &ParsedField) -> Option<DeployGenericResources> {
7302 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7303 self.expected(
7304 EXPECTED_SEQUENCE,
7305 field,
7306 "deploy resource reservations generic_resources must be a sequence",
7307 );
7308 return None;
7309 };
7310 let mut items = Vec::new();
7311 for node in sequence.values() {
7312 let Some(mapping) = node.as_mapping() else {
7313 self.unsupported_sequence_item(
7314 EXPECTED_MAPPING,
7315 &node,
7316 field.span,
7317 "deploy resource generic-resource entries must be mappings",
7318 );
7319 items.push(DeployGenericResource::unmodeled(
7320 node_span(self.source_id, &node).unwrap_or(field.span),
7321 ));
7322 continue;
7323 };
7324 let mut item = DeployGenericResource::new(span_from_position(self.source_id, mapping.byte_range()));
7325 let mut seen = BTreeMap::new();
7326 for option in self.fields(mapping) {
7327 if self.record_duplicate(&mut seen, &option) {
7328 continue;
7329 }
7330 match option.name.value().as_str() {
7331 name if name.starts_with("x-") => item.push_extension(option.reference()),
7332 "discrete_resource_spec" => {
7333 if let Some(value) = self.parse_deploy_discrete_resource_spec(&option) {
7334 item.set_discrete_resource_spec(value);
7335 } else {
7336 item.push_unknown(option.reference());
7337 }
7338 }
7339 _ => item.push_unknown(option.reference()),
7340 }
7341 }
7342 items.push(item);
7343 }
7344 Some(DeployGenericResources::new(
7345 span_from_position(self.source_id, sequence.byte_range()),
7346 items,
7347 ))
7348 }
7349
7350 fn parse_deploy_reservation_devices(&mut self, field: &ParsedField) -> Option<DeployReservationDevices> {
7351 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7352 self.expected(
7353 EXPECTED_SEQUENCE,
7354 field,
7355 "deploy resource reservation devices must be a sequence",
7356 );
7357 return None;
7358 };
7359 let mut items = Vec::new();
7360 for node in sequence.values() {
7361 let Some(mapping) = node.as_mapping() else {
7362 self.unsupported_sequence_item(
7363 EXPECTED_MAPPING,
7364 &node,
7365 field.span,
7366 "deploy resource reservation device entries must be mappings",
7367 );
7368 items.push(DeployReservationDevice::unmodeled(
7369 node_span(self.source_id, &node).unwrap_or(field.span),
7370 ));
7371 continue;
7372 };
7373 let item_span = span_from_position(self.source_id, mapping.byte_range());
7374 let mut item = DeployReservationDevice::new(item_span);
7375 let mut seen = BTreeMap::new();
7376 let mut has_capabilities = false;
7377 let mut count_field = None;
7378 let mut device_ids_field = None;
7379 for option in self.fields(mapping) {
7380 if self.record_duplicate(&mut seen, &option) {
7381 continue;
7382 }
7383 match option.name.value().as_str() {
7384 name if name.starts_with("x-") => item.push_extension(option.reference()),
7385 "capabilities" => {
7386 has_capabilities = true;
7387 if let Some(value) = self.parse_deploy_reservation_device_capabilities(&option) {
7388 item.set_capabilities(value);
7389 } else {
7390 item.push_unknown(option.reference());
7391 }
7392 }
7393 "driver" => {
7394 let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) else {
7395 self.expected(
7396 EXPECTED_SCALAR,
7397 &option,
7398 "deploy resource reservation device driver must be a YAML string scalar",
7399 );
7400 item.push_unknown(option.reference());
7401 continue;
7402 };
7403 if scalar_type_for_value(scalar) != ScalarType::String {
7404 self.expected(
7405 EXPECTED_SCALAR,
7406 &option,
7407 "deploy resource reservation device driver must be a YAML string scalar",
7408 );
7409 item.push_unknown(option.reference());
7410 continue;
7411 }
7412 item.set_driver(Located::new(
7413 scalar_string_from_source(&self.source, scalar),
7414 span_from_position(self.source_id, scalar.byte_range()),
7415 ));
7416 }
7417 "count" => {
7418 count_field.get_or_insert(option.span);
7419 if let Some(value) = self.parse_deploy_reservation_device_count(&option) {
7420 item.set_count(value);
7421 } else {
7422 item.push_unknown(option.reference());
7423 }
7424 }
7425 "device_ids" => {
7426 device_ids_field.get_or_insert(option.span);
7427 if let Some(value) = self.parse_deploy_reservation_device_ids(&option) {
7428 item.set_device_ids(value);
7429 } else {
7430 item.push_unknown(option.reference());
7431 }
7432 }
7433 "options" => self.set_deploy_reservation_device_options(&mut item, &option),
7434 _ => item.push_unknown(option.reference()),
7435 }
7436 }
7437 self.reservation_device_allocation_selector_conflict(count_field, device_ids_field);
7438 if !has_capabilities {
7439 self.missing(
7440 DEPLOY_RESERVATION_DEVICE_MISSING_CAPABILITIES,
7441 item_span,
7442 "deploy resource reservation device is missing required `capabilities`",
7443 );
7444 }
7445 items.push(item);
7446 }
7447 Some(DeployReservationDevices::new(
7448 span_from_position(self.source_id, sequence.byte_range()),
7449 items,
7450 ))
7451 }
7452
7453 fn reservation_device_allocation_selector_conflict(
7454 &mut self,
7455 count: Option<SourceSpan>,
7456 device_ids: Option<SourceSpan>,
7457 ) {
7458 let (Some(count), Some(device_ids)) = (count, device_ids) else {
7459 return;
7460 };
7461 self.diagnostics.push(
7462 Diagnostic::new(
7463 DEPLOY_RESERVATION_DEVICE_ALLOCATION_SELECTOR_CONFLICT,
7464 Severity::Error,
7465 "deploy resource reservation device count and device_ids are mutually exclusive",
7466 )
7467 .with_label(DiagnosticLabel::primary(count, "count retained"))
7468 .with_label(DiagnosticLabel::secondary(device_ids, "device_ids retained")),
7469 );
7470 }
7471
7472 fn parse_deploy_reservation_device_count(
7473 &mut self,
7474 field: &ParsedField,
7475 ) -> Option<Located<DeployReservationDeviceCount>> {
7476 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
7477 self.expected(
7478 EXPECTED_SCALAR,
7479 field,
7480 "deploy resource reservation device count must be a YAML integer or string scalar",
7481 );
7482 return None;
7483 };
7484 let value = match scalar_type_for_value(scalar) {
7485 ScalarType::Integer => {
7486 DeployReservationDeviceCount::YamlInteger(scalar_string_from_source(&self.source, scalar))
7487 }
7488 ScalarType::String => DeployReservationDeviceCount::String(scalar_string_from_source(&self.source, scalar)),
7489 _ => {
7490 self.expected(
7491 EXPECTED_SCALAR,
7492 field,
7493 "deploy resource reservation device count must be a YAML integer or string scalar",
7494 );
7495 return None;
7496 }
7497 };
7498 Some(Located::new(
7499 value,
7500 span_from_position(self.source_id, scalar.byte_range()),
7501 ))
7502 }
7503
7504 fn parse_deploy_reservation_device_ids(&mut self, field: &ParsedField) -> Option<DeployReservationDeviceIds> {
7505 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7506 self.expected(
7507 EXPECTED_SEQUENCE,
7508 field,
7509 "deploy resource reservation device device_ids must be a sequence",
7510 );
7511 return None;
7512 };
7513 let mut items = Vec::new();
7514 for node in sequence.values() {
7515 let Some(scalar) = node.as_scalar() else {
7516 self.unsupported_sequence_item(
7517 EXPECTED_SCALAR,
7518 &node,
7519 field.span,
7520 "deploy resource reservation device device_ids must be string scalars",
7521 );
7522 items.push(DeployReservationDeviceId::unmodeled(
7523 node_span(self.source_id, &node).unwrap_or(field.span),
7524 ));
7525 continue;
7526 };
7527 let item_span = span_from_position(self.source_id, scalar.byte_range());
7528 if scalar_type_for_value(scalar) != ScalarType::String {
7529 self.unsupported_sequence_item(
7530 EXPECTED_SCALAR,
7531 &YamlNode::Scalar(scalar.clone()),
7532 field.span,
7533 "deploy resource reservation device device_ids must be string scalars",
7534 );
7535 items.push(DeployReservationDeviceId::unmodeled(item_span));
7536 continue;
7537 }
7538 items.push(DeployReservationDeviceId::string(Located::new(
7539 scalar_string_from_source(&self.source, scalar),
7540 item_span,
7541 )));
7542 }
7543 Some(DeployReservationDeviceIds::new(
7544 span_from_position(self.source_id, sequence.byte_range()),
7545 items,
7546 ))
7547 }
7548
7549 fn parse_deploy_reservation_device_options(
7550 &mut self,
7551 field: &ParsedField,
7552 ) -> Option<DeployReservationDeviceOptions> {
7553 match field.value.as_ref() {
7554 Some(YamlNode::Mapping(mapping)) => Some(self.parse_deploy_reservation_device_options_map(mapping)),
7555 Some(YamlNode::Sequence(sequence)) => {
7556 let mut items = Vec::new();
7557 let mut seen = BTreeMap::new();
7558 for node in sequence.values() {
7559 let Some(scalar) = node.as_scalar() else {
7560 self.unsupported_sequence_item(EXPECTED_SCALAR, &node, field.span,
7561 "deploy resource reservation device options list entries must be strict YAML string scalars");
7562 items.push(DeployReservationDeviceOptionItem::unmodeled(
7563 node_span(self.source_id, &node).unwrap_or(field.span),
7564 ));
7565 continue;
7566 };
7567 let span = span_from_position(self.source_id, scalar.byte_range());
7568 if scalar_type_for_value(scalar) != ScalarType::String {
7569 self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar.clone()), field.span,
7570 "deploy resource reservation device options list entries must be strict YAML string scalars");
7571 items.push(DeployReservationDeviceOptionItem::unmodeled(span));
7572 continue;
7573 }
7574 let value = scalar_string_from_source(&self.source, scalar);
7575 if let Some(first) = seen.get(&value) {
7576 self.diagnostics.push(
7577 Diagnostic::new(
7578 DEPLOY_RESERVATION_DEVICE_OPTIONS_DUPLICATE_ITEM,
7579 Severity::Error,
7580 "deploy resource reservation device options list entries must be unique exact strings",
7581 )
7582 .with_label(DiagnosticLabel::primary(span, "duplicate option string"))
7583 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
7584 );
7585 } else {
7586 seen.insert(value.clone(), span);
7587 }
7588 items.push(DeployReservationDeviceOptionItem::string(Located::new(value, span)));
7589 }
7590 Some(DeployReservationDeviceOptions::List {
7591 span: span_from_position(self.source_id, sequence.byte_range()),
7592 items,
7593 })
7594 }
7595 _ => {
7596 self.expected(
7597 DEPLOY_RESERVATION_DEVICE_OPTIONS_EXPECTED_FORM,
7598 field,
7599 "deploy resource reservation device options must be a mapping or sequence",
7600 );
7601 None
7602 }
7603 }
7604 }
7605
7606 fn set_deploy_reservation_device_options(&mut self, device: &mut DeployReservationDevice, field: &ParsedField) {
7607 if let Some(options) = self.parse_deploy_reservation_device_options(field) {
7608 device.set_options(options);
7609 } else {
7610 device.push_unknown(field.reference());
7611 }
7612 }
7613
7614 fn parse_deploy_reservation_device_options_map(&mut self, mapping: &Mapping) -> DeployReservationDeviceOptions {
7615 let mut entries = Vec::new();
7616 let mut unmodeled_entries = Vec::new();
7617 let mut seen = BTreeMap::new();
7618 for entry in mapping.entries() {
7619 let Some(key) = entry.key_node() else { continue };
7620 let key_span = node_span(self.source_id, &key)
7621 .unwrap_or_else(|| span_from_position(self.source_id, mapping.byte_range()));
7622 let authored_value = entry.value_node();
7623 let value_span = authored_value
7624 .as_ref()
7625 .and_then(|value| node_span(self.source_id, value));
7626 let field_span = value_span.map_or(key_span, |value| union(key_span, value));
7627 let Some(scalar) = key.as_scalar() else {
7628 self.diagnostics.push(
7629 Diagnostic::new(
7630 DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY,
7631 Severity::Error,
7632 "deploy resource reservation device options mapping keys must be non-empty strict YAML strings",
7633 )
7634 .with_label(DiagnosticLabel::primary(key_span, "invalid option key")),
7635 );
7636 unmodeled_entries.push(FieldReference {
7637 name: Located::new("<unmodeled-key>".to_owned(), key_span),
7638 span: field_span,
7639 value_span,
7640 });
7641 continue;
7642 };
7643 let name = Located::new(
7644 scalar_string_from_source(&self.source, scalar),
7645 span_from_position(self.source_id, scalar.byte_range()),
7646 );
7647 let field_span = value_span.map_or(name.span(), |value| union(name.span(), value));
7648 let parsed = ParsedField {
7649 name,
7650 value: authored_value
7651 .map(unwrap_processing_tag)
7652 .map(|value| self.resolve_alias(value)),
7653 value_span,
7654 span: field_span,
7655 };
7656 if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String || parsed.name.value().is_empty() {
7657 self.diagnostics.push(
7658 Diagnostic::new(
7659 DEPLOY_RESERVATION_DEVICE_OPTIONS_INVALID_KEY,
7660 Severity::Error,
7661 "deploy resource reservation device options mapping keys must be non-empty strict YAML strings",
7662 )
7663 .with_label(DiagnosticLabel::primary(parsed.name.span(), "invalid option key")),
7664 );
7665 unmodeled_entries.push(parsed.reference());
7666 continue;
7667 }
7668 if self.record_duplicate(&mut seen, &parsed) {
7669 unmodeled_entries.push(parsed.reference());
7670 continue;
7671 }
7672 let Some(value) = self.compose_scalar_from_field(&parsed,
7673 "deploy resource reservation device options mapping values must be scalar strings, numbers, booleans, or null") else {
7674 unmodeled_entries.push(parsed.reference()); continue;
7675 };
7676 entries.push(KeyValueEntry::new(parsed.name, value, parsed.span));
7677 }
7678 DeployReservationDeviceOptions::Map {
7679 span: span_from_position(self.source_id, mapping.byte_range()),
7680 entries,
7681 unmodeled_entries,
7682 }
7683 }
7684
7685 fn compose_scalar_from_field(
7686 &mut self,
7687 field: &ParsedField,
7688 message: &'static str,
7689 ) -> Option<Located<ComposeScalar>> {
7690 let Some(node) = field.value.as_ref() else {
7691 return Some(Located::new(ComposeScalar::Null, field.name.span()));
7692 };
7693 let Some(scalar) = node.as_scalar() else {
7694 self.expected(EXPECTED_SCALAR, field, message);
7695 return None;
7696 };
7697 let value = match scalar_type_for_value(scalar) {
7698 ScalarType::Null => ComposeScalar::Null,
7699 ScalarType::Boolean => ComposeScalar::Boolean(ScalarValue::from_scalar(scalar).to_bool().unwrap_or(false)),
7700 ScalarType::Integer | ScalarType::Float => {
7701 ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
7702 }
7703 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
7704 ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
7705 }
7706 };
7707 Some(Located::new(
7708 value,
7709 span_from_position(self.source_id, scalar.byte_range()),
7710 ))
7711 }
7712
7713 fn parse_deploy_reservation_device_capabilities(
7714 &mut self,
7715 field: &ParsedField,
7716 ) -> Option<DeployReservationDeviceCapabilities> {
7717 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7718 self.expected(
7719 EXPECTED_SEQUENCE,
7720 field,
7721 "deploy resource reservation device capabilities must be a sequence of strings",
7722 );
7723 return None;
7724 };
7725 let mut items = Vec::new();
7726 let mut seen = BTreeMap::new();
7727 for node in sequence.values() {
7728 let Some(scalar) = node.as_scalar() else {
7729 self.unsupported_sequence_item(
7730 EXPECTED_SCALAR,
7731 &node,
7732 field.span,
7733 "deploy resource reservation device capabilities must be string scalars",
7734 );
7735 items.push(DeployReservationDeviceCapability::unmodeled(
7736 node_span(self.source_id, &node).unwrap_or(field.span),
7737 ));
7738 continue;
7739 };
7740 let item_span = span_from_position(self.source_id, scalar.byte_range());
7741 if scalar_type_for_value(scalar) != ScalarType::String {
7742 self.unsupported_sequence_item(
7743 EXPECTED_SCALAR,
7744 &YamlNode::Scalar(scalar.clone()),
7745 field.span,
7746 "deploy resource reservation device capabilities must be string scalars",
7747 );
7748 items.push(DeployReservationDeviceCapability::unmodeled(item_span));
7749 continue;
7750 }
7751 let value = scalar_string_from_source(&self.source, scalar);
7752 if let Some(first) = seen.get(&value) {
7753 self.diagnostics.push(
7754 Diagnostic::new(
7755 DEPLOY_RESERVATION_DEVICE_CAPABILITY_DUPLICATE_ITEM,
7756 Severity::Error,
7757 "deploy resource reservation device capabilities must be unique exact strings",
7758 )
7759 .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
7760 .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
7761 );
7762 } else {
7763 seen.insert(value.clone(), item_span);
7764 }
7765 items.push(DeployReservationDeviceCapability::string(Located::new(
7766 value, item_span,
7767 )));
7768 }
7769 Some(DeployReservationDeviceCapabilities::new(
7770 span_from_position(self.source_id, sequence.byte_range()),
7771 items,
7772 ))
7773 }
7774
7775 fn parse_deploy_discrete_resource_spec(&mut self, field: &ParsedField) -> Option<DeployDiscreteResourceSpec> {
7776 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7777 self.expected(
7778 EXPECTED_MAPPING,
7779 field,
7780 "deploy discrete_resource_spec must be a mapping",
7781 );
7782 return None;
7783 };
7784 let mut spec = DeployDiscreteResourceSpec::new(span_from_position(self.source_id, mapping.byte_range()));
7785 let mut seen = BTreeMap::new();
7786 for option in self.fields(mapping) {
7787 if self.record_duplicate(&mut seen, &option) {
7788 continue;
7789 }
7790 match option.name.value().as_str() {
7791 name if name.starts_with("x-") => spec.push_extension(option.reference()),
7792 "kind" => {
7793 if let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) {
7794 if scalar_type_for_value(scalar) == ScalarType::String {
7795 spec.set_kind(Located::new(
7796 scalar_string_from_source(&self.source, scalar),
7797 span_from_position(self.source_id, scalar.byte_range()),
7798 ));
7799 } else {
7800 self.expected(
7801 EXPECTED_SCALAR,
7802 &option,
7803 "deploy discrete_resource_spec kind must be a YAML string scalar",
7804 );
7805 spec.push_unknown(option.reference());
7806 }
7807 } else {
7808 self.expected(
7809 EXPECTED_SCALAR,
7810 &option,
7811 "deploy discrete_resource_spec kind must be a YAML string scalar",
7812 );
7813 spec.push_unknown(option.reference());
7814 }
7815 }
7816 "value" => {
7817 if let Some(scalar) = option.value.as_ref().and_then(YamlNode::as_scalar) {
7818 let value = match scalar_type_for_value(scalar) {
7819 ScalarType::Integer | ScalarType::Float => Some(DeployDiscreteResourceValue::YamlNumber(
7820 scalar_string_from_source(&self.source, scalar),
7821 )),
7822 ScalarType::String => Some(DeployDiscreteResourceValue::String(scalar_string_from_source(
7823 &self.source,
7824 scalar,
7825 ))),
7826 _ => None,
7827 };
7828 if let Some(value) = value {
7829 spec.set_value(Located::new(
7830 value,
7831 span_from_position(self.source_id, scalar.byte_range()),
7832 ));
7833 } else {
7834 self.expected(
7835 EXPECTED_SCALAR,
7836 &option,
7837 "deploy discrete_resource_spec value must be a YAML number or string scalar",
7838 );
7839 spec.push_unknown(option.reference());
7840 }
7841 } else {
7842 self.expected(
7843 EXPECTED_SCALAR,
7844 &option,
7845 "deploy discrete_resource_spec value must be a YAML number or string scalar",
7846 );
7847 spec.push_unknown(option.reference());
7848 }
7849 }
7850 _ => spec.push_unknown(option.reference()),
7851 }
7852 }
7853 Some(spec)
7854 }
7855
7856 fn source_column(&self, offset: usize) -> usize {
7857 let prefix = self.source.get(..offset).unwrap_or_default();
7858 let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
7859 self.source[line_start..offset].chars().count()
7860 }
7861
7862 fn parse_service_ports(&mut self, field: &ParsedField) -> Vec<Port> {
7863 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
7864 self.expected(EXPECTED_SEQUENCE, field, "service ports must be a sequence");
7865 return Vec::new();
7866 };
7867
7868 let mut ports = Vec::new();
7869 for value in sequence.values() {
7870 match value {
7871 YamlNode::Scalar(scalar) => {
7872 let span = span_from_position(self.source_id, scalar.byte_range());
7873 ports.push(Port::Short(ShortPort::parse(Located::new(
7874 scalar_string_from_source(&self.source, &scalar),
7875 span,
7876 ))));
7877 }
7878 YamlNode::Mapping(mapping) => {
7879 ports.push(Port::Long(Box::new(self.parse_long_port(&mapping))));
7880 }
7881 other => self.unsupported_sequence_item(
7882 PORT_EXPECTED_FORM,
7883 &other,
7884 field.span,
7885 "service port must use scalar short syntax or mapping long syntax",
7886 ),
7887 }
7888 }
7889 ports
7890 }
7891
7892 fn parse_long_port(&mut self, mapping: &Mapping) -> LongPort {
7893 let span = span_from_position(self.source_id, mapping.byte_range());
7894 let mut port = LongPort::new(span);
7895 let mut seen = BTreeMap::new();
7896 for field in self.fields(mapping) {
7897 let duplicate = self.record_duplicate(&mut seen, &field);
7898 match field.name.value.as_str() {
7899 "target" if !duplicate => self
7900 .parse_string(&field, "port target")
7901 .into_iter()
7902 .for_each(|value| port.set_target(value)),
7903 "published" if !duplicate => self
7904 .parse_string(&field, "published port")
7905 .into_iter()
7906 .for_each(|value| port.set_published(value)),
7907 "host_ip" if !duplicate => self
7908 .parse_string(&field, "port host IP")
7909 .into_iter()
7910 .for_each(|value| port.set_host_ip(value)),
7911 "protocol" if !duplicate => self
7912 .parse_string(&field, "port protocol")
7913 .into_iter()
7914 .for_each(|value| port.set_protocol(value)),
7915 "app_protocol" if !duplicate => self
7916 .parse_string(&field, "port application protocol")
7917 .into_iter()
7918 .for_each(|value| port.set_app_protocol(value)),
7919 "mode" if !duplicate => self
7920 .parse_string(&field, "port mode")
7921 .into_iter()
7922 .for_each(|value| port.set_mode(value)),
7923 "name" if !duplicate => self
7924 .parse_string(&field, "port name")
7925 .into_iter()
7926 .for_each(|value| port.set_name(value)),
7927 name if name.starts_with("x-") => port.push_extension(field.reference()),
7928 _ if duplicate => {}
7929 _ => port.push_unknown(field.reference()),
7930 }
7931 }
7932 if port.target().is_none() {
7933 self.missing(PORT_MISSING_TARGET, span, "long port is missing `target`");
7934 }
7935 port
7936 }
7937
7938 fn parse_service_networks(&mut self, field: &ParsedField) -> Option<ServiceNetworks> {
7939 match field.value.as_ref() {
7940 Some(YamlNode::Sequence(sequence)) => {
7941 let span = span_from_position(self.source_id, sequence.byte_range());
7942 let names =
7943 self.parse_scalar_nodes(sequence.values(), field.span, "service network names must be scalars");
7944 Some(ServiceNetworks::Short { span, names })
7945 }
7946 Some(YamlNode::Mapping(mapping)) => {
7947 let span = span_from_position(self.source_id, mapping.byte_range());
7948 let networks = self.parse_service_network_map(mapping);
7949 Some(ServiceNetworks::Long { span, networks })
7950 }
7951 _ => {
7952 self.expected(
7953 EXPECTED_FIELD_FORM,
7954 field,
7955 "service networks must be a sequence or mapping",
7956 );
7957 None
7958 }
7959 }
7960 }
7961
7962 fn parse_service_network_map(&mut self, mapping: &Mapping) -> Vec<ServiceNetwork> {
7963 let mut networks = Vec::new();
7964 let mut seen = BTreeMap::new();
7965 for field in self.fields(mapping) {
7966 if self.record_duplicate(&mut seen, &field) {
7967 continue;
7968 }
7969 if Self::field_is_null(&field) {
7970 networks.push(ServiceNetwork::new(field.name, field.span));
7971 continue;
7972 }
7973 let Some(options) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
7974 self.expected(
7975 EXPECTED_MAPPING,
7976 &field,
7977 "service network options must be a mapping or null",
7978 );
7979 continue;
7980 };
7981 networks.push(self.parse_service_network(&field, options));
7982 }
7983 networks
7984 }
7985
7986 fn parse_service_network(&mut self, field: &ParsedField, mapping: &Mapping) -> ServiceNetwork {
7987 let mut network = ServiceNetwork::new(field.name.clone(), field.span);
7988 let mut seen = BTreeMap::new();
7989 for option in self.fields(mapping) {
7990 let duplicate = self.record_duplicate(&mut seen, &option);
7991 match option.name.value.as_str() {
7992 "aliases" if !duplicate => network.set_aliases(self.parse_string_sequence(&option, "network aliases")),
7993 "interface_name" if !duplicate => self
7994 .parse_string(&option, "network interface name")
7995 .into_iter()
7996 .for_each(|value| network.set_interface_name(value)),
7997 "ipv4_address" if !duplicate => self
7998 .parse_string(&option, "network IPv4 address")
7999 .into_iter()
8000 .for_each(|value| network.set_ipv4_address(value)),
8001 "ipv6_address" if !duplicate => self
8002 .parse_string(&option, "network IPv6 address")
8003 .into_iter()
8004 .for_each(|value| network.set_ipv6_address(value)),
8005 "link_local_ips" if !duplicate => {
8006 network.set_link_local_ips(self.parse_string_sequence(&option, "link-local IP addresses"));
8007 }
8008 "mac_address" if !duplicate => self
8009 .parse_string(&option, "network MAC address")
8010 .into_iter()
8011 .for_each(|value| network.set_mac_address(value)),
8012 "driver_opts" if !duplicate => {
8013 network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
8014 }
8015 "gw_priority" if !duplicate => self
8016 .parse_string(&option, "network gateway priority")
8017 .into_iter()
8018 .for_each(|value| network.set_gw_priority(value)),
8019 "priority" if !duplicate => self
8020 .parse_string(&option, "network priority")
8021 .into_iter()
8022 .for_each(|value| network.set_priority(value)),
8023 name if name.starts_with("x-") => network.push_extension(option.reference()),
8024 _ if duplicate => {}
8025 _ => network.push_unknown(option.reference()),
8026 }
8027 }
8028 network
8029 }
8030
8031 fn parse_config_grants(&mut self, field: &ParsedField) -> Vec<ConfigGrant> {
8032 self.parse_grants(field)
8033 .unwrap_or_default()
8034 .into_iter()
8035 .map(|grant| match grant {
8036 ParsedGrant::Short(value) => ConfigGrant::Short(value),
8037 ParsedGrant::Long(value) => ConfigGrant::Long(value),
8038 })
8039 .collect()
8040 }
8041
8042 fn parse_secret_grants(&mut self, field: &ParsedField) -> Option<Vec<SecretGrant>> {
8043 Some(
8044 self.parse_grants(field)?
8045 .into_iter()
8046 .map(|grant| match grant {
8047 ParsedGrant::Short(value) => SecretGrant::Short(value),
8048 ParsedGrant::Long(value) => SecretGrant::Long(value),
8049 })
8050 .collect(),
8051 )
8052 }
8053
8054 fn parse_grants(&mut self, field: &ParsedField) -> Option<Vec<ParsedGrant>> {
8055 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
8056 self.expected(EXPECTED_SEQUENCE, field, "service grants must be a sequence");
8057 return None;
8058 };
8059 let mut grants = Vec::new();
8060 for value in sequence.values() {
8061 match value {
8062 YamlNode::Scalar(scalar) => {
8063 let span = span_from_position(self.source_id, scalar.byte_range());
8064 grants.push(ParsedGrant::Short(Located::new(
8065 scalar_string_from_source(&self.source, &scalar),
8066 span,
8067 )));
8068 }
8069 YamlNode::Mapping(mapping) => {
8070 grants.push(ParsedGrant::Long(Box::new(self.parse_long_grant(&mapping))));
8071 }
8072 other => self.unsupported_sequence_item(
8073 GRANT_EXPECTED_FORM,
8074 &other,
8075 field.span,
8076 "grant must use scalar short syntax or mapping long syntax",
8077 ),
8078 }
8079 }
8080 Some(grants)
8081 }
8082
8083 fn parse_long_grant(&mut self, mapping: &Mapping) -> LongGrant {
8084 let span = span_from_position(self.source_id, mapping.byte_range());
8085 let mut grant = LongGrant::new(span);
8086 let mut seen = BTreeMap::new();
8087 for field in self.fields(mapping) {
8088 let duplicate = self.record_duplicate(&mut seen, &field);
8089 match field.name.value.as_str() {
8090 "source" if !duplicate => self
8091 .parse_string(&field, "grant source")
8092 .into_iter()
8093 .for_each(|value| grant.set_source(value)),
8094 "target" if !duplicate => self
8095 .parse_string(&field, "grant target")
8096 .into_iter()
8097 .for_each(|value| grant.set_target(value)),
8098 "uid" if !duplicate => self
8099 .parse_string(&field, "grant user ID")
8100 .into_iter()
8101 .for_each(|value| grant.set_uid(value)),
8102 "gid" if !duplicate => self
8103 .parse_string(&field, "grant group ID")
8104 .into_iter()
8105 .for_each(|value| grant.set_gid(value)),
8106 "mode" if !duplicate => self
8107 .parse_string(&field, "grant mode")
8108 .into_iter()
8109 .for_each(|value| grant.set_mode(value)),
8110 name if name.starts_with("x-") => grant.push_extension(field.reference()),
8111 _ if duplicate => {}
8112 _ => grant.push_unknown(field.reference()),
8113 }
8114 }
8115 if grant.source().is_none() {
8116 self.missing(GRANT_MISSING_SOURCE, span, "long grant is missing `source`");
8117 }
8118 grant
8119 }
8120
8121 fn parse_service_volumes(&mut self, field: &ParsedField) -> Vec<VolumeMount> {
8122 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
8123 self.expected(EXPECTED_SEQUENCE, field, "service volumes must be a sequence");
8124 return Vec::new();
8125 };
8126
8127 sequence
8128 .values()
8129 .filter_map(|value| match value {
8130 YamlNode::Scalar(scalar) => {
8131 let span = span_from_position(self.source_id, scalar.byte_range());
8132 let raw = Located::new(scalar_string_from_source(&self.source, &scalar), span);
8133 Some(VolumeMount::Short(ShortVolumeMount::new(raw)))
8134 }
8135 YamlNode::Mapping(mapping) => Some(VolumeMount::Long(Box::new(self.parse_long_volume(&mapping)))),
8136 other => {
8137 let span = node_span(self.source_id, &other).unwrap_or(field.span);
8138 self.diagnostics.push(
8139 Diagnostic::new(
8140 VOLUME_EXPECTED_FORM,
8141 Severity::Error,
8142 "service volume must use scalar short syntax or mapping long syntax",
8143 )
8144 .with_label(DiagnosticLabel::primary(span, "unsupported volume form")),
8145 );
8146 None
8147 }
8148 })
8149 .collect()
8150 }
8151
8152 fn parse_long_volume(&mut self, mapping: &Mapping) -> LongVolumeMount {
8153 let span = span_from_position(self.source_id, mapping.byte_range());
8154 let mut mount = LongVolumeMount::new(span);
8155 let mut seen = BTreeMap::new();
8156 for field in self.fields(mapping) {
8157 let duplicate = self.record_duplicate(&mut seen, &field);
8158 match field.name.value.as_str() {
8159 "type" if !duplicate => {
8160 if let Some(value) = self.parse_string(&field, "volume type") {
8161 mount.set_mount_type(Located::new(MountType::from_text(value.value), value.span));
8162 }
8163 }
8164 "source" if !duplicate => {
8165 if let Some(value) = self.parse_string(&field, "volume source") {
8166 mount.set_source(value);
8167 }
8168 }
8169 "target" if !duplicate => {
8170 if let Some(value) = self.parse_string(&field, "volume target") {
8171 mount.set_target(value);
8172 }
8173 }
8174 "read_only" if !duplicate => {
8175 if let Some(value) = self.parse_boolean(&field, "read_only") {
8176 mount.set_read_only(value);
8177 }
8178 }
8179 "consistency" if !duplicate => {
8180 if let Some(value) = self.parse_string(&field, "volume consistency") {
8181 mount.set_consistency(value);
8182 } else {
8183 mount.push_unknown(field.reference());
8184 }
8185 }
8186 "bind" if !duplicate => {
8187 if let Some(value) = self.parse_bind_options(&field) {
8188 mount.set_bind(value);
8189 } else {
8190 mount.push_unknown(field.reference());
8191 }
8192 }
8193 "image" if !duplicate => {
8194 if let Some(value) = self.parse_image_mount_options(&field) {
8195 mount.set_image(value);
8196 } else {
8197 mount.push_unknown(field.reference());
8198 }
8199 }
8200 "tmpfs" if !duplicate => {
8201 if let Some(value) = self.parse_tmpfs_mount_options(&field) {
8202 mount.set_tmpfs(value);
8203 } else {
8204 mount.push_unknown(field.reference());
8205 }
8206 }
8207 "volume" if !duplicate => {
8208 if let Some(value) = self.parse_volume_mount_options(&field) {
8209 mount.set_volume(value);
8210 } else {
8211 mount.push_unknown(field.reference());
8212 }
8213 }
8214 name if name.starts_with("x-") => mount.push_extension(field.reference()),
8215 _ if duplicate => {}
8216 _ => mount.push_unknown(field.reference()),
8217 }
8218 }
8219
8220 if mount.mount_type().is_none() {
8221 self.missing(VOLUME_MISSING_TYPE, span, "long volume is missing `type`");
8222 }
8223 if mount.target().is_none() {
8224 self.missing(VOLUME_MISSING_TARGET, span, "long volume is missing `target`");
8225 }
8226 mount
8227 }
8228
8229 fn parse_bind_options(&mut self, field: &ParsedField) -> Option<BindOptions> {
8230 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8231 self.expected(EXPECTED_MAPPING, field, "bind options must be a mapping");
8232 return None;
8233 };
8234 let span = span_from_position(self.source_id, mapping.byte_range());
8235 let mut bind = BindOptions::new(span);
8236 let mut seen = BTreeMap::new();
8237 for bind_field in self.fields(mapping) {
8238 let duplicate = self.record_duplicate(&mut seen, &bind_field);
8239 match bind_field.name.value.as_str() {
8240 "propagation" if !duplicate => {
8241 if let Some(value) = self.parse_string(&bind_field, "bind propagation") {
8242 bind.set_propagation(value);
8243 }
8244 }
8245 "create_host_path" if !duplicate => {
8246 if let Some(value) = self.parse_boolean(&bind_field, "create_host_path") {
8247 bind.set_create_host_path(value);
8248 }
8249 }
8250 "selinux" if !duplicate => {
8251 if let Some(value) = self.parse_string(&bind_field, "SELinux relabel mode") {
8252 let mode = match value.value.as_str() {
8253 "z" => Some(SelinuxRelabel::Shared),
8254 "Z" => Some(SelinuxRelabel::Private),
8255 _ => None,
8256 };
8257 if let Some(mode) = mode {
8258 bind.set_selinux(Located::new(mode, value.span));
8259 } else {
8260 self.diagnostics.push(
8261 Diagnostic::new(
8262 VOLUME_INVALID_SELINUX,
8263 Severity::Error,
8264 "SELinux relabel mode must be `z` or `Z`",
8265 )
8266 .with_label(DiagnosticLabel::primary(value.span, "invalid SELinux mode")),
8267 );
8268 }
8269 }
8270 }
8271 "recursive" if !duplicate => {
8272 if let Some(value) = self.parse_string(&bind_field, "bind recursive mode") {
8273 bind.set_recursive(value);
8274 } else {
8275 bind.push_unknown(bind_field.reference());
8276 }
8277 }
8278 name if name.starts_with("x-") => bind.push_extension(bind_field.reference()),
8279 _ if duplicate => {}
8280 _ => bind.push_unknown(bind_field.reference()),
8281 }
8282 }
8283 Some(bind)
8284 }
8285
8286 fn parse_image_mount_options(&mut self, field: &ParsedField) -> Option<ImageMountOptions> {
8287 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8288 self.expected(
8289 VOLUME_OPTION_EXPECTED_MAPPING,
8290 field,
8291 "volume image options must be a mapping",
8292 );
8293 return None;
8294 };
8295 let mut options = ImageMountOptions::new(span_from_position(self.source_id, mapping.byte_range()));
8296 let mut seen = BTreeMap::new();
8297 for option in self.fields(mapping) {
8298 let duplicate = self.record_duplicate(&mut seen, &option);
8299 match option.name.value.as_str() {
8300 "subpath" if !duplicate => {
8301 if let Some(value) = self.parse_string(&option, "image mount subpath") {
8302 options.set_subpath(value);
8303 } else {
8304 options.push_unknown(option.reference());
8305 }
8306 }
8307 name if name.starts_with("x-") => options.push_extension(option.reference()),
8308 _ if duplicate => {}
8309 _ => options.push_unknown(option.reference()),
8310 }
8311 }
8312 Some(options)
8313 }
8314
8315 fn parse_tmpfs_mount_options(&mut self, field: &ParsedField) -> Option<TmpfsMountOptions> {
8316 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8317 self.expected(
8318 VOLUME_OPTION_EXPECTED_MAPPING,
8319 field,
8320 "volume tmpfs options must be a mapping",
8321 );
8322 return None;
8323 };
8324 let mut options = TmpfsMountOptions::new(span_from_position(self.source_id, mapping.byte_range()));
8325 let mut seen = BTreeMap::new();
8326 for option in self.fields(mapping) {
8327 let duplicate = self.record_duplicate(&mut seen, &option);
8328 match option.name.value.as_str() {
8329 "size" if !duplicate => {
8330 if let Some(value) =
8331 self.parse_string_or_number_scalar(&option, "tmpfs size must be a number or string")
8332 {
8333 options.set_size(value);
8334 } else {
8335 options.push_unknown(option.reference());
8336 }
8337 }
8338 "mode" if !duplicate => {
8339 if let Some(value) =
8340 self.parse_string_or_number_scalar(&option, "tmpfs mode must be a number or string")
8341 {
8342 options.set_mode(value);
8343 } else {
8344 options.push_unknown(option.reference());
8345 }
8346 }
8347 name if name.starts_with("x-") => options.push_extension(option.reference()),
8348 _ if duplicate => {}
8349 _ => options.push_unknown(option.reference()),
8350 }
8351 }
8352 Some(options)
8353 }
8354
8355 fn parse_volume_mount_options(&mut self, field: &ParsedField) -> Option<VolumeMountOptions> {
8356 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8357 self.expected(
8358 VOLUME_OPTION_EXPECTED_MAPPING,
8359 field,
8360 "named-volume options must be a mapping",
8361 );
8362 return None;
8363 };
8364 let mut options = VolumeMountOptions::new(span_from_position(self.source_id, mapping.byte_range()));
8365 let mut seen = BTreeMap::new();
8366 for option in self.fields(mapping) {
8367 let duplicate = self.record_duplicate(&mut seen, &option);
8368 match option.name.value.as_str() {
8369 "nocopy" if !duplicate => {
8370 if let Some(value) = self.parse_boolean(&option, "volume nocopy") {
8371 options.set_nocopy(value);
8372 } else {
8373 options.push_unknown(option.reference());
8374 }
8375 }
8376 "subpath" if !duplicate => {
8377 if let Some(value) = self.parse_string(&option, "volume subpath") {
8378 options.set_subpath(value);
8379 } else {
8380 options.push_unknown(option.reference());
8381 }
8382 }
8383 "labels" if !duplicate => {
8384 if let Some(value) = self.parse_labels(&option) {
8385 options.set_labels(value);
8386 } else {
8387 options.push_unknown(option.reference());
8388 }
8389 }
8390 name if name.starts_with("x-") => options.push_extension(option.reference()),
8391 _ if duplicate => {}
8392 _ => options.push_unknown(option.reference()),
8393 }
8394 }
8395 Some(options)
8396 }
8397
8398 fn parse_network_definitions(&mut self, field: &ParsedField) -> Vec<NetworkDefinition> {
8399 let Some(mapping) = self.resource_collection(field, "networks") else {
8400 return Vec::new();
8401 };
8402 let mut definitions = Vec::new();
8403 let mut seen = BTreeMap::new();
8404 for resource in self.fields(&mapping) {
8405 if self.record_duplicate(&mut seen, &resource) {
8406 continue;
8407 }
8408 if Self::field_is_null(&resource) {
8409 definitions.push(NetworkDefinition::new(resource.name, resource.span));
8410 continue;
8411 }
8412 let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
8413 self.expected(
8414 RESOURCE_EXPECTED_FORM,
8415 &resource,
8416 "network definition must be a mapping or null",
8417 );
8418 continue;
8419 };
8420 definitions.push(self.parse_network_definition(&resource, definition));
8421 }
8422 definitions
8423 }
8424
8425 fn parse_network_definition(&mut self, field: &ParsedField, mapping: &Mapping) -> NetworkDefinition {
8426 let mut network = NetworkDefinition::new(field.name.clone(), field.span);
8427 let mut seen = BTreeMap::new();
8428 for option in self.fields(mapping) {
8429 let duplicate = self.record_duplicate(&mut seen, &option);
8430 match option.name.value.as_str() {
8431 "driver" if !duplicate => self
8432 .parse_string(&option, "network driver")
8433 .into_iter()
8434 .for_each(|value| network.set_driver(value)),
8435 "driver_opts" if !duplicate => {
8436 network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
8437 }
8438 "attachable" if !duplicate => self
8439 .parse_boolean(&option, "network attachable")
8440 .into_iter()
8441 .for_each(|value| network.set_attachable(value)),
8442 "enable_ipv4" if !duplicate => self
8443 .parse_boolean(&option, "network enable_ipv4")
8444 .into_iter()
8445 .for_each(|value| network.set_enable_ipv4(value)),
8446 "enable_ipv6" if !duplicate => self
8447 .parse_boolean(&option, "network enable_ipv6")
8448 .into_iter()
8449 .for_each(|value| network.set_enable_ipv6(value)),
8450 "external" if !duplicate => self
8451 .parse_resource_external(&option, "network")
8452 .into_iter()
8453 .for_each(|value| network.set_external(value)),
8454 "internal" if !duplicate => self
8455 .parse_boolean(&option, "network internal")
8456 .into_iter()
8457 .for_each(|value| network.set_internal(value)),
8458 "ipam" if !duplicate => self
8459 .parse_ipam(&option)
8460 .into_iter()
8461 .for_each(|value| network.set_ipam(value)),
8462 "labels" if !duplicate => self
8463 .parse_labels(&option)
8464 .into_iter()
8465 .for_each(|value| network.set_labels(value)),
8466 "name" if !duplicate => self
8467 .parse_string(&option, "network custom name")
8468 .into_iter()
8469 .for_each(|value| network.set_custom_name(value)),
8470 name if name.starts_with("x-") => network.push_extension(option.reference()),
8471 _ if duplicate => {}
8472 _ => network.push_unknown(option.reference()),
8473 }
8474 }
8475 self.validate_resource_external_name(network.external(), network.custom_name(), network.span());
8476 network
8477 }
8478
8479 fn parse_ipam(&mut self, field: &ParsedField) -> Option<Ipam> {
8480 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8481 self.expected(EXPECTED_MAPPING, field, "network IPAM must be a mapping");
8482 return None;
8483 };
8484 let span = span_from_position(self.source_id, mapping.byte_range());
8485 let mut ipam = Ipam::new(span);
8486 let mut seen = BTreeMap::new();
8487 for option in self.fields(mapping) {
8488 let duplicate = self.record_duplicate(&mut seen, &option);
8489 match option.name.value.as_str() {
8490 "driver" if !duplicate => self
8491 .parse_string(&option, "IPAM driver")
8492 .into_iter()
8493 .for_each(|value| ipam.set_driver(value)),
8494 "config" if !duplicate => ipam.set_config(self.parse_ipam_configs(&option)),
8495 "options" if !duplicate => {
8496 ipam.set_options(self.parse_scalar_mapping(&option, "IPAM options"));
8497 }
8498 name if name.starts_with("x-") => ipam.push_extension(option.reference()),
8499 _ if duplicate => {}
8500 _ => ipam.push_unknown(option.reference()),
8501 }
8502 }
8503 Some(ipam)
8504 }
8505
8506 fn parse_ipam_configs(&mut self, field: &ParsedField) -> Vec<IpamConfig> {
8507 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
8508 self.expected(EXPECTED_SEQUENCE, field, "IPAM config must be a sequence");
8509 return Vec::new();
8510 };
8511 let mut configs = Vec::new();
8512 for value in sequence.values() {
8513 let YamlNode::Mapping(mapping) = value else {
8514 self.unsupported_sequence_item(
8515 EXPECTED_MAPPING,
8516 &value,
8517 field.span,
8518 "IPAM config entries must be mappings",
8519 );
8520 continue;
8521 };
8522 configs.push(self.parse_ipam_config(&mapping));
8523 }
8524 configs
8525 }
8526
8527 fn parse_ipam_config(&mut self, mapping: &Mapping) -> IpamConfig {
8528 let span = span_from_position(self.source_id, mapping.byte_range());
8529 let mut config = IpamConfig::new(span);
8530 let mut seen = BTreeMap::new();
8531 for field in self.fields(mapping) {
8532 let duplicate = self.record_duplicate(&mut seen, &field);
8533 match field.name.value.as_str() {
8534 "subnet" if !duplicate => self
8535 .parse_string(&field, "IPAM subnet")
8536 .into_iter()
8537 .for_each(|value| config.set_subnet(value)),
8538 "ip_range" if !duplicate => self
8539 .parse_string(&field, "IPAM allocation range")
8540 .into_iter()
8541 .for_each(|value| config.set_ip_range(value)),
8542 "gateway" if !duplicate => self
8543 .parse_string(&field, "IPAM gateway")
8544 .into_iter()
8545 .for_each(|value| config.set_gateway(value)),
8546 "aux_addresses" if !duplicate => {
8547 config.set_aux_addresses(self.parse_scalar_mapping(&field, "IPAM auxiliary addresses"));
8548 }
8549 name if name.starts_with("x-") => config.push_extension(field.reference()),
8550 _ if duplicate => {}
8551 _ => config.push_unknown(field.reference()),
8552 }
8553 }
8554 config
8555 }
8556
8557 fn parse_volume_definitions(&mut self, field: &ParsedField) -> Vec<VolumeDefinition> {
8558 let Some(mapping) = self.resource_collection(field, "volumes") else {
8559 return Vec::new();
8560 };
8561 let mut definitions = Vec::new();
8562 let mut seen = BTreeMap::new();
8563 for resource in self.fields(&mapping) {
8564 if self.record_duplicate(&mut seen, &resource) {
8565 continue;
8566 }
8567 let mut volume = VolumeDefinition::new(resource.name.clone(), resource.span);
8568 if Self::field_is_null(&resource) {
8569 definitions.push(volume);
8570 continue;
8571 }
8572 let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
8573 self.expected(
8574 RESOURCE_EXPECTED_FORM,
8575 &resource,
8576 "volume definition must be a mapping or null",
8577 );
8578 continue;
8579 };
8580 let mut nested_seen = BTreeMap::new();
8581 for option in self.fields(definition) {
8582 let duplicate = self.record_duplicate(&mut nested_seen, &option);
8583 match option.name.value.as_str() {
8584 "driver" if !duplicate => self
8585 .parse_string(&option, "volume driver")
8586 .into_iter()
8587 .for_each(|value| volume.set_driver(value)),
8588 "driver_opts" if !duplicate => {
8589 volume.set_driver_opts(self.parse_scalar_mapping(&option, "volume driver options"));
8590 }
8591 "external" if !duplicate => self
8592 .parse_resource_external(&option, "volume")
8593 .into_iter()
8594 .for_each(|value| volume.set_external(value)),
8595 "labels" if !duplicate => self
8596 .parse_labels(&option)
8597 .into_iter()
8598 .for_each(|value| volume.set_labels(value)),
8599 "name" if !duplicate => self
8600 .parse_string(&option, "volume custom name")
8601 .into_iter()
8602 .for_each(|value| volume.set_custom_name(value)),
8603 name if name.starts_with("x-") => volume.push_extension(option.reference()),
8604 _ if duplicate => {}
8605 _ => volume.push_unknown(option.reference()),
8606 }
8607 }
8608 self.validate_external_volume_driver_configuration(&volume);
8609 self.validate_external_volume_labels_configuration(&volume);
8610 self.validate_resource_external_name(volume.external(), volume.custom_name(), volume.span());
8611 definitions.push(volume);
8612 }
8613 definitions
8614 }
8615
8616 fn validate_external_volume_driver_configuration(&mut self, volume: &VolumeDefinition) {
8617 if !volume.external().is_some_and(ResourceExternal::is_explicitly_external)
8618 || (volume.driver().is_none() && volume.driver_opts().is_empty())
8619 {
8620 return;
8621 }
8622 let span = volume
8623 .driver()
8624 .map(Located::span)
8625 .or_else(|| volume.driver_opts().first().map(KeyValueEntry::span))
8626 .unwrap_or_else(|| volume.span());
8627 self.diagnostics.push(
8628 Diagnostic::new(
8629 VOLUME_EXTERNAL_DRIVER_CONFIGURATION,
8630 Severity::Error,
8631 "external volume cannot also configure `driver` or `driver_opts`",
8632 )
8633 .with_label(DiagnosticLabel::primary(
8634 span,
8635 "driver configuration remains retained for review",
8636 )),
8637 );
8638 }
8639
8640 fn validate_external_volume_labels_configuration(&mut self, volume: &VolumeDefinition) {
8641 if !volume.external().is_some_and(ResourceExternal::is_explicitly_external) || volume.labels().is_none() {
8642 return;
8643 }
8644 let span = volume.labels().map_or_else(|| volume.span(), Labels::span);
8645 self.diagnostics.push(
8646 Diagnostic::new(
8647 VOLUME_EXTERNAL_LABELS_CONFIGURATION,
8648 Severity::Error,
8649 "external volume cannot also configure `labels`",
8650 )
8651 .with_label(DiagnosticLabel::primary(span, "labels remain retained for review")),
8652 );
8653 }
8654
8655 fn parse_config_definitions(&mut self, field: &ParsedField) -> Vec<ConfigDefinition> {
8656 let Some(mapping) = self.resource_collection(field, "configs") else {
8657 return Vec::new();
8658 };
8659 let mut definitions = Vec::new();
8660 let mut seen = BTreeMap::new();
8661 for resource in self.fields(&mapping) {
8662 if self.record_duplicate(&mut seen, &resource) {
8663 continue;
8664 }
8665 let mut config = ConfigDefinition::new(resource.name.clone(), resource.span);
8666 if Self::field_is_null(&resource) {
8667 definitions.push(config);
8668 continue;
8669 }
8670 let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
8671 self.expected(
8672 RESOURCE_EXPECTED_FORM,
8673 &resource,
8674 "config definition must be a mapping or null",
8675 );
8676 continue;
8677 };
8678 let mut nested_seen = BTreeMap::new();
8679 for option in self.fields(definition) {
8680 let duplicate = self.record_duplicate(&mut nested_seen, &option);
8681 match option.name.value.as_str() {
8682 "file" if !duplicate => self
8683 .parse_string(&option, "config file")
8684 .into_iter()
8685 .for_each(|value| config.set_file(value)),
8686 "environment" if !duplicate => self
8687 .parse_string(&option, "config environment source")
8688 .into_iter()
8689 .for_each(|value| config.set_environment(value)),
8690 "content" if !duplicate => self
8691 .parse_string(&option, "config content")
8692 .into_iter()
8693 .for_each(|value| config.set_content(value)),
8694 "external" if !duplicate => self
8695 .parse_resource_external(&option, "config")
8696 .into_iter()
8697 .for_each(|value| config.set_external(value)),
8698 "labels" if !duplicate => self
8699 .parse_labels(&option)
8700 .into_iter()
8701 .for_each(|value| config.set_labels(value)),
8702 "template_driver" if !duplicate => self
8703 .parse_extends_string(&option, "config template_driver must be a YAML string scalar")
8704 .into_iter()
8705 .for_each(|value| config.set_template_driver(value)),
8706 "name" if !duplicate => self
8707 .parse_string(&option, "config custom name")
8708 .into_iter()
8709 .for_each(|value| config.set_custom_name(value)),
8710 name if name.starts_with("x-") => config.push_extension(option.reference()),
8711 _ if duplicate => {}
8712 _ => config.push_unknown(option.reference()),
8713 }
8714 }
8715 self.validate_resource_external_name(config.external(), config.custom_name(), config.span());
8716 self.validate_external_creation_configuration(
8717 config.external(),
8718 &[
8719 config.file().map(Located::span),
8720 config.environment().map(Located::span),
8721 config.content().map(Located::span),
8722 config.labels().map(Labels::span),
8723 config.template_driver().map(Located::span),
8724 ],
8725 "config",
8726 );
8727 definitions.push(config);
8728 }
8729 definitions
8730 }
8731
8732 fn parse_secret_definitions(&mut self, field: &ParsedField) -> Vec<SecretDefinition> {
8733 let Some(mapping) = self.resource_collection(field, "secrets") else {
8734 return Vec::new();
8735 };
8736 let mut definitions = Vec::new();
8737 let mut seen = BTreeMap::new();
8738 for resource in self.fields(&mapping) {
8739 if self.record_duplicate(&mut seen, &resource) {
8740 continue;
8741 }
8742 let mut secret = SecretDefinition::new(resource.name.clone(), resource.span);
8743 if Self::field_is_null(&resource) {
8744 definitions.push(secret);
8745 continue;
8746 }
8747 let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
8748 self.expected(
8749 RESOURCE_EXPECTED_FORM,
8750 &resource,
8751 "secret definition must be a mapping or null",
8752 );
8753 continue;
8754 };
8755 let mut nested_seen = BTreeMap::new();
8756 for option in self.fields(definition) {
8757 let duplicate = self.record_duplicate(&mut nested_seen, &option);
8758 match option.name.value.as_str() {
8759 "file" if !duplicate => self
8760 .parse_string(&option, "secret file")
8761 .into_iter()
8762 .for_each(|value| secret.set_file(value)),
8763 "environment" if !duplicate => self
8764 .parse_string(&option, "secret environment source")
8765 .into_iter()
8766 .for_each(|value| secret.set_environment(value)),
8767 "external" if !duplicate => self
8768 .parse_resource_external(&option, "secret")
8769 .into_iter()
8770 .for_each(|value| secret.set_external(value)),
8771 "driver" if !duplicate => self
8772 .parse_extends_string(&option, "secret driver must be a YAML string scalar")
8773 .into_iter()
8774 .for_each(|value| secret.set_driver(value)),
8775 "driver_opts" if !duplicate => {
8776 secret.set_driver_opts(self.parse_scalar_mapping(&option, "secret driver options"));
8777 }
8778 "labels" if !duplicate => self
8779 .parse_labels(&option)
8780 .into_iter()
8781 .for_each(|value| secret.set_labels(value)),
8782 "template_driver" if !duplicate => self
8783 .parse_extends_string(&option, "secret template_driver must be a YAML string scalar")
8784 .into_iter()
8785 .for_each(|value| secret.set_template_driver(value)),
8786 "name" if !duplicate => self
8787 .parse_string(&option, "secret custom name")
8788 .into_iter()
8789 .for_each(|value| secret.set_custom_name(value)),
8790 name if name.starts_with("x-") => secret.push_extension(option.reference()),
8791 _ if duplicate => {}
8792 _ => secret.push_unknown(option.reference()),
8793 }
8794 }
8795 self.validate_resource_external_name(secret.external(), secret.custom_name(), secret.span());
8796 self.validate_external_creation_configuration(
8797 secret.external(),
8798 &[
8799 secret.file().map(Located::span),
8800 secret.environment().map(Located::span),
8801 secret.driver().map(Located::span),
8802 secret.driver_opts().first().map(KeyValueEntry::span),
8803 secret.labels().map(Labels::span),
8804 secret.template_driver().map(Located::span),
8805 ],
8806 "secret",
8807 );
8808 definitions.push(secret);
8809 }
8810 definitions
8811 }
8812
8813 fn parse_resource_external(&mut self, field: &ParsedField, kind: &str) -> Option<ResourceExternal> {
8814 let Some(value) = field.value.as_ref() else {
8815 self.expected(
8816 EXPECTED_FIELD_FORM,
8817 field,
8818 format!("{kind} external must be a boolean or mapping"),
8819 );
8820 return None;
8821 };
8822 if value.as_scalar().is_some_and(scalar_is_null) {
8823 self.expected(
8824 EXPECTED_FIELD_FORM,
8825 field,
8826 format!("{kind} external must be a boolean or mapping"),
8827 );
8828 return None;
8829 }
8830 let Some(mapping) = value.as_mapping() else {
8831 return self
8832 .parse_boolean(field, &format!("{kind} external"))
8833 .map(ResourceExternal::Boolean);
8834 };
8835 let mut name_mapping = ExternalNameMapping::new(span_from_position(self.source_id, mapping.byte_range()));
8836 let mut seen = BTreeMap::new();
8837 for member in self.fields(mapping) {
8838 if self.record_duplicate(&mut seen, &member) {
8839 name_mapping.push_unknown(member.reference());
8840 continue;
8841 }
8842 match member.name.value().as_str() {
8843 "name" => {
8844 match self.parse_extends_string(&member, "deprecated external name must be a YAML string scalar") {
8845 Some(value) if !value.value().is_empty() => name_mapping.set_name(value),
8846 Some(value) => {
8847 self.expected(
8848 EXPECTED_SCALAR,
8849 &member,
8850 "deprecated external name must be a non-empty YAML string scalar",
8851 );
8852 let _ = value;
8853 name_mapping.push_unknown(member.reference());
8854 }
8855 None => name_mapping.push_unknown(member.reference()),
8856 }
8857 }
8858 name if name.starts_with("x-") => name_mapping.push_extension(member.reference()),
8859 _ => name_mapping.push_unknown(member.reference()),
8860 }
8861 }
8862 if name_mapping.name().is_none() {
8863 self.missing(
8864 EXPECTED_SCALAR,
8865 name_mapping.span(),
8866 "deprecated external mapping requires a non-empty `name` string",
8867 );
8868 }
8869 self.diagnostics.push(
8870 Diagnostic::new(
8871 RESOURCE_EXTERNAL_NAME_MAPPING_DEPRECATED,
8872 Severity::Warning,
8873 format!("{kind} `external.name` mapping syntax is deprecated"),
8874 )
8875 .with_label(DiagnosticLabel::primary(
8876 name_mapping.span(),
8877 "use `name: VALUE` with `external: true` instead",
8878 )),
8879 );
8880 Some(ResourceExternal::NameMapping(Box::new(name_mapping)))
8881 }
8882
8883 fn validate_resource_external_name(
8884 &mut self,
8885 external: Option<&ResourceExternal>,
8886 custom_name: Option<&Located<String>>,
8887 fallback: SourceSpan,
8888 ) {
8889 let (Some(ResourceExternal::NameMapping(name_mapping)), Some(modern)) = (external, custom_name) else {
8890 return;
8891 };
8892 let mapping_span = name_mapping.name().map_or_else(|| name_mapping.span(), Located::span);
8893 self.diagnostics.push(
8894 Diagnostic::new(
8895 RESOURCE_EXTERNAL_NAME_CONFLICT,
8896 Severity::Error,
8897 "resource cannot use both `name` and deprecated `external.name` mapping syntax",
8898 )
8899 .with_label(DiagnosticLabel::primary(modern.span(), "resource name"))
8900 .with_label(DiagnosticLabel::secondary(mapping_span, "deprecated external name"))
8901 .with_label(DiagnosticLabel::secondary(fallback, "both values remain retained")),
8902 );
8903 }
8904
8905 fn validate_external_creation_configuration(
8906 &mut self,
8907 external: Option<&ResourceExternal>,
8908 fields: &[Option<SourceSpan>],
8909 kind: &str,
8910 ) {
8911 if !external.is_some_and(ResourceExternal::is_explicitly_external) {
8912 return;
8913 }
8914 let Some(span) = fields.iter().flatten().next().copied() else {
8915 return;
8916 };
8917 self.diagnostics.push(
8918 Diagnostic::new(
8919 RESOURCE_EXTERNAL_CREATION_CONFIGURATION,
8920 Severity::Error,
8921 format!("external {kind} cannot also configure creation-time metadata"),
8922 )
8923 .with_label(DiagnosticLabel::primary(span, "metadata remains retained for review")),
8924 );
8925 }
8926
8927 fn resource_collection(&mut self, field: &ParsedField, kind: &str) -> Option<Mapping> {
8928 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
8929 self.expected(EXPECTED_MAPPING, field, format!("top-level {kind} must be a mapping"));
8930 return None;
8931 };
8932 Some(mapping.clone())
8933 }
8934
8935 fn parse_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
8936 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
8937 self.expected(EXPECTED_SCALAR, field, format!("{description} must be a scalar"));
8938 return None;
8939 };
8940 if scalar_is_null(scalar) {
8941 self.expected(
8942 EXPECTED_SCALAR,
8943 field,
8944 format!("{description} must be a non-null scalar"),
8945 );
8946 return None;
8947 }
8948 Some(Located::new(
8949 scalar_string_from_source(&self.source, scalar),
8950 span_from_position(self.source_id, scalar.byte_range()),
8951 ))
8952 }
8953
8954 fn parse_non_empty_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
8955 let value = self.parse_string(field, description)?;
8956 if value.value().is_empty() {
8957 self.diagnostics.push(
8958 Diagnostic::new(
8959 BUILD_DOCKERFILE_EXPECTED_NON_EMPTY,
8960 Severity::Error,
8961 format!("{description} must be a non-empty scalar"),
8962 )
8963 .with_label(DiagnosticLabel::primary(value.span(), "empty scalar retained")),
8964 );
8965 return None;
8966 }
8967 Some(value)
8968 }
8969
8970 fn parse_build_dockerfile(&mut self, definition: &mut BuildDefinition, field: &ParsedField) -> FieldReference {
8971 if let Some(dockerfile) = self.parse_non_empty_string(field, "build dockerfile") {
8972 definition.set_dockerfile(dockerfile);
8973 }
8974 field.reference()
8975 }
8976
8977 fn set_build_dockerfile_inline(
8978 &mut self,
8979 definition: &mut BuildDefinition,
8980 field: &ParsedField,
8981 dockerfile_inline: &mut Option<FieldReference>,
8982 ) {
8983 *dockerfile_inline = Some(field.reference());
8984 if let Some(value) = self.parse_build_dockerfile_inline(field) {
8985 definition.set_dockerfile_inline(value);
8986 }
8987 }
8988
8989 fn parse_build_dockerfile_inline(&mut self, field: &ParsedField) -> Option<Located<String>> {
8990 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
8991 self.expected(
8992 EXPECTED_SCALAR,
8993 field,
8994 "build dockerfile_inline must be a YAML string scalar",
8995 );
8996 return None;
8997 };
8998 if scalar_type_for_value(scalar) != ScalarType::String {
8999 self.expected(
9000 EXPECTED_SCALAR,
9001 field,
9002 "build dockerfile_inline must be a YAML string scalar",
9003 );
9004 return None;
9005 }
9006 Some(Located::new(
9007 scalar_string_from_source(&self.source, scalar),
9008 span_from_position(self.source_id, scalar.byte_range()),
9009 ))
9010 }
9011
9012 fn set_build_no_cache(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9013 if let Some(no_cache) = self.parse_build_no_cache(field) {
9014 definition.set_no_cache(no_cache);
9015 }
9016 }
9017
9018 fn set_build_sbom(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9019 if let Some(sbom) = self.parse_build_sbom(field) {
9020 definition.set_sbom(sbom);
9021 }
9022 }
9023
9024 fn set_build_provenance(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9025 if let Some(value) = self.parse_build_provenance(field) {
9026 definition.set_provenance(value);
9027 }
9028 }
9029
9030 fn set_build_isolation(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9031 if let Some(isolation) = self.parse_build_isolation(field) {
9032 definition.set_isolation(isolation);
9033 }
9034 }
9035
9036 fn parse_build_isolation(&mut self, field: &ParsedField) -> Option<Located<String>> {
9037 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9038 self.expected(
9039 BUILD_ISOLATION_EXPECTED_STRING,
9040 field,
9041 "build isolation must be a YAML string scalar",
9042 );
9043 return None;
9044 };
9045 if scalar_type_for_value(scalar) != ScalarType::String {
9046 self.expected(
9047 BUILD_ISOLATION_EXPECTED_STRING,
9048 field,
9049 "build isolation must be a YAML string scalar",
9050 );
9051 return None;
9052 }
9053 Some(Located::new(
9054 scalar_string_from_source(&self.source, scalar),
9055 span_from_position(self.source_id, scalar.byte_range()),
9056 ))
9057 }
9058
9059 fn parse_boolean(&mut self, field: &ParsedField, description: &str) -> Option<Located<BooleanValue>> {
9060 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9061 self.expected(EXPECTED_BOOLEAN, field, format!("{description} must be a boolean"));
9062 return None;
9063 };
9064 let span = span_from_position(self.source_id, scalar.byte_range());
9065 let scalar_value = ScalarValue::from_scalar(scalar);
9066 if let Some(value) = scalar_value.to_bool() {
9067 return Some(Located::new(BooleanValue::Literal(value), span));
9068 }
9069 let value = scalar_string_from_source(&self.source, scalar);
9070 if value.contains('$') {
9071 return Some(Located::new(BooleanValue::Expression(value), span));
9072 }
9073 self.diagnostics.push(
9074 Diagnostic::new(
9075 EXPECTED_BOOLEAN,
9076 Severity::Error,
9077 format!("{description} must be a boolean or interpolation expression"),
9078 )
9079 .with_label(DiagnosticLabel::primary(span, "not a boolean expression")),
9080 );
9081 None
9082 }
9083
9084 fn parse_build_no_cache(&mut self, field: &ParsedField) -> Option<Located<BuildNoCache>> {
9085 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9086 self.expected(
9087 BUILD_NO_CACHE_EXPECTED_BOOLEAN_OR_STRING,
9088 field,
9089 "build no_cache must be a YAML boolean or string scalar",
9090 );
9091 return None;
9092 };
9093 let span = span_from_position(self.source_id, scalar.byte_range());
9094 let scalar_value = ScalarValue::from_scalar(scalar);
9095 let value = match scalar_type_for_value(scalar) {
9096 ScalarType::Boolean => BuildNoCache::Boolean(scalar_value.to_bool().unwrap_or(false)),
9097 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9098 BuildNoCache::String(scalar_string_from_source(&self.source, scalar))
9099 }
9100 ScalarType::Null | ScalarType::Integer | ScalarType::Float => {
9101 self.expected(
9102 BUILD_NO_CACHE_EXPECTED_BOOLEAN_OR_STRING,
9103 field,
9104 "build no_cache must be a YAML boolean or string scalar",
9105 );
9106 return None;
9107 }
9108 };
9109 Some(Located::new(value, span))
9110 }
9111 fn parse_build_no_cache_filter(&mut self, field: &ParsedField) -> Option<BuildNoCacheFilter> {
9112 match field.value.as_ref() {
9113 Some(YamlNode::Scalar(s)) if scalar_type_for_value(s) == ScalarType::String => {
9114 Some(BuildNoCacheFilter::Scalar(Located::new(
9115 scalar_string_from_source(&self.source, s),
9116 span_from_position(self.source_id, s.byte_range()),
9117 )))
9118 }
9119 Some(YamlNode::Sequence(seq)) => {
9120 let values = self.parse_string_scalar_nodes(
9121 seq.values(),
9122 field.span,
9123 "build no_cache_filter entries must be string scalars",
9124 );
9125 let mut seen = BTreeSet::new();
9126 for value in &values {
9127 if !seen.insert(value.value().clone()) {
9128 self.diagnostics.push(
9129 Diagnostic::new(
9130 BUILD_NO_CACHE_FILTER_DUPLICATE_ITEM,
9131 Severity::Warning,
9132 "build no_cache_filter retains duplicate stage",
9133 )
9134 .with_label(DiagnosticLabel::primary(value.span(), "duplicate retained")),
9135 );
9136 }
9137 }
9138 Some(BuildNoCacheFilter::List(values))
9139 }
9140 _ => {
9141 self.expected(
9142 EXPECTED_FIELD_FORM,
9143 field,
9144 "build no_cache_filter must be a string scalar or sequence",
9145 );
9146 None
9147 }
9148 }
9149 }
9150 fn set_build_no_cache_filter(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9151 if let Some(value) = self.parse_build_no_cache_filter(field) {
9152 definition.set_no_cache_filter(value);
9153 }
9154 }
9155 fn set_build_privileged(&mut self, definition: &mut BuildDefinition, field: &ParsedField) {
9156 if let Some(value) = self.parse_boolean(field, "build privileged") {
9157 definition.set_privileged(value);
9158 }
9159 }
9160
9161 fn parse_build_sbom(&mut self, field: &ParsedField) -> Option<Located<BuildSbom>> {
9162 let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9163 self.expected(
9164 BUILD_SBOM_EXPECTED_BOOLEAN_OR_STRING,
9165 field,
9166 "build sbom must be a YAML boolean or string scalar",
9167 );
9168 return None;
9169 };
9170 let span = span_from_position(self.source_id, scalar.byte_range());
9171 let scalar_value = ScalarValue::from_scalar(scalar);
9172 let value = match scalar_type_for_value(scalar) {
9173 ScalarType::Boolean => BuildSbom::Boolean(scalar_value.to_bool().unwrap_or(false)),
9174 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9175 BuildSbom::String(scalar_string_from_source(&self.source, scalar))
9176 }
9177 ScalarType::Null | ScalarType::Integer | ScalarType::Float => {
9178 self.expected(
9179 BUILD_SBOM_EXPECTED_BOOLEAN_OR_STRING,
9180 field,
9181 "build sbom must be a YAML boolean or string scalar",
9182 );
9183 return None;
9184 }
9185 };
9186 Some(Located::new(value, span))
9187 }
9188
9189 fn parse_build_provenance(&mut self, field: &ParsedField) -> Option<Located<BuildProvenance>> {
9190 let scalar = field.value.as_ref().and_then(YamlNode::as_scalar)?;
9191 let span = span_from_position(self.source_id, scalar.byte_range());
9192 let value = match scalar_type_for_value(scalar) {
9193 ScalarType::Boolean => {
9194 BuildProvenance::Boolean(ScalarValue::from_scalar(scalar).to_bool().unwrap_or(false))
9195 }
9196 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9197 BuildProvenance::String(scalar_string_from_source(&self.source, scalar))
9198 }
9199 _ => {
9200 self.expected(
9201 EXPECTED_SCALAR,
9202 field,
9203 "build provenance must be a YAML boolean or string scalar",
9204 );
9205 return None;
9206 }
9207 };
9208 Some(Located::new(value, span))
9209 }
9210
9211 fn parse_string_sequence(&mut self, field: &ParsedField, description: &str) -> Vec<Located<String>> {
9212 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
9213 self.expected(EXPECTED_SEQUENCE, field, format!("{description} must be a sequence"));
9214 return Vec::new();
9215 };
9216 self.parse_scalar_nodes(
9217 sequence.values(),
9218 field.span,
9219 format!("{description} entries must be scalars"),
9220 )
9221 }
9222
9223 fn parse_strict_string_sequence(
9224 &mut self,
9225 field: &ParsedField,
9226 description: &str,
9227 ) -> (Vec<Located<String>>, Vec<InvalidServiceStringItem>) {
9228 let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
9229 self.expected(EXPECTED_SEQUENCE, field, format!("{description} must be a sequence"));
9230 return (Vec::new(), Vec::new());
9231 };
9232 let mut values = Vec::new();
9233 let mut invalid = Vec::new();
9234 for node in sequence.values() {
9235 let span = match &node {
9236 YamlNode::Scalar(value) => span_from_position(self.source_id, value.byte_range()),
9237 YamlNode::Sequence(value) => span_from_position(self.source_id, value.byte_range()),
9238 YamlNode::Mapping(value) => span_from_position(self.source_id, value.byte_range()),
9239 _ => field.span,
9240 };
9241 let Some(scalar) = node.as_scalar() else {
9242 self.unsupported_sequence_item(
9243 EXPECTED_SCALAR,
9244 &node,
9245 field.span,
9246 format!("{description} entries must be YAML string scalars"),
9247 );
9248 invalid.push(InvalidServiceStringItem::new(span));
9249 continue;
9250 };
9251 if scalar_type_for_value(scalar) != ScalarType::String {
9252 self.unsupported_sequence_item(
9253 EXPECTED_SCALAR,
9254 &node,
9255 field.span,
9256 format!("{description} entries must be YAML string scalars"),
9257 );
9258 invalid.push(InvalidServiceStringItem::new(span));
9259 continue;
9260 }
9261 values.push(Located::new(scalar_string_from_source(&self.source, scalar), span));
9262 }
9263 (values, invalid)
9264 }
9265
9266 fn parse_scalar_nodes(
9267 &mut self,
9268 nodes: impl Iterator<Item = YamlNode>,
9269 fallback_span: SourceSpan,
9270 message: impl Into<String>,
9271 ) -> Vec<Located<String>> {
9272 let message = message.into();
9273 let mut values = Vec::new();
9274 for node in nodes {
9275 let YamlNode::Scalar(scalar) = node else {
9276 self.unsupported_sequence_item(EXPECTED_SCALAR, &node, fallback_span, &message);
9277 continue;
9278 };
9279 if scalar_is_null(&scalar) {
9280 self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar), fallback_span, &message);
9281 continue;
9282 }
9283 let span = span_from_position(self.source_id, scalar.byte_range());
9284 values.push(Located::new(scalar_string_from_source(&self.source, &scalar), span));
9285 }
9286 values
9287 }
9288
9289 fn parse_scalar_mapping(&mut self, field: &ParsedField, description: &str) -> Vec<KeyValueEntry> {
9290 let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
9291 self.expected(EXPECTED_MAPPING, field, format!("{description} must be a mapping"));
9292 return Vec::new();
9293 };
9294 let mut entries = Vec::new();
9295 let mut seen = BTreeMap::new();
9296 for entry in self.fields(mapping) {
9297 if self.record_duplicate(&mut seen, &entry) {
9298 continue;
9299 }
9300 if let Some(value) = self.parse_compose_scalar(&entry, format!("{description} values must be scalars")) {
9301 entries.push(KeyValueEntry::new(entry.name, value, entry.span));
9302 }
9303 }
9304 entries
9305 }
9306
9307 fn parse_compose_scalar(
9308 &mut self,
9309 field: &ParsedField,
9310 message: impl Into<String>,
9311 ) -> Option<Located<ComposeScalar>> {
9312 let Some(node) = field.value.as_ref() else {
9313 return Some(Located::new(ComposeScalar::Null, field.name.span));
9314 };
9315 let Some(scalar) = node.as_scalar() else {
9316 self.expected(EXPECTED_SCALAR, field, message);
9317 return None;
9318 };
9319 let span = span_from_position(self.source_id, scalar.byte_range());
9320 if scalar_is_null(scalar) {
9321 return Some(Located::new(ComposeScalar::Null, span));
9322 }
9323 let value = ScalarValue::from_scalar(scalar);
9324 let typed = match scalar_type_for_value(scalar) {
9325 ScalarType::Null => ComposeScalar::Null,
9326 ScalarType::Boolean => ComposeScalar::Boolean(value.to_bool().unwrap_or(false)),
9327 ScalarType::Integer | ScalarType::Float => {
9328 ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
9329 }
9330 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9331 ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
9332 }
9333 };
9334 Some(Located::new(typed, span))
9335 }
9336
9337 fn parse_integer_or_string_scalar(
9338 &mut self,
9339 field: &ParsedField,
9340 message: impl Into<String>,
9341 ) -> Option<Located<ComposeScalar>> {
9342 let message = message.into();
9343 let Some(node) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9344 self.expected(EXPECTED_FIELD_FORM, field, message);
9345 return None;
9346 };
9347 let type_ = scalar_type_for_value(node);
9348 if type_ != ScalarType::Integer && type_ != ScalarType::String {
9349 self.expected(EXPECTED_FIELD_FORM, field, message);
9350 return None;
9351 }
9352 self.parse_compose_scalar(field, "integer or string scalar already checked")
9353 }
9354
9355 fn parse_string_or_number_scalar(
9356 &mut self,
9357 field: &ParsedField,
9358 message: impl Into<String>,
9359 ) -> Option<Located<ComposeScalar>> {
9360 let message = message.into();
9361 let Some(node) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
9362 self.expected(EXPECTED_FIELD_FORM, field, message);
9363 return None;
9364 };
9365 if !matches!(
9366 scalar_type_for_value(node),
9367 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex | ScalarType::Integer | ScalarType::Float
9368 ) {
9369 self.expected(EXPECTED_FIELD_FORM, field, message);
9370 return None;
9371 }
9372 self.parse_compose_scalar(field, "number or string scalar already checked")
9373 }
9374
9375 fn parse_integer_scalar(
9376 &mut self,
9377 field: &ParsedField,
9378 message: impl Into<String>,
9379 ) -> Option<Located<ComposeScalar>> {
9380 let message = message.into();
9381 let node = field.value.as_ref().and_then(YamlNode::as_scalar)?;
9382 if scalar_type_for_value(node) != ScalarType::Integer {
9383 self.expected(EXPECTED_FIELD_FORM, field, message);
9384 return None;
9385 }
9386 self.parse_compose_scalar(field, "integer scalar already checked")
9387 }
9388
9389 fn parse_labels(&mut self, field: &ParsedField) -> Option<Labels> {
9390 match field.value.as_ref() {
9391 Some(YamlNode::Sequence(sequence)) => {
9392 let span = span_from_position(self.source_id, sequence.byte_range());
9393 let values = self.parse_string_scalar_nodes(
9394 sequence.values(),
9395 field.span,
9396 "label list entries must be string scalars",
9397 );
9398 Some(Labels::List { span, values })
9399 }
9400 Some(YamlNode::Mapping(mapping)) => {
9401 let span = span_from_position(self.source_id, mapping.byte_range());
9402 let entries = self.parse_scalar_mapping(field, "labels");
9403 Some(Labels::Map { span, entries })
9404 }
9405 _ => {
9406 self.expected(EXPECTED_FIELD_FORM, field, "labels must be a sequence or mapping");
9407 None
9408 }
9409 }
9410 }
9411
9412 fn parse_string_scalar_nodes(
9413 &mut self,
9414 nodes: impl Iterator<Item = YamlNode>,
9415 fallback_span: SourceSpan,
9416 message: impl Into<String>,
9417 ) -> Vec<Located<String>> {
9418 let message = message.into();
9419 let mut values = Vec::new();
9420 for node in nodes {
9421 let YamlNode::Scalar(scalar) = node else {
9422 self.unsupported_sequence_item(EXPECTED_SCALAR, &node, fallback_span, &message);
9423 continue;
9424 };
9425 if !matches!(
9426 scalar_type_for_value(&scalar),
9427 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
9428 ) {
9429 self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar), fallback_span, &message);
9430 continue;
9431 }
9432 let span = span_from_position(self.source_id, scalar.byte_range());
9433 values.push(Located::new(scalar_string_from_source(&self.source, &scalar), span));
9434 }
9435 values
9436 }
9437
9438 fn parse_annotations(&mut self, field: &ParsedField) -> Option<Annotations> {
9439 match field.value.as_ref() {
9440 Some(YamlNode::Sequence(sequence)) => Some(self.parse_annotation_list(sequence, field.span)),
9441 Some(YamlNode::Mapping(mapping)) => Some(self.parse_annotation_map(mapping)),
9442 _ => {
9443 self.expected(
9444 ANNOTATIONS_EXPECTED_FORM,
9445 field,
9446 "annotations must be a sequence or mapping",
9447 );
9448 None
9449 }
9450 }
9451 }
9452
9453 fn parse_annotation_list(&mut self, sequence: &yaml_edit::Sequence, fallback: SourceSpan) -> Annotations {
9454 let span = span_from_position(self.source_id, sequence.byte_range());
9455 let mut values = Vec::new();
9456 let mut seen = BTreeSet::new();
9457 for node in sequence.values() {
9458 let YamlNode::Scalar(scalar) = node else {
9459 self.unsupported_sequence_item(
9460 ANNOTATIONS_EXPECTED_STRING,
9461 &node,
9462 fallback,
9463 "annotation list entries must be string scalars",
9464 );
9465 continue;
9466 };
9467 let item_span = span_from_position(self.source_id, scalar.byte_range());
9468 let scalar_value = ScalarValue::from_scalar(&scalar);
9469 let value = match scalar_type_for_value(&scalar) {
9470 ScalarType::Null => ComposeScalar::Null,
9471 ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
9472 ScalarType::Integer | ScalarType::Float => {
9473 ComposeScalar::Number(scalar_string_from_source(&self.source, &scalar))
9474 }
9475 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
9476 ComposeScalar::String(scalar_string_from_source(&self.source, &scalar))
9477 }
9478 };
9479 self.validate_annotation_list_scalar(&value, item_span, &mut seen);
9480 values.push(Located::new(value, item_span));
9481 }
9482 Annotations::new(span, AnnotationsForm::List(values))
9483 }
9484
9485 fn validate_annotation_list_scalar(
9486 &mut self,
9487 value: &ComposeScalar,
9488 span: SourceSpan,
9489 seen: &mut BTreeSet<String>,
9490 ) {
9491 let ComposeScalar::String(raw) = value else {
9492 self.diagnostics.push(annotation_diagnostic(
9493 ANNOTATIONS_EXPECTED_STRING,
9494 Severity::Error,
9495 span,
9496 "annotation list entries must be string scalars",
9497 "non-string annotation item retained",
9498 ));
9499 return;
9500 };
9501 let name = raw.split_once('=').map_or(raw.as_str(), |(name, _)| name);
9502 if name.is_empty() {
9503 self.diagnostics.push(annotation_diagnostic(
9504 ANNOTATIONS_EMPTY_NAME,
9505 Severity::Error,
9506 span,
9507 "service annotation name must not be empty",
9508 "empty annotation name",
9509 ));
9510 } else if !seen.insert(name.to_owned()) {
9511 self.diagnostics.push(annotation_diagnostic(
9512 ANNOTATIONS_DUPLICATE_NAME,
9513 Severity::Error,
9514 span,
9515 "service annotation names must be unique",
9516 "duplicate annotation name",
9517 ));
9518 }
9519 if !raw.contains('=') {
9520 self.diagnostics.push(annotation_diagnostic(
9521 ANNOTATIONS_KEY_ONLY,
9522 Severity::Warning,
9523 span,
9524 "key-only service annotation has no explicit value",
9525 "ambiguous key-only annotation",
9526 ));
9527 }
9528 }
9529
9530 fn parse_annotation_map(&mut self, mapping: &Mapping) -> Annotations {
9531 let span = span_from_position(self.source_id, mapping.byte_range());
9532 let mut entries = Vec::new();
9533 let mut seen = BTreeMap::new();
9534 for entry in self.fields(mapping) {
9535 let _duplicate = self.record_duplicate(&mut seen, &entry);
9536 if entry.name.value.is_empty() {
9537 self.diagnostics.push(annotation_diagnostic(
9538 ANNOTATIONS_EMPTY_NAME,
9539 Severity::Error,
9540 entry.name.span,
9541 "service annotation name must not be empty",
9542 "empty annotation name",
9543 ));
9544 }
9545 if let Some(value) = self.parse_compose_scalar(
9546 &entry,
9547 "annotation mapping values must be scalar strings, numbers, booleans, or null",
9548 ) {
9549 entries.push(KeyValueEntry::new(entry.name, value, entry.span));
9550 }
9551 }
9552 Annotations::new(span, AnnotationsForm::Map(entries))
9553 }
9554
9555 fn field_is_null(field: &ParsedField) -> bool {
9556 field
9557 .value
9558 .as_ref()
9559 .is_none_or(|node| node.as_scalar().is_some_and(scalar_is_null))
9560 }
9561
9562 fn unsupported_sequence_item(
9563 &mut self,
9564 code: DiagnosticCode,
9565 node: &YamlNode,
9566 fallback_span: SourceSpan,
9567 message: impl Into<String>,
9568 ) {
9569 let span = node_span(self.source_id, node).unwrap_or(fallback_span);
9570 self.diagnostics.push(
9571 Diagnostic::new(code, Severity::Error, message)
9572 .with_label(DiagnosticLabel::primary(span, "unsupported value form")),
9573 );
9574 }
9575
9576 fn fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
9577 let fields = self.raw_fields(mapping);
9578 let mut fields = self.flatten_empty_value_continuations(fields);
9579 for field in &mut fields {
9580 field.value = field.value.take().map(|value| self.resolve_alias(value));
9581 }
9582 fields
9583 }
9584
9585 fn raw_fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
9586 mapping
9587 .entries()
9588 .filter_map(|entry| {
9589 let key = entry.key_node()?;
9590 let Some(scalar) = key.as_scalar() else {
9591 let span = node_span(self.source_id, &key)
9592 .unwrap_or_else(|| span_from_position(self.source_id, mapping.byte_range()));
9593 self.diagnostics.push(
9594 Diagnostic::new(EXPECTED_SCALAR, Severity::Error, "Compose mapping keys must be scalars")
9595 .with_label(DiagnosticLabel::primary(span, "non-scalar key")),
9596 );
9597 return None;
9598 };
9599 let name_span = span_from_position(self.source_id, scalar.byte_range());
9600 let authored_value = entry.value_node();
9601 let value_span = authored_value
9602 .as_ref()
9603 .and_then(|value| node_span(self.source_id, value));
9604 let value = authored_value.map(unwrap_processing_tag);
9605 let span = value_span.map_or(name_span, |value_span| union(name_span, value_span));
9606 Some(ParsedField {
9607 name: Located::new(scalar_string_from_source(&self.source, scalar), name_span),
9608 value,
9609 value_span,
9610 span,
9611 })
9612 })
9613 .collect()
9614 }
9615
9616 fn resolve_alias(&self, node: YamlNode) -> YamlNode {
9617 let mut node = node;
9618 let mut visited = BTreeSet::new();
9619 for _ in 0..64 {
9620 let YamlNode::Alias(alias) = &node else {
9621 return node;
9622 };
9623 if !visited.insert(alias.name()) {
9624 return node;
9625 }
9626 let Some(target) = self.anchors.resolve(&alias.name()).and_then(|target| {
9627 YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
9628 }) else {
9629 return node;
9630 };
9631 node = target;
9632 }
9633 node
9634 }
9635
9636 fn flatten_empty_value_continuations(&mut self, fields: Vec<ParsedField>) -> Vec<ParsedField> {
9637 let Some(target_column) = fields.first().map(|field| self.source_column(field.name.span.start())) else {
9638 return fields;
9639 };
9640 self.recover_fields(fields, target_column)
9641 }
9642
9643 fn recover_fields(&mut self, fields: Vec<ParsedField>, target_column: usize) -> Vec<ParsedField> {
9644 let mut flattened = Vec::new();
9645 for mut field in fields {
9646 let field_column = self.source_column(field.name.span.start());
9647 let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
9648 let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
9649 !self.is_flow_mapping(mapping)
9650 && mapping
9651 .entries()
9652 .find_map(|entry| {
9653 let key = entry.key_node()?;
9654 let scalar = key.as_scalar()?;
9655 Some(scalar.byte_range().start as usize)
9656 })
9657 .is_some_and(|key_start| self.source_column(key_start) <= field_column)
9658 });
9659
9660 if continuation {
9661 field.value = None;
9662 field.value_span = None;
9663 field.span = field.name.span;
9664 }
9665 if field_column == target_column {
9666 flattened.push(field);
9667 }
9668 if let Some(mapping) = nested_mapping.filter(|mapping| !self.is_flow_mapping(mapping)) {
9669 let nested = self.raw_fields(&mapping);
9670 flattened.extend(self.recover_fields(nested, target_column));
9671 }
9672 }
9673 flattened
9674 }
9675
9676 fn is_flow_mapping(&self, mapping: &Mapping) -> bool {
9677 let position = mapping.byte_range();
9678 self.source
9679 .get(position.start as usize..position.end as usize)
9680 .is_some_and(|text| text.trim_start().starts_with('{'))
9681 }
9682
9683 fn record_duplicate(&mut self, seen: &mut BTreeMap<String, SourceSpan>, field: &ParsedField) -> bool {
9684 if let Some(first) = seen.get(field.name.value()) {
9685 self.diagnostics.push(
9686 Diagnostic::new(
9687 DUPLICATE_FIELD,
9688 Severity::Error,
9689 "Compose mapping fields must be unique",
9690 )
9691 .with_label(DiagnosticLabel::primary(field.name.span, "duplicate field"))
9692 .with_label(DiagnosticLabel::secondary(*first, "first field")),
9693 );
9694 true
9695 } else {
9696 seen.insert(field.name.value.clone(), field.name.span);
9697 false
9698 }
9699 }
9700
9701 fn expected(&mut self, code: DiagnosticCode, field: &ParsedField, message: impl Into<String>) {
9702 self.diagnostics.push(
9703 Diagnostic::new(code, Severity::Error, message)
9704 .with_label(DiagnosticLabel::primary(field.span, "unexpected value form")),
9705 );
9706 }
9707
9708 fn missing(&mut self, code: DiagnosticCode, span: SourceSpan, message: impl Into<String>) {
9709 self.diagnostics.push(
9710 Diagnostic::new(code, Severity::Error, message)
9711 .with_label(DiagnosticLabel::primary(span, "incomplete long syntax")),
9712 );
9713 }
9714}
9715
9716fn unwrap_processing_tag(node: YamlNode) -> YamlNode {
9717 let YamlNode::TaggedNode(tagged) = &node else {
9718 return node;
9719 };
9720 if !matches!(tagged.tag().as_deref(), Some("!reset" | "!override")) {
9721 return node;
9722 }
9723 tagged
9724 .as_node()
9725 .and_then(|syntax| syntax.children().find_map(YamlNode::from_syntax))
9726 .unwrap_or(node)
9727}
9728
9729fn command_is_non_empty(command: &Command) -> bool {
9730 match command {
9731 Command::Null(_) => false,
9732 Command::String(value) => !value.value().is_empty(),
9733 Command::List { values, .. } => !values.is_empty(),
9734 }
9735}
9736
9737fn gpu_has_capabilities(device: &GpuDevice) -> bool {
9738 device.capabilities().iter().any(|value| !value.value().is_empty())
9739}
9740
9741#[derive(Debug, Clone)]
9742enum ParsedGrant {
9743 Short(Located<String>),
9744 Long(Box<LongGrant>),
9745}
9746
9747#[derive(Debug, Clone)]
9748struct ParsedField {
9749 name: Located<String>,
9750 value: Option<YamlNode>,
9751 value_span: Option<SourceSpan>,
9752 span: SourceSpan,
9753}
9754
9755impl ParsedField {
9756 fn reference(&self) -> FieldReference {
9757 FieldReference {
9758 name: self.name.clone(),
9759 span: self.span,
9760 value_span: self.value_span,
9761 }
9762 }
9763}
9764
9765fn scalar_uses_block_style(source: &str, scalar: &Scalar) -> bool {
9766 let start = scalar.byte_range().start as usize;
9767 source[start..].trim_start().starts_with(['|', '>'])
9768 || source[..start]
9769 .lines()
9770 .rev()
9771 .find(|line| !line.trim().is_empty())
9772 .is_some_and(|header| header.contains(": |") || header.contains(": >"))
9773}
9774
9775fn node_span(source_id: SourceId, node: &YamlNode) -> Option<SourceSpan> {
9776 let position = match node {
9777 YamlNode::Scalar(value) => value.byte_range(),
9778 YamlNode::Mapping(value) => value.byte_range(),
9779 YamlNode::Sequence(value) => value.byte_range(),
9780 YamlNode::Alias(_) | YamlNode::TaggedNode(_) => {
9781 let range = node.as_node()?.text_range();
9782 return Some(SourceSpan::from_valid_offsets(
9783 source_id,
9784 u32::from(range.start()) as usize,
9785 u32::from(range.end()) as usize,
9786 ));
9787 }
9788 };
9789 Some(span_from_position(source_id, position))
9790}
9791
9792fn span_from_position(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
9793 SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
9794}
9795
9796fn scalar_is_null(scalar: &Scalar) -> bool {
9797 scalar_type_for_value(scalar) == ScalarType::Null
9798}
9799
9800fn scalar_type_for_value(scalar: &Scalar) -> ScalarType {
9801 if scalar.byte_range().is_empty() {
9802 ScalarType::Null
9803 } else {
9804 ScalarValue::from_scalar(scalar).scalar_type()
9805 }
9806}
9807
9808fn union(left: SourceSpan, right: SourceSpan) -> SourceSpan {
9809 SourceSpan::from_valid_offsets(
9810 left.source_id(),
9811 left.start().min(right.start()),
9812 left.end().max(right.end()),
9813 )
9814}