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}
41
42impl Capability {
43 pub fn as_str(self) -> &'static str {
44 match self {
45 Self::ReverseProxy => "reverse-proxy",
46 Self::OidcProvider => "oidc-provider",
47 Self::ForwardAuthProvider => "forward-auth-provider",
48 Self::SmtpRelay => "smtp-relay",
49 }
50 }
51}
52
53impl std::fmt::Display for Capability {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.write_str(self.as_str())
56 }
57}
58
59/// Whether a service named `name` provides the given capability,
60/// resolved by reading its `[capabilities] provides` declaration from
61/// the cached default registry on disk.
62///
63/// Returns `false` if the default registry hasn't been cloned yet, the
64/// service isn't in the default registry, or the file fails to parse —
65/// capability dispatch on uninstalled, unknown names is not a query we
66/// answer. Call sites that already hold a
67/// [`crate::registry::service_def::ServiceDef`] should call
68/// [`def_provides`] instead to skip the round-trip.
69pub fn service_provides(name: &str, cap: Capability) -> bool {
70 lookup_provides_from_registry(name)
71 .map(|provides| provides.contains(&cap))
72 .unwrap_or(false)
73}
74
75/// Capability list declared by a [`ServiceDef`]. Use this when the def
76/// is already in scope (e.g. in `add_service` after `find_service`) —
77/// it avoids the registry round-trip that [`service_provides`] does.
78pub fn def_provides(def: &crate::registry::service_def::ServiceDef, cap: Capability) -> bool {
79 def.capabilities.provides.contains(&cap)
80}
81
82/// Whether an `InstalledService` provides the given capability. Reads
83/// from the persisted snapshot in `metadata.toml` (hydrated into
84/// [`InstalledService::provides`] at [`crate::list_installed`] time).
85pub fn installed_provides(svc: &InstalledService, cap: Capability) -> bool {
86 svc.provides.contains(&cap)
87}
88
89/// Read `[capabilities] provides` for a service in the default registry.
90///
91/// Reads from the on-disk cache at `<cache>/default/<name>/service.toml`
92/// (populated by the first `ryra add`/`ryra search`) or from the
93/// `RYRA_REGISTRY_DIR` override directory. Returns `None` if the
94/// registry hasn't been cloned yet, the service isn't in the default
95/// registry, or the file fails to parse — capability dispatch on
96/// uninstalled, unknown names is not a query we answer.
97///
98/// Intentionally sync: callers (e.g. `retroactive_network_joins`) run in
99/// sync contexts and only need the cached snapshot, not a fresh git
100/// clone. The first `ryra add` populates the cache, so by the time any
101/// installed-services workflow asks "does X provide Y," the on-disk
102/// registry directory is already there.
103fn lookup_provides_from_registry(name: &str) -> Option<Vec<Capability>> {
104 let paths = crate::config::ConfigPaths::resolve().ok()?;
105
106 // Mirror resolve_default_registry_dir's env-override logic so an
107 // RYRA_REGISTRY_DIR=/path/to/registry can serve capability lookups
108 // before any clone has happened.
109 let registry_dir = if let Ok(override_path) = std::env::var(crate::paths::REGISTRY_DIR_ENV)
110 && let Some(p) = Some(std::path::PathBuf::from(override_path)).filter(|p| p.is_dir())
111 {
112 p
113 } else {
114 paths.cache_dir.join("default")
115 };
116
117 if !registry_dir.exists() {
118 return None;
119 }
120
121 let entry = crate::registry::find_service(®istry_dir, name).ok()?;
122 Some(entry.def.capabilities.provides)
123}
124
125/// Find an installed service that provides the given capability. Returns
126/// the first match — capabilities like [`Capability::ReverseProxy`] are
127/// expected to have at most one provider installed at a time, but we
128/// don't enforce that yet (a future "multiple OIDC providers" world is
129/// the caller's problem to resolve).
130pub fn find_installed_provider(
131 installed: &[InstalledService],
132 cap: Capability,
133) -> Option<&InstalledService> {
134 installed.iter().find(|s| installed_provides(s, cap))
135}
136
137/// Convenience: check live install state via [`crate::list_installed`]
138/// for whether *any* provider of `cap` is currently installed. Use this
139/// at planning sites that don't already have an `installed: &[…]` slice
140/// in scope — anything inside [`crate::auth_bridge`] takes the slice as
141/// a parameter and should call [`find_installed_provider`] instead.
142pub fn any_installed_provider(cap: Capability) -> bool {
143 crate::list_installed()
144 .ok()
145 .map(|installed| find_installed_provider(&installed, cap).is_some())
146 .unwrap_or(false)
147}