1use super::paths::is_path_source;
2use super::{entry_span, selection_matches, service_entries, service_in_scope};
3use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::merge::{MergedEntry, MergedProject, MergedScalar, MergedValue};
5use crate::model::{Located, ShortVolumeMount};
6use crate::profiles::ProfileSelection;
7use crate::source::SourceSpan;
8use std::collections::BTreeSet;
9use std::fmt;
10
11pub const MISSING_REFERENCE: DiagnosticCode = DiagnosticCode::new("compose.references.missing");
13
14pub const INACTIVE_SERVICE_REFERENCE: DiagnosticCode = DiagnosticCode::new("compose.references.inactive-service");
16
17pub const UNVERIFIED_DEPENDENCY_HEALTHCHECK: DiagnosticCode =
19 DiagnosticCode::new("compose.references.healthcheck-unverified");
20
21pub const DISABLED_DEPENDENCY_HEALTHCHECK: DiagnosticCode =
23 DiagnosticCode::new("compose.references.healthcheck-disabled");
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum ReferenceKind {
28 Network,
30 Volume,
32 Config,
34 Secret,
36 Dependency,
38 ServiceNamespace,
40 Link,
42 Extends,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum ReferenceStatus {
49 Found,
51 Missing,
53 Inactive,
55}
56
57#[derive(Clone, PartialEq, Eq)]
59pub struct Reference {
60 source_service: String,
61 target: String,
62 source: SourceSpan,
63 kind: ReferenceKind,
64 status: ReferenceStatus,
65 sensitive: bool,
66 required: bool,
67}
68
69impl Reference {
70 #[must_use]
72 pub fn source_service(&self) -> &str {
73 &self.source_service
74 }
75
76 #[must_use]
78 pub fn target(&self) -> &str {
79 &self.target
80 }
81
82 #[must_use]
84 pub const fn source(&self) -> SourceSpan {
85 self.source
86 }
87
88 #[must_use]
90 pub const fn kind(&self) -> ReferenceKind {
91 self.kind
92 }
93
94 #[must_use]
96 pub const fn status(&self) -> ReferenceStatus {
97 self.status
98 }
99
100 #[must_use]
102 pub const fn is_sensitive(&self) -> bool {
103 self.sensitive
104 }
105
106 #[must_use]
110 pub const fn is_required(&self) -> bool {
111 self.required
112 }
113}
114
115impl fmt::Debug for Reference {
116 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117 formatter
118 .debug_struct("Reference")
119 .field("source_service", &self.source_service)
120 .field("target", &if self.sensitive { "<redacted>" } else { &self.target })
121 .field("source", &self.source)
122 .field("kind", &self.kind)
123 .field("status", &self.status)
124 .field("sensitive", &self.sensitive)
125 .field("required", &self.required)
126 .finish()
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ReferenceValidation {
133 references: Vec<Reference>,
134 diagnostics: Vec<Diagnostic>,
135}
136
137impl ReferenceValidation {
138 #[must_use]
140 pub fn references(&self) -> &[Reference] {
141 &self.references
142 }
143
144 #[must_use]
146 pub fn diagnostics(&self) -> &[Diagnostic] {
147 &self.diagnostics
148 }
149
150 #[must_use]
152 pub fn is_valid(&self) -> bool {
153 self.diagnostics
154 .iter()
155 .all(|diagnostic| diagnostic.severity() != Severity::Error)
156 }
157}
158
159#[must_use]
161pub fn validate_references(project: &MergedProject, selection: Option<&ProfileSelection>) -> ReferenceValidation {
162 let mut diagnostics = Vec::new();
163 if !selection_matches(project, selection, &mut diagnostics) {
164 return ReferenceValidation {
165 references: Vec::new(),
166 diagnostics,
167 };
168 }
169
170 let services = service_entries(project);
171 let service_names: BTreeSet<_> = services.iter().map(MergedEntry::key).collect();
172 let networks = resource_names(project, "networks");
173 let volumes = resource_names(project, "volumes");
174 let configs = resource_names(project, "configs");
175 let secrets = resource_names(project, "secrets");
176 let mut references = Vec::new();
177
178 for service in services {
179 if !service_in_scope(selection, service.key()) {
180 continue;
181 }
182 collect_networks(service, &networks, &mut references, &mut diagnostics);
183 collect_volumes(service, &volumes, &mut references, &mut diagnostics);
184 collect_grants(
185 service,
186 "configs",
187 ReferenceKind::Config,
188 &configs,
189 &mut references,
190 &mut diagnostics,
191 );
192 collect_grants(
193 service,
194 "secrets",
195 ReferenceKind::Secret,
196 &secrets,
197 &mut references,
198 &mut diagnostics,
199 );
200 collect_service_references(service, &service_names, selection, &mut references, &mut diagnostics);
201 validate_dependency_healthchecks(service, services, selection, &mut diagnostics);
202 }
203
204 ReferenceValidation {
205 references,
206 diagnostics,
207 }
208}
209
210fn resource_names<'a>(project: &'a MergedProject, field: &str) -> BTreeSet<&'a str> {
211 project
212 .root()
213 .get(field)
214 .and_then(MergedValue::as_mapping)
215 .into_iter()
216 .flatten()
217 .map(MergedEntry::key)
218 .collect()
219}
220
221fn collect_networks(
222 service: &MergedEntry,
223 definitions: &BTreeSet<&str>,
224 references: &mut Vec<Reference>,
225 diagnostics: &mut Vec<Diagnostic>,
226) {
227 let Some(networks) = service.value().get("networks") else {
228 return;
229 };
230 if let Some(values) = networks.as_sequence() {
231 for value in values {
232 if let Some(scalar) = value.as_scalar() {
233 push_resource(
234 service.key(),
235 scalar.value(),
236 super::effective_span(value),
237 ReferenceKind::Network,
238 definitions.contains(scalar.value()) || scalar.value() == "default",
239 scalar.is_sensitive(),
240 references,
241 diagnostics,
242 );
243 }
244 }
245 } else if let Some(entries) = networks.as_mapping() {
246 for network in entries {
247 push_reference(
248 service.key(),
249 network.key(),
250 entry_span(network),
251 ReferenceKind::Network,
252 if definitions.contains(network.key()) || network.key() == "default" {
253 ReferenceStatus::Found
254 } else {
255 ReferenceStatus::Missing
256 },
257 false,
258 true,
259 references,
260 diagnostics,
261 );
262 }
263 }
264}
265
266fn collect_volumes(
267 service: &MergedEntry,
268 definitions: &BTreeSet<&str>,
269 references: &mut Vec<Reference>,
270 diagnostics: &mut Vec<Diagnostic>,
271) {
272 let Some(values) = service.value().get("volumes").and_then(MergedValue::as_sequence) else {
273 return;
274 };
275 for value in values {
276 if let Some(scalar) = value.as_scalar() {
277 let span = super::effective_span(value);
278 let mount = ShortVolumeMount::new(Located::new(scalar.value().to_owned(), span));
279 if let Some(source) = mount.source().filter(|source| !is_path_source(source)) {
280 push_resource(
281 service.key(),
282 source,
283 span,
284 ReferenceKind::Volume,
285 definitions.contains(source),
286 scalar.is_sensitive(),
287 references,
288 diagnostics,
289 );
290 }
291 continue;
292 }
293 let mount_type = value
294 .get("type")
295 .and_then(MergedValue::as_scalar)
296 .map_or("volume", MergedScalar::value);
297 if mount_type != "volume" {
298 continue;
299 }
300 if let Some(source) = value.get("source") {
301 if let Some(scalar) = source.as_scalar() {
302 push_resource(
303 service.key(),
304 scalar.value(),
305 super::effective_span(source),
306 ReferenceKind::Volume,
307 definitions.contains(scalar.value()),
308 scalar.is_sensitive(),
309 references,
310 diagnostics,
311 );
312 }
313 }
314 }
315}
316
317fn collect_grants(
318 service: &MergedEntry,
319 field: &str,
320 kind: ReferenceKind,
321 definitions: &BTreeSet<&str>,
322 references: &mut Vec<Reference>,
323 diagnostics: &mut Vec<Diagnostic>,
324) {
325 let Some(values) = service.value().get(field).and_then(MergedValue::as_sequence) else {
326 return;
327 };
328 for value in values {
329 let source = value
330 .as_scalar()
331 .map(|scalar| (scalar, super::effective_span(value)))
332 .or_else(|| {
333 let source = value.get("source")?;
334 Some((source.as_scalar()?, super::effective_span(source)))
335 });
336 if let Some((scalar, span)) = source {
337 push_resource(
338 service.key(),
339 scalar.value(),
340 span,
341 kind,
342 definitions.contains(scalar.value()),
343 scalar.is_sensitive(),
344 references,
345 diagnostics,
346 );
347 }
348 }
349}
350
351fn collect_service_references(
352 service: &MergedEntry,
353 service_names: &BTreeSet<&str>,
354 selection: Option<&ProfileSelection>,
355 references: &mut Vec<Reference>,
356 diagnostics: &mut Vec<Diagnostic>,
357) {
358 if let Some(depends_on) = service.value().get("depends_on") {
359 collect_service_collection(
360 service,
361 depends_on,
362 ReferenceKind::Dependency,
363 service_names,
364 selection,
365 references,
366 diagnostics,
367 );
368 }
369 for field in ["network_mode", "ipc", "pid"] {
370 let Some(value) = service.value().get(field) else {
371 continue;
372 };
373 let Some(scalar) = value.as_scalar() else {
374 continue;
375 };
376 if let Some(target) = scalar.value().strip_prefix("service:") {
377 push_service(
378 service.key(),
379 target,
380 super::effective_span(value),
381 ReferenceKind::ServiceNamespace,
382 scalar.is_sensitive(),
383 true,
384 service_names,
385 selection,
386 references,
387 diagnostics,
388 );
389 }
390 }
391 if let Some(links) = service.value().get("links").and_then(MergedValue::as_sequence) {
392 for link in links {
393 if let Some(scalar) = link.as_scalar() {
394 let target = scalar
395 .value()
396 .split_once(':')
397 .map_or(scalar.value(), |(target, _)| target);
398 push_service(
399 service.key(),
400 target,
401 super::effective_span(link),
402 ReferenceKind::Link,
403 scalar.is_sensitive(),
404 true,
405 service_names,
406 selection,
407 references,
408 diagnostics,
409 );
410 }
411 }
412 }
413 if let Some(extends) = service.value().get("extends") {
414 if extends.get("file").is_none() {
415 if let Some(target) = extends.get("service") {
416 if let Some(scalar) = target.as_scalar() {
417 push_service(
418 service.key(),
419 scalar.value(),
420 super::effective_span(target),
421 ReferenceKind::Extends,
422 scalar.is_sensitive(),
423 true,
424 service_names,
425 selection,
426 references,
427 diagnostics,
428 );
429 }
430 }
431 }
432 }
433}
434
435fn validate_dependency_healthchecks(
436 service: &MergedEntry,
437 services: &[MergedEntry],
438 selection: Option<&ProfileSelection>,
439 diagnostics: &mut Vec<Diagnostic>,
440) {
441 let Some(dependencies) = service.value().get("depends_on").and_then(MergedValue::as_mapping) else {
442 return;
443 };
444 for dependency in dependencies {
445 let Some(condition) = dependency.value().get("condition") else {
446 continue;
447 };
448 if condition.as_scalar().map(MergedScalar::value) != Some("service_healthy") {
449 continue;
450 }
451 let Some(target) = services.iter().find(|candidate| candidate.key() == dependency.key()) else {
452 continue;
453 };
454 if !service_in_scope(selection, target.key()) {
455 continue;
456 }
457 let span = super::effective_span(condition);
458 let required = dependency
459 .value()
460 .get("required")
461 .and_then(MergedValue::as_scalar)
462 .is_none_or(|value| value.value() != "false");
463 match target.value().get("healthcheck") {
464 None => diagnostics.push(
465 Diagnostic::new(
466 UNVERIFIED_DEPENDENCY_HEALTHCHECK,
467 Severity::Warning,
468 "service_healthy dependency has no Compose healthcheck to validate",
469 )
470 .with_label(DiagnosticLabel::primary(span, "image health metadata is not available"))
471 .with_note("the dependency image may still define a health check; verify it at build or runtime"),
472 ),
473 Some(healthcheck) if healthcheck_is_disabled(healthcheck) => diagnostics.push(
474 Diagnostic::new(
475 DISABLED_DEPENDENCY_HEALTHCHECK,
476 if required { Severity::Error } else { Severity::Warning },
477 if required {
478 "service_healthy dependency explicitly disables its health check"
479 } else {
480 "optional service_healthy dependency explicitly disables its health check"
481 },
482 )
483 .with_label(DiagnosticLabel::primary(span, "dependency cannot become healthy")),
484 ),
485 Some(_) => {}
486 }
487 }
488}
489
490fn healthcheck_is_disabled(healthcheck: &MergedValue) -> bool {
491 if healthcheck
492 .get("disable")
493 .and_then(MergedValue::as_scalar)
494 .is_some_and(|value| value.value() == "true")
495 {
496 return true;
497 }
498 healthcheck
499 .get("test")
500 .and_then(MergedValue::as_sequence)
501 .and_then(|values| values.first())
502 .and_then(MergedValue::as_scalar)
503 .is_some_and(|value| value.value() == "NONE")
504}
505
506#[allow(clippy::too_many_arguments)]
507fn collect_service_collection(
508 service: &MergedEntry,
509 value: &MergedValue,
510 kind: ReferenceKind,
511 service_names: &BTreeSet<&str>,
512 selection: Option<&ProfileSelection>,
513 references: &mut Vec<Reference>,
514 diagnostics: &mut Vec<Diagnostic>,
515) {
516 if let Some(values) = value.as_sequence() {
517 for dependency in values {
518 if let Some(scalar) = dependency.as_scalar() {
519 push_service(
520 service.key(),
521 scalar.value(),
522 super::effective_span(dependency),
523 kind,
524 scalar.is_sensitive(),
525 true,
526 service_names,
527 selection,
528 references,
529 diagnostics,
530 );
531 }
532 }
533 } else if let Some(entries) = value.as_mapping() {
534 for dependency in entries {
535 let required = dependency
536 .value()
537 .get("required")
538 .and_then(MergedValue::as_scalar)
539 .is_none_or(|value| value.value() != "false");
540 push_service(
541 service.key(),
542 dependency.key(),
543 entry_span(dependency),
544 kind,
545 false,
546 required,
547 service_names,
548 selection,
549 references,
550 diagnostics,
551 );
552 }
553 }
554}
555
556#[allow(clippy::too_many_arguments)]
557fn push_resource(
558 source_service: &str,
559 target: &str,
560 source: SourceSpan,
561 kind: ReferenceKind,
562 found: bool,
563 sensitive: bool,
564 references: &mut Vec<Reference>,
565 diagnostics: &mut Vec<Diagnostic>,
566) {
567 push_reference(
568 source_service,
569 target,
570 source,
571 kind,
572 if found {
573 ReferenceStatus::Found
574 } else {
575 ReferenceStatus::Missing
576 },
577 sensitive,
578 true,
579 references,
580 diagnostics,
581 );
582}
583
584#[allow(clippy::too_many_arguments)]
585fn push_service(
586 source_service: &str,
587 target: &str,
588 source: SourceSpan,
589 kind: ReferenceKind,
590 sensitive: bool,
591 required: bool,
592 service_names: &BTreeSet<&str>,
593 selection: Option<&ProfileSelection>,
594 references: &mut Vec<Reference>,
595 diagnostics: &mut Vec<Diagnostic>,
596) {
597 let status = if !service_names.contains(target) {
598 ReferenceStatus::Missing
599 } else if selection.is_some_and(|selection| !selection.is_active(target)) {
600 ReferenceStatus::Inactive
601 } else {
602 ReferenceStatus::Found
603 };
604 push_reference(
605 source_service,
606 target,
607 source,
608 kind,
609 status,
610 sensitive,
611 required,
612 references,
613 diagnostics,
614 );
615}
616
617#[allow(clippy::too_many_arguments)]
618fn push_reference(
619 source_service: &str,
620 target: &str,
621 source: SourceSpan,
622 kind: ReferenceKind,
623 status: ReferenceStatus,
624 sensitive: bool,
625 required: bool,
626 references: &mut Vec<Reference>,
627 diagnostics: &mut Vec<Diagnostic>,
628) {
629 if status != ReferenceStatus::Found {
630 let (code, message, label) = if status == ReferenceStatus::Missing {
631 (
632 MISSING_REFERENCE,
633 "selected service has an undefined reference",
634 "target is not declared",
635 )
636 } else {
637 (
638 INACTIVE_SERVICE_REFERENCE,
639 "selected service references a profile-disabled service",
640 "target service is inactive",
641 )
642 };
643 diagnostics.push(
644 Diagnostic::new(
645 code,
646 if required { Severity::Error } else { Severity::Warning },
647 if required {
648 message
649 } else {
650 "optional service dependency is unavailable"
651 },
652 )
653 .with_label(DiagnosticLabel::primary(source, label)),
654 );
655 }
656 references.push(Reference {
657 source_service: source_service.to_owned(),
658 target: target.to_owned(),
659 source,
660 kind,
661 status,
662 sensitive,
663 required,
664 });
665}