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