ryra_core/capability.rs
1//! Capabilities a service provides to other services.
2//!
3//! Dispatch sites that ask "is service X installed?" almost always actually
4//! mean "is there an installed service that *plays role Y*?" — modeling
5//! that question as a typed [`Capability`] lookup decouples integration
6//! glue from hardcoded service names. New providers (a different reverse
7//! proxy, a different OIDC IdP, an external SMTP relay) drop in without
8//! the auth bridge / Caddy patcher / network-join logic having to learn
9//! their names.
10//!
11//! Today the provider→capability mapping comes from
12//! [`crate::WellKnownService::capabilities`] (a static map). Step 2 of
13//! the migration moves the declaration into each service's `service.toml`
14//! and persists it through `metadata.toml` so [`InstalledService`] can
15//! report capabilities without core knowing the service name.
16
17use crate::config::schema::InstalledService;
18
19/// A role a service can play for other services. Pattern-match exhaustively
20/// — adding a new variant forces every dispatch site to think about it.
21///
22/// Serializes as a kebab-case string so it round-trips cleanly through
23/// `service.toml` (`provides = ["reverse-proxy", …]`) and through
24/// `metadata.toml` (per-install snapshot).
25#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum Capability {
28 /// Terminates TLS and routes external hostnames to service containers.
29 /// Today: Caddy. Future: nginx, traefik, etc.
30 ReverseProxy,
31 /// Issues OIDC tokens; ryra registers clients against it.
32 /// Today: Authelia. Future: Pocket-ID, Authentik, Keycloak, …
33 OidcProvider,
34 /// Sits in front of services as Caddy `forward_auth` (cookie-based
35 /// gate, no native OIDC in the protected service).
36 ForwardAuthProvider,
37 /// Accepts mail from services. Today: Inbucket (dev). Future: real
38 /// MTA configurations.
39 SmtpRelay,
40 /// Scrapes and stores metrics; ryra drops file_sd target files into
41 /// its `targets/` dir. Today: Prometheus. Future: VictoriaMetrics, …
42 MetricsStore,
43 /// Visualizes metrics from a [`Capability::MetricsStore`]; ryra
44 /// provisions a datasource pointing at the installed store.
45 /// Today: Grafana.
46 MetricsDashboard,
47}
48
49impl Capability {
50 pub fn as_str(self) -> &'static str {
51 match self {
52 Self::ReverseProxy => "reverse-proxy",
53 Self::OidcProvider => "oidc-provider",
54 Self::ForwardAuthProvider => "forward-auth-provider",
55 Self::SmtpRelay => "smtp-relay",
56 Self::MetricsStore => "metrics-store",
57 Self::MetricsDashboard => "metrics-dashboard",
58 }
59 }
60}
61
62impl std::fmt::Display for Capability {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.write_str(self.as_str())
65 }
66}
67
68/// Whether a service named `name` provides the given capability,
69/// resolved by reading its `[capabilities] provides` declaration from
70/// the cached default registry on disk.
71///
72/// Returns `false` if the default registry hasn't been cloned yet, the
73/// service isn't in the default registry, or the file fails to parse —
74/// capability dispatch on uninstalled, unknown names is not a query we
75/// answer. Call sites that already hold a
76/// [`crate::registry::service_def::ServiceDef`] should call
77/// [`def_provides`] instead to skip the round-trip.
78pub fn service_provides(name: &str, cap: Capability) -> bool {
79 lookup_provides_from_registry(name)
80 .map(|provides| provides.contains(&cap))
81 .unwrap_or(false)
82}
83
84/// Capability list declared by a [`ServiceDef`]. Use this when the def
85/// is already in scope (e.g. in `add_service` after `find_service`) —
86/// it avoids the registry round-trip that [`service_provides`] does.
87pub fn def_provides(def: &crate::registry::service_def::ServiceDef, cap: Capability) -> bool {
88 def.capabilities.provides.contains(&cap)
89}
90
91/// Whether an `InstalledService` provides the given capability. Reads
92/// from the persisted snapshot in `metadata.toml` (hydrated into
93/// [`InstalledService::provides`] at [`crate::list_installed`] time).
94pub fn installed_provides(svc: &InstalledService, cap: Capability) -> bool {
95 svc.provides.contains(&cap)
96}
97
98/// Read `[capabilities] provides` for a service in the default registry.
99///
100/// Reads from the on-disk cache at `<cache>/default/<name>/service.toml`
101/// (populated by the first `ryra add`/`ryra search`) or from the
102/// `RYRA_REGISTRY_DIR` override directory. Returns `None` if the
103/// registry hasn't been cloned yet, the service isn't in the default
104/// registry, or the file fails to parse — capability dispatch on
105/// uninstalled, unknown names is not a query we answer.
106///
107/// Intentionally sync: callers (e.g. `retroactive_network_joins`) run in
108/// sync contexts and only need the cached snapshot, not a fresh git
109/// clone. The first `ryra add` populates the cache, so by the time any
110/// installed-services workflow asks "does X provide Y," the on-disk
111/// registry directory is already there.
112fn lookup_provides_from_registry(name: &str) -> Option<Vec<Capability>> {
113 Some(lookup_registry_def(name)?.capabilities.provides)
114}
115
116/// Read a service's full definition from the cached default registry (or
117/// the `RYRA_REGISTRY_DIR` override). Same availability caveats as
118/// [`service_provides`]: `None` until the first `ryra add`/`ryra search`
119/// has populated the cache. Used by dispatch sites that need more than
120/// `provides` — e.g. the metrics bridge reading `[metrics]` and port
121/// declarations of already-installed services.
122pub(crate) fn lookup_registry_def(name: &str) -> Option<crate::registry::service_def::ServiceDef> {
123 let paths = crate::config::ConfigPaths::resolve().ok()?;
124
125 // Mirror resolve_default_registry_dir's env-override logic so an
126 // RYRA_REGISTRY_DIR=/path/to/registry can serve capability lookups
127 // before any clone has happened.
128 let registry_dir = if let Ok(override_path) = std::env::var(crate::paths::REGISTRY_DIR_ENV)
129 && let Some(p) = Some(std::path::PathBuf::from(override_path)).filter(|p| p.is_dir())
130 {
131 p
132 } else {
133 paths.cache_dir.join("default")
134 };
135
136 if !registry_dir.exists() {
137 return None;
138 }
139
140 Some(crate::registry::find_service(®istry_dir, name).ok()?.def)
141}
142
143/// Find an installed service that provides the given capability. Returns
144/// the first match — capabilities like [`Capability::ReverseProxy`] are
145/// expected to have at most one provider installed at a time, but we
146/// don't enforce that yet (a future "multiple OIDC providers" world is
147/// the caller's problem to resolve).
148pub fn find_installed_provider(
149 installed: &[InstalledService],
150 cap: Capability,
151) -> Option<&InstalledService> {
152 installed.iter().find(|s| installed_provides(s, cap))
153}
154
155/// Convenience: check live install state via [`crate::list_installed`]
156/// for whether *any* provider of `cap` is currently installed. Use this
157/// at planning sites that don't already have an `installed: &[…]` slice
158/// in scope — anything inside [`crate::auth_bridge`] takes the slice as
159/// a parameter and should call [`find_installed_provider`] instead.
160pub fn any_installed_provider(cap: Capability) -> bool {
161 crate::list_installed()
162 .ok()
163 .map(|installed| find_installed_provider(&installed, cap).is_some())
164 .unwrap_or(false)
165}