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 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}