Skip to main content

compose_lens/resolution/
references.rs

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
11/// A selected service references an undefined service or top-level resource.
12pub const MISSING_REFERENCE: DiagnosticCode = DiagnosticCode::new("compose.references.missing");
13
14/// A selected service references a service excluded by the active profiles.
15pub const INACTIVE_SERVICE_REFERENCE: DiagnosticCode = DiagnosticCode::new("compose.references.inactive-service");
16
17/// A healthy dependency has no Compose health check and requires image/runtime verification.
18pub const UNVERIFIED_DEPENDENCY_HEALTHCHECK: DiagnosticCode =
19    DiagnosticCode::new("compose.references.healthcheck-unverified");
20
21/// A healthy dependency explicitly disables its health check.
22pub const DISABLED_DEPENDENCY_HEALTHCHECK: DiagnosticCode =
23    DiagnosticCode::new("compose.references.healthcheck-disabled");
24
25/// The semantic kind of a Compose cross-reference.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub enum ReferenceKind {
28    /// A service network attachment.
29    Network,
30    /// A named service volume mount.
31    Volume,
32    /// A service config grant.
33    Config,
34    /// A service secret grant.
35    Secret,
36    /// A `depends_on` service edge.
37    Dependency,
38    /// A `service:name` namespace edge from `network_mode`, `ipc`, or `pid`.
39    ServiceNamespace,
40    /// A legacy `links` service edge.
41    Link,
42    /// A local `extends.service` edge without an external file.
43    Extends,
44}
45
46/// Whether the referenced project object is available to the selected view.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum ReferenceStatus {
49    /// The target exists and participates in the selected view.
50    Found,
51    /// No target with that name is declared.
52    Missing,
53    /// The target service exists but its profiles are inactive.
54    Inactive,
55}
56
57/// One source-aware Compose cross-reference.
58#[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    /// Returns the service containing the reference.
71    #[must_use]
72    pub fn source_service(&self) -> &str {
73        &self.source_service
74    }
75
76    /// Returns the referenced model key.
77    #[must_use]
78    pub fn target(&self) -> &str {
79        &self.target
80    }
81
82    /// Returns the reference source span.
83    #[must_use]
84    pub const fn source(&self) -> SourceSpan {
85        self.source
86    }
87
88    /// Returns the reference kind.
89    #[must_use]
90    pub const fn kind(&self) -> ReferenceKind {
91        self.kind
92    }
93
94    /// Returns whether the target is available.
95    #[must_use]
96    pub const fn status(&self) -> ReferenceStatus {
97        self.status
98    }
99
100    /// Reports whether interpolation inserted sensitive content into the target name.
101    #[must_use]
102    pub const fn is_sensitive(&self) -> bool {
103        self.sensitive
104    }
105
106    /// Reports whether an unavailable dependency is an error rather than a warning.
107    ///
108    /// Non-dependency reference kinds are always required.
109    #[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/// Cross-reference validation for one selected project view.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ReferenceValidation {
133    references: Vec<Reference>,
134    diagnostics: Vec<Diagnostic>,
135}
136
137impl ReferenceValidation {
138    /// Returns discovered references in deterministic traversal order.
139    #[must_use]
140    pub fn references(&self) -> &[Reference] {
141        &self.references
142    }
143
144    /// Returns missing and inactive-reference diagnostics.
145    #[must_use]
146    pub fn diagnostics(&self) -> &[Diagnostic] {
147        &self.diagnostics
148    }
149
150    /// Reports whether every discovered reference is available.
151    #[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/// Validates top-level resource and service references for active services.
160#[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}