Skip to main content

ryra_core/
lib.rs

1pub mod auth_bridge;
2pub mod authelia;
3pub mod backup;
4pub mod caddy;
5pub mod capability;
6pub mod config;
7pub mod configure;
8pub mod data;
9pub mod error;
10pub mod exposure;
11pub mod generate;
12pub mod manifest;
13pub mod metadata;
14pub mod metrics_bridge;
15pub mod ops;
16pub mod paths;
17pub mod plan;
18pub mod registry;
19pub mod system;
20pub mod upgrade;
21pub mod well_known;
22
23use std::collections::BTreeMap;
24use std::path::{Path, PathBuf};
25
26use config::ConfigPaths;
27use config::schema::InstalledService;
28use error::{Error, Result};
29
30pub use capability::{
31    Capability, any_installed_provider, find_installed_provider, installed_provides,
32    service_provides,
33};
34pub use configure::{
35    ConfigureChange, ConfigureResult, ExposureChange, Overrides as ConfigureOverrides,
36    configure_service,
37};
38pub use exposure::{
39    Exposure, check_auth_exposure_compat, is_caddy_local_url, is_public_url, is_tailscale_url,
40};
41pub use generate::GeneratedFile;
42pub use manifest::{ManifestEntry, manifest_path};
43pub use metadata::{Metadata, load_metadata};
44pub use paths::{
45    CONFIG_DIR_ENV, DATA_DIR_ENV, DEFAULT_REGISTRY_URL, REGISTRY_DEFAULT, REGISTRY_DIR_ENV,
46    metadata_path, quadlet_dir, service_data_root, service_home, systemd_user_dir,
47};
48pub use plan::{AddResult, RemoveResult, ResetResult, Step, TailscalePort, TrackedEnv, Warning};
49pub use upgrade::{
50    BackupSnapshot, DEFAULT_BACKUP_KEEP, DiffEntry, DiffKind, DiffResult, EnvAddition,
51    RevertResult, UpgradeResult, diff_service, list_backups, prune_backups, revert_service,
52    upgrade_service,
53};
54pub use well_known::WellKnownService;
55
56pub(crate) use paths::home_dir;
57pub(crate) use well_known::caddy_https_port;
58
59/// Resolve the registry directory for a service reference.
60pub async fn resolve_registry_dir(service_ref: &registry::resolve::ServiceRef) -> Result<PathBuf> {
61    let paths = ConfigPaths::resolve()?;
62    paths.ensure_cache_dir()?;
63    let config = config::load_or_default(&paths.config_file)?;
64    registry::resolve::resolve_registry_dir(service_ref, &config, &paths.cache_dir).await
65}
66
67/// Build a ServiceRef from an installed service's stored registry name.
68pub fn service_ref_from_installed(installed: &InstalledService) -> registry::resolve::ServiceRef {
69    if installed.repo.is_empty() || installed.repo == REGISTRY_DEFAULT {
70        registry::resolve::ServiceRef::Default(installed.name.clone())
71    } else {
72        registry::resolve::ServiceRef::Custom {
73            registry: installed.repo.clone(),
74            service: installed.name.clone(),
75        }
76    }
77}
78
79/// When a shared-network provider (caddy or inbucket) is installed, patch
80/// already-installed services' primary quadlets to include `Network=<svc>.network`
81/// if they should reach the new provider. Emits `WriteFile` + `DaemonReload`
82/// + `RestartService` per patched service.
83///
84/// Scope is intentionally narrow: it only adds the network that the newly
85/// installed provider owns. The install-time networking policy for each
86/// patched service's OTHER networks is unchanged.
87fn retroactive_network_joins(
88    new_service: &str,
89    quadlet_path: &std::path::Path,
90    _repo_dir: Option<&std::path::Path>,
91) -> Vec<Step> {
92    let mut steps = Vec::new();
93    // Which join-relevant capability did the new service just become a
94    // provider of? `OidcProvider` doesn't trigger this — auth-aware
95    // services join its network at install time via the auth bridge,
96    // not retroactively.
97    let new_cap = if service_provides(new_service, Capability::ReverseProxy) {
98        Capability::ReverseProxy
99    } else if service_provides(new_service, Capability::SmtpRelay) {
100        Capability::SmtpRelay
101    } else {
102        return steps;
103    };
104
105    let installed = list_installed().unwrap_or_default();
106    for svc in &installed {
107        // Providers (of any capability) don't join themselves to other
108        // providers' networks via this path.
109        if !svc.provides.is_empty() {
110            continue;
111        }
112        let (network_name, should_join) = match new_cap {
113            Capability::ReverseProxy => {
114                // Services with a routed URL want the proxy network.
115                // Tailscale-exposed services route via `tailscale serve`,
116                // not the reverse proxy, so they skip.
117                let wants_proxy = matches!(
118                    svc.exposure,
119                    Exposure::Internal { .. } | Exposure::Public { .. }
120                );
121                (new_service.to_string(), wants_proxy)
122            }
123            Capability::SmtpRelay => {
124                // Any already-installed service whose .env points SMTP at
125                // the relay's hostname needs to reach it.
126                (
127                    new_service.to_string(),
128                    service_uses_smtp_relay(&svc.name, new_service),
129                )
130            }
131            // Unreachable: `new_cap` was selected from the two cases
132            // above. Match exhaustively so a new join-relevant capability
133            // forces a compile error here. Metrics wiring has its own
134            // retroactive pass (`retroactive_metrics_wiring`) because it
135            // writes targets/datasources, not just network joins.
136            Capability::OidcProvider
137            | Capability::ForwardAuthProvider
138            | Capability::MetricsStore
139            | Capability::MetricsDashboard => {
140                continue;
141            }
142        };
143        if !should_join {
144            continue;
145        }
146        let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
147        let all_service_names: Vec<&str> =
148            installed_names_owned.iter().map(|s| s.as_str()).collect();
149        steps.extend(network_join_steps(
150            &svc.name,
151            &network_name,
152            quadlet_path,
153            &all_service_names,
154        ));
155    }
156    steps
157}
158
159/// Steps joining every quadlet belonging to `svc_name` to
160/// `<network_name>.network`, followed by a daemon-reload and restarts so
161/// podman recreates the containers on the new network. Empty when every
162/// file already carries the network line.
163///
164/// Multi-container services (e.g. zammad with a separate railsserver
165/// that actually sends mail) need the network on every component
166/// container — restarting only the primary unit doesn't cascade to
167/// subunits, whose containers would keep running on the old network.
168fn network_join_steps(
169    svc_name: &str,
170    network_name: &str,
171    quadlet_path: &std::path::Path,
172    all_service_names: &[&str],
173) -> Vec<Step> {
174    let mut steps = Vec::new();
175    let marker = format!("Network={network_name}.network");
176    let mut units_to_restart: Vec<String> = Vec::new();
177    let Ok(entries) = std::fs::read_dir(quadlet_path) else {
178        return steps;
179    };
180    for entry in entries.flatten() {
181        let path = entry.path();
182        let name = match path.file_name().and_then(|n| n.to_str()) {
183            Some(n) if n.ends_with(".container") => n.to_string(),
184            _ => continue,
185        };
186        if !quadlet_belongs_to(&name, svc_name, all_service_names) {
187            continue;
188        }
189        let content = match std::fs::read_to_string(&path) {
190            Ok(c) => c,
191            Err(_) => continue,
192        };
193        if content.contains(&marker) {
194            continue;
195        }
196        // The quadlet dir holds symlinks into service homes — patch the
197        // real file, not the link: atomic_write (correctly) refuses to
198        // overwrite a symlink with a regular file.
199        let real_path = match std::fs::canonicalize(&path) {
200            Ok(p) => p,
201            Err(_) => continue,
202        };
203        let updated = generate::bundle::inject_networks(
204            &content,
205            std::slice::from_ref(&network_name.to_string()),
206        );
207        steps.push(Step::WriteFile(GeneratedFile {
208            path: real_path,
209            content: updated,
210        }));
211        // Unit name is the .container filename minus extension; systemd's
212        // generator turns `foo-bar.container` into `foo-bar.service`.
213        let unit = name.trim_end_matches(".container").to_string();
214        units_to_restart.push(unit);
215    }
216    if !units_to_restart.is_empty() {
217        steps.push(Step::DaemonReload);
218        for unit in units_to_restart {
219            steps.push(Step::RestartService { unit });
220        }
221    }
222    steps
223}
224
225/// The container port a metrics store's primary endpoint listens on,
226/// from its registry definition ("http" port, else the first declared).
227fn store_container_port(store_name: &str) -> Option<u16> {
228    let def = capability::lookup_registry_def(store_name)?;
229    def.ports
230        .iter()
231        .find(|p| p.name.eq_ignore_ascii_case("http"))
232        .or_else(|| def.ports.first())
233        .map(|p| p.container_port)
234}
235
236/// When a metrics store is being installed, wire up everything already
237/// on the machine: scrape targets for `[metrics]`-declaring services,
238/// datasources for dashboard providers, and the network joins both need
239/// to reach (or be reached by) the store. Consumers installed *after*
240/// the store are handled at their own add time instead.
241fn retroactive_metrics_wiring(
242    store_name: &str,
243    store_def: &registry::service_def::ServiceDef,
244    quadlet_path: &std::path::Path,
245) -> Vec<Step> {
246    let mut steps = Vec::new();
247    let installed = list_installed().unwrap_or_default();
248    let store_port = store_def
249        .ports
250        .iter()
251        .find(|p| p.name.eq_ignore_ascii_case("http"))
252        .or_else(|| store_def.ports.first())
253        .map(|p| p.container_port);
254    let installed_names_owned: Vec<String> = installed.iter().map(|s| s.name.clone()).collect();
255    let all_service_names: Vec<&str> = installed_names_owned.iter().map(|s| s.as_str()).collect();
256
257    for svc in &installed {
258        if svc.name == store_name {
259            continue;
260        }
261        // Needs the registry def for [metrics]/provides — services from
262        // other registries or missing from the cache are skipped (their
263        // next `ryra upgrade`/reinstall wires them).
264        let Some(def) = capability::lookup_registry_def(&svc.name) else {
265            continue;
266        };
267        let mut dashboard_wired = false;
268        let mut needs_join = false;
269        // Host-network consumers (node-exporter) are scraped via the host
270        // gateway at their *resolved* host port, parsed back out of the
271        // installed .env — and they never join the store's bridge network
272        // (Network=host conflicts with bridge networks).
273        let metrics_host_port = def.metrics.as_ref().and_then(|m| {
274            upgrade::read_existing_ports(&svc.name)
275                .ok()
276                .and_then(|ports| ports.get(&m.port.to_ascii_lowercase()).copied())
277        });
278        if let Ok(Some(step)) =
279            metrics_bridge::scrape_target_step(store_name, &def, metrics_host_port)
280        {
281            steps.push(step);
282            needs_join = def.metrics.as_ref().is_some_and(|m| !m.host_network);
283        }
284        if def
285            .capabilities
286            .provides
287            .contains(&Capability::MetricsDashboard)
288            && let Some(port) = store_port
289            && let Ok(step) = metrics_bridge::datasource_step(&svc.name, store_name, port)
290        {
291            steps.push(step);
292            dashboard_wired = true;
293            needs_join = true;
294        }
295        if !needs_join {
296            continue;
297        }
298        let join_steps =
299            network_join_steps(&svc.name, store_name, quadlet_path, &all_service_names);
300        // Dashboards read provisioning at boot — restart even when the
301        // network join was a no-op (e.g. the store was re-added and the
302        // quadlet still carries the network line).
303        let restarts_main = join_steps
304            .iter()
305            .any(|s| matches!(s, Step::RestartService { unit } if unit == &svc.name));
306        steps.extend(join_steps);
307        if dashboard_wired && !restarts_main {
308            steps.push(Step::RestartService {
309                unit: svc.name.clone(),
310            });
311        }
312    }
313    steps
314}
315
316/// Heuristic: does this service's `.env` point SMTP at the given relay's
317/// container hostname? Matches any line whose value is `<relay>` or
318/// `<relay>:<port>` — covers the common shape
319/// `SOMETHING_SMTP_HOST=<relay>` and variants like
320/// `FORGEJO__mailer__SMTP_ADDR=<relay>`.
321fn service_uses_smtp_relay(service_name: &str, relay_host: &str) -> bool {
322    let env_path = match service_home(service_name) {
323        Ok(h) => h.join(".env"),
324        Err(_) => return false,
325    };
326    let content = match std::fs::read_to_string(&env_path) {
327        Ok(c) => c,
328        Err(_) => return false,
329    };
330    let with_port = format!("{relay_host}:");
331    content.lines().any(|line| {
332        let Some((_, value)) = line.split_once('=') else {
333            return false;
334        };
335        let v = value.trim();
336        v == relay_host || v.starts_with(&with_port)
337    })
338}
339
340/// Determine which extra podman networks a service should join.
341///
342/// Four providers own a shared network:
343/// - `authelia.network` — services with `--auth` join so they can reach the
344///   OIDC provider by container DNS.
345/// - `inbucket.network` — services with SMTP configured join so they can
346///   reach `inbucket:2500` without requiring caddy to be installed.
347/// - `caddy.network` — URL-having services join for reverse-proxy routing;
348///   auth-enabled services join so OIDC discovery goes through caddy's TLS;
349///   inbucket itself joins so its web UI can be reverse-proxied when a URL
350///   is supplied.
351/// - the metrics store's network — `[metrics]`-declaring services join so
352///   the store can scrape them by container DNS; dashboard providers join
353///   so they can query the store.
354#[allow(clippy::too_many_arguments)]
355fn resolve_extra_networks(
356    service_name: &str,
357    enable_auth: bool,
358    authelia_installed: bool,
359    caddy_installed: bool,
360    inbucket_installed: bool,
361    has_url: bool,
362    has_smtp: bool,
363    metrics_store: Option<&str>,
364    wants_metrics: bool,
365) -> Vec<String> {
366    let mut networks = Vec::new();
367    if enable_auth && authelia_installed && !WellKnownService::Authelia.matches(service_name) {
368        networks.push(WellKnownService::Authelia.to_string());
369    }
370    // SMTP-using services reach inbucket via its own network — no caddy
371    // dependency. This is symmetric with how auth services reach authelia.
372    let joins_inbucket =
373        has_smtp && inbucket_installed && !WellKnownService::Inbucket.matches(service_name);
374    if joins_inbucket {
375        networks.push(WellKnownService::Inbucket.to_string());
376    }
377    let joins_caddy = (has_url || enable_auth || WellKnownService::Inbucket.matches(service_name))
378        && caddy_installed
379        && !WellKnownService::Caddy.matches(service_name);
380    if joins_caddy && !networks.contains(&WellKnownService::Caddy.to_string()) {
381        networks.push(WellKnownService::Caddy.to_string());
382    }
383    // Scraped services and dashboards reach the metrics store the same
384    // way SMTP users reach inbucket — over the store's own network.
385    if let Some(store) = metrics_store
386        && wants_metrics
387        && store != service_name
388    {
389        networks.push(store.to_string());
390    }
391    networks
392}
393
394/// Why the planner is running. The render path is shared between fresh
395/// installs and re-renders (`ryra upgrade`); the side-effect steps are
396/// not — re-registering an OIDC client on upgrade would mint a new
397/// `client_id`/`client_secret` against authelia's existing entry, and
398/// patching every other installed service's quadlet (retroactive network
399/// joins) is install-time work. The mode gates those.
400#[derive(Debug, Clone, Copy, PartialEq, Eq)]
401pub enum PlanMode {
402    /// Fresh install. Validate that the service isn't already on disk,
403    /// register OIDC clients, retroactively patch other services to
404    /// join shared networks, set up Tailscale, and start the unit.
405    Add,
406    /// Re-render an installed service to pick up registry changes.
407    /// Skips the validation rejects and the install-time side effects;
408    /// the upgrade caller handles diff/backup/restart.
409    Upgrade,
410}
411
412/// The user's auth decision for one install.
413///
414/// Replaces the `(Option<AuthKind>, bool)` pair that previously travelled
415/// through the planner, where `enable_auth = false` with a `Some` kind was
416/// representable but meaningless. Frontends resolve "--auth on a service
417/// with no native kinds" before constructing this (error, or a no-op note
418/// for the OIDC provider itself), so a choice always round-trips through
419/// `metadata.toml` as `Option<AuthKind>`.
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub enum AuthChoice {
422    /// No auth integration.
423    None,
424    /// Auth with the service's native OIDC integration: OIDC env vars are
425    /// templated and a client is registered with the provider.
426    Native(registry::service_def::AuthKind),
427}
428
429impl AuthChoice {
430    /// True when the user asked for auth. Drives the auth fabric:
431    /// network joins, the auth bridge, the no-native-OIDC validation.
432    pub fn enabled(&self) -> bool {
433        !matches!(self, AuthChoice::None)
434    }
435
436    /// The native OIDC kind, when the service has one. Drives OIDC env
437    /// templating and client registration.
438    pub fn native_kind(&self) -> Option<&registry::service_def::AuthKind> {
439        match self {
440            AuthChoice::Native(kind) => Some(kind),
441            AuthChoice::None => None,
442        }
443    }
444}
445
446/// Inputs to [`add_service`]. One typed request instead of a positional
447/// argument list, so call sites (CLI today, other frontends later) name
448/// what they're asking for and the retry path can't drift out of sync
449/// with the original call.
450pub struct AddServiceParams<'a> {
451    pub service_name: &'a str,
452    pub exposure: &'a Exposure,
453    pub auth: AuthChoice,
454    pub enable_smtp: bool,
455    pub enable_backup: bool,
456    pub env_overrides: &'a BTreeMap<String, String>,
457    pub enabled_groups: &'a std::collections::BTreeSet<String>,
458    pub registry_name: &'a str,
459    pub repo_dir: &'a Path,
460    /// When provided, its secrets and auth credentials are reused instead
461    /// of generating fresh ones. Pass the context from the interactive
462    /// prompt phase so the values the user saw match what gets written.
463    pub pre_built_ctx: Option<BTreeMap<String, String>>,
464    pub port_in_use: &'a dyn Fn(u16) -> bool,
465    pub acme_mode: Option<&'a caddy::AcmeMode>,
466    pub mode: PlanMode,
467    /// Pin specific port assignments by name (e.g. `{"http": 10005}`)
468    /// instead of running the allocator. Used by upgrade so a re-render
469    /// preserves the install's existing host ports — port_in_use would
470    /// say they're taken (the running service holds them) and the
471    /// allocator would skip to the next free one.
472    pub port_overrides: &'a BTreeMap<String, u16>,
473}
474
475/// Add a service: generate config, return steps to execute.
476pub fn add_service(params: AddServiceParams<'_>) -> Result<AddResult> {
477    let AddServiceParams {
478        service_name,
479        exposure,
480        auth,
481        enable_smtp,
482        enable_backup,
483        env_overrides,
484        enabled_groups,
485        registry_name,
486        repo_dir,
487        pre_built_ctx,
488        port_in_use,
489        acme_mode,
490        mode,
491        port_overrides,
492    } = params;
493    // Derived views of the typed inputs, bound once for the many body
494    // sites that only need one facet. Helpers that need the full picture
495    // (env templating, exposure-variant dispatch) take `&Exposure` /
496    // `&AuthChoice` facets directly.
497    let auth_kind: Option<&registry::service_def::AuthKind> = auth.native_kind();
498    let enable_auth: bool = auth.enabled();
499    let url: Option<&str> = exposure.url();
500    let paths = ConfigPaths::resolve()?;
501    let config = config::load_or_default(&paths.config_file)?;
502
503    // Quadlet directory is the source of truth: a marker'd `.container`
504    // means the service is already installed.
505    //
506    // Upgrade explicitly *re-renders* an installed service — those rejects
507    // would block the legitimate path.
508    if mode == PlanMode::Add {
509        if is_service_installed(service_name) {
510            return Err(Error::ServiceAlreadyInstalled(service_name.to_string()));
511        }
512
513        // No config entry, but preserved volumes or a lingering home dir from
514        // `ryra remove <svc>` (default Preserve mode) would make the fresh .env's
515        // generated secrets disagree with what's already baked into the volume —
516        // postgres writes POSTGRES_PASSWORD into pgdata on first init and then
517        // skips reinit, so a new password in .env just restart-loops on auth
518        // failures. Surface the same way as an incomplete install; the CLI's
519        // existing purge-and-retry recovery handles it.
520        if data::enumerate_service(service_name)?.is_some() {
521            return Err(Error::ServiceIncomplete(service_name.to_string()));
522        }
523    }
524
525    let reg_service = registry::find_service(repo_dir, service_name)?;
526
527    // Validate: architecture compatibility
528    if let Some(msg) = reg_service.def.check_architecture() {
529        return Err(Error::UnsupportedArchitecture(msg));
530    }
531
532    // Validate: all required services must be installed
533    let missing_requires: Vec<&str> = reg_service
534        .def
535        .requires
536        .iter()
537        .filter(|r| !is_service_installed(&r.service))
538        .map(|r| r.service.as_str())
539        .collect();
540    if !missing_requires.is_empty() {
541        return Err(Error::MissingRequiredServices {
542            service: service_name.to_string(),
543            missing: missing_requires.iter().map(|s| s.to_string()).collect(),
544        });
545    }
546
547    // If the user chose to enable auth, an auth provider must be configured
548    if auth_kind.is_some() && config.auth.is_none() {
549        return Err(Error::AuthNotConfigured);
550    }
551
552    // --auth requires native OIDC support; forward auth is no longer supported.
553    // The exception is the OIDC provider itself, which doesn't need to act as
554    // a client of itself.
555    if enable_auth
556        && reg_service.def.integrations.auth.is_empty()
557        && !capability::def_provides(&reg_service.def, Capability::OidcProvider)
558    {
559        return Err(Error::NoOidcSupport(service_name.to_string()));
560    }
561
562    // --backup requires the service author to have certified backup
563    // safety. Refusing here means a user typo can't silently produce
564    // an install whose backups would never restore cleanly.
565    if enable_backup && !reg_service.def.integrations.backup {
566        return Err(Error::BackupNotSupported(service_name.to_string()));
567    }
568
569    // Every `--enable <group>` must match a group defined on this service.
570    // Surfacing unknown group names here (vs. silently ignoring them) means
571    // a typo fails fast instead of producing a half-configured service.
572    for g in enabled_groups {
573        if !reg_service.def.env_groups.iter().any(|eg| &eg.name == g) {
574            let known: Vec<String> = reg_service
575                .def
576                .env_groups
577                .iter()
578                .map(|eg| eg.name.clone())
579                .collect();
580            let hint = if known.is_empty() {
581                " (service defines no env_groups)".to_string()
582            } else {
583                format!(" (known: {})", known.join(", "))
584            };
585            return Err(Error::UnknownEnvGroup {
586                service: service_name.to_string(),
587                group: g.clone(),
588                hint,
589            });
590        }
591    }
592
593    // Resolve a host port for every entry in [[ports]]. Each port gets its
594    // own distinct host port — a prior bug allocated one port and gave it
595    // to every entry, so services with multiple [[ports]] (ente-web:
596    // 3000/3002/3003, inbucket: http+smtp) hit `bind: address already in
597    // use` on all but the first.
598    let mut port_warnings: Vec<Warning> = Vec::new();
599    let mut claimed: std::collections::HashSet<u16> = reg_service
600        .def
601        .ports
602        .iter()
603        .filter_map(|p| p.host_port)
604        .collect();
605    let mut resolved_ports: Vec<(String, u16)> = Vec::with_capacity(reg_service.def.ports.len());
606    for p in &reg_service.def.ports {
607        let host = if let Some(pinned) = port_overrides.get(&p.name) {
608            // Upgrade passes the install's existing port here so re-renders
609            // are stable. Trust the caller — port_in_use would say it's
610            // taken (the running service holds it) and the allocator would
611            // pick a different one.
612            *pinned
613        } else if let Some(hp) = p.host_port {
614            hp
615        } else {
616            let privileged = p.container_port < 1024;
617            let claimed_in_service = claimed.contains(&p.container_port);
618            let in_use = port_in_use(p.container_port);
619            if privileged || claimed_in_service || in_use {
620                let allocated = system::port::allocate_port_excluding(&claimed, port_in_use)?;
621                let reason = if privileged {
622                    "port is privileged (requires root)".to_string()
623                } else if claimed_in_service {
624                    format!(
625                        "port {} is already claimed by another port in this service",
626                        p.container_port
627                    )
628                } else {
629                    format!("port {} is already in use", p.container_port)
630                };
631                port_warnings.push(Warning::PortReassigned {
632                    service_name: service_name.to_string(),
633                    port_name: p.name.clone(),
634                    original_port: p.container_port,
635                    assigned_port: allocated,
636                    reason,
637                });
638                allocated
639            } else {
640                p.container_port
641            }
642        };
643        claimed.insert(host);
644        resolved_ports.push((p.name.clone(), host));
645    }
646
647    // Caddy on rootless podman can't bind <1024 by default — service.toml
648    // therefore declares 8080/8443 as the host ports. When the kernel has
649    // been retuned (`sysctl net.ipv4.ip_unprivileged_port_start=80`), we
650    // can listen on 80/443 directly: cleaner URLs, no router NAT
651    // translation needed. Override here so the quadlet's `PublishPort=`
652    // and the stored config record both reflect the real listen port.
653    if WellKnownService::Caddy.matches(service_name)
654        && system::sysctl::rootless_can_bind_low_ports()
655    {
656        for (name, port) in resolved_ports.iter_mut() {
657            match name.as_str() {
658                "http" if *port == 8080 => *port = 80,
659                "https" if *port == 8443 => *port = 443,
660                _ => {}
661            }
662        }
663    }
664
665    // Primary host port drives service.url / service.port templating.
666    // Prefer "http", fall back to the first defined port.
667    let host_port = resolved_ports
668        .iter()
669        .find(|(name, _)| name.eq_ignore_ascii_case("http"))
670        .or_else(|| resolved_ports.first())
671        .map(|(_, p)| *p);
672
673    // Check for port conflicts by probing whether the port is already bound.
674    // For explicitly-set host_port entries, allocate_port_excluding didn't run
675    // so we re-check here.
676    for (_, port) in &resolved_ports {
677        if port_in_use(*port) {
678            return Err(Error::PortConflict { port: *port });
679        }
680    }
681
682    let home_dir = service_home(service_name)?;
683    let quadlet_path = quadlet_dir()?;
684
685    // Authoritative: capability presence in `list_installed` answers
686    // "is there a reverse-proxy / OIDC IdP / SMTP relay / metrics
687    // scraper installed?" without naming any specific provider.
688    let installed_now = list_installed().unwrap_or_default();
689    let authelia_installed =
690        find_installed_provider(&installed_now, Capability::OidcProvider).is_some();
691    let caddy_installed =
692        find_installed_provider(&installed_now, Capability::ReverseProxy).is_some();
693    let inbucket_installed =
694        find_installed_provider(&installed_now, Capability::SmtpRelay).is_some();
695    let metrics_store =
696        find_installed_provider(&installed_now, Capability::MetricsStore).map(|s| s.name.clone());
697
698    // Build auth-bridge artifacts (CA trust + dynamic /etc/hosts for the
699    // auth provider's domain). Pure — all filesystem writes are
700    // emitted as Step::WriteFile below, not performed here.
701    let auth_bridge = auth_bridge::build(&auth_bridge::AuthBridgeParams {
702        service_name,
703        service_provides: &reg_service.def.capabilities.provides,
704        enable_auth,
705        config: &config,
706        installed: &installed_now,
707        service_data: &home_dir,
708    })?;
709
710    let (extra_volumes, extra_env, extra_exec_start_pre, auth_bridge_steps) = match auth_bridge {
711        Some(b) => (b.volumes, b.env, b.exec_start_pre, b.steps),
712        None => (Vec::new(), BTreeMap::new(), Vec::new(), Vec::new()),
713    };
714
715    let has_smtp = enable_smtp
716        && reg_service.def.integrations.smtp
717        && !reg_service.def.mappings.smtp.is_empty()
718        && config.smtp.is_some();
719    // Host-network metrics services (node-exporter) can't join a bridge
720    // network — their scrape target goes via the host gateway instead.
721    let wants_metrics = reg_service
722        .def
723        .metrics
724        .as_ref()
725        .is_some_and(|m| !m.host_network)
726        || capability::def_provides(&reg_service.def, Capability::MetricsDashboard);
727    let extra_networks = resolve_extra_networks(
728        service_name,
729        enable_auth,
730        authelia_installed,
731        caddy_installed,
732        inbucket_installed,
733        url.is_some(),
734        has_smtp,
735        metrics_store.as_deref(),
736        wants_metrics,
737    );
738
739    let output = generate::generate_env(generate::GenerateEnvParams {
740        config: &config,
741        service_def: &reg_service.def,
742        auth_kind,
743        host_port,
744        resolved_ports: &resolved_ports,
745        env_overrides,
746        exposure,
747        extra_env,
748        pre_built_ctx,
749        enable_smtp: has_smtp,
750        enabled_groups,
751    })?;
752
753    let podman_args: Vec<String> = Vec::new();
754
755    // Declared port names, for validating ${SERVICE_PORT_*} quadlet refs.
756    let port_names: Vec<String> = resolved_ports.iter().map(|(n, _)| n.clone()).collect();
757
758    // Build install metadata persisted to `metadata.toml` in service_home.
759    // This is what makes service state authoritative for "what's installed
760    // and how is it wired" — every field a future `ryra list` needs to
761    // reconstruct the install reads back from this file.
762    // `smtp_enabled` captures *user intent* (the `enable_smtp` flag the
763    // caller passed) rather than the gated render flag (`has_smtp`).
764    // Otherwise an install on a host without globally-configured SMTP
765    // records `false`, and a later `ryra configure` that doesn't touch
766    // SMTP would still show as "modified" because the legacy default
767    // (`true`) disagrees with what gets serialized. Storing intent keeps
768    // metadata stable across re-renders and lets `ryra configure --smtp`
769    // remember the choice even before global SMTP is configured.
770    let install_metadata = Metadata {
771        registry: registry_name.to_string(),
772        url: url.map(str::to_string),
773        auth: auth_kind.cloned(),
774        provides: reg_service.def.capabilities.provides.clone(),
775        backup_enabled: enable_backup,
776        smtp_enabled: enable_smtp,
777        enabled_groups: enabled_groups.iter().cloned().collect(),
778        runtime: reg_service.def.service.runtime.clone(),
779    };
780
781    // Native services have no quadlet bundle / image: build the binary, install
782    // it, write a plain systemd --user unit, and start it. Returns here so the
783    // entire podman path below stays untouched. Reuses everything already
784    // computed: home_dir, the generated .env (`output`), ports, and metadata.
785    if reg_service.def.service.runtime == registry::service_def::Runtime::Native {
786        let tracked_envs = collect_static_envs(&reg_service.def, &output.ctx, enabled_groups)?;
787        let allocated_ports = resolved_ports.clone();
788        let generated_secrets = collect_generated_secrets(&reg_service.def, env_overrides);
789        return build_native_add(NativeAddParams {
790            service_name,
791            reg_service: &reg_service,
792            home_dir: &home_dir,
793            output,
794            install_metadata: &install_metadata,
795            registry_name,
796            url,
797            tracked_envs,
798            allocated_ports,
799            generated_secrets,
800        });
801    }
802
803    // Process quadlet bundle from registry
804    let bundle =
805        generate::bundle::process_quadlet_bundle(&generate::bundle::ProcessBundleParams {
806            service_dir: &reg_service.service_dir,
807            service_name,
808            extra_networks: &extra_networks,
809            extra_volumes: &extra_volumes,
810            podman_args: &podman_args,
811            extra_exec_start_pre: &extra_exec_start_pre,
812            port_names: &port_names,
813        })?;
814
815    // Generate warnings
816    let mut warnings = Vec::new();
817
818    if let Some(ref reqs) = reg_service.def.requirements
819        && let Some(total) = system::memory::total_ram_mb()
820    {
821        if total < reqs.ram.min {
822            warnings.push(Warning::RamBelowMinimum {
823                service_name: service_name.to_string(),
824                min_mb: reqs.ram.min,
825                available_mb: total,
826            });
827        } else if let Some(rec) = reqs.ram.recommended
828            && total < rec
829        {
830            warnings.push(Warning::RamBelowRecommended {
831                service_name: service_name.to_string(),
832                recommended_mb: rec,
833                available_mb: total,
834            });
835        }
836    }
837    warnings.extend(port_warnings);
838
839    // Build ordered steps
840    let mut steps = Vec::new();
841
842    // 1. Create service data directory
843    steps.push(Step::CreateDir(home_dir.clone()));
844
845    // Capture env content before it is moved into steps
846    let env_content = output.env_file.content.clone();
847
848    // 2. Pull all images (from quadlet bundle)
849    for image in &bundle.images {
850        steps.push(Step::PullImage {
851            image: image.clone(),
852        });
853    }
854
855    // 3. Write quadlet files from bundle (real files live in service_home)
856    //    and symlink each one into the systemd-mandated quadlet path so
857    //    quadlet's generator finds them on daemon-reload.
858    for file in bundle.quadlet_files {
859        let link = file
860            .path
861            .file_name()
862            .map(|n| quadlet_path.join(n))
863            .ok_or_else(|| {
864                Error::Bundle(format!("invalid quadlet path: {}", file.path.display()))
865            })?;
866        let target = file.path.clone();
867        steps.push(Step::WriteFile(file));
868        steps.push(Step::Symlink { link, target });
869    }
870
871    // 3b. Write metadata.toml — install record (registry, exposure, url,
872    // auth) used by `ryra list` / `remove` / `status` to reconstruct the
873    // install. Emitted *before* any step that can fail remotely (Tailscale
874    // API calls, image pulls) so a partial install can still be torn down
875    // by `remove_service` — which relies on metadata.toml to identify the
876    // install. World-readable mode (atomic_write picks 0o644 by name).
877    let metadata_content = toml::to_string_pretty(&install_metadata)?;
878    steps.push(Step::WriteFile(GeneratedFile {
879        path: metadata_path(service_name)?,
880        content: metadata_content,
881    }));
882
883    // 3c. Tailscale Services — when `--tailscale` was used, the host's
884    // existing tailscaled advertises the service at
885    // `https://<name>.<tailnet>.ts.net` (TailVIP-routed). One-time
886    // `TailscaleSetup` ensures ACL tags + auto-approval are in place;
887    // `TailscaleEnable` defines the service via admin API and runs
888    // `tailscale serve --service=...` from the host. No sidecar
889    // containers, no per-service tailscaled.
890    if mode == PlanMode::Add && exposure.is_tailscale() {
891        // Scope the Tailscale Service name by host (`<service>-<host>`)
892        // — Tailscale Services are global per tailnet, so without the
893        // suffix two ryra machines that both `ryra add vikunja --tailscale`
894        // would silently stomp each other's registration. The svc_name
895        // falls out of the exposure URL (built by
896        // `system::tailscale::derive_service_url` with the host suffix)
897        // — keeping URL as the single source of
898        // truth means `metadata.toml` round-trips and remove paths
899        // recover the same name without re-shelling tailscale.
900        let svc_name = exposure.tailscale_svc_name().ok_or_else(|| {
901            Error::InvalidServiceRef(format!(
902                "tailscale exposure for '{service_name}' has a malformed URL — \
903                 expected `https://<service>-<host>.<tailnet>.ts.net/`"
904            ))
905        })?;
906        // A multi-port service (e.g. ente: web UI + API) serves each
907        // tailscale_https port; single-port services serve their primary
908        // port at the web root. Empty only when there are no ports at all.
909        let ts_ports = plan::tailscale_ports(&reg_service.def.ports, &resolved_ports, host_port);
910        if !ts_ports.is_empty() {
911            steps.push(Step::TailscaleSetup);
912            steps.push(Step::TailscaleEnable {
913                svc_name,
914                ports: ts_ports,
915            });
916        }
917    }
918
919    // 4. Write config files from bundle
920    for file in bundle.config_files {
921        steps.push(Step::WriteFile(file));
922    }
923
924    // 4b. Copy vendored files (plugin DLLs, archives etc.) from the
925    // registry into service_home. The config pipeline is UTF-8 /
926    // template-only; binary payloads flow through CopyFile instead.
927    for (src, dst) in bundle.files {
928        steps.push(Step::CopyFile { src, dst });
929    }
930
931    // 5. Write .env file
932    steps.push(Step::WriteFile(output.env_file));
933
934    // 6. Create bind mount directories (must exist before container starts)
935    for dir in &bundle.bind_mount_dirs {
936        steps.push(Step::CreateDir(dir.clone()));
937    }
938
939    // 7. Auth-bridge artifacts (CA bundle, refresh script, host-resolve script,
940    // placeholder /etc/hosts) — needed before container starts for TLS trust
941    // and auth-domain resolution.
942    steps.extend(auth_bridge_steps);
943
944    // 8. Register OIDC client with the auth provider BEFORE starting the service.
945    // This must happen first because the service's ExecStartPost (e.g., register-oidc.sh)
946    // needs the auth provider configured and caddy's network alias in place so OIDC
947    // discovery URLs resolve correctly from within the service container.
948    //
949    // Skipped on upgrade: build_context generates a fresh client_id/secret
950    // every call, so re-registering would append a second entry to authelia's
951    // configuration.yml and break the existing OIDC integration.
952    if mode == PlanMode::Add
953        && let (
954            Some(registry::service_def::AuthKind::Oidc),
955            Some(config::schema::AuthCredentials::Authelia { .. }),
956        ) = (auth_kind, config.auth.as_ref())
957    {
958        steps.extend(authelia::register_oidc_client(
959            service_name,
960            &reg_service.def,
961            url,
962            &output.ctx,
963            &config,
964            &quadlet_path,
965        )?);
966    }
967
968    // 9. Add Caddy route for services with a URL when Caddy is installed.
969    // This creates a reverse proxy from the service's domain to its container port.
970    //
971    // Tailscale exposures skip this: the service is already reachable on
972    // its host port via the tailnet, and MagicDNS handles the hostname.
973    if let Some(url) = url
974        && !WellKnownService::Caddy.matches(service_name)
975        && !exposure.is_tailscale()
976    {
977        if caddy_installed {
978            let parsed = url::Url::parse(url)
979                .map_err(|e| Error::Template(format!("invalid service URL '{url}': {e}")))?;
980            let domain = parsed.host_str().ok_or_else(|| {
981                Error::Template(format!(
982                    "service URL '{url}' has no host — Caddy needs a hostname to route to"
983                ))
984            })?;
985            let container_port = reg_service
986                .def
987                .ports
988                .first()
989                .map(|p| p.container_port)
990                .unwrap_or(80);
991            let primary_quadlet = reg_service
992                .service_dir
993                .join("quadlets")
994                .join(format!("{service_name}.container"));
995            let target_host = caddy::primary_container_name(&primary_quadlet, service_name);
996            let block = caddy::render_site_block(&caddy::CaddySiteParams {
997                service_name: service_name.to_string(),
998                target_host,
999                domain: domain.to_string(),
1000                container_port,
1001                https_port: caddy_https_port(&config),
1002            });
1003            let caddyfile_path = caddy::caddyfile_path()?;
1004            let existing =
1005                std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1006                    path: caddyfile_path.clone(),
1007                    source,
1008                })?;
1009            let updated = caddy::add_route(&existing, service_name, &block);
1010            steps.push(Step::WriteFile(GeneratedFile {
1011                path: caddyfile_path,
1012                content: updated,
1013            }));
1014            steps.push(Step::ReloadCaddy);
1015        } else if let Some(primary) = host_port {
1016            // --url was passed but no ryra-managed reverse proxy is installed.
1017            // Templating and OIDC still work, but the user is responsible for
1018            // routing <url> → 127.0.0.1:<primary> via nginx / Cloudflare Tunnel
1019            // / Tailscale Funnel / etc.
1020            warnings.push(Warning::UrlWithoutReverseProxy {
1021                service_name: service_name.to_string(),
1022                url: url.to_string(),
1023                host_port: primary,
1024            });
1025        }
1026    }
1027
1028    // 9b. When a shared-network provider (caddy, inbucket) is being
1029    // installed, retroactively patch services that were installed before
1030    // it so they can reach the new provider by container DNS.
1031    // resolve_extra_networks only decides at install time; without this
1032    // step, services installed earlier remain isolated.
1033    //
1034    // Skipped on upgrade: re-running on the same shared-network provider
1035    // would re-patch services unnecessarily, and a re-render of a regular
1036    // service shouldn't touch its peers' quadlets.
1037    if mode == PlanMode::Add {
1038        steps.extend(retroactive_network_joins(
1039            service_name,
1040            &quadlet_path,
1041            Some(repo_dir),
1042        ));
1043    }
1044
1045    // 9c. Metrics wiring. Consumer side at install time: a service that
1046    // declares [metrics] gets a file_sd scrape target dropped into the
1047    // installed store (live — file_sd needs no reload); a dashboard
1048    // provider gets a datasource provisioned before its first start.
1049    // Provider side retroactively: a store being installed wires up
1050    // everything already on the machine.
1051    if mode == PlanMode::Add {
1052        if let Some(store) = &metrics_store {
1053            let metrics_host_port = reg_service.def.metrics.as_ref().and_then(|m| {
1054                resolved_ports
1055                    .iter()
1056                    .find(|(n, _)| n == &m.port)
1057                    .map(|(_, p)| *p)
1058            });
1059            if let Some(step) =
1060                metrics_bridge::scrape_target_step(store, &reg_service.def, metrics_host_port)?
1061            {
1062                steps.push(step);
1063            }
1064            if capability::def_provides(&reg_service.def, Capability::MetricsDashboard)
1065                && let Some(port) = store_container_port(store)
1066            {
1067                steps.push(metrics_bridge::datasource_step(service_name, store, port)?);
1068            }
1069        }
1070        if capability::def_provides(&reg_service.def, Capability::MetricsStore) {
1071            steps.extend(retroactive_metrics_wiring(
1072                service_name,
1073                &reg_service.def,
1074                &quadlet_path,
1075            ));
1076        }
1077    }
1078
1079    // 9d. Caddy: seed the user-owned `tls.caddy` snippet on first install.
1080    // Site blocks emit `import services_tls`; this file defines that snippet.
1081    // After first write ryra never touches it — users edit it directly
1082    // for Cloudflare DNS-01, wildcards, BYO certs, plain HTTP for Tunnel,
1083    // anything Caddy supports. seed-caddyfile.sh defensively recreates
1084    // the file as `tls internal` on container start if it goes missing.
1085    if WellKnownService::Caddy.matches(service_name) {
1086        let snippet_path = caddy::tls_snippet_path()?;
1087        if !snippet_path.exists() {
1088            let mode = acme_mode.cloned().unwrap_or(caddy::AcmeMode::Internal);
1089            steps.push(Step::WriteFile(GeneratedFile {
1090                path: snippet_path,
1091                content: mode.snippet(),
1092            }));
1093        }
1094    }
1095
1096    // 9z. Manifest — sha256 list of every file we just emitted, written to
1097    // `~/.local/share/services/<svc>/service.manifest` so `ryra diff` and
1098    // `ryra upgrade` can detect drift between the registry and what's
1099    // actually on disk. `.env` is excluded because it carries generated
1100    // secrets that legitimately rotate at runtime; the manifest itself is
1101    // excluded to avoid the chicken-and-egg of hashing itself. CopyFile
1102    // sources (binary plugin payloads) are not yet covered — drift on
1103    // those is rare and adds I/O at plan time. Revisit if it bites.
1104    let manifest_path_for_svc = manifest::manifest_path(service_name)?;
1105    let env_filename = std::ffi::OsStr::new(".env");
1106    let mut manifest_entries: Vec<manifest::ManifestEntry> = Vec::new();
1107    for step in &steps {
1108        if let Step::WriteFile(file) = step {
1109            if file.path == manifest_path_for_svc {
1110                continue;
1111            }
1112            if file.path.file_name() == Some(env_filename) {
1113                continue;
1114            }
1115            manifest_entries.push(manifest::ManifestEntry {
1116                path: file.path.clone(),
1117                sha256: manifest::hash_bytes(file.content.as_bytes()),
1118            });
1119        }
1120    }
1121    // Static env vars — every registry-defined env whose template carries
1122    // no `{{secret.*}}` or `{{auth.*}}` reference. Tracked so `ryra
1123    // upgrade` can append registry-added env vars to the user's existing
1124    // `.env` without re-rendering it (which would clobber rotated
1125    // secrets). Append-only by design. The richer `tracked_envs` is what
1126    // upgrade uses to decide whether to prompt the user; the on-disk
1127    // manifest only records key+value (the `# env: KEY=VAL` lines).
1128    let tracked_envs = collect_static_envs(&reg_service.def, &output.ctx, enabled_groups)?;
1129    let manifest_envs: Vec<manifest::EnvEntry> = tracked_envs
1130        .iter()
1131        .map(|t| manifest::EnvEntry {
1132            key: t.key.clone(),
1133            value: t.value.clone(),
1134        })
1135        .collect();
1136    steps.push(Step::WriteFile(GeneratedFile {
1137        path: manifest_path_for_svc,
1138        content: manifest::format(&manifest_entries, &manifest_envs),
1139    }));
1140
1141    // 10. Reload and start via systemd
1142    steps.push(Step::DaemonReload);
1143    // Start — dependencies start automatically via Requires=/After= in the quadlet
1144    steps.push(Step::StartService {
1145        unit: service_name.to_string(),
1146    });
1147
1148    // Collect post-install info
1149    let allocated_ports: Vec<(String, u16)> = resolved_ports.clone();
1150
1151    // Secret names from env var templates (not stored in state)
1152    let mut generated_secrets: Vec<String> = reg_service
1153        .def
1154        .env
1155        .iter()
1156        .filter(|e| !env_overrides.contains_key(&e.name))
1157        .flat_map(|e| generate::extract_secret_refs(&e.value))
1158        .collect();
1159    // Deduplicate — the same secret may be referenced by multiple env vars
1160    generated_secrets.sort();
1161    generated_secrets.dedup();
1162
1163    Ok(AddResult {
1164        steps,
1165        warnings,
1166        repo_url: registry_name.to_string(),
1167        allocated_ports,
1168        generated_secrets,
1169        env_content,
1170        url: url.map(|u| u.to_string()),
1171        tracked_envs,
1172    })
1173}
1174
1175/// Secret names referenced by a service's env templates (for the install
1176/// summary; values live in `.env`, not state). Shared by add paths.
1177fn collect_generated_secrets(
1178    def: &registry::service_def::ServiceDef,
1179    env_overrides: &BTreeMap<String, String>,
1180) -> Vec<String> {
1181    let mut out: Vec<String> = def
1182        .env
1183        .iter()
1184        .filter(|e| !env_overrides.contains_key(&e.name))
1185        .flat_map(|e| generate::extract_secret_refs(&e.value))
1186        .collect();
1187    out.sort();
1188    out.dedup();
1189    out
1190}
1191
1192/// Inputs for [`build_native_add`] — grouped to keep the signature sane.
1193struct NativeAddParams<'a> {
1194    service_name: &'a str,
1195    reg_service: &'a registry::RegistryService,
1196    home_dir: &'a Path,
1197    output: generate::EnvOutput,
1198    install_metadata: &'a Metadata,
1199    registry_name: &'a str,
1200    url: Option<&'a str>,
1201    tracked_envs: Vec<TrackedEnv>,
1202    allocated_ports: Vec<(String, u16)>,
1203    generated_secrets: Vec<String>,
1204}
1205
1206/// Plan a `runtime = "native"` install: build the binary (unless prebuilt),
1207/// install it, write the service `.env`, render a plain `systemd --user` unit
1208/// and link it, then start. No image, no quadlet — but the same `.env`
1209/// contract (`SERVICE_PORT_HTTP`, etc.) and the same `service_home` the rest of
1210/// ryra (Caddy routing, whole-folder backups) already understands.
1211fn build_native_add(p: NativeAddParams<'_>) -> Result<AddResult> {
1212    let NativeAddParams {
1213        service_name,
1214        reg_service,
1215        home_dir,
1216        output,
1217        install_metadata,
1218        registry_name,
1219        url,
1220        tracked_envs,
1221        allocated_ports,
1222        generated_secrets,
1223    } = p;
1224
1225    let run = reg_service.def.service.run.as_ref().ok_or_else(|| {
1226        Error::Bundle(format!(
1227            "native service '{service_name}' is missing its `run` command"
1228        ))
1229    })?;
1230    let build = reg_service.def.service.build.as_ref();
1231
1232    let env_content = output.env_file.content.clone();
1233    let source_dir = reg_service.service_dir.clone();
1234    let mut steps = Vec::new();
1235
1236    // The service home holds STATE only: data/, .env, the unit, the install
1237    // record. The service itself runs from its source dir (no binary copy), so
1238    // a plain `target/release/app`, `bun run src/index.ts`, or `cargo watch`
1239    // all work the same and a rebuild lands where the unit already looks.
1240    steps.push(Step::CreateDir(home_dir.to_path_buf()));
1241    steps.push(Step::CreateDir(home_dir.join("data")));
1242
1243    // Optional build/prepare step (cargo build, bun install) in the source dir.
1244    if let Some(command) = build {
1245        steps.push(Step::Build {
1246            dir: source_dir.clone(),
1247            command: command.clone(),
1248        });
1249    }
1250
1251    // Install record + the generated .env (carries SERVICE_PORT_HTTP).
1252    steps.push(Step::WriteFile(GeneratedFile {
1253        path: metadata_path(service_name)?,
1254        content: toml::to_string_pretty(install_metadata)?,
1255    }));
1256    steps.push(Step::WriteFile(output.env_file));
1257
1258    // The unit: real file in the service home, symlinked into the systemd
1259    // --user dir so the unit is found on daemon-reload (mirrors quadlets).
1260    let unit_name = format!("{service_name}.service");
1261    let unit_path = home_dir.join(&unit_name);
1262    steps.push(Step::WriteFile(GeneratedFile {
1263        path: unit_path.clone(),
1264        content: native_unit(
1265            home_dir,
1266            &source_dir,
1267            run,
1268            &reg_service.def.service.description,
1269        ),
1270    }));
1271    steps.push(Step::Symlink {
1272        link: systemd_user_dir()?.join(&unit_name),
1273        target: unit_path,
1274    });
1275
1276    steps.push(Step::DaemonReload);
1277    steps.push(Step::StartService {
1278        unit: service_name.to_string(),
1279    });
1280
1281    Ok(AddResult {
1282        steps,
1283        warnings: Vec::new(),
1284        repo_url: registry_name.to_string(),
1285        allocated_ports,
1286        generated_secrets,
1287        env_content,
1288        url: url.map(|u| u.to_string()),
1289        tracked_envs,
1290    })
1291}
1292
1293/// Render a plain `systemd --user` unit for a native service. `EnvironmentFile`
1294/// supplies the service `.env` (so `SERVICE_PORT_HTTP` and friends are present);
1295/// `SERVICE_HOME` points the process at its data dir, matching the contract a
1296/// container service gets via the quadlet.
1297fn native_unit(home_dir: &Path, source_dir: &Path, run: &str, description: &str) -> String {
1298    let home = home_dir.display();
1299    let source = source_dir.display();
1300    // ExecStart via `sh -c 'exec <run>'` so a binary path (`target/release/app`),
1301    // an interpreter command (`bun run src/index.ts`), and a watcher
1302    // (`cargo watch -x run`) all work the same. `exec` replaces the shell so
1303    // systemd tracks the real PID and stop/restart reach the process.
1304    //
1305    // A user-level unit's PATH is minimal, so toolchains installed under $HOME
1306    // (bun, cargo, deno, go, pipx) wouldn't be found. Prepend the common ones
1307    // (%h = the user's home) so `run = "bun ..."` / `"cargo ..."` just work.
1308    format!(
1309        "[Unit]\n\
1310         Description={description}\n\
1311         After=network.target\n\
1312         \n\
1313         [Service]\n\
1314         Type=simple\n\
1315         WorkingDirectory={source}\n\
1316         EnvironmentFile={home}/.env\n\
1317         Environment=SERVICE_HOME={home}\n\
1318         Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
1319         ExecStart=/bin/sh -c 'exec {run}'\n\
1320         Restart=always\n\
1321         RestartSec=5\n\
1322         \n\
1323         [Install]\n\
1324         WantedBy=default.target\n",
1325    )
1326}
1327
1328/// Check if a quadlet filename belongs to a service.
1329///
1330/// Matches `{service_name}.container`, `{service_name}-db.volume`, etc.
1331/// but NOT `{service_name_prefix}-other.container` (e.g., "foo" must not
1332/// match "foo-bar.container" when "foo-bar" is a known service).
1333///
1334/// `all_service_names` contains every installed service name — used to detect
1335/// when a longer service name owns the file instead.
1336pub fn quadlet_belongs_to(filename: &str, service_name: &str, all_service_names: &[&str]) -> bool {
1337    if !filename.starts_with(service_name) {
1338        return false;
1339    }
1340    let rest = &filename[service_name.len()..];
1341    if rest.starts_with('.') {
1342        return true;
1343    }
1344    if !rest.starts_with('-') {
1345        return false;
1346    }
1347    // Check that no other installed service is a longer prefix match.
1348    // e.g., "foo-bar.container" with service "foo" — if "foo-bar"
1349    // is also installed, it owns this file.
1350    !all_service_names.iter().any(|&other| {
1351        other.len() > service_name.len()
1352            && other.starts_with(service_name)
1353            && filename.starts_with(other)
1354            && filename[other.len()..].starts_with(['.', '-'])
1355    })
1356}
1357
1358/// How destructive `remove_service` should be.
1359#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
1360#[serde(rename_all = "snake_case")]
1361pub enum RemoveMode {
1362    #[default]
1363    /// Stop + remove quadlets + delete ephemeral config files, but keep
1364    /// the data subdirs under the service home dir and keep all podman
1365    /// named volumes. After this, `ryra data ls` reports the service as
1366    /// `Orphan`.
1367    Preserve,
1368    /// Stop + remove everything: quadlets, entire home dir, named volumes.
1369    Purge,
1370}
1371
1372/// Remove a service: update state, return cleanup steps.
1373pub fn remove_service(service_name: &str, mode: RemoveMode) -> Result<RemoveResult> {
1374    // Reconstruct the InstalledService view from the quadlet's
1375    // `# Service-*` headers — that's the source of truth now.
1376    let installed_owned = build_installed_from_metadata(service_name)
1377        .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1378    let installed = &installed_owned;
1379
1380    // Native services have no quadlets / podman objects: tear down the
1381    // systemd --user unit and (on purge) the home dir. Runtime comes from the
1382    // install record, so this works without the registry. Mirrors the native
1383    // add path's early return.
1384    if let Ok(Some(meta)) = metadata::load_metadata(service_name)
1385        && meta.runtime == registry::service_def::Runtime::Native
1386    {
1387        let url = installed.exposure.url().map(|s| s.to_string());
1388        return remove_native_service(service_name, mode, url);
1389    }
1390
1391    // Stop all units belonging to this service (main + sidecars).
1392    // Quadlet files named {service_name}.ext or {service_name}-sidecar.ext.
1393    let quadlet_path = quadlet_dir()?;
1394    let mut steps = Vec::new();
1395    let mut volume_names = Vec::new();
1396    let mut networks: Vec<String> = Vec::new();
1397    let mut has_named_volumes = false;
1398    // Quadlet directory scan is authoritative — captures every
1399    // ryra-managed service so the "is foo-bar a sibling service?"
1400    // prefix check (used to scope file removal) sees every install.
1401    let name_pool = scan_managed_services().unwrap_or_default();
1402    let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1403
1404    // Disable the Tailscale Service before tearing the host port down.
1405    // Always emit when the service was tailscale-enabled — the API
1406    // delete is idempotent and `tailscale serve --service=svc:X off`
1407    // is fine to run on a service that's already cleared.
1408    //
1409    // svc_name comes from the stored exposure URL (the `<service>-<host>`
1410    // first label) — pulling it from the URL captured at install time
1411    // means a hostname change post-install doesn't break teardown. If
1412    // the URL is malformed, skip the step rather than blocking the
1413    // whole removal — a stale tailnet entry is a smaller harm than a
1414    // service that won't uninstall.
1415    if let Some(svc_name) = installed.exposure.tailscale_svc_name() {
1416        steps.push(Step::TailscaleDisable { svc_name });
1417    }
1418
1419    if quadlet_path.is_dir()
1420        && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1421    {
1422        for entry in entries.flatten() {
1423            let file_name = entry.file_name();
1424            let name = file_name.to_string_lossy();
1425            // Catches both the service's own quadlets (foo.container,
1426            // foo-db.container, …) and its `ts-foo*` tailscale sidecar.
1427            if !quadlet_belongs_to(&name, service_name, &all_names) {
1428                continue;
1429            }
1430            // Stop each .container unit before removing files
1431            if name.ends_with(".container") {
1432                let unit = name.trim_end_matches(".container").to_string();
1433                steps.push(Step::StopService { unit });
1434            }
1435            if name.ends_with(".network") {
1436                // Stop the generated `<net>-network` oneshot, and remember the
1437                // network so it can be dropped once every container is down.
1438                let net = name.trim_end_matches(".network").to_string();
1439                steps.push(Step::StopService {
1440                    unit: format!("{net}-network"),
1441                });
1442                networks.push(net);
1443            }
1444            if name.ends_with(".volume") {
1445                has_named_volumes = true;
1446                if matches!(mode, RemoveMode::Purge) {
1447                    let vol = name.trim_end_matches(".volume").to_string();
1448                    // Quadlet prefixes volume names with "systemd-"
1449                    volume_names.push(format!("systemd-{vol}"));
1450                }
1451            }
1452            steps.push(Step::RemoveFile(entry.path()));
1453        }
1454    }
1455
1456    // Clean up ryra-managed Caddy site block + OIDC client registration
1457    // BEFORE the daemon reload, so the routing layers drop their stale
1458    // pointers while the doomed containers are already stopped.
1459    // Caddy-routed exposures (Internal / Public) had a `# Service-Source: registry/<svc>`
1460    // block written into the Caddyfile on add; remove it now. Loopback
1461    // and Tailscale never had one (no Caddy involvement), so skip.
1462    let had_caddy_route = matches!(
1463        installed.exposure,
1464        Exposure::Internal { .. } | Exposure::Public { .. }
1465    );
1466    if !WellKnownService::Caddy.matches(service_name) && had_caddy_route {
1467        let caddyfile_path = caddy::caddyfile_path()?;
1468        if caddyfile_path.exists() {
1469            let existing =
1470                std::fs::read_to_string(&caddyfile_path).map_err(|source| Error::FileRead {
1471                    path: caddyfile_path.clone(),
1472                    source,
1473                })?;
1474            let updated = caddy::remove_route(&existing, service_name);
1475            if updated != existing {
1476                steps.push(Step::WriteFile(GeneratedFile {
1477                    path: caddyfile_path,
1478                    content: updated.clone(),
1479                }));
1480                // Skip reload if the Caddyfile is now empty — Caddy rejects
1481                // empty configs and will fail the reload.
1482                if !updated.trim().is_empty() {
1483                    steps.push(Step::ReloadCaddy);
1484                }
1485            }
1486        }
1487    }
1488
1489    if !WellKnownService::Authelia.matches(service_name)
1490        && matches!(
1491            installed.auth_kind,
1492            Some(registry::service_def::AuthKind::Oidc)
1493        )
1494    {
1495        steps.extend(authelia::unregister_oidc_client(service_name)?);
1496    }
1497
1498    // Metrics cleanup. Drop this service's scrape target from every
1499    // installed store (file_sd notices the removal, no reload). If the
1500    // service being removed IS a store, also drop the datasources it
1501    // provisioned on dashboards and restart them so the dead datasource
1502    // disappears from their UI.
1503    let installed_all = list_installed().unwrap_or_default();
1504    for store in installed_all
1505        .iter()
1506        .filter(|s| installed_provides(s, Capability::MetricsStore))
1507    {
1508        if store.name != service_name
1509            && let Ok(target) = metrics_bridge::target_file_path(&store.name, service_name)
1510            && target.exists()
1511        {
1512            steps.push(Step::RemoveFile(target));
1513        }
1514    }
1515    if installed.provides.contains(&Capability::MetricsStore) {
1516        for dash in installed_all
1517            .iter()
1518            .filter(|s| installed_provides(s, Capability::MetricsDashboard))
1519        {
1520            if dash.name == service_name {
1521                continue;
1522            }
1523            if let Ok(ds) = metrics_bridge::datasource_file_path(&dash.name, service_name)
1524                && ds.exists()
1525            {
1526                steps.push(Step::RemoveFile(ds));
1527                steps.push(Step::RestartService {
1528                    unit: dash.name.clone(),
1529                });
1530            }
1531        }
1532    }
1533
1534    // Reload systemd after removing quadlet files
1535    steps.push(Step::DaemonReload);
1536
1537    // Drop the service's podman networks now that all its containers are
1538    // stopped and the network units unloaded. `ryra remove` previously left
1539    // these behind — it deleted the `.network` file but never the network
1540    // itself — and the leak broke the next install: the regenerated network
1541    // unit's `podman network create` hit the still-present network and failed.
1542    // Best-effort — a network still used by another service is correctly
1543    // skipped (the rm fails and is ignored by the executor).
1544    for net in networks {
1545        steps.push(Step::RemoveNetwork { name: net });
1546    }
1547
1548    match mode {
1549        RemoveMode::Purge => {
1550            // Remove podman volumes after containers and units are gone
1551            for vol_name in volume_names {
1552                steps.push(Step::RemoveVolume { name: vol_name });
1553            }
1554            // Wipe entire service data directory
1555            steps.push(Step::RemoveDir(service_home(service_name)?));
1556        }
1557        RemoveMode::Preserve => {
1558            // Keep volumes intact — volume_names is guaranteed empty here
1559            // because accumulation is gated on Purge mode above.
1560            // Remove only ephemeral children of the home dir; keep data.
1561            let home = service_home(service_name)?;
1562            let (data, ephemeral) = crate::data::classify::classify_home_dir(&home)?;
1563            for path in ephemeral {
1564                match std::fs::metadata(&path) {
1565                    Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(path)),
1566                    Ok(_) => steps.push(Step::RemoveFile(path)),
1567                    // Path vanished between scan and step emission.
1568                    // `rm -f` is a no-op on a missing path; keeping the step ensures a
1569                    // retry of the same plan is idempotent.
1570                    Err(_) => steps.push(Step::RemoveFile(path)),
1571                }
1572            }
1573            // If the service has no bind-mounted data *and* no podman
1574            // named volumes, preserve-mode has literally nothing to
1575            // preserve — the home dir would just be an empty ghost.
1576            // Drop it in that case. When volumes exist (twenty,
1577            // postgres, …) we keep the home dir so owner inference in
1578            // enumerate_all can still attribute the volumes back to
1579            // this service; `ryra list` then reports a real orphan.
1580            if data.is_empty() && !has_named_volumes && home.exists() {
1581                steps.push(Step::RemoveDir(home));
1582            }
1583        }
1584    }
1585
1586    let url = installed.exposure.url().map(|s| s.to_string());
1587
1588    Ok(RemoveResult {
1589        steps,
1590        service_name: service_name.to_string(),
1591        url,
1592    })
1593}
1594
1595/// Tear down a `runtime = "native"` install: stop its `systemd --user` unit,
1596/// drop the unit symlink, reload, and remove either the whole home (purge) or
1597/// just the rebuildable/ephemeral bits (preserve keeps `data/` + the install
1598/// record). The dual of [`build_native_add`].
1599fn remove_native_service(
1600    service_name: &str,
1601    mode: RemoveMode,
1602    url: Option<String>,
1603) -> Result<RemoveResult> {
1604    let home = service_home(service_name)?;
1605    let unit_name = format!("{service_name}.service");
1606    let mut steps = vec![
1607        Step::StopService {
1608            unit: service_name.to_string(),
1609        },
1610        Step::RemoveFile(systemd_user_dir()?.join(&unit_name)),
1611        Step::DaemonReload,
1612    ];
1613
1614    match mode {
1615        RemoveMode::Purge => steps.push(Step::RemoveDir(home)),
1616        RemoveMode::Preserve => {
1617            // Keep data/ and metadata.toml; drop the rebuildable binary, the
1618            // generated .env, and the unit file (all re-created on re-add).
1619            for child in ["bin", ".env", unit_name.as_str()] {
1620                let p = home.join(child);
1621                match std::fs::metadata(&p) {
1622                    Ok(m) if m.is_dir() => steps.push(Step::RemoveDir(p)),
1623                    _ => steps.push(Step::RemoveFile(p)),
1624                }
1625            }
1626        }
1627    }
1628
1629    Ok(RemoveResult {
1630        steps,
1631        service_name: service_name.to_string(),
1632        url,
1633    })
1634}
1635
1636/// A lifecycle transition applied to an installed service's unit family.
1637#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1638#[serde(rename_all = "snake_case")]
1639pub enum Lifecycle {
1640    Start,
1641    Stop,
1642}
1643
1644/// Plan a start/stop of an installed service's full unit family (main
1645/// container + sidecars). Errors with [`Error::ServiceNotInstalled`] if
1646/// the service isn't installed.
1647///
1648/// systemd cascades *start* through `Requires=`, but never cascades
1649/// *stop* — so every `.container` unit is named explicitly and the steps
1650/// are ordered to respect dependencies: the main app unit stops first
1651/// (before its db/cache sidecars) and starts last (after them).
1652pub fn lifecycle_steps(service_name: &str, action: Lifecycle) -> Result<Vec<Step>> {
1653    // Same validation + error surface as `remove_service`.
1654    build_installed_from_metadata(service_name)
1655        .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
1656
1657    // Native services are a single systemd --user unit named after the service
1658    // (no sidecars / quadlets).
1659    if matches!(
1660        metadata::load_metadata(service_name),
1661        Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
1662    ) {
1663        let unit = service_name.to_string();
1664        return Ok(vec![match action {
1665            Lifecycle::Start => Step::StartService { unit },
1666            Lifecycle::Stop => Step::StopService { unit },
1667        }]);
1668    }
1669
1670    let mut units = service_container_units(service_name)?;
1671    match action {
1672        // Main unit first → stops before the sidecars it depends on.
1673        Lifecycle::Stop => units.sort_by_key(|u| u != service_name),
1674        // Main unit last → starts after the sidecars it depends on.
1675        Lifecycle::Start => units.sort_by_key(|u| u == service_name),
1676    }
1677
1678    Ok(units
1679        .into_iter()
1680        .map(|unit| match action {
1681            Lifecycle::Start => Step::StartService { unit },
1682            Lifecycle::Stop => Step::StopService { unit },
1683        })
1684        .collect())
1685}
1686
1687/// systemd unit base names of every `.container` quadlet belonging to a
1688/// service (main container, sidecars, and the `ts-<svc>` tailscale
1689/// sidecar). Mirrors the family scan in [`remove_service`].
1690fn service_container_units(service_name: &str) -> Result<Vec<String>> {
1691    let quadlet_path = quadlet_dir()?;
1692    let name_pool = scan_managed_services().unwrap_or_default();
1693    let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1694
1695    let mut units = Vec::new();
1696    if quadlet_path.is_dir()
1697        && let Ok(entries) = std::fs::read_dir(&quadlet_path)
1698    {
1699        for entry in entries.flatten() {
1700            let file_name = entry.file_name();
1701            let name = file_name.to_string_lossy();
1702            if !quadlet_belongs_to(&name, service_name, &all_names) {
1703                continue;
1704            }
1705            if name.ends_with(".container") {
1706                units.push(name.trim_end_matches(".container").to_string());
1707            }
1708        }
1709    }
1710    Ok(units)
1711}
1712
1713/// Parameters for [`record_pending`].
1714pub struct RecordPendingParams<'a> {
1715    pub service_name: &'a str,
1716    pub auth_kind: Option<registry::service_def::AuthKind>,
1717    pub registry_name: &'a str,
1718    pub allocated_ports: &'a [(String, u16)],
1719    pub repo_dir: &'a Path,
1720    /// How the service is exposed to clients. Replaces the previous
1721    /// `(url: Option<&str>, tailscale_enabled: bool)` pair so callers
1722    /// can't construct invalid combinations like a `*.ts.net` URL with
1723    /// `tailscale_enabled = false`. Decomposed into the legacy storage
1724    /// fields inside `record_pending` until the schema migrates to
1725    /// hold the typed enum directly.
1726    pub exposure: &'a Exposure,
1727}
1728
1729/// Record a service as pending installation (installed: false).
1730/// Called BEFORE executing steps so that partial failures are recoverable.
1731/// Persist install-time scaffolding to `preferences.toml`. This is now
1732/// the only side-effect — quadlet headers track the install itself,
1733/// preferences just remembers cross-cutting defaults so the next
1734/// `ryra add --auth` doesn't have to re-prompt for the OIDC issuer.
1735pub fn record_pending(params: RecordPendingParams<'_>) -> Result<()> {
1736    let paths = ConfigPaths::resolve()?;
1737    paths.ensure_dirs()?;
1738    let mut config = config::load_or_default(&paths.config_file)?;
1739
1740    // Auto-configure [auth] when an auth provider is installed so
1741    // future `ryra add <svc> --auth` calls know where to wire the
1742    // OIDC client. The `services` array is not touched — quadlet
1743    // headers are the source of truth for what's installed.
1744    if WellKnownService::Authelia.matches(params.service_name) {
1745        config.auth = Some(authelia::auth_config(
1746            params.allocated_ports,
1747            params.exposure.url(),
1748        )?);
1749        config::save_config(&paths.config_file, &config)?;
1750    }
1751
1752    Ok(())
1753}
1754
1755/// Drop the cached `[auth]` block when the auth provider is removed —
1756/// otherwise a later `ryra add <svc> --auth` thinks auth is still
1757/// configured and skips the auto-install path, then bombs out trying
1758/// to register an OIDC client against a non-existent authelia config.
1759/// The function name is preserved for caller compatibility; quadlet
1760/// removal is what actually finalises the install state.
1761pub fn finalize_remove(service_name: &str) -> Result<()> {
1762    let paths = ConfigPaths::resolve()?;
1763    let mut config = config::load_or_default(&paths.config_file)?;
1764
1765    if WellKnownService::Authelia.matches(service_name)
1766        && let Some(auth) = &config.auth
1767        && auth.provider_name() == "authelia"
1768    {
1769        config.auth = None;
1770        config::save_config(&paths.config_file, &config)?;
1771    }
1772
1773    Ok(())
1774}
1775
1776/// Steps to purge leftover data/volumes for an orphan service — one
1777/// with data on disk but no live install (e.g., after `ryra remove
1778/// <svc>` in default Preserve mode, or after a partial install where
1779/// the quadlets landed but `metadata.toml` never did). Unlike
1780/// `remove_service`, this doesn't require an install record to exist.
1781/// Templates whose rendered value is "sensitive" — either because the
1782/// value itself is a secret/credential, or because it rotates with each
1783/// install (so tracking it as static produces false drift positives).
1784/// Anything referencing one of these is excluded from the manifest.
1785///
1786/// Crucially this is *narrower* than "every {{auth.*}} reference":
1787/// `{{auth.url}}`, `{{auth.issuer}}`, `{{auth.provider}}`, `{{auth.internal_url}}`
1788/// are all stable per-install URLs/strings that the user benefits from
1789/// having tracked (so a global authelia URL change is caught by diff).
1790/// Only the credential pair `auth.client_id` + `auth.client_secret`
1791/// rotate per install. Same for SMTP: `smtp.host`/`smtp.port`/`smtp.from`/
1792/// `smtp.security` are tracked, only `smtp.username` and `smtp.password`
1793/// are excluded.
1794const SENSITIVE_TEMPLATE_REFS: &[&str] = &[
1795    "{{secret.",
1796    "{{auth.client_id",
1797    "{{auth.client_secret",
1798    "{{smtp.username",
1799    "{{smtp.password",
1800];
1801
1802fn is_static_template(value: &str) -> bool {
1803    !SENSITIVE_TEMPLATE_REFS.iter().any(|s| value.contains(s))
1804}
1805
1806/// Render every static env var the registry expects in `.env` for the
1807/// service. "Static" means the template carries no reference to any
1808/// rotating per-install value (see `SENSITIVE_TEMPLATE_REFS`).
1809///
1810/// Walks four sources, in the same order they're rendered into `.env` by
1811/// `generate::generate_env`:
1812///   1. `service_def.env` — top-level static entries.
1813///   2. Each enabled `[[env_group]]` — opt-in bundles.
1814///   3. `service_def.mappings.smtp` — only when SMTP is configured globally
1815///      and the service opts in (`integrations.smtp`).
1816///   4. `service_def.mappings.auth` — only when `--auth` was used.
1817///
1818/// Capturing 3 and 4 is what makes global-config drift visible: when the
1819/// user reconfigures global SMTP / re-installs authelia, the per-service
1820/// mapping values change, and tracking them lets `ryra diff` notice.
1821fn collect_static_envs(
1822    service_def: &registry::service_def::ServiceDef,
1823    ctx: &BTreeMap<String, String>,
1824    enabled_groups: &std::collections::BTreeSet<String>,
1825) -> Result<Vec<plan::TrackedEnv>> {
1826    use registry::service_def::EnvKind;
1827    let mut out: Vec<plan::TrackedEnv> = Vec::new();
1828    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1829    let push = |name: &str,
1830                value_template: &str,
1831                kind: EnvKind,
1832                prompt: Option<String>,
1833                out: &mut Vec<plan::TrackedEnv>,
1834                seen: &mut std::collections::HashSet<String>|
1835     -> Result<()> {
1836        if !is_static_template(value_template) {
1837            return Ok(());
1838        }
1839        if !seen.insert(name.to_string()) {
1840            return Ok(());
1841        }
1842        let value = generate::template::render(value_template, ctx)?;
1843        out.push(plan::TrackedEnv {
1844            key: name.to_string(),
1845            value,
1846            kind,
1847            prompt,
1848        });
1849        Ok(())
1850    };
1851    for env in &service_def.env {
1852        push(
1853            &env.name,
1854            &env.value,
1855            env.kind.clone(),
1856            env.prompt.clone(),
1857            &mut out,
1858            &mut seen,
1859        )?;
1860    }
1861    for group in &service_def.env_groups {
1862        if !enabled_groups.contains(&group.name) {
1863            continue;
1864        }
1865        for env in &group.env {
1866            push(
1867                &env.name,
1868                &env.value,
1869                env.kind.clone(),
1870                env.prompt.clone(),
1871                &mut out,
1872                &mut seen,
1873            )?;
1874        }
1875    }
1876    // Mirror the gating from `generate::render_env_vars`: SMTP mappings
1877    // only fire when smtp is configured globally; auth mappings only when
1878    // --auth was used. ctx-key presence is a faithful proxy for both.
1879    // Mapping-emitted env vars are always treated as Default (silent
1880    // append on upgrade) — there's no user-facing prompt label for them.
1881    if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
1882        for (env_name, value_template) in &service_def.mappings.smtp {
1883            push(
1884                env_name,
1885                value_template,
1886                EnvKind::Default,
1887                None,
1888                &mut out,
1889                &mut seen,
1890            )?;
1891        }
1892    }
1893    if ctx.contains_key("auth.client_id") {
1894        for (env_name, value_template) in &service_def.mappings.auth {
1895            push(
1896                env_name,
1897                value_template,
1898                EnvKind::Default,
1899                None,
1900                &mut out,
1901                &mut seen,
1902            )?;
1903        }
1904    }
1905    Ok(out)
1906}
1907
1908pub fn orphan_purge_steps(svc: &data::ServiceData) -> Vec<Step> {
1909    let mut steps = Vec::new();
1910
1911    // Quadlet files in `~/.config/containers/systemd/` belonging to
1912    // this service. Mirrors `remove_service`'s sweep — filename match
1913    // via `quadlet_belongs_to` catches both regular files and
1914    // symlinks, so a re-`ryra add` after purge starts clean instead
1915    // of seeing a leftover `.volume` and re-prompting about orphan data.
1916    let mut had_quadlet = false;
1917    let mut networks: Vec<String> = Vec::new();
1918    if let Ok(qdir) = quadlet_dir()
1919        && qdir.is_dir()
1920        && let Ok(entries) = std::fs::read_dir(&qdir)
1921    {
1922        let name_pool = scan_managed_services().unwrap_or_default();
1923        let all_names: Vec<&str> = name_pool.iter().map(|s| s.as_str()).collect();
1924        for entry in entries.flatten() {
1925            let file_name = entry.file_name();
1926            let name = file_name.to_string_lossy();
1927            if !quadlet_belongs_to(&name, &svc.service, &all_names) {
1928                continue;
1929            }
1930            // Stop generated units before removing files so the
1931            // upcoming daemon-reload unloads them cleanly instead of
1932            // leaving "loaded: not-found, active (exited)" entries.
1933            if name.ends_with(".container") {
1934                let unit = name.trim_end_matches(".container").to_string();
1935                steps.push(Step::StopService { unit });
1936            } else if name.ends_with(".network") {
1937                let net = name.trim_end_matches(".network").to_string();
1938                steps.push(Step::StopService {
1939                    unit: format!("{net}-network"),
1940                });
1941                networks.push(net);
1942            } else if name.ends_with(".volume") {
1943                let unit = format!("{}-volume", name.trim_end_matches(".volume"));
1944                steps.push(Step::StopService { unit });
1945            }
1946            steps.push(Step::RemoveFile(entry.path()));
1947            had_quadlet = true;
1948        }
1949    }
1950    if had_quadlet {
1951        steps.push(Step::DaemonReload);
1952    }
1953    // Drop the podman networks once the containers are down (see remove_service).
1954    for net in networks {
1955        steps.push(Step::RemoveNetwork { name: net });
1956    }
1957
1958    for path in &svc.data_paths {
1959        if path.is_dir() {
1960            steps.push(Step::RemoveDir(path.clone()));
1961        } else {
1962            steps.push(Step::RemoveFile(path.clone()));
1963        }
1964    }
1965    if svc.home_dir.exists() {
1966        steps.push(Step::RemoveDir(svc.home_dir.clone()));
1967    }
1968    for v in &svc.volumes {
1969        steps.push(Step::RemoveVolume {
1970            name: v.name.clone(),
1971        });
1972    }
1973    steps
1974}
1975
1976/// Reset ryra: tear down all services, infrastructure, and config.
1977pub fn reset() -> Result<ResetResult> {
1978    let mut steps = Vec::new();
1979
1980    // Quadlet directory scan is the source of truth for ryra-managed
1981    // services — every install stamps a marker comment on the main
1982    // `.container`, so this catches every install regardless of the
1983    // state of `preferences.toml`.
1984    let managed_names = scan_managed_services().unwrap_or_default();
1985
1986    // 0. Disable every Tailscale Service before tearing services down.
1987    // `TailscaleDisable` stops `tailscale serve --service=svc:<X>` and
1988    // deletes the admin-side service definition via the API, so the
1989    // tailnet is clean after reset and the next install gets bare
1990    // hostnames. Read exposure from the quadlet headers so this still
1991    // works after the services array goes away.
1992    for svc in list_installed().unwrap_or_default() {
1993        if let Some(svc_name) = svc.exposure.tailscale_svc_name() {
1994            steps.push(Step::TailscaleDisable { svc_name });
1995        }
1996    }
1997
1998    // 1. Stop and remove only ryra-managed quadlet files (scoped by installed service names)
1999    let quadlet_path = quadlet_dir()?;
2000    let all_names: Vec<&str> = managed_names.iter().map(|s| s.as_str()).collect();
2001    let mut networks: Vec<String> = Vec::new();
2002    if quadlet_path.is_dir()
2003        && let Ok(entries) = std::fs::read_dir(&quadlet_path)
2004    {
2005        for entry in entries.flatten() {
2006            let file_name = entry.file_name();
2007            let name = file_name.to_string_lossy();
2008            // Only touch files belonging to a ryra-managed service —
2009            // including the `ts-<service>` tailscale sidecars when the
2010            // service was installed with --tailscale.
2011            let is_ryra_file = managed_names
2012                .iter()
2013                .any(|svc| quadlet_belongs_to(&name, svc, &all_names));
2014            if !is_ryra_file {
2015                continue;
2016            }
2017            if name.ends_with(".container") {
2018                let unit = name.trim_end_matches(".container").to_string();
2019                steps.push(Step::StopService { unit });
2020            }
2021            if name.ends_with(".network") {
2022                let net = name.trim_end_matches(".network").to_string();
2023                steps.push(Step::StopService {
2024                    unit: format!("{net}-network"),
2025                });
2026                networks.push(net);
2027            }
2028            if name.ends_with(".volume") {
2029                let vol = name.trim_end_matches(".volume").to_string();
2030                // Quadlet auto-generates `<vol>-volume.service` for each
2031                // `.volume` file. Stopping it before we remove the file
2032                // makes systemd unload the unit on the upcoming
2033                // daemon-reload — without this, leftover oneshot units
2034                // sit in "loaded: not-found, active (exited)" forever
2035                // until logout.
2036                steps.push(Step::StopService {
2037                    unit: format!("{vol}-volume"),
2038                });
2039            }
2040            steps.push(Step::RemoveFile(entry.path()));
2041        }
2042    }
2043
2044    // 1b. Native services keep their unit in the systemd --user dir, not the
2045    // quadlet dir, so the scan above misses them. Sweep ryra's native installs:
2046    // each home dir whose install record says `runtime = native` gets its unit
2047    // stopped and its `systemd/user` symlink removed (before the reload below
2048    // unloads it, and before step 4 wipes the data root).
2049    let user_unit_dir = systemd_user_dir()?;
2050    if let Ok(root) = service_data_root()
2051        && let Ok(entries) = std::fs::read_dir(&root)
2052    {
2053        for entry in entries.flatten() {
2054            let Some(name) = entry.file_name().to_str().map(str::to_string) else {
2055                continue;
2056            };
2057            if matches!(
2058                metadata::load_metadata(&name),
2059                Ok(Some(m)) if m.runtime == registry::service_def::Runtime::Native
2060            ) {
2061                steps.push(Step::StopService { unit: name.clone() });
2062                steps.push(Step::RemoveFile(
2063                    user_unit_dir.join(format!("{name}.service")),
2064                ));
2065            }
2066        }
2067    }
2068
2069    // 2. Reload user systemd after removing quadlets
2070    steps.push(Step::DaemonReload);
2071
2072    // 2b. Drop the podman networks now that every container is stopped and the
2073    // network units unloaded (see remove_service for why the leak matters).
2074    for net in networks {
2075        steps.push(Step::RemoveNetwork { name: net });
2076    }
2077
2078    // 3. Remove podman volumes for every ryra-visible service — installed
2079    // and orphaned. `enumerate_all` walks both the quadlet markers and the
2080    // data root, so volumes left behind by a `ryra remove --preserve`
2081    // (which drops the quadlet but keeps the named volume) get swept up
2082    // here too.
2083    let mut seen_volumes = std::collections::BTreeSet::new();
2084    for svc in data::enumerate_all().unwrap_or_default() {
2085        for vol in svc.volumes {
2086            if seen_volumes.insert(vol.name.clone()) {
2087                steps.push(Step::RemoveVolume { name: vol.name });
2088            }
2089        }
2090    }
2091
2092    // 4. Nuke the entire service data root in one shot. The user-facing
2093    // reset prompt promises "Delete ~/.local/share/services/", so the
2094    // implementation must match — sweeping managed dirs, orphan dirs
2095    // (left by `--preserve` removes), the top-level caddy-root-ca.crt,
2096    // and any other ryra-written tooling state living under that root.
2097    let data_root = service_data_root()?;
2098    if data_root.exists() {
2099        steps.push(Step::RemoveDir(data_root));
2100    }
2101
2102    Ok(ResetResult { steps })
2103}
2104
2105/// Called after reset steps succeed — removes ryra's config directory.
2106pub fn finalize_reset() -> Result<()> {
2107    let paths = ConfigPaths::resolve()?;
2108    if paths.config_dir.exists() {
2109        std::fs::remove_dir_all(&paths.config_dir).map_err(|source| Error::FileWrite {
2110            path: paths.config_dir,
2111            source,
2112        })?;
2113    }
2114    Ok(())
2115}
2116
2117/// Get the current status of the ryra installation. Considers ryra
2118/// "initialized" when EITHER a marker'd quadlet is on disk OR a
2119/// `preferences.toml` exists — quadlets are the source of truth for
2120/// installed services, but a preferences-only state (e.g. an SMTP relay
2121/// configured before any service install) still counts.
2122pub fn status() -> config::status::RyraStatus {
2123    let paths = match ConfigPaths::resolve() {
2124        Ok(p) => p,
2125        Err(_) => return config::status::RyraStatus::NotInitialized,
2126    };
2127
2128    let has_quadlets = scan_managed_services()
2129        .map(|n| !n.is_empty())
2130        .unwrap_or(false);
2131
2132    let config = match config::load_config(&paths.config_file) {
2133        Ok(c) => c,
2134        Err(Error::ConfigNotFound(_)) if has_quadlets => config::schema::Config::default(),
2135        Err(Error::ConfigNotFound(_)) => return config::status::RyraStatus::NotInitialized,
2136        Err(e) => return config::status::RyraStatus::Error(e.to_string()),
2137    };
2138
2139    config::status::RyraStatus::Initialized(config::status::StatusInfo::from_config(
2140        paths.config_file,
2141        &config,
2142    ))
2143}
2144
2145/// True if the named service is ryra-managed and *fully* installed —
2146/// the marker'd `.container` is present AND `metadata.toml` exists.
2147/// A partial install (quadlets written but the install plan errored
2148/// before metadata.toml landed) is treated as not-installed so that
2149/// `ryra remove <svc> --purge` routes through the orphan-cleanup path
2150/// instead of failing with "service is not installed". Same source of
2151/// truth as [`list_installed`].
2152pub fn is_service_installed(name: &str) -> bool {
2153    // The install record says whether (and how) a service is installed. Native
2154    // services have no quadlet — their presence is the systemd --user unit;
2155    // podman services are the marker'd quadlet. No metadata → not installed.
2156    let Ok(Some(meta)) = metadata::load_metadata(name) else {
2157        return false;
2158    };
2159    match meta.runtime {
2160        registry::service_def::Runtime::Native => systemd_user_dir()
2161            .map(|d| d.join(format!("{name}.service")).exists())
2162            .unwrap_or(false),
2163        registry::service_def::Runtime::Podman => scan_managed_services()
2164            .map(|names| names.iter().any(|n| n == name))
2165            .unwrap_or(false),
2166    }
2167}
2168
2169/// Scan the user's quadlet directory for ryra-managed services. A
2170/// `.container` file is considered ryra-managed iff it carries a
2171/// `# Service-Source: registry/<name>` comment within its first 16
2172/// lines (added at install time). Returns the deduplicated set of
2173/// service names found.
2174///
2175/// This makes the on-disk quadlet directory the source of truth for
2176/// "which services are installed" — `preferences.toml` was historically
2177/// authoritative, but could drift (e.g. if config was wiped while
2178/// services kept running). Callers that want richer metadata (URL,
2179/// exposure) still need `preferences.toml`; for "is X installed" the
2180/// quadlet scan is reliable.
2181pub fn scan_managed_services() -> Result<Vec<String>> {
2182    let dir = match quadlet_dir() {
2183        Ok(d) => d,
2184        Err(_) => return Ok(Vec::new()),
2185    };
2186    let entries = match std::fs::read_dir(&dir) {
2187        Ok(e) => e,
2188        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
2189        Err(source) => return Err(Error::FileRead { path: dir, source }),
2190    };
2191    let mut names: Vec<String> = Vec::new();
2192    for entry in entries.flatten() {
2193        let path = entry.path();
2194        if path.extension().and_then(|e| e.to_str()) != Some("container") {
2195            continue;
2196        }
2197        let Ok(content) = std::fs::read_to_string(&path) else {
2198            continue;
2199        };
2200        for line in content.lines().take(16) {
2201            if let Some(rest) = line.trim().strip_prefix("# Service-Source: registry/")
2202                && !rest.is_empty()
2203                && !names.iter().any(|n| n == rest)
2204            {
2205                names.push(rest.to_string());
2206                break;
2207            }
2208        }
2209    }
2210    names.sort();
2211    Ok(names)
2212}
2213
2214/// Build a full [`InstalledService`] from `metadata.toml` + `.env`.
2215/// Returns `None` if metadata.toml is missing — that's the signal that
2216/// either the service was never installed or it was installed by a
2217/// pre-metadata.toml ryra (in which case the caller should treat it as
2218/// not-installed and let the user reinstall).
2219fn build_installed_from_metadata(service_name: &str) -> Option<InstalledService> {
2220    let meta = load_metadata(service_name).ok().flatten()?;
2221
2222    // Loopback when no URL; otherwise classify by hostname suffix.
2223    let exposure = match meta.url.as_deref() {
2224        None => Exposure::Loopback,
2225        Some(u) => Exposure::from_url(u),
2226    };
2227
2228    let auth_kind = meta.auth.clone();
2229
2230    // Ports come from the `.env` file ryra writes alongside the quadlet
2231    // — `SERVICE_PORT_<NAME>=<value>` lines map back to the BTreeMap
2232    // keyed by lowercase name. Missing `.env` is treated as empty (still
2233    // a valid install — services without published ports legitimately
2234    // omit it).
2235    let ports = service_home(service_name)
2236        .ok()
2237        .and_then(|home| std::fs::read_to_string(home.join(".env")).ok())
2238        .map(|env| {
2239            env.lines()
2240                .filter_map(|l| {
2241                    let l = l.trim();
2242                    if l.is_empty() || l.starts_with('#') {
2243                        return None;
2244                    }
2245                    let (key, val) = l.split_once('=')?;
2246                    let name = key.strip_prefix("SERVICE_PORT_")?.to_lowercase();
2247                    let port = val
2248                        .trim_matches(|c: char| c == '"' || c == '\'')
2249                        .parse::<u16>()
2250                        .ok()?;
2251                    Some((name, port))
2252                })
2253                .collect::<std::collections::BTreeMap<String, u16>>()
2254        })
2255        .unwrap_or_default();
2256
2257    Some(InstalledService {
2258        name: service_name.to_string(),
2259        version: "0.1.0".to_string(),
2260        repo: meta.registry,
2261        ports,
2262        auth_kind,
2263        exposure,
2264        provides: meta.provides,
2265        installed: true,
2266    })
2267}
2268
2269/// List installed services. **Quadlet directory is the source of
2270/// truth** — every service whose main `.container` file carries our
2271/// marker is reconstructed from its on-disk headers + `.env`. The
2272/// preferences file is only consulted as a fallback for entries the
2273/// scan can't see (e.g. partially-rolled-out installs from older
2274/// ryra versions before metadata headers landed).
2275pub fn list_installed() -> Result<Vec<InstalledService>> {
2276    let mut names: std::collections::BTreeSet<String> = scan_managed_services()
2277        .unwrap_or_default()
2278        .into_iter()
2279        .collect();
2280    // Native services carry no quadlet marker. Pick them up from the data root:
2281    // any home dir that `is_service_installed` confirms (runtime-aware) and that
2282    // the quadlet scan didn't already catch.
2283    if let Ok(root) = service_data_root()
2284        && let Ok(entries) = std::fs::read_dir(&root)
2285    {
2286        for entry in entries.flatten() {
2287            if let Some(name) = entry.file_name().to_str()
2288                && !names.contains(name)
2289                && is_service_installed(name)
2290            {
2291                names.insert(name.to_string());
2292            }
2293        }
2294    }
2295    let out: Vec<InstalledService> = names
2296        .iter()
2297        .filter_map(|n| build_installed_from_metadata(n))
2298        .collect();
2299    Ok(out)
2300}
2301
2302/// Search available services in a repo, optionally filtered by query.
2303pub fn search_services(repo_dir: &Path, query: Option<&str>) -> Result<Vec<SearchResult>> {
2304    let available = registry::list_available(repo_dir)?;
2305
2306    let results = available
2307        .into_iter()
2308        .filter(|reg_svc| match query {
2309            None => true,
2310            Some(q) => {
2311                let q = q.to_lowercase();
2312                reg_svc.def.service.name.to_lowercase().contains(&q)
2313                    || reg_svc.def.service.description.to_lowercase().contains(&q)
2314            }
2315        })
2316        .map(|reg_svc| {
2317            let name = &reg_svc.def.service.name;
2318            let installed = is_service_installed(name);
2319            let mut supports = Vec::new();
2320            for kind in &reg_svc.def.integrations.auth {
2321                supports.push(kind.to_string());
2322            }
2323            if reg_svc.def.integrations.smtp {
2324                supports.push("smtp".to_string());
2325            }
2326            SearchResult {
2327                name: name.clone(),
2328                description: reg_svc.def.service.description,
2329                installed,
2330                supports,
2331            }
2332        })
2333        .collect();
2334
2335    Ok(results)
2336}
2337
2338pub struct SearchResult {
2339    pub name: String,
2340    pub description: String,
2341    pub installed: bool,
2342    /// Integrations this service supports (e.g., "oidc", "smtp").
2343    pub supports: Vec<String>,
2344}
2345
2346/// Get test definitions for an installed service by reading its `test.toml`.
2347pub async fn service_tests(service_name: &str) -> Result<ServiceTestInfo> {
2348    let installed = build_installed_from_metadata(service_name)
2349        .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
2350
2351    let service_ref = service_ref_from_installed(&installed);
2352    let repo_dir = resolve_registry_dir(&service_ref).await?;
2353
2354    let test_toml_path = repo_dir.join(service_name).join("test.toml");
2355    let env_file = service_home(service_name)?.join(".env");
2356
2357    if !test_toml_path.exists() {
2358        return Ok(ServiceTestInfo {
2359            service_name: service_name.to_string(),
2360            registry_name: service_ref.registry_name().to_string(),
2361            tests: vec![],
2362            env_file,
2363        });
2364    }
2365
2366    let content = std::fs::read_to_string(&test_toml_path).map_err(|source| Error::FileRead {
2367        path: test_toml_path.clone(),
2368        source,
2369    })?;
2370
2371    #[derive(serde::Deserialize)]
2372    struct TestFile {
2373        #[serde(default)]
2374        tests: Vec<registry::test_def::TestDef>,
2375    }
2376
2377    let parsed: TestFile = toml::from_str(&content).map_err(|source| Error::TomlParse {
2378        path: test_toml_path,
2379        source,
2380    })?;
2381
2382    Ok(ServiceTestInfo {
2383        service_name: service_name.to_string(),
2384        registry_name: service_ref.registry_name().to_string(),
2385        tests: parsed.tests,
2386        env_file,
2387    })
2388}
2389
2390pub struct ServiceTestInfo {
2391    pub service_name: String,
2392    pub registry_name: String,
2393    pub tests: Vec<registry::test_def::TestDef>,
2394    pub env_file: PathBuf,
2395}
2396
2397/// Get detailed info about a service from a repo.
2398pub fn service_info(repo_dir: &Path, service_name: &str) -> Result<ServiceDetail> {
2399    let reg_service = registry::find_service(repo_dir, service_name)?;
2400    let def = &reg_service.def;
2401
2402    Ok(ServiceDetail {
2403        name: def.service.name.clone(),
2404        description: def.service.description.clone(),
2405        url: def.service.url.clone(),
2406        ports: def
2407            .ports
2408            .iter()
2409            .map(|p| (p.container_port, p.protocol.clone(), p.name.clone()))
2410            .collect(),
2411        env_vars: def
2412            .env
2413            .iter()
2414            .map(|e| (e.name.clone(), e.prompt.clone()))
2415            .collect(),
2416    })
2417}
2418
2419pub struct ServiceDetail {
2420    pub name: String,
2421    pub description: String,
2422    pub url: Option<String>,
2423    pub ports: Vec<(u16, registry::service_def::PortProtocol, String)>,
2424    pub env_vars: Vec<(String, Option<String>)>,
2425}
2426
2427#[cfg(test)]
2428mod tests {
2429    use super::*;
2430
2431    #[test]
2432    fn static_template_filter_excludes_secrets_and_credentials() {
2433        // Plain literal — tracked.
2434        assert!(is_static_template("3306"));
2435        assert!(is_static_template("mariadb"));
2436        // Stable template references — tracked.
2437        assert!(is_static_template("{{service.port}}"));
2438        assert!(is_static_template("{{service.url}}"));
2439        assert!(is_static_template("{{auth.url}}"));
2440        assert!(is_static_template("{{auth.issuer}}"));
2441        assert!(is_static_template("{{auth.provider}}"));
2442        assert!(is_static_template("{{auth.internal_url}}"));
2443        assert!(is_static_template("{{smtp.host}}"));
2444        assert!(is_static_template("{{smtp.port}}"));
2445        assert!(is_static_template("{{smtp.from}}"));
2446        // Composite template: stable + stable — tracked.
2447        assert!(is_static_template("{{service.url}}/oauth/callback"));
2448
2449        // Secrets — never tracked.
2450        assert!(!is_static_template("{{secret.admin_password}}"));
2451        assert!(!is_static_template("{{secret.jwt_key}}"));
2452        // Per-install OIDC credentials — never tracked (rotates on auth provider reinstall).
2453        assert!(!is_static_template("{{auth.client_id}}"));
2454        assert!(!is_static_template("{{auth.client_secret}}"));
2455        // SMTP credentials — never tracked.
2456        assert!(!is_static_template("{{smtp.username}}"));
2457        assert!(!is_static_template("{{smtp.password}}"));
2458        // Composite templates carrying a sensitive ref must also be excluded.
2459        assert!(!is_static_template(
2460            "redis://:{{secret.redis_pw}}@host:6379"
2461        ));
2462    }
2463
2464    #[test]
2465    fn tailscale_url_matches() {
2466        assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net"));
2467        assert!(is_tailscale_url("http://debian.cobbler-tuna.ts.net:10001/"));
2468        assert!(is_tailscale_url("https://foo.example-net.ts.net"));
2469        assert!(is_tailscale_url("http://HOST.COBBLER-TUNA.TS.NET"));
2470    }
2471
2472    #[test]
2473    fn tailscale_url_rejects() {
2474        assert!(!is_tailscale_url("https://nextcloud.internal:8443"));
2475        assert!(!is_tailscale_url("https://example.com"));
2476        assert!(!is_tailscale_url("http://127.0.0.1:10001"));
2477        // lookalike — must be exact `.ts.net` suffix
2478        assert!(!is_tailscale_url("https://ts.net"));
2479        assert!(!is_tailscale_url("https://evil-ts.net.example.com"));
2480        assert!(!is_tailscale_url("not a url"));
2481    }
2482
2483    #[test]
2484    fn public_url_accepts_public_domains() {
2485        assert!(is_public_url("https://seafile.ryra.no"));
2486        assert!(is_public_url("https://example.com"));
2487        assert!(is_public_url("https://docs.ryra.no:8443"));
2488    }
2489
2490    #[test]
2491    fn public_url_rejects_lan_and_tailnet() {
2492        assert!(!is_public_url("https://nextcloud.internal:8443"));
2493        assert!(!is_public_url("https://service.localhost"));
2494        assert!(!is_public_url("https://something.local"));
2495        assert!(!is_public_url("https://localhost:8080"));
2496        assert!(!is_public_url("https://debian.cobbler-tuna.ts.net"));
2497        assert!(!is_public_url("http://127.0.0.1:10001"));
2498        assert!(!is_public_url("http://192.168.1.10"));
2499        assert!(!is_public_url("http://[::1]"));
2500        assert!(!is_public_url("not a url"));
2501    }
2502
2503    // resolve_extra_networks positional args:
2504    // (name, enable_auth, authelia_installed, caddy_installed,
2505    //  inbucket_installed, has_url, has_smtp)
2506
2507    #[test]
2508    fn networks_empty_when_no_auth() {
2509        let nets = resolve_extra_networks(
2510            "whoami", false, false, false, false, false, false, None, false,
2511        );
2512        assert!(nets.is_empty());
2513    }
2514
2515    #[test]
2516    fn networks_empty_when_auth_but_no_authelia() {
2517        let nets = resolve_extra_networks(
2518            "forgejo", true, false, false, false, false, false, None, false,
2519        );
2520        assert!(nets.is_empty());
2521    }
2522
2523    #[test]
2524    fn networks_authelia_when_auth_enabled() {
2525        let nets = resolve_extra_networks(
2526            "forgejo", true, true, false, false, false, false, None, false,
2527        );
2528        assert_eq!(nets, vec!["authelia"]);
2529    }
2530
2531    #[test]
2532    fn networks_auth_with_caddy_includes_both() {
2533        let nets = resolve_extra_networks(
2534            "forgejo", true, true, true, false, false, false, None, false,
2535        );
2536        assert!(nets.contains(&"authelia".to_string()));
2537        assert!(nets.contains(&"caddy".to_string()));
2538    }
2539
2540    #[test]
2541    fn networks_authelia_excluded_for_authelia_itself() {
2542        let nets = resolve_extra_networks(
2543            "authelia", true, true, false, false, false, false, None, false,
2544        );
2545        assert!(nets.is_empty());
2546    }
2547
2548    #[test]
2549    fn networks_smtp_joins_inbucket_without_caddy() {
2550        // Reaching inbucket for SMTP must NOT require caddy.
2551        let nets = resolve_extra_networks(
2552            "forgejo", false, false, false, true, false, true, None, false,
2553        );
2554        assert_eq!(nets, vec!["inbucket"]);
2555    }
2556
2557    #[test]
2558    fn networks_smtp_skips_inbucket_when_it_is_self() {
2559        let nets = resolve_extra_networks(
2560            "inbucket", false, false, false, true, false, true, None, false,
2561        );
2562        assert!(!nets.contains(&"inbucket".to_string()));
2563    }
2564
2565    #[test]
2566    fn networks_smtp_skips_inbucket_when_not_installed() {
2567        let nets = resolve_extra_networks(
2568            "forgejo", false, false, false, false, false, true, None, false,
2569        );
2570        assert!(!nets.contains(&"inbucket".to_string()));
2571    }
2572
2573    #[test]
2574    fn networks_metrics_consumer_joins_store() {
2575        let nets = resolve_extra_networks(
2576            "grafana",
2577            false,
2578            false,
2579            false,
2580            false,
2581            false,
2582            false,
2583            Some("prometheus"),
2584            true,
2585        );
2586        assert_eq!(nets, vec!["prometheus".to_string()]);
2587    }
2588
2589    #[test]
2590    fn networks_metrics_store_skips_itself() {
2591        let nets = resolve_extra_networks(
2592            "prometheus",
2593            false,
2594            false,
2595            false,
2596            false,
2597            false,
2598            false,
2599            Some("prometheus"),
2600            true,
2601        );
2602        assert!(nets.is_empty());
2603    }
2604
2605    #[test]
2606    fn networks_metrics_indifferent_service_skips_store() {
2607        let nets = resolve_extra_networks(
2608            "vaultwarden",
2609            false,
2610            false,
2611            false,
2612            false,
2613            false,
2614            false,
2615            Some("prometheus"),
2616            false,
2617        );
2618        assert!(nets.is_empty());
2619    }
2620
2621    #[test]
2622    fn quadlet_belongs_to_exact_match() {
2623        let all = &["foo", "foo-bar"];
2624        assert!(quadlet_belongs_to("foo.container", "foo", all));
2625        assert!(quadlet_belongs_to("foo.network", "foo", all));
2626    }
2627
2628    #[test]
2629    fn quadlet_belongs_to_sidecar() {
2630        // foo-db is a sidecar, not a separate service
2631        let all = &["foo"];
2632        assert!(quadlet_belongs_to("foo-db.volume", "foo", all));
2633    }
2634
2635    #[test]
2636    fn quadlet_belongs_to_rejects_prefix_collision() {
2637        let all = &["foo", "foo-bar"];
2638        assert!(!quadlet_belongs_to("foo-bar.container", "foo", all));
2639        assert!(!quadlet_belongs_to("foo-bar-db.volume", "foo", all));
2640    }
2641
2642    #[test]
2643    fn quadlet_belongs_to_hyphenated_service() {
2644        let all = &["foo", "foo-bar"];
2645        assert!(quadlet_belongs_to("foo-bar.container", "foo-bar", all));
2646        assert!(quadlet_belongs_to("foo-bar-db.volume", "foo-bar", all));
2647        assert!(!quadlet_belongs_to("foo.container", "foo-bar", all));
2648    }
2649}