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 models = resource_names(project, "models");
177 let mut references = Vec::new();
178
179 for service in services {
180 if !service_in_scope(selection, service.key()) {
181 continue;
182 }
183 collect_networks(service, &networks, &mut references, &mut diagnostics);
184 collect_volumes(service, &volumes, &mut references, &mut diagnostics);
185 collect_grants(
186 service,
187 "configs",
188 ReferenceKind::Config,
189 &configs,
190 &mut references,
191 &mut diagnostics,
192 );
193 collect_grants(
194 service,
195 "secrets",
196 ReferenceKind::Secret,
197 &secrets,
198 &mut references,
199 &mut diagnostics,
200 );
201 collect_service_references(service, &service_names, selection, &mut references, &mut diagnostics);
202 validate_service_models(service, &models, &mut diagnostics);
203 validate_dependency_healthchecks(service, services, selection, &mut diagnostics);
204 }
205
206 ReferenceValidation {
207 references,
208 diagnostics,
209 }
210}
211
212fn validate_service_models(service: &MergedEntry, models: &BTreeSet<&str>, diagnostics: &mut Vec<Diagnostic>) {
213 let Some(value) = service.value().get("models") else {
214 return;
215 };
216 match value.kind() {
217 crate::merge::MergedValueKind::Sequence(items) => {
218 for item in items {
219 if let Some(scalar) = item.as_scalar() {
220 diagnose_missing_model(
221 service.key(),
222 scalar.value(),
223 super::effective_span(item),
224 models,
225 diagnostics,
226 );
227 }
228 }
229 }
230 crate::merge::MergedValueKind::Mapping(entries) => {
231 for entry in entries {
232 diagnose_missing_model(service.key(), entry.key(), entry_span(entry), models, diagnostics);
233 }
234 }
235 _ => {}
236 }
237}
238
239fn diagnose_missing_model(
240 _service: &str,
241 model: &str,
242 source: SourceSpan,
243 models: &BTreeSet<&str>,
244 diagnostics: &mut Vec<Diagnostic>,
245) {
246 if models.contains(model) {
247 return;
248 }
249 diagnostics.push(
250 Diagnostic::new(
251 MISSING_REFERENCE,
252 Severity::Error,
253 "selected service model binding references an undefined model",
254 )
255 .with_label(DiagnosticLabel::primary(source, "model is not declared")),
256 );
257}
258
259fn resource_names<'a>(project: &'a MergedProject, field: &str) -> BTreeSet<&'a str> {
260 project
261 .root()
262 .get(field)
263 .and_then(MergedValue::as_mapping)
264 .into_iter()
265 .flatten()
266 .map(MergedEntry::key)
267 .collect()
268}
269
270fn collect_networks(
271 service: &MergedEntry,
272 definitions: &BTreeSet<&str>,
273 references: &mut Vec<Reference>,
274 diagnostics: &mut Vec<Diagnostic>,
275) {
276 let Some(networks) = service.value().get("networks") else {
277 return;
278 };
279 if let Some(values) = networks.as_sequence() {
280 for value in values {
281 if let Some(scalar) = value.as_scalar() {
282 push_resource(
283 service.key(),
284 scalar.value(),
285 super::effective_span(value),
286 ReferenceKind::Network,
287 definitions.contains(scalar.value()) || scalar.value() == "default",
288 scalar.is_sensitive(),
289 references,
290 diagnostics,
291 );
292 }
293 }
294 } else if let Some(entries) = networks.as_mapping() {
295 for network in entries {
296 push_reference(
297 service.key(),
298 network.key(),
299 entry_span(network),
300 ReferenceKind::Network,
301 if definitions.contains(network.key()) || network.key() == "default" {
302 ReferenceStatus::Found
303 } else {
304 ReferenceStatus::Missing
305 },
306 false,
307 true,
308 references,
309 diagnostics,
310 );
311 }
312 }
313}
314
315fn collect_volumes(
316 service: &MergedEntry,
317 definitions: &BTreeSet<&str>,
318 references: &mut Vec<Reference>,
319 diagnostics: &mut Vec<Diagnostic>,
320) {
321 let Some(values) = service.value().get("volumes").and_then(MergedValue::as_sequence) else {
322 return;
323 };
324 for value in values {
325 if let Some(scalar) = value.as_scalar() {
326 let span = super::effective_span(value);
327 let mount = ShortVolumeMount::new(Located::new(scalar.value().to_owned(), span));
328 if let Some(source) = mount.source().filter(|source| !is_path_source(source)) {
329 push_resource(
330 service.key(),
331 source,
332 span,
333 ReferenceKind::Volume,
334 definitions.contains(source),
335 scalar.is_sensitive(),
336 references,
337 diagnostics,
338 );
339 }
340 continue;
341 }
342 let mount_type = value
343 .get("type")
344 .and_then(MergedValue::as_scalar)
345 .map_or("volume", MergedScalar::value);
346 if mount_type != "volume" {
347 continue;
348 }
349 if let Some(source) = value.get("source") {
350 if let Some(scalar) = source.as_scalar() {
351 push_resource(
352 service.key(),
353 scalar.value(),
354 super::effective_span(source),
355 ReferenceKind::Volume,
356 definitions.contains(scalar.value()),
357 scalar.is_sensitive(),
358 references,
359 diagnostics,
360 );
361 }
362 }
363 }
364}
365
366fn collect_grants(
367 service: &MergedEntry,
368 field: &str,
369 kind: ReferenceKind,
370 definitions: &BTreeSet<&str>,
371 references: &mut Vec<Reference>,
372 diagnostics: &mut Vec<Diagnostic>,
373) {
374 let Some(values) = service.value().get(field).and_then(MergedValue::as_sequence) else {
375 return;
376 };
377 for value in values {
378 let source = value
379 .as_scalar()
380 .map(|scalar| (scalar, super::effective_span(value)))
381 .or_else(|| {
382 let source = value.get("source")?;
383 Some((source.as_scalar()?, super::effective_span(source)))
384 });
385 if let Some((scalar, span)) = source {
386 push_resource(
387 service.key(),
388 scalar.value(),
389 span,
390 kind,
391 definitions.contains(scalar.value()),
392 scalar.is_sensitive(),
393 references,
394 diagnostics,
395 );
396 }
397 }
398}
399
400fn collect_service_references(
401 service: &MergedEntry,
402 service_names: &BTreeSet<&str>,
403 selection: Option<&ProfileSelection>,
404 references: &mut Vec<Reference>,
405 diagnostics: &mut Vec<Diagnostic>,
406) {
407 if let Some(depends_on) = service.value().get("depends_on") {
408 collect_service_collection(
409 service,
410 depends_on,
411 ReferenceKind::Dependency,
412 service_names,
413 selection,
414 references,
415 diagnostics,
416 );
417 }
418 for field in ["network_mode", "ipc", "pid"] {
419 let Some(value) = service.value().get(field) else {
420 continue;
421 };
422 let Some(scalar) = value.as_scalar() else {
423 continue;
424 };
425 if let Some(target) = scalar.value().strip_prefix("service:") {
426 push_service(
427 service.key(),
428 target,
429 super::effective_span(value),
430 ReferenceKind::ServiceNamespace,
431 scalar.is_sensitive(),
432 true,
433 service_names,
434 selection,
435 references,
436 diagnostics,
437 );
438 }
439 }
440 if let Some(values) = service.value().get("volumes_from").and_then(MergedValue::as_sequence) {
441 for value in values {
442 let Some(scalar) = value.as_scalar() else {
443 continue;
444 };
445 let source = scalar.value().rsplit_once(':').map_or(scalar.value(), |(name, mode)| {
446 if matches!(mode, "ro" | "rw") {
447 name
448 } else {
449 scalar.value()
450 }
451 });
452 let source = source.strip_prefix("service:").unwrap_or(source);
453 if !source.starts_with("container:") {
454 push_service(
455 service.key(),
456 source,
457 super::effective_span(value),
458 ReferenceKind::ServiceNamespace,
459 scalar.is_sensitive(),
460 true,
461 service_names,
462 selection,
463 references,
464 diagnostics,
465 );
466 }
467 }
468 }
469 collect_service_links(service, service_names, selection, references, diagnostics);
470 if let Some(extends) = service.value().get("extends") {
471 if extends.get("file").is_none() {
472 if let Some(target) = extends.get("service") {
473 if let Some(scalar) = target.as_scalar() {
474 push_service(
475 service.key(),
476 scalar.value(),
477 super::effective_span(target),
478 ReferenceKind::Extends,
479 scalar.is_sensitive(),
480 true,
481 service_names,
482 selection,
483 references,
484 diagnostics,
485 );
486 }
487 }
488 }
489 }
490}
491
492fn collect_service_links(
493 service: &MergedEntry,
494 service_names: &BTreeSet<&str>,
495 selection: Option<&ProfileSelection>,
496 references: &mut Vec<Reference>,
497 diagnostics: &mut Vec<Diagnostic>,
498) {
499 let Some(links) = service.value().get("links").and_then(MergedValue::as_sequence) else {
500 return;
501 };
502 for link in links {
503 let Some(scalar) = link.as_scalar() else {
504 continue;
505 };
506 let target = scalar
507 .value()
508 .split_once(':')
509 .map_or(scalar.value(), |(target, _)| target);
510 push_service(
511 service.key(),
512 target,
513 super::effective_span(link),
514 ReferenceKind::Link,
515 scalar.is_sensitive(),
516 true,
517 service_names,
518 selection,
519 references,
520 diagnostics,
521 );
522 }
523}
524
525fn validate_dependency_healthchecks(
526 service: &MergedEntry,
527 services: &[MergedEntry],
528 selection: Option<&ProfileSelection>,
529 diagnostics: &mut Vec<Diagnostic>,
530) {
531 let Some(dependencies) = service.value().get("depends_on").and_then(MergedValue::as_mapping) else {
532 return;
533 };
534 for dependency in dependencies {
535 let Some(condition) = dependency.value().get("condition") else {
536 continue;
537 };
538 if condition.as_scalar().map(MergedScalar::value) != Some("service_healthy") {
539 continue;
540 }
541 let Some(target) = services.iter().find(|candidate| candidate.key() == dependency.key()) else {
542 continue;
543 };
544 if !service_in_scope(selection, target.key()) {
545 continue;
546 }
547 let span = super::effective_span(condition);
548 let required = dependency
549 .value()
550 .get("required")
551 .and_then(MergedValue::as_scalar)
552 .is_none_or(|value| value.value() != "false");
553 match target.value().get("healthcheck") {
554 None => diagnostics.push(
555 Diagnostic::new(
556 UNVERIFIED_DEPENDENCY_HEALTHCHECK,
557 Severity::Warning,
558 "service_healthy dependency has no Compose healthcheck to validate",
559 )
560 .with_label(DiagnosticLabel::primary(span, "image health metadata is not available"))
561 .with_note("the dependency image may still define a health check; verify it at build or runtime"),
562 ),
563 Some(healthcheck) if healthcheck_is_disabled(healthcheck) => diagnostics.push(
564 Diagnostic::new(
565 DISABLED_DEPENDENCY_HEALTHCHECK,
566 if required { Severity::Error } else { Severity::Warning },
567 if required {
568 "service_healthy dependency explicitly disables its health check"
569 } else {
570 "optional service_healthy dependency explicitly disables its health check"
571 },
572 )
573 .with_label(DiagnosticLabel::primary(span, "dependency cannot become healthy")),
574 ),
575 Some(_) => {}
576 }
577 }
578}
579
580fn healthcheck_is_disabled(healthcheck: &MergedValue) -> bool {
581 if healthcheck
582 .get("disable")
583 .and_then(MergedValue::as_scalar)
584 .is_some_and(|value| value.value() == "true")
585 {
586 return true;
587 }
588 healthcheck
589 .get("test")
590 .and_then(MergedValue::as_sequence)
591 .and_then(|values| values.first())
592 .and_then(MergedValue::as_scalar)
593 .is_some_and(|value| value.value() == "NONE")
594}
595
596#[allow(clippy::too_many_arguments)]
597fn collect_service_collection(
598 service: &MergedEntry,
599 value: &MergedValue,
600 kind: ReferenceKind,
601 service_names: &BTreeSet<&str>,
602 selection: Option<&ProfileSelection>,
603 references: &mut Vec<Reference>,
604 diagnostics: &mut Vec<Diagnostic>,
605) {
606 if let Some(values) = value.as_sequence() {
607 for dependency in values {
608 if let Some(scalar) = dependency.as_scalar() {
609 push_service(
610 service.key(),
611 scalar.value(),
612 super::effective_span(dependency),
613 kind,
614 scalar.is_sensitive(),
615 true,
616 service_names,
617 selection,
618 references,
619 diagnostics,
620 );
621 }
622 }
623 } else if let Some(entries) = value.as_mapping() {
624 for dependency in entries {
625 let required = dependency
626 .value()
627 .get("required")
628 .and_then(MergedValue::as_scalar)
629 .is_none_or(|value| value.value() != "false");
630 push_service(
631 service.key(),
632 dependency.key(),
633 entry_span(dependency),
634 kind,
635 false,
636 required,
637 service_names,
638 selection,
639 references,
640 diagnostics,
641 );
642 }
643 }
644}
645
646#[allow(clippy::too_many_arguments)]
647fn push_resource(
648 source_service: &str,
649 target: &str,
650 source: SourceSpan,
651 kind: ReferenceKind,
652 found: bool,
653 sensitive: bool,
654 references: &mut Vec<Reference>,
655 diagnostics: &mut Vec<Diagnostic>,
656) {
657 push_reference(
658 source_service,
659 target,
660 source,
661 kind,
662 if found {
663 ReferenceStatus::Found
664 } else {
665 ReferenceStatus::Missing
666 },
667 sensitive,
668 true,
669 references,
670 diagnostics,
671 );
672}
673
674#[allow(clippy::too_many_arguments)]
675fn push_service(
676 source_service: &str,
677 target: &str,
678 source: SourceSpan,
679 kind: ReferenceKind,
680 sensitive: bool,
681 required: bool,
682 service_names: &BTreeSet<&str>,
683 selection: Option<&ProfileSelection>,
684 references: &mut Vec<Reference>,
685 diagnostics: &mut Vec<Diagnostic>,
686) {
687 let status = if !service_names.contains(target) {
688 ReferenceStatus::Missing
689 } else if selection.is_some_and(|selection| !selection.is_active(target)) {
690 ReferenceStatus::Inactive
691 } else {
692 ReferenceStatus::Found
693 };
694 push_reference(
695 source_service,
696 target,
697 source,
698 kind,
699 status,
700 sensitive,
701 required,
702 references,
703 diagnostics,
704 );
705}
706
707#[allow(clippy::too_many_arguments)]
708fn push_reference(
709 source_service: &str,
710 target: &str,
711 source: SourceSpan,
712 kind: ReferenceKind,
713 status: ReferenceStatus,
714 sensitive: bool,
715 required: bool,
716 references: &mut Vec<Reference>,
717 diagnostics: &mut Vec<Diagnostic>,
718) {
719 if status != ReferenceStatus::Found {
720 let (code, message, label) = if status == ReferenceStatus::Missing {
721 (
722 MISSING_REFERENCE,
723 "selected service has an undefined reference",
724 "target is not declared",
725 )
726 } else {
727 (
728 INACTIVE_SERVICE_REFERENCE,
729 "selected service references a profile-disabled service",
730 "target service is inactive",
731 )
732 };
733 diagnostics.push(
734 Diagnostic::new(
735 code,
736 if required { Severity::Error } else { Severity::Warning },
737 if required {
738 message
739 } else {
740 "optional service dependency is unavailable"
741 },
742 )
743 .with_label(DiagnosticLabel::primary(source, label)),
744 );
745 }
746 references.push(Reference {
747 source_service: source_service.to_owned(),
748 target: target.to_owned(),
749 source,
750 kind,
751 status,
752 sensitive,
753 required,
754 });
755}