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